diff --git a/.prettierignore b/.prettierignore index e0aa18a1..036ee8bc 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,4 +2,5 @@ dist node_modules package.json package-lock.json -LICENSE \ No newline at end of file +LICENSE +harness diff --git a/build.config.ts b/build.config.ts index ec8a41d0..ac51db97 100644 --- a/build.config.ts +++ b/build.config.ts @@ -1,3 +1,3 @@ export default { - base: '/', + base: process.env.CINNY_BASE ?? '/', }; diff --git a/config.json b/config.json index a61bb966..d1c461a4 100644 --- a/config.json +++ b/config.json @@ -5,17 +5,7 @@ "featuredCommunities": { "openAsDefault": false, - "spaces": [ - "#cinny:matrix.org", - "#community:matrix.org", - "#space:unredacted.org", - "#librewolf-community:matrix.org", - "#stickers-and-emojis:tastytea.de", - "#videogames:waywardinn.com", - "#science-space:matrix.org", - "#libregaming-games:tchncs.de", - "#mathematics-on:matrix.org" - ], + "spaces": ["#gamecult:gamecult.org"], "rooms": [ "#tuwunel:grin.hu", "#freesoftware:matrix.org", @@ -27,5 +17,9 @@ "hashRouter": { "enabled": false, "basename": "/" + }, + + "backgroundScene": { + "tier": "off" } } diff --git a/docs/gamecult/ratatoskr-target.md b/docs/gamecult/ratatoskr-target.md new file mode 100644 index 00000000..166c5c72 --- /dev/null +++ b/docs/gamecult/ratatoskr-target.md @@ -0,0 +1,352 @@ +# Ratatoskr: target and prose map + +Ratatoskr is GameCult's Matrix chat client: this fork of Cinny, forge +`GameCult/cinny` (to be renamed `GameCult/Ratatoskr`; see the cut sequence). +Void stewards it. Campaign `ratatoskr` lives in the eureka mind for now, beside +the rulings it rests on. Under `eureka-body:ruling:ruling-home-steward-mind` it +is handed to Void's mind once, by procedure, as soon as that hand-off procedure +exists (`hand_off` is parked); after that every new ruling on it is admitted in +Void's mind. Nothing here is written into Void's mind before then. + +The mind holds the questions, rulings, specs, reports and verdicts. This file +holds what no document kind carries: the design truths, body facts, the model +page, rationale, the reconstructed collaboration plan, and the cut sequence. +All times are UTC. + +## Why + +GameCult is the only server this client talks to. A general Matrix client +treats it as one community among thousands, so the operator meets GameCult's +structure three clicks deep in an invite list of 50+ rooms (operator, `#swarm` +10-31 05:14: "The biggest pain is navigation. GameCult has a structure."). The +client should open on GameCult's estate as data: its spaces, its project rooms +grouped by steward, its Minds, and the person's own place in it. It should +also be a pleasant place to be in its own right: emotes, sound, and weather in +the background. + +## Design truths (from rulings in force) + +- **We own the interface, not the engine.** The fork keeps Cinny's engine + (matrix-js-sdk, Rust crypto, sync) and replaces the whole UI; every + user-facing string and its translation is ours, and upstream locale files + are not the source (`gamecult-chat:ruling:we-own-the-ui-and-translation`). +- **Mobile first.** On phones, inactive elements hide, vertical space is + cheaper than horizontal, and text never gets squeezed into narrow columns + (`eureka-body:ruling:chat-client-base-cinny-mobile-first`). +- **The first spec** (`eureka-body:ruling:chat-client-first-spec`): an emote + slot on every bubble (a pose id carried on the event); room and participant + lists tiered (category, then items) and reached from the header menu, not + side columns; participants grouped by estate (Minds, Members, Users, + Patrons); rooms grouped by kind (general, estates, projects, work rooms); + Minds indexed by stewardship, read from Matrix state projected from the + stewardship records in the Minds' own minds (the minds stay the truth); one + room per project, a Space child, with the steward joined. +- **Rooms are visible without a join dance.** No invite screen in the way; a + room the server lets you join shows in the same list as joined ones; opening + one shows its name and a one-tap Join; joining happens on that tap or a first + post, never silently; no peeking (operator, `#swarm` 10-31 04:08, 04:12 + "Peeking sounds like YAGNI to me", 04:14 "One tap is a reasonable cost"). + Rooms you may not enter are refused by the server, not hidden by the client + (Heimdall 04:10, accepted by Void 04:11 and 04:15). +- **Language.** A user's language is the BCP 47 tag in profile field + `org.gamecult.language`, owned by the user through the client's language + picker (`gamecult-chat:ruling:user-language-profile-field`). The client ships + localized; people talk with stewards in their own language; rooms are made + for a language, with no per-language mirrors + (`gamecult-chat:ruling:multilingual-client-stewards-language-rooms`). One + host Mind sits in a language room, Weksa for `#pt-br` + (`gamecult-chat:ruling:language-room-one-host-weksa`). The first person this + must work for is a non-English, non-technical patron (placeholder + `patron-pt1`) whose account we set up and walk through login + (`gamecult-chat:ruling:patron-in-his-own-language`). +- **Push.** `gamecult-chat:ruling:chat-push` rules out native mobile push for + now; Web Push to the installed web app is a different thing and is open + (question `ratatoskr:question:pwa-web-push`). +- **Collaboration is recorded, not chatted.** Work spanning Minds belongs in + Atlas (`eureka-body:ruling:atlas-records-collaborative-work`). Atlas is not + deployed, so the plan below is its interim home; Atlas imports it later. + +## Body facts + +Probes run by Imagination on 2026-10-31, session `self-2026-10-29-eureka`. + +1. **Branch.** `void/gamecult-theme-and-project-nav` is `b8e696c8` over main + `1e6412ed` (v4.12.8), 2 commits, 14 files, +1153/-32 (686 of it lockfile): + a GameCult theme as default, member drawer hidden by default, bubble + layout, and steward grouping in `src/app/pages/client/space/Space.tsx` + (`useStewardGroups` at 429-437, group headers at 505-524) over + `useSpaceJoinedHierarchy` (409). Read by `git diff`. +2. **Live estate shape** (Synapse on Yggdrasil, read as `admin` over the + client API, counts only). Root Space `#gamecult:gamecult.org`, room version + 12, has 9 children: 8 rooms and the `Projects` sub-Space. `Projects` has 52 + children; 50 readable rooms, all room version 12. Server `/hierarchy` from + the root returns 59 rooms, 2 of them spaces. **Every room's join rule is + `invite`**; none is world-readable. 50 rooms carry `org.gamecult.steward` + with content `{mind, repo, source, steward: }`. The root carries + `org.gamecult.stewardships`: Mind localpart to list of `Org/Repo`. +3. **Consequence of 1 and 2.** Project rooms are invite-only and Members are + invited, not joined (gamecult-ops `runbooks/gamecult-chat-host-yggdrasil.md` + 146-158: "Members and every Mind are invited; the steward alone joins"). + So a Member's project rooms are pending invites. Void's grouping reads + `useSpaceJoinedHierarchy` and a per-room state event that an invitee cannot + read, so for a typical Member it groups nothing and the rooms stay in the + invite list. This is the operator's "three clicks deep" complaint, seen from + the data. +4. **Provisioning contract.** gamecult-ops + `scripts/synapse-accounts-hand-managed.py` writes the rooms and both state + events from the Minds' stewardship documents; each project room's alias is + `#` (runbook 146-150). This is the only place the client + can learn a not-yet-joined room's steward today: hierarchy `canonical_alias` + joined to the root `org.gamecult.stewardships`. Follow-up + `ratatoskr:follow_up:stewardships-carry-room-ids` asks the projection to + name room ids so the client stops leaning on the alias convention. +5. **Sync and storage** (`src/client/initMatrix.ts`). The client uses + matrix-js-sdk 41.7.0 with `IndexedDBStore` (`web-sync-store`, 15-19) and the + Rust crypto store, and `startClient({ lazyLoadMembers: true })` (43-47): v2 + `/sync`, no sliding sync, no custom filter or `initialSyncLimit`. A cold + start is therefore an IndexedDB load plus an incremental sync from the + stored token, not a full initial sync. +6. **Server sync support.** `/_matrix/client/versions` on Synapse 1.164.1 + (127.0.0.1:8834) advertises `org.matrix.simplified_msc3575: true`: + simplified sliding sync (MSC4186) is available. Cinny 4.12.8 does not use it. +7. **Shell delivery.** `https://chat.gamecult.org/assets/index-DWIvG3QT.js` is + served at 3,736,520 bytes with no `Content-Encoding` even when gzip and br + are offered, and with no `Cache-Control` (only `ETag`/`Last-Modified`). The + service worker (`src/sw.ts`, 158 lines) handles only `install`, `activate`, + a session `message`, and authenticated media `fetch` (79-158); the + `vite-plugin-pwa` config sets `injectionPoint: undefined` + (`vite.config.ts` 101-113), so nothing is precached. +8. **No push path.** `src/sw.ts` has no `push` or `notificationclick` handler; + no code subscribes through `PushManager`; the only pusher the client sets is + the email pusher (`src/app/features/settings/notifications/SystemNotification.tsx` + 22-40). Notifications and sounds fire only from a live page: + `src/app/pages/client/ClientNonUIFeatures.tsx` 79-129 (invites) and 131-258 + (messages: `RoomEvent.Timeline` at 181-235, `new window.Notification` at + 156, `notification.ogg` played at 175-178). Nothing in `src/` listens for + `visibilitychange` or calls `retryImmediately`. +9. **Sygnal WebPush** (matrix-org/sygnal `docs/applications.md`, read + 2026-10-31; the page itself is marked partly out of date): a `webpush` app + takes `vapid_private_key`, `vapid_contact_email`, optional + `allowed_endpoints` and `ttl`; the client's pusher `data` carries + `endpoint`, `auth`, `p256dh` and optional `default_payload`; the push + carries `room_id`, `room_name`, `sender_display_name`, `content`, unread + counts and so on; browsers need a visible notification for each push + (`events_only` avoids event-less pushes). + +10. **Baseline at `dd88424e`** (Yggdrasil, `ygg-verify.sh`, image + `node:22-bookworm`, `npm ci --ignore-scripts`): `vitest run` passes (1 + file, 5 tests, Void's steward grouping); `vite build` succeeds, `dist/` 61 + MB with source maps, main chunk 3,739,716 bytes, Rust crypto wasm + 5,573,870 bytes, `dist/sw.mjs` 1.66 kB. `tsc --noEmit` fails at base with + 793 errors in 246 files (455 of them TS2614, named imports from + `matrix-js-sdk` 41.7.0 not resolved), so typecheck is not a usable gate as + is; a cut gates on zero errors in the files it adds and no rise in the + total. + +Not probed: phone frame times, the real resume timeline on the operator's +Android Chrome install (needs her device or remote DevTools), the Rust crypto +wasm size, and the router base under `/preview/` (its `config.json` sets +`hashRouter.basename` to `/`). + +## Why the PWA resumes slowly (diagnosis, moderate confidence) + +Android freezes a backgrounded installed web app and later discards it. On a +**discard**, returning is a full cold boot: 3.7 MB of uncompressed JS over a +phone link with no precache (fact 7), Rust crypto wasm init, the IndexedDB +store load, then a catch-up `/sync` over every room since the stored token +(fact 5). On a **freeze without discard**, the in-flight long-poll is dead, and +the SDK only notices on its own timers because the client never nudges it on +`visibilitychange` (fact 8). The catch-up sync grows with the number of rooms +and the swarm's chat volume, which is exactly where sliding sync helps +(fact 6), but the first two costs are cheaper to remove and should be measured +first. Without Web Push, a frozen page cannot notify at all, which is the +second half of the operator's report. + +## Model page: the estate + +| Kind | Named by | Over time | Who decides | +|---|---|---|---| +| Stewardship | `:stewardship:.n` in each Mind's mind | sequence advances on hand-over | the operator and the Minds; the minds are the truth | +| Estate projection | `org.gamecult.stewardships` on the root Space; `org.gamecult.steward` per project room | re-PUT on every provisioning run | gamecult-ops provisioning script (Eureka's slice) | +| Estate room list | the server's `/hierarchy` of the root Space for this user | changes with Space children and the user's invites and joins | Synapse (membership and join rules); the client derives | +| Room membership | the user's `m.room.member` | join on tap or first post | the user | +| Navigator view | derived per render | never stored | the client's estate index | + +## Authority map for the navigator + +- **Owner:** one estate index in the client (`src/app/features/estate/`): + `buildEstate()` is pure, `useEstate()` feeds it. It decides what rooms the + person sees, in which tier and group, and with what membership affordance. +- **Inputs:** the paged server `/hierarchy` of the root Space; root Space + state `org.gamecult.stewardships`; the person's membership per room from the + client store; unread and selection atoms for emphasis only; the `m.direct` + list for the direct-messages tier. +- **Outputs:** an ordered list of tiers (one per root room group and per + sub-Space, plus Direct), each holding steward groups or rooms, each room + marked joined, invited or joinable. +- **Derived:** steward group labels (Mind display names); counts; collapsed + state is per-device UI state only. +- **Demoted or dead:** the sidebar rail and its account-data ordering + (`useSidebarItems`) are dead; `useSpaceJoinedHierarchy` no longer decides + what is listed; Void's per-room steward reader is deleted, not kept beside + the index. +- **Shared paths:** every nav slot (home, direct, space routes) renders the + same navigator; tapping Join and sending a first post both go through + `mx.joinRoom`; deep links into a room still resolve through the existing + room routes. + +## Reconstructed collaboration plan (Atlas import) + +Planned in `#swarm` and `#minds` on 2026-10-31 and then lost to scroll. Status +as of 2026-10-31 ~15:00. + +**Who holds which part** + +| Part | Holder | Status | Evidence | +|---|---|---|---| +| The client: integration, navigator, emote slot, sound hookup, background canvas | Void (steward) | theme and steward grouping on branch; nothing else built | `void:stewardship:gamecult_-cinny.n1`; `#swarm` 10-31 14:54 Void report | +| Dings earcons: motif per event kind, one stable instrument per Mind | Aqua | not rendered: render env blocked, then .NET SDK installed and Faust scouted; AquaSynth PR #1 (Linux Faust loader, `faust-loader-platform-aware`) merged at 3b43b7bd | `aqua-body:follow_up:chat-s410-416`, `aqua-body:follow_up:chat-s410-416-render-env`, `aqua-body:resolution:follow_up.chat-s430-430.n1`, forge AquaSynth pull 1 | +| Ambient soundscape for the client | Aqua | idea only; no design recorded | operator `#swarm` 10-31 08:32 ("Motifs for everything"), `eureka-body:ruling:atlas-records-collaborative-work` | +| Background math, in CultMath: the public `fbm` gap, Phacelle erosion's Lipschitz bound, any new GLSL mirrors | Libby (steward of CultLib, which owns CultMath; holds `cultmath-tapes`) | sources located; nothing built | `#swarm` 10-31 06:34 and 06:46 Libby; `libby-body:follow_up:cultmath-ts-noise-absent`; `#swarm` 04:22 Libby accepts `cultmath-tapes` | +| Background scene and render: the raymarched terrain, the tree-and-squirrel intro scene, lighting and atmosphere, march and TAA tuning on the site-ground framework | Nibu (steward of Aetheria, whose volumetric nebula and cloud shaders she owns: `Raymarching/CloudShader.shader`, `Volumetric.cginc`) | proposed by the operator; not yet taken up by Nibu | operator to Self, 10-31: "If we want to spread the work around, Nibu owns the nebulae so she has shader expertise." | +| Animated bubbles, flourishes, corner avatar and XP bar | Void, with Kiko's offer on juice | advice only | `#swarm` 10-31 03:59 Kiko and Void, 06:45 Void | +| Attention bubbles | Eureka (Huginn heartbeat publishes), Void (render), Heimdall (scope rules) | advice only | `#swarm` 10-31 04:00 Eureka, 04:02 Heimdall | + +**Agreements** + +- Dings are pre-rendered clips shipped as static assets, no synth in the + browser: `dings//.ogg` plus a small manifest; an + unknown sender falls back to a neutral ding; ogg or mp3, not wav (Void + `#minds` 03:19, Aqua 07:41). The set is motif times instrument, and the + instrument says who spoke (operator 03:18, Eureka 03:19). Events that sound: + a mention, a DM, an invite, a message in an unmuted room; mentions and DMs + loud, ordinary messages quiet; everything else silent (Void `#minds` 03:19). + The operator wants far more than four motifs (08:32). The instrument per + Mind belongs in a typed document on the Mind side, not hardcoded (Eureka + 03:19, advice). Aqua's ear check comes before anything is called done, and + the operator's ear is ground truth (Aqua 07:41). Browsers block audio until + the first click, so the first ding after a fresh load may be silent. +- The background has its own canvas and budget, separate from the bubble + flourishes; capped at 30 fps, paused when hidden; under reduced motion one + static frame; an adaptive step-down (Aqua 04:17, Void 04:18 and 04:22). +- The background is an infinite raymarched terrain: CultMath noise plus the + Phacelle erosion filter, in the shader (operator 04:18 "painter's algorithm", + 05:24 "all we need is noise", 05:25 erosion, restated by the operator to + Self on 10-31: "use CultMath noise and erosion filter running in the shader + to have an infinite terrain. We can just raymarch that instead"). Voronoi + and Apollonian are dropped (05:24). +- Yggdrasil is the scene's centre (operator to Self, 10-31: "Yggdrasil should + be the star of the show now, so really the camera should just pull back a + bit when you login and the personas should be orbiting the tree, dancing + around each other in accordance with their activity"). On login the camera + pulls back from the tree; there is no separate intro to cut away from. The + Personas orbit the tree and move around each other by their live activity. +- One activity vocabulary drives both sound and motion: Aqua's activity-kinds + grammar (`aqua-body:follow_up:chat-s431-432`, "enumerate the kinds of agent + activity in typed state") is the shared contract between Aqua's motifs and + Nibu's orbits, not two vocabularies. The live feed behind it is the same + typed state the swarm dashboard projects + (`eureka-body:ruling:swarm-dashboard-first-thing2-surface`, Libby; + `libby-body:follow_up:chat-s354-359`): Run documents, the heartbeat trace. + What reaches the client is a member-safe projection: kind, Mind, time and + intensity, never briefs or mind contents. Matrix presence and typing cover + humans and are too coarse for Minds. Where the feed comes from is + `ratatoskr:question:activity-feed-source`. +- The scene renders in two passes, not one loop (operator to Self, 10-31: + "The tape optimized solid surface march should run really fast, i imagine, + way faster than the volumetric version, so they should not be one loop"). + Pass 1, the solid-surface march (terrain, tree, squirrel), sphere-traces + with the field's Lipschitz bound and interval tile culling, at its own + resolution, and writes depth. Pass 2, the volumetric atmosphere march on + the site-ground framework (reduced resolution, TAA), is bounded by pass 1's + depth, so it marches only up to the surface hit. Each pass has its own + budget and its own measurement. Nibu owns both passes. +- The web math path is CultLib's own: HLSL source lowered by dxc and + SPIRV-Cross to WebGL2 GLSL ES (`packages/cultmath/shaders/CultMath.glsl`, + `.Phacelle.glsl`; `tools/glsl/build-glsl.sh`), and C# CPU math through + .NET wasm, with parity presumed for now (operator 06:23, 06:48; Libby + 06:34). No TS or WGSL port. The gamecult.org site ground already + concatenates `CultMath.glsl` in `ground.js` and runs an interval-culled, + TAA-reprojected per-pixel march (CultLib `docs/cultmath-interval-ground-cut.md`, + spec `cultmath-interval-ground-cut-site-ground.r3`): that framework is the + base, not new tape work. +- Gaps go to the owner: a plain public `fbm` (only `fbm_grad`, `span_fbm`, + `af_fbm` exist) is added in CultMath, not in the client + (`libby-body:follow_up:cultmath-ts-noise-absent`; Libby 07:57 "tell me what + you need and I'll fill it at the library"). +- Mood and flourish ride as metadata on the event, never guessed from text; + bounded per-frame budget; reduced-motion fallback (Void 03:59, Kiko 03:59). +- XP is two numbers, never one: a cosmetic, labelled game bar fed by things + worth doing, and contribution credit shown only once a real measure exists + (Void 06:45, with Nibu and Kiko). + +**Open items** + +- Libby: the raymarch step bound needs the field's Lipschitz bound. CultMath + tracks `SNOISE_LIPSCHITZ` for noise; erosion's bound is unknown and must be + probed before the march is trusted. +- Libby: public `fbm` in CultMath, with its GLSL mirror. +- Nibu (once she takes it): the terrain scene, lighting and atmosphere, and + march and TAA tuning on the site ground's framework at a phone budget; + waits on Libby's `fbm` and the erosion bound. Then the intro scene in the + same world. +- Aqua: render the first earcon set once Faust builds in her sandbox; then the + instrument-per-Mind list and the manifest; then the soundscape design. +- Aqua and Nibu: agree the activity vocabulary once (Aqua's grammar), so + motifs and orbits read the same kinds. +- Void: the navigator (next cut); the Dings hook at + `ClientNonUIFeatures.tsx` 131-258; the background canvas after Libby's shader. +- Eureka: compression and caching for the shell on the chat host + (`ratatoskr:follow_up:chat-shell-compression`); the stewardship projection's + room ids; a Web Push gateway if the operator picks it. + +## Cut sequence + +`estate-navigator` is specced (`ratatoskr:cut_spec:cut-estate-navigator.r1`). +Each later cut is one line, with what it waits on. + +- `resume-fast`: precache the app shell in the service worker, nudge sync on + `visibilitychange`, and measure cold and warm resume on the operator's phone + before and after; waits on nothing; runs beside `estate-navigator` in its + own worktree, and pairs with the ops follow-up on compression. +- `pwa-web-push`: service worker `push` and `notificationclick` handlers, a + `webpush` pusher registered with VAPID keys, and a self-hosted Sygnal on the + chat host; waits on `ratatoskr:question:pwa-web-push`. +- `sliding-sync`: move the client to simplified sliding sync (MSC4186), which + the server already offers; waits on `resume-fast`'s measurements showing + catch-up sync dominates. +- `dings`: per-Mind earcons through the manifest at the existing notification + hook, and the same motifs in push notifications; waits on Aqua's renders. +- `emote-slot`: the pose-id emote on every bubble and the picker; waits on + `estate-navigator` (shared header work). +- `participant-tiers`: participants tiered by estate in the header menu, Minds + indexed by stewardship; waits on `estate-navigator`. +- `language`: our own strings and translations, the `org.gamecult.language` + picker, and the walk-through login for `patron-pt1`; waits on + `estate-navigator`. +- `terrain-background`: the raymarched CultMath noise and Phacelle terrain on + the site ground's march and TAA framework, in its own canvas and budget, as + two passes (solid-surface sphere trace writing depth, then the volumetric + atmosphere bounded by it), each measured on a phone; Nibu on the scene and + both passes, Libby's CultMath underneath, Void + integrating; waits on Libby's `fbm` and the erosion Lipschitz probe (both in + CultLib), then a phone measurement. +- `yggdrasil-scene`: the tree and squirrel at the centre of the terrain world; + on login the camera pulls back from the tree, and it may play during the + cold start or resume sync but never holds the client up; Nibu on the scene, + Libby on any CultMath it needs, Void integrating, Aqua's soundscape under + it; waits on `terrain-background`. +- `orbiting-personas`: Personas orbit the tree and dance around each other by + live activity, from the shared activity vocabulary; waits on + `yggdrasil-scene`, Aqua's activity-kinds grammar, and + `ratatoskr:question:activity-feed-source`. +- `bubbles-and-juice`: animated bubbles and mood flourishes from event + metadata, the corner avatar and the cosmetic XP bar; waits on `emote-slot`. +- `attention-bubbles`: per-person attention bubbles from Huginn's typed + attention document, scoped to room members; waits on Huginn publishing it. +- `rename-ratatoskr`: package name, app name and branding, manifest and + titles, the `/preview/` path and its deploy, and every in-repo `cinny` + reference that is ours (not upstream engine paths); waits on Void's forge + rename of `GameCult/cinny` to `GameCult/Ratatoskr` + (`eureka-body:ruling:chat-client-named-ratatoskr`), and the campaign's repo + list moves with it. diff --git a/harness/estate/check.cjs b/harness/estate/check.cjs new file mode 100644 index 00000000..656d532e --- /dev/null +++ b/harness/estate/check.cjs @@ -0,0 +1,46 @@ +// Assertions for the estate drives: every drive states the outcome it exists to show and exits +// non-zero when the page or the fake homeserver's log says otherwise. +const fs = require('fs'); +const fails = []; +exports.expect = (label, ok, detail = '') => { + console.log(`[ASSERT] ${ok ? 'PASS' : 'FAIL'} ${label}${ok ? '' : ` :: ${detail}`}`); + if (!ok) fails.push(label); +}; +/** Exit code for the drive: 4 when any assertion failed. */ +exports.done = () => (fails.length ? 4 : 0); +/** The fake homeserver's request log (HSLOG is shared with the server process). */ +exports.hsLog = () => fs.readFileSync(process.env.HSLOG, 'utf8').split('\n'); +/** Bodies of the account-data PUTs for m.direct, in order. */ +exports.mDirectPuts = () => + exports.hsLog().filter((l) => / PUT .*m\.direct BODY /.test(l)).map((l) => l.split(' BODY ')[1]); +/** Join requests the server saw for a room id. */ +exports.joinsOf = (roomId) => exports.hsLog().filter((l) => new RegExp(` JOIN ${roomId}$`).test(l)).length; +const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages']; +/** The tier header above a row, 'absent' when the page does not show the row, 'none-above' when no tier header precedes it. */ +const tierOf = (pg, name) => + pg.evaluate((n) => { + const s = document.body.innerText.split('\n').map((x) => x.trim()); + const i = s.indexOf(n); + if (i < 0) return 'absent'; + for (let j = i; j >= 0; j--) if (/^(elsewhere|direct messages)$/i.test(s[j])) return s[j]; + return 'none-above'; + }, name); +/** + * The tier a room is listed under, read from a fresh visit to the room list: a collapsed tier is + * opened only if the row is not already showing, so reading never closes what is open. + */ +exports.revealedTier = async (pg, name) => { + await pg.goto('http://127.0.0.1:8080/#/home/'); + await pg.waitForTimeout(2500); + let t = await tierOf(pg, name); + for (const h of HEADERS) { + if (t !== 'absent') break; + const hl = pg.getByText(h, { exact: true }); + if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); t = await tierOf(pg, name); } + } + return t; +}; +/** True when the page text matches re within ms of now (a message can be replaced by a row change). */ +exports.sawText = (pg, re, ms) => + pg.waitForFunction((src) => new RegExp(src, 'i').test(document.body.innerText), re.source, { timeout: ms, polling: 50 }).then(() => true, () => false); +exports.isDm = (tier) => /^direct messages$/i.test(tier); diff --git a/harness/estate/drive-failures.cjs b/harness/estate/drive-failures.cjs new file mode 100644 index 00000000..d53d88a4 --- /dev/null +++ b/harness/estate/drive-failures.cjs @@ -0,0 +1,72 @@ +// Failure-mode scenarios for the estate join paths (estate tap and inbox Accept), run by run.sh +// against fakehs.cjs with the env flag each mode needs. +const { chromium } = require('playwright'); +const { expect, done, isDm, mDirectPuts, joinsOf, revealedTier, sawText } = require('./check.cjs'); +const MODE = process.env.MODE; +const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`; +const log = (...a) => console.log(`[${MODE}]`, ...a); +const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages']; +let b; +// Close the browser, but never wait on it: a hung close must not hang the run. +const quit = (code) => Promise.race([Promise.resolve(b && b.close()).catch(() => undefined), new Promise((r) => setTimeout(r, 5000))]).finally(() => process.exit(code)); +setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 240000); +(async () => { + b = await chromium.launch(); + const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, deviceScaleFactor: 1 }); + const pg = await ctx.newPage(); pg.on('console', (m) => { if (m.type() === 'error') console.log('[console]', m.type(), m.text().slice(0, 160)); }); + const open = async (h = '#/home/') => { await pg.goto('http://127.0.0.1:8080/' + h); await pg.reload(); await pg.waitForTimeout(9000); }; + await pg.goto('http://127.0.0.1:8080/'); + await pg.evaluate(() => { localStorage.setItem('cinny_access_token', 'tok'); localStorage.setItem('cinny_device_id', 'DEV'); localStorage.setItem('cinny_user_id', '@t:test'); localStorage.setItem('cinny_hs_base_url', 'http://127.0.0.1:8080'); }); + await open(); + const body = async () => (await pg.evaluate(() => document.body.innerText)).replace(/\s+/g, ' '); + const reveal = async (name) => { const loc = pg.locator(`[aria-label="Join ${name}"]`); if (await loc.count()) return loc; for (let r = 0; r < 2; r++) for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); if (await loc.count()) return loc; } } return loc; }; + const tierOf = (name) => revealedTier(pg, name); + const tap = async (n) => { const l = await reveal(n); log('JOINCTRL', n, await l.count()); await l.first().click(); log('CLICK', n, t()); }; + const tiers = async (tag) => { const r = { friend: await tierOf('Friend DM'), slow: await tierOf('Slow DM') }; log('TIER', tag, '| Friend DM', r.friend, '| Slow DM', r.slow); return r; }; + const neverUnfiled = () => mDirectPuts().every((b) => b.includes('!dm1:test')); + const acceptInInbox = async (name) => { + // reach the inbox through the navigator's own Inbox tab + await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2000); + const tab = pg.locator('[aria-label="Inbox"], [aria-label*="nbox"]'); log('INBOX_TAB', await tab.count()); + if (await tab.count()) { await tab.first().click(); await pg.waitForTimeout(1500); } + const inv = pg.getByText('Invites', { exact: true }); if (await inv.count()) { await inv.first().click(); await pg.waitForTimeout(1500); } + log('URL', pg.url()); + if (!/invites/.test(pg.url())) { await pg.goto('http://127.0.0.1:8080/#/inbox/invites/'); await pg.waitForTimeout(3000); log('URL_FALLBACK', pg.url()); } + { const pub = pg.getByText('Public', { exact: true }); log('PUBLIC_FILTER', await pub.count()); if (await pub.count()) { await pub.first().click(); await pg.waitForTimeout(1500); } } + for (let r = 0; r < 3; r++) { const v = pg.getByText('View All', { exact: true }); if (!(await v.count())) break; await v.first().click(); await pg.waitForTimeout(800); } + const b0 = await body(); const i = b0.indexOf(name); log('INBOX has', name, i >= 0, JSON.stringify(b0.slice(Math.max(0, i - 120), i + 120))); + const card = pg.locator('div').filter({ hasText: name }).filter({ has: pg.getByText('Accept', { exact: true }) }).last(); + await card.getByText('Accept', { exact: true }).first().click(); log('ACCEPT', name, t()); + }; + if (MODE === 'pjfail') { + await tap('Friend DM'); await pg.waitForTimeout(3000); + const bb = await body(); const k = bb.search(/Internal server error/); log('AFTER_FAIL', k >= 0 ? bb.slice(Math.max(0, k - 40), k + 80) : 'no message'); + expect('a failed m.direct write shows the refusal', k >= 0, bb.slice(0, 200)); + expect('no join is sent while the filing is failing', joinsOf('!dm1:test') === 0, String(joinsOf('!dm1:test'))); + await tap('Friend DM'); await pg.waitForTimeout(6000); const r = await tiers('after retry'); + expect('the retry joins once', joinsOf('!dm1:test') === 1, String(joinsOf('!dm1:test'))); + expect('Friend DM is filed under Direct Messages after the retry', isDm(r.friend), r.friend); + } else if (MODE === 'jdrop') { + await tap('Friend DM'); const saw = await sawText(pg, /tap again to retry/, 3000); log('AFTER_DROP', saw); + expect('a dropped join shows the retryable message', saw, (await body()).slice(0, 200)); + await pg.waitForTimeout(6000); const r1 = await tiers('after drop'); await open(); const r2 = await tiers('after reload'); + expect('the entry is kept after the drop', isDm(r1.friend) && neverUnfiled(), r1.friend); + expect('Friend DM is under Direct Messages after the reload', isDm(r2.friend), r2.friend); + } else if (MODE === 'admin') { + await tap('Friend DM'); await pg.waitForTimeout(6000); const r = await tiers('admin estate'); + expect('an admin-invited DM is filed under Direct Messages', isDm(r.friend), r.friend); + } else if (MODE === 'inboxadmin') { + await acceptInInbox('Friend DM'); await pg.waitForTimeout(6000); const r = await tiers('admin inbox'); + expect('an admin-invited DM accepted in the inbox is filed under Direct Messages', isDm(r.friend), r.friend); + } else if (MODE === 'inboxreload') { + await acceptInInbox('Slow DM'); await pg.waitForTimeout(2000); log('RELOAD', t()); await open('#/inbox/invites/'); + await pg.waitForTimeout(30000); const r = await tiers('inbox reload'); + expect('a DM accepted in the inbox is filed under Direct Messages after a reload', isDm(r.slow), r.slow); + } else if (MODE === 'pjreload') { + await tap('Slow DM'); await pg.waitForTimeout(2000); log('RELOAD', t()); await open(); + await pg.waitForTimeout(8000); await tiers('pjreload'); + const n = await (await reveal('Slow DM')).count(); log('SLOW_DM_STILL_JOINABLE', n); + expect('a join cut off by a reload stays joinable', n === 1, String(n)); + } + await quit(done()); +})().catch((e) => { log('ERROR', String(e.stack || e).slice(0, 300)); quit(1); }); diff --git a/harness/estate/drive-routes.cjs b/harness/estate/drive-routes.cjs new file mode 100644 index 00000000..75c9a8df --- /dev/null +++ b/harness/estate/drive-routes.cjs @@ -0,0 +1,42 @@ +const { chromium } = require('playwright'); +const { expect, done, isDm, mDirectPuts, hsLog, revealedTier, sawText } = require('./check.cjs'); +const MODE = process.env.MODE; const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`; +const log = (...a) => console.log(`[${MODE}]`, ...a); +const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages']; +let b; const quit = (c) => Promise.race([Promise.resolve(b && b.close()).catch(() => 0), new Promise((r) => setTimeout(r, 5000))]).finally(() => process.exit(c)); +setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 120000); +(async () => { + b = await chromium.launch(); + const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true }); + const pg = await ctx.newPage(); + await pg.goto('http://127.0.0.1:8080/'); + await pg.evaluate(() => { localStorage.setItem('cinny_access_token', 'tok'); localStorage.setItem('cinny_device_id', 'DEV'); localStorage.setItem('cinny_user_id', '@t:test'); localStorage.setItem('cinny_hs_base_url', 'http://127.0.0.1:8080'); }); + await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.reload(); await pg.waitForTimeout(9000); + const body = async () => (await pg.evaluate(() => document.body.innerText)).replace(/\s+/g, ' '); + const tiers = async (tag) => { const f = await revealedTier(pg, 'Friend DM'); log('TIER', tag, '| Friend DM', f); return f; }; + let joined = false; + if (MODE === 'deeplink') { + for (const route of ['direct', 'home']) { + await pg.goto(`http://127.0.0.1:8080/#/${route}/${encodeURIComponent('!dm1:test')}/`); await pg.waitForTimeout(4000); + const bb = await body(); log('ROUTE', route, JSON.stringify(bb.slice(0, 300))); + const j = pg.getByRole('button', { name: /^Join$/ }); log('JOIN_BUTTONS', route, await j.count()); + if (await j.count()) { joined = true; await j.first().click(); log('CLICK_JOIN', route, t()); await pg.waitForTimeout(6000); break; } + } + const f = await tiers('after deeplink join'); + const stamps = (re) => hsLog().filter((l) => re.test(l)).map((l) => Number(l.split(' ')[0])); + const put = stamps(/ PUT .*m\.direct BODY .*!dm1:test/)[0], join = stamps(/ JOIN !dm1:test$/)[0]; + expect('the deep link offers one Join button', joined, 'no Join button'); + expect('the DM is filed before the join is sent', put !== undefined && join !== undefined && put <= join, `put ${put} join ${join}`); + expect('the DM is filed under Direct Messages', isDm(f), f); + } else if (MODE === 'gw504') { + const HL = HEADERS; let l = pg.locator('[aria-label="Join Friend DM"]'); + for (let r = 0; r < 2 && !(await l.count()); r++) for (const h of HL) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } + log('JOINCTRL', await l.count()); await l.first().click(); log('CLICK', t()); + const saw = await sawText(pg, /tap again to retry/, 3000); log('AFTER_504', saw); + expect('a gateway timeout shows the retryable message', saw, (await body()).slice(0, 200)); + await pg.waitForTimeout(4000); const f = await tiers('after 504 (join delivered by sync)'); + expect('the DM stays filed after a gateway timeout', mDirectPuts().every((b) => b.includes('!dm1:test')) && mDirectPuts().length > 0, JSON.stringify(mDirectPuts())); + expect('the DM is filed under Direct Messages once sync delivers it', isDm(f), f); + } + await quit(done()); +})().catch((e) => { log('ERROR', String(e.stack || e).slice(0, 300)); quit(1); }); diff --git a/harness/estate/drive.cjs b/harness/estate/drive.cjs new file mode 100644 index 00000000..03bd2ad0 --- /dev/null +++ b/harness/estate/drive.cjs @@ -0,0 +1,48 @@ +const { chromium } = require('playwright'); +const { expect, done, isDm, revealedTier } = require('./check.cjs'); +const MODE = process.env.MODE; // twodm | reload +const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`; +const log = (...a) => console.log(`[${MODE}]`, ...a); +const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages']; +let tierBefore; +(async () => { + const b = await chromium.launch(); + const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, deviceScaleFactor: 1 }); + const pg = await ctx.newPage(); pg.on('console', (m) => { if (m.type() === 'error' || /command|direct/i.test(m.text())) console.log('[console]', m.type(), m.text().slice(0, 200)); }); + const open = async () => { await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.reload(); await pg.waitForTimeout(9000); }; + await pg.goto('http://127.0.0.1:8080/'); + await pg.evaluate(() => { localStorage.setItem('cinny_access_token', 'tok'); localStorage.setItem('cinny_device_id', 'DEV'); localStorage.setItem('cinny_user_id', '@t:test'); localStorage.setItem('cinny_hs_base_url', 'http://127.0.0.1:8080'); }); + await open(); + const text = () => pg.evaluate(() => document.body.innerText); + const reveal = async (name) => { const loc = pg.locator(`[aria-label="Join ${name}"]`); if (await loc.count()) return loc; for (let r = 0; r < 2; r++) for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); if (await loc.count()) return loc; } } return loc; }; + const tierOf = (name) => revealedTier(pg, name); + const tap = async (n) => { const l = await reveal(n); log('JOINCTRL', n, await l.count()); await l.first().click(); log('CLICK', n, t()); }; + if (MODE === 'twodm') { + await tap('Slow DM'); await pg.waitForTimeout(300); await tap('Late DM'); + await pg.waitForTimeout(45000); + } else if (MODE === 'convert') { + await tap('Friend DM'); await pg.waitForTimeout(6000); + tierBefore = await tierOf('Friend DM'); log('TIER before', tierBefore); + await pg.goto('http://127.0.0.1:8080/#/direct/!dm1:test/'); await pg.waitForTimeout(4000); + const ed = pg.locator('[contenteditable="true"]'); log('EDITORS', await ed.count(), pg.url()); + await ed.first().click(); await pg.keyboard.type('/converttoroom', { delay: 30 }); await pg.waitForTimeout(500); + log('ED_TEXT', JSON.stringify(await ed.first().innerText())); log('BODY', JSON.stringify((await text()).slice(-600))); + await pg.getByText('Convert direct message to room').first().click(); await pg.waitForTimeout(400); log('ED_PICKED', JSON.stringify(await ed.first().innerHTML()).slice(0, 300)); + await pg.keyboard.press('Enter'); log('SENT', t()); await pg.waitForTimeout(800); log('ED_TEXT2', JSON.stringify(await ed.first().innerText().catch(()=>''))); + if (await ed.first().innerText().catch(() => '')) { await pg.keyboard.press('Enter'); log('SENT2', t()); } + await pg.waitForTimeout(8000); + await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(3000); + } else { + await tap('Slow DM'); await pg.waitForTimeout(3000); + log('RELOAD', t()); await open(); + await pg.waitForTimeout(30000); + } + const slow = await tierOf('Slow DM'), late = await tierOf('Late DM'), friend = await tierOf('Friend DM'); + log('TIER Slow DM', slow, '| Late DM', late, '| Friend DM', friend); + if (MODE === 'twodm') { expect('Slow DM is filed under Direct Messages', isDm(slow), slow); expect('Late DM is filed under Direct Messages', isDm(late), late); } + else if (MODE === 'convert') { + expect('Friend DM was filed under Direct Messages before the conversion', isDm(tierBefore), tierBefore); + expect('the converted room left Direct Messages and is still listed', !isDm(friend) && friend !== 'absent', friend); + } else expect('Slow DM is filed under Direct Messages after the reload', isDm(slow), slow); + await b.close(); process.exitCode = done(); +})().catch((e) => { log('ERROR', String(e.stack || e)); process.exitCode = 1; }); diff --git a/harness/estate/fakehs.cjs b/harness/estate/fakehs.cjs new file mode 100644 index 00000000..4a6875e0 --- /dev/null +++ b/harness/estate/fakehs.cjs @@ -0,0 +1,127 @@ +// Soul s2 fake homeserver. Invitee with no root state; stewards only in m.space.child content. +// Rooms: GEN joined; OP invited; 6 project rooms invited (odin slow: join arrives at +24 s); +// PUB public joinable, not in store; STRAY joined outside hierarchy; DM1 invited direct (fast); +// DM2 invited direct (slow, +24 s). NOROOT=1: the root alias does not resolve. +const http = require('http'), fs = require('fs'), path = require('path'); +const DIST = process.argv[2], PORT = 8080, ME = '@t:test'; +const NOROOT = process.env.NOROOT === '1'; +const log = []; +const P = ['aetheria', 'ghostlight', 'huginn', 'idunn', 'bifrost', 'odin']; +const STEWARD = { aetheria: 'nibu', ghostlight: 'nibu', huginn: 'eureka', idunn: 'eureka' }; +const PROJ = P.map((p, i) => ({ id: `!p${i}:test`, alias: `#${p}:test`, name: p[0].toUpperCase() + p.slice(1), key: p })); +const ROOT = '!root:test', PSP = '!proj:test', GEN = '!gen:test', OP = '!op:test', PUB = '!pub:test', STRAY = '!stray:test', DM1 = '!dm1:test', DM2 = '!dm2:test', DM3 = '!dm3:test'; +const SLOW = new Set([PROJ[5].id, DM2, DM3]); +const NAMES = { [PUB]: 'Commons', [DM1]: 'Friend DM', [DM2]: 'Slow DM', [DM3]: 'Late DM', [STRAY]: 'Adhoc' }; +PROJ.forEach((r) => (NAMES[r.id] = r.name)); +const held = []; const pendingState = []; const pending = []; const adLog = []; let ev = 0; +const se = (type, content, sk = '', sender = '@admin:test') => ({ type, content, state_key: sk, sender, event_id: `$e${++ev}`, origin_server_ts: Date.now() }); +const strip = (id, name, space, direct) => [ + { type: 'm.room.create', state_key: '', sender: direct ? '@friend:test' : '@admin:test', content: space ? { type: 'm.space', room_version: '12' } : { room_version: '12' } }, + { type: 'm.room.name', state_key: '', sender: '@admin:test', content: { name } }, + { type: 'm.room.join_rules', state_key: '', sender: '@admin:test', content: { join_rule: 'invite' } }, + ...(direct ? [{ type: 'm.room.member', state_key: '@friend:test', sender: '@friend:test', content: { membership: 'join', displayname: 'Friend' } }] : []), + { type: 'm.room.member', state_key: ME, sender: direct ? (process.env.INVITER_ADMIN === '1' && id === DM1 ? '@admin:test' : '@friend:test') : '@admin:test', content: direct ? { membership: 'invite', is_direct: true } : { membership: 'invite' } }, +]; +const joinedRoom = (id, name, extra = [], space = false) => ({ + state: { events: [se('m.room.create', space ? { type: 'm.space', room_version: '12' } : { room_version: '12' }), se('m.room.name', { name }), se('m.room.join_rules', { join_rule: 'invite' }), se('m.room.member', { membership: 'join' }, ME, ME), ...extra] }, + timeline: { events: [{ type: 'm.room.message', sender: '@admin:test', content: { msgtype: 'm.text', body: `welcome to ${name}` }, event_id: `$m${++ev}`, origin_server_ts: Date.now() }], limited: false }, + ephemeral: { events: [] }, account_data: { events: [] }, unread_notifications: {}, summary: {}, +}); +const invites = () => { + const o = {}; + o[ROOT] = { invite_state: { events: strip(ROOT, 'GameCult', true) } }; + o[PSP] = { invite_state: { events: strip(PSP, 'Projects', true) } }; + o[OP] = { invite_state: { events: strip(OP, 'Operator') } }; + PROJ.forEach((r) => (o[r.id] = { invite_state: { events: strip(r.id, r.name) } })); + o[DM1] = { invite_state: { events: strip(DM1, NAMES[DM1], false, true) } }; + o[DM2] = { invite_state: { events: strip(DM2, NAMES[DM2], false, true) } }; + o[DM3] = { invite_state: { events: strip(DM3, NAMES[DM3], false, true) } }; + return o; +}; +const child = (id, steward) => ({ type: 'm.space.child', state_key: id, content: { via: ['test'], ...(steward ? { 'org.gamecult.steward': { mind: steward, repo: 'x', source: 'y', steward: 'z' } } : {}) }, sender: '@admin:test', origin_server_ts: 1 }); +const hroom = (id, name, alias, extra = {}) => ({ room_id: id, name, canonical_alias: alias, num_joined_members: 1, world_readable: false, guest_can_join: false, join_rule: 'invite', children_state: [], ...extra }); +const hierarchy = () => [ + hroom(ROOT, 'GameCult', '#gamecult:test', { room_type: 'm.space', children_state: [child(GEN), child(OP), child(PUB), child(PSP)] }), + hroom(GEN, 'General', '#general:test'), + hroom(OP, 'Operator', '#operator:test'), + hroom(PUB, 'Commons', '#commons:test', { join_rule: 'public' }), + hroom(PSP, 'Projects', '#projects:test', { room_type: 'm.space', children_state: PROJ.map((r) => child(r.id, STEWARD[r.key])) }), + ...PROJ.map((r) => hroom(r.id, r.name, r.alias)), +]; +let syncN = 1; +const json = (res, code, body) => { res.writeHead(code, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': '*', 'Access-Control-Allow-Methods': '*' }); res.end(JSON.stringify(body)); }; +const mime = { '.js': 'text/javascript', '.mjs': 'text/javascript', '.css': 'text/css', '.html': 'text/html', '.json': 'application/json', '.wasm': 'application/wasm', '.svg': 'image/svg+xml', '.png': 'image/png', '.woff2': 'font/woff2' }; +const flush = () => fs.writeFileSync(process.env.HSLOG, log.join('\n')); +http.createServer((req, res) => { + const u = new URL(req.url, 'http://x'); const p = decodeURIComponent(u.pathname); + if (req.method === 'OPTIONS') return json(res, 200, {}); + if (!p.startsWith('/_matrix') && !p.startsWith('/.well-known')) { + let f = path.join(DIST, p === '/' ? 'index.html' : p); + if (!fs.existsSync(f) || fs.statSync(f).isDirectory()) f = path.join(DIST, 'index.html'); + res.writeHead(200, { 'Content-Type': mime[path.extname(f)] || 'application/octet-stream' }); + return fs.createReadStream(f).pipe(res); + } + let body = ''; req.on('data', (c) => (body += c)); req.on('end', () => { + if (req.method === 'PUT' && p.includes('m.direct')) console.log('MDIRECT_PUT ' + (Date.now()) + ' ' + body); + log.push(`${Date.now()} ${req.method} ${p}${req.method === 'PUT' && p.includes('account_data') ? ' BODY ' + body : ''}`); + if (p === '/_matrix/client/versions') return json(res, 200, { versions: ['v1.1', 'v1.5', 'v1.11'], unstable_features: {} }); + if (p.endsWith('/sync')) { + const since = u.searchParams.get('since'); + if (!since) { + const join = {}; join[GEN] = joinedRoom(GEN, 'General'); join[STRAY] = joinedRoom(STRAY, 'Adhoc'); + return json(res, 200, { next_batch: `s${syncN}`, rooms: { join, invite: invites(), leave: {} }, account_data: { events: held.slice() }, presence: { events: [] }, to_device: { events: [] }, device_one_time_keys_count: { signed_curve25519: 50 } }); + } + const respond = () => { + const now = Date.now(); const join = {}; + for (let i = pending.length - 1; i >= 0; i--) if (pending[i].at <= now) { + const id = pending[i].id; pending.splice(i, 1); + const extra = id === DM1 || id === DM2 || id === DM3 ? [se('m.room.member', { membership: 'join', displayname: 'Friend' }, '@friend:test', '@friend:test')] : []; + join[id] = joinedRoom(id, NAMES[id] || id, extra); if (extra.length) { const me = join[id].state.events.find((e) => e.type === 'm.room.member' && e.state_key === ME); me.unsigned = { prev_content: { membership: 'invite', is_direct: true } }; } log.push(`${now} SYNC delivers join ${id}`); console.log('DELIVER ' + now + ' ' + id); + } + for (const s of pendingState.splice(0)) { join[s.id] = join[s.id] || { state: { events: [] }, timeline: { events: [], limited: false, prev_batch: 'p' }, ephemeral: { events: [] }, account_data: { events: [] }, unread_notifications: {}, summary: {} }; join[s.id].timeline.events.push(s.ev); console.log('STATE_ECHO ' + now + ' ' + s.id + ' ' + JSON.stringify(s.ev.content)); } + const k = Number(String(since).replace(/^s/, '')) || 0; const ad = adLog.filter((a) => a.after >= k).map((a) => ({ type: a.type, content: a.content })); // a server answers with what changed since the client's token, whichever client asks + if (Object.keys(join).length || ad.length) return json(res, 200, { next_batch: `s${++syncN}`, rooms: { join, invite: {}, leave: {} }, account_data: { events: ad } }); + return json(res, 200, { next_batch: since, rooms: {} }); + }; + return setTimeout(respond, 300); + } + const jm = p.match(/\/rooms\/([^/]+)\/join$/) || p.match(/\/join\/([^/]+)$/); + if (req.method === 'POST' && jm) { + const id = jm[1]; log.push(`${Date.now()} JOIN ${id}`); + if (id === PROJ[1].id) return json(res, 403, { errcode: 'M_FORBIDDEN', error: 'You are not invited to this room.' }); + const prior = pending.find((q) => q.id === DM2 || q.id === DM3); + pending.push({ id, at: (id === DM3 || id === DM2) && prior ? prior.at : Date.now() + (SLOW.has(id) ? 24000 : 1000) }); + if (process.env.JOIN_504 === '1' && id === DM1) { console.log('JOIN_504 ' + Date.now() + ' ' + id); res.writeHead(504, {'Content-Type':'text/html'}); return res.end('504 Gateway Time-out'); } + if (process.env.JOIN_DROP === '1' && id === DM1) { console.log('JOIN_DROPPED ' + Date.now() + ' ' + id); return req.socket.destroy(); } + if (process.env.JOIN_DELAY === '1') { console.log('JOIN_DELAYED ' + Date.now() + ' ' + id); return setTimeout(() => json(res, 200, { room_id: id }), 6000); } + return json(res, 200, { room_id: id }); + } + const sm = p.match(/\/rooms\/([^/]+)\/state\/m\.room\.member\/(.+)$/); + if (req.method === 'PUT' && sm) { console.log('STATE_PUT ' + Date.now() + ' ' + sm[1] + ' ' + body); const e = se('m.room.member', JSON.parse(body), sm[2], sm[2]); e.unsigned = { prev_content: { membership: 'join' } }; setTimeout(() => pendingState.push({ id: sm[1], ev: e }), 400); return json(res, 200, { event_id: e.event_id }); } + if (p.includes('/account_data/') && req.method === 'PUT' && p.includes('m.direct') && process.env.FAIL_MDIRECT === '1' && !global.failedOnce) { global.failedOnce = 1; console.log('MDIRECT_FAIL ' + Date.now()); return json(res, 500, { errcode: 'M_UNKNOWN', error: 'Internal server error' }); } + if (p.includes('/account_data/') && req.method === 'PUT') { + const DELAY = p.includes('m.direct') && process.env.DELAY_MDIRECT === '1'; + const type = decodeURIComponent(p.split('/account_data/')[1]); + try { const c = JSON.parse(body); adLog.push({ type, content: c, after: syncN }); const i = held.findIndex((h) => h.type === type); if (i >= 0) held.splice(i, 1); held.push({ type, content: c }); } catch (e) {} + if (DELAY) { console.log('MDIRECT_DELAYED ' + Date.now()); return setTimeout(() => json(res, 200, {}), 6000); } + return json(res, 200, {}); + } + if (p.includes('/hierarchy')) return json(res, 200, { rooms: hierarchy() }); + if (p.includes('/directory/room/')) return NOROOT ? json(res, 404, { errcode: 'M_NOT_FOUND', error: 'Room alias not found' }) : json(res, 200, { room_id: ROOT, servers: ['test'] }); + if (p.endsWith('/keys/upload')) return json(res, 200, { one_time_key_counts: { signed_curve25519: 50 } }); + if (p.endsWith('/keys/query')) return json(res, 200, { device_keys: {}, failures: {} }); + if (p.includes('/room_keys/version')) return json(res, 404, { errcode: 'M_NOT_FOUND', error: 'none' }); + if (p.includes('/pushrules')) return json(res, 200, { global: { override: [], underride: [], room: [], sender: [], content: [] } }); + if (p.includes('/filter')) return json(res, 200, { filter_id: '1' }); + if (p.includes('/account_data/')) return json(res, 404, { errcode: 'M_NOT_FOUND', error: 'none' }); + if (p.includes('/messages')) return json(res, 200, { chunk: [], start: 't0', end: 't0' }); + if (p.includes('/members')) return json(res, 200, { chunk: [] }); + if (p.includes('/profile/')) return json(res, 200, { displayname: 'Tess' }); + if (p.includes('/capabilities')) return json(res, 200, { capabilities: {} }); + if (p.includes('/devices')) return json(res, 200, { devices: [] }); + if (p.includes('summary')) return json(res, 200, { room_id: p.split('/').pop(), name: 'Summary card', num_joined_members: 1, membership: 'invite', join_rule: 'invite' }); + if (p.includes('/login')) return json(res, 200, { flows: [{ type: 'm.login.password' }] }); + return json(res, 200, {}); + }); +}).listen(PORT, () => console.log('fakehs up')); +setInterval(flush, 300); diff --git a/harness/estate/run.sh b/harness/estate/run.sh new file mode 100644 index 00000000..65242ab0 --- /dev/null +++ b/harness/estate/run.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Runs inside mcr.microsoft.com/playwright:v1.48.2-jammy with the repo at /src: +# builds the client, then drives the direct-message and join-failure scenarios against fakehs.cjs. +set -e +cd /src +npm ci --no-audit --no-fund --ignore-scripts >/tmp/npmci.log 2>&1 || { tail -20 /tmp/npmci.log; exit 1; } +npm run build > /tmp/build.log 2>&1 || { tail -30 /tmp/build.log; exit 1; } +echo BUILD_OK $(git rev-parse HEAD) +printf '{"defaultHomeserver":0,"homeserverList":["127.0.0.1:8080"],"allowCustomHomeservers":true,"featuredCommunities":{"spaces":["#gamecult:test"],"rooms":[],"servers":["test"]},"hashRouter":{"enabled":true,"basename":"/"}}' > dist/config.json +mkdir -p /tmp/pw && cd /tmp/pw && npm init -y >/dev/null && npm i --no-audit --no-fund playwright@1.48.2 >/tmp/pw.log 2>&1 +cp /src/harness/estate/check.cjs /src/harness/estate/drive.cjs /src/harness/estate/drive-failures.cjs /src/harness/estate/drive-routes.cjs /tmp/pw/ +set +e +for m in twodm reload convert; do + HSLOG=/tmp/hslog-$m.txt NOROOT=0 node /src/harness/estate/fakehs.cjs /src/dist > /tmp/hs-$m.out 2>&1 & + HS=$!; sleep 2 + HSLOG=/tmp/hslog-$m.txt MODE=$m node /tmp/pw/drive.cjs; rc=$?; echo "DRIVE $m RC=$rc"; [ $rc -eq 0 ] || FAILED="$FAILED $m" + kill $HS; wait $HS 2>/dev/null; sleep 1 + echo "=== hs $m"; grep -E "MDIRECT_PUT|DELIVER|STATE_" /tmp/hs-$m.out; grep -E " JOIN " /tmp/hslog-$m.txt +done +failure() { m=$1; shift + env "$@" HSLOG=/tmp/hslog-$m.txt NOROOT=0 node /src/harness/estate/fakehs.cjs /src/dist > /tmp/hs-$m.out 2>&1 & + HS=$!; sleep 2 + HSLOG=/tmp/hslog-$m.txt MODE=$m node /tmp/pw/${DRIVER:-drive-failures}.cjs; rc=$?; echo "DRIVE $m RC=$rc"; [ $rc -eq 0 ] || FAILED="$FAILED $m" + kill $HS; wait $HS 2>/dev/null; sleep 1 + echo "=== hs $m"; grep -E "MDIRECT|DELIVER|JOIN_|STATE_" /tmp/hs-$m.out; grep -E " JOIN " /tmp/hslog-$m.txt; } +failure pjfail FAIL_MDIRECT=1 +failure pjreload DELAY_MDIRECT=1 +failure jdrop JOIN_DROP=1 +failure admin INVITER_ADMIN=1 +failure inboxadmin INVITER_ADMIN=1 +failure inboxreload JOIN_DELAY=1 +DRIVER=drive-routes failure deeplink +DRIVER=drive-routes failure gw504 JOIN_504=1 +echo "FAILED_DRIVES:${FAILED:- none}" +[ -z "$FAILED" ] diff --git a/harness/scene/README.md b/harness/scene/README.md new file mode 100644 index 00000000..ef735d2e --- /dev/null +++ b/harness/scene/README.md @@ -0,0 +1,21 @@ +# Scene harness + +Compiles, renders and times the background scene (`src/app/features/scene/`) in headless +Chromium. Not shipped, not run by CI. + + node bundle.cjs # esbuild bundle of entry.ts plus an index.html + node drive.cjs compile # compile and link each stage, JSON report + node drive.cjs shots # rest, mid pull-back and pulled-back screenshots + node drive.cjs measure # GPU time per pass by timer query, two viewports, two levels + node drive.cjs variants # the same with source patches (no erosion, no tree, value-only fbm) + node drive.cjs trace # mean trace steps per pixel and pass ms at three camera poses, PREPASS=0,1 arms + node drive.cjs diff # one frozen frame with and without the prepass, pixel diff and both PNGs + node drive.cjs phacelle # the Phacelle gradient probe behind world.ts `phacelleGradBound` + +Needs `esbuild` and `playwright@1.48.2` on `NODE_PATH` and a Chromium. The first run was done on a +host with no GPU, so `drive.cjs` launches with `--use-angle=swiftshader` (software Vulkan). On a +machine with a GPU, remove those flags to measure the real device. `bundle.cjs` must run from a +directory whose ancestors hold no empty `package.json` (esbuild reads them). + +Timings from SwiftShader are CPU time: use them for the split between passes and for before and +after of one change, never as a phone frame time. diff --git a/harness/scene/bundle.cjs b/harness/scene/bundle.cjs new file mode 100644 index 00000000..027a2006 --- /dev/null +++ b/harness/scene/bundle.cjs @@ -0,0 +1,40 @@ +// Bundles entry.ts with the scene's ?raw GLSL imports into $OUT/bundle.js plus an index.html. +// usage: node bundle.cjs [demo] (esbuild from NODE_PATH). `demo` bundles the standalone page. +const esbuild = require('esbuild'); +const fs = require('fs'); +const path = require('path'); +const out = process.argv[2]; +const demo = process.argv[3] === 'demo'; +fs.mkdirSync(out, { recursive: true }); +const rawPlugin = { + name: 'raw', + setup(b) { + b.onResolve({ filter: /\?raw$/ }, (a) => ({ + path: path.resolve(a.resolveDir, a.path.slice(0, -4)), + namespace: 'raw', + })); + b.onLoad({ filter: /.*/, namespace: 'raw' }, (a) => ({ + contents: fs.readFileSync(a.path, 'utf8'), + loader: 'text', + })); + }, +}; +esbuild + .build({ + entryPoints: [path.join(__dirname, demo ? 'demo.ts' : 'entry.ts')], + bundle: true, + outfile: path.join(out, 'bundle.js'), + plugins: [rawPlugin], + format: 'iife', + target: 'es2020', + }) + .then(() => { + fs.writeFileSync( + path.join(out, 'index.html'), + 'Ratatoskr scene: trace prepass' + + (demo + ? '
device
' + : '') + + '' + ); + }); diff --git a/harness/scene/demo.ts b/harness/scene/demo.ts new file mode 100644 index 00000000..40424ff5 --- /dev/null +++ b/harness/scene/demo.ts @@ -0,0 +1,102 @@ +// Standalone page: the scene full-window with a readout of pass ms and trace steps per pixel, and +// a switch for the cone prepass. Not shipped. +import { + createScene, + Scene, + SceneStats, + SceneUnavailable, + TraceStats, +} from '../../src/app/features/scene/Scene'; +import { LEVELS } from '../../src/app/features/scene/budget'; + +let canvas = document.getElementById('c') as HTMLCanvasElement; +const out = document.getElementById('out') as HTMLPreElement; +const box = document.getElementById('prepass') as HTMLInputElement; +const pull = document.getElementById('pull') as HTMLInputElement; +const half = document.getElementById('half') as HTMLInputElement; +const dev = document.getElementById('dev') as HTMLDetailsElement; +const info = document.getElementById('info') as HTMLPreElement; +const timerless = (() => { + const g = document.createElement('canvas').getContext('webgl2'); + return !g?.getExtension('EXT_disjoint_timer_query_webgl2'); +})(); + +const WHY: Record = { + 'no-webgl2': 'this browser gave no WebGL2 context', + 'no-float-or-half-float-colour-buffer': + 'WebGL2 is there, but neither EXT_color_buffer_float nor EXT_color_buffer_half_float is', +}; + +// What the browser says about itself, read from a scratch context so it shows even when the scene +// could not start. A canvas that never got a WebGL2 context is asked for WebGL1 to name the GPU. +function describeDevice(): string { + const c = document.createElement('canvas'); + const gl2 = c.getContext('webgl2'); + const gl = gl2 ?? (document.createElement('canvas').getContext('webgl') as WebGLRenderingContext | null); + const lines = [`webgl2 context: ${gl2 ? 'yes' : 'NO'}`]; + if (!gl) return [...lines, 'webgl1 context: NO'].join('\n'); + const dbg = gl.getExtension('WEBGL_debug_renderer_info'); + lines.push( + `renderer: ${gl.getParameter(dbg ? dbg.UNMASKED_RENDERER_WEBGL : gl.RENDERER)}`, + `vendor: ${gl.getParameter(dbg ? dbg.UNMASKED_VENDOR_WEBGL : gl.VENDOR)}`, + `version: ${gl.getParameter(gl.VERSION)}`, + `user agent: ${navigator.userAgent}` + ); + const ext = gl.getSupportedExtensions() ?? []; + for (const n of ['EXT_color_buffer_float', 'EXT_color_buffer_half_float', 'OES_texture_float_linear', 'EXT_disjoint_timer_query_webgl2']) + lines.push(`${n}: ${ext.includes(n) ? 'yes' : 'NO'}`); + lines.push(`extensions (${ext.length}): ${ext.join(' ')}`); + return lines.join('\n'); +} +info.textContent = describeDevice(); + +let scene: Scene | null = null; +let stats: SceneStats | undefined; +let trace: TraceStats | undefined; + +function start() { + scene?.dispose(); + stats = trace = undefined; + // A fresh canvas: a context cannot change its attributes, and dispose leaves the old one bound. + const fresh = canvas.cloneNode() as HTMLCanvasElement; + canvas.replaceWith(fresh); + canvas = fresh; + scene = createScene({ + canvas: fresh, + unthrottled: true, + fixedLevel: LEVELS[0], + fixedPull: +pull.value, + prepass: box.checked, + halfStart: half.checked, + onUnavailable: (r) => (out.textContent = `createScene returned null: ${WHY[r]}`), + onCompileFail: (e) => (out.textContent = `createScene returned null: ${e.message}`), + onStats: (s) => (stats = s), + onTraceStats: (t) => (trace = t), + }); + dev.open = !scene; +} + +setInterval(() => { + if (!scene) return; + const lines = [`prepass ${box.checked ? 'on' : 'off'} start target ${scene.startFormat} pull ${pull.value}`]; + if (stats) { + const ms = stats.passMs; + const total = Object.values(ms).reduce((a, b) => a + b, 0); + lines.push( + `gpu ms prepass ${ms.prepass.toFixed(2)} terrain ${ms.terrain.toFixed(2)} atmosphere ${ms.atmosphere.toFixed(2)} resolve ${ms.resolve.toFixed(2)} composite ${ms.composite.toFixed(2)} total ${total.toFixed(2)}`, + `solid ${stats.solidSize.join('x')}` + ); + } else if (timerless) { + lines.push('gpu ms: this browser has no EXT_disjoint_timer_query_webgl2'); + } + if (trace) + lines.push( + `steps/pixel ${trace.stepsPerPixel.toFixed(2)} (pass 1 ${trace.traceSteps.toFixed(2)}, prepass ${trace.prepassSteps.toFixed(1)} per tile)` + ); + out.textContent = lines.join('\n'); +}, 500); + +box.addEventListener('change', start); +half.addEventListener('change', start); +pull.addEventListener('input', start); +start(); diff --git a/harness/scene/drive.cjs b/harness/scene/drive.cjs new file mode 100644 index 00000000..0f34191f --- /dev/null +++ b/harness/scene/drive.cjs @@ -0,0 +1,309 @@ +// Drives the scene in headless Chromium (software GL unless the host has a GPU). +// usage: node drive.cjs [outdir] modes: compile | shots | measure +// Needs playwright from NODE_PATH and PLAYWRIGHT_BROWSERS_PATH. Prints one JSON report. +const { chromium } = require('playwright'); +const path = require('path'); +const fs = require('fs'); +const [, , dir, mode, outdir = '.'] = process.argv; + +(async () => { + const b = await chromium.launch({ + args: [ + '--use-gl=angle', + '--use-angle=swiftshader', + '--enable-unsafe-swiftshader', + '--ignore-gpu-blocklist', + ], + }); + const page = await b.newPage({ viewport: { width: 960, height: 540 } }); + page.on('pageerror', (e) => process.stderr.write(`[pageerror] ${e}\n`)); + page.on('console', (m) => process.stderr.write(`[page] ${m.text()}\n`)); + await page.goto('file://' + path.resolve(dir, 'index.html')); + const report = { mode }; + report.renderer = await page.evaluate(() => { + const g = document.createElement('canvas').getContext('webgl2'); + const e = g.getExtension('WEBGL_debug_renderer_info'); + return g.getParameter(e ? e.UNMASKED_RENDERER_WEBGL : g.RENDERER); + }); + + if (mode === 'compile') { + report.stages = await page.evaluate(() => { + const gl = document.createElement('canvas').getContext('webgl2'); + const S = window.__scene; + return ['terrain', 'atmosphere', 'resolve', 'composite'].map((stage) => { + const t0 = performance.now(); + try { + const p = S.linkStage(gl, stage); + return { + stage, + ok: true, + ms: +(performance.now() - t0).toFixed(1), + sourceBytes: S.fragmentSource(stage).length, + log: gl.getProgramInfoLog(p), + }; + } catch (e) { + return { + stage, + ok: false, + ms: +(performance.now() - t0).toFixed(1), + log: String(e.log || e), + }; + } + }); + }); + } + + if (mode === 'phacelle') { + report.probe = await page.evaluate(() => + window.__probePhacelle( + window.__scene.WORLD.erodeSide.slice(), + window.__scene.WORLD.erodeNorm, + 4 + ) + ); + } + + if (mode === 'shots') { + await page.evaluate(() => { + const c = document.getElementById('c'); + window.__s = window.__scene.createScene({ + canvas: c, + unthrottled: true, + fixedLevel: window.__scene.LEVELS[0], + }); + }); + const wait = (ms) => page.waitForTimeout(ms); + await wait(4000); + await page.screenshot({ path: path.join(outdir, 'rest.png') }); + await page.evaluate(() => window.__s.login()); + await wait(3500); + await page.screenshot({ path: path.join(outdir, 'mid-pullback.png') }); + await wait(9000); + await page.screenshot({ path: path.join(outdir, 'pulled-back.png') }); + report.shots = ['rest.png', 'mid-pullback.png', 'pulled-back.png']; + } + + if (mode === 'measure' || mode === 'variants') { + const VARIANTS = { + baseline: null, + // What a public value-only fbm would cost: the same octaves without the gradient work. + valueOnlyFbm: [ + 'float terrainFbm(vec2 xz) { return cultmath_fbm_grad(vec3(xz * W_FREQ, 0.0), W_OCTAVES, W_LACUNARITY, W_GAIN).w; }', + 'float terrainFbm(vec2 xz) { float a = 1.0, fr = 1.0, v = 0.0; for (int i = 0; i < W_OCTAVES; i++) { v += a * cultmath_snoise(vec3(xz * W_FREQ * fr, 0.0)); fr *= W_LACUNARITY; a *= W_GAIN; } return v; }', + ], + noErosion: ['#define W_ERODE_ENABLED 1', '#define W_ERODE_ENABLED 0'], + // Upper bound on what skipping sky rays would save: every upward ray misses at once. + noSkyMarch: [' bool hit = false;\n bool onTree = false;', ' if (rd.y > 0.0) { o_color = vec4(0.0); o_depth = W_FAR; return; }\n bool hit = false;\n bool onTree = false;'], + noTree: ['float dr = treeDistance(p, leafness);', 'float dr = 1e9; leafness = 0.0;'], + }; + const wanted = mode === "variants" ? (process.env.ONLY ? process.env.ONLY.split(",") : Object.keys(VARIANTS)) : ['baseline']; + const sizes = [ + [960, 540], + [540, 1200], + ]; + report.runs = []; + for (const [w, h] of sizes) { + for (const [li, variant] of wanted.flatMap((v) => + mode === 'variants' + ? [[0, v]] + : [ + [0, v], + [3, v], + ] + )) { + await page.setViewportSize({ width: w, height: h }); + const r = await page.evaluate( + ({ li, rep }) => + new Promise((res) => { + const rows = []; + const c = document.getElementById('c'); + const s = window.__scene.createScene({ + canvas: c, + unthrottled: true, + fixedLevel: window.__scene.LEVELS[li], + patchSource: rep + ? (st, src) => + st === 'terrain' + ? rep[0].startsWith('#define') + ? src.replace(/#define W_ERODE_ENABLED \d/, rep[1]) + : src.split(rep[0]).join(rep[1]) + : src + : undefined, + onStats: (st) => { + rows.push(st); + if (rows.length === 40) { + s.dispose(); + const keep = rows.slice(10); + const med = (k) => + keep.map((x) => x.passMs[k]).sort((a, b) => a - b)[keep.length >> 1]; + res({ + solid: keep[0].solidSize, + atm: keep[0].atmosphereSize, + medianMs: { + terrain: med('terrain'), + atmosphere: med('atmosphere'), + resolve: med('resolve'), + composite: med('composite'), + }, + }); + } + }, + }); + if (!s) res({ error: 'createScene returned null' }); + }), + { li, rep: VARIANTS[variant] } + ); + report.runs.push({ viewport: [w, h], level: li, variant, ...r }); + } + } + } + + if (mode === 'trace') { + // Steps per pixel and pass ms at fixed camera poses, one scene per run. PREPASS=0,1 picks the arms. + const arms = (process.env.PREPASS || '1').split(',').map((x) => x === '1'); + report.runs = []; + for (const [w, h] of process.env.PORTRAIT === '0' + ? [[960, 540]] + : [ + [960, 540], + [540, 1200], + ]) { + for (const pull of (process.env.PULLS || '0,0.5,1').split(',').map(Number)) { + for (const prepass of arms) { + await page.setViewportSize({ width: w, height: h }); + const r = await page.evaluate( + ({ pull, prepass, FRAMES }) => + new Promise((res) => { + const rows = []; + const trace = []; + let done = false; + const finish = () => { + if (done || rows.length < FRAMES || trace.length < 4) return; + done = true; + s.dispose(); + const keep = rows.slice(FRAMES >> 2); + const med = (xs) => xs.sort((a, b) => a - b)[xs.length >> 1]; + const tk = trace.slice(1); + const mean = (k) => tk.reduce((a, t) => a + t[k], 0) / tk.length; + res({ + solid: keep[0].solidSize, + readbacks: tk.length, + traceSteps: +mean('traceSteps').toFixed(2), + prepassSteps: +mean('prepassSteps').toFixed(2), + stepsPerPixel: +mean('stepsPerPixel').toFixed(2), + medianMs: Object.fromEntries( + Object.keys(keep[0].passMs).map((k) => [k, +med(keep.map((x) => x.passMs[k])).toFixed(2)]) + ), + }); + }; + const s = window.__scene.createScene({ + canvas: document.getElementById('c'), + unthrottled: true, + fixedLevel: window.__scene.LEVELS[0], + fixedPull: pull, + prepass, + onTraceStats: (t) => { + trace.push(t); + finish(); + }, + onStats: (st) => { + rows.push(st); + finish(); + }, + }); + if (!s) res({ error: 'createScene returned null' }); + }), + { pull, prepass, FRAMES: +(process.env.FRAMES || 64) } + ); + report.runs.push({ viewport: [w, h], pull, prepass, ...r }); + } + } + } + } + + if (mode === 'diff') { + // Same frozen frame (reduced motion: time 0, 32 frames) with and without the prepass, compared + // pixel by pixel. The prepass is meant to change cost, not the picture. + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.setViewportSize({ width: 640, height: 360 }); + const shoot = async (pull, prepass, steps) => { + await page.evaluate( + ({ pull, prepass, steps }) => { + // Reduced motion bounds the scene to 32 frames; each asks for the next with rAF, so the + // count stops moving when it is done. Timer-query callbacks lag too far to count on. + window.__raf = 0; + if (!window.__rafPatched) { + window.__rafPatched = true; + const raf = window.requestAnimationFrame.bind(window); + window.requestAnimationFrame = (cb) => (window.__raf++, raf(cb)); + } + window.__d = window.__scene.createScene({ + canvas: document.getElementById('c'), + fixedLevel: { ...window.__scene.LEVELS[0], traceSteps: steps }, + fixedPull: pull, + prepass, + }); + }, + { pull, prepass, steps } + ); + for (let last = -1, still = 0; still < 3; await page.waitForTimeout(1000)) { + const n = await page.evaluate(() => window.__raf); + still = n === last ? still + 1 : 0; + last = n; + } + const png = await page.screenshot(); + await page.evaluate(() => window.__d.dispose()); + return png.toString('base64'); + }; + // The step cap makes a grazing ray give up and count as a hit, so the baseline is compared + // with a 512-step render of the same frame as well: whichever is closer to it is the more + // faithful one. + const decode = (x) => + page.evaluate( + (x) => + new Promise((r) => { + const i = new Image(); + i.onload = () => { + const c = document.createElement('canvas'); + c.width = i.width; + c.height = i.height; + const g = c.getContext('2d'); + g.drawImage(i, 0, 0); + r(Array.from(g.getImageData(0, 0, i.width, i.height).data)); + }; + i.src = 'data:image/png;base64,' + x; + }), + x + ); + const compare = (da, db) => { + let max = 0, over = 0, sum = 0; + const px = da.length / 4; + for (let i = 0; i < da.length; i += 4) { + let m = 0; + for (let k = 0; k < 3; k++) m = Math.max(m, Math.abs(da[i + k] - db[i + k])); + sum += m; + if (m > max) max = m; + if (m > 2) over++; + } + return { maxChannelDiff: max, pixelsOver2of255: over, ofPixels: px, meanMaxChannelDiff: +(sum / px).toFixed(3) }; + }; + report.diffs = []; + for (const pull of (process.env.PULLS || '0,0.5,1').split(',').map(Number)) { + const png = { + off: await shoot(pull, false, 96), + on: await shoot(pull, true, 96), + ref: await shoot(pull, false, 512), + off2: await shoot(pull, false, 96), + }; + for (const k of Object.keys(png)) fs.writeFileSync(path.join(outdir, `diff-${k}-${pull}.png`), Buffer.from(png[k], 'base64')); + const [off, on, ref, off2] = [await decode(png.off), await decode(png.on), await decode(png.ref), await decode(png.off2)]; + report.diffs.push({ pull, offVsOffAgain: compare(off, off2), offVsOn: compare(off, on), offVsRef512: compare(off, ref), onVsRef512: compare(on, ref) }); + } + } + + console.log(JSON.stringify(report, null, 1)); + await b.close(); +})().catch((e) => { + console.log('ERR', String(e).slice(0, 2000)); + process.exit(1); +}); diff --git a/harness/scene/entry.ts b/harness/scene/entry.ts new file mode 100644 index 00000000..d43a5453 --- /dev/null +++ b/harness/scene/entry.ts @@ -0,0 +1,87 @@ +// Browser entry for the harness: exposes the scene's seams to the driver. Not shipped. +import { createScene, linkStage, ShaderCompileError } from '../../src/app/features/scene/Scene'; +import { LEVELS } from '../../src/app/features/scene/budget'; +import { fragmentSource } from '../../src/app/features/scene/shaderSource'; +import { WORLD } from '../../src/app/features/scene/world'; + +(window as unknown as Record).__scene = { + createScene, + linkStage, + ShaderCompileError, + LEVELS, + fragmentSource, + WORLD, +}; + +// ---- Phacelle gradient probe ----------------------------------------------------------------- +import cultmath from '../../src/app/features/scene/shaders/CultMath.glsl?raw'; +import phacelle from '../../src/app/features/scene/shaders/CultMath.Phacelle.glsl?raw'; +import { vertexSource } from '../../src/app/features/scene/shaderSource'; + +const PROBE_FRAG = `#version 300 es +precision highp float; precision highp int; +${cultmath} +${phacelle} +uniform float u_seed; uniform vec3 u_side; uniform float u_norm; uniform float u_span; +in vec2 v_uv; layout(location=0) out vec4 o; +void main() { + // The scene samples the z = 0 slice: random points in a u_span square of it. + vec3 h = vec3(cultmath_pcg3d(vec3(gl_FragCoord.xy, u_seed))) ; + vec2 xy = vec2(float(uint(int(h.x)) >> 8), float(uint(int(h.y)) >> 8)) * (1.0 / 16777216.0) * u_span; + CultPhasor ph = cultmath_phacelle(vec3(xy, 0.0), u_side, 0.0, u_norm); + o = vec4(length(ph.cos.xyz), length(ph.sin.xyz), ph.cos.w, ph.sin.w); +}`; + +/** Max |grad| of the phasor over `draws * 1024^2` random points; min/max of the cos component. */ +(window as unknown as Record).__probePhacelle = ( + side: number[], + norm: number, + draws: number +) => { + const c = document.createElement('canvas'); + const gl = c.getContext('webgl2')!; + gl.getExtension('EXT_color_buffer_float'); + const mk = (t: number, s: string) => { + const x = gl.createShader(t)!; + gl.shaderSource(x, s); + gl.compileShader(x); + if (!gl.getShaderParameter(x, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(x)!); + return x; + }; + const p = gl.createProgram()!; + gl.attachShader(p, mk(gl.VERTEX_SHADER, vertexSource)); + gl.attachShader(p, mk(gl.FRAGMENT_SHADER, PROBE_FRAG)); + gl.linkProgram(p); + gl.useProgram(p); + const N = 1024; + const tex = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, tex); + gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA32F, N, N); + const fbo = gl.createFramebuffer(); + gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0); + gl.viewport(0, 0, N, N); + gl.bindVertexArray(gl.createVertexArray()); + gl.uniform3fv(gl.getUniformLocation(p, 'u_side'), side); + gl.uniform1f(gl.getUniformLocation(p, 'u_norm'), norm); + gl.uniform1f(gl.getUniformLocation(p, 'u_span'), 400); + const buf = new Float32Array(N * N * 4); + let gradCos = 0, + gradSin = 0, + vMin = 1e9, + vMax = -1e9, + n = 0; + for (let d = 0; d < draws; d++) { + gl.uniform1f(gl.getUniformLocation(p, 'u_seed'), d + 1); + gl.drawArrays(gl.TRIANGLES, 0, 3); + gl.readPixels(0, 0, N, N, gl.RGBA, gl.FLOAT, buf); + for (let i = 0; i < buf.length; i += 4) { + if (buf[i] > gradCos) gradCos = buf[i]; + if (buf[i + 1] > gradSin) gradSin = buf[i + 1]; + if (buf[i + 2] < vMin) vMin = buf[i + 2]; + if (buf[i + 2] > vMax) vMax = buf[i + 2]; + n++; + } + } + return { samples: n, maxGradCos: gradCos, maxGradSin: gradSin, minCos: vMin, maxCos: vMax }; +}; diff --git a/index.html b/index.html index 2eefeee2..7242f309 100644 --- a/index.html +++ b/index.html @@ -4,21 +4,17 @@ - Cinny - - + Ratatoskr + + - + - - - + + - - + + diff --git a/package-lock.json b/package-lock.json index d61fec51..dc774000 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,10 @@ "@atlaskit/pragmatic-drag-and-drop": "1.1.6", "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0", "@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3", + "@fontsource/ibm-plex-mono": "5.2.5", "@fontsource/inter": "4.5.14", + "@fontsource/montserrat": "5.2.5", + "@fontsource/ubuntu": "5.2.5", "@tanstack/react-query": "5.24.1", "@tanstack/react-query-devtools": "5.24.1", "@tanstack/react-virtual": "3.2.0", @@ -99,7 +102,8 @@ "vite": "5.4.19", "vite-plugin-pwa": "0.20.5", "vite-plugin-static-copy": "1.0.4", - "vite-plugin-top-level-await": "1.4.4" + "vite-plugin-top-level-await": "1.4.4", + "vitest": "1.6.0" }, "engines": { "node": ">=16.0.0" @@ -2188,11 +2192,38 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@fontsource/ibm-plex-mono": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.2.5.tgz", + "integrity": "sha512-G09N3GfuT9qj3Ax2FDZvKqZttzM3v+cco2l8uXamhKyXLdmlaUDH5o88/C3vtTHj2oT7yRKsvxz9F+BXbWKMYA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@fontsource/inter": { "version": "4.5.14", "resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-4.5.14.tgz", "integrity": "sha512-JDC9AocdPLuGsASkvWw9hS5gtHE7K9dOwL98XLrk5yjYqxy4uVnScG58NUvFMJDVJRl/7c8Wnap6PEs+7Zvj1Q==" }, + "node_modules/@fontsource/montserrat": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/@fontsource/montserrat/-/montserrat-5.2.5.tgz", + "integrity": "sha512-/OA+bdg1yqUArDUnEeCqDwgTDiIpvED56TxYskje8UKpsvjl/0A9eNod7IcdHz6/Osi70vo7briRO3la8/u+5Q==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/ubuntu": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/@fontsource/ubuntu/-/ubuntu-5.2.5.tgz", + "integrity": "sha512-VKVFVqmJ9MGnOJW2dsQ982qHN8Zr+tNeEwEHnzt5VdGi9ZWL37wYFD32YsqukWlI/+I7v5ZkgHTEAvsZxU6aDA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@formatjs/ecma402-abstract": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.2.tgz", @@ -2307,6 +2338,19 @@ "@swc/helpers": "^0.5.0" } }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.8", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", @@ -2347,9 +2391,10 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==" + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.25", @@ -4330,6 +4375,13 @@ "url": "https://ko-fi.com/dangreen" } }, + "node_modules/@sinclair/typebox": { + "version": "0.27.12", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz", + "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==", + "dev": true, + "license": "MIT" + }, "node_modules/@surma/rollup-plugin-off-main-thread": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz", @@ -5130,6 +5182,143 @@ "vite": "^4.2.0 || ^5.0.0" } }, + "node_modules/@vitest/expect": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.0.tgz", + "integrity": "sha512-ixEvFVQjycy/oNgHjqsL6AZCDduC+tflRluaHIzKIsdbzkLn2U/iBnVeJwB6HsIjQBdfMR8Z0tRxKUsvFJEeWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "1.6.0", + "@vitest/utils": "1.6.0", + "chai": "^4.3.10" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.0.tgz", + "integrity": "sha512-P4xgwPjwesuBiHisAVz/LSSZtDjOTPYZVmNAnpHHSR6ONrf8eCJOFRvUwdHn30F5M1fxhqtl7QZQUk2dprIXAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "1.6.0", + "p-limit": "^5.0.0", + "pathe": "^1.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/node_modules/p-limit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", + "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@vitest/runner/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/runner/node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@vitest/snapshot": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.6.0.tgz", + "integrity": "sha512-+Hx43f8Chus+DCmygqqfetcAZrDJwvTj0ymqjQq4CvmpKFSTVteEOBzCusu1x2tt4OJcvBflyHUE0DZSLgEMtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/@vitest/snapshot/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/spy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.6.0.tgz", + "integrity": "sha512-leUTap6B/cqi/bQkXUu6bQV5TZPx7pmMBKBQiI0rJA8c3pB56ZsaTbREnF7CJfmvAS4V2cXIBAh/3rVwrrCYgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^2.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.6.0.tgz", + "integrity": "sha512-21cPiuGMoMZwiOHa2i4LXkMkMkCGzA+MVFV70jRwHo95dL4x/ts5GZhML1QWuy7yfp3WzK3lRvZi3JnXTYqrBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "diff-sequences": "^29.6.3", + "estree-walker": "^3.0.3", + "loupe": "^2.3.7", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", @@ -5156,6 +5345,19 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -5448,6 +5650,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/ast-types-flow": { "version": "0.0.7", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.7.tgz", @@ -5857,6 +6069,25 @@ "node": ">=6" } }, + "node_modules/chai": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", + "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.1.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -5879,6 +6110,19 @@ "dev": true, "license": "MIT" }, + "node_modules/check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -6614,6 +6858,19 @@ } } }, + "node_modules/deep-eql": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", + "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -6715,6 +6972,16 @@ "node": ">=8" } }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -7638,6 +7905,85 @@ "node": ">=0.8.x" } }, + "node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/execa/node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/execa/node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/execa/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/expand-tilde": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/expand-tilde/-/expand-tilde-2.0.2.tgz", @@ -8078,6 +8424,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/get-intrinsic": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.7.tgz", @@ -8121,6 +8477,19 @@ "node": ">= 0.4" } }, + "node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-symbol-description": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", @@ -8525,6 +8894,16 @@ "node": ">= 6" } }, + "node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.17.0" + } + }, "node_modules/husky": { "version": "9.1.7", "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", @@ -9719,6 +10098,23 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/local-pkg": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz", + "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.3", + "pkg-types": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -10051,6 +10447,16 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.1" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -10173,6 +10579,13 @@ "dev": true, "license": "MIT" }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -10439,6 +10852,35 @@ "node": ">=0.10.0" } }, + "node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/npmlog": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", @@ -10830,6 +11272,16 @@ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.2.tgz", "integrity": "sha512-15Ztpk+nov8DR524R4BF7uEuzESgzUEAV4Ah7CUMNGXdE5ELuvxElxGXndBl32vMSsWa1jpNf22Z+Er3sKwq+w==" }, + "node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/pdfjs-dist": { "version": "4.2.67", "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-4.2.67.tgz", @@ -10971,6 +11423,41 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, "node_modules/prismjs": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", @@ -11993,6 +12480,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -12220,6 +12714,20 @@ "deprecated": "Please use @jridgewell/sourcemap-codec instead", "dev": true }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -12383,6 +12891,19 @@ "node": ">=10" } }, + "node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -12395,6 +12916,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-literal": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz", + "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/style-to-js": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.3.tgz", @@ -12526,6 +13067,13 @@ "integrity": "sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", @@ -12553,6 +13101,26 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinypool": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz", + "integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", + "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tmp": { "version": "0.0.33", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", @@ -12646,6 +13214,16 @@ "node": ">= 0.8.0" } }, + "node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/type-fest": { "version": "0.16.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz", @@ -13474,6 +14052,89 @@ "@esbuild/win32-x64": "0.21.5" } }, + "node_modules/vitest": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.0.tgz", + "integrity": "sha512-H5r/dN06swuFnzNFhq/dnz37bPXnq8xB2xB5JOVk8K09rUtoeNN+LHWkoQ0A/i3hvbUKKcCei9KpbxqHMLhLLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "1.6.0", + "@vitest/runner": "1.6.0", + "@vitest/snapshot": "1.6.0", + "@vitest/spy": "1.6.0", + "@vitest/utils": "1.6.0", + "acorn-walk": "^8.3.2", + "chai": "^4.3.10", + "debug": "^4.3.4", + "execa": "^8.0.1", + "local-pkg": "^0.5.0", + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "tinybench": "^2.5.1", + "tinypool": "^0.8.3", + "vite": "^5.0.0", + "vite-node": "1.6.0", + "why-is-node-running": "^2.2.2" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "1.6.0", + "@vitest/ui": "1.6.0", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/vitest/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, "node_modules/void-elements": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", @@ -13607,6 +14268,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wide-align": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", diff --git a/package.json b/package.json index 8166ccd8..298fbf36 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "typecheck": "tsc --noEmit", "prepare": "husky install", "commit": "git-cz", - "bump": "node scripts/update-version.js" + "bump": "node scripts/update-version.js", + "test": "vitest run" }, "lint-staged": { "*.{ts,tsx,js,jsx}": "eslint", @@ -87,7 +88,10 @@ "slate-dom": "0.123.0", "slate-history": "0.113.1", "slate-react": "0.123.0", - "ua-parser-js": "1.0.35" + "ua-parser-js": "1.0.35", + "@fontsource/ubuntu": "5.2.5", + "@fontsource/montserrat": "5.2.5", + "@fontsource/ibm-plex-mono": "5.2.5" }, "devDependencies": { "@element-hq/element-call-embedded": "0.20.1", @@ -123,6 +127,7 @@ "vite": "5.4.19", "vite-plugin-pwa": "0.20.5", "vite-plugin-static-copy": "1.0.4", - "vite-plugin-top-level-await": "1.4.4" + "vite-plugin-top-level-await": "1.4.4", + "vitest": "1.6.0" } } diff --git a/public/favicon.ico b/public/favicon.ico index 91671306..cfce9943 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ diff --git a/public/manifest.json b/public/manifest.json index acdcd004..81fd714c 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -1,7 +1,7 @@ { - "name": "Cinny", - "short_name": "Cinny", - "description": "Yet another matrix client", + "name": "Ratatoskr", + "short_name": "Ratatoskr", + "description": "GameCult's Matrix client", "dir": "auto", "lang": "en-US", "display": "standalone", diff --git a/public/res/android/android-chrome-144x144.png b/public/res/android/android-chrome-144x144.png index 8c8c2668..d76ed356 100644 Binary files a/public/res/android/android-chrome-144x144.png and b/public/res/android/android-chrome-144x144.png differ diff --git a/public/res/android/android-chrome-192x192.png b/public/res/android/android-chrome-192x192.png index 6d53edb5..7689b6d0 100644 Binary files a/public/res/android/android-chrome-192x192.png and b/public/res/android/android-chrome-192x192.png differ diff --git a/public/res/android/android-chrome-256x256.png b/public/res/android/android-chrome-256x256.png index 57378b41..2f61f46b 100644 Binary files a/public/res/android/android-chrome-256x256.png and b/public/res/android/android-chrome-256x256.png differ diff --git a/public/res/android/android-chrome-36x36.png b/public/res/android/android-chrome-36x36.png index 507c4439..91b26af2 100644 Binary files a/public/res/android/android-chrome-36x36.png and b/public/res/android/android-chrome-36x36.png differ diff --git a/public/res/android/android-chrome-384x384.png b/public/res/android/android-chrome-384x384.png index 92fd32c7..ab06a074 100644 Binary files a/public/res/android/android-chrome-384x384.png and b/public/res/android/android-chrome-384x384.png differ diff --git a/public/res/android/android-chrome-48x48.png b/public/res/android/android-chrome-48x48.png index 489cfd32..f8fafcd9 100644 Binary files a/public/res/android/android-chrome-48x48.png and b/public/res/android/android-chrome-48x48.png differ diff --git a/public/res/android/android-chrome-512x512.png b/public/res/android/android-chrome-512x512.png index d312b532..2c584f3d 100644 Binary files a/public/res/android/android-chrome-512x512.png and b/public/res/android/android-chrome-512x512.png differ diff --git a/public/res/android/android-chrome-72x72.png b/public/res/android/android-chrome-72x72.png index 8e04930c..32d5d516 100644 Binary files a/public/res/android/android-chrome-72x72.png and b/public/res/android/android-chrome-72x72.png differ diff --git a/public/res/android/android-chrome-96x96.png b/public/res/android/android-chrome-96x96.png index 44981d96..5da68e4b 100644 Binary files a/public/res/android/android-chrome-96x96.png and b/public/res/android/android-chrome-96x96.png differ diff --git a/public/res/apple/apple-touch-icon-114x114.png b/public/res/apple/apple-touch-icon-114x114.png index 418f8833..1ff43a12 100644 Binary files a/public/res/apple/apple-touch-icon-114x114.png and b/public/res/apple/apple-touch-icon-114x114.png differ diff --git a/public/res/apple/apple-touch-icon-120x120.png b/public/res/apple/apple-touch-icon-120x120.png index 1d570eba..d10efb31 100644 Binary files a/public/res/apple/apple-touch-icon-120x120.png and b/public/res/apple/apple-touch-icon-120x120.png differ diff --git a/public/res/apple/apple-touch-icon-144x144.png b/public/res/apple/apple-touch-icon-144x144.png index b6a731eb..d76ed356 100644 Binary files a/public/res/apple/apple-touch-icon-144x144.png and b/public/res/apple/apple-touch-icon-144x144.png differ diff --git a/public/res/apple/apple-touch-icon-152x152.png b/public/res/apple/apple-touch-icon-152x152.png index 968ae2f0..76590ddf 100644 Binary files a/public/res/apple/apple-touch-icon-152x152.png and b/public/res/apple/apple-touch-icon-152x152.png differ diff --git a/public/res/apple/apple-touch-icon-167x167.png b/public/res/apple/apple-touch-icon-167x167.png index a41d0d10..3f26f91b 100644 Binary files a/public/res/apple/apple-touch-icon-167x167.png and b/public/res/apple/apple-touch-icon-167x167.png differ diff --git a/public/res/apple/apple-touch-icon-180x180.png b/public/res/apple/apple-touch-icon-180x180.png index a651cd1c..ed498370 100644 Binary files a/public/res/apple/apple-touch-icon-180x180.png and b/public/res/apple/apple-touch-icon-180x180.png differ diff --git a/public/res/apple/apple-touch-icon-57x57.png b/public/res/apple/apple-touch-icon-57x57.png index f5ff2ea5..2e2081f5 100644 Binary files a/public/res/apple/apple-touch-icon-57x57.png and b/public/res/apple/apple-touch-icon-57x57.png differ diff --git a/public/res/apple/apple-touch-icon-60x60.png b/public/res/apple/apple-touch-icon-60x60.png index 47fafb07..87c8fc2b 100644 Binary files a/public/res/apple/apple-touch-icon-60x60.png and b/public/res/apple/apple-touch-icon-60x60.png differ diff --git a/public/res/apple/apple-touch-icon-72x72.png b/public/res/apple/apple-touch-icon-72x72.png index 6e5b13c4..32d5d516 100644 Binary files a/public/res/apple/apple-touch-icon-72x72.png and b/public/res/apple/apple-touch-icon-72x72.png differ diff --git a/public/res/apple/apple-touch-icon-76x76.png b/public/res/apple/apple-touch-icon-76x76.png index 7799a6fd..1164b72d 100644 Binary files a/public/res/apple/apple-touch-icon-76x76.png and b/public/res/apple/apple-touch-icon-76x76.png differ diff --git a/public/res/svg/cinny-highlight.svg b/public/res/svg/cinny-highlight.svg deleted file mode 100644 index d59b42a1..00000000 --- a/public/res/svg/cinny-highlight.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/public/res/svg/cinny-unread.svg b/public/res/svg/cinny-unread.svg deleted file mode 100644 index be301375..00000000 --- a/public/res/svg/cinny-unread.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/public/res/svg/cinny.svg b/public/res/svg/cinny.svg deleted file mode 100644 index 8701d67b..00000000 --- a/public/res/svg/cinny.svg +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - - \ No newline at end of file diff --git a/public/res/svg/ratatoskr-highlight.svg b/public/res/svg/ratatoskr-highlight.svg new file mode 100644 index 00000000..661442d4 --- /dev/null +++ b/public/res/svg/ratatoskr-highlight.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/public/res/svg/ratatoskr-unread.svg b/public/res/svg/ratatoskr-unread.svg new file mode 100644 index 00000000..69a08c95 --- /dev/null +++ b/public/res/svg/ratatoskr-unread.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/public/res/svg/ratatoskr.svg b/public/res/svg/ratatoskr.svg new file mode 100644 index 00000000..3ca98122 --- /dev/null +++ b/public/res/svg/ratatoskr.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/scripts/gen-brand.mjs b/scripts/gen-brand.mjs new file mode 100644 index 00000000..fa44bfde --- /dev/null +++ b/scripts/gen-brand.mjs @@ -0,0 +1,202 @@ +// Draws the Ratatoskr squirrel once, as shapes, and writes the SVG logos plus the +// favicon and app-icon PNGs from the same shapes (no image tools needed). +// Run: node scripts/gen-brand.mjs +import { deflateSync } from 'node:zlib'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const BG = '#1c1b22'; + +// Squirrel facing right on a 64x64 grid. Later shapes draw over earlier ones. +const squirrel = [ + { t: 'ellipse', cx: 19, cy: 33, rx: 13, ry: 22, rot: -15, fill: '#d9742b' }, + { t: 'ellipse', cx: 21, cy: 33, rx: 7, ry: 15, rot: -15, fill: '#f0a45d' }, + { t: 'ellipse', cx: 36, cy: 43, rx: 11, ry: 14, rot: 0, fill: '#e0833a' }, + { t: 'ellipse', cx: 40, cy: 46, rx: 6, ry: 9, rot: 0, fill: '#f6dcb8' }, + { + t: 'poly', + pts: [ + [35, 19], + [36, 7], + [43, 15], + ], + fill: '#c9672a', + }, + { + t: 'poly', + pts: [ + [44, 15], + [50, 7], + [52, 19], + ], + fill: '#c9672a', + }, + { t: 'circle', cx: 43, cy: 25, r: 10, fill: '#e0833a' }, + { t: 'ellipse', cx: 47, cy: 29, rx: 5, ry: 3.5, rot: 0, fill: '#f6dcb8' }, + { t: 'circle', cx: 46.5, cy: 23.5, r: 1.8, fill: '#1a1a1a' }, + { t: 'circle', cx: 47, cy: 23, r: 0.6, fill: '#ffffff' }, + { t: 'circle', cx: 52, cy: 27.5, r: 1.5, fill: '#1a1a1a' }, + { t: 'ellipse', cx: 33, cy: 56, rx: 5, ry: 2.5, rot: 0, fill: '#c9672a' }, + { t: 'ellipse', cx: 44, cy: 56, rx: 4.5, ry: 2.5, rot: 0, fill: '#c9672a' }, +]; +const badge = (fill) => [ + { t: 'circle', cx: 52, cy: 52, r: 11, fill: '#ffffff' }, + { t: 'circle', cx: 52, cy: 52, r: 8, fill }, +]; +const background = [{ t: 'rect', x: 0, y: 0, w: 64, h: 64, fill: BG }]; + +const f = (n) => +n.toFixed(2); +const svgOf = (shapes) => + shapes + .map((s) => { + if (s.t === 'circle') + return ``; + if (s.t === 'rect') return ``; + if (s.t === 'poly') + return ``; + return ``; + }) + .join('\n'); +const writeSvg = (name, shapes) => + writeFileSync( + join(root, 'public/res/svg', name), + `\n${svgOf( + shapes + )}\n\n` + ); + +const hex = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16)); +const inside = (s, x, y) => { + if (s.t === 'rect') return x >= s.x && x < s.x + s.w && y >= s.y && y < s.y + s.h; + if (s.t === 'circle') return (x - s.cx) ** 2 + (y - s.cy) ** 2 <= s.r ** 2; + if (s.t === 'ellipse') { + const a = (-s.rot * Math.PI) / 180; + const dx = x - s.cx; + const dy = y - s.cy; + const u = dx * Math.cos(a) - dy * Math.sin(a); + const v = dx * Math.sin(a) + dy * Math.cos(a); + return (u / s.rx) ** 2 + (v / s.ry) ** 2 <= 1; + } + let c = false; + for (let i = 0, j = s.pts.length - 1; i < s.pts.length; j = i++) { + const [xi, yi] = s.pts[i]; + const [xj, yj] = s.pts[j]; + if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) c = !c; + } + return c; +}; + +const crcTable = Array.from({ length: 256 }, (_, n) => { + let c = n; + for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + return c >>> 0; +}); +const crc = (b) => { + let c = 0xffffffff; + for (const x of b) c = crcTable[(c ^ x) & 255] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +}; +const chunk = (type, data) => { + const out = Buffer.alloc(12 + data.length); + out.writeUInt32BE(data.length, 0); + out.write(type, 4, 'ascii'); + data.copy(out, 8); + out.writeUInt32BE(crc(out.subarray(4, 8 + data.length)), 8 + data.length); + return out; +}; + +// Rasterise with 4x4 supersampling; `scale` shrinks the drawing about the centre. +const png = (shapes, size, scale = 1) => { + const rgba = Buffer.alloc(size * size * 4); + const SS = 4; + const colours = shapes.map((s) => hex(s.fill)); + for (let py = 0; py < size; py += 1) { + for (let px = 0; px < size; px += 1) { + let r = 0; + let g = 0; + let b = 0; + let a = 0; + for (let sy = 0; sy < SS; sy += 1) { + for (let sx = 0; sx < SS; sx += 1) { + const gx = ((px + (sx + 0.5) / SS) / size) * 64; + const gy = ((py + (sy + 0.5) / SS) / size) * 64; + // the background rect covers the whole canvas and ignores `scale` + let hit = -1; + shapes.forEach((s, i) => { + const x = s.t === 'rect' ? gx : (gx - 32) / scale + 32; + const y = s.t === 'rect' ? gy : (gy - 32) / scale + 32; + if (inside(s, x, y)) hit = i; + }); + if (hit >= 0) { + [r, g, b] = [r + colours[hit][0], g + colours[hit][1], b + colours[hit][2]]; + a += 1; + } + } + } + const o = (py * size + px) * 4; + if (a > 0) { + rgba[o] = Math.round(r / a); + rgba[o + 1] = Math.round(g / a); + rgba[o + 2] = Math.round(b / a); + } + rgba[o + 3] = Math.round((a / (SS * SS)) * 255); + } + } + const raw = Buffer.alloc(size * (size * 4 + 1)); + for (let y = 0; y < size; y += 1) { + raw[y * (size * 4 + 1)] = 0; + rgba.copy(raw, y * (size * 4 + 1) + 1, y * size * 4, (y + 1) * size * 4); + } + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(size, 0); + ihdr.writeUInt32BE(size, 4); + ihdr.set([8, 6, 0, 0, 0], 8); + return Buffer.concat([ + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), + chunk('IHDR', ihdr), + chunk('IDAT', deflateSync(raw)), + chunk('IEND', Buffer.alloc(0)), + ]); +}; + +// ICO container holding PNG images. +const ico = (images) => { + const head = Buffer.alloc(6 + images.length * 16); + head.writeUInt16LE(1, 2); + head.writeUInt16LE(images.length, 4); + let offset = head.length; + images.forEach(({ size, data }, i) => { + const e = 6 + i * 16; + head[e] = size; + head[e + 1] = size; + head.writeUInt16LE(1, e + 4); + head.writeUInt16LE(32, e + 6); + head.writeUInt32LE(data.length, e + 8); + head.writeUInt32LE(offset, e + 12); + offset += data.length; + }); + return Buffer.concat([head, ...images.map((i) => i.data)]); +}; + +writeSvg('ratatoskr.svg', squirrel); +writeSvg('ratatoskr-unread.svg', [...squirrel, ...badge('#989898')]); +writeSvg('ratatoskr-highlight.svg', [...squirrel, ...badge('#45B83B')]); + +const icon = [...background, ...squirrel]; +const put = (dir, name, size) => { + mkdirSync(join(root, 'public/res', dir), { recursive: true }); + writeFileSync(join(root, 'public/res', dir, name), png(icon, size, 0.82)); +}; +[36, 48, 72, 96, 144, 192, 256, 384, 512].forEach((s) => + put('android', `android-chrome-${s}x${s}.png`, s) +); +[57, 60, 72, 76, 114, 120, 144, 152, 167, 180].forEach((s) => + put('apple', `apple-touch-icon-${s}x${s}.png`, s) +); +writeFileSync( + join(root, 'public/favicon.ico'), + ico([16, 32, 48].map((size) => ({ size, data: png(icon, size, 0.9) }))) +); +console.log('wrote svg logos, favicon.ico, android and apple icons'); diff --git a/src/app/components/room-card/RoomCard.tsx b/src/app/components/room-card/RoomCard.tsx index 34a7e24b..64aaaacb 100644 --- a/src/app/components/room-card/RoomCard.tsx +++ b/src/app/components/room-card/RoomCard.tsx @@ -1,5 +1,5 @@ import React, { ReactNode, useCallback, useRef, useState } from 'react'; -import { MatrixError, Room } from 'matrix-js-sdk'; +import { MatrixError } from 'matrix-js-sdk'; import { Avatar, Badge, @@ -31,6 +31,7 @@ import { RoomType, StateEvent } from '../../../types/matrix/room'; import { useJoinedRoomId } from '../../hooks/useJoinedRoomId'; import { useElementSizeObserver } from '../../hooks/useElementSizeObserver'; import { getRoomAvatarUrl, getStateEvent } from '../../utils/room'; +import { joinEstateRoomOrThrow } from '../../features/estate/joinEstateRoom'; import { useStateEventCallback } from '../../hooks/useStateEventCallback'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; @@ -197,8 +198,10 @@ export const RoomCard = as<'div', RoomCardProps>( ) ); - const [joinState, join] = useAsyncCallback( - useCallback(() => mx.joinRoom(roomIdOrAlias, { viaServers }), [mx, roomIdOrAlias, viaServers]) + const [joinState, join] = useAsyncCallback( + useCallback(async () => { + await joinEstateRoomOrThrow(mx, roomIdOrAlias, viaServers); + }, [mx, roomIdOrAlias, viaServers]) ); const joining = joinState.status === AsyncStatus.Loading || joinState.status === AsyncStatus.Success; diff --git a/src/app/components/room-intro/RoomIntro.tsx b/src/app/components/room-intro/RoomIntro.tsx index ce550992..2e4e3ebc 100644 --- a/src/app/components/room-intro/RoomIntro.tsx +++ b/src/app/components/room-intro/RoomIntro.tsx @@ -17,6 +17,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useSetting } from '../../state/hooks/settings'; import { settingsAtom } from '../../state/settings'; import { InviteUserPrompt } from '../invite-user-prompt'; +import { joinEstateRoomOrThrow } from '../../features/estate/joinEstateRoom'; export type RoomIntroProps = { room: Room; @@ -43,7 +44,7 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) => const prevRoomId = createContent?.predecessor?.room_id; const [prevRoomState, joinPrevRoom] = useAsyncCallback( - useCallback(async (roomId: string) => mx.joinRoom(roomId), [mx]) + useCallback((roomId: string) => joinEstateRoomOrThrow(mx, roomId), [mx]) ); const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); diff --git a/src/app/components/splash-screen/SplashScreen.tsx b/src/app/components/splash-screen/SplashScreen.tsx index 27adadba..8e8f05b6 100644 --- a/src/app/components/splash-screen/SplashScreen.tsx +++ b/src/app/components/splash-screen/SplashScreen.tsx @@ -21,7 +21,7 @@ export function SplashScreen({ children }: SplashScreenProps) { justifyContent="Center" > - Cinny + Ratatoskr diff --git a/src/app/features/estate/EstateNavigator.css.ts b/src/app/features/estate/EstateNavigator.css.ts new file mode 100644 index 00000000..157d69cd --- /dev/null +++ b/src/app/features/estate/EstateNavigator.css.ts @@ -0,0 +1,14 @@ +import { globalStyle, style } from '@vanilla-extract/css'; +import { MOBILE_BREAKPOINT } from '../../hooks/useScreenSize'; + +/** On phones every control inside the navigator is at least 44 px square to tap. */ +export const Targets = style({}); + +globalStyle(`${Targets} button, ${Targets} a`, { + '@media': { + [`screen and (max-width: ${MOBILE_BREAKPOINT}px)`]: { + minHeight: 44, + minWidth: 44, + }, + }, +}); diff --git a/src/app/features/estate/EstateNavigator.tsx b/src/app/features/estate/EstateNavigator.tsx new file mode 100644 index 00000000..6fa4bf48 --- /dev/null +++ b/src/app/features/estate/EstateNavigator.tsx @@ -0,0 +1,326 @@ +import React, { MouseEventHandler, ReactNode, useCallback, useMemo, useRef } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAtom, useAtomValue, useSetAtom } from 'jotai'; +import { Avatar, Box, Chip, Icon, IconButton, Icons, Spinner, Text, color } from 'folds'; +import { PageNav, PageNavContent, PageNavHeader } from '../../components/page'; +import { + NavButton, + NavCategory, + NavCategoryHeader, + NavItem, + NavItemContent, +} from '../../components/nav'; +import { UseStateProvider } from '../../components/UseStateProvider'; +import { JoinAddressPrompt } from '../../components/join-address-prompt'; +import { RoomNavCategoryButton, RoomNavItem } from '../room-nav'; +import { InboxTab, SettingsTab, UnverifiedTab } from '../../pages/client/sidebar'; +import { + encodeSearchParamValueArray, + getDirectRoomPath, + getHomeCreatePath, + getHomeRoomPath, + getSpaceRoomPath, + withSearchParam, +} from '../../pages/pathUtils'; +import { _RoomSearchParams } from '../../pages/paths'; +import { searchModalAtom } from '../../state/searchModal'; +import { getCanonicalAliasOrRoomId } from '../../utils/matrix'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { useSelectedRoom } from '../../hooks/router/useSelectedRoom'; +import { useCallEmbed } from '../../hooks/useCallEmbed'; +import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; +import { + getRoomNotificationMode, + useRoomsNotificationPreferencesContext, +} from '../../hooks/useRoomsNotificationPreferences'; +import { useClosedNavCategoriesAtom } from '../../state/hooks/closedNavCategories'; +import { makeNavCategoryId } from '../../state/closedNavCategories'; +import { roomToUnreadAtom } from '../../state/room/roomToUnread'; +import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; +import { EstateRoom, EstateTier } from './buildEstate'; +import { activateEstateRoom, awaitMyJoin } from './joinEstateRoom'; +import { useEstate, useEstateRootId } from './useEstate'; +import * as css from './EstateNavigator.css'; + +/** How long a tap waits for the client store to hold the room it just joined. */ +const JOIN_ARRIVAL_TIMEOUT_MS = 20000; + +const estateRoomPath = (mx: ReturnType, room: EstateRoom): string => { + const alias = getCanonicalAliasOrRoomId(mx, room.roomId); + if (room.direct) return getDirectRoomPath(alias); + // A Space the user has not joined has no Space route; the room still resolves from Home. + if (room.parentId && mx.getRoom(room.parentId)?.getMyMembership() === 'join') { + return getSpaceRoomPath(getCanonicalAliasOrRoomId(mx, room.parentId), alias); + } + return getHomeRoomPath(alias); +}; + +function JoinRow({ room }: { room: EstateRoom }) { + const mx = useMatrixClient(); + const navigate = useNavigate(); + const [state, join] = useAsyncCallback( + useCallback(async () => { + const result = await activateEstateRoom(mx, room); + if (result.kind === 'refused') throw new Error(result.message); + // joinRoom resolves before the store holds the room; opening it now shows the invite card. + if (!(await awaitMyJoin(mx, room.roomId, JOIN_ARRIVAL_TIMEOUT_MS))) { + // The join is accepted; when the room arrives the row becomes a joined row by itself. + throw new Error('Joined. The room is still on its way and will open from this list.'); + } + navigate(estateRoomPath(mx, room)); + }, [mx, navigate, room]) + ); + const busy = state.status === AsyncStatus.Loading; + + return ( + + + + + {room.name} + + + {state.status === AsyncStatus.Error && ( + + {(state.error as Error).message} + + )} + : } + onClick={join} + disabled={busy} + aria-label={`Join ${room.name}`} + > + Join + + + + ); +} + +function EstateRow({ room }: { room: EstateRoom }) { + const mx = useMatrixClient(); + const selectedRoomId = useSelectedRoom(); + const notificationPreferences = useRoomsNotificationPreferencesContext(); + const mxRoom = room.state === 'joined' ? mx.getRoom(room.roomId) : null; + + if (!mxRoom) return ; + return ( + + ); +} + +type CollapsibleProps = { + categoryId: string; + label: string; + rooms: EstateRoom[]; + children: ReactNode; +}; +function Collapsible({ categoryId, label, rooms, children }: CollapsibleProps) { + const mobile = useScreenSizeContext() === ScreenSize.Mobile; + const selectedRoomId = useSelectedRoom(); + const roomToUnread = useAtomValue(roomToUnreadAtom); + const callRoomId = useCallEmbed()?.roomId; + const [toggled, setToggled] = useAtom(useClosedNavCategoriesAtom()); + + const active = rooms.some( + (r) => r.roomId === selectedRoomId || r.roomId === callRoomId || roomToUnread.has(r.roomId) + ); + // Phones start collapsed unless the section holds something live; wide screens start open. + // A tap flips that default; the flip is remembered per device and per screen class. + const key = makeNavCategoryId('estate', mobile ? 'phone' : 'wide', categoryId); + const closed = (mobile && !active) !== toggled.has(key); + + const handleClick: MouseEventHandler = () => + setToggled({ type: toggled.has(key) ? 'DELETE' : 'PUT', categoryId: key }); + + return ( + + + + {label} + + + {!closed && children} + + ); +} + +function Tier({ tier }: { tier: EstateTier }) { + const allRooms = useMemo(() => [...tier.loose, ...tier.groups.flatMap((g) => g.rooms)], [tier]); + return ( + + {tier.loose.map((r) => ( + + ))} + {tier.groups.map((g) => ( + + {g.rooms.map((r) => ( + + ))} + + ))} + + ); +} + +function EntryPoints() { + const navigate = useNavigate(); + return ( + + + navigate(getHomeCreatePath())}> + + + + + + + Create Room + + + + + + + {(open, setOpen) => ( + <> + + setOpen(true)}> + + + + + + + Join with Address + + + + + + {open && ( + setOpen(false)} + onOpen={(roomIdOrAlias, viaServers, eventId) => { + setOpen(false); + const path = getHomeRoomPath(roomIdOrAlias, eventId); + navigate( + viaServers + ? withSearchParam<_RoomSearchParams>(path, { + viaServers: encodeSearchParamValueArray(viaServers), + }) + : path + ); + }} + /> + )} + + )} + + + ); +} + +/** A short message that wraps inside the navigator; never a raw request error. */ +function EstateError({ message }: { message: string }) { + return ( + + {message} + + ); +} + +function EstateTiers({ rootId }: { rootId: string }) { + const { fetching, error, tiers } = useEstate(rootId); + + return ( + <> + {tiers.map((tier) => ( + + ))} + {fetching && ( + + + + )} + {error && ( + + )} + + ); +} + +/** The single navigator: every nav slot that lists rooms mounts this. */ +export function EstateNavigator() { + const openRoomFinder = useSetAtom(searchModalAtom); + const { rootId, error } = useEstateRootId(); + const scrollRef = useRef(null); + const mx = useMatrixClient(); + const title = (rootId && mx.getRoom(rootId)?.name) || 'GameCult'; + + return ( + + + + + + {title} + + + openRoomFinder(true)} + > + + + + + + + + {rootId && } + {!rootId && error && ( + + )} + {!rootId && !error && ( + + + + )} + + + + + + + + + ); +} diff --git a/src/app/features/estate/buildEstate.test.ts b/src/app/features/estate/buildEstate.test.ts new file mode 100644 index 00000000..8f898a0f --- /dev/null +++ b/src/app/features/estate/buildEstate.test.ts @@ -0,0 +1,326 @@ +import { describe, expect, it } from 'vitest'; +import { buildEstate, EstateHierarchyRoom, EstateInput, stewardOfChild } from './buildEstate'; + +const ROOT = '!root:gamecult.org'; +const PROJECTS = '!projects:gamecult.org'; + +/** A Space whose child entries may carry the steward Mind, as the accounts script writes it. */ +const space = ( + id: string, + name: string, + children: string[], + stewards: Record = {} +): EstateHierarchyRoom => ({ + room_id: id, + name, + room_type: 'm.space', + children_state: children.map((c) => ({ + state_key: c, + content: { + via: ['gamecult.org'], + ...(stewards[c] ? { 'org.gamecult.steward': { mind: stewards[c], repo: 'x' } } : {}), + }, + })), +}); +const room = (id: string, name: string, alias?: string): EstateHierarchyRoom => ({ + room_id: id, + name, + canonical_alias: alias, +}); + +const hierarchy = [ + space(ROOT, 'GameCult', ['!general:gamecult.org', PROJECTS]), + room('!general:gamecult.org', 'General', '#general:gamecult.org'), + space( + PROJECTS, + 'Projects', + ['!cultlib:gamecult.org', '!aeth:gamecult.org', '!misc:gamecult.org'], + { '!cultlib:gamecult.org': 'eureka', '!aeth:gamecult.org': 'void' } + ), + room('!cultlib:gamecult.org', 'CultLib', '#cultlib:gamecult.org'), + room('!aeth:gamecult.org', 'Aetheria', '#Aetheria:gamecult.org'), + room('!misc:gamecult.org', 'Misc', '#misc:gamecult.org'), +]; + +const input = (over: Partial = {}): EstateInput => ({ + rootId: ROOT, + hierarchy, + membership: new Map(), + directRoomIds: [], + ...over, +}); + +const find = (tiers: ReturnType, roomId: string) => + tiers + .flatMap((t) => [...t.loose, ...t.groups.flatMap((g) => g.rooms)]) + .find((r) => r.roomId === roomId); + +describe('buildEstate', () => { + it('lists an invited project room as invited', () => { + const tiers = buildEstate( + input({ membership: new Map([['!cultlib:gamecult.org', 'invite']]) }) + ); + expect(find(tiers, '!cultlib:gamecult.org')?.state).toBe('invited'); + }); + + it('lists a hierarchy room the user has not joined as joinable, beside joined ones', () => { + const tiers = buildEstate(input({ membership: new Map([['!general:gamecult.org', 'join']]) })); + expect(find(tiers, '!general:gamecult.org')?.state).toBe('joined'); + expect(find(tiers, '!aeth:gamecult.org')?.state).toBe('joinable'); + expect(find(tiers, '!aeth:gamecult.org')?.via).toEqual(['gamecult.org']); + }); + + it('omits a room absent from the hierarchy even when the client store knows it', () => { + const hidden = '!estate-secret:gamecult.org'; + const tiers = buildEstate( + input({ + membership: new Map([[hidden, 'join']]), + // The root names the room as a child, but the server did not return it. + hierarchy: [ + space(ROOT, 'GameCult', ['!general:gamecult.org', PROJECTS, hidden]), + ...hierarchy.slice(1), + ], + }) + ); + expect(find(tiers, hidden)).toBeUndefined(); + }); + + it('lists every room the server returned, whatever the user may join', () => { + const tiers = buildEstate(input()); + expect(tiers.flatMap((t) => [...t.loose, ...t.groups.flatMap((g) => g.rooms)])).toHaveLength(4); + }); + + it('groups rooms by steward Mind from the steward on each Space child entry', () => { + const projects = buildEstate(input()).find((t) => t.id === PROJECTS)!; + expect(projects.groups.map((g) => [g.mind, g.rooms.map((r) => r.roomId)])).toEqual([ + ['eureka', ['!cultlib:gamecult.org']], + ['void', ['!aeth:gamecult.org']], + ]); + }); + + it('sorts steward groups by display label', () => { + const projects = buildEstate( + input({ + mindLabels: new Map([ + ['eureka', 'Zed'], + ['void', 'Alpha'], + ]), + }) + ).find((t) => t.id === PROJECTS)!; + expect(projects.groups.map((g) => g.label)).toEqual(['Alpha', 'Zed']); + }); + + it('lists a room with no alias match loose in its tier', () => { + const projects = buildEstate(input()).find((t) => t.id === PROJECTS)!; + expect(projects.loose.map((r) => r.roomId)).toEqual(['!misc:gamecult.org']); + }); + + it('groups an invited-only user by the same child-entry stewards, with no root state', () => { + const invitee = new Map([ + [ROOT, 'invite'], + [PROJECTS, 'invite'], + ['!cultlib:gamecult.org', 'invite'], + ['!aeth:gamecult.org', 'invite'], + ]); + const projects = buildEstate(input({ membership: invitee })).find((t) => t.id === PROJECTS)!; + expect(projects.groups.map((g) => [g.mind, g.rooms.map((r) => r.state)])).toEqual([ + ['eureka', ['invited']], + ['void', ['invited']], + ]); + }); + + it('ignores a steward that is not a non-empty string', () => { + const odd = (steward: unknown): EstateHierarchyRoom[] => [ + { + ...space(ROOT, 'GameCult', ['!a:gamecult.org']), + children_state: [ + { state_key: '!a:gamecult.org', content: { 'org.gamecult.steward': { mind: steward } } }, + ], + }, + room('!a:gamecult.org', 'A'), + ]; + [42, '', undefined, null].forEach((m) => { + const t = buildEstate(input({ hierarchy: odd(m) }))[0]; + expect(t.groups).toEqual([]); + expect(t.loose).toHaveLength(1); + }); + }); + + it('makes one tier per sub-Space in hierarchy order, root rooms first, Direct last', () => { + const second = '!second:gamecult.org'; + const tiers = buildEstate( + input({ + hierarchy: [ + space(ROOT, 'GameCult', ['!general:gamecult.org', PROJECTS, second]), + ...hierarchy.slice(1), + space(second, 'Workshop', ['!w1:gamecult.org']), + room('!w1:gamecult.org', 'Bench'), + ], + directRoomIds: ['!dm:gamecult.org'], + membership: new Map([['!dm:gamecult.org', 'join']]), + }) + ); + expect(tiers.map((t) => t.label)).toEqual([ + 'GameCult', + 'Projects', + 'Workshop', + 'Direct Messages', + ]); + expect(tiers[3].loose[0].direct).toBe(true); + expect(tiers[0].loose.map((r) => r.name)).toEqual(['General']); + expect(tiers[3].loose[0]).toMatchObject({ roomId: '!dm:gamecult.org', parentId: null }); + }); + + it('takes join servers from the matching children_state entry', () => { + const tiers = buildEstate( + input({ + hierarchy: [ + { + ...space(ROOT, 'GameCult', ['!a:gamecult.org', '!b:gamecult.org']), + children_state: [ + { state_key: '!a:gamecult.org', content: { via: ['one.example'] } }, + { state_key: '!b:gamecult.org', content: { via: ['two.example'] } }, + ], + }, + room('!a:gamecult.org', 'A'), + room('!b:gamecult.org', 'B'), + ], + }) + ); + expect(find(tiers, '!a:gamecult.org')?.via).toEqual(['one.example']); + expect(find(tiers, '!b:gamecult.org')?.via).toEqual(['two.example']); + }); + + it('puts every room of one Mind in a single group', () => { + const tiers = buildEstate( + input({ + hierarchy: [ + ...hierarchy.slice(0, 2), + space(PROJECTS, 'Projects', ['!cultlib:gamecult.org', '!aeth:gamecult.org'], { + '!cultlib:gamecult.org': 'eureka', + '!aeth:gamecult.org': 'eureka', + }), + ...hierarchy.slice(3, 5), + ], + }) + ); + const projects = tiers.find((t) => t.id === PROJECTS)!; + expect(projects.groups).toHaveLength(1); + expect(projects.groups[0].rooms.map((r) => r.roomId)).toEqual([ + '!cultlib:gamecult.org', + '!aeth:gamecult.org', + ]); + }); + + it('sorts group labels without regard to case', () => { + const projects = buildEstate( + input({ + mindLabels: new Map([ + ['eureka', 'beta'], + ['void', 'Alpha'], + ]), + }) + ).find((t) => t.id === PROJECTS)!; + expect(projects.groups.map((g) => g.label)).toEqual(['Alpha', 'beta']); + }); + + it('omits a tier whose Space returned no rooms and the Direct tier when there are no DMs', () => { + const tiers = buildEstate( + input({ + hierarchy: [ + space(ROOT, 'GameCult', ['!general:gamecult.org', PROJECTS]), + room('!general:gamecult.org', 'General'), + space(PROJECTS, 'Projects', ['!hidden:gamecult.org']), + ], + }) + ); + expect(tiers.map((t) => t.id)).toEqual([ROOT]); + }); + + it('puts direct messages in the final tier, off any Space', () => { + const tiers = buildEstate(input({ directRoomIds: ['!dm:gamecult.org'] })); + expect(tiers[tiers.length - 1]).toMatchObject({ id: 'direct', groups: [] }); + }); + + it('is identical for identical inputs', () => { + expect(buildEstate(input())).toEqual(buildEstate(input())); + }); +}); + +describe('stewardOfChild', () => { + it('reads the Mind from the org.gamecult.steward key of the child content', () => { + expect(stewardOfChild({ via: ['x'], 'org.gamecult.steward': { mind: 'void' } })).toBe('void'); + }); + + it('has no steward without the key, content or a string Mind', () => { + expect(stewardOfChild(undefined)).toBeUndefined(); + expect(stewardOfChild({ via: ['x'] })).toBeUndefined(); + expect(stewardOfChild({ 'org.gamecult.steward': {} })).toBeUndefined(); + }); +}); + +describe('Elsewhere', () => { + const strays = (over: Partial) => + buildEstate(input({ membership: new Map([['!adhoc:x', 'join']]), ...over })).find( + (t) => t.id === 'elsewhere' + ); + + it('lists a joined non-DM room the hierarchy does not return, named from the store', () => { + const t = strays({ + strayRoomIds: ['!adhoc:x'], + roomNames: new Map([['!adhoc:x', 'Meeting']]), + })!; + expect(t.label).toBe('Elsewhere'); + expect(t.loose).toEqual([ + { + roomId: '!adhoc:x', + name: 'Meeting', + state: 'joined', + via: [], + parentId: null, + direct: false, + }, + ]); + }); + + it('does not repeat a room the hierarchy lists or a DM, and is last before Direct', () => { + const tiers = buildEstate( + input({ + membership: new Map([ + ['!general:gamecult.org', 'join'], + ['!dm:x', 'join'], + ['!adhoc:x', 'join'], + ]), + strayRoomIds: ['!general:gamecult.org', '!dm:x', '!adhoc:x'], + directRoomIds: ['!dm:x'], + }) + ); + expect(tiers.map((t) => t.id)).toEqual([ROOT, PROJECTS, 'elsewhere', 'direct']); + expect(tiers[2].loose.map((r) => r.roomId)).toEqual(['!adhoc:x']); + }); + + it('never lists a room the user has not joined', () => { + expect( + strays({ membership: new Map([['!adhoc:x', 'invite']]), strayRoomIds: ['!adhoc:x'] }) + ).toBeUndefined(); + }); + + it('is absent when there are no strays', () => { + expect(strays({})).toBeUndefined(); + }); +}); + +describe('direct invites', () => { + it('lists an invited DM in the Direct tier as invited, named from the store', () => { + const tiers = buildEstate( + input({ + membership: new Map([['!dm:x', 'invite']]), + directRoomIds: ['!dm:x'], + roomNames: new Map([['!dm:x', 'Void']]), + }) + ); + expect(tiers[tiers.length - 1].loose).toEqual([ + { roomId: '!dm:x', name: 'Void', state: 'invited', via: [], parentId: null, direct: true }, + ]); + }); +}); diff --git a/src/app/features/estate/buildEstate.ts b/src/app/features/estate/buildEstate.ts new file mode 100644 index 00000000..b9232beb --- /dev/null +++ b/src/app/features/estate/buildEstate.ts @@ -0,0 +1,186 @@ +/** + * The estate index: the only code that decides which rooms the navigator lists, + * in which tier and steward group, and whether each is joined, invited or joinable. + * + * The server's /hierarchy of the root Space is the access rule. A room the + * hierarchy does not return is never listed, whatever the client store knows; + * a room it returns is never hidden by the client. The one licensed addition is + * the user's own joined rooms that sit outside the Space (the Elsewhere tier). + * + * A room's steward is read from the hierarchy too: the content of the + * m.space.child entry that lists the room carries org.gamecult.steward, which an + * invited-only member can read (she cannot read the Space's state). + */ + +export const STEWARD_KEY = 'org.gamecult.steward'; + +export type SpaceChildContent = { + via?: string[]; + /** The Mind who stewards the room, as the accounts script writes it. */ + [STEWARD_KEY]?: { mind?: unknown }; +}; + +export type EstateHierarchyRoom = { + room_id: string; + name?: string; + canonical_alias?: string; + room_type?: string; + children_state?: { state_key: string; content?: SpaceChildContent }[]; +}; + +export type EstateMembership = 'joined' | 'invited' | 'joinable'; + +export type EstateRoom = { + roomId: string; + name: string; + state: EstateMembership; + /** Servers to join through, from the hierarchy's children_state. */ + via: string[]; + /** The Space tier the room sits in; null for direct messages and Elsewhere rooms. */ + parentId: string | null; + direct: boolean; +}; + +export type EstateGroup = { + mind: string; + label: string; + rooms: EstateRoom[]; +}; + +export type EstateTier = { + id: string; + label: string; + /** Rooms without a steward. */ + loose: EstateRoom[]; + groups: EstateGroup[]; +}; + +export type EstateInput = { + rootId: string; + /** Rooms from the server's hierarchy of the root, in hierarchy order. */ + hierarchy: readonly EstateHierarchyRoom[]; + /** The user's own membership per room id, from the client store. */ + membership: ReadonlyMap; + /** Joined and invited direct messages. */ + directRoomIds: readonly string[]; + /** Joined non-DM, non-Space rooms, whether or not the hierarchy lists them. */ + strayRoomIds?: readonly string[]; + /** Names for rooms the hierarchy does not name (direct and stray rooms). */ + roomNames?: ReadonlyMap; + /** Display names for Minds, keyed by localpart. */ + mindLabels?: ReadonlyMap; +}; + +export const DIRECT_TIER_ID = 'direct'; +export const ELSEWHERE_TIER_ID = 'elsewhere'; + +const SPACE_TYPE = 'm.space'; + +/** The Mind a Space child entry names as the room's steward, if any. */ +export const stewardOfChild = (content: SpaceChildContent | undefined): string | undefined => { + const mind = content?.[STEWARD_KEY]?.mind; + return typeof mind === 'string' && mind !== '' ? mind : undefined; +}; + +const stateOf = ( + roomId: string, + membership: ReadonlyMap +): EstateMembership => { + const m = membership.get(roomId); + if (m === 'join') return 'joined'; + if (m === 'invite') return 'invited'; + return 'joinable'; +}; + +const roomLabel = (r: EstateHierarchyRoom): string => r.name || r.canonical_alias || r.room_id; + +export const buildEstate = ({ + rootId, + hierarchy, + membership, + directRoomIds, + strayRoomIds = [], + roomNames, + mindLabels, +}: EstateInput): EstateTier[] => { + const direct = new Set(directRoomIds); + const byId = new Map(hierarchy.map((r) => [r.room_id, r])); + const spaces = hierarchy.filter((r) => r.room_type === SPACE_TYPE); + const root = byId.get(rootId); + + // Root first, then every sub-Space in hierarchy order. + const tierSpaces = root ? [root, ...spaces.filter((s) => s.room_id !== rootId)] : spaces; + + const tiers: EstateTier[] = tierSpaces.map((space) => { + const childIds = new Set((space.children_state ?? []).map((c) => c.state_key)); + const tier: EstateTier = { id: space.room_id, label: roomLabel(space), loose: [], groups: [] }; + const groups = new Map(); + + hierarchy.forEach((r) => { + if (r.room_type === SPACE_TYPE || !childIds.has(r.room_id) || direct.has(r.room_id)) return; + const content = space.children_state?.find((c) => c.state_key === r.room_id)?.content; + const room: EstateRoom = { + roomId: r.room_id, + name: roomLabel(r), + state: stateOf(r.room_id, membership), + via: content?.via ?? [], + parentId: space.room_id, + direct: false, + }; + const mind = stewardOfChild(content); + if (!mind) { + tier.loose.push(room); + return; + } + let group = groups.get(mind); + if (!group) { + group = { mind, label: mindLabels?.get(mind) ?? mind, rooms: [] }; + groups.set(mind, group); + } + group.rooms.push(room); + }); + + tier.groups = [...groups.values()].sort((a, b) => + a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }) + ); + return tier; + }); + + const directTier: EstateTier = { + id: DIRECT_TIER_ID, + label: 'Direct Messages', + loose: directRoomIds.map((roomId) => ({ + roomId, + name: roomNames?.get(roomId) ?? roomId, + state: stateOf(roomId, membership), + via: [], + parentId: null, + direct: true, + })), + groups: [], + }; + + const listedIds = new Set(hierarchy.map((r) => r.room_id)); + const elsewhereTier: EstateTier = { + id: ELSEWHERE_TIER_ID, + label: 'Elsewhere', + loose: strayRoomIds + .filter((id) => !listedIds.has(id) && !direct.has(id) && membership.get(id) === 'join') + .map((roomId) => ({ + roomId, + name: roomNames?.get(roomId) ?? roomId, + state: 'joined' as const, + via: [], + parentId: null, + direct: false, + })), + groups: [], + }; + + const listed = tiers.filter((t) => t.loose.length > 0 || t.groups.length > 0); + return [ + ...listed, + ...(elsewhereTier.loose.length > 0 ? [elsewhereTier] : []), + ...(directTier.loose.length > 0 ? [directTier] : []), + ]; +}; diff --git a/src/app/features/estate/config.test.ts b/src/app/features/estate/config.test.ts new file mode 100644 index 00000000..4304e1c2 --- /dev/null +++ b/src/app/features/estate/config.test.ts @@ -0,0 +1,9 @@ +import { readFileSync } from 'fs'; +import { describe, expect, it } from 'vitest'; + +describe('repo config.json', () => { + it('names the GameCult Space first, so a wholesale deploy resolves the estate root', () => { + const config = JSON.parse(readFileSync('config.json', 'utf8')); + expect(config.featuredCommunities.spaces[0]).toBe('#gamecult:gamecult.org'); + }); +}); diff --git a/src/app/features/estate/index.ts b/src/app/features/estate/index.ts new file mode 100644 index 00000000..761586c4 --- /dev/null +++ b/src/app/features/estate/index.ts @@ -0,0 +1 @@ +export * from './EstateNavigator'; diff --git a/src/app/features/estate/joinEstateRoom.test.ts b/src/app/features/estate/joinEstateRoom.test.ts new file mode 100644 index 00000000..932e7cce --- /dev/null +++ b/src/app/features/estate/joinEstateRoom.test.ts @@ -0,0 +1,352 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + activateEstateRoom, + awaitMyJoin, + joinEstateRoomIdOrAlias, + joinEstateRoomOrThrow, + runJoinCommand, +} from './joinEstateRoom'; + +const room = (state: 'joined' | 'invited' | 'joinable') => ({ + roomId: '!r:gamecult.org', + state, + via: ['gamecult.org'], +}); + +/** A client that records every call; anything but joinRoom is a peek or a side door. */ +const recordingClient = (joinRoom = vi.fn().mockResolvedValue({ roomId: '!r:gamecult.org' })) => { + const calls: string[] = []; + const mx = new Proxy({} as Record, { + get: (_t, prop: string) => { + if (prop === 'joinRoom') { + return (...args: unknown[]) => { + calls.push('joinRoom'); + return joinRoom(...args); + }; + } + if (prop === 'getRoom') return () => null; + if (prop === 'getSafeUserId') return () => '@me:test'; + return () => { + calls.push(prop); + throw new Error(`unexpected client call: ${prop}`); + }; + }, + }); + return { mx: mx as never, calls, joinRoom }; +}; + +describe('activateEstateRoom', () => { + it('opens a joined room without touching the client', async () => { + const { mx, calls } = recordingClient(); + expect(await activateEstateRoom(mx, room('joined'))).toEqual({ + kind: 'open', + roomId: '!r:gamecult.org', + }); + expect(calls).toEqual([]); + }); + + it.each(['invited', 'joinable'] as const)( + 'joins a %s room on the tap, through the hierarchy servers, and nothing else', + async (state) => { + const { mx, calls, joinRoom } = recordingClient(); + const result = await activateEstateRoom(mx, room(state)); + expect(result).toEqual({ kind: 'joined', roomId: '!r:gamecult.org' }); + expect(joinRoom).toHaveBeenCalledWith('!r:gamecult.org', { viaServers: ['gamecult.org'] }); + expect(calls).toEqual(['joinRoom']); // no peekInRoom, no read, no second path + } + ); + + it('falls back to the error message, then to a generic one', async () => { + const named = recordingClient( + vi + .fn() + .mockRejectedValue( + Object.assign(new Error('Boom'), { httpStatus: 403, errcode: 'M_FORBIDDEN' }) + ) + ); + expect(await activateEstateRoom(named.mx, room('joinable'))).toEqual({ + kind: 'refused', + message: 'Boom', + }); + const bare = recordingClient( + vi.fn().mockRejectedValue({ httpStatus: 403, errcode: 'M_FORBIDDEN' }) + ); + expect(await activateEstateRoom(bare.mx, room('joinable'))).toEqual({ + kind: 'refused', + message: 'Could not join', + }); + }); + + it('reports the server refusal and does not decide access itself', async () => { + const refusal = Object.assign(new Error('M_FORBIDDEN'), { + httpStatus: 403, + errcode: 'M_FORBIDDEN', + data: { error: 'You are not invited to this room.' }, + }); + const { mx, joinRoom } = recordingClient(vi.fn().mockRejectedValue(refusal)); + const result = await activateEstateRoom(mx, room('joinable')); + expect(result).toEqual({ kind: 'refused', message: 'You are not invited to this room.' }); + expect(joinRoom).toHaveBeenCalledTimes(1); + }); +}); + +/** A client holding DM invites as the server sends them: the mark is in the invite event only. */ +const dmClient = (opts: { + status?: number; + errcode?: string; + refuse?: boolean; + drop?: boolean; + failWrite?: boolean; + invites: Record; +}) => { + let direct: Record | undefined; + const writes: Record[] = []; + const joined: string[] = []; + const joinOpts: unknown[] = []; + const mx = { + getSafeUserId: () => '@me:test', + getRoom: (roomId: string) => ({ + getMyMembership: () => 'invite', + getMember: () => ({ + events: { + member: { + getContent: () => ({ membership: 'invite', is_direct: true }), + getSender: () => opts.invites[roomId], + }, + }, + }), + }), + getAccountData: () => (direct ? { getContent: () => direct } : undefined), + setAccountData: async (_t: string, content: Record) => { + if (opts.failWrite) throw new Error('account data write failed'); + await new Promise((r) => { + setTimeout(r, 5); + }); + writes.push(structuredClone(content)); + direct = structuredClone(content); + }, + joinRoom: async (roomId: string, joinArgs?: unknown) => { + joinOpts.push(joinArgs); + // the join lands only after m.direct has been written: record what it held + joined.push(JSON.stringify(direct ?? {})); + if (opts.refuse) + throw Object.assign(new Error('M_FORBIDDEN'), { httpStatus: 403, errcode: 'M_FORBIDDEN' }); + if (opts.status) + throw Object.assign(new Error('Gateway Time-out'), { + httpStatus: opts.status, + errcode: opts.errcode, + }); + if (opts.drop) throw new Error('fetch failed'); + return { roomId }; + }, + }; + return { mx: mx as never, writes, joined, joinOpts, current: () => direct }; +}; +const tap = (id: string) => ({ roomId: id, state: 'invited' as const, via: ['test'] }); + +describe('activateEstateRoom for a direct message invite', () => { + it('lists the room under the inviter before the join is sent', async () => { + const c = dmClient({ invites: { '!dm1:test': '@friend:test' } }); + await activateEstateRoom(c.mx, tap('!dm1:test')); + expect(c.joined).toEqual([JSON.stringify({ '@friend:test': ['!dm1:test'] })]); + }); + + it('keeps both rooms when two DM joins are tapped together', async () => { + const c = dmClient({ invites: { '!dm2:test': '@friend:test', '!dm3:test': '@friend:test' } }); + const results = await Promise.all([ + activateEstateRoom(c.mx, tap('!dm2:test')), + activateEstateRoom(c.mx, tap('!dm3:test')), + ]); + expect(results.map((r) => r.kind)).toEqual(['joined', 'joined']); + expect(c.current()).toEqual({ '@friend:test': ['!dm2:test', '!dm3:test'] }); + }); + + it('takes the room out again when the join is refused', async () => { + const c = dmClient({ refuse: true, invites: { '!dm1:test': '@friend:test' } }); + const result = await activateEstateRoom(c.mx, tap('!dm1:test')); + expect(result).toEqual({ kind: 'refused', message: 'M_FORBIDDEN' }); + expect(c.current()).toEqual({ '@friend:test': [] }); + }); +}); + +describe('activateEstateRoom when the join gets no answer', () => { + it('keeps the room listed and shows a retryable message', async () => { + const c = dmClient({ drop: true, invites: { '!dm1:test': '@friend:test' } }); + const result = await activateEstateRoom(c.mx, tap('!dm1:test')); + expect(result.kind).toBe('refused'); + expect((result as { message: string }).message).toMatch(/tap again to retry/); + expect(c.current()).toEqual({ '@friend:test': ['!dm1:test'] }); + }); +}); + +describe('activateEstateRoom when the filing cannot be written', () => { + it('reports the failure and sends no join', async () => { + const c = dmClient({ failWrite: true, invites: { '!dm1:test': '@friend:test' } }); + const result = await activateEstateRoom(c.mx, tap('!dm1:test')); + expect(result).toEqual({ kind: 'refused', message: 'account data write failed' }); + expect(c.joined).toEqual([]); + }); +}); + +describe('activateEstateRoom when a room that is not a direct message refuses', () => { + it('reports the refusal and leaves m.direct alone', async () => { + const joinRoom = vi + .fn() + .mockRejectedValue( + Object.assign(new Error('no'), { httpStatus: 403, errcode: 'M_FORBIDDEN' }) + ); + const { mx, calls } = recordingClient(joinRoom); + const result = await activateEstateRoom(mx, room('joinable')); + expect(result.kind).toBe('refused'); + expect(calls).toEqual(['joinRoom']); + }); +}); + +describe('activateEstateRoom refusal bounds', () => { + it.each([400, 429, 499])('unfiles the room on a %i that carries an errcode', async (status) => { + const c = dmClient({ + status, + errcode: 'M_LIMIT_EXCEEDED', + invites: { '!dm1:test': '@friend:test' }, + }); + const result = await activateEstateRoom(c.mx, tap('!dm1:test')); + expect(result.kind).toBe('refused'); + expect((result as { message: string }).message).not.toMatch(/tap again to retry/); + expect(c.current()).toEqual({ '@friend:test': [] }); + }); + + it.each([399, 500])('keeps the room listed on a %i that carries an errcode', async (status) => { + const c = dmClient({ status, errcode: 'M_UNKNOWN', invites: { '!dm1:test': '@friend:test' } }); + await activateEstateRoom(c.mx, tap('!dm1:test')); + expect(c.current()).toEqual({ '@friend:test': ['!dm1:test'] }); + }); +}); + +describe('activateEstateRoom when a gateway answers instead of the server', () => { + it.each([502, 503, 504, 404])('keeps the room listed on a %i with no errcode', async (status) => { + const c = dmClient({ status, invites: { '!dm1:test': '@friend:test' } }); + const result = await activateEstateRoom(c.mx, tap('!dm1:test')); + expect(result.kind).toBe('refused'); + expect((result as { message: string }).message).toMatch(/tap again to retry/); + expect(c.current()).toEqual({ '@friend:test': ['!dm1:test'] }); + }); + + it('keeps the room listed on a 5xx even when it carries an errcode', async () => { + const c = dmClient({ + status: 500, + errcode: 'M_UNKNOWN', + invites: { '!dm1:test': '@friend:test' }, + }); + await activateEstateRoom(c.mx, tap('!dm1:test')); + expect(c.current()).toEqual({ '@friend:test': ['!dm1:test'] }); + }); +}); + +describe('joinEstateRoomIdOrAlias, the room card and /join', () => { + it('files a direct invite opened by its link before the join is sent', async () => { + const c = dmClient({ invites: { '!dm1:test': '@friend:test' } }); + const result = await joinEstateRoomIdOrAlias(c.mx, '!dm1:test', ['test']); + expect(result).toEqual({ kind: 'joined', roomId: '!dm1:test' }); + expect(c.joined).toEqual([JSON.stringify({ '@friend:test': ['!dm1:test'] })]); + }); + + it('hands the via servers to the join', async () => { + const c = dmClient({ invites: { '!dm1:test': '@friend:test' } }); + await joinEstateRoomIdOrAlias(c.mx, '!dm1:test', ['other.org']); + expect(c.joinOpts).toEqual([{ viaServers: ['other.org'] }]); + }); + + it('joins an alias or an unheld room without touching m.direct', async () => { + const { mx, calls } = recordingClient(); + await joinEstateRoomIdOrAlias(mx, '#room:gamecult.org'); + expect(calls).toEqual(['joinRoom']); + }); +}); + +describe('joinEstateRoomOrThrow, the lobby, tombstone and predecessor buttons', () => { + it('files a direct invite and answers the joined room id', async () => { + const c = dmClient({ invites: { '!dm1:test': '@friend:test' } }); + await expect(joinEstateRoomOrThrow(c.mx, '!dm1:test', ['test'])).resolves.toBe('!dm1:test'); + expect(c.joined).toEqual([JSON.stringify({ '@friend:test': ['!dm1:test'] })]); + }); + + it('throws the refusal and unfiles the room', async () => { + const c = dmClient({ refuse: true, invites: { '!dm1:test': '@friend:test' } }); + await expect(joinEstateRoomOrThrow(c.mx, '!dm1:test')).rejects.toThrow('M_FORBIDDEN'); + expect(c.current()).toEqual({ '@friend:test': [] }); + }); +}); + +describe('runJoinCommand, /join', () => { + it('reports each refusal and joins the others', async () => { + const c = dmClient({ refuse: true, invites: { '!dm1:test': '@friend:test' } }); + const reported: string[] = []; + await runJoinCommand(c.mx, '!dm1:test notanaddress', (m) => reported.push(m)); + expect(reported).toEqual(['M_FORBIDDEN']); + expect(c.joined).toHaveLength(1); + }); + + it('reports nothing when the join is accepted', async () => { + const c = dmClient({ invites: { '!dm1:test': '@friend:test' } }); + const reported: string[] = []; + await runJoinCommand(c.mx, '!dm1:test', (m) => reported.push(m)); + expect(reported).toEqual([]); + expect(c.joined).toHaveLength(1); + }); +}); + +describe('awaitMyJoin', () => { + /** A store that gains the room when the test says so, emitting like the SDK. */ + const store = () => { + let membership: string | null = null; + const handlers = new Set<(room: { roomId: string }, m: string) => void>(); + return { + mx: { + getRoom: () => (membership ? { getMyMembership: () => membership! } : null), + on: (e: string, h: (room: { roomId: string }, m: string) => void) => { + if (e === 'Room.myMembership') handlers.add(h); + }, + off: (e: string, h: (room: { roomId: string }, m: string) => void) => { + if (e === 'Room.myMembership') handlers.delete(h); + }, + }, + handlers, + become: (roomId: string, m: string) => { + membership = m; + handlers.forEach((h) => h({ roomId }, m)); + }, + }; + }; + + it('resolves at once when the store already holds the joined room', async () => { + const s = store(); + s.become('!r:x', 'join'); + expect(await awaitMyJoin(s.mx, '!r:x', 1000)).toBe(true); + }); + + it('stays pending while the store still shows an invite, then resolves on join', async () => { + const s = store(); + let settled = false; + const p = awaitMyJoin(s.mx, '!r:x', 1000).then((v) => { + settled = true; + return v; + }); + s.become('!r:x', 'invite'); + s.become('!other:x', 'join'); + await Promise.resolve(); + expect(settled).toBe(false); + s.become('!r:x', 'join'); + expect(await p).toBe(true); + expect(s.handlers.size).toBe(0); + }); + + it('gives up after the timeout and stops listening', async () => { + vi.useFakeTimers(); + const s = store(); + const p = awaitMyJoin(s.mx, '!r:x', 500); + await vi.advanceTimersByTimeAsync(500); + expect(await p).toBe(false); + expect(s.handlers.size).toBe(0); + vi.useRealTimers(); + }); +}); diff --git a/src/app/features/estate/joinEstateRoom.ts b/src/app/features/estate/joinEstateRoom.ts new file mode 100644 index 00000000..4e1629d0 --- /dev/null +++ b/src/app/features/estate/joinEstateRoom.ts @@ -0,0 +1,151 @@ +import { EstateRoom } from './buildEstate'; +import { + addRoomIdToMDirect, + isRoomAlias, + isRoomId, + removeRoomIdFromMDirect, +} from '../../utils/matrix'; +import { splitWithSpace } from '../../utils/common'; +import { isDirectInvite } from '../../utils/room'; + +export type EstateActivation = + | { kind: 'open'; roomId: string } + | { kind: 'joined'; roomId: string } + | { kind: 'refused'; message: string }; + +const LOST_JOIN_RESPONSE = + 'The server did not answer the join. It may have gone through; tap again to retry.'; + +type JoinClient = Parameters[0]; + +/** + * What a tap on an estate row does. A joined room opens; any other room is + * joined, and only here (plus a first post in the room view). Opening a room + * never joins it and the client has no peek path. The server decides whether + * the join is allowed; its refusal is reported, the row is not hidden. + * + * An invite marked is_direct carries the mark in the invite event the client + * already holds, and the join event does not repeat it. So the room is listed + * in m.direct under the inviter before the join is sent, and taken out again if + * the join is refused by a Matrix error answer (a 4xx carrying an errcode). A + * join that fails any other way (lost response, dropped connection, a gateway's + * 5xx or a status with no errcode) may still have been accepted upstream, so the + * entry stays and the tap is retryable. Nothing needs to recognise the DM after it + * arrives. Every client join goes through here: the estate tap, the inbox Accept, + * the room card, /join, the lobby and space Join buttons, the tombstone and + * predecessor links. One owner for joining. + */ +export const activateEstateRoom = async ( + mx: JoinClient, + room: Pick & { via?: string[] } +): Promise => { + if (room.state === 'joined') return { kind: 'open', roomId: room.roomId }; + const refusal = (e: unknown): EstateActivation => { + const err = e as { data?: { error?: string }; message?: string }; + return { kind: 'refused', message: err.data?.error ?? err.message ?? 'Could not join' }; + }; + const refusedByServer = (e: unknown) => { + const err = e as { httpStatus?: unknown; errcode?: unknown; data?: { errcode?: unknown } }; + const status = err.httpStatus; + const errcode = err.errcode ?? err.data?.errcode; + return ( + typeof status === 'number' && status >= 400 && status < 500 && typeof errcode === 'string' + ); + }; + const invite = room.state === 'invited' ? mx.getRoom(room.roomId) : null; + const inviter = isDirectInvite(invite, mx.getSafeUserId()) + ? invite?.getMember(mx.getSafeUserId())?.events?.member?.getSender() + : undefined; + try { + if (inviter) await addRoomIdToMDirect(mx, room.roomId, inviter); + } catch (e) { + return refusal(e); + } + try { + const joined = await mx.joinRoom(room.roomId, { viaServers: room.via }); + return { kind: 'joined', roomId: joined.roomId }; + } catch (e) { + if (!refusedByServer(e)) return { kind: 'refused', message: LOST_JOIN_RESPONSE }; + if (inviter) await removeRoomIdFromMDirect(mx, room.roomId).catch(() => undefined); + return refusal(e); + } +}; + +/** + * activateEstateRoom for a caller that holds only an id or alias (room card, + * /join): a room the client has an invite for is an invited room. + */ +export const joinEstateRoomIdOrAlias = ( + mx: JoinClient, + roomIdOrAlias: string, + via?: string[] +): Promise => + activateEstateRoom(mx, { + roomId: roomIdOrAlias, + state: mx.getRoom(roomIdOrAlias)?.getMyMembership() === 'invite' ? 'invited' : 'joinable', + via, + }); + +/** + * joinEstateRoomIdOrAlias for a caller that reports failure by throwing (a + * useAsyncCallback button): the joined room id, or an Error carrying the refusal. + */ +export const joinEstateRoomOrThrow = async ( + mx: JoinClient, + roomIdOrAlias: string, + via?: string[] +): Promise => { + const result = await joinEstateRoomIdOrAlias(mx, roomIdOrAlias, via); + if (result.kind === 'refused') throw new Error(result.message); + return result.roomId; +}; + +/** + * The /join command: every room id or alias in the payload is joined, and each + * refusal is handed to report, never dropped. + */ +export const runJoinCommand = async ( + mx: JoinClient, + payload: string, + report: (message: string) => void +): Promise => { + const addresses = splitWithSpace(payload).filter((a) => isRoomId(a) || isRoomAlias(a)); + const results = await Promise.all(addresses.map((a) => joinEstateRoomIdOrAlias(mx, a))); + results.forEach((r) => { + if (r.kind === 'refused') report(r.message); + }); +}; + +type MembershipSource = { + getRoom(roomId: string): { getMyMembership(): string } | null; + on(event: 'Room.myMembership', handler: (room: { roomId: string }, m: string) => void): unknown; + off(event: 'Room.myMembership', handler: (room: { roomId: string }, m: string) => void): unknown; +}; + +/** + * joinRoom resolves when the server accepts the join, before the client store + * holds the room; navigating then lands on the invite card. Resolves true once + * the store says the user's membership is join, false after timeoutMs. + */ +export const awaitMyJoin = ( + mx: MembershipSource, + roomId: string, + timeoutMs: number +): Promise => + new Promise((resolve) => { + if (mx.getRoom(roomId)?.getMyMembership() === 'join') { + resolve(true); + return; + } + let finish: (joined: boolean) => void = () => undefined; + const onMembership = (room: { roomId: string }, membership: string) => { + if (room.roomId === roomId && membership === 'join') finish(true); + }; + const timer = setTimeout(() => finish(false), timeoutMs); + finish = (joined) => { + clearTimeout(timer); + mx.off('Room.myMembership', onMembership); + resolve(joined); + }; + mx.on('Room.myMembership', onMembership); + }); diff --git a/src/app/features/estate/useEstate.ts b/src/app/features/estate/useEstate.ts new file mode 100644 index 00000000..d27d4710 --- /dev/null +++ b/src/app/features/estate/useEstate.ts @@ -0,0 +1,122 @@ +import { useCallback, useEffect, useMemo } from 'react'; +import { useAtomValue } from 'jotai'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { useClientConfig } from '../../hooks/useClientConfig'; +import { useFetchSpaceHierarchyLevel } from '../../hooks/useSpaceHierarchy'; +import { isDirectInvite, isRoom, isUnsupportedRoom } from '../../utils/room'; +import { useStateEventCallback } from '../../hooks/useStateEventCallback'; +import { allRoomsAtom } from '../../state/room-list/roomList'; +import { allInvitesAtom } from '../../state/room-list/inviteList'; +import { mDirectAtom } from '../../state/mDirectList'; +import { StateEvent } from '../../../types/matrix/room'; +import { buildEstate, EstateHierarchyRoom, EstateTier, stewardOfChild } from './buildEstate'; + +const HIERARCHY_DEPTH = 10; + +type AliasedRoom = { + roomId: string; + getCanonicalAlias(): string | null; + getAltAliases(): string[]; +}; + +const findLoadedRoom = ( + mx: ReturnType, + alias: string +): string | undefined => + (mx.getRooms() as AliasedRoom[]).find( + (r) => r.getCanonicalAlias() === alias || r.getAltAliases().includes(alias) + )?.roomId; + +/** The estate's root Space: featuredCommunities.spaces[0] from the client config. */ +export const useEstateRootId = (): { rootId?: string; error?: Error } => { + const mx = useMatrixClient(); + const alias = useClientConfig().featuredCommunities?.spaces?.[0]; + const loaded = alias && !alias.startsWith('#') ? alias : alias && findLoadedRoom(mx, alias); + const resolved = useQuery({ + queryKey: ['estate-root', alias], + queryFn: async () => (await mx.getRoomIdForAlias(alias!)).room_id, + enabled: !!alias && !loaded, + }); + if (!alias) return { error: new Error('No estate root Space is configured') }; + return { rootId: loaded || resolved.data, error: resolved.error ?? undefined }; +}; + +export type Estate = { + fetching: boolean; + error: Error | null; + tiers: EstateTier[]; +}; + +/** + * Feeds buildEstate: the server hierarchy of the root Space (which also carries + * each room's steward), the user's membership, m.direct and direct invites, and + * her joined rooms outside the Space. Recomputes on membership and Space child changes. + */ +export const useEstate = (rootId: string): Estate => { + const mx = useMatrixClient(); + const queryClient = useQueryClient(); + const { fetching, error, rooms } = useFetchSpaceHierarchyLevel(rootId, true, HIERARCHY_DEPTH); + const joined = useAtomValue(allRoomsAtom); + const invited = useAtomValue(allInvitesAtom); + const mDirects = useAtomValue(mDirectAtom); + + const refetchHierarchy = useCallback( + () => queryClient.invalidateQueries({ queryKey: [rootId, 'hierarchy_level'] }), + [queryClient, rootId] + ); + useStateEventCallback( + mx, + useCallback( + (event) => { + if (event.getType() === StateEvent.SpaceChild) refetchHierarchy(); + }, + [refetchHierarchy] + ) + ); + // What the server lets this user see changes with invitations. + useEffect(() => { + refetchHierarchy(); + }, [invited.length, refetchHierarchy]); + + const tiers = useMemo(() => { + const membership = new Map(); + invited.forEach((id) => membership.set(id, 'invite')); + joined.forEach((id) => membership.set(id, 'join')); + const hierarchy = Array.from(rooms.values()) as unknown as EstateHierarchyRoom[]; + const domain = mx.getDomain(); + const mindLabels = new Map(); + hierarchy.forEach((r) => + r.children_state?.forEach((c) => { + const mind = stewardOfChild(c.content); + const name = mind && mx.getUser(`@${mind}:${domain}`)?.displayName; + if (mind && name) mindLabels.set(mind, name); + }) + ); + const userId = mx.getUserId(); + const directInvites = invited.filter((id) => isDirectInvite(mx.getRoom(id), userId)); + const directRoomIds = [ + ...new Set([...Array.from(mDirects).filter((id) => joined.includes(id)), ...directInvites]), + ]; + const strayRoomIds = joined.filter((id) => { + const r = mx.getRoom(id); + return !mDirects.has(id) && isRoom(r) && !isUnsupportedRoom(r); + }); + const roomNames = new Map(); + [...directInvites, ...strayRoomIds].forEach((id) => { + const name = mx.getRoom(id)?.name; + if (name) roomNames.set(id, name); + }); + return buildEstate({ + rootId, + hierarchy, + membership, + directRoomIds, + strayRoomIds, + roomNames, + mindLabels, + }); + }, [mx, rootId, rooms, joined, invited, mDirects]); + + return { fetching, error, tiers }; +}; diff --git a/src/app/features/lobby/HierarchyItemMenu.tsx b/src/app/features/lobby/HierarchyItemMenu.tsx index c6aff741..1802e3b0 100644 --- a/src/app/features/lobby/HierarchyItemMenu.tsx +++ b/src/app/features/lobby/HierarchyItemMenu.tsx @@ -200,16 +200,12 @@ type HierarchyItemMenuProps = { joined: boolean; powerLevels?: IPowerLevels; canEditChild: boolean; - pinned?: boolean; - onTogglePin?: (roomId: string) => void; }; export function HierarchyItemMenu({ item, joined, powerLevels, canEditChild, - pinned, - onTogglePin, }: HierarchyItemMenuProps) { const mx = useMatrixClient(); const [menuAnchor, setMenuAnchor] = useState(); @@ -264,20 +260,6 @@ export function HierarchyItemMenu({ {joined && ( - {onTogglePin && ( - { - onTogglePin(item.roomId); - handleRequestClose(); - }} - > - - {pinned ? 'Unpin from Sidebar' : 'Pin to Sidebar'} - - - )} { - const sideSpaces = sidebarItems.flatMap((item) => { - if (typeof item === 'string') return item; - return item.content; - }); - - return new Set(sideSpaces); - }, [sidebarItems]); - const [spacesItems, setSpacesItem] = useState>(() => new Map()); useElementSizeObserver( @@ -415,18 +395,6 @@ export function Lobby() { navigate(getSpaceRoomPath(pSpaceIdOrAlias, getCanonicalAliasOrRoomId(mx, rId))); }; - const togglePinToSidebar = useCallback( - (rId: string) => { - const newItems = sidebarItemWithout(sidebarItems, rId); - if (!sidebarSpaces.has(rId)) { - newItems.push(rId); - } - const newSpacesContent = makeCinnySpacesContent(mx, newItems); - mx.setAccountData(AccountDataEvent.CinnySpaces as any, newSpacesContent as any); - }, - [mx, sidebarItems, sidebarSpaces] - ); - return ( @@ -499,8 +467,6 @@ export function Lobby() { disabledReorder={reordering} nextSpaceId={nextSpaceId} getRoom={getRoom} - pinned={sidebarSpaces.has(item.space.roomId)} - togglePinToSidebar={togglePinToSidebar} onSpacesFound={handleSpacesFound} onOpenRoom={handleOpenRoom} /> diff --git a/src/app/features/lobby/RoomItem.tsx b/src/app/features/lobby/RoomItem.tsx index 7de59acd..132aaf8c 100644 --- a/src/app/features/lobby/RoomItem.tsx +++ b/src/app/features/lobby/RoomItem.tsx @@ -19,7 +19,7 @@ import { toRem, } from 'folds'; import FocusTrap from 'focus-trap-react'; -import { JoinRule, MatrixError, Room } from 'matrix-js-sdk'; +import { JoinRule, Room } from 'matrix-js-sdk'; import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces'; import { RoomAvatar, RoomIcon } from '../../components/room-avatar'; import { SequenceCard } from '../../components/sequence-card'; @@ -38,6 +38,7 @@ import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../../utils/room'; import { ItemDraggableTarget, useDraggableItem } from './DnD'; import { mxcUrlToHttp } from '../../utils/matrix'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; +import { joinEstateRoomOrThrow } from '../estate/joinEstateRoom'; type RoomJoinButtonProps = { roomId: string; @@ -46,8 +47,8 @@ type RoomJoinButtonProps = { function RoomJoinButton({ roomId, via }: RoomJoinButtonProps) { const mx = useMatrixClient(); - const [joinState, join] = useAsyncCallback( - useCallback(() => mx.joinRoom(roomId, { viaServers: via }), [mx, roomId, via]) + const [joinState, join] = useAsyncCallback( + useCallback(() => joinEstateRoomOrThrow(mx, roomId, via), [mx, roomId, via]) ); const canJoin = joinState.status === AsyncStatus.Idle || joinState.status === AsyncStatus.Error; @@ -60,9 +61,8 @@ function RoomJoinButton({ roomId, via }: RoomJoinButtonProps) { - {joinState.error.data?.error || joinState.error.message} + {joinState.error.message} - {joinState.error.name} } @@ -75,7 +75,7 @@ function RoomJoinButton({ roomId, via }: RoomJoinButtonProps) { size="400" filled tabIndex={0} - aria-label={joinState.error.data?.error || joinState.error.message} + aria-label={joinState.error.message} /> )} diff --git a/src/app/features/lobby/SpaceHierarchy.tsx b/src/app/features/lobby/SpaceHierarchy.tsx index 280b8a5a..9a283378 100644 --- a/src/app/features/lobby/SpaceHierarchy.tsx +++ b/src/app/features/lobby/SpaceHierarchy.tsx @@ -35,8 +35,6 @@ type SpaceHierarchyProps = { disabledReorder?: boolean; nextSpaceId?: string; getRoom: (roomId: string) => Room | undefined; - pinned: boolean; - togglePinToSidebar: (roomId: string) => void; onSpacesFound: (spaceItems: IHierarchyRoom[]) => void; onOpenRoom: MouseEventHandler; }; @@ -58,8 +56,6 @@ export const SpaceHierarchy = forwardRef( disabledReorder, nextSpaceId, getRoom, - pinned, - togglePinToSidebar, onOpenRoom, onSpacesFound, }, @@ -136,8 +132,6 @@ export const SpaceHierarchy = forwardRef( canEditChild={ !!parentPermissions?.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId()) } - pinned={pinned} - onTogglePin={togglePinToSidebar} /> ) } diff --git a/src/app/features/lobby/SpaceItem.tsx b/src/app/features/lobby/SpaceItem.tsx index ed3eaff9..e1ffbfe5 100644 --- a/src/app/features/lobby/SpaceItem.tsx +++ b/src/app/features/lobby/SpaceItem.tsx @@ -18,7 +18,7 @@ import { } from 'folds'; import FocusTrap from 'focus-trap-react'; import classNames from 'classnames'; -import { MatrixError, Room } from 'matrix-js-sdk'; +import { Room } from 'matrix-js-sdk'; import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces'; import { HierarchyItem } from '../../hooks/useSpaceHierarchy'; import { useMatrixClient } from '../../hooks/useMatrixClient'; @@ -38,6 +38,7 @@ import { useOpenCreateSpaceModal } from '../../state/hooks/createSpaceModal'; import { AddExistingModal } from '../add-existing'; import { CreateRoomType } from '../../components/create-room/types'; import { BetaNoticeBadge } from '../../components/BetaNoticeBadge'; +import { joinEstateRoomOrThrow } from '../estate/joinEstateRoom'; function SpaceProfileLoading() { return ( @@ -112,8 +113,8 @@ function UnjoinedSpaceProfile({ }: UnjoinedSpaceProfileProps) { const mx = useMatrixClient(); - const [joinState, join] = useAsyncCallback( - useCallback(() => mx.joinRoom(roomId, { viaServers: via }), [mx, roomId, via]) + const [joinState, join] = useAsyncCallback( + useCallback(() => joinEstateRoomOrThrow(mx, roomId, via), [mx, roomId, via]) ); const canJoin = joinState.status === AsyncStatus.Idle || joinState.status === AsyncStatus.Error; diff --git a/src/app/features/room/RoomTombstone.tsx b/src/app/features/room/RoomTombstone.tsx index 24f0b80e..3556e6fc 100644 --- a/src/app/features/room/RoomTombstone.tsx +++ b/src/app/features/room/RoomTombstone.tsx @@ -8,6 +8,7 @@ import { Membership } from '../../../types/matrix/room'; import { RoomInputPlaceholder } from './RoomInputPlaceholder'; import { useRoomNavigate } from '../../hooks/useRoomNavigate'; import { getViaServers } from '../../plugins/via-servers'; +import { joinEstateRoomOrThrow } from '../estate/joinEstateRoom'; type RoomTombstoneProps = { roomId: string; body?: string; replacementRoomId: string }; export function RoomTombstone({ roomId, body, replacementRoomId }: RoomTombstoneProps) { @@ -18,16 +19,14 @@ export function RoomTombstone({ roomId, body, replacementRoomId }: RoomTombstone useCallback(() => { const currentRoom = mx.getRoom(roomId); const via = currentRoom ? getViaServers(currentRoom) : []; - return mx.joinRoom(replacementRoomId, { - viaServers: via, - }); + return joinEstateRoomOrThrow(mx, replacementRoomId, via); }, [mx, roomId, replacementRoomId]) ); const replacementRoom = mx.getRoom(replacementRoomId); const handleOpen = () => { if (replacementRoom) navigateRoom(replacementRoom.roomId); - if (joinState.status === AsyncStatus.Success) navigateRoom(joinState.data.roomId); + if (joinState.status === AsyncStatus.Success) navigateRoom(joinState.data); }; return ( diff --git a/src/app/features/scene/README.md b/src/app/features/scene/README.md new file mode 100644 index 00000000..ad4c13b1 --- /dev/null +++ b/src/app/features/scene/README.md @@ -0,0 +1,79 @@ +# Background scene + +A two-pass WebGL2 scene for the client's background canvas: an infinite terrain of CultMath +noise with a world tree at its centre, dusk atmosphere and clouds, and a camera that pulls back +from the tree on login. It is a self-contained module; nothing in the client imports it yet. +Integration (a canvas under the app, `startBackgroundScene`, `login()` when the session starts, +`dispose()`) is Void's cut `terrain-background`; the personas (`orbiting-personas`) and the squirrel +are later cuts. This PR is the render core plus the contract the host needs: flag, tier, failure +callbacks and the creature sizing rule. Nothing has drawn a creature. + +## Entry points + +- `index.ts` `startBackgroundScene(config.backgroundScene, capabilities, options)` is the host's + door. The client config's `backgroundScene.tier` (`off` default, `still`, `low`, `full`) is the + feature flag and the ceiling; `tier.ts` lowers it for `saveData`, low memory or few cores. At + `off` nothing is imported and no canvas context is requested; otherwise `Scene.ts` loads on + demand, so it stays out of the main chunk; if that chunk fails to load the error goes to + `onCompileFail` and the door returns null, it does not throw. +- `Scene.ts` `createScene({ canvas, tier, onCompileFail, onContextLost, ... })` returns + `{ login, dispose }`, or `null` (keep the CSS ground then) when the tier is `off`, WebGL2 is missing, neither + `EXT_color_buffer_float` nor `EXT_color_buffer_half_float` exists (`onUnavailable` says which; with + only the half extension the prepass start target is RGBA16F, written rounded down), or a shader fails to compile (`onCompileFail` gets the + `ShaderCompileError`; programs already linked are freed). `still` is a live rendered frame, not a + baked image: it runs the full pipeline once at the top level and stops; `low` starts the ladder at level 2; `full` at level 0. A lost WebGL context + stops the scene for good and calls `onContextLost`; nothing rebuilds it, the host decides what to + show. It caps at 30 fps, pauses when + the tab is hidden, renders 32 frames and stops under `prefers-reduced-motion` (a change of the + preference while it runs freezes or thaws it), and steps down a + quality ladder (`budget.ts`) against a 4 ms GPU budget when timer queries exist. +- `creatures.ts` the creature record and `creatureScale`: a neutral creature is one fixed size and + overrides `sizeBucket`. The bucket names and scales are placeholders until the persona cut owns them. +- `camera.ts` the pull-back path as pure functions (`pullProgress`, `cameraPose`). +- `world.ts` every world number, and the Lipschitz bounds the sphere trace steps by. The GLSL + `#define`s are generated from it; no number is written twice. +- `shaders/` `sdf.glsl` (the distance bound pass 0 and pass 1 share), pass 0 `prepass.frag.glsl`, pass 1 `terrain.frag.glsl`, pass 2 `atmosphere.frag.glsl`, `resolve.frag.glsl` (TAA), + `composite.frag.glsl`, plus CultLib's vendored GLSL (`shaders/README.md`). + +## How it renders + +0. **Cone prepass** (`prepass.frag.glsl`, one texel per 8x8 tile of pass 1): marches one ray per + tile, shrinking each step by the widest any ray of the tile strays from it, so every step is + free for the whole tile. It stores the distance no ray of the tile can have hit before, or + `W_FAR` when the whole tile climbs out above the highest geometry. Pass 1 starts its rays + there. `createScene({ prepass: false })` skips it (the measurement baseline). +1. **Solid pass** (`terrain.frag.glsl`, 0.4-0.75 of canvas resolution): sphere trace of + `min(terrain, tree)`. Terrain height is `mask(r) * (A * fbm + E * phacelle)`, with + `cultmath_fbm_grad(...).w` and `cultmath_phacelle(...).cos.w`. It writes lit colour with + coverage and the hit distance (second render target). +2. **Atmosphere pass** (0.25-0.5 of canvas): closed-form exponential height fog plus a cloud + slab marched with a few jittered steps, both only up to the hit distance from pass 1. +3. **Resolve**: TAA on the atmosphere (Halton jitter, history reprojected by the pass-1 depth, + 3x3 neighbourhood clamp, 1/8 blend; reset on resize, level change and wake). +4. **Composite** at canvas resolution: solid over analytic sky, then the atmosphere, ACES + tonemap, dither. + +`createScene({ onTraceStats })` measures the trace: pass 1 gets a step-count output and an extra +render target, read back every 8th frame through a pixel-pack buffer and a fence (never waited +on), and reports mean steps per pixel. Unset, neither the output nor the target exists. + +## The step bound, and what is not proven + +The trace steps by `(y - h(x, z)) / sqrt(1 + G^2)`, `G` the Lipschitz bound of the height field, +computed from `SNOISE_LIPSCHITZ` (CultMath's constant for `snoise`) in `world.ts`. Above the +envelope (fbm plus the erosion amplitude) the step uses the envelope's bound alone and Phacelle is +not evaluated. + +- The Phacelle gradient bound `phacelleGradBound` (7.0) is Libby's measured sup (6.63 at + |side| = 1.5, n = 0, CultLib PR #1 head 990521b3) plus margin: a measurement, not a proof. + The shader fixes roughness n = 0; a nonzero n needs the bound divided by (1 - n). +- The bound makes grazing rays crawl. A ray that exhausts its steps while still below the highest + terrain is ended as a distant surface (fog hides it). That is a visible approximation, not a + hit proven by the march. +- The low-resolution atmosphere samples pass-1 depth nearest, so silhouettes of near objects + carry a thin fog halo. + +## Measured and not measured + +See the pull request or `harness/scene/README.md` for the numbers and how they were taken. They +come from a software rasterizer, not a GPU, and nothing has been run on a phone. diff --git a/src/app/features/scene/Scene.test.ts b/src/app/features/scene/Scene.test.ts new file mode 100644 index 00000000..6493ad88 --- /dev/null +++ b/src/app/features/scene/Scene.test.ts @@ -0,0 +1,443 @@ +import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest'; +import { createScene, ShaderCompileError, HALF_START_BIAS, HALF_START_SCALE } from './Scene'; +import { WORLD } from './world'; +import { LEVELS } from './budget'; +import { fragmentSource } from './shaderSource'; +import { startBackgroundScene } from './index'; + +type Fn = Mock; + +/** A GL context where every call is a spy and every constant is 1. Compiles succeed until `failFrom`. */ +function fakeGl(failFrom = Infinity, missing: string[] = []) { + let compiles = 0; + const calls: Record = {}; + const spy = (name: string, impl: (...a: any[]) => unknown = () => ({})): Fn => { + if (!calls[name]) calls[name] = vi.fn(impl); + return calls[name]; + }; + const gl = new Proxy({} as Record, { + get(_, prop: string) { + // the two start-target formats must be told apart; every other constant is 1 + if (prop === 'RGBA32F') return 32; + if (prop === 'RGBA16F') return 16; + if (/^[A-Z][A-Z0-9_]*$/.test(prop)) return 1; + if (prop === 'getExtension') + return spy(prop, (n: string) => (missing.includes(n) ? null : {})); + if (prop === 'compileShader') { + return spy(prop, () => { + compiles += 1; + }); + } + if (prop === 'getShaderParameter') return spy(prop, () => compiles < failFrom); + if (prop === 'getShaderInfoLog') return spy(prop, () => 'ERROR: 0:1: bad'); + if (prop === 'getProgramParameter') return spy(prop, () => true); + return spy(prop); + }, + }); + return { gl: gl as unknown as WebGL2RenderingContext, calls }; +} + +function fakeCanvas(gl: WebGL2RenderingContext | null) { + const canvas = new EventTarget() as EventTarget & { + clientWidth: number; + clientHeight: number; + width: number; + height: number; + getContext: Fn; + }; + canvas.clientWidth = 400; + canvas.clientHeight = 300; + canvas.width = 0; + canvas.height = 0; + canvas.getContext = vi.fn(() => gl); + return canvas; +} + +let rafQueue: Array<(t: number) => void>; +let cancelled: number; +let win: EventTarget; +let motion: EventTarget & { matches: boolean }; +let doc: EventTarget & { visibilityState: string }; + +beforeEach(() => { + rafQueue = []; + cancelled = 0; + motion = Object.assign(new EventTarget(), { matches: false }); + win = Object.assign(new EventTarget(), { + devicePixelRatio: 1, + matchMedia: () => motion, + }); + doc = Object.assign(new EventTarget(), { visibilityState: 'visible' }); + vi.stubGlobal('window', win); + vi.stubGlobal('document', doc); + vi.stubGlobal('requestAnimationFrame', (cb: (t: number) => void) => rafQueue.push(cb)); + vi.stubGlobal('cancelAnimationFrame', () => { + cancelled += 1; + }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +const runFrame = (now: number) => { + const cb = rafQueue.shift(); + if (!cb) throw new Error('no frame was scheduled'); + cb(now); +}; +const asCanvas = (c: ReturnType) => c as unknown as HTMLCanvasElement; + +describe('createScene tiers', () => { + it("'off' returns null without asking the canvas for a context", () => { + const { gl } = fakeGl(); + const canvas = fakeCanvas(gl); + expect(createScene({ canvas: asCanvas(canvas), tier: 'off' })).toBeNull(); + expect(canvas.getContext).not.toHaveBeenCalled(); + expect(rafQueue).toHaveLength(0); + }); + + it("'still' draws one frame and schedules no more", () => { + const { gl, calls } = fakeGl(); + const scene = createScene({ canvas: asCanvas(fakeCanvas(gl)), tier: 'still' }); + expect(scene).not.toBeNull(); + runFrame(1000); + expect(calls.drawArrays).toHaveBeenCalledTimes(5); // prepass and four passes, one frame + expect(rafQueue).toHaveLength(0); + }); + + it("'still' ignores login(): the pull-back is for a moving scene", () => { + const { gl } = fakeGl(); + const scene = createScene({ canvas: asCanvas(fakeCanvas(gl)), tier: 'still' }); + runFrame(1000); + scene?.login(); + expect(rafQueue).toHaveLength(0); + }); + + it("'full' keeps scheduling frames", () => { + const { gl } = fakeGl(); + createScene({ canvas: asCanvas(fakeCanvas(gl)), tier: 'full' }); + runFrame(1000); + expect(rafQueue).toHaveLength(1); + }); + + it("'low' traces with a cheaper level than 'full'", () => { + const traceSteps = (tier: 'low' | 'full') => { + rafQueue.length = 0; // each call is a fresh scene + const { gl, calls } = fakeGl(); + createScene({ canvas: asCanvas(fakeCanvas(gl)), tier }); + runFrame(1000); + // u_maxSteps is the only uniform1i whose value is a trace-step count + return calls.uniform1i.mock.calls.map((c) => c[1]).filter((v) => v >= 40); + }; + expect(traceSteps('full')).toContain(LEVELS[0].traceSteps); + expect(traceSteps('low')).toContain(LEVELS[2].traceSteps); + expect(traceSteps('low')).not.toContain(LEVELS[0].traceSteps); + }); + + it('returns null when WebGL2 is missing, so the host keeps its ground', () => { + expect(createScene({ canvas: asCanvas(fakeCanvas(null)) })).toBeNull(); + }); +}); + +describe('cone prepass', () => { + const programsFor = (prepass?: boolean) => { + rafQueue.length = 0; + const { gl, calls } = fakeGl(); + createScene({ canvas: asCanvas(fakeCanvas(gl)), prepass }); + return calls.createProgram.mock.calls.length; + }; + + it('is one more program than the baseline, and off means off', () => { + expect(programsFor(true)).toBe(programsFor(false) + 1); + expect(programsFor(undefined)).toBe(programsFor(true)); + }); + + it('draws before pass 1 and only when on', () => { + const drawsFor = (prepass: boolean) => { + rafQueue.length = 0; + const { gl, calls } = fakeGl(); + createScene({ canvas: asCanvas(fakeCanvas(gl)), prepass }); + runFrame(1000); + return calls.drawArrays.mock.calls.length; + }; + expect(drawsFor(true)).toBe(drawsFor(false) + 1); + }); + + it('the trace-step output exists in pass 1 only when the host asked to measure', () => { + expect(fragmentSource('terrain')).not.toContain('TRACE_STATS 1'); + expect(fragmentSource('terrain', '#define TRACE_STATS 1\n')).toContain('#define TRACE_STATS 1'); + }); + + it('reads the step bytes tightly packed, whatever the width', () => { + rafQueue.length = 0; + const { gl, calls } = fakeGl(); + createScene({ canvas: asCanvas(fakeCanvas(gl)), onTraceStats: () => {} }); + runFrame(1000); + // fake constants are all 1: PACK_ALIGNMENT is 1, so a restore to 4 is the only other call + const packs = calls.pixelStorei.mock.calls.map((c) => c[1]); + expect(packs[0]).toBe(1); + expect(packs).toContain(4); + expect(calls.readPixels.mock.invocationCallOrder[0]).toBeGreaterThan( + calls.pixelStorei.mock.invocationCallOrder[0] + ); + }); + + it('shares one distance bound between pass 1 and the prepass', () => { + const bound = (stage: 'terrain' | 'prepass') => + fragmentSource(stage).match(/float sceneDistance\([^)]*\)\s*\{[^}]*\}/)?.[0]; + expect(bound('prepass')).toBeDefined(); + expect(bound('prepass')).toBe(bound('terrain')); + expect(fragmentSource('atmosphere')).not.toContain('sceneDistance'); + }); +}); + +describe('device without EXT_color_buffer_float', () => { + const FLOAT = 'EXT_color_buffer_float'; + const HALF = 'EXT_color_buffer_half_float'; + const build = (missing: string[], extra: Partial[0]> = {}) => { + rafQueue.length = 0; + const { gl, calls } = fakeGl(Infinity, missing); + const onUnavailable = vi.fn(); + const scene = createScene({ canvas: asCanvas(fakeCanvas(gl)), onUnavailable, ...extra }); + if (scene) runFrame(1000); // targets are allocated on the first frame + const formats = calls.texStorage2D?.mock.calls.map((c) => c[2]) ?? []; + const sources = (calls.shaderSource?.mock.calls ?? []).map((c) => c[1] as string); + return { + scene, + onUnavailable, + formats, + prepassSource: sources.find((x) => x.includes('o_start')), + }; + }; + + it('names why the scene is unavailable: no WebGL2, or neither float nor half-float colour buffers', () => { + const none = vi.fn(); + createScene({ canvas: asCanvas(fakeCanvas(null)), onUnavailable: none }); + expect(none).toHaveBeenCalledTimes(1); + expect(none).toHaveBeenCalledWith('no-webgl2'); + const r = build([FLOAT, HALF]); + expect(r.scene).toBeNull(); + expect(r.onUnavailable).toHaveBeenCalledTimes(1); + expect(r.onUnavailable).toHaveBeenCalledWith('no-float-or-half-float-colour-buffer'); + expect(build([]).onUnavailable).not.toHaveBeenCalled(); + }); + + it('with only half-float colour buffers, starts the prepass in an RGBA16F target with the rounding-down write', () => { + const r = build([FLOAT]); + expect(r.scene?.startFormat).toBe('RGBA16F'); + expect(r.formats).not.toContain(32); + expect(r.prepassSource).toContain('#define START_HALF 1'); + expect(r.prepassSource).toContain(`#define START_HALF_SCALE ${HALF_START_SCALE}`); + }); + + it('keeps RGBA32F and the plain write where it is renderable, unless asked for half', () => { + const wide = build([HALF]); + expect(wide.scene?.startFormat).toBe('RGBA32F'); + expect(wide.formats).toContain(32); + expect(wide.prepassSource).not.toContain('#define START_HALF'); + expect(build([], { halfStart: true }).scene?.startFormat).toBe('RGBA16F'); + }); + + // The shader writes max(0, t*SCALE - BIAS) into a half, which rounds to nearest (even); this is + // that rounding. The GPU's rounding of a half write is the unknown a phone must confirm. + const toHalf = (x: number) => { + if (x === 0) return 0; + let e = Math.max(Math.floor(Math.log2(x)), -14); + if (2 ** e > x) e -= 1; + const ulp = 2 ** (e - 10); + const q = x / ulp; + let r = Math.floor(q); + const d = q - r; + if (d > 0.5 || (d === 0.5 && r % 2 === 1)) r += 1; + return r * ulp; + }; + const stored = (t: number) => toHalf(Math.max(0, t * HALF_START_SCALE - HALF_START_BIAS)); + + it('stores a start distance never past the true one, and not much short of it', () => { + let seed = 7; + const rand = () => (seed = (seed * 16807) % 2147483647) / 2147483647; + const ts = [0, 1e-6, 1e-4, 6.1e-5, 0.01, 1, 1023.9, 1024, 2047.9]; + for (let i = 0; i < 20000; i++) ts.push(rand() * WORLD.far, rand() * rand() * 4); + for (let t = 0; t <= WORLD.far; t += 0.37) ts.push(t); + for (const t of ts) { + expect(stored(t)).toBeLessThanOrEqual(t); + expect(t - stored(t)).toBeLessThanOrEqual(t * 0.0021 + 6e-5); + } + }); + + it('holds the miss sentinel W_FAR exactly, so pass 1 still skips a tile the prepass proved empty', () => { + expect(toHalf(WORLD.far)).toBe(WORLD.far); + }); +}); + +describe('shader compile failure', () => { + it('reports through onCompileFail, returns null, and frees the programs already linked', () => { + const { gl, calls } = fakeGl(4); // terrain compiles both shaders; atmosphere's fragment is the 4th and fails + const onCompileFail = vi.fn(); + const scene = createScene({ canvas: asCanvas(fakeCanvas(gl)), onCompileFail }); + expect(scene).toBeNull(); + expect(onCompileFail).toHaveBeenCalledTimes(1); + const err = onCompileFail.mock.calls[0][0] as ShaderCompileError; + expect(err).toBeInstanceOf(ShaderCompileError); + expect(err.stage).toBe('atmosphere'); + expect(err.log).toContain('bad'); + expect(calls.deleteProgram).toHaveBeenCalled(); // the terrain program that did link + expect(rafQueue).toHaveLength(0); + }); + + it('does not throw out of createScene when no callback is given', () => { + const { gl } = fakeGl(0); + expect(() => createScene({ canvas: asCanvas(fakeCanvas(gl)) })).not.toThrow(); + }); +}); + +describe('context loss', () => { + it('stops the loop, tells the host, and detaches every listener', () => { + const { gl } = fakeGl(); + const canvas = fakeCanvas(gl); + const onContextLost = vi.fn(); + createScene({ canvas: asCanvas(canvas), onContextLost }); + const lost = new Event('webglcontextlost', { cancelable: true }); + canvas.dispatchEvent(lost); + expect(lost.defaultPrevented).toBe(true); + expect(onContextLost).toHaveBeenCalledTimes(1); + expect(cancelled).toBe(1); + // listeners are gone: a second loss, a resize and a visibility change do nothing + canvas.dispatchEvent(new Event('webglcontextlost', { cancelable: true })); + win.dispatchEvent(new Event('resize')); + doc.dispatchEvent(new Event('visibilitychange')); + expect(onContextLost).toHaveBeenCalledTimes(1); + expect(rafQueue.length).toBe(1); // only the frame queued before the loss, never run + }); + + it('does not render a queued frame after the loss', () => { + const { gl, calls } = fakeGl(); + const canvas = fakeCanvas(gl); + createScene({ canvas: asCanvas(canvas) }); + canvas.dispatchEvent(new Event('webglcontextlost', { cancelable: true })); + runFrame(1000); + expect(calls.drawArrays).toBeUndefined(); + }); +}); + +describe('prefers-reduced-motion', () => { + const runAll = (t0: number) => { + let t = t0; + let n = 0; + while (rafQueue.length && n < 200) { + runFrame(t); + t += 40; + n += 1; + } + return n; + }; + + it('freezes to a bounded run when the preference turns on mid-flight', () => { + const { gl } = fakeGl(); + createScene({ canvas: asCanvas(fakeCanvas(gl)) }); + runFrame(1000); + expect(rafQueue).toHaveLength(1); // moving: keeps going + motion.matches = true; + motion.dispatchEvent(new Event('change')); + expect(runAll(1040)).toBeLessThan(200); // ends + expect(rafQueue).toHaveLength(0); + }); + + it('thaws a frozen scene when the preference turns off', () => { + motion.matches = true; + const { gl } = fakeGl(); + createScene({ canvas: asCanvas(fakeCanvas(gl)) }); + runAll(1000); + expect(rafQueue).toHaveLength(0); + motion.matches = false; + motion.dispatchEvent(new Event('change')); + expect(rafQueue).toHaveLength(1); + runFrame(5000); + expect(rafQueue).toHaveLength(1); // unbounded again + }); + + it('removes the listener on dispose and on context loss', () => { + const { gl } = fakeGl(); + const scene = createScene({ canvas: asCanvas(fakeCanvas(gl)) }); + scene?.dispose(); + rafQueue.length = 0; + motion.dispatchEvent(new Event('change')); + expect(rafQueue).toHaveLength(0); + + const lostCanvas = fakeCanvas(fakeGl().gl); + createScene({ canvas: asCanvas(lostCanvas) }); + lostCanvas.dispatchEvent(new Event('webglcontextlost', { cancelable: true })); + rafQueue.length = 0; + motion.matches = true; + motion.dispatchEvent(new Event('change')); + expect(rafQueue).toHaveLength(0); + }); + + it('a still does not listen: one frame stays one frame', () => { + const { gl } = fakeGl(); + createScene({ canvas: asCanvas(fakeCanvas(gl)), tier: 'still' }); + runFrame(1000); + motion.matches = false; + motion.dispatchEvent(new Event('change')); + expect(rafQueue).toHaveLength(0); + }); +}); + +describe('startBackgroundScene', () => { + it("does not request a context when the config leaves the tier 'off'", async () => { + const { gl } = fakeGl(); + const canvas = fakeCanvas(gl); + expect(await startBackgroundScene(undefined, {}, { canvas: asCanvas(canvas) })).toBeNull(); + expect( + await startBackgroundScene({ tier: 'off' }, {}, { canvas: asCanvas(canvas) }) + ).toBeNull(); + expect(canvas.getContext).not.toHaveBeenCalled(); + }); + + it('applies the device ceiling: saveData runs a still, one frame', async () => { + const { gl, calls } = fakeGl(); + const scene = await startBackgroundScene( + { tier: 'full' }, + { saveData: true }, + { canvas: asCanvas(fakeCanvas(gl)) } + ); + expect(scene).not.toBeNull(); + runFrame(1000); + expect(calls.drawArrays).toHaveBeenCalledTimes(5); + expect(rafQueue).toHaveLength(0); + }); + + it('reports a failed scene chunk to onCompileFail and returns null instead of throwing', async () => { + vi.resetModules(); + vi.doMock('./Scene', () => { + throw new Error('Failed to fetch dynamically imported module'); + }); + try { + const { startBackgroundScene: start } = await import('./index'); + const onCompileFail = vi.fn(); + const canvas = fakeCanvas(fakeGl().gl); + const scene = await start({ tier: 'full' }, {}, { canvas: asCanvas(canvas), onCompileFail }); + expect(scene).toBeNull(); + expect(onCompileFail).toHaveBeenCalledTimes(1); + expect(onCompileFail.mock.calls[0][0]).toBeInstanceOf(Error); + expect(canvas.getContext).not.toHaveBeenCalled(); + // and with no callback it still does not throw + expect(await start({ tier: 'full' }, {}, { canvas: asCanvas(canvas) })).toBeNull(); + } finally { + vi.doUnmock('./Scene'); + vi.resetModules(); + } + }); + + it('hands compile failure to the host and returns null, the fall to the CSS ground', async () => { + const { gl } = fakeGl(0); + const onCompileFail = vi.fn(); + const scene = await startBackgroundScene( + { tier: 'full' }, + {}, + { canvas: asCanvas(fakeCanvas(gl)), onCompileFail } + ); + expect(scene).toBeNull(); + expect(onCompileFail).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/features/scene/Scene.ts b/src/app/features/scene/Scene.ts new file mode 100644 index 00000000..70580ab8 --- /dev/null +++ b/src/app/features/scene/Scene.ts @@ -0,0 +1,638 @@ +import { fragmentSource, vertexSource, Stage } from './shaderSource'; +import { cameraPose, pullProgress, CameraPose } from './camera'; +import { BudgetLadder, Level, LEVELS } from './budget'; +import type { SceneTier } from './tier'; +import { PALETTE } from './palette'; + +/** Phone GPU budget for the whole scene, from the ground-cut doc's budget table. */ +export const GPU_BUDGET_MS = 4.0; +const FRAME_INTERVAL_MS = 1000 / 30; +const REDUCED_MOTION_FRAMES = 32; +const MAX_DPR = 1.5; + +export type PassName = 'terrain' | 'atmosphere' | 'resolve' | 'composite' | 'prepass'; +const PASSES: PassName[] = ['terrain', 'atmosphere', 'resolve', 'composite', 'prepass']; +/** Pass 1 pixels per prepass tile edge. */ +const TILE = 8; + +export interface SceneStats { + /** GPU milliseconds per pass for one finished frame (timer queries; absent without the extension). */ + passMs: Record; + level: number; + solidSize: [number, number]; + atmosphereSize: [number, number]; +} + +/** Trace cost over one finished readback: mean distance evaluations per pass-1 pixel. */ +export interface TraceStats { + /** Mean pass-1 steps per pixel. */ + traceSteps: number; + /** Mean prepass steps per tile; 0 without the prepass. */ + prepassSteps: number; + /** traceSteps plus the prepass's steps spread over the pixels of each tile. */ + stepsPerPixel: number; +} + +/** Measurement seam: the harness rewrites a stage's source to time a variant. Never set in the client. */ +export type SourcePatch = (stage: Stage, source: string) => string; + +export class ShaderCompileError extends Error { + constructor(readonly stage: Stage | 'vertex', readonly log: string) { + super(`${stage} shader failed to compile:\n${log}`); + } +} + +/** + * Why createScene returned null for want of a device feature. Rendering to half floats needs + * EXT_color_buffer_float or EXT_color_buffer_half_float; the prepass start target is RGBA32F with + * the first and RGBA16F with only the second. + */ +export type SceneUnavailable = 'no-webgl2' | 'no-float-or-half-float-colour-buffer'; + +/** + * The RGBA16F prepass start is written as `t * HALF_START_SCALE - HALF_START_BIAS`. A half rounds + * to nearest with relative error at most 2^-11, so shaving 2^-10 of t leaves the stored value + * below t, and the bias covers the absolute error where halves go subnormal. The shader reads both + * through #defines, so this is the one copy. + */ +export const HALF_START_SCALE = 1 - 1 / 1024; +export const HALF_START_BIAS = 1 / 32768; +const HALF_START_DEFINES = `#define START_HALF 1\n#define START_HALF_SCALE ${HALF_START_SCALE}\n#define START_HALF_BIAS ${HALF_START_BIAS}\n`; + +/** Ladder level a 'low' scene starts at: the cost of a weak device's first 45 frames is never paid at full. */ +const LOW_START_LEVEL = 2; +/** The 'still' tier draws one frame at this level and stops. */ +const STILL_LEVEL = LEVELS[0]; + +export interface SceneOptions { + canvas: HTMLCanvasElement; + /** + * 'off' returns null without touching the canvas. 'still' draws one frame and stops. 'low' starts + * the adaptive ladder low. 'full' starts it at the top. Default 'full'. + */ + tier?: SceneTier; + /** createScene is about to return null because the device lacks a feature, and which one. */ + onUnavailable?: (reason: SceneUnavailable) => void; + /** Force the RGBA16F prepass start target even where RGBA32F is renderable (measurement, tests). */ + halfStart?: boolean; + /** A shader failed to compile or link. createScene frees what it built and returns null. */ + onCompileFail?: (e: ShaderCompileError) => void; + /** The WebGL context was lost. The scene stops for good; the host keeps its ground. */ + onContextLost?: () => void; + /** Called per finished frame when timer queries exist. */ + onStats?: (s: SceneStats) => void; + /** + * Measurement: mean sphere-trace steps per pass-1 pixel, read back every few frames. Setting it + * compiles pass 1 with a step-count output and an extra render target; unset, neither exists. + */ + onTraceStats?: (s: TraceStats) => void; + /** Weather knob, 0 clear .. 1 overcast. */ + coverage?: number; + /** Fixed level instead of the adaptive ladder (measurement). */ + fixedLevel?: Level; + /** Cone prepass that starts pass-1 rays past empty space. Default true; false is the measurement baseline. */ + prepass?: boolean; + /** Hold the camera at this pull-back progress, 0..1, instead of following login (measurement). */ + fixedPull?: number; + /** Ignore the 30 fps cap and the visibility pause (measurement). */ + unthrottled?: boolean; + patchSource?: SourcePatch; +} + +export interface Scene { + /** Texture format of the prepass start target. */ + readonly startFormat: 'RGBA32F' | 'RGBA16F'; + /** Start the camera pull-back from the tree. */ + login(): void; + dispose(): void; +} + +function compile( + gl: WebGL2RenderingContext, + type: number, + source: string, + stage: Stage | 'vertex' +): WebGLShader { + const sh = gl.createShader(type)!; + gl.shaderSource(sh, source); + gl.compileShader(sh); + if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) { + const log = gl.getShaderInfoLog(sh) ?? ''; + gl.deleteShader(sh); + throw new ShaderCompileError(stage, log); + } + return sh; +} + +export function linkStage( + gl: WebGL2RenderingContext, + stage: Stage, + patch?: SourcePatch, + defines?: string +): WebGLProgram { + const vs = compile(gl, gl.VERTEX_SHADER, vertexSource, 'vertex'); + const fs = compile( + gl, + gl.FRAGMENT_SHADER, + patch ? patch(stage, fragmentSource(stage, defines)) : fragmentSource(stage, defines), + stage + ); + const prog = gl.createProgram()!; + gl.attachShader(prog, vs); + gl.attachShader(prog, fs); + gl.linkProgram(prog); + gl.deleteShader(vs); + gl.deleteShader(fs); + if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) { + const log = gl.getProgramInfoLog(prog) ?? ''; + gl.deleteProgram(prog); + throw new ShaderCompileError(stage, log); + } + return prog; +} + +interface Target { + tex: WebGLTexture; + w: number; + h: number; +} + +function halton(i: number, base: number): number { + let f = 1; + let r = 0; + let n = i; + while (n > 0) { + f /= base; + r += f * (n % base); + n = Math.floor(n / base); + } + return r; +} + +/** Null when WebGL2 or a float colour buffer is missing (`onUnavailable` says which); the host keeps its CSS ground. */ +export function createScene(opts: SceneOptions): Scene | null { + const { canvas } = opts; + const tier = opts.tier ?? 'full'; + if (tier === 'off') return null; + const gl = canvas.getContext('webgl2', { + antialias: false, + alpha: false, + depth: false, + stencil: false, + powerPreference: 'low-power', + }); + if (!gl) { + opts.onUnavailable?.('no-webgl2'); + return null; + } + const wide = !!gl.getExtension('EXT_color_buffer_float'); + if (!wide && !gl.getExtension('EXT_color_buffer_half_float')) { + opts.onUnavailable?.('no-float-or-half-float-colour-buffer'); + return null; + } + // Every other target is a half; only the prepass start can ask for 32 bits. + const halfStart = opts.halfStart || !wide; + gl.getExtension('OES_texture_float_linear'); + const timer = gl.getExtension('EXT_disjoint_timer_query_webgl2') as { + TIME_ELAPSED_EXT: number; + GPU_DISJOINT_EXT: number; + } | null; + + const usePrepass = opts.prepass ?? true; + const passes = usePrepass ? PASSES : PASSES.filter((p) => p !== 'prepass'); + const programs = {} as Record; + try { + passes.forEach((p) => { + const defines = + p === 'terrain' && opts.onTraceStats + ? '#define TRACE_STATS 1\n' + : p === 'prepass' && halfStart + ? HALF_START_DEFINES + : undefined; + programs[p] = linkStage(gl, p, opts.patchSource, defines); + }); + } catch (e) { + passes.forEach((p) => { + if (programs[p]) gl.deleteProgram(programs[p]); + }); + if (!(e instanceof ShaderCompileError)) throw e; + opts.onCompileFail?.(e); + return null; + } + const uniforms = new Map>(); + const loc = (p: WebGLProgram, name: string) => { + let m = uniforms.get(p); + if (!m) uniforms.set(p, (m = new Map())); + if (!m.has(name)) m.set(name, gl.getUniformLocation(p, name)); + return m.get(name) ?? null; + }; + + const vao = gl.createVertexArray(); + gl.bindVertexArray(vao); + + const startFormat = halfStart ? gl.RGBA16F : gl.RGBA32F; + + const makeTarget = (w: number, h: number, format: number, filter: number): Target => { + const tex = gl.createTexture()!; + gl.bindTexture(gl.TEXTURE_2D, tex); + gl.texStorage2D(gl.TEXTURE_2D, 1, format, w, h); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + return { tex, w, h }; + }; + + const fbos = { + solid: gl.createFramebuffer()!, + pre: gl.createFramebuffer()!, + atm: gl.createFramebuffer()!, + hist: [gl.createFramebuffer()!, gl.createFramebuffer()!], + }; + let solid: Target; + let depth: Target; + let steps: Target | undefined; + let startTex: Target; + let atm: Target; + let hist: [Target, Target]; + const freeTargets = () => { + [solid, depth, steps, startTex, atm, hist?.[0], hist?.[1]].forEach((t) => { + if (t) gl.deleteTexture(t.tex); + }); + }; + + const attach = (fbo: WebGLFramebuffer, targets: Target[]) => { + gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); + targets.forEach((t, i) => + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0 + i, gl.TEXTURE_2D, t.tex, 0) + ); + gl.drawBuffers(targets.map((_, i) => gl.COLOR_ATTACHMENT0 + i)); + }; + + const still = tier === 'still'; + const ladder = new BudgetLadder(GPU_BUDGET_MS, 45, tier === 'low' ? LOW_START_LEVEL : 0); + const level = (): Level => opts.fixedLevel ?? (still ? STILL_LEVEL : ladder.current); + + // Trace-step readback: one pixel-pack read in flight at a time, polled by fence, never waited on. + // Pass 1's step bytes and the prepass's RGBA floats (g: steps) come back together. + const READBACK_EVERY = 8; + interface Readback { + fence: WebGLSync; + pixels: number; + tiles: number; + } + let readback: Readback | undefined; + const packSteps = opts.onTraceStats ? gl.createBuffer() : null; + const packStart = opts.onTraceStats && usePrepass ? gl.createBuffer() : null; + const discardReadback = () => { + if (readback) gl.deleteSync(readback.fence); + readback = undefined; + }; + const startReadback = () => { + if (!steps || readback) return; + gl.pixelStorei(gl.PACK_ALIGNMENT, 1); // R8 rows are not 4-byte multiples at every width + gl.bindBuffer(gl.PIXEL_PACK_BUFFER, packSteps); + gl.bufferData(gl.PIXEL_PACK_BUFFER, steps.w * steps.h, gl.STREAM_READ); + gl.bindFramebuffer(gl.FRAMEBUFFER, fbos.solid); + gl.readBuffer(gl.COLOR_ATTACHMENT0 + 2); + gl.readPixels(0, 0, steps.w, steps.h, gl.RED, gl.UNSIGNED_BYTE, 0); + let tiles = 0; + if (packStart) { + tiles = startTex.w * startTex.h; + gl.bindBuffer(gl.PIXEL_PACK_BUFFER, packStart); + gl.bufferData(gl.PIXEL_PACK_BUFFER, tiles * 16, gl.STREAM_READ); + gl.bindFramebuffer(gl.FRAMEBUFFER, fbos.pre); + gl.readPixels(0, 0, startTex.w, startTex.h, gl.RGBA, gl.FLOAT, 0); + } + gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null); + gl.pixelStorei(gl.PACK_ALIGNMENT, 4); + readback = { + fence: gl.fenceSync(gl.SYNC_GPU_COMMANDS_COMPLETE, 0)!, + pixels: steps.w * steps.h, + tiles, + }; + }; + const pollReadback = () => { + if (!readback) return; + const status = gl.clientWaitSync(readback.fence, 0, 0); + if (status !== gl.ALREADY_SIGNALED && status !== gl.CONDITION_SATISFIED) return; + const { pixels, tiles } = readback; + const bytes = new Uint8Array(pixels); + gl.bindBuffer(gl.PIXEL_PACK_BUFFER, packSteps); + gl.getBufferSubData(gl.PIXEL_PACK_BUFFER, 0, bytes); + let preSum = 0; + if (packStart) { + const floats = new Float32Array(tiles * 4); + gl.bindBuffer(gl.PIXEL_PACK_BUFFER, packStart); + gl.getBufferSubData(gl.PIXEL_PACK_BUFFER, 0, floats); + for (let i = 1; i < floats.length; i += 4) preSum += floats[i]; + } + gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null); + let sum = 0; + for (let i = 0; i < bytes.length; i += 1) sum += bytes[i]; + discardReadback(); + const traceSteps = sum / pixels; + const prepassSteps = tiles ? preSum / tiles : 0; + opts.onTraceStats?.({ + traceSteps, + prepassSteps, + stepsPerPixel: traceSteps + (prepassSteps * tiles) / pixels, + }); + }; + + let canvasW = 0; + let canvasH = 0; + let sizedFor: Level | undefined; + let resetHistory = true; + + const ensureSize = () => { + const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR); + const w = Math.max(2, Math.round(canvas.clientWidth * dpr)); + const h = Math.max(2, Math.round(canvas.clientHeight * dpr)); + const lv = level(); + if (w === canvasW && h === canvasH && lv === sizedFor) return; + canvasW = w; + canvasH = h; + sizedFor = lv; + canvas.width = w; + canvas.height = h; + freeTargets(); + const sw = Math.max(2, Math.round(w * lv.solidScale)); + const sh = Math.max(2, Math.round(h * lv.solidScale)); + const aw = Math.max(2, Math.round(w * lv.atmosphereScale)); + const ah = Math.max(2, Math.round(h * lv.atmosphereScale)); + solid = makeTarget(sw, sh, gl.RGBA16F, gl.LINEAR); + depth = makeTarget(sw, sh, gl.R16F, gl.NEAREST); + // Without the prepass pass 1 reads a 1x1 zero start: every ray from the camera, as before. + startTex = usePrepass + ? makeTarget(Math.ceil(sw / TILE), Math.ceil(sh / TILE), startFormat, gl.NEAREST) + : makeTarget(1, 1, startFormat, gl.NEAREST); + steps = opts.onTraceStats ? makeTarget(sw, sh, gl.R8, gl.NEAREST) : undefined; + atm = makeTarget(aw, ah, gl.RGBA16F, gl.LINEAR); + hist = [makeTarget(aw, ah, gl.RGBA16F, gl.LINEAR), makeTarget(aw, ah, gl.RGBA16F, gl.LINEAR)]; + attach(fbos.solid, steps ? [solid, depth, steps] : [solid, depth]); + discardReadback(); + attach(fbos.pre, [startTex]); + attach(fbos.atm, [atm]); + attach(fbos.hist[0], [hist[0]]); + attach(fbos.hist[1], [hist[1]]); + resetHistory = true; + }; + + const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)'); + const start = performance.now(); + let loginAt: number | undefined; + let frame = 0; + let histIndex = 0; + let prev: CameraPose | undefined; + let raf = 0; + let lastRendered = -Infinity; + let framesLeft = still ? 1 : Infinity; // a still frame, or reduced motion, bounds this + let disposed = false; + + // Timer queries in flight: each holds one pass's query. + const pending: Array<{ q: WebGLQuery; pass: PassName; frame: number }> = []; + const finished = new Map>>(); + + const timed = (pass: PassName, f: number, body: () => void) => { + if (!timer || !opts.onStats) { + body(); + return; + } + const q = gl.createQuery()!; + gl.beginQuery(timer.TIME_ELAPSED_EXT, q); + body(); + gl.endQuery(timer.TIME_ELAPSED_EXT); + pending.push({ q, pass, frame: f }); + }; + + const collect = () => { + if (!timer) return; + while (pending.length && gl.getQueryParameter(pending[0].q, gl.QUERY_RESULT_AVAILABLE)) { + const { q, pass, frame: f } = pending.shift()!; + const disjoint = gl.getParameter(timer.GPU_DISJOINT_EXT); + const ns = gl.getQueryParameter(q, gl.QUERY_RESULT) as number; + gl.deleteQuery(q); + const row = finished.get(f) ?? {}; + if (!disjoint) row[pass] = ns / 1e6; + finished.set(f, row); + if (Object.keys(row).length === passes.length) { + finished.delete(f); + const passMs = { prepass: 0, ...row } as Record; + const total = passes.reduce((s, p) => s + passMs[p], 0); + if (!opts.fixedLevel && ladder.push(total)) sizedFor = undefined; + opts.onStats?.({ + passMs, + level: ladder.index, + solidSize: [solid.w, solid.h], + atmosphereSize: [atm.w, atm.h], + }); + } + } + }; + + const setCommon = (p: WebGLProgram, pose: CameraPose, time: number) => { + const aspect = canvasW / canvasH; + const th = Math.tan(pose.fov / 2); + gl.uniform3fv(loc(p, 'u_camPos'), pose.position as unknown as number[]); + gl.uniformMatrix3fv(loc(p, 'u_camBasis'), false, pose.basis as unknown as number[]); + gl.uniform2f(loc(p, 'u_tanHalf'), th * aspect, th); + gl.uniform1f(loc(p, 'u_time'), time); + gl.uniform3fv(loc(p, 'u_sunDir'), PALETTE.sunDir as unknown as number[]); + gl.uniform3fv(loc(p, 'u_sunColor'), PALETTE.sunColor as unknown as number[]); + gl.uniform3fv(loc(p, 'u_skyTop'), PALETTE.skyTop as unknown as number[]); + gl.uniform3fv(loc(p, 'u_skyHorizon'), PALETTE.skyHorizon as unknown as number[]); + gl.uniform3fv(loc(p, 'u_fogColor'), PALETTE.fogColor as unknown as number[]); + }; + + const bindTex = (unit: number, t: Target, p: WebGLProgram, name: string) => { + gl.activeTexture(gl.TEXTURE0 + unit); + gl.bindTexture(gl.TEXTURE_2D, t.tex); + gl.uniform1i(loc(p, name), unit); + }; + + const draw = () => gl.drawArrays(gl.TRIANGLES, 0, 3); + + const render = (now: number) => { + ensureSize(); + const lv = level(); + const frozen = reducedMotion?.matches ?? false; + const time = frozen ? 0 : (now - start) / 1000; + let elapsed: number | undefined; + if (loginAt !== undefined) elapsed = frozen ? 1e9 : (now - loginAt) / 1000; + const pose = cameraPose(opts.fixedPull ?? pullProgress(elapsed), time); + const f = frame; + frame += 1; + const jx = halton((f % 16) + 1, 2) - 0.5; + const jy = halton((f % 16) + 1, 3) - 0.5; + + // Pass 0: cone march at one ray per 8x8 tile of pass 1, the start distance for its rays. + if (usePrepass) { + timed('prepass', f, () => { + gl.bindFramebuffer(gl.FRAMEBUFFER, fbos.pre); + gl.viewport(0, 0, startTex.w, startTex.h); + const p = programs.prepass; + gl.useProgram(p); + setCommon(p, pose, time); + gl.uniform1i(loc(p, 'u_maxSteps'), lv.traceSteps); + gl.uniform2f(loc(p, 'u_tileHalf'), 0.5 / startTex.w, 0.5 / startTex.h); + draw(); + }); + } + + // Pass 1: solid surfaces, writes colour and depth. + timed('terrain', f, () => { + gl.bindFramebuffer(gl.FRAMEBUFFER, fbos.solid); + gl.viewport(0, 0, solid.w, solid.h); + const p = programs.terrain; + gl.useProgram(p); + setCommon(p, pose, time); + gl.uniform3fv(loc(p, 'u_grass'), PALETTE.grass as unknown as number[]); + gl.uniform3fv(loc(p, 'u_rock'), PALETTE.rock as unknown as number[]); + gl.uniform3fv(loc(p, 'u_bark'), PALETTE.bark as unknown as number[]); + gl.uniform3fv(loc(p, 'u_leaf'), PALETTE.leaf as unknown as number[]); + gl.uniform1i(loc(p, 'u_maxSteps'), lv.traceSteps); + bindTex(0, startTex, p, 'u_start'); + draw(); + }); + if (f % READBACK_EVERY === 0) startReadback(); // outside the timed span: not pass 1's cost + + // Pass 2: atmosphere up to pass 1's depth, low-res. + timed('atmosphere', f, () => { + gl.bindFramebuffer(gl.FRAMEBUFFER, fbos.atm); + gl.viewport(0, 0, atm.w, atm.h); + const p = programs.atmosphere; + gl.useProgram(p); + setCommon(p, pose, time); + bindTex(0, depth, p, 'u_depth'); + gl.uniform1i(loc(p, 'u_steps'), lv.cloudSteps); + gl.uniform1f(loc(p, 'u_frame'), f); + gl.uniform2f(loc(p, 'u_jitter'), jx, jy); + gl.uniform2f(loc(p, 'u_size'), atm.w, atm.h); + gl.uniform1f(loc(p, 'u_coverage'), opts.coverage ?? 0.45); + draw(); + }); + + // TAA resolve into the history that is not being read. + const write = histIndex === 0 ? 1 : 0; + timed('resolve', f, () => { + gl.bindFramebuffer(gl.FRAMEBUFFER, fbos.hist[write]); + gl.viewport(0, 0, atm.w, atm.h); + const p = programs.resolve; + gl.useProgram(p); + setCommon(p, pose, time); + bindTex(0, atm, p, 'u_cur'); + bindTex(1, hist[histIndex], p, 'u_history'); + bindTex(2, depth, p, 'u_depth'); + const pp = prev ?? pose; + const th = Math.tan(pp.fov / 2); + gl.uniform3fv(loc(p, 'u_prevPos'), pp.position as unknown as number[]); + gl.uniformMatrix3fv(loc(p, 'u_prevBasis'), false, pp.basis as unknown as number[]); + gl.uniform2f(loc(p, 'u_prevTanHalf'), th * (canvasW / canvasH), th); + gl.uniform1f(loc(p, 'u_blend'), resetHistory ? 1 : 0.125); + gl.uniform2f(loc(p, 'u_curSize'), atm.w, atm.h); + draw(); + }); + + // Full-resolution composite to the canvas. + timed('composite', f, () => { + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + gl.viewport(0, 0, canvasW, canvasH); + const p = programs.composite; + gl.useProgram(p); + setCommon(p, pose, time); + bindTex(0, solid, p, 'u_solid'); + bindTex(1, hist[write], p, 'u_atm'); + gl.uniform1f(loc(p, 'u_frame'), f); + draw(); + }); + + histIndex = write; + prev = pose; + resetHistory = false; + collect(); + pollReadback(); + }; + + const tick = (now: number) => { + raf = 0; + if (disposed) return; + const due = opts.unthrottled || now - lastRendered >= FRAME_INTERVAL_MS - 2; + if (due) { + if ( + !opts.fixedLevel && + !timer && + lastRendered > 0 && + now - lastRendered > FRAME_INTERVAL_MS * 1.5 + ) { + // no timer: a missed cap counts as over budget + if (ladder.push(GPU_BUDGET_MS * 2)) sizedFor = undefined; + } + lastRendered = now; + render(now); + framesLeft -= 1; + } + if (framesLeft > 0 && (opts.unthrottled || document.visibilityState === 'visible')) + raf = requestAnimationFrame(tick); + }; + + const wake = () => { + if (disposed || raf) return; + if (reducedMotion?.matches && framesLeft <= 0) return; + resetHistory = true; // the first frame after a pause has a real elapsed dt + raf = requestAnimationFrame(tick); + }; + const undo: Array<() => void> = []; + const listen = (target: EventTarget, type: string, handler: (e: Event) => void) => { + target.addEventListener(type, handler); + undo.push(() => target.removeEventListener(type, handler)); + }; + const stop = () => { + disposed = true; + if (raf) cancelAnimationFrame(raf); + undo.forEach((f) => f()); + }; + if (!opts.unthrottled) { + listen(document, 'visibilitychange', () => { + if (document.visibilityState === 'visible') wake(); + }); + } + listen(window, 'resize', () => wake()); + // A lost context takes every texture and program with it and nothing here rebuilds them, so the + // scene ends and the host decides what to show. preventDefault: otherwise the browser never + // restores the context, which is what we want, and a restore would find a dead scene. + listen(canvas, 'webglcontextlost', (e) => { + e.preventDefault(); + stop(); + opts.onContextLost?.(); + }); + if (reducedMotion?.matches && !still) framesLeft = REDUCED_MOTION_FRAMES; + // The preference can flip while the scene lives: freeze to a bounded run, or thaw to a free one. + if (reducedMotion && !still) { + listen(reducedMotion, 'change', () => { + framesLeft = reducedMotion.matches ? REDUCED_MOTION_FRAMES : Infinity; + wake(); + }); + } + wake(); + + return { + startFormat: halfStart ? 'RGBA16F' : 'RGBA32F', + login() { + if (still || disposed) return; // a still has no pull-back + loginAt = performance.now(); + if (reducedMotion?.matches) framesLeft = REDUCED_MOTION_FRAMES; + wake(); + }, + dispose() { + if (disposed) return; + stop(); + freeTargets(); + passes.forEach((p) => gl.deleteProgram(programs[p])); + discardReadback(); + [packSteps, packStart].forEach((b) => b && gl.deleteBuffer(b)); + gl.deleteVertexArray(vao); + [fbos.solid, fbos.pre, fbos.atm, ...fbos.hist].forEach((fb) => gl.deleteFramebuffer(fb)); + }, + }; +} diff --git a/src/app/features/scene/budget.test.ts b/src/app/features/scene/budget.test.ts new file mode 100644 index 00000000..9ac8f0af --- /dev/null +++ b/src/app/features/scene/budget.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { BudgetLadder, LEVELS } from './budget'; + +describe('budget ladder', () => { + it('steps down after a whole window over budget and never back up', () => { + const ladder = new BudgetLadder(4, 5); + Array.from({ length: 4 }).forEach(() => expect(ladder.push(9)).toBe(false)); + expect(ladder.push(9)).toBe(true); + expect(ladder.index).toBe(1); + Array.from({ length: 100 }).forEach(() => ladder.push(0.1)); // fast frames never raise the level + expect(ladder.index).toBe(1); + }); + + it('judges the median, so one slow frame does not step down', () => { + const ladder = new BudgetLadder(4, 5); + [1, 1, 50, 1, 1].forEach((ms) => ladder.push(ms)); + expect(ladder.index).toBe(0); + }); + + it('stops at the last level', () => { + const ladder = new BudgetLadder(4, 1); + Array.from({ length: 20 }).forEach(() => ladder.push(99)); + expect(ladder.index).toBe(LEVELS.length - 1); + }); + + it('every level is cheaper than the one before in each cost axis', () => { + LEVELS.slice(1).forEach((lv, i) => { + const before = LEVELS[i]; + expect(lv.solidScale).toBeLessThan(before.solidScale); + expect(lv.atmosphereScale).toBeLessThan(before.atmosphereScale); + expect(lv.cloudSteps).toBeLessThan(before.cloudSteps); + expect(lv.traceSteps).toBeLessThan(before.traceSteps); + }); + }); + + it('starts at the level it is given, clamped into the ladder', () => { + expect(new BudgetLadder(4, 5, 2).index).toBe(2); + expect(new BudgetLadder(4, 5, 99).index).toBe(LEVELS.length - 1); + expect(new BudgetLadder(4, 5, -3).index).toBe(0); + }); +}); diff --git a/src/app/features/scene/budget.ts b/src/app/features/scene/budget.ts new file mode 100644 index 00000000..3b373d86 --- /dev/null +++ b/src/app/features/scene/budget.ts @@ -0,0 +1,59 @@ +// The adaptive step-down. The ladder only descends within a session; nothing here ever steps up. + +export interface Level { + /** Pass 1 resolution as a fraction of the canvas. */ + solidScale: number; + /** Pass 2 resolution as a fraction of the canvas. */ + atmosphereScale: number; + /** Cloud march steps. */ + cloudSteps: number; + /** Sphere-trace step cap. */ + traceSteps: number; +} + +export const LEVELS: readonly Level[] = [ + { solidScale: 0.75, atmosphereScale: 0.5, cloudSteps: 12, traceSteps: 96 }, + { solidScale: 0.6, atmosphereScale: 0.4, cloudSteps: 8, traceSteps: 80 }, + { solidScale: 0.5, atmosphereScale: 0.33, cloudSteps: 6, traceSteps: 64 }, + { solidScale: 0.4, atmosphereScale: 0.25, cloudSteps: 4, traceSteps: 48 }, +]; + +/** Median of the last `window` frame times above `budgetMs` for a whole window steps down one level. */ +export class BudgetLadder { + private level: number; + + private recent: number[] = []; + + private readonly budgetMs: number; + + private readonly window: number; + + /** `startLevel` lets a weak device begin below level 0; it is clamped into the ladder. */ + constructor(budgetMs: number, window = 45, startLevel = 0) { + this.budgetMs = budgetMs; + this.window = window; + this.level = Math.min(Math.max(startLevel, 0), LEVELS.length - 1); + } + + get current(): Level { + return LEVELS[this.level]; + } + + get index(): number { + return this.level; + } + + /** Feed one frame's GPU (or cadence) time. Returns true when the level changed. */ + push(ms: number): boolean { + this.recent.push(ms); + if (this.recent.length < this.window) return false; + const sorted = [...this.recent].sort((a, b) => a - b); + const median = sorted[Math.floor(sorted.length / 2)]; + this.recent = []; + if (median > this.budgetMs && this.level < LEVELS.length - 1) { + this.level += 1; + return true; + } + return false; + } +} diff --git a/src/app/features/scene/camera.test.ts b/src/app/features/scene/camera.test.ts new file mode 100644 index 00000000..f7199a65 --- /dev/null +++ b/src/app/features/scene/camera.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { cameraPose, pullProgress, PULL_BACK_SECONDS } from './camera'; +import { WORLD } from './world'; + +const dist = (p: readonly [number, number, number]) => Math.hypot(p[0], p[2]); + +describe('login pull-back', () => { + it('is at rest before login and complete after PULL_BACK_SECONDS', () => { + expect(pullProgress(undefined)).toBe(0); + expect(pullProgress(0)).toBe(0); + expect(pullProgress(PULL_BACK_SECONDS)).toBe(1); + expect(pullProgress(PULL_BACK_SECONDS * 10)).toBe(1); + }); + + it('only moves away from the tree and never reverses', () => { + let last = -1; + for (let s = 0; s <= PULL_BACK_SECONDS; s += 0.1) { + const d = dist(cameraPose(pullProgress(s), 0).position); + expect(d).toBeGreaterThanOrEqual(last); + last = d; + } + }); + + it('ends over the flat ground so it cannot be inside the terrain, whatever the orbit angle', () => { + for (let t = 0; t < 2000; t += 37) { + expect(dist(cameraPose(1, t).position)).toBeLessThan(WORLD.plateauInner); + } + }); + + it('looks at the tree: forward points at the axis and the basis is orthonormal', () => { + const pose = cameraPose(0.5, 12); + const [rx, ry, rz, ux, uy, uz, fx, fy, fz] = pose.basis; + const dot = (a: number[], b: number[]) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + expect(dot([rx, ry, rz], [ux, uy, uz])).toBeCloseTo(0, 6); + expect(dot([rx, ry, rz], [fx, fy, fz])).toBeCloseTo(0, 6); + expect(dot([ux, uy, uz], [fx, fy, fz])).toBeCloseTo(0, 6); + expect(Math.hypot(fx, fy, fz)).toBeCloseTo(1, 6); + // Forward points back across the tree axis, not away from it. + const [px, , pz] = pose.position; + expect(fx * px + fz * pz).toBeLessThan(0); + }); +}); diff --git a/src/app/features/scene/camera.ts b/src/app/features/scene/camera.ts new file mode 100644 index 00000000..2b08f0d1 --- /dev/null +++ b/src/app/features/scene/camera.ts @@ -0,0 +1,56 @@ +// The scene's camera: close under the tree at rest, pulled back on login, drifting after. +// Pure functions of time so the pull-back is testable without a GL context. + +export type Vec3 = readonly [number, number, number]; + +export interface CameraPose { + position: Vec3; + /** Column-major right, up, forward: the layout of the shader's mat3 uniform. */ + basis: readonly [number, number, number, number, number, number, number, number, number]; + /** Vertical field of view in radians. */ + fov: number; +} + +export const PULL_BACK_SECONDS = 6; + +// Keep the end distance inside WORLD.plateauInner so the camera is always over flat ground. +const NEAR = { radius: 20, camY: 5, targetY: 34, fov: 1.15 }; +const FAR = { radius: 110, camY: 50, targetY: 36, fov: 0.82 }; +const DRIFT_RAD_PER_SEC = 0.025; + +const smoother = (x: number) => x * x * x * (x * (x * 6 - 15) + 10); +const lerp = (a: number, b: number, t: number) => a + (b - a) * t; + +/** 0 before login, easing to 1 over PULL_BACK_SECONDS. `elapsed` is seconds since login. */ +export function pullProgress(elapsed: number | undefined): number { + if (elapsed === undefined || elapsed <= 0) return 0; + return smoother(Math.min(1, elapsed / PULL_BACK_SECONDS)); +} + +function norm(v: Vec3): Vec3 { + const l = Math.hypot(v[0], v[1], v[2]) || 1; + return [v[0] / l, v[1] / l, v[2] / l]; +} +function cross(a: Vec3, b: Vec3): Vec3 { + return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; +} + +/** + * @param pull 0..1 from pullProgress + * @param time seconds since the scene started, for the slow orbit + */ +export function cameraPose(pull: number, time: number): CameraPose { + const radius = lerp(NEAR.radius, FAR.radius, pull); + const camY = lerp(NEAR.camY, FAR.camY, pull); + const targetY = lerp(NEAR.targetY, FAR.targetY, pull); + const azimuth = 0.9 + time * DRIFT_RAD_PER_SEC; + const position: Vec3 = [radius * Math.cos(azimuth), camY, radius * Math.sin(azimuth)]; + const forward = norm([-position[0], targetY - position[1], -position[2]]); + const right = norm(cross([0, 1, 0], forward)); + const up = cross(forward, right); + return { + position, + basis: [...right, ...up, ...forward] as unknown as CameraPose['basis'], + fov: lerp(NEAR.fov, FAR.fov, pull), + }; +} diff --git a/src/app/features/scene/creatures.test.ts b/src/app/features/scene/creatures.test.ts new file mode 100644 index 00000000..938c217f --- /dev/null +++ b/src/app/features/scene/creatures.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { BUCKET_SCALE, creatureScale, SizeBucket } from './creatures'; + +const buckets: SizeBucket[] = ['small', 'medium', 'large']; + +describe('creature scale', () => { + it('follows sizeBucket for a creature with an estate', () => { + buckets.forEach((sizeBucket) => + expect(creatureScale({ sizeBucket, neutral: false })).toBe(BUCKET_SCALE[sizeBucket]) + ); + }); + + it('draws nothing for a neutral creature, whatever sizeBucket it was sent', () => { + expect(creatureScale({ neutral: true })).toBeNull(); + buckets.forEach((sizeBucket) => expect(creatureScale({ sizeBucket, neutral: true })).toBeNull()); + }); + + it('does not crash or default to medium when a non-neutral creature has no sizeBucket', () => { + expect(creatureScale({ neutral: false })).toBeNull(); + expect(creatureScale({ sizeBucket: undefined, neutral: false })).toBeNull(); + }); + + it('draws nothing for an unknown bucket, including inherited property names', () => { + expect(creatureScale({ sizeBucket: 'huge' as SizeBucket, neutral: false })).toBeNull(); + expect(creatureScale({ sizeBucket: 'toString' as SizeBucket, neutral: false })).toBeNull(); + }); +}); diff --git a/src/app/features/scene/creatures.ts b/src/app/features/scene/creatures.ts new file mode 100644 index 00000000..a0b2bd29 --- /dev/null +++ b/src/app/features/scene/creatures.ts @@ -0,0 +1,37 @@ +// Creature sizing for the orbiting personas. Only the rule lives here; drawing them is a later cut. +// +// Props contract: heimdall-body:follow_up:ratatoskr-creature-projection-v2 (against GameCult/Bifrost; supersedes heimdall-body/ratatoskr-creature-projection). +// Nothing here is binding until Bifrost's steward cuts that contract. +// +// Shape: {id, estate, neutral, activity} plus sizeBucket when neutral is false. +// Invariants: +// - Exact vote weights stay in Bifrost; Ratatoskr only ever sees a bucket. +// - neutral=true means Bifrost publishes no sizeBucket, no kind and no energy. A received value is +// ignored, and a neutral creature is not drawn. +// - A missing or unknown sizeBucket is not drawn either; no size is ever defaulted. +// - activity comes from the Heimdall session. +// Consent: humans get a coarse bucket by default plus a typed opt-out; Minds and Personas may show finer buckets. +// Humans have three coarse buckets. The scales below (0.7 / 1 / 1.4) are placeholders, not fixed. + +export type SizeBucket = 'small' | 'medium' | 'large'; + +export interface Creature { + id: string; + estate: string; + /** Absent when neutral. Read it only through creatureScale. */ + sizeBucket?: SizeBucket; + neutral: boolean; + activity: 'idle' | 'active'; +} + +export const BUCKET_SCALE: Readonly> = { + small: 0.7, + medium: 1, + large: 1.4, +}; + +/** The draw scale, or null when nothing is drawn: a neutral creature, or a bucket that is absent or unknown. */ +export function creatureScale(c: Pick): number | null { + if (c.neutral || c.sizeBucket === undefined) return null; + return Object.prototype.hasOwnProperty.call(BUCKET_SCALE, c.sizeBucket) ? BUCKET_SCALE[c.sizeBucket] : null; +} diff --git a/src/app/features/scene/cultmathTrim.test.ts b/src/app/features/scene/cultmathTrim.test.ts new file mode 100644 index 00000000..cef6b281 --- /dev/null +++ b/src/app/features/scene/cultmathTrim.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; +import { trimGlsl } from './cultmathTrim'; +import { fragmentSource, Stage } from './shaderSource'; +import cultmath from './shaders/CultMath.glsl?raw'; + +const LIB = `// header +#ifndef LIB_GLSL +#define LIB_GLSL +const float LIB_PI = 3.14; + +float lib_half(float v) { return v * 0.5; } +vec2 lib_half(vec2 v) { return v * 0.5; } + +// uses the constant and an overload set +float lib_pi_half(float v) { return lib_half(v) * LIB_PI; } + +struct LibPair { float a; float b; }; + +LibPair lib_pair(float v) { LibPair p; p.a = v; p.b = lib_half(v); return p; } +float lib_unused(float v) { return v; } +#endif +`; + +describe('trimGlsl', () => { + it('keeps what a root names and what that reaches, and drops the rest', () => { + const out = trimGlsl(LIB, ['float x = lib_pi_half(1.0);']); + expect(out).toContain('lib_pi_half'); + expect(out).toContain('LIB_PI'); + expect(out).toContain('float lib_half'); + expect(out).toContain('vec2 lib_half'); // overloads stay together + expect(out).not.toContain('lib_unused'); + expect(out).not.toContain('LibPair'); + expect(out).not.toContain('lib_pair'); + }); + + it('keeps a struct that a kept function returns', () => { + const out = trimGlsl(LIB, ['LibPair p = lib_pair(1.0);']); + expect(out).toContain('struct LibPair'); + expect(out).toContain('lib_half'); + expect(out).not.toContain('lib_pi_half'); + }); + + it('keeps the include guard balanced', () => { + const out = trimGlsl(LIB, ['lib_half(1.0)']); + expect(out).toMatch(/^#ifndef LIB_GLSL\n#define LIB_GLSL\n/); + expect(out.trimEnd().endsWith('#endif')).toBe(true); + }); + + it('keeps nothing but the guard for a root that names nothing', () => { + const out = trimGlsl(LIB, ['void main() {}']); + expect(out).not.toContain('lib_'); + expect(out).toContain('#ifndef LIB_GLSL'); + }); +}); + +describe('CultMath per shader stage', () => { + const stages: Stage[] = ['terrain', 'atmosphere', 'resolve', 'composite']; + const cultmathNames = (text: string) => new Set(text.match(/\bcultmath_\w+/g) ?? []); + const defined = (src: string, name: string) => + new RegExp(`^\\s*(?:float|vec[234]|mat3|int|\\w+)\\s+${name}\\s*\\(`, 'm').test(src); + + it.each(stages)('%s defines every CultMath function it, or Phacelle, calls', (stage) => { + const src = fragmentSource(stage); + cultmathNames(src).forEach((n) => expect(defined(src, n), n).toBe(true)); + }); + + it.each(stages)('%s is smaller than the whole library', (stage) => { + expect(fragmentSource(stage).length).toBeLessThan(cultmath.length / 2); + }); + + it('puts the noise a stage calls in that stage and not in the others', () => { + expect(fragmentSource('terrain')).toContain('cultmath_snoise'); + expect(fragmentSource('composite')).not.toContain('cultmath_snoise'); + }); + + it('adds Phacelle to terrain alone, as its own file', () => { + expect(fragmentSource('terrain')).toContain('cultmath_phacelle'); + stages + .filter((s) => s !== 'terrain') + .forEach((s) => expect(fragmentSource(s)).not.toContain('cultmath_phacelle')); + }); +}); diff --git a/src/app/features/scene/cultmathTrim.ts b/src/app/features/scene/cultmathTrim.ts new file mode 100644 index 00000000..fc246e5c --- /dev/null +++ b/src/app/features/scene/cultmathTrim.ts @@ -0,0 +1,81 @@ +/** + * Keeps the part of CultMath's GLSL that a stage can reach. The vendored file is whole and never + * edited; each stage compiles only the declarations its own source (and the source concatenated + * beside it) names, directly or through other kept declarations. Overloads share a name and are + * kept together. + */ + +interface Decl { + name: string; + text: string; +} + +const GUARD_OPEN = /^#ifndef\s+\w+\s*\n#define\s+\w+\s*\n/m; +const GUARD_CLOSE = /\n#endif\s*$/; + +/** Name a top-level declaration defines: function, struct or const. */ +function declName(text: string): string | undefined { + const code = text.replace(/\/\/.*$/gm, ''); + if (!code.includes('{')) return /\b(\w+)\s*=/.exec(code)?.[1]; + return /\bstruct\s+(\w+)/.exec(code)?.[1] ?? /\b(\w+)\s*\(/.exec(code)?.[1]; +} + +/** Splits at brace depth 0: a declaration ends at the `;` or `}` that returns to depth 0. */ +function declarations(body: string): Decl[] { + const out: Decl[] = []; + let depth = 0; + let start = 0; + let i = 0; + while (i < body.length) { + const c = body[i]; + if (c === '/' && body[i + 1] === '/') { + const eol = body.indexOf('\n', i); + i = eol < 0 ? body.length : eol; + } else if (c === '{') { + depth += 1; + } else if ((c === '}' || c === ';') && depth - (c === '}' ? 1 : 0) === 0) { + if (c === '}') depth -= 1; + // a struct's closing brace is followed by `;` + const end = c === '}' && /^\s*;/.test(body.slice(i + 1)) ? body.indexOf(';', i) : i; + const text = body.slice(start, end + 1); + const name = declName(text); + if (name) out.push({ name, text }); + start = end + 1; + i = end; + } else if (c === '}') { + depth -= 1; + } + i += 1; + } + return out; +} + +const idents = (text: string): string[] => text.match(/\b[A-Za-z_]\w*\b/g) ?? []; + +/** `library` trimmed to what `roots` reach; the include guard is kept. */ +export function trimGlsl(library: string, roots: string[]): string { + const open = GUARD_OPEN.exec(library)?.[0] ?? ''; + const close = GUARD_CLOSE.test(library) ? '\n#endif\n' : ''; + const body = library + .slice(open ? library.indexOf(open) + open.length : 0) + .replace(GUARD_CLOSE, ''); + const decls = declarations(body); + const byName = new Map(); + decls.forEach((d) => byName.set(d.name, [...(byName.get(d.name) ?? []), d])); + + const kept = new Set(); + const queue = roots.flatMap(idents); + while (queue.length) { + const group = byName.get(queue.pop() ?? ''); + group?.forEach((d) => { + if (kept.has(d)) return; + kept.add(d); + queue.push(...idents(d.text)); + }); + } + const text = decls + .filter((d) => kept.has(d)) + .map((d) => d.text.replace(/^\s*\n/, '')) + .join('\n'); + return `${open}${text}${close}`; +} diff --git a/src/app/features/scene/index.ts b/src/app/features/scene/index.ts new file mode 100644 index 00000000..c896649e --- /dev/null +++ b/src/app/features/scene/index.ts @@ -0,0 +1,33 @@ +import type { Scene, SceneOptions } from './Scene'; +import { BackgroundSceneConfig, Capabilities, resolveTier } from './tier'; + +export type { Scene, SceneStats } from './Scene'; +export type { SceneTier } from './tier'; + +export type StartOptions = Omit & { + /** A shader failed to compile, or the scene chunk failed to load. The host keeps its ground. */ + onCompileFail?: (e: Error) => void; +}; + +/** + * The client's one door to the scene. When the tier resolves to 'off' nothing is imported and no + * canvas context is requested; otherwise the scene code loads on demand, so it stays out of the + * main chunk. Null means "keep the CSS ground", including when that chunk fails to load (offline, + * stale deploy): the failure goes to onCompileFail and nothing throws. + */ +export async function startBackgroundScene( + config: BackgroundSceneConfig | undefined, + capabilities: Capabilities, + options: StartOptions +): Promise { + const tier = resolveTier(config, capabilities); + if (tier === 'off') return null; + let createScene: typeof import('./Scene').createScene; + try { + ({ createScene } = await import('./Scene')); + } catch (e) { + options.onCompileFail?.(e instanceof Error ? e : new Error(String(e))); + return null; + } + return createScene({ ...options, tier }); +} diff --git a/src/app/features/scene/palette.ts b/src/app/features/scene/palette.ts new file mode 100644 index 00000000..54a84ad3 --- /dev/null +++ b/src/app/features/scene/palette.ts @@ -0,0 +1,27 @@ +// The scene's colours, as linear RGB uniforms. The sky tints are GameCult's three nebula inputs +// (orange 255 138 42, violet 109 96 255, sky 89 183 255) over the brand base #07111a, so the +// background reads as the same dusk as gamecult.org. The shaders hold no colour literal. + +export type Rgb = readonly [number, number, number]; + +const srgb = (r: number, g: number, b: number): Rgb => [ + (r / 255) ** 2.2, + (g / 255) ** 2.2, + (b / 255) ** 2.2, +]; + +export const PALETTE = { + sunColor: srgb(255, 150, 70).map((c) => c * 3.2) as unknown as Rgb, + skyTop: srgb(30, 36, 92), + skyHorizon: srgb(255, 138, 42).map((c) => c * 0.55) as unknown as Rgb, + fogColor: srgb(109, 96, 255).map((c) => c * 0.12) as unknown as Rgb, + grass: srgb(72, 112, 86), + rock: srgb(112, 104, 122), + bark: srgb(62, 46, 40), + leaf: srgb(89, 183, 255).map((c) => c * 0.7) as unknown as Rgb, + sunDir: (() => { + const v = [0.55, 0.16, -0.82]; + const l = Math.hypot(v[0], v[1], v[2]); + return [v[0] / l, v[1] / l, v[2] / l] as Rgb; + })(), +} as const; diff --git a/src/app/features/scene/shaderSource.ts b/src/app/features/scene/shaderSource.ts new file mode 100644 index 00000000..42631d57 --- /dev/null +++ b/src/app/features/scene/shaderSource.ts @@ -0,0 +1,41 @@ +import cultmath from './shaders/CultMath.glsl?raw'; +import phacelle from './shaders/CultMath.Phacelle.glsl?raw'; +import common from './shaders/common.glsl?raw'; +import fullscreenVert from './shaders/fullscreen.vert.glsl?raw'; +import sdf from './shaders/sdf.glsl?raw'; +import prepassFrag from './shaders/prepass.frag.glsl?raw'; +import terrainFrag from './shaders/terrain.frag.glsl?raw'; +import atmosphereFrag from './shaders/atmosphere.frag.glsl?raw'; +import resolveFrag from './shaders/resolve.frag.glsl?raw'; +import compositeFrag from './shaders/composite.frag.glsl?raw'; +import { WORLD, worldDefines } from './world'; +import { trimGlsl } from './cultmathTrim'; + +export type Stage = 'prepass' | 'terrain' | 'atmosphere' | 'resolve' | 'composite'; + +const HEADER = + '#version 300 es\nprecision highp float;\nprecision highp int;\nprecision highp sampler2D;\n'; + +export const vertexSource = HEADER + fullscreenVert; + +const stages: Record = { + prepass: prepassFrag, + terrain: terrainFrag, + atmosphere: atmosphereFrag, + resolve: resolveFrag, + composite: compositeFrag, +}; + +/** + * Fragment source for a stage: header, world defines, the part of CultMath the stage reaches, the + * shared block, the distance bound (stages that trace), the stage. `defines` is whole `#define` + * lines (measurement flags) ahead of the world's. Phacelle stays a separate file, added only where + * it is called. + */ +export function fragmentSource(stage: Stage, defines = ''): string { + const solid = stage === 'terrain' || stage === 'prepass'; + const erosion = solid && WORLD.phacelleGradBound > 0 ? phacelle : ''; + const world = solid ? sdf : ''; + const math = trimGlsl(cultmath, [erosion, common, world, stages[stage]]); + return `${HEADER}${defines}${worldDefines()}\n${math}\n${erosion}\n${common}\n${world}\n${stages[stage]}`; +} diff --git a/src/app/features/scene/shaders/CultMath.Phacelle.glsl b/src/app/features/scene/shaders/CultMath.Phacelle.glsl new file mode 100644 index 00000000..11405fa2 --- /dev/null +++ b/src/app/features/scene/shaders/CultMath.Phacelle.glsl @@ -0,0 +1,99 @@ +// Generated from shaders/CultMath.Phacelle.hlsl by GlslLowering (packages/cultmath/tests/CultMath.Tests/GlslLowering.cs). Do not edit; regenerate with CULTMATH_WRITE_GLSL=1 dotnet test --filter GlslMirrorTests. MPL-2.0; requires CultMath.glsl before it. +#ifndef CULTMATH_PHACELLE_GLSL +#define CULTMATH_PHACELLE_GLSL +// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of +// the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/. +// +// Phacelle noise. Copyright (c) 2025 Rune Skovbo Johansen (Shadertoy t3dyWl; MPL-2.0), read from the +// C# port PhacelleNoise in lpmitchell/AdvancedTerrainErosion, converted to burstable C# by Luke +// Mitchell (2026). Modified by GameCult: 3D cells, a caller-supplied stripe wave vector, an exact +// gradient and exact pruning. This file is an MPL-2.0 file; CultMath.hlsl, which includes it after +// its own definitions of CULTMATH_TAU and cultmath_pcg3d, is MIT (see THIRD-PARTY-NOTICES.md). + +// Invariant 8's struct return shape for cultmath_phacelle, compared field by field and bit for bit +// against CultMath.CultPhasor, same field order. +struct CultPhasor +{ + vec4 cos; + vec4 sin; +}; + +// Johansen's Phacelle noise on 3D cells. See math.phacelle's comment (math.Phacelle.cs) for the stripe wave +// vector, the weight, the normalization, the exact gradient and why the prune is exact. Mirrors +// the C# body operation for operation; the C#-only internal overload that switches the prune off +// has no shader twin. +CultPhasor cultmath_phacelle(vec3 p, vec3 side, float offset, float normalization) +{ + vec3 cell = floor(p); + vec3 local = p - cell; + float phaseOffset = offset * CULTMATH_TAU; + float sumW = 0.0; + float sumCos = 0.0; + float sumSin = 0.0; + vec3 gradW = vec3(0.0, 0.0, 0.0); + vec3 gradCos = vec3(0.0, 0.0, 0.0); + vec3 gradSin = vec3(0.0, 0.0, 0.0); + + for (int dz = -1; dz <= 2; dz++) + { + float gz = max(0.0, abs(local.z - float(dz)) - 0.5); + for (int dy = -1; dy <= 2; dy++) + { + float gy = max(0.0, abs(local.y - float(dy)) - 0.5); + for (int dx = -1; dx <= 2; dx++) + { + float gx = max(0.0, abs(local.x - float(dx)) - 0.5); + if (gx * gx + gy * gy + gz * gz >= 2.25) + continue; + + vec3 gridStep = vec3(dx, dy, dz); + ivec3 hash = cultmath_pcg3d(ivec3(cell + gridStep)); + vec3 jitter = vec3( + float(uint(hash.x) >> 8) * (1.0 / 16777216.0) - 0.5, + float(uint(hash.y) >> 8) * (1.0 / 16777216.0) - 0.5, + float(uint(hash.z) >> 8) * (1.0 / 16777216.0) - 0.5); + vec3 v = local - gridStep - jitter; + float falloff = exp(-2.0 * dot(v, v)); + float w = max(0.0, falloff - 0.01111); + vec3 dw = w > 0.0 ? (-4.0 * falloff) * v : vec3(0.0, 0.0, 0.0); + float phase = dot(v, side) + phaseOffset; + float c = cos(phase); + float s = sin(phase); + + sumW += w; + sumCos += w * c; + sumSin += w * s; + gradW += dw; + gradCos += dw * c - (w * s) * side; + gradSin += dw * s + (w * c) * side; + } + } + } + + float invW = 1.0 / sumW; + float rawCos = sumCos * invW; + float rawSin = sumSin * invW; + vec3 gradRawCos = (gradCos - rawCos * gradW) * invW; + vec3 gradRawSin = (gradSin - rawSin * gradW) * invW; + float rawLength = sqrt(rawCos * rawCos + rawSin * rawSin); + float floorLength = 1.0 - normalization; + + CultPhasor result; + if (rawLength > floorLength) + { + float outCos = rawCos / rawLength; + float outSin = rawSin / rawLength; + vec3 gradLength = (rawCos * gradRawCos + rawSin * gradRawSin) / rawLength; + result.cos = vec4((gradRawCos - outCos * gradLength) / rawLength, outCos); + result.sin = vec4((gradRawSin - outSin * gradLength) / rawLength, outSin); + } + else + { + result.cos = vec4(gradRawCos / floorLength, rawCos / floorLength); + result.sin = vec4(gradRawSin / floorLength, rawSin / floorLength); + } + + return result; +} + +#endif diff --git a/src/app/features/scene/shaders/CultMath.glsl b/src/app/features/scene/shaders/CultMath.glsl new file mode 100644 index 00000000..8a380a96 --- /dev/null +++ b/src/app/features/scene/shaders/CultMath.glsl @@ -0,0 +1,1035 @@ +// Generated from shaders/CultMath.hlsl and CultMath.Interval.hlsl and CultMath.Affine.hlsl by GlslLowering (packages/cultmath/tests/CultMath.Tests/GlslLowering.cs). Do not edit; regenerate with CULTMATH_WRITE_GLSL=1 dotnet test --filter GlslMirrorTests. MIT. cultmath_phacelle is in CultMath.Phacelle.glsl, to be concatenated after this file by consumers that call it. cultmath_cycles_divide, cultmath_cycles_power, cultmath_cycles_logarithm, cultmath_cycles_sqrt, cultmath_cycles_inverse_sqrt, cultmath_cycles_modulo, cultmath_cycles_floored_modulo, cultmath_cycles_wrap, cultmath_cycles_snap, cultmath_cycles_pingpong, cultmath_cycles_smooth_min, cultmath_cycles_smooth_max, cultmath_cycles_compare, cultmath_cycles_round, cultmath_cycles_trunc, cultmath_cycles_fract, cultmath_cycles_sign, cultmath_cycles_arcsine, cultmath_cycles_arccosine, cultmath_cycles_arctan2, cultmath_cycles_map_range_linear, cultmath_cycles_map_range_stepped, cultmath_cycles_map_range_smoothstep, cultmath_cycles_map_range_smootherstep, cultmath_cycles_clamp_minmax, cultmath_cycles_clamp_range, cultmath_cycles_mix_float, cultmath_cycles_mix_vector, cultmath_cycles_mix_blend, cultmath_cycles_mix_add, cultmath_cycles_mix_mul, cultmath_cycles_mix_screen, cultmath_cycles_mix_overlay, cultmath_cycles_mix_sub, cultmath_cycles_mix_div, cultmath_cycles_mix_diff, cultmath_cycles_mix_exclusion, cultmath_cycles_mix_dark, cultmath_cycles_mix_light, cultmath_cycles_mix_dodge, cultmath_cycles_mix_burn, cultmath_cycles_rgb_to_hsv, cultmath_cycles_hsv_to_rgb, cultmath_cycles_mix_hue, cultmath_cycles_mix_sat, cultmath_cycles_mix_val, cultmath_cycles_mix_color, cultmath_cycles_mix_soft, cultmath_cycles_mix_linear, cultmath_cycles_rgb_to_bw, cultmath_cycles_ramp_index is in CultMath.Cycles.glsl, to be concatenated after this file by consumers that call it. +#ifndef CULTMATH_GLSL +#define CULTMATH_GLSL +// CultMath HLSL mirror. +// +// HLSL already owns the vec2/vec3/vec4 type names and most primitive +// intrinsics. This include names the CultMath semantic surface explicitly so +// shader code, C# wrappers, and Rust/native parity fixtures can share the same +// small vocabulary without local helper drift. + +const float CULTMATH_PI = 3.14159265358979323846; +const float CULTMATH_TAU = 6.28318530717958647692; +const float CULTMATH_HALF_PI = 1.57079632679489661923; + +float cultmath_radians(float degrees_value) { return degrees_value * (CULTMATH_PI / 180.0); } +float cultmath_degrees(float radians_value) { return radians_value * (180.0 / CULTMATH_PI); } + +float cultmath_frac(float value) { return value - floor(value); } +vec2 cultmath_frac(vec2 value) { return value - floor(value); } +vec3 cultmath_frac(vec3 value) { return value - floor(value); } +vec4 cultmath_frac(vec4 value) { return value - floor(value); } + +float cultmath_clamp(float value, float minimum, float maximum) { return min(max(value, minimum), maximum); } +vec2 cultmath_clamp(vec2 value, vec2 minimum, vec2 maximum) { return min(max(value, minimum), maximum); } +vec3 cultmath_clamp(vec3 value, vec3 minimum, vec3 maximum) { return min(max(value, minimum), maximum); } +vec4 cultmath_clamp(vec4 value, vec4 minimum, vec4 maximum) { return min(max(value, minimum), maximum); } + +// DXIL Saturate is FMin(1, FMax(0, x)); dxc lowers min/max to the same FMin/FMax, NaN rules included. +float cultmath_saturate(float value) { return min(1.0, max(0.0, value)); } +vec2 cultmath_saturate(vec2 value) { return min(vec2(1.0, 1.0), max(vec2(0.0, 0.0), value)); } +vec3 cultmath_saturate(vec3 value) { return min(vec3(1.0, 1.0, 1.0), max(vec3(0.0, 0.0, 0.0), value)); } +vec4 cultmath_saturate(vec4 value) { return min(vec4(1.0, 1.0, 1.0, 1.0), max(vec4(0.0, 0.0, 0.0, 0.0), value)); } + +float cultmath_lerp(float start, float end, float amount) { return start + (end - start) * amount; } +vec2 cultmath_lerp(vec2 start, vec2 end, vec2 amount) { return start + (end - start) * amount; } +vec3 cultmath_lerp(vec3 start, vec3 end, vec3 amount) { return start + (end - start) * amount; } +vec4 cultmath_lerp(vec4 start, vec4 end, vec4 amount) { return start + (end - start) * amount; } + +float cultmath_step(float edge, float value) { return value < edge ? 0.0 : 1.0; } +vec2 cultmath_step(vec2 edge, vec2 value) { return vec2(cultmath_step(edge.x, value.x), cultmath_step(edge.y, value.y)); } +vec3 cultmath_step(vec3 edge, vec3 value) { return vec3(cultmath_step(edge.x, value.x), cultmath_step(edge.y, value.y), cultmath_step(edge.z, value.z)); } +vec4 cultmath_step(vec4 edge, vec4 value) { return vec4(cultmath_step(edge.x, value.x), cultmath_step(edge.y, value.y), cultmath_step(edge.z, value.z), cultmath_step(edge.w, value.w)); } + +float cultmath_smoothstep(float minimum, float maximum, float value) +{ + float t = cultmath_saturate((value - minimum) / (maximum - minimum)); + return t * t * (3.0 - 2.0 * t); +} + +vec2 cultmath_smoothstep(vec2 minimum, vec2 maximum, vec2 value) +{ + vec2 t = cultmath_saturate((value - minimum) / (maximum - minimum)); + return t * t * (vec2(3.0, 3.0) - vec2(2.0, 2.0) * t); +} + +vec3 cultmath_smoothstep(vec3 minimum, vec3 maximum, vec3 value) +{ + vec3 t = cultmath_saturate((value - minimum) / (maximum - minimum)); + return t * t * (vec3(3.0, 3.0, 3.0) - vec3(2.0, 2.0, 2.0) * t); +} + +vec4 cultmath_smoothstep(vec4 minimum, vec4 maximum, vec4 value) +{ + vec4 t = cultmath_saturate((value - minimum) / (maximum - minimum)); + return t * t * (vec4(3.0, 3.0, 3.0, 3.0) - vec4(2.0, 2.0, 2.0, 2.0) * t); +} + +float cultmath_smoothstep01(float value) { return cultmath_smoothstep(0.0, 1.0, value); } + +float cultmath_smootherstep(float value) +{ + value = cultmath_saturate(value); + return value * value * value * (value * (value * 6.0 - 15.0) + 10.0); +} + +vec2 cultmath_snoise_mod289(vec2 value) { return value - floor(value * (1.0 / 289.0)) * 289.0; } +vec3 cultmath_snoise_mod289(vec3 value) { return value - floor(value * (1.0 / 289.0)) * 289.0; } +vec4 cultmath_snoise_mod289(vec4 value) { return value - floor(value * (1.0 / 289.0)) * 289.0; } +vec3 cultmath_snoise_permute(vec3 value) { return cultmath_snoise_mod289(((value * 34.0) + 10.0) * value); } +vec4 cultmath_snoise_permute(vec4 value) { return cultmath_snoise_mod289(((value * 34.0) + 10.0) * value); } + +// Ashima Arts / Ian McEwan 3D simplex noise (MIT), same float32 evaluation +// order as the C# math.snoise(vec3) mirror. Ties resolve in simplexnoise1234's +// total order; see math.snoise. +float cultmath_snoise(vec3 value) +{ + vec2 c = vec2(1.0 / 6.0, 1.0 / 3.0); + vec3 i = floor(value + dot(value, c.yyy)); + vec3 x0 = value - i + dot(i, c.xxx); + vec3 g = vec3(step(x0.y, x0.x), step(x0.z, x0.y), 1.0 - step(x0.z, x0.x)); + vec3 l = 1.0 - g; + vec3 i1 = min(g.xyz, l.zxy); + vec3 i2 = max(g.xyz, l.zxy); + vec3 x1 = x0 - i1 + c.xxx; + vec3 x2 = x0 - i2 + c.yyy; + vec3 x3 = x0 - 0.5; + + i = cultmath_snoise_mod289(i); + vec4 p = cultmath_snoise_permute(cultmath_snoise_permute(cultmath_snoise_permute( + i.z + vec4(0.0, i1.z, i2.z, 1.0)) + i.y + vec4(0.0, i1.y, i2.y, 1.0)) + i.x + vec4(0.0, i1.x, i2.x, 1.0)); + + const float n_ = 0.142857142857; + vec3 ns = vec3(2.0 * n_, 0.5 * n_ - 1.0, n_); + vec4 j = p - 49.0 * floor(p * ns.z * ns.z); + vec4 x_ = floor(j * ns.z); + vec4 y_ = floor(j - 7.0 * x_); + vec4 x = x_ * ns.x + ns.yyyy; + vec4 y = y_ * ns.x + ns.yyyy; + vec4 h = 1.0 - abs(x) - abs(y); + + vec4 b0 = vec4(x.xy, y.xy); + vec4 b1 = vec4(x.zw, y.zw); + vec4 s0 = floor(b0) * 2.0 + 1.0; + vec4 s1 = floor(b1) * 2.0 + 1.0; + vec4 sh = -step(h, vec4(0.0, 0.0, 0.0, 0.0)); + vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy; + vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww; + + vec3 p0 = vec3(a0.xy, h.x); + vec3 p1 = vec3(a0.zw, h.y); + vec3 p2 = vec3(a1.xy, h.z); + vec3 p3 = vec3(a1.zw, h.w); + vec4 norm = 1.79284291400159 - 0.85373472095314 * vec4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3)); + p0 *= norm.x; + p1 *= norm.y; + p2 *= norm.z; + p3 *= norm.w; + + vec4 m = max(0.5 - vec4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0); + m = m * m; + return 105.0 * dot(m * m, vec4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3))); +} + +float cultmath_snoise(vec2 value) +{ + vec4 c = vec4( + 0.211324865405187, + 0.366025403784439, + -0.577350269189626, + 0.024390243902439); + vec2 i = floor(value + dot(value, c.yy)); + vec2 x0 = value - i + dot(i, c.xx); + vec2 i1 = x0.x > x0.y ? vec2(1.0, 0.0) : vec2(0.0, 1.0); + vec4 x12 = vec4(x0.x + c.x - i1.x, x0.y + c.x - i1.y, x0.x + c.z, x0.y + c.z); + + i = cultmath_snoise_mod289(i); + vec3 p = cultmath_snoise_permute( + cultmath_snoise_permute(i.y + vec3(0.0, i1.y, 1.0)) + i.x + vec3(0.0, i1.x, 1.0)); + vec3 m = max(0.5 - vec3(dot(x0, x0), dot(x12.xy, x12.xy), dot(x12.zw, x12.zw)), 0.0); + m *= m; + m *= m; + + vec3 x = 2.0 * fract(p * c.w) - 1.0; + vec3 h = abs(x) - 0.5; + vec3 ox = floor(x + 0.5); + vec3 a0 = x - ox; + m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h); + + vec3 g = vec3( + a0.x * x0.x + h.x * x0.y, + a0.y * x12.x + h.y * x12.y, + a0.z * x12.z + h.z * x12.w); + return 130.0 * dot(m, g); +} + +// Analytic gradient of cultmath_snoise(vec3) (invariant 8), following webgl-noise +// src/noise3Dgrad.glsl (Ashima Arts / McEwan, MIT); same kernel, permutation and scale as +// cultmath_snoise(vec3) (stegu/webgl-noise 22434e04d7). See the C# math.snoise_grad comment for the +// derivation. Every local up through p0..p3 and x0..x3 is cultmath_snoise(vec3)'s own derivation +// verbatim. +vec4 cultmath_snoise_grad(vec3 value) +{ + vec2 c = vec2(1.0 / 6.0, 1.0 / 3.0); + vec3 i = floor(value + dot(value, c.yyy)); + vec3 x0 = value - i + dot(i, c.xxx); + vec3 g = vec3(step(x0.y, x0.x), step(x0.z, x0.y), 1.0 - step(x0.z, x0.x)); + vec3 l = 1.0 - g; + vec3 i1 = min(g.xyz, l.zxy); + vec3 i2 = max(g.xyz, l.zxy); + vec3 x1 = x0 - i1 + c.xxx; + vec3 x2 = x0 - i2 + c.yyy; + vec3 x3 = x0 - 0.5; + + i = cultmath_snoise_mod289(i); + vec4 p = cultmath_snoise_permute(cultmath_snoise_permute(cultmath_snoise_permute( + i.z + vec4(0.0, i1.z, i2.z, 1.0)) + i.y + vec4(0.0, i1.y, i2.y, 1.0)) + i.x + vec4(0.0, i1.x, i2.x, 1.0)); + + const float n_ = 0.142857142857; + vec3 ns = vec3(2.0 * n_, 0.5 * n_ - 1.0, n_); + vec4 j = p - 49.0 * floor(p * ns.z * ns.z); + vec4 x_ = floor(j * ns.z); + vec4 y_ = floor(j - 7.0 * x_); + vec4 x = x_ * ns.x + ns.yyyy; + vec4 y = y_ * ns.x + ns.yyyy; + vec4 h = 1.0 - abs(x) - abs(y); + + vec4 b0 = vec4(x.xy, y.xy); + vec4 b1 = vec4(x.zw, y.zw); + vec4 s0 = floor(b0) * 2.0 + 1.0; + vec4 s1 = floor(b1) * 2.0 + 1.0; + vec4 sh = -step(h, vec4(0.0, 0.0, 0.0, 0.0)); + vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy; + vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww; + + vec3 p0 = vec3(a0.xy, h.x); + vec3 p1 = vec3(a0.zw, h.y); + vec3 p2 = vec3(a1.xy, h.z); + vec3 p3 = vec3(a1.zw, h.w); + vec4 norm = 1.79284291400159 - 0.85373472095314 * vec4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3)); + p0 *= norm.x; + p1 *= norm.y; + p2 *= norm.z; + p3 *= norm.w; + + vec4 m0 = max(0.5 - vec4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0); + vec4 m2 = m0 * m0; + vec4 m3 = m2 * m0; + vec4 m4 = m2 * m2; + + vec4 px = vec4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3)); + float value2 = 105.0 * dot(m4, px); + vec3 grad = -8.0 * m3.x * x0 * px.x + m4.x * p0 + + -8.0 * m3.y * x1 * px.y + m4.y * p1 + + -8.0 * m3.z * x2 * px.z + m4.z * p2 + + -8.0 * m3.w * x3 * px.w + m4.w * p3; + grad *= 105.0; + + return vec4(grad, value2); +} + +// Analytic Hessian of cultmath_snoise(vec3); see the C# math.snoise_hessian comment for the +// derivation. Every local up through p0..p3 and x0..x3 is cultmath_snoise_grad's own. The matrix is +// built from nine scalars, which read as rows in HLSL and as columns in GLSL; it is symmetric, so the +// two agree. +mat3 cultmath_snoise_hessian(vec3 value) +{ + vec2 c = vec2(1.0 / 6.0, 1.0 / 3.0); + vec3 i = floor(value + dot(value, c.yyy)); + vec3 x0 = value - i + dot(i, c.xxx); + vec3 g = vec3(step(x0.y, x0.x), step(x0.z, x0.y), 1.0 - step(x0.z, x0.x)); + vec3 l = 1.0 - g; + vec3 i1 = min(g.xyz, l.zxy); + vec3 i2 = max(g.xyz, l.zxy); + vec3 x1 = x0 - i1 + c.xxx; + vec3 x2 = x0 - i2 + c.yyy; + vec3 x3 = x0 - 0.5; + + i = cultmath_snoise_mod289(i); + vec4 p = cultmath_snoise_permute(cultmath_snoise_permute(cultmath_snoise_permute( + i.z + vec4(0.0, i1.z, i2.z, 1.0)) + i.y + vec4(0.0, i1.y, i2.y, 1.0)) + i.x + vec4(0.0, i1.x, i2.x, 1.0)); + + const float n_ = 0.142857142857; + vec3 ns = vec3(2.0 * n_, 0.5 * n_ - 1.0, n_); + vec4 j = p - 49.0 * floor(p * ns.z * ns.z); + vec4 x_ = floor(j * ns.z); + vec4 y_ = floor(j - 7.0 * x_); + vec4 x = x_ * ns.x + ns.yyyy; + vec4 y = y_ * ns.x + ns.yyyy; + vec4 h = 1.0 - abs(x) - abs(y); + + vec4 b0 = vec4(x.xy, y.xy); + vec4 b1 = vec4(x.zw, y.zw); + vec4 s0 = floor(b0) * 2.0 + 1.0; + vec4 s1 = floor(b1) * 2.0 + 1.0; + vec4 sh = -step(h, vec4(0.0, 0.0, 0.0, 0.0)); + vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy; + vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww; + + vec3 p0 = vec3(a0.xy, h.x); + vec3 p1 = vec3(a0.zw, h.y); + vec3 p2 = vec3(a1.xy, h.z); + vec3 p3 = vec3(a1.zw, h.w); + vec4 norm = 1.79284291400159 - 0.85373472095314 * vec4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3)); + p0 *= norm.x; + p1 *= norm.y; + p2 *= norm.z; + p3 *= norm.w; + + vec4 m0 = max(0.5 - vec4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0); + vec4 m2 = m0 * m0; + vec4 m3 = m2 * m0; + vec4 px = vec4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3)); + vec4 wide = 48.0 * m2 * px; + vec4 narrow = 8.0 * m3; + + vec4 cxx = vec4(x0.x, x1.x, x2.x, x3.x); + vec4 cxy = vec4(x0.y, x1.y, x2.y, x3.y); + vec4 cxz = vec4(x0.z, x1.z, x2.z, x3.z); + vec4 cpx = vec4(p0.x, p1.x, p2.x, p3.x); + vec4 cpy = vec4(p0.y, p1.y, p2.y, p3.y); + vec4 cpz = vec4(p0.z, p1.z, p2.z, p3.z); + + float hxx = 105.0 * (dot(wide * cxx, cxx) - dot(narrow, 2.0 * cpx * cxx + px)); + float hyy = 105.0 * (dot(wide * cxy, cxy) - dot(narrow, 2.0 * cpy * cxy + px)); + float hzz = 105.0 * (dot(wide * cxz, cxz) - dot(narrow, 2.0 * cpz * cxz + px)); + float hxy = 105.0 * (dot(wide * cxx, cxy) - dot(narrow, cpx * cxy + cpy * cxx)); + float hxz = 105.0 * (dot(wide * cxx, cxz) - dot(narrow, cpx * cxz + cpz * cxx)); + float hyz = 105.0 * (dot(wide * cxy, cxz) - dot(narrow, cpy * cxz + cpz * cxy)); + return mat3(hxx, hxy, hxz, hxy, hyy, hyz, hxz, hyz, hzz); +} + +// The integral over [t0, t1] of clamp(a + b t, lo, hi); see the C# math.integral_clamp_linear comment. +float cultmath_integral_clamp_linear(float a, float b, float lo, float hi, float t0, float t1) +{ + lo = min(lo, hi); + if (b == 0.0) + return t1 != t0 ? clamp(a, lo, hi) * (t1 - t0) : 0.0; + + float direction = t1 < t0 ? -1.0 : 1.0; + float start = min(t0, t1); + float stop = max(t0, t1); + float atLo = (lo - a) / b; + float atHi = (hi - a) / b; + float enter = clamp(min(atLo, atHi), start, stop); + float leave = clamp(max(atLo, atHi), start, stop); + float before = b > 0.0 ? lo : hi; + float after = b > 0.0 ? hi : lo; + float held = enter > start ? before * (enter - start) : 0.0; + float kept = stop > leave ? after * (stop - leave) : 0.0; + float total = held + (a + b * (0.5 * (enter + leave))) * (leave - enter) + kept; + return direction * total; +} + +// Octave sum of cultmath_snoise_grad (invariant 8); see the C# math.fbm_grad comment for the +// per-octave frequency/amplitude derivation and why octaves is clamped. +vec4 cultmath_fbm_grad(vec3 p, int octaves, float lacunarity, float gain) +{ + octaves = clamp(octaves, 0, 16); + float amplitude = 1.0; + float frequency = 1.0; + float value = 0.0; + vec3 gradient = vec3(0.0, 0.0, 0.0); + for (int i = 0; i < octaves; i++) + { + vec4 n = cultmath_snoise_grad(p * frequency); + value += amplitude * n.w; + gradient += amplitude * frequency * n.xyz; + frequency *= lacunarity; + amplitude *= gain; + } + + return vec4(gradient, value); +} + +// Ridged multifractal noise (Musgrave, Texturing and Modeling, ch. 16); see the C# math.ridged_grad +// comment for the fold/sign derivation, the deliberate crease at n = 0, and why octaves is clamped. +vec4 cultmath_ridged_grad(vec3 p, int octaves, float lacunarity, float gain) +{ + octaves = clamp(octaves, 0, 16); + float amplitude = 1.0; + float frequency = 1.0; + float value = 0.0; + vec3 gradient = vec3(0.0, 0.0, 0.0); + for (int i = 0; i < octaves; i++) + { + vec4 n = cultmath_snoise_grad(p * frequency); + float s = sign(n.w); + value += amplitude * (1.0 - abs(n.w)); + gradient += -amplitude * frequency * s * n.xyz; + frequency *= lacunarity; + amplitude *= gain; + } + + return vec4(gradient, value); +} + +float cultmath_lengthsq(vec2 value) { return dot(value, value); } +float cultmath_lengthsq(vec3 value) { return dot(value, value); } +float cultmath_lengthsq(vec4 value) { return dot(value, value); } + +float cultmath_distance(vec2 left, vec2 right) { return length(left - right); } +float cultmath_distance(vec3 left, vec3 right) { return length(left - right); } +float cultmath_distance(vec4 left, vec4 right) { return length(left - right); } + +vec2 cultmath_reflect(vec2 incident, vec2 normal_value) +{ + return incident - 2.0 * dot(normal_value, incident) * normal_value; +} + +vec3 cultmath_reflect(vec3 incident, vec3 normal_value) +{ + return incident - 2.0 * dot(normal_value, incident) * normal_value; +} + +vec2 cultmath_rotate(vec2 value, float radians_value) +{ + float s = sin(radians_value); + float c = cos(radians_value); + return vec2(value.x * c - value.y * s, value.x * s + value.y * c); +} + +vec2 cultmath_rotate_degrees(vec2 value, float degrees_value) +{ + return cultmath_rotate(value, cultmath_radians(degrees_value)); +} + +float cultmath_csum(vec2 value) { return value.x + value.y; } +float cultmath_csum(vec3 value) { return value.x + value.y + value.z; } +float cultmath_csum(vec4 value) { return value.x + value.y + value.z + value.w; } + +float cultmath_decay(float source, float lambda_value, float dt) { return source * exp(-lambda_value * dt); } +vec2 cultmath_decay(vec2 source, float lambda_value, float dt) { return source * exp(-lambda_value * dt); } +vec3 cultmath_decay(vec3 source, float lambda_value, float dt) { return source * exp(-lambda_value * dt); } +vec4 cultmath_decay(vec4 source, float lambda_value, float dt) { return source * exp(-lambda_value * dt); } + +float cultmath_damp(float start, float end, float lambda_value, float dt) +{ + return cultmath_lerp(start, end, 1.0 - exp(-lambda_value * dt)); +} + +vec2 cultmath_damp(vec2 start, vec2 end, float lambda_value, float dt) +{ + float amount = 1.0 - exp(-lambda_value * dt); + return cultmath_lerp(start, end, vec2(amount, amount)); +} + +vec3 cultmath_damp(vec3 start, vec3 end, float lambda_value, float dt) +{ + float amount = 1.0 - exp(-lambda_value * dt); + return cultmath_lerp(start, end, vec3(amount, amount, amount)); +} + +vec4 cultmath_damp(vec4 start, vec4 end, float lambda_value, float dt) +{ + float amount = 1.0 - exp(-lambda_value * dt); + return cultmath_lerp(start, end, vec4(amount, amount, amount, amount)); +} + +float cultmath_catmullrom(float p0, float p1, float p2, float p3, float t) +{ + float t2 = t * t; + float t3 = t2 * t; + return 0.5 * ((2.0 * p1) + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3); +} + +vec2 cultmath_catmullrom(vec2 p0, vec2 p1, vec2 p2, vec2 p3, float t) +{ + float t2 = t * t; + float t3 = t2 * t; + return 0.5 * ((2.0 * p1) + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3); +} + +vec3 cultmath_catmullrom(vec3 p0, vec3 p1, vec3 p2, vec3 p3, float t) +{ + float t2 = t * t; + float t3 = t2 * t; + return 0.5 * ((2.0 * p1) + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3); +} + +vec4 cultmath_catmullrom(vec4 p0, vec4 p1, vec4 p2, vec4 p3, float t) +{ + float t2 = t * t; + float t3 = t2 * t; + return 0.5 * ((2.0 * p1) + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3); +} + +float cultmath_quadratic_bezier(float p0, float p1, float p2, float t) +{ + t = cultmath_saturate(t); + float inv = 1.0 - t; + return inv * inv * p0 + 2.0 * inv * t * p1 + t * t * p2; +} + +vec3 cultmath_quadratic_bezier(vec3 p0, vec3 p1, vec3 p2, float t) +{ + t = cultmath_saturate(t); + float inv = 1.0 - t; + return inv * inv * p0 + 2.0 * inv * t * p1 + t * t * p2; +} + +float cultmath_cubic_bezier(float p0, float p1, float p2, float p3, float t) +{ + t = cultmath_saturate(t); + float inv = 1.0 - t; + return inv * inv * inv * p0 + 3.0 * inv * inv * t * p1 + 3.0 * inv * t * t * p2 + t * t * t * p3; +} + +vec3 cultmath_cubic_bezier(vec3 p0, vec3 p1, vec3 p2, vec3 p3, float t) +{ + t = cultmath_saturate(t); + float inv = 1.0 - t; + return inv * inv * inv * p0 + 3.0 * inv * inv * t * p1 + 3.0 * inv * t * t * p2 + t * t * t * p3; +} + +float cultmath_hash(float value) { return cultmath_frac(sin(value) * 43758.5453); } +float cultmath_hash(vec2 value) { return cultmath_hash(dot(value, vec2(127.1, 311.7))); } +float cultmath_hash(vec3 value) { return cultmath_hash(dot(value, vec3(127.1, 311.7, 74.7))); } + +// PCG hashes (Jarzynski and Olano, JCGT 2020), written over scalar uints so CultMath's ivec3/ivec4 carry the bits. +uint cultmath_pcg(uint value) +{ + uint state = value * 747796405u + 2891336453u; + uint word = ((state >> int((state >> 28) + 4u)) ^ state) * 277803737u; + return (word >> 22) ^ word; +} + +ivec3 cultmath_pcg3d(ivec3 value) +{ + uint x = uint(value.x) * 1664525u + 1013904223u, y = uint(value.y) * 1664525u + 1013904223u, z = uint(value.z) * 1664525u + 1013904223u; + x += y * z; y += z * x; z += x * y; + x ^= x >> 16; y ^= y >> 16; z ^= z >> 16; + x += y * z; y += z * x; z += x * y; + return ivec3(int(x), int(y), int(z)); +} + +ivec3 cultmath_pcg3d(vec3 value) { return cultmath_pcg3d(ivec3(int(floatBitsToUint(value.x)), int(floatBitsToUint(value.y)), int(floatBitsToUint(value.z)))); } +ivec3 cultmath_pcg3d(vec2 value) { return cultmath_pcg3d(vec3(value, 0.0)); } + +ivec4 cultmath_pcg4d(ivec4 value) +{ + uint x = uint(value.x) * 1664525u + 1013904223u, y = uint(value.y) * 1664525u + 1013904223u; + uint z = uint(value.z) * 1664525u + 1013904223u, w = uint(value.w) * 1664525u + 1013904223u; + x += y * w; y += z * x; z += x * y; w += y * z; + x ^= x >> 16; y ^= y >> 16; z ^= z >> 16; w ^= w >> 16; + x += y * w; y += z * x; z += x * y; w += y * z; + return ivec4(int(x), int(y), int(z), int(w)); +} + +ivec4 cultmath_pcg4d(vec4 value) { return cultmath_pcg4d(ivec4(int(floatBitsToUint(value.x)), int(floatBitsToUint(value.y)), int(floatBitsToUint(value.z)), int(floatBitsToUint(value.w)))); } + +// Inigo Quilez, "smooth minimum": quadratic-polynomial smin carried to value-and-gradient form. See +// math.smin_grad's comment (math.cs) for why the gradient blend is exact, not an approximation. +vec4 cultmath_smin_grad(vec4 a, vec4 b, float k) +{ + float h = clamp(0.5 + 0.5 * (b.w - a.w) / k, 0.0, 1.0); + float value = mix(b.w, a.w, h) - k * h * (1.0 - h); + vec3 gradient = mix(b.xyz, a.xyz, h); + return vec4(gradient, value); +} + +// Invariant 8's one struct return shape (design.md, "HLSL Target: Source Transformations"): +// compared field by field and bit for bit against CultMath.CultCellular, same field order. +struct CultCellular +{ + vec4 nearest; + vec4 edge; + float id; +}; + +// Worley, "A Cellular Texture Basis Function" (SIGGRAPH 1996). See math.cellular's comment +// (math.cs) for the hashing, gradient, search-radius exactness proof, id source and +// degenerate-point conventions this mirrors exactly, including the 5x5x5 lower-bound prune. +CultCellular cultmath_cellular(vec3 p) +{ + vec3 cell = floor(p); + float f1 = 1.0e30; + float f2 = 1.0e30; + vec3 c1 = vec3(0.0, 0.0, 0.0); + vec3 c2 = vec3(0.0, 0.0, 0.0); + vec3 cellId = vec3(0.0, 0.0, 0.0); + + for (int dz = -2; dz <= 2; dz++) + { + float lz = max(0.0, abs(float(dz)) - 1.0); + for (int dy = -2; dy <= 2; dy++) + { + float ly = max(0.0, abs(float(dy)) - 1.0); + for (int dx = -2; dx <= 2; dx++) + { + float lx = max(0.0, abs(float(dx)) - 1.0); + float lowerBound = sqrt(lx * lx + ly * ly + lz * lz); + if (lowerBound >= f2) + continue; + + vec3 neighbor = cell + vec3(dx, dy, dz); + ivec3 hash = cultmath_pcg3d(ivec3(neighbor)); + vec3 jitter = vec3( + float(uint(hash.x) >> 8) * (1.0 / 16777216.0), + float(uint(hash.y) >> 8) * (1.0 / 16777216.0), + float(uint(hash.z) >> 8) * (1.0 / 16777216.0)); + vec3 feature = neighbor + jitter; + float d = cultmath_distance(p, feature); + + if (d < f1) + { + f2 = f1; c2 = c1; + f1 = d; c1 = feature; cellId = neighbor; + } + else if (d < f2) + { + f2 = d; c2 = feature; + } + } + } + } + + // F1 = 0 and F2 = 0 share the same degenerate-gradient guard; see math.cellular's comment + // (math.cs) for why F2 = 0 is reachable past roughly |p| = 2^23. + vec3 grad1 = f1 > 0.0 ? (p - c1) / f1 : vec3(0.0, 0.0, 0.0); + vec3 grad2 = f2 > 0.0 ? (p - c2) / f2 : vec3(0.0, 0.0, 0.0); + + // id comes from pcg4d over the winning cell's integer coordinate alone (same int-hashing rule + // as the jitter above), never from the pcg3d hash that produced its jitter (F4). + ivec4 idHash = cultmath_pcg4d(ivec4(ivec3(cellId), 0)); + + CultCellular result; + result.nearest = vec4(grad1, f1); + result.edge = vec4(grad2 - grad1, f2 - f1); + result.id = float(uint(idHash.w) >> 8) * (1.0 / 16777216.0); + return result; +} + + + + +// Interval arithmetic over vec2(lo, hi), lo <= hi, both finite. Every cultmath_iv_* function returns +// an interval containing f(x) for every x in its input intervals, mirroring math.Interval.cs bit for +// bit; see that file and docs/design.md ("Intervals") for each op's enclosure and its ulp widening. +// Included by CultMath.hlsl after its own definitions of cultmath_smoothstep and cultmath_snoise. + +// Lipschitz constant of cultmath_snoise(vec3); provenance in math.Interval.cs (SNOISE_LIPSCHITZ). +const float CULTMATH_SNOISE_LIPSCHITZ = 7.9640074; + +// Lipschitz constant of cultmath_snoise(vec2); provenance in math.Interval.cs (SNOISE2_LIPSCHITZ). +const float CULTMATH_SNOISE2_LIPSCHITZ = 8.117002; + +vec2 cultmath_iv_point(float value) { return vec2(value, value); } +vec2 cultmath_iv_add(vec2 a, vec2 b) { return vec2(a.x + b.x, a.y + b.y); } +vec2 cultmath_iv_sub(vec2 a, vec2 b) { return vec2(a.x - b.y, a.y - b.x); } +vec2 cultmath_iv_neg(vec2 a) { return vec2(-a.y, -a.x); } + +vec2 cultmath_iv_mul(vec2 a, vec2 b) +{ + float p0 = a.x * b.x; + float p1 = a.x * b.y; + float p2 = a.y * b.x; + float p3 = a.y * b.y; + return vec2(min(min(p0, p1), min(p2, p3)), max(max(p0, p1), max(p2, p3))); +} + +vec2 cultmath_iv_scale(vec2 a, float s) { return s >= 0.0 ? vec2(a.x * s, a.y * s) : vec2(a.y * s, a.x * s); } + +vec2 cultmath_iv_abs(vec2 a) +{ + if (a.x >= 0.0) + return a; + if (a.y <= 0.0) + return vec2(-a.y, -a.x); + return vec2(0.0, max(-a.x, a.y)); +} + +vec2 cultmath_iv_min(vec2 a, vec2 b) { return vec2(min(a.x, b.x), min(a.y, b.y)); } +vec2 cultmath_iv_max(vec2 a, vec2 b) { return vec2(max(a.x, b.x), max(a.y, b.y)); } + +vec2 cultmath_iv_sqr(vec2 a) +{ + float l = a.x * a.x; + float h = a.y * a.y; + if (a.x >= 0.0) + return vec2(l, h); + if (a.y <= 0.0) + return vec2(h, l); + return vec2(0.0, max(l, h)); +} + +vec2 cultmath_iv_sqrt(vec2 a) { return vec2(sqrt(max(a.x, 0.0)), sqrt(max(a.y, 0.0))); } + +// Widened by one ulp on each side: exp is not required to be correctly rounded. +vec2 cultmath_iv_exp(vec2 a) +{ + float lo = exp(a.x); + float hi = exp(a.y); + return vec2(lo > 0.0 ? uintBitsToFloat(floatBitsToUint(lo) - 1u) : 0.0, uintBitsToFloat(floatBitsToUint(hi) + 1u)); +} + +vec2 cultmath_iv_clamp(vec2 a, float minimum, float maximum) +{ + return vec2(cultmath_clamp(a.x, minimum, maximum), cultmath_clamp(a.y, minimum, maximum)); +} + +vec2 cultmath_iv_saturate(vec2 a) { return vec2(cultmath_saturate(a.x), cultmath_saturate(a.y)); } + +// The endpoints, ordered, widened by one ulp inside [0, 1]: the float32 evaluation of smoothstep is +// monotone only to within one ulp. +vec2 cultmath_iv_smoothstep(float minimum, float maximum, vec2 a) +{ + float s0 = cultmath_smoothstep(minimum, maximum, a.x); + float s1 = cultmath_smoothstep(minimum, maximum, a.y); + float lo = min(s0, s1); + float hi = max(s0, s1); + return vec2(lo > 0.0 ? uintBitsToFloat(floatBitsToUint(lo) - 1u) : 0.0, hi < 1.0 ? uintBitsToFloat(floatBitsToUint(hi) + 1u) : 1.0); +} + +// The natural interval extension of x + (y - x) * amount, which is how cultmath_lerp evaluates. +vec2 cultmath_iv_lerp(vec2 a, vec2 b, float amount) { return cultmath_iv_add(a, cultmath_iv_scale(cultmath_iv_sub(b, a), amount)); } + +// [n - L r, n + L r] intersected with [-1, 1], n = cultmath_snoise(centre). One snoise, value only. +vec2 cultmath_iv_snoise_ball(vec3 centre, float radius) +{ + float n = cultmath_snoise(centre); + float e = CULTMATH_SNOISE_LIPSCHITZ * radius; + return vec2(max(n - e, -1.0), min(n + e, 1.0)); +} + +// The 2D overload: [n - L2 r, n + L2 r] intersected with [-1, 1], n = cultmath_snoise(centre). One snoise, value only. +vec2 cultmath_iv_snoise_ball(vec2 centre, float radius) +{ + float n = cultmath_snoise(centre); + float e = CULTMATH_SNOISE2_LIPSCHITZ * radius; + return vec2(max(n - e, -1.0), min(n + e, 1.0)); +} + +// Octave sum of cultmath_iv_snoise_ball, compounding frequency and amplitude as the value-and-gradient fBm does. +vec2 cultmath_iv_fbm_ball(vec3 centre, float radius, int octaves, float lacunarity, float gain) +{ + octaves = clamp(octaves, 0, 16); + float amplitude = 1.0; + float frequency = 1.0; + vec2 sum = vec2(0.0, 0.0); + for (int i = 0; i < octaves; i++) + { + sum = cultmath_iv_add(sum, cultmath_iv_scale(cultmath_iv_snoise_ball(centre * frequency, radius * abs(frequency)), amplitude)); + frequency *= lacunarity; + amplitude *= gain; + } + + return sum; +} + +// A ball (xyz centre, w radius) enclosing every point of every ray of a screen tile over the depth segment +// [z0, z1], warped by at most warp. Camera frame: pinhole at the origin looking down +z, ray (m z, z). +// footprintPerDepth = N / (sqrt(2) f) for an N x N tile at focal length f. The radius is widened by +// (|c|_1 + radius) 2^-20, which covers the ball's own float32 rounding. Derivation in math.Interval.cs. +vec4 cultmath_iv_frustum_ball(vec2 centreSlope, float z0, float z1, float footprintPerDepth, float warp) +{ + float zm = (z0 + z1) * 0.5; + float axis = sqrt(centreSlope.x * centreSlope.x + centreSlope.y * centreSlope.y + 1.0); + float cx = centreSlope.x * zm; + float cy = centreSlope.y * zm; + float radius = (z1 - z0) * 0.5 * axis + z1 * footprintPerDepth + warp; + radius += (abs(cx) + abs(cy) + abs(zm) + radius) * 9.5367431640625e-7; + return vec4(cx, cy, zm, radius); +} + + + + + + +// Reduced affine forms with one shared symbol over vec3(x0, a, e): the set { x0 + a eps + e delta : +// delta in [-1, 1] } at the region's shared eps in [-1, 1], e >= 0. Every cultmath_af_* function mirrors +// math.Affine.cs bit for bit; see that file and docs/design.md ("Affine forms") for each op's enclosure +// and the float32 rounding allowance it folds into e. Included by CultMath.hlsl after +// CultMath.Interval.hlsl, whose CULTMATH_SNOISE_LIPSCHITZ it shares, and after its own +// definition of cultmath_snoise_grad. + +// An upper bound on the spectral norm of the Hessian of cultmath_snoise(vec3); provenance in +// math.Affine.cs (SNOISE_HESSIAN). +const float CULTMATH_SNOISE_HESSIAN = 55.642143; + +// The absolute rounding of one float32 read of cultmath_snoise_grad, value and gradient; the owner is +// math.Affine.cs (SNOISE_READ_VALUE, SNOISE_READ_GRADIENT), read here by cultmath_af_snoise and in +// CultMath.hlsl by cultmath_span_slack. +const float CULTMATH_SNOISE_READ_VALUE = 6.103515625e-5; +const float CULTMATH_SNOISE_READ_GRADIENT = 2.44140625e-4; + +// An upper bound on the operator norm of the third-derivative tensor of cultmath_snoise(vec3), the +// next order after CULTMATH_SNOISE_HESSIAN; provenance in math.Span.cs (SNOISE_THIRD). +const float CULTMATH_SNOISE_THIRD = 535.8009; + +vec3 cultmath_af_point(float value) { return vec3(value, 0.0, 0.0); } +vec3 cultmath_af_symbol(float x0, float a) { return vec3(x0, a, 0.0); } + +vec3 cultmath_af_from_iv(vec2 interval) +{ + float x0 = interval.x * 0.5 + interval.y * 0.5; + float e = interval.y * 0.5 - interval.x * 0.5; + return vec3(x0, 0.0, e + (abs(x0) + e) * 9.5367431640625e-7 + 1.17549435e-38); +} + +// An endpoint that would be NaN is the infinity on its side. NaN is found by its bit pattern: dxc marks +// compares fast, so a test that needs NaN to make `lo == lo` false is deleted under its default flags. +vec2 cultmath_af_range(vec3 x) +{ + float r = abs(x.y) + x.z; + float w = abs(x.x) * 9.5367431640625e-7 + r * 9.5367431640625e-7; + float lo = x.x - r - w; + float hi = x.x + r + w; + return vec2((floatBitsToUint(lo) & 0x7fffffffu) > 0x7f800000u ? uintBitsToFloat(0xff800000u) : lo, + (floatBitsToUint(hi) & 0x7fffffffu) > 0x7f800000u ? uintBitsToFloat(0x7f800000u) : hi); +} + +vec3 cultmath_af_add(vec3 x, vec3 y) +{ + float x0 = x.x + y.x; + float a = x.y + y.y; + float e = x.z + y.z; + return vec3(x0, a, e + (abs(x0) + abs(a) + e) * 9.5367431640625e-7); +} + +vec3 cultmath_af_sub(vec3 x, vec3 y) +{ + float x0 = x.x - y.x; + float a = x.y - y.y; + float e = x.z + y.z; + return vec3(x0, a, e + (abs(x0) + abs(a) + e) * 9.5367431640625e-7); +} + +vec3 cultmath_af_neg(vec3 x) { return vec3(-x.x, -x.y, x.z); } + +vec3 cultmath_af_scale(vec3 x, float s) +{ + float x0 = x.x * s; + float a = x.y * s; + float e = x.z * abs(s); + return vec3(x0, a, e + (abs(x0) + abs(a) + e) * 9.5367431640625e-7 + 1.17549435e-38); +} + +vec3 cultmath_af_add_iv(vec3 x, vec2 interval) { return cultmath_af_add(x, cultmath_af_from_iv(interval)); } + +vec3 cultmath_af_mul(vec3 x, vec3 y) +{ + float x0 = x.x * y.x; + float p = x.x * y.y; + float q = y.x * x.y; + float a = p + q; + float e = abs(x.x) * y.z + abs(y.x) * x.z + (abs(x.y) + x.z) * (abs(y.y) + y.z); + return vec3(x0, a, e + (abs(x0) + abs(p) + abs(q) + e) * 9.5367431640625e-7 + 1.17549435e-38); +} + +// The narrower of the Lipschitz form and the centred (Taylor) form over every p = centre + axis eps + w, +// |w| <= radius, from one cultmath_snoise_grad at the centre; the allowance for the float32 evaluation is +// in math.Affine.cs. +vec3 cultmath_af_snoise(vec3 centre, vec3 axis, float radius) +{ + vec4 g = cultmath_snoise_grad(centre); + vec3 gradient = vec3(g.x, g.y, g.z); + float reach = length(axis) + radius; + float lipschitz = CULTMATH_SNOISE_LIPSCHITZ * reach; + float a = dot(gradient, axis); + float remainder = length(gradient) * radius + 0.5 * CULTMATH_SNOISE_HESSIAN * reach * reach; + float allowance = CULTMATH_SNOISE_READ_VALUE + CULTMATH_SNOISE_READ_GRADIENT * reach + + CULTMATH_SNOISE_LIPSCHITZ * 9.5367431640625e-7 * (abs(centre.x) + abs(centre.y) + abs(centre.z) + reach); + bool centred = abs(a) + remainder < lipschitz; + float slope = centred ? a : 0.0; + float e = (centred ? remainder : lipschitz) + allowance; + return vec3(g.w, slope, e + (abs(g.w) + abs(slope) + e) * 9.5367431640625e-7); +} + +// Octave sum of cultmath_af_snoise, compounding frequency and amplitude as the value-and-gradient fBm does. +vec3 cultmath_af_fbm(vec3 centre, vec3 axis, float radius, int octaves, float lacunarity, float gain) +{ + octaves = clamp(octaves, 0, 16); + float amplitude = 1.0; + float frequency = 1.0; + vec3 sum = vec3(0.0, 0.0, 0.0); + for (int i = 0; i < octaves; i++) + { + sum = cultmath_af_add(sum, cultmath_af_scale(cultmath_af_snoise(centre * frequency, axis * frequency, radius * abs(frequency)), amplitude)); + frequency *= lacunarity; + amplitude *= gain; + } + + return sum; +} + +// The axis of a screen tile's slice over the depth segment [z0, z1]; camera frame of +// cultmath_iv_frustum_ball. +vec3 cultmath_af_frustum_axis(vec2 centreSlope, float z0, float z1) +{ + float h = (z1 - z0) * 0.5; + return vec3(centreSlope.x * h, centreSlope.y * h, h); +} + +// The affine slice of a screen tile over [z0, z1]: centre xyz, radius w. Derivation in math.Affine.cs. +vec4 cultmath_af_frustum_ball(vec2 centreSlope, float z0, float z1, float footprintPerDepth, float warpVariation) +{ + float zm = (z0 + z1) * 0.5; + float h = (z1 - z0) * 0.5; + float cx = centreSlope.x * zm; + float cy = centreSlope.y * zm; + float spread = (abs(centreSlope.x) + abs(centreSlope.y) + 1.0) * h; + float radius = z1 * footprintPerDepth + warpVariation; + radius += (abs(cx) + abs(cy) + abs(zm) + spread + radius) * 9.5367431640625e-7; + return vec4(cx, cy, zm, radius); +} + + + + +// Span stages (invariant 8): the SpanPath, SpanStage and SpanNoise of math.Span.cs, field for field in +// C#'s order, and cultmath_span_stage, its octave stage, statement for statement so the text is a bit-for-bit +// mirror. They follow the includes because the bounds read CULTMATH_SNOISE_LIPSCHITZ, _HESSIAN and _THIRD. +// A shader caller builds a SpanStage itself (SpanStage(1.0, 1.0, vec3(0.0), vec3(0.0), vec3(0.0), vec3(0.0)) +// is the identity of math.SpanStage.Identity, which has no shader form); see math.span_stage's comments for +// the span, the transform, the bounds and the allowances. cultmath_span_rounding and cultmath_span_slack +// are math.span_rounding and math.span_slack, the owners of S_o and of the three allowances. +struct SpanPath +{ + vec3 midpoint; + vec3 velocity; + float speed; + float acceleration; + float halfLength; +}; + +struct SpanStage +{ + float frequency; + float stretch; + vec3 axis; + vec3 shift; + vec3 offset; + vec3 octaveStep; +}; + +struct SpanNoise +{ + vec4 field; + float curvature; + float fold; +}; + +float cultmath_span_rounding(float scale, vec3 mq, SpanStage stage, vec3 octaveShift, float nu, float halfLength) +{ + return 9.5367431640625e-7 * (abs(scale) * (cultmath_csum(abs(mq)) + cultmath_csum(abs(stage.shift))) + cultmath_csum(abs(stage.offset)) + cultmath_csum(abs(octaveShift)) + 1.7320508 * nu * halfLength); +} + +vec3 cultmath_span_slack(float rounding) +{ + return vec3(CULTMATH_SNOISE_READ_VALUE + CULTMATH_SNOISE_LIPSCHITZ * rounding, + CULTMATH_SNOISE_READ_GRADIENT + CULTMATH_SNOISE_HESSIAN * rounding, + 9.765625e-4 + CULTMATH_SNOISE_THIRD * rounding); +} + +// The octave stage behind cultmath_span_fbm and cultmath_span_ridged, as math.span_stage is behind span_fbm +// and span_ridged. The Frobenius norm of the Hessian is written out in C#'s order (GLSL has no _mRC member +// access); the C# local named step is octaveShift here (it would shadow GLSL's step()). +SpanNoise cultmath_span_stage(bool ridged, SpanPath path, SpanStage stage, int octaves, float lacunarity, float gain) +{ + vec3 q = path.midpoint; + float halfLength = path.halfLength; + octaves = clamp(octaves, 0, 16); + bool stretched = dot(stage.axis, stage.axis) > 0.0 && stage.stretch != 1.0; + vec3 direction = stretched ? normalize(stage.axis) : vec3(0.0, 0.0, 0.0); + float squeeze = stretched ? 1.0 / stage.stretch - 1.0 : 0.0; + float norm = abs(stage.frequency) * (stretched ? max(1.0, 1.0 / stage.stretch) : 1.0); + vec3 mq = stretched ? stage.frequency * (q + squeeze * dot(direction, q) * direction) : stage.frequency * q; + vec3 mv = stretched ? stage.frequency * (path.velocity + squeeze * dot(direction, path.velocity) * direction) : stage.frequency * path.velocity; + float amplitude = 1.0; + float scale = 1.0; + float value = 0.0; + vec3 gradient = vec3(0.0, 0.0, 0.0); + float curvature = 0.0; + float fold = 0.0; + for (int i = 0; i < octaves; i++) + { + vec3 octaveShift = float(i) * stage.octaveStep; + vec3 x = scale * (mq + stage.shift) + (stage.offset + octaveShift); + vec4 n = cultmath_snoise_grad(x); + vec3 g = vec3(n.x, n.y, n.z); + vec3 mg = stretched ? stage.frequency * (g + squeeze * dot(direction, g) * direction) : stage.frequency * g; + if (ridged) + { + float s = sign(n.w); + value += amplitude * (1.0 - abs(n.w)); + gradient += -amplitude * scale * s * mg; + } + else + { + value += amplitude * n.w; + gradient += amplitude * scale * mg; + } + + float weight = abs(amplitude); + float factor = abs(scale) * norm; + float nu = factor * path.speed; + float rounding = cultmath_span_rounding(scale, mq, stage, octaveShift, nu, halfLength); + vec3 slack = cultmath_span_slack(rounding); + float valueSlack = slack.x; + float gradientSlack = slack.y; + float hessianSlack = slack.z; + mat3 hm = cultmath_snoise_hessian(x); + float frobenius = sqrt(hm[0][0] * hm[0][0] + hm[0][1] * hm[0][1] + hm[0][2] * hm[0][2] + hm[1][0] * hm[1][0] + hm[1][1] * hm[1][1] + hm[1][2] * hm[1][2] + + hm[2][0] * hm[2][0] + hm[2][1] * hm[2][1] + hm[2][2] * hm[2][2]); + float hessianBound = min(CULTMATH_SNOISE_HESSIAN, frobenius + hessianSlack + CULTMATH_SNOISE_THIRD * nu * halfLength); + curvature += weight * factor * factor * hessianBound; + if (ridged) + { + vec3 w = scale * mv; + float reach = hessianBound * nu * halfLength; + float gl = min(CULTMATH_SNOISE_LIPSCHITZ, length(g) + gradientSlack + reach); + float travel = (abs(dot(g, w)) + gradientSlack * length(w)) * halfLength + (hessianBound * nu * length(w) + gl * factor * path.acceleration) * (0.5 * halfLength * halfLength) + 2.0 * valueSlack; + fold += 2.0 * weight * max(travel - abs(n.w), 0.0); + } + + scale *= lacunarity; + amplitude *= gain; + } + + SpanNoise result; + result.field = vec4(gradient, value); + result.curvature = curvature; + result.fold = fold; + return result; +} + +SpanNoise cultmath_span_fbm(SpanPath path, SpanStage stage, int octaves, float lacunarity, float gain) +{ + return cultmath_span_stage(false, path, stage, octaves, lacunarity, gain); +} + +SpanNoise cultmath_span_ridged(SpanPath path, SpanStage stage, int octaves, float lacunarity, float gain) +{ + return cultmath_span_stage(true, path, stage, octaves, lacunarity, gain); +} + +float cultmath_value_noise(vec2 position) +{ + vec2 cell = floor(position); + vec2 local = cultmath_frac(position); + vec2 u = local * local * (vec2(3.0, 3.0) - vec2(2.0, 2.0) * local); + + float a = cultmath_hash(cell); + float b = cultmath_hash(cell + vec2(1.0, 0.0)); + float c = cultmath_hash(cell + vec2(0.0, 1.0)); + float d = cultmath_hash(cell + vec2(1.0, 1.0)); + + return cultmath_lerp(cultmath_lerp(a, b, u.x), cultmath_lerp(c, d, u.x), u.y); +} + +float cultmath_value_noise_bicubic(vec2 position) +{ + vec2 cell = floor(position); + vec2 local = cultmath_frac(position); + + float y0 = cultmath_catmullrom(cultmath_hash(cell + vec2(-1.0, -1.0)), cultmath_hash(cell + vec2(0.0, -1.0)), cultmath_hash(cell + vec2(1.0, -1.0)), cultmath_hash(cell + vec2(2.0, -1.0)), local.x); + float y1 = cultmath_catmullrom(cultmath_hash(cell + vec2(-1.0, 0.0)), cultmath_hash(cell + vec2(0.0, 0.0)), cultmath_hash(cell + vec2(1.0, 0.0)), cultmath_hash(cell + vec2(2.0, 0.0)), local.x); + float y2 = cultmath_catmullrom(cultmath_hash(cell + vec2(-1.0, 1.0)), cultmath_hash(cell + vec2(0.0, 1.0)), cultmath_hash(cell + vec2(1.0, 1.0)), cultmath_hash(cell + vec2(2.0, 1.0)), local.x); + float y3 = cultmath_catmullrom(cultmath_hash(cell + vec2(-1.0, 2.0)), cultmath_hash(cell + vec2(0.0, 2.0)), cultmath_hash(cell + vec2(1.0, 2.0)), cultmath_hash(cell + vec2(2.0, 2.0)), local.x); + + return cultmath_catmullrom(y0, y1, y2, y3, local.y); +} + +#endif diff --git a/src/app/features/scene/shaders/README.md b/src/app/features/scene/shaders/README.md new file mode 100644 index 00000000..b970f82b --- /dev/null +++ b/src/app/features/scene/shaders/README.md @@ -0,0 +1,19 @@ +# Vendored CultMath GLSL + +`CultMath.glsl` and `CultMath.Phacelle.glsl` are CultLib's generated WebGL2 +lowering of `packages/cultmath/shaders/CultMath.hlsl` and +`CultMath.Phacelle.hlsl`, copied unedited from CultLib branch +`eureka/cultmath-tapes-interval-ops-r5` at `658c09e` (the branch that carries +the interval ops and `CULTMATH_SNOISE2_LIPSCHITZ`; the repo default branch +does not carry the GLSL yet). Never edit them here: regenerate in CultLib and +re-copy. `CultMath.glsl` is MIT; `CultMath.Phacelle.glsl` is MPL-2.0 (Rune +Skovbo Johansen's Phacelle noise, see its header), so that file keeps its +header and any change to it is published under MPL-2.0. + +The other `*.glsl` files here are the scene's own stages. They are +concatenated by `../shaderSource.ts` after a prelude; they use no colour +literal (the palette is uniforms) and define no noise. + +`../shaderSource.ts` compiles each stage with only the part of `CultMath.glsl` that stage reaches +(`../cultmathTrim.ts`); the vendored file itself stays whole. Phacelle is added to the terrain stage +alone. diff --git a/src/app/features/scene/shaders/atmosphere.frag.glsl b/src/app/features/scene/shaders/atmosphere.frag.glsl new file mode 100644 index 00000000..adbf1b5b --- /dev/null +++ b/src/app/features/scene/shaders/atmosphere.frag.glsl @@ -0,0 +1,90 @@ +// Pass 2: atmosphere, marched only up to pass 1's depth. Height fog is closed-form; the cloud +// slab is marched with a few jittered steps. Output: rgb in-scattered light, a transmittance. +uniform sampler2D u_depth; // pass 1 hit distance, nearest +uniform int u_steps; // cloud march steps +uniform float u_frame; +uniform vec2 u_jitter; // sub-pixel jitter in pixels of this target +uniform vec2 u_size; // this target's size in pixels +uniform float u_coverage; // weather knob: 0 clear .. 1 overcast + +in vec2 v_uv; +layout(location = 0) out vec4 o_atm; + +float hg(float cosTheta, float g) +{ + float g2 = g * g; + return (1.0 - g2) / (12.566370614 * pow(1.0 + g2 - 2.0 * g * cosTheta, 1.5)); +} + +float cloudDensity(vec3 p) +{ + float wind = u_time * 2.5; + float f = cultmath_fbm_grad(vec3(p.x * 0.0042 + wind * 0.0042, p.y * 0.0075, p.z * 0.0042), 3, 2.0, 0.5).w; + float h = (p.y - W_CLOUD_Y0) / (W_CLOUD_Y1 - W_CLOUD_Y0); + float profile = smoothstep(0.0, 0.2, h) * smoothstep(1.0, 0.55, h); + return clamp((f + 0.55 * u_coverage - 0.25) * 2.4, 0.0, 1.0) * profile; +} + +void main() +{ + vec2 jittered = (gl_FragCoord.xy + u_jitter) / u_size; + vec3 rd = camRay(jittered); + vec3 ro = u_camPos; + float depth = texture(u_depth, v_uv).r; + float tEnd = min(depth, W_ATM_FAR); + + // Exponential height fog, closed form: optical depth of density d0 * exp(-k y) along the ray. + float k = W_FOG_FALLOFF; + float dy = rd.y * tEnd; + float tau = W_FOG_DENSITY * exp(-k * ro.y) * (abs(dy) > 1e-3 ? (1.0 - exp(-k * dy)) / (k * rd.y) : tEnd); + float fogT = exp(-max(tau, 0.0)); + float cosSun = dot(rd, u_sunDir); + vec3 fogLight = u_fogColor + u_sunColor * hg(cosSun, 0.55) * 0.9; + vec3 inscatter = fogLight * (1.0 - fogT); + float trans = fogT; + + // Clouds: only the part of the ray inside the slab and in front of the surface. + float ta = 0.0; + float tb = tEnd; + if (abs(rd.y) > 1e-4) + { + float t0 = (W_CLOUD_Y0 - ro.y) / rd.y; + float t1 = (W_CLOUD_Y1 - ro.y) / rd.y; + ta = max(0.0, min(t0, t1)); + tb = min(tEnd, max(t0, t1)); + } + else if (ro.y < W_CLOUD_Y0 || ro.y > W_CLOUD_Y1) + tb = 0.0; + + if (tb > ta && u_steps > 0) + { + float dt = (tb - ta) / float(u_steps); + float t = ta + dt * ign(gl_FragCoord.xy, u_frame); + float T = 1.0; + vec3 L = vec3(0.0); + float phase = hg(cosSun, 0.6) + 0.35 * hg(cosSun, -0.2); + for (int i = 0; i < u_steps; i++) + { + vec3 p = ro + rd * t; + float d = cloudDensity(p); + if (d > 0.01) + { + // One sample toward the sun stands in for self-shadowing. + float shade = exp(-cloudDensity(p + u_sunDir * 38.0) * 2.2); + vec3 light = u_sunColor * (phase * 6.0) * shade + mix(u_skyHorizon, u_skyTop, 0.6) * 0.35; + float ext = d * dt * 0.035; + float stepT = exp(-ext); + L += T * (1.0 - stepT) * light; + T *= stepT; + if (T < 0.03) + break; + } + t += dt; + } + // Fog in front of the slab is already in `inscatter`; clouds sit behind it. + inscatter += trans * L; + trans *= T; + } + + o_atm = vec4(inscatter, trans); +} diff --git a/src/app/features/scene/shaders/common.glsl b/src/app/features/scene/shaders/common.glsl new file mode 100644 index 00000000..6378a2e6 --- /dev/null +++ b/src/app/features/scene/shaders/common.glsl @@ -0,0 +1,31 @@ +// Shared by every fragment stage; concatenated after the prelude, CultMath and the world defines. +uniform vec3 u_camPos; +uniform mat3 u_camBasis; // columns: right, up, forward +uniform vec2 u_tanHalf; // x: tan(fov/2) * aspect, y: tan(fov/2) +uniform float u_time; +uniform vec3 u_sunDir; // unit, towards the sun +uniform vec3 u_sunColor; +uniform vec3 u_skyTop; +uniform vec3 u_skyHorizon; +uniform vec3 u_fogColor; + +vec3 camRay(vec2 uv) +{ + vec2 ndc = uv * 2.0 - 1.0; + return normalize(u_camBasis * vec3(ndc * u_tanHalf, 1.0)); +} + +float ign(vec2 p, float frame) +{ + p += 5.588238 * mod(frame, 64.0); + return fract(52.9829189 * fract(dot(p, vec2(0.06711056, 0.00583715)))); +} + +vec3 skyColor(vec3 rd) +{ + float h = clamp(rd.y, 0.0, 1.0); + vec3 c = mix(u_skyHorizon, u_skyTop, pow(h, 0.45)); + float s = max(dot(rd, u_sunDir), 0.0); + c += u_sunColor * (1.5 * pow(s, 256.0)); + return c; +} diff --git a/src/app/features/scene/shaders/composite.frag.glsl b/src/app/features/scene/shaders/composite.frag.glsl new file mode 100644 index 00000000..9dce31f1 --- /dev/null +++ b/src/app/features/scene/shaders/composite.frag.glsl @@ -0,0 +1,26 @@ +// Full-resolution composite to the canvas: solid over sky, then the accumulated atmosphere. +uniform sampler2D u_solid; // rgb lit, a coverage (bilinear) +uniform sampler2D u_atm; // TAA'd atmosphere (bilinear) +uniform float u_frame; + +in vec2 v_uv; +out vec4 o_frag; + +vec3 tonemap(vec3 x) +{ + // Narkowicz ACES fit. + return clamp((x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14), 0.0, 1.0); +} + +void main() +{ + vec3 rd = camRay(v_uv); + vec4 solid = texture(u_solid, v_uv); + vec4 atm = texture(u_atm, v_uv); + vec3 base = mix(skyColor(rd), solid.rgb, solid.a); + vec3 col = base * atm.a + atm.rgb; + col = tonemap(col); + col = pow(col, vec3(1.0 / 2.2)); + col += (ign(gl_FragCoord.xy, u_frame) - 0.5) / 255.0; + o_frag = vec4(col, 1.0); +} diff --git a/src/app/features/scene/shaders/fullscreen.vert.glsl b/src/app/features/scene/shaders/fullscreen.vert.glsl new file mode 100644 index 00000000..cab0e527 --- /dev/null +++ b/src/app/features/scene/shaders/fullscreen.vert.glsl @@ -0,0 +1,8 @@ +out vec2 v_uv; +void main() +{ + // One oversized triangle; uv spans 0..1 over the visible part. + vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2)); + v_uv = p; + gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0); +} diff --git a/src/app/features/scene/shaders/prepass.frag.glsl b/src/app/features/scene/shaders/prepass.frag.glsl new file mode 100644 index 00000000..4ca21a2c --- /dev/null +++ b/src/app/features/scene/shaders/prepass.frag.glsl @@ -0,0 +1,57 @@ +// Pass 0: a cone march at one ray per tile of pass-1 pixels. Writes the distance along the tile's +// centre ray that no ray in the tile can have hit before, so pass 1 starts there instead of at the +// camera. Every step is safe for every ray of the tile: the cone's radius at t is the widest any +// ray strays from the centre ray, and the centre's distance bound minus that radius is a free ball +// around each ray's own point. +uniform int u_maxSteps; +uniform vec2 u_tileHalf; // half a tile in uv: the footprint of this texel, as pass 1 samples it + +in vec2 v_uv; +layout(location = 0) out vec4 o_start; // r: start distance, W_FAR when the tile misses; g: steps spent (a half holds both) + +void main() +{ + vec3 ro = u_camPos; + vec3 rd = camRay(v_uv); + + // Widest stray of any ray in the tile from the centre ray, per unit t. The rays are the unit + // directions through a rectangle on the view plane, whose sublevel sets about rd are convex, so + // the maximum sits at a corner. The margin is for float rounding. + float spread = 0.0; + for (int c = 0; c < 4; c++) + { + vec2 corner = vec2(c & 1, c >> 1) * 2.0 - 1.0; + spread = max(spread, length(camRay(v_uv + corner * u_tileHalf) - rd)); + } + spread *= 1.05; + + // Past tEnd every ray of the tile is above W_MAX_HEIGHT and still rising (or beyond W_FAR): + // none can hit anything. + float rise = rd.y - spread; + float tEnd = W_FAR; + if (rise > 0.0) + tEnd = min(W_FAR, max(0.0, (W_MAX_HEIGHT - ro.y) / rise)); + + float t = 0.0; + float leafness; + bool onTree; + int evals = 0; + for (int i = 0; i < u_maxSteps && t < tEnd; i++) + { + evals++; + // Pass 1's hit tolerance, 0.0006 t + 0.01, is kept clear at the far end of the step too, so + // no ray of the tile starts inside its own hit radius. + float s = (sceneDistance(ro + rd * t, leafness, onTree) - spread * t - (0.0006 * t + 0.01)) / 1.0006; + if (s <= 0.0) + break; + t += s; + } + float start = t; +#ifdef START_HALF + // The target is RGBA16F: the write rounds to a half, and the round may go up. Any start past + // the true one is a ray that could skip a surface, so shave more than half a ulp off first + // (START_HALF_SCALE, START_HALF_BIAS: Scene.ts). W_FAR stays exact, an integer a half holds. + start = max(0.0, t * START_HALF_SCALE - START_HALF_BIAS); +#endif + o_start = vec4(t >= tEnd ? W_FAR : start, float(evals), 0.0, 0.0); +} diff --git a/src/app/features/scene/shaders/resolve.frag.glsl b/src/app/features/scene/shaders/resolve.frag.glsl new file mode 100644 index 00000000..cc6dd2e0 --- /dev/null +++ b/src/app/features/scene/shaders/resolve.frag.glsl @@ -0,0 +1,41 @@ +// TAA resolve for the atmosphere target: reproject history by the pass-1 depth, clamp it to the +// current frame's 3x3 neighbourhood, blend. +uniform sampler2D u_cur; +uniform sampler2D u_history; +uniform sampler2D u_depth; +uniform vec3 u_prevPos; +uniform mat3 u_prevBasis; +uniform vec2 u_prevTanHalf; +uniform float u_blend; // weight of the current frame; 1 resets +uniform vec2 u_curSize; + +in vec2 v_uv; +layout(location = 0) out vec4 o_hist; + +void main() +{ + vec2 px = 1.0 / u_curSize; + vec4 cur = texture(u_cur, v_uv); + vec4 lo = cur; + vec4 hi = cur; + for (int y = -1; y <= 1; y++) + for (int x = -1; x <= 1; x++) + { + vec4 s = texture(u_cur, v_uv + vec2(float(x), float(y)) * px); + lo = min(lo, s); + hi = max(hi, s); + } + + // Where this pixel's ray ended, reprojected into last frame's camera. + vec3 rd = camRay(v_uv); + float t = min(texture(u_depth, v_uv).r, 600.0); + vec3 v = u_camPos + rd * t - u_prevPos; + vec3 local = transpose(u_prevBasis) * v; + vec2 prevNdc = local.z > 0.0 ? local.xy / (local.z * u_prevTanHalf) : vec2(2.0); + vec2 prevUv = prevNdc * 0.5 + 0.5; + bool inside = all(greaterThanEqual(prevUv, vec2(0.0))) && all(lessThanEqual(prevUv, vec2(1.0))); + + float a = (inside && u_blend < 1.0) ? u_blend : 1.0; + vec4 hist = clamp(texture(u_history, prevUv), lo, hi); + o_hist = mix(hist, cur, a); +} diff --git a/src/app/features/scene/shaders/sdf.glsl b/src/app/features/scene/shaders/sdf.glsl new file mode 100644 index 00000000..95e96bc2 --- /dev/null +++ b/src/app/features/scene/shaders/sdf.glsl @@ -0,0 +1,95 @@ +// The solid world as a distance bound: terrain graph plus tree. Shared by the pass-1 trace and the +// cone prepass, so both step by the same bound. + +// ---- terrain ------------------------------------------------------------------------------- + +float terrainMask(vec2 xz) { return smoothstep(W_PLATEAU_IN, W_PLATEAU_OUT, length(xz)); } + +// Value only; cultmath_fbm_grad is the shipped fbm, a public value-only fbm is Libby's gap. +float terrainFbm(vec2 xz) { return cultmath_fbm_grad(vec3(xz * W_FREQ, 0.0), W_OCTAVES, W_LACUNARITY, W_GAIN).w; } + +#if W_ERODE_ENABLED +CultPhasor terrainPhacelle(vec2 xz) +{ + // A fixed stripe wave vector: ridges across the sheet, not slope-aligned erosion. + return cultmath_phacelle(vec3(xz * W_ERODE_FREQ, 0.0), W_ERODE_SIDE, 0.0, W_ERODE_NORM); +} +#endif + +// Conservative distance to the terrain graph. Above the envelope (fbm + erosion amplitude) the +// cheap envelope bound is exact enough to step by; only within it is the eroded field evaluated. +float terrainDistance(vec3 p) +{ + float m = terrainMask(p.xz); + // Flat plateau: the height is exactly 0, so neither noise is evaluated. Sky rays spend most + // of their steps here. + if (m <= 0.0) + return p.y * W_SLOPE_ENV_INV; + float hb = m * W_AMP * terrainFbm(p.xz); + float above = p.y - (hb + m * W_ERODE_AMP); + if (above > 0.0) + return above * W_SLOPE_ENV_INV; +#if W_ERODE_ENABLED + hb += m * W_ERODE_AMP * terrainPhacelle(p.xz).cos.w; +#endif + return (p.y - hb) * W_SLOPE_FULL_INV; +} + +vec3 terrainNormal(vec2 xz) +{ + float r = length(xz); + float m = smoothstep(W_PLATEAU_IN, W_PLATEAU_OUT, r); + float s = clamp((r - W_PLATEAU_IN) / (W_PLATEAU_OUT - W_PLATEAU_IN), 0.0, 1.0); + float dm = r > 0.0 ? 6.0 * s * (1.0 - s) / (W_PLATEAU_OUT - W_PLATEAU_IN) : 0.0; + vec2 dir = r > 0.0 ? xz / r : vec2(0.0); + vec4 fg = cultmath_fbm_grad(vec3(xz * W_FREQ, 0.0), W_OCTAVES, W_LACUNARITY, W_GAIN); + float f = fg.w; + vec2 g = m * W_AMP * W_FREQ * fg.xz + dm * W_AMP * f * dir; +#if W_ERODE_ENABLED + CultPhasor ph = terrainPhacelle(xz); + g += m * W_ERODE_AMP * W_ERODE_FREQ * ph.cos.xz + dm * W_ERODE_AMP * ph.cos.w * dir; +#endif + return normalize(vec3(-g.x, 1.0, -g.y)); +} + +// ---- world tree ---------------------------------------------------------------------------- + +float smin(float a, float b, float k) +{ + float h = max(k - abs(a - b), 0.0) / k; + return min(a, b) - h * h * k * 0.25; +} + +// Not an exact SDF: the flare, ellipsoid and displacement are all scaled to stay under 1-Lipschitz. +float treeDistance(vec3 p, out float leafness) +{ + vec3 c = vec3(0.0, 0.5 * (W_CANOPY_Y + W_CANOPY_R.y), 0.0); + float bound = length(p - c) - (W_CANOPY_Y + W_CANOPY_R.y); + leafness = 0.0; + if (bound > 8.0) + return bound; + + float ang = atan(p.z, p.x); + float y01 = clamp(p.y / W_TRUNK_H, 0.0, 1.0); + float radius = mix(W_TRUNK_R0, W_TRUNK_R1, y01) + 6.0 * exp(-p.y * 0.2) * (1.0 + 0.35 * sin(6.0 * ang + p.y * 0.15)); + float trunk = max(length(p.xz) - radius, p.y - W_TRUNK_H * 1.2) * 0.7; + + vec3 q = (p - vec3(0.0, W_CANOPY_Y, 0.0)) / W_CANOPY_R; + float disp = cultmath_snoise(p * 0.05 + vec3(0.0, u_time * 0.015, 0.0)); + float canopy = (length(q) - 1.0) * min(W_CANOPY_R.x, W_CANOPY_R.y) - 3.0 * disp; + canopy *= 0.45; // 1 / (1 + 3 * 0.05 * L) + + float d = smin(trunk, canopy, 6.0); + leafness = clamp(0.5 + (trunk - canopy) * 0.15, 0.0, 1.0); + return d; +} + +// ---- trace --------------------------------------------------------------------------------- + +float sceneDistance(vec3 p, out float leafness, out bool onTree) +{ + float dt = terrainDistance(p); + float dr = treeDistance(p, leafness); + onTree = dr < dt; + return min(dt, dr); +} diff --git a/src/app/features/scene/shaders/terrain.frag.glsl b/src/app/features/scene/shaders/terrain.frag.glsl new file mode 100644 index 00000000..5946cc4a --- /dev/null +++ b/src/app/features/scene/shaders/terrain.frag.glsl @@ -0,0 +1,108 @@ +// Pass 1: solid surfaces (terrain + world tree), sphere-traced. Writes lit colour with coverage +// and, separately, the hit distance that bounds pass 2. +uniform vec3 u_grass; +uniform vec3 u_rock; +uniform vec3 u_bark; +uniform vec3 u_leaf; +uniform int u_maxSteps; +uniform sampler2D u_start; // prepass: r = distance no ray of this pixel's tile hits before; W_FAR = tile misses + +in vec2 v_uv; +layout(location = 0) out vec4 o_color; // rgb lit, a coverage +layout(location = 1) out float o_depth; // hit distance along the ray, W_FAR on a miss +#ifdef TRACE_STATS +layout(location = 2) out float o_steps; // distance evaluations this ray spent, over 255 (measurement only) +#endif + +// ---- trace --------------------------------------------------------------------------------- + +void main() +{ + vec3 ro = u_camPos; + vec3 rd = camRay(v_uv); + + float t = texture(u_start, v_uv).r; + // Nothing is above W_MAX_HEIGHT: skip the empty air above it, or miss outright. A tile the + // prepass proved empty arrives as W_FAR. + bool missAtOnce = t >= W_FAR; + if (ro.y > W_MAX_HEIGHT) + { + missAtOnce = missAtOnce || rd.y >= 0.0; + t = max(t, (W_MAX_HEIGHT - ro.y) / rd.y); + } + if (missAtOnce) + { + o_color = vec4(0.0); + o_depth = W_FAR; +#ifdef TRACE_STATS + o_steps = 0.0; +#endif + return; + } + + bool hit = false; + bool onTree = false; + float leafness = 0.0; + int i = 0; + int evals = 0; + for (; i < u_maxSteps; i++) + { + evals++; + vec3 p = ro + rd * t; + float d = sceneDistance(p, leafness, onTree); + if (d < 0.0006 * t + 0.01) + { + hit = true; + break; + } + t += d; + if (t > W_FAR || (p.y > W_MAX_HEIGHT && rd.y > 0.0)) + break; + } + // The conservative bound makes grazing rays crawl: a ray that ran out of steps while still + // below the highest terrain is a distant surface under thick fog, so it ends there. + if (!hit && i == u_maxSteps && t < W_FAR) + { + hit = true; + sceneDistance(ro + rd * t, leafness, onTree); + } +#ifdef TRACE_STATS + o_steps = float(evals) / 255.0; +#endif + + if (!hit) + { + o_color = vec4(0.0); + o_depth = W_FAR; + return; + } + + vec3 p = ro + rd * t; + vec3 n; + vec3 albedo; + if (onTree) + { + float e = 0.35; + float l; + n = normalize(vec3( + treeDistance(p + vec3(e, 0.0, 0.0), l) - treeDistance(p - vec3(e, 0.0, 0.0), l), + treeDistance(p + vec3(0.0, e, 0.0), l) - treeDistance(p - vec3(0.0, e, 0.0), l), + treeDistance(p + vec3(0.0, 0.0, e), l) - treeDistance(p - vec3(0.0, 0.0, e), l))); + float speckle = 0.75 + 0.25 * cultmath_snoise(p * 0.8); + albedo = mix(u_bark, u_leaf * speckle, leafness); + } + else + { + n = terrainNormal(p.xz); + float rockness = smoothstep(0.78, 0.55, n.y); + albedo = mix(u_grass, u_rock, rockness); + } + + float ndl = dot(n, u_sunDir); + float wrap = clamp(ndl * 0.6 + 0.4, 0.0, 1.0); // half-lambert: no shadow ray in the cheap pass + vec3 ambient = mix(u_skyHorizon, u_skyTop, 0.5 + 0.5 * n.y) * 0.45; + vec3 col = albedo * (u_sunColor * wrap * wrap + ambient); + + o_color = vec4(col, 1.0); + o_depth = t; +} diff --git a/src/app/features/scene/tier.test.ts b/src/app/features/scene/tier.test.ts new file mode 100644 index 00000000..d35b6bb6 --- /dev/null +++ b/src/app/features/scene/tier.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { resolveTier } from './tier'; + +describe('scene tier', () => { + it('is off unless the config turns it on, which is the feature flag', () => { + expect(resolveTier(undefined)).toBe('off'); + expect(resolveTier({})).toBe('off'); + expect(resolveTier({ tier: 'off' }, { deviceMemory: 16 })).toBe('off'); + expect(resolveTier({ tier: 'full' })).toBe('full'); + }); + + it('lets the device lower the configured tier and never raise it', () => { + expect(resolveTier({ tier: 'full' }, { saveData: true })).toBe('still'); + expect(resolveTier({ tier: 'full' }, { deviceMemory: 2 })).toBe('low'); + expect(resolveTier({ tier: 'full' }, { hardwareConcurrency: 4 })).toBe('low'); + expect(resolveTier({ tier: 'low' }, { saveData: true })).toBe('still'); + expect(resolveTier({ tier: 'still' }, { deviceMemory: 16, hardwareConcurrency: 16 })).toBe( + 'still' + ); + expect(resolveTier({ tier: 'low' }, { deviceMemory: 16, hardwareConcurrency: 16 })).toBe('low'); + }); + + it('treats unknown capabilities as capable', () => { + expect(resolveTier({ tier: 'full' }, {})).toBe('full'); + }); +}); diff --git a/src/app/features/scene/tier.ts b/src/app/features/scene/tier.ts new file mode 100644 index 00000000..1c1668c7 --- /dev/null +++ b/src/app/features/scene/tier.ts @@ -0,0 +1,49 @@ +// Which quality the background scene runs at. The client config's `backgroundScene.tier` is both +// the feature flag (absent or 'off' means the scene is not even imported) and the ceiling; the +// device can only lower it. + +export type SceneTier = 'off' | 'still' | 'low' | 'full'; + +/** Cheapest first. */ +export const TIERS: readonly SceneTier[] = ['off', 'still', 'low', 'full']; + +export interface Capabilities { + /** `navigator.connection.saveData`. */ + saveData?: boolean; + /** `navigator.deviceMemory` in GiB (Chromium only). */ + deviceMemory?: number; + hardwareConcurrency?: number; +} + +export interface BackgroundSceneConfig { + tier?: SceneTier; +} + +export function readCapabilities(nav: Navigator): Capabilities { + const n = nav as Navigator & { + deviceMemory?: number; + connection?: { saveData?: boolean }; + }; + return { + saveData: n.connection?.saveData, + deviceMemory: n.deviceMemory, + hardwareConcurrency: n.hardwareConcurrency, + }; +} + +/** The most the device is allowed to run, before the configured ceiling is applied. */ +function deviceCeiling(c: Capabilities): SceneTier { + if (c.saveData) return 'still'; + if ((c.deviceMemory ?? Infinity) <= 2 || (c.hardwareConcurrency ?? Infinity) <= 4) return 'low'; + return 'full'; +} + +/** The tier to run: the configured one (default 'off'), lowered by what the device can carry. */ +export function resolveTier( + config: BackgroundSceneConfig | undefined, + capabilities: Capabilities = {} +): SceneTier { + const configured = config?.tier ?? 'off'; + const ceiling = deviceCeiling(capabilities); + return TIERS.indexOf(configured) <= TIERS.indexOf(ceiling) ? configured : ceiling; +} diff --git a/src/app/features/scene/world.test.ts b/src/app/features/scene/world.test.ts new file mode 100644 index 00000000..caa04fad --- /dev/null +++ b/src/app/features/scene/world.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import common from './shaders/common.glsl?raw'; +import terrain from './shaders/terrain.frag.glsl?raw'; +import atmosphere from './shaders/atmosphere.frag.glsl?raw'; +import resolve from './shaders/resolve.frag.glsl?raw'; +import composite from './shaders/composite.frag.glsl?raw'; +import { terrainBounds, WORLD, worldDefines } from './world'; + +describe('world defines', () => { + it('define every W_ name any scene shader uses', () => { + const defined = new Set([...worldDefines().matchAll(/#define (W_\w+)/g)].map((m) => m[1])); + [common, terrain, atmosphere, resolve, composite] + .flatMap((src) => [...src.matchAll(/\bW_[A-Z0-9_]+\b/g)]) + .forEach((m) => expect(defined, m[0]).toContain(m[0])); + }); + + it('emit only valid GLSL numbers', () => { + expect(worldDefines()).not.toMatch(/NaN|Infinity|undefined/); + }); +}); + +describe('terrain step bound', () => { + it('is more conservative with erosion than without, and without erosion carries no phacelle term', () => { + const b = terrainBounds(); + expect(b.slopeFull).toBeGreaterThan(b.slopeEnvelope); + const off = terrainBounds({ ...WORLD, phacelleGradBound: 0 }); + expect(off.slopeFull).toBe(off.slopeEnvelope); + }); + + it('puts every surface under the height the tracer skips to', () => { + const maxHeight = Number(/#define W_MAX_HEIGHT (\S+)/.exec(worldDefines())![1]); + expect(maxHeight).toBeGreaterThanOrEqual(terrainBounds().maxHeight); + expect(maxHeight).toBeGreaterThanOrEqual(WORLD.tree.canopyY + WORLD.tree.canopyRadius[1]); + }); + + it('leaves the erosion bound turned on only when a bound exists', () => { + expect(worldDefines()).toContain('#define W_ERODE_ENABLED 1'); + expect(worldDefines({ ...WORLD, phacelleGradBound: 0 })).toContain('#define W_ERODE_ENABLED 0'); + }); +}); diff --git a/src/app/features/scene/world.ts b/src/app/features/scene/world.ts new file mode 100644 index 00000000..e9594a68 --- /dev/null +++ b/src/app/features/scene/world.ts @@ -0,0 +1,117 @@ +// The scene's world constants. One owner: the shader prelude below is generated from this +// object, so a number lives here and nowhere else, and the Lipschitz bounds that make the +// sphere-trace step safe are computed beside the numbers they bound. + +// Lipschitz constant of cultmath_snoise(vec3), CULTMATH_SNOISE_LIPSCHITZ in the vendored +// CultMath.glsl (provenance in CultLib math.Interval.cs). +export const SNOISE_LIPSCHITZ = 7.9640074; + +export const WORLD = { + far: 1400, + // Terrain: height = mask(r) * (amp * fbm(xz * freq) + erodeAmp * phacelle(xz * erodeFreq)). + amp: 70, + freq: 1 / 520, + octaves: 4, + lacunarity: 2.0, + gain: 0.45, + // Flat ground (height 0) out to plateauInner, full terrain from plateauOuter. The login + // camera stays inside plateauInner, so it can never end up inside the terrain. + plateauInner: 135, + plateauOuter: 380, + erodeAmp: 2.5, + erodeFreq: 1 / 38, + // The stripe wave vector and normalization the terrain feeds cultmath_phacelle. + erodeSide: [1.7, 0, 1] as const, + erodeNorm: 0.5, + // Bound on |grad| of the phasor's cos component for the side and norm above. Libby measured a + // sup of 6.63 at |side| = 1.5, n = 0 (CultLib PR #1 head 990521b3); 7.0 adds margin. + // The sup was measured, not proved. The shader passes roughness n = 0 as a literal, so the doc + // envelope max(10, 1.25|side|)/(1-n) needs no 1/(1-n) factor here; if n ever becomes nonzero, + // divide this bound by (1 - n). + // |value| <= 1 holds by construction (the output is a normalized phasor). + // Typed number, not the literal 7, so callers can build variants such as { ...WORLD, phacelleGradBound: 0 }. + phacelleGradBound: 7.0 as number, + // World tree at the origin. + tree: { + trunkHeight: 38, + baseRadius: 3.4, + topRadius: 1.9, + canopyY: 52, + canopyRadius: [30, 17, 30] as const, + }, + // Atmosphere. + atmosphereFar: 1400, + cloudBase: 130, + cloudTop: 230, + fogDensity: 0.0028, + fogFalloff: 0.011, +} as const; + +const f = (n: number) => (Number.isInteger(n) ? `${n}.0` : `${n}`); + +/** Sum over octaves of (lacunarity * gain)^i: the slope bound's octave factor. */ +function slopeSum(octaves: number, lacunarity: number, gain: number): number { + let s = 0; + for (let i = 0; i < octaves; i += 1) s += (lacunarity * gain) ** i; + return s; +} + +/** Sum over octaves of gain^i: the fbm value bound. */ +function valueSum(octaves: number, gain: number): number { + let s = 0; + for (let i = 0; i < octaves; i += 1) s += gain ** i; + return s; +} + +export interface TerrainBounds { + /** Lipschitz bound of the envelope surface (no erosion). */ + slopeEnvelope: number; + /** Lipschitz bound of the eroded surface. */ + slopeFull: number; + /** Highest point the terrain can reach. */ + maxHeight: number; +} + +export function terrainBounds(w: typeof WORLD = WORLD): TerrainBounds { + const fbmMax = valueSum(w.octaves, w.gain); + const maskSlope = 1.5 / (w.plateauOuter - w.plateauInner); // smoothstep's max slope + const fbmSlope = w.amp * w.freq * SNOISE_LIPSCHITZ * slopeSum(w.octaves, w.lacunarity, w.gain); + const slopeEnvelope = fbmSlope + (w.amp * fbmMax + w.erodeAmp) * maskSlope; + const slopeFull = slopeEnvelope + w.erodeAmp * w.erodeFreq * w.phacelleGradBound; + return { slopeEnvelope, slopeFull, maxHeight: w.amp * fbmMax + w.erodeAmp }; +} + +/** The #defines every stage sees. */ +export function worldDefines(w: typeof WORLD = WORLD): string { + const b = terrainBounds(w); + const t = w.tree; + const lines: Array<[string, string]> = [ + ['W_FAR', f(w.far)], + ['W_AMP', f(w.amp)], + ['W_FREQ', f(w.freq)], + ['W_OCTAVES', String(w.octaves)], + ['W_LACUNARITY', f(w.lacunarity)], + ['W_GAIN', f(w.gain)], + ['W_PLATEAU_IN', f(w.plateauInner)], + ['W_PLATEAU_OUT', f(w.plateauOuter)], + ['W_ERODE_AMP', f(w.erodeAmp)], + ['W_ERODE_FREQ', f(w.erodeFreq)], + ['W_ERODE_SIDE', `vec3(${w.erodeSide.map(f).join(', ')})`], + ['W_ERODE_NORM', f(w.erodeNorm)], + ['W_ERODE_ENABLED', w.phacelleGradBound > 0 ? '1' : '0'], + ['W_SLOPE_ENV_INV', f(1 / Math.sqrt(1 + b.slopeEnvelope ** 2))], + ['W_SLOPE_FULL_INV', f(1 / Math.sqrt(1 + b.slopeFull ** 2))], + ['W_MAX_HEIGHT', f(Math.max(b.maxHeight, t.canopyY + t.canopyRadius[1] + 6))], + ['W_TRUNK_H', f(t.trunkHeight)], + ['W_TRUNK_R0', f(t.baseRadius)], + ['W_TRUNK_R1', f(t.topRadius)], + ['W_CANOPY_Y', f(t.canopyY)], + ['W_CANOPY_R', `vec3(${t.canopyRadius.map(f).join(', ')})`], + ['W_ATM_FAR', f(w.atmosphereFar)], + ['W_CLOUD_Y0', f(w.cloudBase)], + ['W_CLOUD_Y1', f(w.cloudTop)], + ['W_FOG_DENSITY', f(w.fogDensity)], + ['W_FOG_FALLOFF', f(w.fogFalloff)], + ]; + return lines.map(([k, v]) => `#define ${k} ${v}`).join('\n'); +} diff --git a/src/app/features/settings/about/About.tsx b/src/app/features/settings/about/About.tsx index d3dcde96..6b304965 100644 --- a/src/app/features/settings/about/About.tsx +++ b/src/app/features/settings/about/About.tsx @@ -4,7 +4,7 @@ import { Page, PageContent, PageHeader } from '../../../components/page'; import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCardStyle } from '../styles.css'; import { SettingTile } from '../../../components/setting-tile'; -import CinnySVG from '../../../../../public/res/svg/cinny.svg'; +import RatatoskrSVG from '../../../../../public/res/svg/ratatoskr.svg'; import { clearCacheAndReload } from '../../../../client/initMatrix'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; @@ -38,23 +38,23 @@ export function About({ requestClose }: AboutProps) { Cinny logo - Cinny + Ratatoskr v4.12.8 - Yet another matrix client. + GameCult's Matrix client. - + + Based on{' '} + + Cinny + {' '} + 4.12.8 by Ajay Bura and contributors. Ratatoskr is free software under the GNU + Affero General Public License v3; its source is at the Source Code link above. + diff --git a/src/app/features/settings/devices/LocalBackup.tsx b/src/app/features/settings/devices/LocalBackup.tsx index 00128c8f..4fcdacdb 100644 --- a/src/app/features/settings/devices/LocalBackup.tsx +++ b/src/app/features/settings/devices/LocalBackup.tsx @@ -28,7 +28,7 @@ function ExportKeys() { const blob = new Blob([encKeys], { type: 'text/plain;charset=us-ascii', }); - FileSaver.saveAs(blob, 'cinny-keys.txt'); + FileSaver.saveAs(blob, 'ratatoskr-keys.txt'); }, [mx] ) diff --git a/src/app/features/settings/notifications/SystemNotification.tsx b/src/app/features/settings/notifications/SystemNotification.tsx index e0df06df..38115980 100644 --- a/src/app/features/settings/notifications/SystemNotification.tsx +++ b/src/app/features/settings/notifications/SystemNotification.tsx @@ -27,7 +27,7 @@ function EmailNotification() { device_display_name: email, lang: 'en', data: { - brand: 'Cinny', + brand: 'Ratatoskr', }, append: true, }); diff --git a/src/app/features/soundscape/device.ts b/src/app/features/soundscape/device.ts new file mode 100644 index 00000000..ecdf1593 --- /dev/null +++ b/src/app/features/soundscape/device.ts @@ -0,0 +1,12 @@ +export interface DeviceHints { + deviceMemory?: number; + hardwareConcurrency?: number; +} + +/** + * Off on weak phones. The thresholds are placeholders: no phone has been measured (grammar + * section 7, item 4). Unknown hints (Safari reports neither) do not count as weak. + */ +export const isWeakDevice = ({ deviceMemory, hardwareConcurrency }: DeviceHints): boolean => + (deviceMemory !== undefined && deviceMemory < 4) || + (hardwareConcurrency !== undefined && hardwareConcurrency < 4); diff --git a/src/app/features/soundscape/frame.ts b/src/app/features/soundscape/frame.ts new file mode 100644 index 00000000..947d2bb5 --- /dev/null +++ b/src/app/features/soundscape/frame.ts @@ -0,0 +1,108 @@ +// The activity frame of Aqua's soundscape grammar (AquaSynth docs/soundscape-grammar, grammar head +// 0091d01, section 2). The Heimdall session produces it; this file only reads it. Shape follows +// heimdall-body:follow_up:ratatoskr-creature-projection-v2 as the grammar relays it. The human +// kind subset (Idle/Working/Speaking) below is PROVISIONAL: still proposed, not ruled, until +// Bifrost's steward cuts the list. + +export type Estate = 'human' | 'mind' | 'persona'; +export type Kind = 'Idle' | 'Reading' | 'Thinking' | 'Working' | 'Speaking' | 'Waiting' | 'Alert'; +export type Energy = 'Low' | 'Mid' | 'High' | 'Peak'; +export type Size = 'S' | 'M' | 'L'; +export type Override = 'None' | 'Muted'; + +interface FrameBase { + mind: string; + estate: Estate; + override: Override; + seq: number; + /** Producer observation time, whole seconds (grammar: 1 s resolution). */ + at: number; +} +/** O1: a neutral frame carries no size, kind or energy; it is not an Idle and nothing reads one. */ +export interface NeutralFrame extends FrameBase { + neutral: true; + size?: undefined; + kind?: undefined; + energy?: undefined; +} +export interface LiveFrame extends FrameBase { + neutral: false; + size: Size; + kind: Kind; + energy: Energy; +} +export type ActivityFrame = NeutralFrame | LiveFrame; + +const ESTATES: readonly Estate[] = ['human', 'mind', 'persona']; +const KINDS: readonly Kind[] = [ + 'Idle', + 'Reading', + 'Thinking', + 'Working', + 'Speaking', + 'Waiting', + 'Alert', +]; +const ENERGIES: readonly Energy[] = ['Low', 'Mid', 'High', 'Peak']; +const SIZES: readonly Size[] = ['S', 'M', 'L']; + +/** Grammar S2 (proposed, unmeasured): a frame older than this is absent. */ +export const STALE_AFTER_S = 10; + +const oneOf = (set: readonly T[], v: unknown): T | undefined => + set.find((s) => s === v); + +/** PROVISIONAL (grammar section 2, awaiting the projection-v2 field list): a human's kinds. */ +export const HUMAN_KINDS: readonly Kind[] = ['Idle', 'Working', 'Speaking']; + +/** Estate clamp, only ever revealing less: a human's out-of-subset or omitted kind is Idle. */ +const clampKind = (estate: Estate, kind: unknown): Kind | undefined => + estate === 'human' ? oneOf(HUMAN_KINDS, kind) ?? 'Idle' : oneOf(KINDS, kind); + +/** A human's energy is three buckets: Peak reads as High. */ +const clampEnergy = (estate: Estate, energy: Energy): Energy => + estate === 'human' && energy === 'Peak' ? 'High' : energy; + +/** + * Read an untrusted frame. Only the grammar's fields survive: any other key (a weight, a vote, a + * room id) is dropped, so a consumer cannot be steered by data the grammar does not define (A3). + * `neutral` is read first: a neutral frame yields no size, kind or energy even if the producer sent + * them (O1). Returns undefined for anything malformed; the caller treats that as no frame (A4). + */ +export const readFrame = (raw: unknown): ActivityFrame | undefined => { + if (typeof raw !== 'object' || raw === null) return undefined; + const r = raw as Record; + if (typeof r.neutral !== 'boolean') return undefined; + const estate = oneOf(ESTATES, r.estate); + const override = + r.override === undefined ? 'None' : oneOf(['None', 'Muted'] as const, r.override); + if (!estate || !override) return undefined; + if (typeof r.mind !== 'string' || r.mind === '') return undefined; + if (!Number.isInteger(r.seq) || !Number.isFinite(r.at)) return undefined; + const base = { mind: r.mind, estate, override, seq: r.seq as number, at: r.at as number }; + if (r.neutral) return { ...base, neutral: true }; + const kind = clampKind(estate, r.kind); + const energy = oneOf(ENERGIES, r.energy); + const size = oneOf(SIZES, r.size); + if (!kind || !energy || !size) return undefined; + return { ...base, neutral: false, size, kind, energy: clampEnergy(estate, energy) }; +}; + +/** Latest frame per mind (S1: consumers drop seq <= last). */ +export class FrameTable { + private frames = new Map(); + + offer(raw: unknown): boolean { + const f = readFrame(raw); + if (!f) return false; + const last = this.frames.get(f.mind); + if (last && f.seq <= last.seq) return false; + this.frames.set(f.mind, f); + return true; + } + + /** Frames not stale at `nowS`; a missing or stale frame is simply absent (A4), not neutral. */ + fresh(nowS: number): ActivityFrame[] { + return [...this.frames.values()].filter((f) => nowS - f.at <= STALE_AFTER_S); + } +} diff --git a/src/app/features/soundscape/scene.ts b/src/app/features/soundscape/scene.ts new file mode 100644 index 00000000..027cc27a --- /dev/null +++ b/src/app/features/soundscape/scene.ts @@ -0,0 +1,8 @@ +import type { ActivityFrame, Size } from './frame'; + +/** Placeholders owned by the orbit renderer, not the frame (grammar, Estate section). Unmeasured. */ +export const PLACEHOLDER_SIZE_SCALE: Record = { S: 0.7, M: 1, L: 1.4 }; + +/** O1: a neutral participant gets no orbit. There is no neutral pose; it is not in the list. */ +export const drawnCreatures = (frames: readonly ActivityFrame[]): ActivityFrame[] => + frames.filter((f) => !f.neutral); diff --git a/src/app/features/soundscape/socket.ts b/src/app/features/soundscape/socket.ts new file mode 100644 index 00000000..e1d23010 --- /dev/null +++ b/src/app/features/soundscape/socket.ts @@ -0,0 +1,47 @@ +import { FrameTable } from './frame'; +import { drawnCreatures } from './scene'; +import { planVoices, type SoundscapePrefs, type VoiceCommand } from './voices'; +import { isWeakDevice, type DeviceHints } from './device'; + +/** + * Where frames come from is `ratatoskr:question:activity-feed-source`, still open. Until the + * Heimdall session feed exists the only source is `silentSource`. A source never reads a room's + * member list (A1): being in a room says nothing about being active. + */ +export interface ActivitySource { + subscribe(onFrame: (raw: unknown) => void): () => void; +} +const noop = () => undefined; +export const silentSource: ActivitySource = { subscribe: () => noop }; + +/** Receives the plan. No audio assets exist yet, so nothing in this repo sounds. */ +export interface VoiceSink { + play(commands: readonly VoiceCommand[]): void; +} + +export interface AudioSocketDeps { + source: ActivitySource; + sink: VoiceSink; + prefs: () => SoundscapePrefs; + device: () => DeviceHints; + /** Whole seconds. */ + nowS: () => number; +} + +export const createAudioSocket = (deps: AudioSocketDeps) => { + const table = new FrameTable(); + const unsubscribe = deps.source.subscribe((raw) => table.offer(raw)); + return { + /** Called by the scheduler tick; a hidden page simply stops calling it (S5). */ + tick(): VoiceCommand[] { + const prefs = deps.prefs(); + const effective = { ...prefs, enabled: prefs.enabled && !isWeakDevice(deps.device()) }; + const commands = planVoices(table.fresh(deps.nowS()), effective); + deps.sink.play(commands); + return commands; + }, + /** What the orbit renderer draws: the same table, neutral removed. */ + drawn: () => drawnCreatures(table.fresh(deps.nowS())), + close: unsubscribe, + }; +}; diff --git a/src/app/features/soundscape/soundscape.test.ts b/src/app/features/soundscape/soundscape.test.ts new file mode 100644 index 00000000..294456a1 --- /dev/null +++ b/src/app/features/soundscape/soundscape.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, it, vi } from 'vitest'; +import { FrameTable, readFrame, STALE_AFTER_S, type ActivityFrame, type LiveFrame } from './frame'; +import { drawnCreatures, PLACEHOLDER_SIZE_SCALE } from './scene'; +import { DEFAULT_VOICE_CAP, planVoices, type SoundscapePrefs } from './voices'; +import { isWeakDevice } from './device'; +import { createAudioSocket, silentSource, type ActivitySource } from './socket'; + +const frame = (o: Partial = {}): LiveFrame => ({ + mind: 'void', + estate: 'mind', + kind: 'Working', + energy: 'Mid', + size: 'M', + neutral: false, + override: 'None', + seq: 1, + at: 100, + ...o, +}); +const neutralFrame = (o: Partial = {}): ActivityFrame => + ({ ...frame(), ...o, neutral: true } as unknown as ActivityFrame); +/** A raw frame as a producer sends it, with the named keys omitted. */ +const without = (f: object, ...keys: string[]) => + Object.fromEntries(Object.entries(f).filter(([k]) => !keys.includes(k))); +const noop = () => undefined; +const on: SoundscapePrefs = { enabled: true, muted: false, volume: 0.5, voiceCap: 4 }; + +describe('neutral overrides everything', () => { + it('draws nothing and sounds nothing for any kind, energy, size or estate', () => { + const cases = (['human', 'mind', 'persona'] as const).flatMap((estate) => + (['Speaking', 'Alert', 'Working'] as const).flatMap((kind) => + (['High', 'Peak'] as const).flatMap((energy) => + (['S', 'M', 'L'] as const).map((size) => neutralFrame({ estate, kind, energy, size })) + ) + ) + ); + expect(cases).toHaveLength(54); + cases.forEach((f) => { + expect(drawnCreatures([f])).toEqual([]); + expect(planVoices([f], on)).toEqual([]); + }); + }); + it('is not the same as an absent frame: absent reads as idle, neutral as hidden', () => { + const t = new FrameTable(); + t.offer(frame({ at: 100 })); + expect(drawnCreatures(t.fresh(100 + STALE_AFTER_S + 1))).toEqual([]); + expect(drawnCreatures(t.fresh(100)).length).toBe(1); + }); +}); + +describe('neutral is read first', () => { + it('yields no size, kind or energy, even when the producer sent them', () => { + (['human', 'mind', 'persona'] as const).forEach((estate) => { + const f = readFrame({ + ...frame({ estate, kind: 'Speaking', energy: 'High' }), + neutral: true, + }); + expect(f).toBeDefined(); + expect(f?.neutral).toBe(true); + expect(f?.size).toBeUndefined(); + expect(Object.keys(f ?? {})).not.toContain('size'); + expect(f?.kind).toBeUndefined(); + expect(f?.energy).toBeUndefined(); + expect(Object.keys(f ?? {})).not.toContain('kind'); + expect(Object.keys(f ?? {})).not.toContain('energy'); + }); + }); + it('a neutral human with kind omitted is not an Idle', () => { + const bare = without(frame({ estate: 'human' }), 'kind', 'energy'); + const f = readFrame({ ...bare, neutral: true }); + expect(f?.kind).toBeUndefined(); + expect(drawnCreatures([f as ActivityFrame])).toEqual([]); + expect(planVoices([f as ActivityFrame], on)).toEqual([]); + }); +}); + +describe('human kinds are the provisional subset', () => { + it.each(['Reading', 'Thinking', 'Waiting', 'Alert'] as const)('clamps %s to Idle', (kind) => { + expect(readFrame(frame({ estate: 'human', kind }))?.kind).toBe('Idle'); + }); + it('keeps Idle, Working and Speaking', () => { + (['Idle', 'Working', 'Speaking'] as const).forEach((kind) => + expect(readFrame(frame({ estate: 'human', kind }))?.kind).toBe(kind) + ); + }); + it('an omitted kind is Idle for a non-neutral human only', () => { + const noKind = without(frame({ estate: 'human' }), 'kind'); + expect(readFrame(noKind)?.kind).toBe('Idle'); + expect(readFrame({ ...noKind, neutral: true })?.kind).toBeUndefined(); + }); + it('minds and personas keep all seven kinds, and omitted is malformed for them', () => { + (['mind', 'persona'] as const).forEach((estate) => { + (['Idle', 'Reading', 'Thinking', 'Working', 'Speaking', 'Waiting', 'Alert'] as const).forEach( + (kind) => expect(readFrame(frame({ estate, kind }))?.kind).toBe(kind) + ); + const noKind = without(frame({ estate }), 'kind'); + expect(readFrame(noKind)).toBeUndefined(); + }); + }); +}); + +describe('human energy is clamped to three buckets by the consumer', () => { + it('turns Peak into High for humans only', () => { + expect(readFrame(frame({ estate: 'human', energy: 'Peak' }))?.energy).toBe('High'); + expect(readFrame(frame({ estate: 'mind', energy: 'Peak' }))?.energy).toBe('Peak'); + expect(readFrame(frame({ estate: 'persona', energy: 'Peak' }))?.energy).toBe('Peak'); + }); + it('keeps the clamp through the voice plan', () => { + const t = new FrameTable(); + t.offer(frame({ estate: 'human', energy: 'Peak' })); + expect(planVoices(t.fresh(100), on)[0].energy).toBe('High'); + }); +}); + +describe('activity is a stub, never the room member list', () => { + it('the silent source and the socket yield nothing, however many members a room has', () => { + const members = ['@a:x', '@b:x', '@c:x']; + const sock = createAudioSocket({ + source: silentSource, + sink: { play: noop }, + prefs: () => on, + device: () => ({}), + nowS: () => 100, + }); + expect(members.length).toBeGreaterThan(0); + expect(sock.tick()).toEqual([]); + expect(sock.drawn()).toEqual([]); + }); + it('only frames the source pushes produce voices', () => { + const pushes: ((raw: unknown) => void)[] = []; + const source: ActivitySource = { + subscribe: (cb) => { + pushes.push(cb); + return noop; + }, + }; + const sock = createAudioSocket({ + source, + sink: { play: noop }, + prefs: () => on, + device: () => ({}), + nowS: () => 100, + }); + pushes[0](frame({ mind: 'aqua' })); + expect(sock.tick().map((v) => v.mind)).toEqual(['aqua']); + }); +}); + +describe('no vote weights', () => { + it('drops fields the grammar does not define, and the plan carries only user volume as gain', () => { + const f = readFrame({ ...frame(), weight: 9, votes: 40, roomId: '!r:x', brief: 'secret' }); + expect(Object.keys(f ?? {}).sort()).toEqual( + ['at', 'energy', 'estate', 'kind', 'mind', 'neutral', 'override', 'seq', 'size'].sort() + ); + const t = new FrameTable(); + t.offer({ ...frame(), weight: 9 }); + expect(planVoices(t.fresh(100), on)[0].gain).toBe(0.5); + }); +}); + +describe('size scales are placeholders', () => { + it('live in the renderer table, not in the frame, and change nothing about voices', () => { + expect(PLACEHOLDER_SIZE_SCALE).toEqual({ S: 0.7, M: 1, L: 1.4 }); + const s = planVoices([frame({ size: 'S' })], on); + const l = planVoices([frame({ size: 'L' })], on); + expect(s).toEqual(l); + }); +}); + +describe('socket defaults and limits', () => { + it('ships off, muted, with the proposed cap', async () => { + vi.stubGlobal('localStorage', { getItem: () => null }); + const { getSettings } = await import('../../state/settings'); + const s = getSettings(); + expect(s.soundscapeEnabled).toBe(false); + expect(s.soundscapeMuted).toBe(true); + expect(s.soundscapeVoiceCap).toBe(DEFAULT_VOICE_CAP); + }); + it('master mute, disabled flag and zero volume all silence', () => { + const f = [frame()]; + expect(planVoices(f, { ...on, muted: true })).toEqual([]); + expect(planVoices(f, { ...on, enabled: false })).toEqual([]); + expect(planVoices(f, { ...on, volume: 0 })).toEqual([]); + }); + it('caps voices, dropping the lowest energy first', () => { + const f = ['a', 'b', 'c', 'd', 'e'].map((mind, i) => + frame({ mind, energy: (['Low', 'Mid', 'High', 'Peak', 'Mid'] as const)[i] }) + ); + const voices = planVoices(f, { ...on, voiceCap: 4 }).map((v) => v.mind); + expect(voices).toHaveLength(4); + expect(voices).not.toContain('a'); + }); + it('Muted silences the voice but the creature is still drawn; Idle/Waiting are silent', () => { + const f = [frame({ override: 'Muted' }), frame({ mind: 'x', kind: 'Idle' })]; + expect(planVoices(f, on)).toEqual([]); + expect(drawnCreatures(f)).toHaveLength(2); + }); + it('is off on weak phones even when the flag is on, and unknown hints are not weak', () => { + expect(isWeakDevice({ deviceMemory: 2 })).toBe(true); + expect(isWeakDevice({ hardwareConcurrency: 2 })).toBe(true); + expect(isWeakDevice({})).toBe(false); + const played: number[] = []; + const sock = createAudioSocket({ + source: { + subscribe: (cb) => { + cb(frame()); + return noop; + }, + }, + sink: { play: (c) => played.push(c.length) }, + prefs: () => on, + device: () => ({ deviceMemory: 1 }), + nowS: () => 100, + }); + expect(sock.tick()).toEqual([]); + expect(played).toEqual([0]); + }); + it('drops frames with seq <= last, and malformed frames', () => { + const t = new FrameTable(); + expect(t.offer(frame({ seq: 5 }))).toBe(true); + expect(t.offer(frame({ seq: 5 }))).toBe(false); + expect(t.offer(frame({ seq: 4 }))).toBe(false); + expect(t.offer({ ...frame(), kind: 'Dancing' })).toBe(false); + }); +}); + +describe('neutral carries no size', () => { + it('reads without a size, and a live frame without one is malformed', () => { + const raw = frame(); + expect(readFrame({ ...without(raw, 'size', 'kind', 'energy'), neutral: true })).toBeDefined(); + expect(readFrame(without(raw, 'size'))).toBeUndefined(); + }); +}); diff --git a/src/app/features/soundscape/voices.ts b/src/app/features/soundscape/voices.ts new file mode 100644 index 00000000..676ae853 --- /dev/null +++ b/src/app/features/soundscape/voices.ts @@ -0,0 +1,51 @@ +import type { ActivityFrame, Energy, Kind, LiveFrame } from './frame'; + +/** Grammar V3 (proposed, unmeasured): voices audible at once. */ +export const DEFAULT_VOICE_CAP = 4; + +/** Local listener preferences. Never written back into a frame (O3). */ +export interface SoundscapePrefs { + /** The feature flag. Default off. */ + enabled: boolean; + /** Master mute. Default on. */ + muted: boolean; + /** 0..1 */ + volume: number; + voiceCap: number; +} + +export interface VoiceCommand { + mind: string; + kind: Kind; + energy: Energy; + gain: number; +} + +const ENERGY_RANK: Record = { Low: 0, Mid: 1, High: 2, Peak: 3 }; +/** Grammar section 3: Idle and Waiting default silent. */ +const SILENT_KINDS: ReadonlySet = new Set(['Idle', 'Waiting']); + +const clamp01 = (v: number) => (Number.isFinite(v) ? Math.min(1, Math.max(0, v)) : 0); + +/** + * Which voices speak now. Neutral overrides everything (O1); `Muted` silences one Mind (O2); + * over the cap the lowest energy drops first (V3, ties by mind id so the result is stable). + * Identity is the mind id only (V1); `gain` is the user's volume, not a vote weight. + * Lead exemption in V3 is not implemented: the grammar does not say how a lead is chosen. + */ +export const planVoices = ( + frames: readonly ActivityFrame[], + prefs: SoundscapePrefs +): VoiceCommand[] => { + if (!prefs.enabled || prefs.muted) return []; + const gain = clamp01(prefs.volume); + if (gain === 0) return []; + const cap = Math.max(0, Math.floor(prefs.voiceCap)); + return frames + .filter( + (f): f is LiveFrame => !f.neutral && f.override !== 'Muted' && !SILENT_KINDS.has(f.kind) + ) + .sort((a, b) => ENERGY_RANK[b.energy] - ENERGY_RANK[a.energy] || a.mind.localeCompare(b.mind)) + .slice(0, cap) + .map((f) => ({ mind: f.mind, kind: f.kind, energy: f.energy, gain })); +}; diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts index e5fc6cc6..7a0586eb 100644 --- a/src/app/hooks/useClientConfig.ts +++ b/src/app/hooks/useClientConfig.ts @@ -1,4 +1,5 @@ import { createContext, useContext } from 'react'; +import type { BackgroundSceneConfig } from '../features/scene/tier'; export type HashRouterConfig = { enabled?: boolean; @@ -18,6 +19,8 @@ export type ClientConfig = { }; hashRouter?: HashRouterConfig; + + backgroundScene?: BackgroundSceneConfig; }; const ClientConfigContext = createContext(null); diff --git a/src/app/hooks/useCommands.ts b/src/app/hooks/useCommands.ts index c02b0e94..11f379dc 100644 --- a/src/app/hooks/useCommands.ts +++ b/src/app/hooks/useCommands.ts @@ -15,7 +15,6 @@ import { addRoomIdToMDirect, getDMRoomFor, guessDmRoomUserId, - isRoomAlias, isRoomId, isServerName, isUserId, @@ -26,6 +25,7 @@ import { useRoomNavigate } from './useRoomNavigate'; import { Membership, StateEvent } from '../../types/matrix/room'; import { getStateEvent } from '../utils/room'; import { splitWithSpace } from '../utils/common'; +import { runJoinCommand } from '../features/estate/joinEstateRoom'; import { createRoomEncryptionState } from '../components/create-room'; export const SHRUG = '¯\\_(ツ)_/¯'; @@ -227,15 +227,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { [Command.Join]: { name: Command.Join, description: 'Join room with address. Example: /join address1 address2', - exe: async (payload) => { - const rawIds = splitWithSpace(payload); - const roomIdOrAliases = rawIds.filter( - (idOrAlias) => isRoomId(idOrAlias) || isRoomAlias(idOrAlias) - ); - roomIdOrAliases.forEach(async (idOrAlias) => { - await mx.joinRoom(idOrAlias); - }); - }, + exe: (payload) => runJoinCommand(mx, payload, (message) => window.alert(message)), }, [Command.Leave]: { name: Command.Leave, diff --git a/src/app/hooks/useSidebarItems.ts b/src/app/hooks/useSidebarItems.ts deleted file mode 100644 index 16aba225..00000000 --- a/src/app/hooks/useSidebarItems.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { Dispatch, SetStateAction, useCallback, useEffect, useState } from 'react'; -import { MatrixClient } from 'matrix-js-sdk'; -import { AccountDataEvent } from '../../types/matrix/accountData'; -import { useMatrixClient } from './useMatrixClient'; -import { getAccountData, isSpace } from '../utils/room'; -import { Membership } from '../../types/matrix/room'; -import { useAccountDataCallback } from './useAccountDataCallback'; - -export type ISidebarFolder = { - name?: string; - id: string; - content: string[]; -}; -export type TSidebarItem = string | ISidebarFolder; -export type SidebarItems = Array; - -export type InCinnySpacesContent = { - shortcut?: string[]; - sidebar?: SidebarItems; -}; - -export const parseSidebar = ( - mx: MatrixClient, - orphanSpaces: string[], - content?: InCinnySpacesContent -) => { - const sidebar = content?.sidebar ?? content?.shortcut ?? []; - const orphans = new Set(orphanSpaces); - - const items: SidebarItems = []; - - const safeToAdd = (spaceId: string): boolean => { - if (typeof spaceId !== 'string') return false; - const space = mx.getRoom(spaceId); - if (space?.getMyMembership() !== Membership.Join) return false; - return isSpace(space); - }; - - sidebar.forEach((item) => { - if (typeof item === 'string') { - if (safeToAdd(item) && !items.includes(item)) { - orphans.delete(item); - items.push(item); - } - return; - } - if ( - typeof item === 'object' && - typeof item.id === 'string' && - Array.isArray(item.content) && - !items.find((i) => (typeof i === 'string' ? false : i.id === item.id)) - ) { - const safeContent = item.content.filter(safeToAdd); - safeContent.forEach((i) => orphans.delete(i)); - items.push({ - ...item, - content: Array.from(new Set(safeContent)), - }); - } - }); - - orphans.forEach((spaceId) => items.push(spaceId)); - return items; -}; - -export const useSidebarItems = ( - orphanSpaces: string[] -): [SidebarItems, Dispatch>] => { - const mx = useMatrixClient(); - - const [sidebarItems, setSidebarItems] = useState(() => { - const inCinnySpacesContent = getAccountData( - mx, - AccountDataEvent.CinnySpaces - )?.getContent(); - return parseSidebar(mx, orphanSpaces, inCinnySpacesContent); - }); - - useEffect(() => { - const inCinnySpacesContent = getAccountData( - mx, - AccountDataEvent.CinnySpaces - )?.getContent(); - setSidebarItems(parseSidebar(mx, orphanSpaces, inCinnySpacesContent)); - }, [mx, orphanSpaces]); - - useAccountDataCallback( - mx, - useCallback( - (mEvent) => { - if (mEvent.getType() === AccountDataEvent.CinnySpaces) { - const newContent = mEvent.getContent(); - setSidebarItems(parseSidebar(mx, orphanSpaces, newContent)); - } - }, - [mx, orphanSpaces] - ) - ); - - return [sidebarItems, setSidebarItems]; -}; - -export const sidebarItemWithout = (items: SidebarItems, roomId: string) => { - const newItems: SidebarItems = items - .map((item) => { - if (typeof item === 'string') { - if (item === roomId) return null; - return item; - } - if (item.content.includes(roomId)) { - const newContent = item.content.filter((id) => id !== roomId); - if (newContent.length === 0) return null; - return { - ...item, - content: newContent, - }; - } - return item; - }) - .filter((item) => item !== null) as SidebarItems; - - return newItems; -}; - -export const makeCinnySpacesContent = ( - mx: MatrixClient, - items: SidebarItems -): InCinnySpacesContent => { - const currentInSpaces = - getAccountData(mx, AccountDataEvent.CinnySpaces)?.getContent() ?? {}; - - const newSpacesContent: InCinnySpacesContent = { - ...currentInSpaces, - sidebar: items, - }; - - return newSpacesContent; -}; diff --git a/src/app/hooks/useSpaceHierarchy.ts b/src/app/hooks/useSpaceHierarchy.ts index ad34e3f4..59889c1c 100644 --- a/src/app/hooks/useSpaceHierarchy.ts +++ b/src/app/hooks/useSpaceHierarchy.ts @@ -276,7 +276,8 @@ export type FetchSpaceHierarchyLevelData = { }; export const useFetchSpaceHierarchyLevel = ( roomId: string, - enable: boolean + enable: boolean, + maxDepth = 1 ): FetchSpaceHierarchyLevelData => { const mx = useMatrixClient(); const pageNoRef = useRef(0); @@ -286,13 +287,13 @@ export const useFetchSpaceHierarchyLevel = ( string[], string | undefined > = useCallback( - ({ pageParam }) => mx.getRoomHierarchy(roomId, PER_PAGE_COUNT, 1, false, pageParam), - [roomId, mx] + ({ pageParam }) => mx.getRoomHierarchy(roomId, PER_PAGE_COUNT, maxDepth, false, pageParam), + [roomId, maxDepth, mx] ); const queryResponse = useInfiniteQuery({ refetchOnMount: enable, - queryKey: [roomId, 'hierarchy_level'], + queryKey: [roomId, 'hierarchy_level', maxDepth], initialPageParam: undefined, queryFn: fetchLevel, getNextPageParam: (result) => { diff --git a/src/app/hooks/useTheme.ts b/src/app/hooks/useTheme.ts index cdbb9dba..4bfe01da 100644 --- a/src/app/hooks/useTheme.ts +++ b/src/app/hooks/useTheme.ts @@ -1,7 +1,8 @@ import { lightTheme } from 'folds'; import { createContext, useContext, useEffect, useMemo, useState } from 'react'; import { onDarkFontWeight, onLightFontWeight } from '../../config.css'; -import { butterTheme, darkTheme, silverTheme } from '../../colors.css'; +import { butterTheme, darkTheme, gamecultTheme, silverTheme } from '../../colors.css'; +import '../../gamecult.css'; import { settingsAtom } from '../state/settings'; import { useSetting } from '../state/hooks/settings'; @@ -38,8 +39,14 @@ export const ButterTheme: Theme = { classNames: ['butter-theme', butterTheme, onDarkFontWeight, 'prism-dark'], }; +export const GameCultTheme: Theme = { + id: 'gamecult-theme', + kind: ThemeKind.Dark, + classNames: ['gamecult-theme', gamecultTheme, onDarkFontWeight, 'prism-dark'], +}; + export const useThemes = (): Theme[] => { - const themes: Theme[] = useMemo(() => [LightTheme, SilverTheme, DarkTheme, ButterTheme], []); + const themes: Theme[] = useMemo(() => [GameCultTheme, LightTheme, SilverTheme, DarkTheme, ButterTheme], []); return themes; }; @@ -47,6 +54,7 @@ export const useThemes = (): Theme[] => { export const useThemeNames = (): Record => useMemo( () => ({ + [GameCultTheme.id]: 'GameCult', [LightTheme.id]: 'Light', [SilverTheme.id]: 'Silver', [DarkTheme.id]: 'Dark', @@ -84,15 +92,15 @@ export const useActiveTheme = (): Theme => { const [darkThemeId] = useSetting(settingsAtom, 'darkThemeId'); if (!systemTheme) { - const selectedTheme = themes.find((theme) => theme.id === themeId) ?? LightTheme; + const selectedTheme = themes.find((theme) => theme.id === themeId) ?? GameCultTheme; return selectedTheme; } const selectedTheme = systemThemeKind === ThemeKind.Dark - ? themes.find((theme) => theme.id === darkThemeId) ?? DarkTheme - : themes.find((theme) => theme.id === lightThemeId) ?? LightTheme; + ? themes.find((theme) => theme.id === darkThemeId) ?? GameCultTheme + : themes.find((theme) => theme.id === lightThemeId) ?? GameCultTheme; return selectedTheme; }; diff --git a/src/app/pages/MobileFriendly.tsx b/src/app/pages/MobileFriendly.tsx index ca947ac6..225b1b80 100644 --- a/src/app/pages/MobileFriendly.tsx +++ b/src/app/pages/MobileFriendly.tsx @@ -1,28 +1,6 @@ import { ReactNode } from 'react'; import { useMatch } from 'react-router-dom'; import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize'; -import { DIRECT_PATH, EXPLORE_PATH, HOME_PATH, INBOX_PATH, SPACE_PATH } from './paths'; - -type MobileFriendlyClientNavProps = { - children: ReactNode; -}; -export function MobileFriendlyClientNav({ children }: MobileFriendlyClientNavProps) { - const screenSize = useScreenSizeContext(); - const homeMatch = useMatch({ path: HOME_PATH, caseSensitive: true, end: true }); - const directMatch = useMatch({ path: DIRECT_PATH, caseSensitive: true, end: true }); - const spaceMatch = useMatch({ path: SPACE_PATH, caseSensitive: true, end: true }); - const exploreMatch = useMatch({ path: EXPLORE_PATH, caseSensitive: true, end: true }); - const inboxMatch = useMatch({ path: INBOX_PATH, caseSensitive: true, end: true }); - - if ( - screenSize === ScreenSize.Mobile && - !(homeMatch || directMatch || spaceMatch || exploreMatch || inboxMatch) - ) { - return null; - } - - return children; -} type MobileFriendlyPageNavProps = { path: string; diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index 4de42081..159162dd 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -40,19 +40,19 @@ import { getSpaceLobbyPath, } from './pathUtils'; import { ClientBindAtoms, ClientLayout, ClientRoot } from './client'; -import { Home, HomeRouteRoomProvider, HomeSearch } from './client/home'; -import { Direct, DirectCreate, DirectRouteRoomProvider } from './client/direct'; -import { RouteSpaceProvider, Space, SpaceRouteRoomProvider, SpaceSearch } from './client/space'; +import { HomeRouteRoomProvider, HomeSearch } from './client/home'; +import { DirectCreate, DirectRouteRoomProvider } from './client/direct'; +import { RouteSpaceProvider, SpaceRouteRoomProvider, SpaceSearch } from './client/space'; import { Explore, FeaturedRooms, PublicRooms } from './client/explore'; import { Notifications, Inbox, Invites } from './client/inbox'; import { setAfterLoginRedirectPath } from './afterLoginRedirectPath'; import { Room } from '../features/room'; import { Lobby } from '../features/lobby'; import { WelcomePage } from './client/WelcomePage'; -import { SidebarNav } from './client/SidebarNav'; +import { EstatePageNav } from './client/EstatePageNav'; import { PageRoot } from '../components/page'; import { ScreenSize } from '../hooks/useScreenSize'; -import { MobileFriendlyPageNav, MobileFriendlyClientNav } from './MobileFriendly'; +import { MobileFriendlyPageNav } from './MobileFriendly'; import { ClientInitStorageAtom } from './client/ClientInitStorageAtom'; import { ClientNonUIFeatures } from './client/ClientNonUIFeatures'; import { AuthRouteThemeManager, UnAuthRouteThemeManager } from './ThemeManager'; @@ -127,13 +127,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) - - - - } - > + @@ -157,13 +151,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) - - - } - > + }> } @@ -184,13 +172,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) - - - } - > + }> } @@ -210,13 +192,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) path={SPACE_PATH} element={ - - - - } - > + }> diff --git a/src/app/pages/ThemeManager.tsx b/src/app/pages/ThemeManager.tsx index 69d50cdb..983ca684 100644 --- a/src/app/pages/ThemeManager.tsx +++ b/src/app/pages/ThemeManager.tsx @@ -1,29 +1,20 @@ import React, { ReactNode, useEffect } from 'react'; import { configClass, varsClass } from 'folds'; import { - DarkTheme, - LightTheme, + GameCultTheme, ThemeContextProvider, - ThemeKind, useActiveTheme, - useSystemThemeKind, } from '../hooks/useTheme'; import { useSetting } from '../state/hooks/settings'; import { settingsAtom } from '../state/settings'; export function UnAuthRouteThemeManager() { - const systemThemeKind = useSystemThemeKind(); - useEffect(() => { document.body.className = ''; document.body.classList.add(configClass, varsClass); - if (systemThemeKind === ThemeKind.Dark) { - document.body.classList.add(...DarkTheme.classNames); - } - if (systemThemeKind === ThemeKind.Light) { - document.body.classList.add(...LightTheme.classNames); - } - }, [systemThemeKind]); + // The GameCult identity is single-theme: signed-out pages ignore the system scheme. + document.body.classList.add(...GameCultTheme.classNames); + }, []); return null; } diff --git a/src/app/pages/auth/AuthFooter.tsx b/src/app/pages/auth/AuthFooter.tsx index b0b94319..2b888f55 100644 --- a/src/app/pages/auth/AuthFooter.tsx +++ b/src/app/pages/auth/AuthFooter.tsx @@ -5,20 +5,26 @@ import * as css from './styles.css'; export function AuthFooter() { return ( - + About - v4.12.8 + Source - - Twitter + + Based on Cinny Powered by Matrix diff --git a/src/app/pages/auth/AuthLayout.tsx b/src/app/pages/auth/AuthLayout.tsx index 3943f42d..c53e5a65 100644 --- a/src/app/pages/auth/AuthLayout.tsx +++ b/src/app/pages/auth/AuthLayout.tsx @@ -20,7 +20,7 @@ import { } from '../../hooks/useClientConfig'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { LOGIN_PATH, REGISTER_PATH, RESET_PASSWORD_PATH } from '../paths'; -import CinnySVG from '../../../../public/res/svg/cinny.svg'; +import RatatoskrSVG from '../../../../public/res/svg/ratatoskr.svg'; import { ServerPicker } from './ServerPicker'; import { AutoDiscoveryAction, autoDiscovery } from '../../cs-api'; import { SpecVersionsLoader } from '../../components/SpecVersionsLoader'; @@ -134,8 +134,8 @@ export function AuthLayout() {
- Cinny Logo - Cinny + Ratatoskr logo + Ratatoskr
diff --git a/src/app/pages/auth/login/PasswordLoginForm.tsx b/src/app/pages/auth/login/PasswordLoginForm.tsx index 62f46dd2..3c8641d9 100644 --- a/src/app/pages/auth/login/PasswordLoginForm.tsx +++ b/src/app/pages/auth/login/PasswordLoginForm.tsx @@ -133,7 +133,7 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog user: username, }, password, - initial_device_display_name: 'Cinny Web', + initial_device_display_name: 'Ratatoskr Web', }); }; @@ -151,7 +151,7 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog user: mxIdUsername, }, password, - initial_device_display_name: 'Cinny Web', + initial_device_display_name: 'Ratatoskr Web', }); }; const handleEmailLogin = (email: string, password: string) => { @@ -163,7 +163,7 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog address: email, }, password, - initial_device_display_name: 'Cinny Web', + initial_device_display_name: 'Ratatoskr Web', }); }; diff --git a/src/app/pages/auth/login/TokenLogin.tsx b/src/app/pages/auth/login/TokenLogin.tsx index 761d5dc5..0541742d 100644 --- a/src/app/pages/auth/login/TokenLogin.tsx +++ b/src/app/pages/auth/login/TokenLogin.tsx @@ -57,7 +57,7 @@ export function TokenLogin({ token }: TokenLoginProps) { startLogin(baseUrl, { type: 'm.login.token', token, - initial_device_display_name: 'Cinny Web', + initial_device_display_name: 'Ratatoskr Web', }); }, [baseUrl, token, startLogin]); diff --git a/src/app/pages/auth/register/PasswordRegisterForm.tsx b/src/app/pages/auth/register/PasswordRegisterForm.tsx index 9f173422..c9b1924c 100644 --- a/src/app/pages/auth/register/PasswordRegisterForm.tsx +++ b/src/app/pages/auth/register/PasswordRegisterForm.tsx @@ -109,7 +109,7 @@ function RegisterUIAFlow({ auth: authDict, password, username, - initial_device_display_name: 'Cinny Web', + initial_device_display_name: 'Ratatoskr Web', }); }, [onRegister, formData] @@ -250,7 +250,7 @@ export function PasswordRegisterForm({ auth: { session: authData.session, }, - initial_device_display_name: 'Cinny Web', + initial_device_display_name: 'Ratatoskr Web', }); }; diff --git a/src/app/pages/client/ClientLayout.tsx b/src/app/pages/client/ClientLayout.tsx index 4a077ba6..e1d83c95 100644 --- a/src/app/pages/client/ClientLayout.tsx +++ b/src/app/pages/client/ClientLayout.tsx @@ -1,15 +1,6 @@ import React, { ReactNode } from 'react'; import { Box } from 'folds'; -type ClientLayoutProps = { - nav: ReactNode; - children: ReactNode; -}; -export function ClientLayout({ nav, children }: ClientLayoutProps) { - return ( - - {nav} - {children} - - ); +export function ClientLayout({ children }: { children: ReactNode }) { + return {children}; } diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index ce952bfc..db3eefc2 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -3,9 +3,9 @@ import React, { ReactNode, useCallback, useEffect, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk'; import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread'; -import LogoSVG from '../../../../public/res/svg/cinny.svg'; -import LogoUnreadSVG from '../../../../public/res/svg/cinny-unread.svg'; -import LogoHighlightSVG from '../../../../public/res/svg/cinny-highlight.svg'; +import LogoSVG from '../../../../public/res/svg/ratatoskr.svg'; +import LogoUnreadSVG from '../../../../public/res/svg/ratatoskr-unread.svg'; +import LogoHighlightSVG from '../../../../public/res/svg/ratatoskr-highlight.svg'; import NotificationSound from '../../../../public/sound/notification.ogg'; import InviteSound from '../../../../public/sound/invite.ogg'; import { notificationPermission, setFavicon } from '../../utils/dom'; diff --git a/src/app/pages/client/EstatePageNav.tsx b/src/app/pages/client/EstatePageNav.tsx new file mode 100644 index 00000000..470cbdd7 --- /dev/null +++ b/src/app/pages/client/EstatePageNav.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { MobileFriendlyPageNav } from '../MobileFriendly'; +import { EstateNavigator } from '../../features/estate'; + +/** + * Where the navigator mounts in a route's nav slot. The wide-screen placement + * question (ratatoskr:question:desktop-navigator-placement) is answered here. + */ +export function EstatePageNav({ path }: { path: string }) { + return ( + + + + ); +} diff --git a/src/app/pages/client/SidebarNav.tsx b/src/app/pages/client/SidebarNav.tsx deleted file mode 100644 index a2e1b682..00000000 --- a/src/app/pages/client/SidebarNav.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import React, { useRef } from 'react'; -import { Scroll } from 'folds'; - -import { - Sidebar, - SidebarContent, - SidebarStackSeparator, - SidebarStack, -} from '../../components/sidebar'; -import { - DirectTab, - HomeTab, - SpaceTabs, - InboxTab, - ExploreTab, - SettingsTab, - UnverifiedTab, - SearchTab, -} from './sidebar'; -import { CreateTab } from './sidebar/CreateTab'; - -export function SidebarNav() { - const scrollRef = useRef(null); - - return ( - - - - - - - - - - - - - - } - sticky={ - <> - - - - - - - - - } - /> - - ); -} diff --git a/src/app/pages/client/WelcomePage.tsx b/src/app/pages/client/WelcomePage.tsx index 6a25392c..74869c3f 100644 --- a/src/app/pages/client/WelcomePage.tsx +++ b/src/app/pages/client/WelcomePage.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Box, Button, Icon, Icons, Text, config, toRem } from 'folds'; import { Page, PageHero, PageHeroSection } from '../../components/page'; -import CinnySVG from '../../../../public/res/svg/cinny.svg'; +import RatatoskrSVG from '../../../../public/res/svg/ratatoskr.svg'; export function WelcomePage() { return ( @@ -14,17 +14,17 @@ export function WelcomePage() { > } - title="Welcome to Cinny" + icon={Ratatoskr logo} + title="Welcome to Ratatoskr" subTitle={ - Yet another matrix client.{' '} + GameCult's Matrix client, based on{' '} - v4.12.8 + Cinny } @@ -33,7 +33,7 @@ export function WelcomePage() { - diff --git a/src/app/pages/client/direct/Direct.tsx b/src/app/pages/client/direct/Direct.tsx deleted file mode 100644 index 04edf109..00000000 --- a/src/app/pages/client/direct/Direct.tsx +++ /dev/null @@ -1,280 +0,0 @@ -import React, { MouseEventHandler, forwardRef, useMemo, useRef, useState } from 'react'; -import { useAtom, useAtomValue } from 'jotai'; -import { - Avatar, - Box, - Button, - Icon, - IconButton, - Icons, - Menu, - MenuItem, - PopOut, - RectCords, - Text, - config, - toRem, -} from 'folds'; -import { useVirtualizer } from '@tanstack/react-virtual'; -import FocusTrap from 'focus-trap-react'; -import { useNavigate } from 'react-router-dom'; -import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { factoryRoomIdByActivity } from '../../../utils/sort'; -import { - NavButton, - NavCategory, - NavCategoryHeader, - NavEmptyCenter, - NavEmptyLayout, - NavItem, - NavItemContent, -} from '../../../components/nav'; -import { getDirectCreatePath, getDirectRoomPath } from '../../pathUtils'; -import { getCanonicalAliasOrRoomId } from '../../../utils/matrix'; -import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom'; -import { VirtualTile } from '../../../components/virtualizer'; -import { RoomNavCategoryButton, RoomNavItem } from '../../../features/room-nav'; -import { makeNavCategoryId } from '../../../state/closedNavCategories'; -import { roomToUnreadAtom } from '../../../state/room/roomToUnread'; -import { useCategoryHandler } from '../../../hooks/useCategoryHandler'; -import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper'; -import { useDirectRooms } from './useDirectRooms'; -import { PageNav, PageNavContent, PageNavHeader } from '../../../components/page'; -import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories'; -import { useRoomsUnread } from '../../../state/hooks/unread'; -import { markAsRead } from '../../../utils/notifications'; -import { stopPropagation } from '../../../utils/keyboard'; -import { useSetting } from '../../../state/hooks/settings'; -import { settingsAtom } from '../../../state/settings'; -import { - getRoomNotificationMode, - useRoomsNotificationPreferencesContext, -} from '../../../hooks/useRoomsNotificationPreferences'; -import { useDirectCreateSelected } from '../../../hooks/router/useDirectSelected'; - -type DirectMenuProps = { - requestClose: () => void; -}; -const DirectMenu = forwardRef(({ requestClose }, ref) => { - const mx = useMatrixClient(); - const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); - const orphanRooms = useDirectRooms(); - const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom); - - const handleMarkAsRead = () => { - if (!unread) return; - orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity)); - requestClose(); - }; - - return ( - - - } - radii="300" - aria-disabled={!unread} - > - - Mark as Read - - - - - ); -}); - -function DirectHeader() { - const [menuAnchor, setMenuAnchor] = useState(); - - const handleOpenMenu: MouseEventHandler = (evt) => { - const cords = evt.currentTarget.getBoundingClientRect(); - setMenuAnchor((currentState) => { - if (currentState) return undefined; - return cords; - }); - }; - - return ( - <> - - - - - Direct Messages - - - - - - - - - - setMenuAnchor(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', - isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', - escapeDeactivates: stopPropagation, - }} - > - setMenuAnchor(undefined)} /> - - } - /> - - ); -} - -function DirectEmpty() { - const navigate = useNavigate(); - - return ( - - } - title={ - - No Direct Messages - - } - content={ - - You do not have any direct messages yet. - - } - options={ - - } - /> - - ); -} - -const DEFAULT_CATEGORY_ID = makeNavCategoryId('direct', 'direct'); -export function Direct() { - const mx = useMatrixClient(); - useNavToActivePathMapper('direct'); - const scrollRef = useRef(null); - const directs = useDirectRooms(); - const notificationPreferences = useRoomsNotificationPreferencesContext(); - const roomToUnread = useAtomValue(roomToUnreadAtom); - const navigate = useNavigate(); - - const createDirectSelected = useDirectCreateSelected(); - - const selectedRoomId = useSelectedRoom(); - const noRoomToDisplay = directs.length === 0; - const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom()); - - const sortedDirects = useMemo(() => { - const items = Array.from(directs).sort(factoryRoomIdByActivity(mx)); - if (closedCategories.has(DEFAULT_CATEGORY_ID)) { - return items.filter((rId) => roomToUnread.has(rId) || rId === selectedRoomId); - } - return items; - }, [mx, directs, closedCategories, roomToUnread, selectedRoomId]); - - const virtualizer = useVirtualizer({ - count: sortedDirects.length, - getScrollElement: () => scrollRef.current, - estimateSize: () => 38, - overscan: 10, - }); - - const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) => - closedCategories.has(categoryId) - ); - - return ( - - - {noRoomToDisplay ? ( - - ) : ( - - - - - navigate(getDirectCreatePath())}> - - - - - - - - Create Chat - - - - - - - - - - - Chats - - -
- {virtualizer.getVirtualItems().map((vItem) => { - const roomId = sortedDirects[vItem.index]; - const room = mx.getRoom(roomId); - if (!room) return null; - const selected = selectedRoomId === roomId; - - return ( - - - - ); - })} -
-
-
-
- )} -
- ); -} diff --git a/src/app/pages/client/direct/index.ts b/src/app/pages/client/direct/index.ts index d247bbc0..aaa8c929 100644 --- a/src/app/pages/client/direct/index.ts +++ b/src/app/pages/client/direct/index.ts @@ -1,3 +1,2 @@ -export * from './Direct'; export * from './RoomProvider'; export * from './DirectCreate'; diff --git a/src/app/pages/client/home/Home.tsx b/src/app/pages/client/home/Home.tsx deleted file mode 100644 index d87db963..00000000 --- a/src/app/pages/client/home/Home.tsx +++ /dev/null @@ -1,362 +0,0 @@ -import React, { MouseEventHandler, forwardRef, useMemo, useRef, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { - Avatar, - Box, - Button, - Icon, - IconButton, - Icons, - Menu, - MenuItem, - PopOut, - RectCords, - Text, - config, - toRem, -} from 'folds'; -import { useVirtualizer } from '@tanstack/react-virtual'; -import { useAtom, useAtomValue } from 'jotai'; -import FocusTrap from 'focus-trap-react'; -import { factoryRoomIdByActivity, factoryRoomIdByAtoZ } from '../../../utils/sort'; -import { - NavButton, - NavCategory, - NavCategoryHeader, - NavEmptyCenter, - NavEmptyLayout, - NavItem, - NavItemContent, - NavLink, -} from '../../../components/nav'; -import { - encodeSearchParamValueArray, - getExplorePath, - getHomeCreatePath, - getHomeRoomPath, - getHomeSearchPath, - withSearchParam, -} from '../../pathUtils'; -import { getCanonicalAliasOrRoomId } from '../../../utils/matrix'; -import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom'; -import { - useHomeCreateSelected, - useHomeSearchSelected, -} from '../../../hooks/router/useHomeSelected'; -import { useHomeRooms } from './useHomeRooms'; -import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { VirtualTile } from '../../../components/virtualizer'; -import { RoomNavCategoryButton, RoomNavItem } from '../../../features/room-nav'; -import { makeNavCategoryId } from '../../../state/closedNavCategories'; -import { roomToUnreadAtom } from '../../../state/room/roomToUnread'; -import { useCategoryHandler } from '../../../hooks/useCategoryHandler'; -import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper'; -import { PageNav, PageNavHeader, PageNavContent } from '../../../components/page'; -import { useRoomsUnread } from '../../../state/hooks/unread'; -import { markAsRead } from '../../../utils/notifications'; -import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories'; -import { stopPropagation } from '../../../utils/keyboard'; -import { useSetting } from '../../../state/hooks/settings'; -import { settingsAtom } from '../../../state/settings'; -import { - getRoomNotificationMode, - useRoomsNotificationPreferencesContext, -} from '../../../hooks/useRoomsNotificationPreferences'; -import { UseStateProvider } from '../../../components/UseStateProvider'; -import { JoinAddressPrompt } from '../../../components/join-address-prompt'; -import { _RoomSearchParams } from '../../paths'; - -type HomeMenuProps = { - requestClose: () => void; -}; -const HomeMenu = forwardRef(({ requestClose }, ref) => { - const orphanRooms = useHomeRooms(); - const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); - const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom); - const mx = useMatrixClient(); - - const handleMarkAsRead = () => { - if (!unread) return; - orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity)); - requestClose(); - }; - - return ( - - - } - radii="300" - aria-disabled={!unread} - > - - Mark as Read - - - - - ); -}); - -function HomeHeader() { - const [menuAnchor, setMenuAnchor] = useState(); - - const handleOpenMenu: MouseEventHandler = (evt) => { - const cords = evt.currentTarget.getBoundingClientRect(); - setMenuAnchor((currentState) => { - if (currentState) return undefined; - return cords; - }); - }; - - return ( - <> - - - - - Home - - - - - - - - - - setMenuAnchor(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', - isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', - escapeDeactivates: stopPropagation, - }} - > - setMenuAnchor(undefined)} /> - - } - /> - - ); -} - -function HomeEmpty() { - const navigate = useNavigate(); - - return ( - - } - title={ - - No Rooms - - } - content={ - - You do not have any rooms yet. - - } - options={ - <> - - - - } - /> - - ); -} - -const DEFAULT_CATEGORY_ID = makeNavCategoryId('home', 'room'); -export function Home() { - const mx = useMatrixClient(); - useNavToActivePathMapper('home'); - const scrollRef = useRef(null); - const rooms = useHomeRooms(); - const notificationPreferences = useRoomsNotificationPreferencesContext(); - const roomToUnread = useAtomValue(roomToUnreadAtom); - const navigate = useNavigate(); - - const selectedRoomId = useSelectedRoom(); - const createRoomSelected = useHomeCreateSelected(); - const searchSelected = useHomeSearchSelected(); - const noRoomToDisplay = rooms.length === 0; - const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom()); - - const sortedRooms = useMemo(() => { - const items = Array.from(rooms).sort( - closedCategories.has(DEFAULT_CATEGORY_ID) - ? factoryRoomIdByActivity(mx) - : factoryRoomIdByAtoZ(mx) - ); - if (closedCategories.has(DEFAULT_CATEGORY_ID)) { - return items.filter((rId) => roomToUnread.has(rId) || rId === selectedRoomId); - } - return items; - }, [mx, rooms, closedCategories, roomToUnread, selectedRoomId]); - - const virtualizer = useVirtualizer({ - count: sortedRooms.length, - getScrollElement: () => scrollRef.current, - estimateSize: () => 38, - overscan: 10, - }); - - const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) => - closedCategories.has(categoryId) - ); - - return ( - - - {noRoomToDisplay ? ( - - ) : ( - - - - - navigate(getHomeCreatePath())}> - - - - - - - - Create Room - - - - - - - - {(open, setOpen) => ( - <> - - setOpen(true)}> - - - - - - - - Join with Address - - - - - - - {open && ( - setOpen(false)} - onOpen={(roomIdOrAlias, viaServers, eventId) => { - setOpen(false); - const path = getHomeRoomPath(roomIdOrAlias, eventId); - navigate( - viaServers - ? withSearchParam<_RoomSearchParams>(path, { - viaServers: encodeSearchParamValueArray(viaServers), - }) - : path - ); - }} - /> - )} - - )} - - - - - - - - - - - Message Search - - - - - - - - - - - Rooms - - -
- {virtualizer.getVirtualItems().map((vItem) => { - const roomId = sortedRooms[vItem.index]; - const room = mx.getRoom(roomId); - if (!room) return null; - const selected = selectedRoomId === roomId; - - return ( - - - - ); - })} -
-
-
-
- )} -
- ); -} diff --git a/src/app/pages/client/home/index.ts b/src/app/pages/client/home/index.ts index 80764481..5669f658 100644 --- a/src/app/pages/client/home/index.ts +++ b/src/app/pages/client/home/index.ts @@ -1,3 +1,2 @@ -export * from './Home'; export * from './Search'; export * from './RoomProvider'; diff --git a/src/app/pages/client/inbox/Invites.tsx b/src/app/pages/client/inbox/Invites.tsx index 20518e56..ca26bac3 100644 --- a/src/app/pages/client/inbox/Invites.tsx +++ b/src/app/pages/client/inbox/Invites.tsx @@ -45,12 +45,8 @@ import { } from '../../../utils/room'; import { nameInitials } from '../../../utils/common'; import { RoomAvatar } from '../../../components/room-avatar'; -import { - addRoomIdToMDirect, - getMxIdLocalPart, - guessDmRoomUserId, - rateLimitedActions, -} from '../../../utils/matrix'; +import { getMxIdLocalPart, rateLimitedActions } from '../../../utils/matrix'; +import { activateEstateRoom } from '../../../features/estate/joinEstateRoom'; import { Time } from '../../../components/message'; import { useElementSizeObserver } from '../../../hooks/useElementSizeObserver'; import { onEnterOrSpace, stopPropagation } from '../../../utils/keyboard'; @@ -160,24 +156,20 @@ function InviteCard({ hideAvatar, }: InviteCardProps) { const mx = useMatrixClient(); - const userId = mx.getSafeUserId(); - const [viewTopic, setViewTopic] = useState(false); const closeTopic = () => setViewTopic(false); const openTopic = () => setViewTopic(true); const [joinState, join] = useAsyncCallback( useCallback(async () => { - const dmUserId = isDirectInvite(invite.room, userId) - ? guessDmRoomUserId(invite.room, userId) - : undefined; - - await mx.joinRoom(invite.roomId); - if (dmUserId) { - await addRoomIdToMDirect(mx, invite.roomId, dmUserId); - } + const result = await activateEstateRoom(mx, { + roomId: invite.roomId, + state: 'invited', + via: [], + }); + if (result.kind === 'refused') throw new Error(result.message); onNavigate(invite.roomId, invite.isSpace); - }, [mx, invite, userId, onNavigate]) + }, [mx, invite, onNavigate]) ); const [leaveState, leave] = useAsyncCallback, MatrixError, []>( useCallback(() => mx.leave(invite.roomId), [mx, invite]) diff --git a/src/app/pages/client/sidebar/CreateTab.tsx b/src/app/pages/client/sidebar/CreateTab.tsx deleted file mode 100644 index e6575cb4..00000000 --- a/src/app/pages/client/sidebar/CreateTab.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import React, { MouseEventHandler, useState } from 'react'; -import { Box, config, Icon, Icons, Menu, PopOut, RectCords, Text } from 'folds'; -import FocusTrap from 'focus-trap-react'; -import { useNavigate } from 'react-router-dom'; -import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '../../../components/sidebar'; -import { stopPropagation } from '../../../utils/keyboard'; -import { SequenceCard } from '../../../components/sequence-card'; -import { SettingTile } from '../../../components/setting-tile'; -import { ContainerColor } from '../../../styles/ContainerColor.css'; -import { - encodeSearchParamValueArray, - getCreatePath, - getSpacePath, - withSearchParam, -} from '../../pathUtils'; -import { useCreateSelected } from '../../../hooks/router/useCreateSelected'; -import { JoinAddressPrompt } from '../../../components/join-address-prompt'; -import { _RoomSearchParams } from '../../paths'; - -export function CreateTab() { - const createSelected = useCreateSelected(); - - const navigate = useNavigate(); - const [menuCords, setMenuCords] = useState(); - const [joinAddress, setJoinAddress] = useState(false); - - const handleMenu: MouseEventHandler = (evt) => { - setMenuCords(menuCords ? undefined : evt.currentTarget.getBoundingClientRect()); - }; - - const handleCreateSpace = () => { - navigate(getCreatePath()); - setMenuCords(undefined); - }; - - const handleJoinWithAddress = () => { - setJoinAddress(true); - setMenuCords(undefined); - }; - - return ( - - - {(triggerRef) => ( - setMenuCords(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => - evt.key === 'ArrowDown' || evt.key === 'ArrowRight', - isKeyBackward: (evt: KeyboardEvent) => - evt.key === 'ArrowUp' || evt.key === 'ArrowLeft', - escapeDeactivates: stopPropagation, - }} - > - - - - }> - Create Space - - Build a space for your community. - - - - - }> - Join with Address - - Become a part of existing community. - - - - - - - } - > - - - - {joinAddress && ( - setJoinAddress(false)} - onOpen={(roomIdOrAlias, viaServers) => { - setJoinAddress(false); - const path = getSpacePath(roomIdOrAlias); - navigate( - viaServers - ? withSearchParam<_RoomSearchParams>(path, { - viaServers: encodeSearchParamValueArray(viaServers), - }) - : path - ); - }} - /> - )} - - )} - - - ); -} diff --git a/src/app/pages/client/sidebar/DirectTab.tsx b/src/app/pages/client/sidebar/DirectTab.tsx deleted file mode 100644 index dcd44e2f..00000000 --- a/src/app/pages/client/sidebar/DirectTab.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import React, { MouseEventHandler, forwardRef, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { Box, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text, config, toRem } from 'folds'; -import FocusTrap from 'focus-trap-react'; -import { useAtomValue } from 'jotai'; -import { useDirects } from '../../../state/hooks/roomList'; -import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { mDirectAtom } from '../../../state/mDirectList'; -import { allRoomsAtom } from '../../../state/room-list/roomList'; -import { roomToUnreadAtom } from '../../../state/room/roomToUnread'; -import { getDirectPath, joinPathComponent } from '../../pathUtils'; -import { useRoomsUnread } from '../../../state/hooks/unread'; -import { - SidebarAvatar, - SidebarItem, - SidebarItemBadge, - SidebarItemTooltip, -} from '../../../components/sidebar'; -import { useDirectSelected } from '../../../hooks/router/useDirectSelected'; -import { UnreadBadge } from '../../../components/unread-badge'; -import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; -import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath'; -import { useDirectRooms } from '../direct/useDirectRooms'; -import { markAsRead } from '../../../utils/notifications'; -import { stopPropagation } from '../../../utils/keyboard'; -import { settingsAtom } from '../../../state/settings'; -import { useSetting } from '../../../state/hooks/settings'; - -type DirectMenuProps = { - requestClose: () => void; -}; -const DirectMenu = forwardRef(({ requestClose }, ref) => { - const orphanRooms = useDirectRooms(); - const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); - const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom); - const mx = useMatrixClient(); - - const handleMarkAsRead = () => { - if (!unread) return; - orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity)); - requestClose(); - }; - - return ( - - - } - radii="300" - aria-disabled={!unread} - > - - Mark as Read - - - - - ); -}); - -export function DirectTab() { - const navigate = useNavigate(); - const mx = useMatrixClient(); - const screenSize = useScreenSizeContext(); - const navToActivePath = useAtomValue(useNavToActivePathAtom()); - - const mDirects = useAtomValue(mDirectAtom); - const directs = useDirects(mx, allRoomsAtom, mDirects); - const directUnread = useRoomsUnread(directs, roomToUnreadAtom); - const [menuAnchor, setMenuAnchor] = useState(); - - const directSelected = useDirectSelected(); - - const handleDirectClick = () => { - const activePath = navToActivePath.get('direct'); - if (activePath && screenSize !== ScreenSize.Mobile) { - navigate(joinPathComponent(activePath)); - return; - } - - navigate(getDirectPath()); - }; - - const handleContextMenu: MouseEventHandler = (evt) => { - evt.preventDefault(); - const cords = evt.currentTarget.getBoundingClientRect(); - setMenuAnchor((currentState) => { - if (currentState) return undefined; - return cords; - }); - }; - return ( - - - {(triggerRef) => ( - - - - )} - - {directUnread && ( - 0}> - 0} count={directUnread.total} /> - - )} - {menuAnchor && ( - setMenuAnchor(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', - isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', - escapeDeactivates: stopPropagation, - }} - > - setMenuAnchor(undefined)} /> - - } - /> - )} - - ); -} diff --git a/src/app/pages/client/sidebar/ExploreTab.tsx b/src/app/pages/client/sidebar/ExploreTab.tsx deleted file mode 100644 index a45b5830..00000000 --- a/src/app/pages/client/sidebar/ExploreTab.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import React from 'react'; -import { Icon, Icons } from 'folds'; -import { useNavigate } from 'react-router-dom'; -import { useAtomValue } from 'jotai'; -import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '../../../components/sidebar'; -import { useExploreSelected } from '../../../hooks/router/useExploreSelected'; -import { - getExploreFeaturedPath, - getExplorePath, - getExploreServerPath, - joinPathComponent, -} from '../../pathUtils'; -import { useClientConfig } from '../../../hooks/useClientConfig'; -import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { getMxIdServer } from '../../../utils/matrix'; -import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; -import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath'; - -export function ExploreTab() { - const mx = useMatrixClient(); - const screenSize = useScreenSizeContext(); - const clientConfig = useClientConfig(); - const navigate = useNavigate(); - const navToActivePath = useAtomValue(useNavToActivePathAtom()); - - const exploreSelected = useExploreSelected(); - - const handleExploreClick = () => { - if (screenSize === ScreenSize.Mobile) { - navigate(getExplorePath()); - return; - } - - const activePath = navToActivePath.get('explore'); - if (activePath) { - navigate(joinPathComponent(activePath)); - return; - } - - if (clientConfig.featuredCommunities?.openAsDefault) { - navigate(getExploreFeaturedPath()); - return; - } - const userId = mx.getUserId(); - const userServer = userId ? getMxIdServer(userId) : undefined; - if (userServer) { - navigate(getExploreServerPath(userServer)); - return; - } - navigate(getExplorePath()); - }; - - return ( - - - {(triggerRef) => ( - - - - )} - - - ); -} diff --git a/src/app/pages/client/sidebar/HomeTab.tsx b/src/app/pages/client/sidebar/HomeTab.tsx deleted file mode 100644 index 778b729e..00000000 --- a/src/app/pages/client/sidebar/HomeTab.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import React, { MouseEventHandler, forwardRef, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { Box, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text, config, toRem } from 'folds'; -import { useAtomValue } from 'jotai'; -import FocusTrap from 'focus-trap-react'; -import { useOrphanRooms } from '../../../state/hooks/roomList'; -import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { mDirectAtom } from '../../../state/mDirectList'; -import { roomToParentsAtom } from '../../../state/room/roomToParents'; -import { allRoomsAtom } from '../../../state/room-list/roomList'; -import { roomToUnreadAtom } from '../../../state/room/roomToUnread'; -import { getHomePath, joinPathComponent } from '../../pathUtils'; -import { useRoomsUnread } from '../../../state/hooks/unread'; -import { - SidebarAvatar, - SidebarItem, - SidebarItemBadge, - SidebarItemTooltip, -} from '../../../components/sidebar'; -import { useHomeSelected } from '../../../hooks/router/useHomeSelected'; -import { UnreadBadge } from '../../../components/unread-badge'; -import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; -import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath'; -import { useHomeRooms } from '../home/useHomeRooms'; -import { markAsRead } from '../../../utils/notifications'; -import { stopPropagation } from '../../../utils/keyboard'; -import { useSetting } from '../../../state/hooks/settings'; -import { settingsAtom } from '../../../state/settings'; - -type HomeMenuProps = { - requestClose: () => void; -}; -const HomeMenu = forwardRef(({ requestClose }, ref) => { - const orphanRooms = useHomeRooms(); - const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); - const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom); - const mx = useMatrixClient(); - - const handleMarkAsRead = () => { - if (!unread) return; - orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity)); - requestClose(); - }; - - return ( - - - } - radii="300" - aria-disabled={!unread} - > - - Mark as Read - - - - - ); -}); - -export function HomeTab() { - const navigate = useNavigate(); - const mx = useMatrixClient(); - const screenSize = useScreenSizeContext(); - const navToActivePath = useAtomValue(useNavToActivePathAtom()); - - const mDirects = useAtomValue(mDirectAtom); - const roomToParents = useAtomValue(roomToParentsAtom); - const orphanRooms = useOrphanRooms(mx, allRoomsAtom, mDirects, roomToParents); - const homeUnread = useRoomsUnread(orphanRooms, roomToUnreadAtom); - const homeSelected = useHomeSelected(); - const [menuAnchor, setMenuAnchor] = useState(); - - const handleHomeClick = () => { - const activePath = navToActivePath.get('home'); - if (activePath && screenSize !== ScreenSize.Mobile) { - navigate(joinPathComponent(activePath)); - return; - } - - navigate(getHomePath()); - }; - - const handleContextMenu: MouseEventHandler = (evt) => { - evt.preventDefault(); - const cords = evt.currentTarget.getBoundingClientRect(); - setMenuAnchor((currentState) => { - if (currentState) return undefined; - return cords; - }); - }; - - return ( - - - {(triggerRef) => ( - - - - )} - - {homeUnread && ( - 0}> - 0} count={homeUnread.total} /> - - )} - {menuAnchor && ( - setMenuAnchor(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', - isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', - escapeDeactivates: stopPropagation, - }} - > - setMenuAnchor(undefined)} /> - - } - /> - )} - - ); -} diff --git a/src/app/pages/client/sidebar/SearchTab.tsx b/src/app/pages/client/sidebar/SearchTab.tsx deleted file mode 100644 index 7ceb5c49..00000000 --- a/src/app/pages/client/sidebar/SearchTab.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import React from 'react'; -import { Icon, Icons } from 'folds'; -import { useAtom } from 'jotai'; -import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '../../../components/sidebar'; -import { searchModalAtom } from '../../../state/searchModal'; - -export function SearchTab() { - const [opened, setOpen] = useAtom(searchModalAtom); - - const open = () => setOpen(true); - - return ( - - - {(triggerRef) => ( - - - - )} - - - ); -} diff --git a/src/app/pages/client/sidebar/SpaceTabs.tsx b/src/app/pages/client/sidebar/SpaceTabs.tsx deleted file mode 100644 index 6fc528a1..00000000 --- a/src/app/pages/client/sidebar/SpaceTabs.tsx +++ /dev/null @@ -1,866 +0,0 @@ -import React, { - MouseEventHandler, - ReactNode, - RefObject, - forwardRef, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; -import { useNavigate } from 'react-router-dom'; -import { - Box, - Icon, - IconButton, - Icons, - Line, - Menu, - MenuItem, - PopOut, - RectCords, - Text, - config, - toRem, -} from 'folds'; -import { useAtom, useAtomValue } from 'jotai'; -import { Room } from 'matrix-js-sdk'; -import { - draggable, - dropTargetForElements, - monitorForElements, -} from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; -import { - attachInstruction, - extractInstruction, - Instruction, -} from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item'; -import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element'; -import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'; -import FocusTrap from 'focus-trap-react'; -import { - useOrphanSpaces, - useRecursiveChildScopeFactory, - useSpaceChildren, -} from '../../../state/hooks/roomList'; -import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { roomToParentsAtom } from '../../../state/room/roomToParents'; -import { allRoomsAtom } from '../../../state/room-list/roomList'; -import { getSpaceLobbyPath, getSpacePath, joinPathComponent } from '../../pathUtils'; -import { - SidebarAvatar, - SidebarItem, - SidebarItemBadge, - SidebarItemTooltip, - SidebarStack, - SidebarStackSeparator, - SidebarFolder, - SidebarFolderDropTarget, -} from '../../../components/sidebar'; -import { RoomUnreadProvider, RoomsUnreadProvider } from '../../../components/RoomUnreadProvider'; -import { useSelectedSpace } from '../../../hooks/router/useSelectedSpace'; -import { UnreadBadge } from '../../../components/unread-badge'; -import { getCanonicalAliasOrRoomId, isRoomAlias } from '../../../utils/matrix'; -import { RoomAvatar } from '../../../components/room-avatar'; -import { nameInitials, randomStr } from '../../../utils/common'; -import { - ISidebarFolder, - SidebarItems, - TSidebarItem, - makeCinnySpacesContent, - parseSidebar, - sidebarItemWithout, - useSidebarItems, -} from '../../../hooks/useSidebarItems'; -import { AccountDataEvent } from '../../../../types/matrix/accountData'; -import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; -import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath'; -import { useOpenedSidebarFolderAtom } from '../../../state/hooks/openedSidebarFolder'; -import { usePowerLevels } from '../../../hooks/usePowerLevels'; -import { useRoomsUnread } from '../../../state/hooks/unread'; -import { roomToUnreadAtom } from '../../../state/room/roomToUnread'; -import { markAsRead } from '../../../utils/notifications'; -import { copyToClipboard } from '../../../utils/dom'; -import { stopPropagation } from '../../../utils/keyboard'; -import { getMatrixToRoom } from '../../../plugins/matrix-to'; -import { getViaServers } from '../../../plugins/via-servers'; -import { getRoomAvatarUrl } from '../../../utils/room'; -import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; -import { useSetting } from '../../../state/hooks/settings'; -import { settingsAtom } from '../../../state/settings'; -import { useOpenSpaceSettings } from '../../../state/hooks/spaceSettings'; -import { useRoomCreators } from '../../../hooks/useRoomCreators'; -import { useRoomPermissions } from '../../../hooks/useRoomPermissions'; -import { InviteUserPrompt } from '../../../components/invite-user-prompt'; - -type SpaceMenuProps = { - room: Room; - requestClose: () => void; - onUnpin?: (roomId: string) => void; -}; -const SpaceMenu = forwardRef( - ({ room, requestClose, onUnpin }, ref) => { - const mx = useMatrixClient(); - const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); - const roomToParents = useAtomValue(roomToParentsAtom); - const powerLevels = usePowerLevels(room); - const creators = useRoomCreators(room); - - const permissions = useRoomPermissions(creators, powerLevels); - const canInvite = permissions.action('invite', mx.getSafeUserId()); - const openSpaceSettings = useOpenSpaceSettings(); - - const [invitePrompt, setInvitePrompt] = useState(false); - - const allChild = useSpaceChildren( - allRoomsAtom, - room.roomId, - useRecursiveChildScopeFactory(mx, roomToParents) - ); - const unread = useRoomsUnread(allChild, roomToUnreadAtom); - - const handleMarkAsRead = () => { - allChild.forEach((childRoomId) => markAsRead(mx, childRoomId, hideActivity)); - requestClose(); - }; - - const handleUnpin = () => { - onUnpin?.(room.roomId); - requestClose(); - }; - - const handleCopyLink = () => { - const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId); - const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room); - copyToClipboard(getMatrixToRoom(roomIdOrAlias, viaServers)); - requestClose(); - }; - - const handleInvite = () => { - setInvitePrompt(true); - }; - - const handleRoomSettings = () => { - openSpaceSettings(room.roomId); - requestClose(); - }; - - return ( - - {invitePrompt && room && ( - { - setInvitePrompt(false); - requestClose(); - }} - /> - )} - - } - radii="300" - disabled={!unread} - > - - Mark as Read - - - {onUnpin && ( - } - > - - Unpin - - - )} - - - - } - radii="300" - aria-pressed={invitePrompt} - disabled={!canInvite} - > - - Invite - - - } - radii="300" - > - - Copy Link - - - } - radii="300" - > - - Space Settings - - - - - ); - } -); - -type InstructionType = Instruction['type']; -type FolderDraggable = { - folder: ISidebarFolder; - spaceId?: string; - open?: boolean; -}; -type SidebarDraggable = string | FolderDraggable; - -const useDraggableItem = ( - item: SidebarDraggable, - targetRef: RefObject, - onDragging: (item?: SidebarDraggable) => void, - dragHandleRef?: RefObject -): boolean => { - const [dragging, setDragging] = useState(false); - - useEffect(() => { - const target = targetRef.current; - const dragHandle = dragHandleRef?.current ?? undefined; - - return !target - ? undefined - : draggable({ - element: target, - dragHandle, - getInitialData: () => ({ item }), - onDragStart: () => { - setDragging(true); - onDragging?.(item); - }, - onDrop: () => { - setDragging(false); - onDragging?.(undefined); - }, - }); - }, [targetRef, dragHandleRef, item, onDragging]); - - return dragging; -}; - -const useDropTarget = ( - item: SidebarDraggable, - targetRef: RefObject -): Instruction | undefined => { - const [dropState, setDropState] = useState(); - - useEffect(() => { - const target = targetRef.current; - if (!target) return undefined; - - return dropTargetForElements({ - element: target, - canDrop: ({ source }) => { - const dragItem = source.data.item as SidebarDraggable; - return dragItem !== item; - }, - getData: ({ input, element }) => { - const block: Instruction['type'][] = ['reparent']; - if (typeof item === 'object' && item.spaceId) block.push('make-child'); - - const insData = attachInstruction( - {}, - { - input, - element, - currentLevel: 0, - indentPerLevel: 0, - mode: 'standard', - block, - } - ); - - const instruction: Instruction | null = extractInstruction(insData); - setDropState(instruction ?? undefined); - - return { - item, - instructionType: instruction ? instruction.type : undefined, - }; - }, - onDragLeave: () => setDropState(undefined), - onDrop: () => setDropState(undefined), - }); - }, [item, targetRef]); - - return dropState; -}; - -function useDropTargetInstruction( - item: SidebarDraggable, - targetRef: RefObject, - instructionType: T -): T | undefined { - const [dropState, setDropState] = useState(); - - useEffect(() => { - const target = targetRef.current; - if (!target) return undefined; - - return dropTargetForElements({ - element: target, - canDrop: ({ source }) => { - const dragItem = source.data.item as SidebarDraggable; - return dragItem !== item; - }, - getData: () => { - setDropState(instructionType); - - return { - item, - instructionType, - }; - }, - onDragLeave: () => setDropState(undefined), - onDrop: () => setDropState(undefined), - }); - }, [item, targetRef, instructionType]); - - return dropState; -} - -const useDnDMonitor = ( - scrollRef: RefObject, - onDragging: (dragItem?: SidebarDraggable) => void, - onReorder: ( - draggable: SidebarDraggable, - container: SidebarDraggable, - instruction: InstructionType - ) => void -) => { - useEffect(() => { - const scrollElement = scrollRef.current; - if (!scrollElement) { - throw Error('Scroll element ref not configured'); - } - - return combine( - monitorForElements({ - onDrop: ({ source, location }) => { - onDragging(undefined); - const { dropTargets } = location.current; - if (dropTargets.length === 0) return; - const item = source.data.item as SidebarDraggable; - const containerItem = dropTargets[0].data.item as SidebarDraggable; - const instructionType = dropTargets[0].data.instructionType as - | InstructionType - | undefined; - if (!instructionType) return; - onReorder(item, containerItem, instructionType); - }, - }), - autoScrollForElements({ - element: scrollElement, - }) - ); - }, [scrollRef, onDragging, onReorder]); -}; - -type SpaceTabProps = { - space: Room; - selected: boolean; - onClick: MouseEventHandler; - folder?: ISidebarFolder; - onDragging: (dragItem?: SidebarDraggable) => void; - disabled?: boolean; - onUnpin?: (roomId: string) => void; -}; -function SpaceTab({ - space, - selected, - onClick, - folder, - onDragging, - disabled, - onUnpin, -}: SpaceTabProps) { - const mx = useMatrixClient(); - const useAuthentication = useMediaAuthentication(); - const targetRef = useRef(null); - - const spaceDraggable: SidebarDraggable = useMemo( - () => - folder - ? { - folder, - spaceId: space.roomId, - } - : space.roomId, - [folder, space] - ); - - useDraggableItem(spaceDraggable, targetRef, onDragging); - const dropState = useDropTarget(spaceDraggable, targetRef); - const dropType = dropState?.type; - - const [menuAnchor, setMenuAnchor] = useState(); - - const handleContextMenu: MouseEventHandler = (evt) => { - evt.preventDefault(); - const cords = evt.currentTarget.getBoundingClientRect(); - setMenuAnchor((currentState) => { - if (currentState) return undefined; - return cords; - }); - }; - - return ( - - {(unread) => ( - - - {(triggerRef) => ( - - ( - {nameInitials(space.name, 2)} - )} - /> - - )} - - {unread && ( - 0}> - 0} count={unread.total} /> - - )} - {menuAnchor && ( - setMenuAnchor(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', - isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', - escapeDeactivates: stopPropagation, - }} - > - setMenuAnchor(undefined)} - onUnpin={onUnpin} - /> - - } - /> - )} - - )} - - ); -} - -type OpenedSpaceFolderProps = { - folder: ISidebarFolder; - onClose: MouseEventHandler; - children?: ReactNode; -}; -function OpenedSpaceFolder({ folder, onClose, children }: OpenedSpaceFolderProps) { - const aboveTargetRef = useRef(null); - const belowTargetRef = useRef(null); - - const spaceDraggable: SidebarDraggable = useMemo(() => ({ folder, open: true }), [folder]); - - const orderAbove = useDropTargetInstruction(spaceDraggable, aboveTargetRef, 'reorder-above'); - const orderBelow = useDropTargetInstruction(spaceDraggable, belowTargetRef, 'reorder-below'); - - return ( - - - - - - - - {children} - - - ); -} - -type ClosedSpaceFolderProps = { - folder: ISidebarFolder; - selected: boolean; - onOpen: MouseEventHandler; - onDragging: (dragItem?: SidebarDraggable) => void; - disabled?: boolean; -}; -function ClosedSpaceFolder({ - folder, - selected, - onOpen, - onDragging, - disabled, -}: ClosedSpaceFolderProps) { - const mx = useMatrixClient(); - const useAuthentication = useMediaAuthentication(); - const handlerRef = useRef(null); - - const spaceDraggable: FolderDraggable = useMemo(() => ({ folder }), [folder]); - useDraggableItem(spaceDraggable, handlerRef, onDragging); - const dropState = useDropTarget(spaceDraggable, handlerRef); - const dropType = dropState?.type; - - const tooltipName = - folder.name ?? folder.content.map((i) => mx.getRoom(i)?.name ?? '').join(', ') ?? 'Unnamed'; - - return ( - - {(unread) => ( - - - {(tooltipRef) => ( - - {folder.content.map((sId) => { - const space = mx.getRoom(sId); - if (!space) return null; - - return ( - - ( - - {nameInitials(space.name, 2)} - - )} - /> - - ); - })} - - )} - - {unread && ( - 0}> - 0} count={unread.total} /> - - )} - - )} - - ); -} - -type SpaceTabsProps = { - scrollRef: RefObject; -}; -export function SpaceTabs({ scrollRef }: SpaceTabsProps) { - const navigate = useNavigate(); - const mx = useMatrixClient(); - const screenSize = useScreenSizeContext(); - const roomToParents = useAtomValue(roomToParentsAtom); - const orphanSpaces = useOrphanSpaces(mx, allRoomsAtom, roomToParents); - const [sidebarItems, localEchoSidebarItem] = useSidebarItems(orphanSpaces); - const navToActivePath = useAtomValue(useNavToActivePathAtom()); - const [openedFolder, setOpenedFolder] = useAtom(useOpenedSidebarFolderAtom()); - const [draggingItem, setDraggingItem] = useState(); - - useDnDMonitor( - scrollRef, - setDraggingItem, - useCallback( - (item, containerItem, instructionType) => { - const newItems: SidebarItems = []; - - const matchDest = (sI: TSidebarItem, dI: SidebarDraggable): boolean => { - if (typeof sI === 'string' && typeof dI === 'string') { - return sI === dI; - } - if (typeof sI === 'object' && typeof dI === 'object') { - return sI.id === dI.folder.id; - } - return false; - }; - const itemAsFolderContent = (i: SidebarDraggable): string[] => { - if (typeof i === 'string') { - return [i]; - } - if (i.spaceId) { - return [i.spaceId]; - } - return [...i.folder.content]; - }; - - sidebarItems.forEach((i) => { - const sameFolders = - typeof item === 'object' && - typeof containerItem === 'object' && - item.folder.id === containerItem.folder.id; - - // remove draggable space from current position or folder - if (!sameFolders && matchDest(i, item)) { - if (typeof item === 'object' && item.spaceId) { - const folderContent = item.folder.content.filter((s) => s !== item.spaceId); - if (folderContent.length === 0) { - // remove open state from local storage - setOpenedFolder({ type: 'DELETE', id: item.folder.id }); - return; - } - newItems.push({ - ...item.folder, - content: folderContent, - }); - } - return; - } - if (matchDest(i, containerItem)) { - // we can make child only if - // container item is space or closed folder - if (instructionType === 'make-child') { - const child: string[] = itemAsFolderContent(item); - if (typeof containerItem === 'string') { - const folder: ISidebarFolder = { - id: randomStr(), - content: [containerItem].concat(child), - }; - newItems.push(folder); - return; - } - newItems.push({ - ...containerItem.folder, - content: containerItem.folder.content.concat(child), - }); - return; - } - - // drop inside opened folder - // or reordering inside same folder - if (typeof containerItem === 'object' && containerItem.spaceId) { - const child = itemAsFolderContent(item); - const newContent: string[] = []; - containerItem.folder.content - .filter((sId) => !child.includes(sId)) - .forEach((sId) => { - if (sId === containerItem.spaceId) { - if (instructionType === 'reorder-below') { - newContent.push(sId, ...child); - } - if (instructionType === 'reorder-above') { - newContent.push(...child, sId); - } - return; - } - newContent.push(sId); - }); - const folder = { - ...containerItem.folder, - content: newContent, - }; - - newItems.push(folder); - return; - } - - // drop above or below space or closed/opened folder - if (typeof item === 'string') { - if (instructionType === 'reorder-below') newItems.push(i); - newItems.push(item); - if (instructionType === 'reorder-above') newItems.push(i); - } else if (item.spaceId) { - if (instructionType === 'reorder-above') { - newItems.push(item.spaceId); - } - if (sameFolders && typeof i === 'object') { - // remove from folder if placing around itself - const newI = { ...i, content: i.content.filter((sId) => sId !== item.spaceId) }; - if (newI.content.length > 0) newItems.push(newI); - } else { - newItems.push(i); - } - if (instructionType === 'reorder-below') { - newItems.push(item.spaceId); - } - } else { - if (instructionType === 'reorder-below') newItems.push(i); - newItems.push(item.folder); - if (instructionType === 'reorder-above') newItems.push(i); - } - return; - } - newItems.push(i); - }); - - const newSpacesContent = makeCinnySpacesContent(mx, newItems); - localEchoSidebarItem(parseSidebar(mx, orphanSpaces, newSpacesContent)); - mx.setAccountData(AccountDataEvent.CinnySpaces, newSpacesContent); - }, - [mx, sidebarItems, setOpenedFolder, localEchoSidebarItem, orphanSpaces] - ) - ); - - const selectedSpaceId = useSelectedSpace(); - - const handleSpaceClick: MouseEventHandler = (evt) => { - const target = evt.currentTarget; - const targetSpaceId = target.getAttribute('data-id'); - if (!targetSpaceId) return; - - const spacePath = getSpacePath(getCanonicalAliasOrRoomId(mx, targetSpaceId)); - if (screenSize === ScreenSize.Mobile) { - navigate(spacePath); - return; - } - - const activePath = navToActivePath.get(targetSpaceId); - if (activePath && activePath.pathname.startsWith(spacePath)) { - navigate(joinPathComponent(activePath)); - return; - } - - navigate(getSpaceLobbyPath(getCanonicalAliasOrRoomId(mx, targetSpaceId))); - }; - - const handleFolderToggle: MouseEventHandler = (evt) => { - const target = evt.currentTarget; - const targetFolderId = target.getAttribute('data-id'); - if (!targetFolderId) return; - - setOpenedFolder({ - type: openedFolder.has(targetFolderId) ? 'DELETE' : 'PUT', - id: targetFolderId, - }); - }; - - const handleUnpin = useCallback( - (roomId: string) => { - if (orphanSpaces.includes(roomId)) return; - const newItems = sidebarItemWithout(sidebarItems, roomId); - - const newSpacesContent = makeCinnySpacesContent(mx, newItems); - localEchoSidebarItem(parseSidebar(mx, orphanSpaces, newSpacesContent)); - mx.setAccountData(AccountDataEvent.CinnySpaces, newSpacesContent); - }, - [mx, sidebarItems, orphanSpaces, localEchoSidebarItem] - ); - - if (sidebarItems.length === 0) return null; - return ( - <> - - - {sidebarItems.map((item) => { - if (typeof item === 'object') { - if (openedFolder.has(item.id)) { - return ( - - {item.content.map((sId) => { - const space = mx.getRoom(sId); - if (!space) return null; - return ( - - ); - })} - - ); - } - - return ( - - ); - } - - const space = mx.getRoom(item); - if (!space) return null; - - return ( - - ); - })} - - - ); -} diff --git a/src/app/pages/client/sidebar/index.ts b/src/app/pages/client/sidebar/index.ts index d44cfaa2..3cf570d9 100644 --- a/src/app/pages/client/sidebar/index.ts +++ b/src/app/pages/client/sidebar/index.ts @@ -1,8 +1,3 @@ -export * from './HomeTab'; -export * from './DirectTab'; -export * from './SpaceTabs'; export * from './InboxTab'; -export * from './ExploreTab'; export * from './SettingsTab'; export * from './UnverifiedTab'; -export * from './SearchTab'; diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx deleted file mode 100644 index 2a7c6199..00000000 --- a/src/app/pages/client/space/Space.tsx +++ /dev/null @@ -1,540 +0,0 @@ -import React, { - MouseEventHandler, - forwardRef, - useCallback, - useMemo, - useRef, - useState, -} from 'react'; -import { useAtom, useAtomValue } from 'jotai'; -import { - Avatar, - Box, - Button, - Icon, - IconButton, - Icons, - Line, - Menu, - MenuItem, - PopOut, - RectCords, - Spinner, - Text, - color, - config, - toRem, -} from 'folds'; -import { useVirtualizer } from '@tanstack/react-virtual'; -import { JoinRule, Room } from 'matrix-js-sdk'; -import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types'; -import FocusTrap from 'focus-trap-react'; -import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { mDirectAtom } from '../../../state/mDirectList'; -import { - NavCategory, - NavCategoryHeader, - NavItem, - NavItemContent, - NavLink, -} from '../../../components/nav'; -import { getSpaceLobbyPath, getSpaceRoomPath, getSpaceSearchPath } from '../../pathUtils'; -import { getCanonicalAliasOrRoomId, isRoomAlias } from '../../../utils/matrix'; -import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom'; -import { - useSpaceLobbySelected, - useSpaceSearchSelected, -} from '../../../hooks/router/useSelectedSpace'; -import { useSpace } from '../../../hooks/useSpace'; -import { VirtualTile } from '../../../components/virtualizer'; -import { RoomNavCategoryButton, RoomNavItem } from '../../../features/room-nav'; -import { makeNavCategoryId } from '../../../state/closedNavCategories'; -import { roomToUnreadAtom } from '../../../state/room/roomToUnread'; -import { useCategoryHandler } from '../../../hooks/useCategoryHandler'; -import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper'; -import { useRoomName } from '../../../hooks/useRoomMeta'; -import { useSpaceJoinedHierarchy } from '../../../hooks/useSpaceHierarchy'; -import { allRoomsAtom } from '../../../state/room-list/roomList'; -import { PageNav, PageNavContent, PageNavHeader } from '../../../components/page'; -import { usePowerLevels } from '../../../hooks/usePowerLevels'; -import { useRecursiveChildScopeFactory, useSpaceChildren } from '../../../state/hooks/roomList'; -import { roomToParentsAtom } from '../../../state/room/roomToParents'; -import { markAsRead } from '../../../utils/notifications'; -import { useRoomsUnread } from '../../../state/hooks/unread'; -import { UseStateProvider } from '../../../components/UseStateProvider'; -import { LeaveSpacePrompt } from '../../../components/leave-space-prompt'; -import { copyToClipboard } from '../../../utils/dom'; -import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories'; -import { useStateEvent } from '../../../hooks/useStateEvent'; -import { Membership, StateEvent } from '../../../../types/matrix/room'; -import { stopPropagation } from '../../../utils/keyboard'; -import { getMatrixToRoom } from '../../../plugins/matrix-to'; -import { getViaServers } from '../../../plugins/via-servers'; -import { useSetting } from '../../../state/hooks/settings'; -import { settingsAtom } from '../../../state/settings'; -import { - getRoomNotificationMode, - useRoomsNotificationPreferencesContext, -} from '../../../hooks/useRoomsNotificationPreferences'; -import { useOpenSpaceSettings } from '../../../state/hooks/spaceSettings'; -import { useRoomNavigate } from '../../../hooks/useRoomNavigate'; -import { useRoomCreators } from '../../../hooks/useRoomCreators'; -import { useRoomPermissions } from '../../../hooks/useRoomPermissions'; -import { ContainerColor } from '../../../styles/ContainerColor.css'; -import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; -import { BreakWord } from '../../../styles/Text.css'; -import { InviteUserPrompt } from '../../../components/invite-user-prompt'; -import { useCallEmbed } from '../../../hooks/useCallEmbed'; - -type SpaceMenuProps = { - room: Room; - requestClose: () => void; -}; -const SpaceMenu = forwardRef(({ room, requestClose }, ref) => { - const mx = useMatrixClient(); - const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); - const [developerTools] = useSetting(settingsAtom, 'developerTools'); - const roomToParents = useAtomValue(roomToParentsAtom); - const powerLevels = usePowerLevels(room); - const creators = useRoomCreators(room); - - const permissions = useRoomPermissions(creators, powerLevels); - const canInvite = permissions.action('invite', mx.getSafeUserId()); - const openSpaceSettings = useOpenSpaceSettings(); - const { navigateRoom } = useRoomNavigate(); - - const [invitePrompt, setInvitePrompt] = useState(false); - - const allChild = useSpaceChildren( - allRoomsAtom, - room.roomId, - useRecursiveChildScopeFactory(mx, roomToParents) - ); - const unread = useRoomsUnread(allChild, roomToUnreadAtom); - - const handleMarkAsRead = () => { - allChild.forEach((childRoomId) => markAsRead(mx, childRoomId, hideActivity)); - requestClose(); - }; - - const handleCopyLink = () => { - const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId); - const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room); - copyToClipboard(getMatrixToRoom(roomIdOrAlias, viaServers)); - requestClose(); - }; - - const handleInvite = () => { - setInvitePrompt(true); - }; - - const handleRoomSettings = () => { - openSpaceSettings(room.roomId); - requestClose(); - }; - - const handleOpenTimeline = () => { - navigateRoom(room.roomId); - requestClose(); - }; - - return ( - - - {invitePrompt && room && ( - { - setInvitePrompt(false); - requestClose(); - }} - /> - )} - } - radii="300" - disabled={!unread} - > - - Mark as Read - - - - - - } - radii="300" - aria-pressed={invitePrompt} - disabled={!canInvite} - > - - Invite - - - } - radii="300" - > - - Copy Link - - - } - radii="300" - > - - Space Settings - - - {developerTools && ( - } - radii="300" - > - - Event Timeline - - - )} - - - - - {(promptLeave, setPromptLeave) => ( - <> - setPromptLeave(true)} - variant="Critical" - fill="None" - size="300" - after={} - radii="300" - aria-pressed={promptLeave} - > - - Leave Space - - - {promptLeave && ( - setPromptLeave(false)} - /> - )} - - )} - - - - ); -}); - -function SpaceHeader() { - const space = useSpace(); - const spaceName = useRoomName(space); - const [menuAnchor, setMenuAnchor] = useState(); - - const joinRules = useStateEvent( - space, - StateEvent.RoomJoinRules - )?.getContent(); - - const handleOpenMenu: MouseEventHandler = (evt) => { - const cords = evt.currentTarget.getBoundingClientRect(); - setMenuAnchor((currentState) => { - if (currentState) return undefined; - return cords; - }); - }; - - return ( - <> - - - - - {spaceName} - - {joinRules?.join_rule !== JoinRule.Public && } - - - - - - - - - {menuAnchor && ( - setMenuAnchor(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', - isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', - escapeDeactivates: stopPropagation, - }} - > - setMenuAnchor(undefined)} /> - - } - /> - )} - - ); -} - -type SpaceTombstoneProps = { roomId: string; replacementRoomId: string }; -export function SpaceTombstone({ roomId, replacementRoomId }: SpaceTombstoneProps) { - const mx = useMatrixClient(); - const { navigateSpace } = useRoomNavigate(); - - const [joinState, handleJoin] = useAsyncCallback( - useCallback(() => { - const currentRoom = mx.getRoom(roomId); - const via = currentRoom ? getViaServers(currentRoom) : []; - return mx.joinRoom(replacementRoomId, { - viaServers: via, - }); - }, [mx, roomId, replacementRoomId]) - ); - const replacementRoom = mx.getRoom(replacementRoomId); - - const handleOpen = () => { - if (replacementRoom) navigateSpace(replacementRoom.roomId); - if (joinState.status === AsyncStatus.Success) navigateSpace(joinState.data.roomId); - }; - - return ( - - - Space Upgraded - This space has been replaced and is no longer active. - {joinState.status === AsyncStatus.Error && ( - - {(joinState.error as any)?.message ?? 'Failed to join replacement space!'} - - )} - - - {replacementRoom?.getMyMembership() === Membership.Join || - joinState.status === AsyncStatus.Success ? ( - - ) : ( - - )} - - - ); -} - -export function Space() { - const mx = useMatrixClient(); - const space = useSpace(); - useNavToActivePathMapper(space.roomId); - const spaceIdOrAlias = getCanonicalAliasOrRoomId(mx, space.roomId); - const scrollRef = useRef(null); - const mDirects = useAtomValue(mDirectAtom); - const roomToUnread = useAtomValue(roomToUnreadAtom); - const allRooms = useAtomValue(allRoomsAtom); - const allJoinedRooms = useMemo(() => new Set(allRooms), [allRooms]); - const notificationPreferences = useRoomsNotificationPreferencesContext(); - - const tombstoneEvent = useStateEvent(space, StateEvent.RoomTombstone); - const selectedRoomId = useSelectedRoom(); - const lobbySelected = useSpaceLobbySelected(spaceIdOrAlias); - const searchSelected = useSpaceSearchSelected(spaceIdOrAlias); - const callEmbed = useCallEmbed(); - - const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom()); - - const getRoom = useCallback( - (rId: string): Room | undefined => { - if (allJoinedRooms.has(rId)) { - return mx.getRoom(rId) ?? undefined; - } - return undefined; - }, - [mx, allJoinedRooms] - ); - - const hierarchy = useSpaceJoinedHierarchy( - space.roomId, - getRoom, - useCallback( - (parentId, roomId) => { - if (!closedCategories.has(makeNavCategoryId(space.roomId, parentId))) { - return false; - } - const showRoomAnyway = - roomToUnread.has(roomId) || roomId === selectedRoomId || callEmbed?.roomId === roomId; - return !showRoomAnyway; - }, - [space.roomId, closedCategories, roomToUnread, selectedRoomId, callEmbed] - ), - useCallback( - (sId) => closedCategories.has(makeNavCategoryId(space.roomId, sId)), - [closedCategories, space.roomId] - ) - ); - - const virtualizer = useVirtualizer({ - count: hierarchy.length, - getScrollElement: () => scrollRef.current, - estimateSize: () => 0, - overscan: 10, - }); - - const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) => - closedCategories.has(categoryId) - ); - - const getToLink = (roomId: string) => - getSpaceRoomPath(spaceIdOrAlias, getCanonicalAliasOrRoomId(mx, roomId)); - - return ( - - - - - {tombstoneEvent && ( - - )} - - - - - - - - - - - Lobby - - - - - - - - - - - - - - - - Message Search - - - - - - - - - {virtualizer.getVirtualItems().map((vItem) => { - const { roomId } = hierarchy[vItem.index] ?? {}; - const room = mx.getRoom(roomId); - if (!room) return null; - - if (room.isSpaceRoom()) { - const categoryId = makeNavCategoryId(space.roomId, roomId); - - return ( - -
- - - {roomId === space.roomId ? 'Rooms' : room?.name} - - -
-
- ); - } - - return ( - - - - ); - })} -
-
-
-
- ); -} diff --git a/src/app/pages/client/space/index.ts b/src/app/pages/client/space/index.ts index 0e32d93b..477d60ff 100644 --- a/src/app/pages/client/space/index.ts +++ b/src/app/pages/client/space/index.ts @@ -1,4 +1,3 @@ export * from './SpaceProvider'; -export * from './Space'; export * from './Search'; export * from './RoomProvider'; diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 9fbad4ff..d4deb39c 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -47,11 +47,17 @@ export interface Settings { dateFormatString: string; developerTools: boolean; + + /** Soundscape flag: default off, and muted by default even when on. */ + soundscapeEnabled: boolean; + soundscapeMuted: boolean; + soundscapeVolume: number; + soundscapeVoiceCap: number; } const defaultSettings: Settings = { - themeId: undefined, - useSystemTheme: true, + themeId: 'gamecult-theme', + useSystemTheme: false, lightThemeId: undefined, darkThemeId: undefined, monochromeMode: false, @@ -61,10 +67,10 @@ const defaultSettings: Settings = { pageZoom: 100, hideActivity: false, - isPeopleDrawer: true, + isPeopleDrawer: false, memberSortFilterIndex: 0, enterForNewline: false, - messageLayout: 0, + messageLayout: 2, messageSpacing: '400', hideMembershipEvents: false, hideNickAvatarEvents: true, @@ -81,6 +87,11 @@ const defaultSettings: Settings = { dateFormatString: 'D MMM YYYY', developerTools: false, + + soundscapeEnabled: false, + soundscapeMuted: true, + soundscapeVolume: 0.5, + soundscapeVoiceCap: 4, }; export const getSettings = () => { diff --git a/src/app/utils/mDirect.test.ts b/src/app/utils/mDirect.test.ts new file mode 100644 index 00000000..1deaf37e --- /dev/null +++ b/src/app/utils/mDirect.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import { addRoomIdToMDirect, removeRoomIdFromMDirect } from './matrix'; + +type Content = Record; + +/** A client whose account-data write takes a while and lands only when it completes. */ +const fakeClient = (initial?: Content) => { + let direct: Content | undefined = initial; + const writes: Content[] = []; + const mx = { + getAccountData: () => (direct ? { getContent: () => direct } : undefined), + setAccountData: async (_type: string, content: Content) => { + await new Promise((r) => { + setTimeout(r, 5); + }); + writes.push(structuredClone(content)); + direct = structuredClone(content); + }, + }; + return { mx: mx as never, writes, current: () => direct }; +}; + +describe('m.direct edits', () => { + it('moves a room between users and removes it, one write at a time', async () => { + const c = fakeClient({ '@a:test': ['!r:test'] }); + await Promise.all([ + addRoomIdToMDirect(c.mx, '!r:test', '@b:test'), + removeRoomIdFromMDirect(c.mx, '!gone:test'), + ]); + expect(c.current()).toEqual({ '@a:test': [], '@b:test': ['!r:test'] }); + await removeRoomIdFromMDirect(c.mx, '!r:test'); + expect(c.current()).toEqual({ '@a:test': [], '@b:test': [] }); + }); + + it('serializes overlapping edits so each builds on the content the last one wrote', async () => { + const c = fakeClient(); + await Promise.all([ + addRoomIdToMDirect(c.mx, '!dm2:test', '@friend:test'), + addRoomIdToMDirect(c.mx, '!dm3:test', '@friend:test'), + addRoomIdToMDirect(c.mx, '!dm4:test', '@other:test'), + ]); + expect(c.current()).toEqual({ + '@friend:test': ['!dm2:test', '!dm3:test'], + '@other:test': ['!dm4:test'], + }); + expect(c.writes).toHaveLength(3); + }); + + it('keeps going after a failed write', async () => { + const c = fakeClient(); + const raw = c.mx as unknown as { setAccountData: (t: string, x: Content) => Promise }; + const original = raw.setAccountData; + raw.setAccountData = async () => { + raw.setAccountData = original; + throw new Error('offline'); + }; + await expect(addRoomIdToMDirect(c.mx, '!a:test', '@f:test')).rejects.toThrow('offline'); + await addRoomIdToMDirect(c.mx, '!a:test', '@f:test'); + expect(c.current()).toEqual({ '@f:test': ['!a:test'] }); + }); +}); diff --git a/src/app/utils/matrix.ts b/src/app/utils/matrix.ts index 4c86c4e2..2859dbc3 100644 --- a/src/app/utils/matrix.ts +++ b/src/app/utils/matrix.ts @@ -225,57 +225,59 @@ export const guessDmRoomUserId = (room: Room, myUserId: string): string => { return member1?.userId ?? myUserId; }; -export const addRoomIdToMDirect = async ( +type MDirectContent = Record; + +const mDirectWrites = new WeakMap>(); + +/** + * The only writer of m.direct. m.direct is one account-data event that every + * edit replaces whole, so edits run one at a time, each reading the content the + * previous one wrote. `edit` returns the new content, or undefined for no write. + */ +export const updateMDirect = ( + mx: MatrixClient, + edit: (current: MDirectContent) => MDirectContent | undefined +): Promise => { + const run = (mDirectWrites.get(mx) ?? Promise.resolve()).then(async () => { + const event = mx.getAccountData(AccountDataEvent.Direct as any); + const next = edit(event ? structuredClone(event.getContent()) : {}); + if (next) await mx.setAccountData(AccountDataEvent.Direct as any, next as any); + }); + mDirectWrites.set( + mx, + run.catch(() => undefined) + ); + return run; +}; + +/** A room can be a DM for one person only: it leaves every other user's list. */ +export const addRoomIdToMDirect = ( mx: MatrixClient, roomId: string, userId: string -): Promise => { - const mDirectsEvent = mx.getAccountData(AccountDataEvent.Direct as any); - let userIdToRoomIds: Record = {}; - - if (typeof mDirectsEvent !== 'undefined') - userIdToRoomIds = structuredClone(mDirectsEvent.getContent()); - - // remove it from the lists of any others users - // (it can only be a DM room for one person) - Object.keys(userIdToRoomIds).forEach((targetUserId) => { - const roomIds = userIdToRoomIds[targetUserId]; - - if (targetUserId !== userId) { - const indexOfRoomId = roomIds.indexOf(roomId); - if (indexOfRoomId > -1) { - roomIds.splice(indexOfRoomId, 1); +): Promise => + updateMDirect(mx, (current) => { + const next = structuredClone(current); + Object.keys(next).forEach((targetUserId) => { + if (targetUserId !== userId) { + next[targetUserId] = next[targetUserId].filter((id) => id !== roomId); } - } + }); + const roomIds = next[userId] ?? []; + if (!roomIds.includes(roomId)) roomIds.push(roomId); + next[userId] = roomIds; + return next; }); - const roomIds = userIdToRoomIds[userId] || []; - if (roomIds.indexOf(roomId) === -1) { - roomIds.push(roomId); - } - userIdToRoomIds[userId] = roomIds; - - await mx.setAccountData(AccountDataEvent.Direct as any, userIdToRoomIds as any); -}; - -export const removeRoomIdFromMDirect = async (mx: MatrixClient, roomId: string): Promise => { - const mDirectsEvent = mx.getAccountData(AccountDataEvent.Direct as any); - let userIdToRoomIds: Record = {}; - - if (typeof mDirectsEvent !== 'undefined') - userIdToRoomIds = structuredClone(mDirectsEvent.getContent()); - - Object.keys(userIdToRoomIds).forEach((targetUserId) => { - const roomIds = userIdToRoomIds[targetUserId]; - const indexOfRoomId = roomIds.indexOf(roomId); - if (indexOfRoomId > -1) { - roomIds.splice(indexOfRoomId, 1); - } +export const removeRoomIdFromMDirect = (mx: MatrixClient, roomId: string): Promise => + updateMDirect(mx, (current) => { + const next: MDirectContent = {}; + Object.keys(current).forEach((userId) => { + next[userId] = current[userId].filter((id) => id !== roomId); + }); + return next; }); - await mx.setAccountData(AccountDataEvent.Direct as any, userIdToRoomIds as any); -}; - export const mxcUrlToHttp = ( mx: MatrixClient, mxcUrl: string, diff --git a/src/colors.css.ts b/src/colors.css.ts index 268cbf78..9c03c2cb 100644 --- a/src/colors.css.ts +++ b/src/colors.css.ts @@ -236,3 +236,95 @@ export const butterTheme = createTheme(color, { OnContainer: '#F2EED3', }, }); + +// GameCult identity (gamecult-site quartz.config.ts): single dark theme on #07111a, +// orange #ff8a2a accent, sky #59b7ff links. The brand defines no status colours, so +// Success/Warning/Critical are invented here and kept distinct from the orange accent. +export const gamecultTheme = createTheme(color, { + Background: { + Container: '#07111a', + ContainerHover: '#0d1a26', + ContainerActive: '#122130', + ContainerLine: '#16212c', + OnContainer: '#b7c7d9', + }, + Surface: { + Container: '#09141f', + ContainerHover: '#0f1c29', + ContainerActive: '#152433', + ContainerLine: '#16212c', + OnContainer: '#b7c7d9', + }, + SurfaceVariant: { + Container: '#0d1a26', + ContainerHover: '#122130', + ContainerActive: '#16273a', + ContainerLine: '#1b2a38', + OnContainer: '#b7c7d9', + }, + Primary: { + Main: '#ff8a2a', + MainHover: '#ff9640', + MainActive: '#ffa155', + MainLine: '#ffab66', + OnMain: '#2a1300', + Container: '#2b1a0e', + ContainerHover: '#35200f', + ContainerActive: '#402611', + ContainerLine: '#4d2d13', + OnContainer: '#ffd3ab', + }, + Secondary: { + Main: '#eef5ff', + MainHover: '#dbe7f6', + MainActive: '#cddced', + MainLine: '#bfd0e4', + OnMain: '#07111a', + Container: '#16212c', + ContainerHover: '#1c2a38', + ContainerActive: '#233342', + ContainerLine: '#2a3b4c', + OnContainer: '#eef5ff', + }, + Success: { + Main: '#5fd0a4', + MainHover: '#72d8b0', + MainActive: '#84deba', + MainLine: '#93e3c2', + OnMain: '#042014', + Container: '#0c2a21', + ContainerHover: '#10342a', + ContainerActive: '#143e32', + ContainerLine: '#19493a', + OnContainer: '#c4f1de', + }, + Warning: { + Main: '#f2d04b', + MainHover: '#f5d963', + MainActive: '#f7e078', + MainLine: '#f9e68b', + OnMain: '#2a2100', + Container: '#2a2410', + ContainerHover: '#352d13', + ContainerActive: '#403616', + ContainerLine: '#4d411a', + OnContainer: '#f8ebb0', + }, + Critical: { + Main: '#ff7a90', + MainHover: '#ff8fa1', + MainActive: '#ffa0af', + MainLine: '#ffb0bc', + OnMain: '#3a0710', + Container: '#2e1118', + ContainerHover: '#391520', + ContainerActive: '#441a27', + ContainerLine: '#51202e', + OnContainer: '#ffd0d8', + }, + Other: { + FocusRing: 'rgba(89, 183, 255, 0.55)', + Shadow: 'rgba(0, 0, 0, 0.6)', + Overlay: 'rgba(3, 7, 13, 0.8)', + }, +}); diff --git a/src/gamecult.css.ts b/src/gamecult.css.ts new file mode 100644 index 00000000..4aaf3e76 --- /dev/null +++ b/src/gamecult.css.ts @@ -0,0 +1,20 @@ +import { globalStyle } from '@vanilla-extract/css'; +import { config } from 'folds'; + +// Softer, rounder shape language and an airier line height. Scoped to the GameCult +// theme class so the stock themes keep folds' defaults. +const scope = 'body.gamecult-theme'; + +globalStyle(scope, { + vars: { + [config.radii.R300]: '10px', + [config.radii.R400]: '16px', + [config.radii.R500]: '22px', + [config.lineHeight.T300]: '1.6', + [config.lineHeight.T400]: '1.7', + [config.lineHeight.T500]: '1.7', + [config.lineHeight.B400]: '1.6', + [config.lineHeight.B500]: '1.6', + [config.lineHeight.D400]: '1.7', + }, +}); diff --git a/src/index.css b/src/index.css index d4696367..f751b94d 100644 --- a/src/index.css +++ b/src/index.css @@ -135,3 +135,67 @@ li p::before { content: ''; display: inline-block; } + +/* GameCult identity: Ubuntu 300 prose, Montserrat thin titles, IBM Plex Mono labels. */ +.gamecult-theme { + --tc-link: #59b7ff; + + --mx-uc-1: hsl(208, 100%, 72%); + --mx-uc-2: hsl(280, 100%, 78%); + --mx-uc-3: hsl(163, 70%, 65%); + --mx-uc-4: hsl(343, 100%, 76%); + --mx-uc-5: hsl(28, 100%, 66%); + --mx-uc-6: hsl(181, 70%, 62%); + --mx-uc-7: hsl(243, 100%, 80%); + --mx-uc-8: hsl(94, 70%, 70%); + + --font-secondary: 'Ubuntu', var(--font-emoji), sans-serif; + --font-title: 'Montserrat', 'Ubuntu', sans-serif; + --font-mono: 'IBM Plex Mono', monospace; + --gamecult-rule: rgba(148, 163, 184, 0.22); + --gamecult-wash: 0.5; + + background-color: #07111a; + background-image: radial-gradient( + circle at 78% 14%, + rgb(255 138 42 / calc(0.18 * var(--gamecult-wash))), + transparent 18% + ), + radial-gradient( + circle at 18% 10%, + rgb(109 96 255 / calc(0.16 * var(--gamecult-wash))), + transparent 24% + ), + radial-gradient( + circle at 52% 0%, + rgb(89 183 255 / calc(0.14 * var(--gamecult-wash))), + transparent 28% + ), + linear-gradient(180deg, #03070d 0%, #07111a 44%, #09141f 100%); + background-attachment: fixed; + color-scheme: dark; + font-weight: 300; +} + +.gamecult-theme b, +.gamecult-theme strong { + font-weight: 500; +} + +.gamecult-theme h1, +.gamecult-theme h2, +.gamecult-theme h3 { + font-family: var(--font-title); + font-weight: 300; +} + +.gamecult-theme code, +.gamecult-theme pre { + font-family: var(--font-mono); +} + +/* Quiet secondary metadata: timestamps, edited marks and the like sit back. */ +.gamecult-theme time { + opacity: 0.55; + font-size: 0.78em; +} diff --git a/src/index.tsx b/src/index.tsx index 6cc0ca38..672ac5c0 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -3,6 +3,13 @@ import React from 'react'; import { createRoot } from 'react-dom/client'; import { enableMapSet } from 'immer'; import '@fontsource/inter/variable.css'; +import '@fontsource/ubuntu/300.css'; +import '@fontsource/ubuntu/400.css'; +import '@fontsource/ubuntu/500.css'; +import '@fontsource/ubuntu/700.css'; +import '@fontsource/montserrat/200.css'; +import '@fontsource/montserrat/300.css'; +import '@fontsource/ibm-plex-mono/400.css'; import 'folds/dist/style.css'; import { configClass, varsClass } from 'folds'; diff --git a/src/types/matrix/accountData.ts b/src/types/matrix/accountData.ts index 98715996..36c1e21b 100644 --- a/src/types/matrix/accountData.ts +++ b/src/types/matrix/accountData.ts @@ -3,8 +3,6 @@ export enum AccountDataEvent { Direct = 'm.direct', IgnoredUserList = 'm.ignored_user_list', - CinnySpaces = 'in.cinny.spaces', - ElementRecentEmoji = 'io.element.recent_emoji', PoniesUserEmotes = 'im.ponies.user_emotes',