Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 14d9fa6246 | |||
| 68f10ce414 | |||
| 7e632e8605 | |||
|
|
293885089a | ||
|
|
7e05866437 | ||
|
|
50ffd90560 | ||
|
|
12469671c9 | ||
|
|
b055b2a5af | ||
|
|
87c9ca6030 | ||
|
|
9e12f6cbe4 | ||
| 95e24aadb5 | |||
|
|
5eb0a63caa | ||
|
|
5e79a1853a | ||
|
|
190e2e724b | ||
|
|
f9cf99faff | ||
|
|
fff46492ca | ||
| 686f72bcf1 | |||
|
|
cc99a3439d | ||
|
|
1f4341dab6 | ||
|
|
c4c00d7127 | ||
|
|
0ec34ff19f | ||
|
|
8c7113bcaf | ||
|
|
093230a2c1 | ||
|
|
7a498584e6 | ||
|
|
b2d6d32cc4 | ||
|
|
b33d8de956 | ||
|
|
a4da78edc5 | ||
|
|
9b66b2b250 | ||
|
|
6950da865e | ||
|
|
8f1bb54b4b | ||
| e377d9f722 | |||
|
|
b20b958762 | ||
|
|
00ec686b1b | ||
|
|
873c0e30a2 | ||
|
|
7453a0bd32 | ||
|
|
2c04bbefa3 | ||
|
|
cdfd43fe9f | ||
|
|
f6244a4781 | ||
|
|
719f062ef1 | ||
|
|
44395328e9 | ||
|
|
e59cc93a2a | ||
|
|
2827699439 | ||
|
|
3d4fb161c3 | ||
|
|
c91d0339b5 | ||
|
|
bfb753e3e5 | ||
|
|
eeb9de8f40 | ||
|
|
98d74d564d | ||
|
|
d7afdc5208 | ||
|
|
2e179a7768 | ||
|
|
44774a3ba1 | ||
|
|
0091e5e90d | ||
|
|
a058d2a2c6 | ||
|
|
d07c7f2327 | ||
|
|
11f0a4d325 | ||
|
|
1cb18a2795 | ||
|
|
6eae929cd9 | ||
|
|
1dfe38a33d | ||
|
|
0b7ded9c99 | ||
|
|
41527f6ba3 | ||
|
|
e8efd3ce76 | ||
|
|
067cb10a90 | ||
|
|
c848626caa | ||
|
|
4d0ee78974 | ||
|
|
f810a83a28 | ||
|
|
e90b70e966 | ||
|
|
f2e3223c5f | ||
|
|
2924a3f4f3 | ||
|
|
841784ea64 | ||
|
|
dd88424e5d | ||
|
|
b8e696c81b | ||
|
|
a588ab82b3 |
|
|
@ -2,4 +2,5 @@ dist
|
||||||
node_modules
|
node_modules
|
||||||
package.json
|
package.json
|
||||||
package-lock.json
|
package-lock.json
|
||||||
LICENSE
|
LICENSE
|
||||||
|
harness
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
export default {
|
export default {
|
||||||
base: '/',
|
base: process.env.CINNY_BASE ?? '/',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
16
config.json
|
|
@ -5,17 +5,7 @@
|
||||||
|
|
||||||
"featuredCommunities": {
|
"featuredCommunities": {
|
||||||
"openAsDefault": false,
|
"openAsDefault": false,
|
||||||
"spaces": [
|
"spaces": ["#gamecult:gamecult.org"],
|
||||||
"#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"
|
|
||||||
],
|
|
||||||
"rooms": [
|
"rooms": [
|
||||||
"#tuwunel:grin.hu",
|
"#tuwunel:grin.hu",
|
||||||
"#freesoftware:matrix.org",
|
"#freesoftware:matrix.org",
|
||||||
|
|
@ -27,5 +17,9 @@
|
||||||
"hashRouter": {
|
"hashRouter": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"basename": "/"
|
"basename": "/"
|
||||||
|
},
|
||||||
|
|
||||||
|
"backgroundScene": {
|
||||||
|
"tier": "off"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
352
docs/gamecult/ratatoskr-target.md
Normal file
|
|
@ -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: <mxid>}`. 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
|
||||||
|
`#<repo lowercased>` (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 | `<instance>:stewardship:<repo>.n<seq>` 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/<mxid-localpart>/<motif>.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.
|
||||||
46
harness/estate/check.cjs
Normal file
|
|
@ -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);
|
||||||
72
harness/estate/drive-failures.cjs
Normal file
|
|
@ -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); });
|
||||||
42
harness/estate/drive-routes.cjs
Normal file
|
|
@ -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); });
|
||||||
48
harness/estate/drive.cjs
Normal file
|
|
@ -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; });
|
||||||
127
harness/estate/fakehs.cjs
Normal file
|
|
@ -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('<html>504 Gateway Time-out</html>'); }
|
||||||
|
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);
|
||||||
35
harness/estate/run.sh
Normal file
|
|
@ -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" ]
|
||||||
21
harness/scene/README.md
Normal file
|
|
@ -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 <outdir> # esbuild bundle of entry.ts plus an index.html
|
||||||
|
node drive.cjs <outdir> compile # compile and link each stage, JSON report
|
||||||
|
node drive.cjs <outdir> shots <pngdir> # rest, mid pull-back and pulled-back screenshots
|
||||||
|
node drive.cjs <outdir> measure # GPU time per pass by timer query, two viewports, two levels
|
||||||
|
node drive.cjs <outdir> variants # the same with source patches (no erosion, no tree, value-only fbm)
|
||||||
|
node drive.cjs <outdir> trace # mean trace steps per pixel and pass ms at three camera poses, PREPASS=0,1 arms
|
||||||
|
node drive.cjs <outdir> diff <pngdir> # one frozen frame with and without the prepass, pixel diff and both PNGs
|
||||||
|
node drive.cjs <outdir> 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.
|
||||||
40
harness/scene/bundle.cjs
Normal file
|
|
@ -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 <outdir> [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'),
|
||||||
|
'<!doctype html><meta charset=utf-8><title>Ratatoskr scene: trace prepass</title><body style="margin:0;background:#000"><canvas id=c style="width:100vw;height:100vh;display:block"></canvas>' +
|
||||||
|
(demo
|
||||||
|
? '<div style="position:fixed;left:8px;top:8px;padding:6px 8px;background:#000a;color:#ddd;font:12px monospace"><label><input id=prepass type=checkbox checked> cone prepass</label> <label><input id=half type=checkbox> half-float start</label> <label>pull-back <input id=pull type=range min=0 max=1 step=0.05 value=0.6></label><pre id=out style="margin:4px 0 0;white-space:pre-wrap"></pre><details id=dev><summary>device</summary><pre id=info style="margin:4px 0 0;white-space:pre-wrap;max-height:50vh;overflow:auto"></pre></details></div>'
|
||||||
|
: '') +
|
||||||
|
'<script src=bundle.js></script>'
|
||||||
|
);
|
||||||
|
});
|
||||||
102
harness/scene/demo.ts
Normal file
|
|
@ -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<SceneUnavailable, string> = {
|
||||||
|
'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();
|
||||||
309
harness/scene/drive.cjs
Normal file
|
|
@ -0,0 +1,309 @@
|
||||||
|
// Drives the scene in headless Chromium (software GL unless the host has a GPU).
|
||||||
|
// usage: node drive.cjs <bundledir> <mode> [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);
|
||||||
|
});
|
||||||
87
harness/scene/entry.ts
Normal file
|
|
@ -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<string, unknown>).__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<string, unknown>).__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 };
|
||||||
|
};
|
||||||
20
index.html
|
|
@ -4,21 +4,17 @@
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" />
|
||||||
<title>Cinny</title>
|
<title>Ratatoskr</title>
|
||||||
<meta name="name" content="Cinny" />
|
<meta name="name" content="Ratatoskr" />
|
||||||
<meta name="author" content="Ajay Bura" />
|
<meta name="author" content="GameCult" />
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="A Matrix client where you can enjoy the conversation using simple, elegant and secure interface protected by e2ee with the power of open source."
|
content="A Matrix client where you can enjoy the conversation using simple, elegant and secure interface protected by e2ee with the power of open source."
|
||||||
/>
|
/>
|
||||||
<meta
|
<meta name="keywords" content="ratatoskr, gamecult, matrix, matrix client, matrix.org" />
|
||||||
name="keywords"
|
|
||||||
content="cinny, cinnyapp, cinnychat, matrix, matrix client, matrix.org, element"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<meta property="og:title" content="Cinny" />
|
<meta property="og:title" content="Ratatoskr" />
|
||||||
<meta property="og:url" content="https://cinny.in" />
|
<meta property="og:url" content="https://gamecult.org" />
|
||||||
<meta property="og:image" content="https://cinny.in/assets/favicon-48x48.png" />
|
|
||||||
<meta
|
<meta
|
||||||
property="og:description"
|
property="og:description"
|
||||||
content="A Matrix client where you can enjoy the conversation using simple, elegant and secure interface protected by e2ee with the power of open source."
|
content="A Matrix client where you can enjoy the conversation using simple, elegant and secure interface protected by e2ee with the power of open source."
|
||||||
|
|
@ -29,8 +25,8 @@
|
||||||
|
|
||||||
<link rel="manifest" href="/manifest.json" />
|
<link rel="manifest" href="/manifest.json" />
|
||||||
<meta name="mobile-web-app-capable" content="yes" />
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
<meta name="application-name" content="Cinny" />
|
<meta name="application-name" content="Ratatoskr" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Cinny" />
|
<meta name="apple-mobile-web-app-title" content="Ratatoskr" />
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
|
||||||
|
|
|
||||||
686
package-lock.json
generated
|
|
@ -12,7 +12,10 @@
|
||||||
"@atlaskit/pragmatic-drag-and-drop": "1.1.6",
|
"@atlaskit/pragmatic-drag-and-drop": "1.1.6",
|
||||||
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
|
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
|
||||||
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
|
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
|
||||||
|
"@fontsource/ibm-plex-mono": "5.2.5",
|
||||||
"@fontsource/inter": "4.5.14",
|
"@fontsource/inter": "4.5.14",
|
||||||
|
"@fontsource/montserrat": "5.2.5",
|
||||||
|
"@fontsource/ubuntu": "5.2.5",
|
||||||
"@tanstack/react-query": "5.24.1",
|
"@tanstack/react-query": "5.24.1",
|
||||||
"@tanstack/react-query-devtools": "5.24.1",
|
"@tanstack/react-query-devtools": "5.24.1",
|
||||||
"@tanstack/react-virtual": "3.2.0",
|
"@tanstack/react-virtual": "3.2.0",
|
||||||
|
|
@ -99,7 +102,8 @@
|
||||||
"vite": "5.4.19",
|
"vite": "5.4.19",
|
||||||
"vite-plugin-pwa": "0.20.5",
|
"vite-plugin-pwa": "0.20.5",
|
||||||
"vite-plugin-static-copy": "1.0.4",
|
"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": {
|
"engines": {
|
||||||
"node": ">=16.0.0"
|
"node": ">=16.0.0"
|
||||||
|
|
@ -2188,11 +2192,38 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/@fontsource/inter": {
|
||||||
"version": "4.5.14",
|
"version": "4.5.14",
|
||||||
"resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-4.5.14.tgz",
|
"resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-4.5.14.tgz",
|
||||||
"integrity": "sha512-JDC9AocdPLuGsASkvWw9hS5gtHE7K9dOwL98XLrk5yjYqxy4uVnScG58NUvFMJDVJRl/7c8Wnap6PEs+7Zvj1Q=="
|
"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": {
|
"node_modules/@formatjs/ecma402-abstract": {
|
||||||
"version": "2.3.2",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.2.tgz",
|
||||||
|
|
@ -2307,6 +2338,19 @@
|
||||||
"@swc/helpers": "^0.5.0"
|
"@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": {
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
"version": "0.3.8",
|
"version": "0.3.8",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz",
|
||||||
|
|
@ -2347,9 +2391,10 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@jridgewell/sourcemap-codec": {
|
"node_modules/@jridgewell/sourcemap-codec": {
|
||||||
"version": "1.5.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
|
||||||
"integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="
|
"integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
|
||||||
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@jridgewell/trace-mapping": {
|
"node_modules/@jridgewell/trace-mapping": {
|
||||||
"version": "0.3.25",
|
"version": "0.3.25",
|
||||||
|
|
@ -4330,6 +4375,13 @@
|
||||||
"url": "https://ko-fi.com/dangreen"
|
"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": {
|
"node_modules/@surma/rollup-plugin-off-main-thread": {
|
||||||
"version": "2.2.3",
|
"version": "2.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz",
|
"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"
|
"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": {
|
"node_modules/abbrev": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
|
"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"
|
"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": {
|
"node_modules/agent-base": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||||
|
|
@ -5448,6 +5650,16 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/ast-types-flow": {
|
||||||
"version": "0.0.7",
|
"version": "0.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.7.tgz",
|
||||||
|
|
@ -5857,6 +6069,25 @@
|
||||||
"node": ">=6"
|
"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": {
|
"node_modules/chalk": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||||
|
|
@ -5879,6 +6110,19 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/chokidar": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
"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": {
|
"node_modules/deep-is": {
|
||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||||
|
|
@ -6715,6 +6972,16 @@
|
||||||
"node": ">=8"
|
"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": {
|
"node_modules/dir-glob": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
|
||||||
|
|
@ -7638,6 +7905,85 @@
|
||||||
"node": ">=0.8.x"
|
"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": {
|
"node_modules/expand-tilde": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/expand-tilde/-/expand-tilde-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/expand-tilde/-/expand-tilde-2.0.2.tgz",
|
||||||
|
|
@ -8078,6 +8424,16 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/get-intrinsic": {
|
||||||
"version": "1.2.7",
|
"version": "1.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.7.tgz",
|
||||||
|
|
@ -8121,6 +8477,19 @@
|
||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/get-symbol-description": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz",
|
||||||
|
|
@ -8525,6 +8894,16 @@
|
||||||
"node": ">= 6"
|
"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": {
|
"node_modules/husky": {
|
||||||
"version": "9.1.7",
|
"version": "9.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
|
||||||
|
|
@ -9719,6 +10098,23 @@
|
||||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
"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": {
|
"node_modules/locate-path": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||||
|
|
@ -10051,6 +10447,16 @@
|
||||||
"loose-envify": "cli.js"
|
"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": {
|
"node_modules/lru-cache": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||||
|
|
@ -10173,6 +10579,13 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/merge2": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||||
|
|
@ -10439,6 +10852,35 @@
|
||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/npmlog": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.2.tgz",
|
||||||
"integrity": "sha512-15Ztpk+nov8DR524R4BF7uEuzESgzUEAV4Ah7CUMNGXdE5ELuvxElxGXndBl32vMSsWa1jpNf22Z+Er3sKwq+w=="
|
"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": {
|
"node_modules/pdfjs-dist": {
|
||||||
"version": "4.2.67",
|
"version": "4.2.67",
|
||||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-4.2.67.tgz",
|
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-4.2.67.tgz",
|
||||||
|
|
@ -10971,6 +11423,41 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/prismjs": {
|
||||||
"version": "1.30.0",
|
"version": "1.30.0",
|
||||||
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
|
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
|
||||||
|
|
@ -11993,6 +12480,13 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/signal-exit": {
|
||||||
"version": "3.0.7",
|
"version": "3.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
|
||||||
|
|
@ -12220,6 +12714,20 @@
|
||||||
"deprecated": "Please use @jridgewell/sourcemap-codec instead",
|
"deprecated": "Please use @jridgewell/sourcemap-codec instead",
|
||||||
"dev": true
|
"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": {
|
"node_modules/string_decoder": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||||
|
|
@ -12383,6 +12891,19 @@
|
||||||
"node": ">=10"
|
"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": {
|
"node_modules/strip-json-comments": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
|
"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"
|
"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": {
|
"node_modules/style-to-js": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.3.tgz",
|
||||||
|
|
@ -12526,6 +13067,13 @@
|
||||||
"integrity": "sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==",
|
"integrity": "sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/tinyexec": {
|
||||||
"version": "1.0.4",
|
"version": "1.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz",
|
||||||
|
|
@ -12553,6 +13101,26 @@
|
||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"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": {
|
"node_modules/tmp": {
|
||||||
"version": "0.0.33",
|
"version": "0.0.33",
|
||||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",
|
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",
|
||||||
|
|
@ -12646,6 +13214,16 @@
|
||||||
"node": ">= 0.8.0"
|
"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": {
|
"node_modules/type-fest": {
|
||||||
"version": "0.16.0",
|
"version": "0.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz",
|
||||||
|
|
@ -13474,6 +14052,89 @@
|
||||||
"@esbuild/win32-x64": "0.21.5"
|
"@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": {
|
"node_modules/void-elements": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
||||||
|
|
@ -13607,6 +14268,23 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/wide-align": {
|
||||||
"version": "1.1.5",
|
"version": "1.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz",
|
||||||
|
|
|
||||||
11
package.json
|
|
@ -18,7 +18,8 @@
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"prepare": "husky install",
|
"prepare": "husky install",
|
||||||
"commit": "git-cz",
|
"commit": "git-cz",
|
||||||
"bump": "node scripts/update-version.js"
|
"bump": "node scripts/update-version.js",
|
||||||
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*.{ts,tsx,js,jsx}": "eslint",
|
"*.{ts,tsx,js,jsx}": "eslint",
|
||||||
|
|
@ -87,7 +88,10 @@
|
||||||
"slate-dom": "0.123.0",
|
"slate-dom": "0.123.0",
|
||||||
"slate-history": "0.113.1",
|
"slate-history": "0.113.1",
|
||||||
"slate-react": "0.123.0",
|
"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": {
|
"devDependencies": {
|
||||||
"@element-hq/element-call-embedded": "0.20.1",
|
"@element-hq/element-call-embedded": "0.20.1",
|
||||||
|
|
@ -123,6 +127,7 @@
|
||||||
"vite": "5.4.19",
|
"vite": "5.4.19",
|
||||||
"vite-plugin-pwa": "0.20.5",
|
"vite-plugin-pwa": "0.20.5",
|
||||||
"vite-plugin-static-copy": "1.0.4",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 2.3 KiB |
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "Cinny",
|
"name": "Ratatoskr",
|
||||||
"short_name": "Cinny",
|
"short_name": "Ratatoskr",
|
||||||
"description": "Yet another matrix client",
|
"description": "GameCult's Matrix client",
|
||||||
"dir": "auto",
|
"dir": "auto",
|
||||||
"lang": "en-US",
|
"lang": "en-US",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 3 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 4 KiB |
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 1 KiB After Width: | Height: | Size: 817 B |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 5 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 3 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 1.6 KiB |
|
|
@ -1,13 +0,0 @@
|
||||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<g clip-path="url(#clip0_2707_1961)">
|
|
||||||
<path d="M10.5867 17.3522C10.0727 17.4492 9.54226 17.5 9 17.5C4.30558 17.5 0.5 13.6944 0.5 9C0.5 4.30558 4.30558 0.5 9 0.5C13.6944 0.5 17.5 4.30558 17.5 9C17.5 9.54226 17.4492 10.0727 17.3522 10.5867C16.6511 10.2123 15.8503 10 15 10C12.2386 10 10 12.2386 10 15C10 15.8503 10.2123 16.6511 10.5867 17.3522Z" fill="white"/>
|
|
||||||
<path d="M10 6.39999C10 6.67614 9.77614 6.89999 9.5 6.89999C9.22386 6.89999 9 6.67614 9 6.39999C9 6.12385 9.22386 5.89999 9.5 5.89999C9.77614 5.89999 10 6.12385 10 6.39999Z" fill="black"/>
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M9 0C4 0 0 4 0 9C0 14 4 18 9 18C9.63967 18 10.263 17.9345 10.8636 17.8099C10.3186 17.0091 10 16.0417 10 15C10 12.2386 12.2386 10 15 10C16.0417 10 17.0091 10.3186 17.8099 10.8636C17.9345 10.263 18 9.63967 18 9C18 4 14 0 9 0ZM1.2 10.8L4.7 8.5V8.2C4.7 6.4 6 5 7.8 4.8H8.2C9.4 4.8 10.5 5.4 11.1 6.4C11.4 6.3 11.7 6.3 12 6.3C12.4 6.3 12.8 6.3 13.2 6.4C13.9 6.6 14.6 6.9 15.2 7.3C14.6 7.1 14 7 13.3 7C12.1 7 11.1 7.4 10.4 8.4C9.7 9.3 9.3 10.4 9.3 11.6C9.3 13.1 8.9 14.5 8 15.8C7.93744 15.8834 7.87923 15.9625 7.82356 16.0381C7.6123 16.325 7.43739 16.5626 7.2 16.8C4.2 16.1 1.9 13.8 1.2 10.8Z" fill="black"/>
|
|
||||||
<path d="M18 15C18 16.6569 16.6569 18 15 18C13.3431 18 12 16.6569 12 15C12 13.3431 13.3431 12 15 12C16.6569 12 18 13.3431 18 15Z" fill="#45B83B"/>
|
|
||||||
</g>
|
|
||||||
<defs>
|
|
||||||
<clipPath id="clip0_2707_1961">
|
|
||||||
<rect width="18" height="18" fill="white"/>
|
|
||||||
</clipPath>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB |
|
|
@ -1,13 +0,0 @@
|
||||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<g clip-path="url(#clip0_2707_2015)">
|
|
||||||
<path d="M10.5867 17.3522C10.0727 17.4492 9.54226 17.5 9 17.5C4.30558 17.5 0.5 13.6944 0.5 9C0.5 4.30558 4.30558 0.5 9 0.5C13.6944 0.5 17.5 4.30558 17.5 9C17.5 9.54226 17.4492 10.0727 17.3522 10.5867C16.6511 10.2123 15.8503 10 15 10C12.2386 10 10 12.2386 10 15C10 15.8503 10.2123 16.6511 10.5867 17.3522Z" fill="white"/>
|
|
||||||
<path d="M10 6.39999C10 6.67614 9.77614 6.89999 9.5 6.89999C9.22386 6.89999 9 6.67614 9 6.39999C9 6.12385 9.22386 5.89999 9.5 5.89999C9.77614 5.89999 10 6.12385 10 6.39999Z" fill="black"/>
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M9 0C4 0 0 4 0 9C0 14 4 18 9 18C9.63967 18 10.263 17.9345 10.8636 17.8099C10.3186 17.0091 10 16.0417 10 15C10 12.2386 12.2386 10 15 10C16.0417 10 17.0091 10.3186 17.8099 10.8636C17.9345 10.263 18 9.63967 18 9C18 4 14 0 9 0ZM1.2 10.8L4.7 8.5V8.2C4.7 6.4 6 5 7.8 4.8H8.2C9.4 4.8 10.5 5.4 11.1 6.4C11.4 6.3 11.7 6.3 12 6.3C12.4 6.3 12.8 6.3 13.2 6.4C13.9 6.6 14.6 6.9 15.2 7.3C14.6 7.1 14 7 13.3 7C12.1 7 11.1 7.4 10.4 8.4C9.7 9.3 9.3 10.4 9.3 11.6C9.3 13.1 8.9 14.5 8 15.8C7.93744 15.8834 7.87923 15.9625 7.82356 16.0381C7.6123 16.325 7.43739 16.5626 7.2 16.8C4.2 16.1 1.9 13.8 1.2 10.8Z" fill="black"/>
|
|
||||||
<path d="M18 15C18 16.6569 16.6569 18 15 18C13.3431 18 12 16.6569 12 15C12 13.3431 13.3431 12 15 12C16.6569 12 18 13.3431 18 15Z" fill="#989898"/>
|
|
||||||
</g>
|
|
||||||
<defs>
|
|
||||||
<clipPath id="clip0_2707_2015">
|
|
||||||
<rect width="18" height="18" fill="white"/>
|
|
||||||
</clipPath>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB |
|
|
@ -1,19 +0,0 @@
|
||||||
<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In -->
|
|
||||||
<svg version="1.1"
|
|
||||||
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:a="http://ns.adobe.com/AdobeSVGViewerExtensions/3.0/"
|
|
||||||
x="0px" y="0px" width="18px" height="18px" viewBox="0 0 18 18" enable-background="new 0 0 18 18" xml:space="preserve">
|
|
||||||
<defs>
|
|
||||||
</defs>
|
|
||||||
<g>
|
|
||||||
<g>
|
|
||||||
<circle fill="#FFFFFF" cx="9" cy="9" r="8.5"/>
|
|
||||||
</g>
|
|
||||||
<g>
|
|
||||||
<path d="M9,0C4,0,0,4,0,9c0,5,4,9,9,9c5,0,9-4,9-9C18,4,14,0,9,0z M1.2,10.8l3.5-2.3c0-0.1,0-0.2,0-0.3c0-1.8,1.3-3.2,3.1-3.4
|
|
||||||
c0.1,0,0.2,0,0.4,0c1.2,0,2.3,0.6,2.9,1.6c0.3-0.1,0.6-0.1,0.9-0.1c0.4,0,0.8,0,1.2,0.1c0.7,0.2,1.4,0.5,2,0.9
|
|
||||||
C14.6,7.1,14,7,13.3,7c-1.2,0-2.2,0.4-2.9,1.4c-0.7,0.9-1.1,2-1.1,3.2c0,1.5-0.4,2.9-1.3,4.2c-0.3,0.4-0.5,0.7-0.8,1
|
|
||||||
C4.2,16.1,1.9,13.8,1.2,10.8z"/>
|
|
||||||
<circle cx="9.5" cy="6.4" r="0.5"/>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 871 B |
17
public/res/svg/ratatoskr-highlight.svg
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
|
||||||
|
<ellipse cx="19" cy="33" rx="13" ry="22" transform="rotate(-15 19 33)" fill="#d9742b"/>
|
||||||
|
<ellipse cx="21" cy="33" rx="7" ry="15" transform="rotate(-15 21 33)" fill="#f0a45d"/>
|
||||||
|
<ellipse cx="36" cy="43" rx="11" ry="14" transform="rotate(0 36 43)" fill="#e0833a"/>
|
||||||
|
<ellipse cx="40" cy="46" rx="6" ry="9" transform="rotate(0 40 46)" fill="#f6dcb8"/>
|
||||||
|
<polygon points="35,19 36,7 43,15" fill="#c9672a"/>
|
||||||
|
<polygon points="44,15 50,7 52,19" fill="#c9672a"/>
|
||||||
|
<circle cx="43" cy="25" r="10" fill="#e0833a"/>
|
||||||
|
<ellipse cx="47" cy="29" rx="5" ry="3.5" transform="rotate(0 47 29)" fill="#f6dcb8"/>
|
||||||
|
<circle cx="46.5" cy="23.5" r="1.8" fill="#1a1a1a"/>
|
||||||
|
<circle cx="47" cy="23" r="0.6" fill="#ffffff"/>
|
||||||
|
<circle cx="52" cy="27.5" r="1.5" fill="#1a1a1a"/>
|
||||||
|
<ellipse cx="33" cy="56" rx="5" ry="2.5" transform="rotate(0 33 56)" fill="#c9672a"/>
|
||||||
|
<ellipse cx="44" cy="56" rx="4.5" ry="2.5" transform="rotate(0 44 56)" fill="#c9672a"/>
|
||||||
|
<circle cx="52" cy="52" r="11" fill="#ffffff"/>
|
||||||
|
<circle cx="52" cy="52" r="8" fill="#45B83B"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
17
public/res/svg/ratatoskr-unread.svg
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
|
||||||
|
<ellipse cx="19" cy="33" rx="13" ry="22" transform="rotate(-15 19 33)" fill="#d9742b"/>
|
||||||
|
<ellipse cx="21" cy="33" rx="7" ry="15" transform="rotate(-15 21 33)" fill="#f0a45d"/>
|
||||||
|
<ellipse cx="36" cy="43" rx="11" ry="14" transform="rotate(0 36 43)" fill="#e0833a"/>
|
||||||
|
<ellipse cx="40" cy="46" rx="6" ry="9" transform="rotate(0 40 46)" fill="#f6dcb8"/>
|
||||||
|
<polygon points="35,19 36,7 43,15" fill="#c9672a"/>
|
||||||
|
<polygon points="44,15 50,7 52,19" fill="#c9672a"/>
|
||||||
|
<circle cx="43" cy="25" r="10" fill="#e0833a"/>
|
||||||
|
<ellipse cx="47" cy="29" rx="5" ry="3.5" transform="rotate(0 47 29)" fill="#f6dcb8"/>
|
||||||
|
<circle cx="46.5" cy="23.5" r="1.8" fill="#1a1a1a"/>
|
||||||
|
<circle cx="47" cy="23" r="0.6" fill="#ffffff"/>
|
||||||
|
<circle cx="52" cy="27.5" r="1.5" fill="#1a1a1a"/>
|
||||||
|
<ellipse cx="33" cy="56" rx="5" ry="2.5" transform="rotate(0 33 56)" fill="#c9672a"/>
|
||||||
|
<ellipse cx="44" cy="56" rx="4.5" ry="2.5" transform="rotate(0 44 56)" fill="#c9672a"/>
|
||||||
|
<circle cx="52" cy="52" r="11" fill="#ffffff"/>
|
||||||
|
<circle cx="52" cy="52" r="8" fill="#989898"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
15
public/res/svg/ratatoskr.svg
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
|
||||||
|
<ellipse cx="19" cy="33" rx="13" ry="22" transform="rotate(-15 19 33)" fill="#d9742b"/>
|
||||||
|
<ellipse cx="21" cy="33" rx="7" ry="15" transform="rotate(-15 21 33)" fill="#f0a45d"/>
|
||||||
|
<ellipse cx="36" cy="43" rx="11" ry="14" transform="rotate(0 36 43)" fill="#e0833a"/>
|
||||||
|
<ellipse cx="40" cy="46" rx="6" ry="9" transform="rotate(0 40 46)" fill="#f6dcb8"/>
|
||||||
|
<polygon points="35,19 36,7 43,15" fill="#c9672a"/>
|
||||||
|
<polygon points="44,15 50,7 52,19" fill="#c9672a"/>
|
||||||
|
<circle cx="43" cy="25" r="10" fill="#e0833a"/>
|
||||||
|
<ellipse cx="47" cy="29" rx="5" ry="3.5" transform="rotate(0 47 29)" fill="#f6dcb8"/>
|
||||||
|
<circle cx="46.5" cy="23.5" r="1.8" fill="#1a1a1a"/>
|
||||||
|
<circle cx="47" cy="23" r="0.6" fill="#ffffff"/>
|
||||||
|
<circle cx="52" cy="27.5" r="1.5" fill="#1a1a1a"/>
|
||||||
|
<ellipse cx="33" cy="56" rx="5" ry="2.5" transform="rotate(0 33 56)" fill="#c9672a"/>
|
||||||
|
<ellipse cx="44" cy="56" rx="4.5" ry="2.5" transform="rotate(0 44 56)" fill="#c9672a"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1,001 B |
202
scripts/gen-brand.mjs
Normal file
|
|
@ -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 `<circle cx="${s.cx}" cy="${s.cy}" r="${s.r}" fill="${s.fill}"/>`;
|
||||||
|
if (s.t === 'rect') return `<rect width="${s.w}" height="${s.h}" fill="${s.fill}"/>`;
|
||||||
|
if (s.t === 'poly')
|
||||||
|
return `<polygon points="${s.pts.map((p) => p.join(',')).join(' ')}" fill="${s.fill}"/>`;
|
||||||
|
return `<ellipse cx="${s.cx}" cy="${s.cy}" rx="${s.rx}" ry="${s.ry}" transform="rotate(${s.rot} ${s.cx} ${s.cy})" fill="${s.fill}"/>`;
|
||||||
|
})
|
||||||
|
.join('\n');
|
||||||
|
const writeSvg = (name, shapes) =>
|
||||||
|
writeFileSync(
|
||||||
|
join(root, 'public/res/svg', name),
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">\n${svgOf(
|
||||||
|
shapes
|
||||||
|
)}\n</svg>\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');
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { ReactNode, useCallback, useRef, useState } from 'react';
|
import React, { ReactNode, useCallback, useRef, useState } from 'react';
|
||||||
import { MatrixError, Room } from 'matrix-js-sdk';
|
import { MatrixError } from 'matrix-js-sdk';
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Badge,
|
Badge,
|
||||||
|
|
@ -31,6 +31,7 @@ import { RoomType, StateEvent } from '../../../types/matrix/room';
|
||||||
import { useJoinedRoomId } from '../../hooks/useJoinedRoomId';
|
import { useJoinedRoomId } from '../../hooks/useJoinedRoomId';
|
||||||
import { useElementSizeObserver } from '../../hooks/useElementSizeObserver';
|
import { useElementSizeObserver } from '../../hooks/useElementSizeObserver';
|
||||||
import { getRoomAvatarUrl, getStateEvent } from '../../utils/room';
|
import { getRoomAvatarUrl, getStateEvent } from '../../utils/room';
|
||||||
|
import { joinEstateRoomOrThrow } from '../../features/estate/joinEstateRoom';
|
||||||
import { useStateEventCallback } from '../../hooks/useStateEventCallback';
|
import { useStateEventCallback } from '../../hooks/useStateEventCallback';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
|
|
||||||
|
|
@ -197,8 +198,10 @@ export const RoomCard = as<'div', RoomCardProps>(
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const [joinState, join] = useAsyncCallback<Room, MatrixError, []>(
|
const [joinState, join] = useAsyncCallback<void, MatrixError, []>(
|
||||||
useCallback(() => mx.joinRoom(roomIdOrAlias, { viaServers }), [mx, roomIdOrAlias, viaServers])
|
useCallback(async () => {
|
||||||
|
await joinEstateRoomOrThrow(mx, roomIdOrAlias, viaServers);
|
||||||
|
}, [mx, roomIdOrAlias, viaServers])
|
||||||
);
|
);
|
||||||
const joining =
|
const joining =
|
||||||
joinState.status === AsyncStatus.Loading || joinState.status === AsyncStatus.Success;
|
joinState.status === AsyncStatus.Loading || joinState.status === AsyncStatus.Success;
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
import { InviteUserPrompt } from '../invite-user-prompt';
|
import { InviteUserPrompt } from '../invite-user-prompt';
|
||||||
|
import { joinEstateRoomOrThrow } from '../../features/estate/joinEstateRoom';
|
||||||
|
|
||||||
export type RoomIntroProps = {
|
export type RoomIntroProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
|
|
@ -43,7 +44,7 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
|
||||||
const prevRoomId = createContent?.predecessor?.room_id;
|
const prevRoomId = createContent?.predecessor?.room_id;
|
||||||
|
|
||||||
const [prevRoomState, joinPrevRoom] = useAsyncCallback(
|
const [prevRoomState, joinPrevRoom] = useAsyncCallback(
|
||||||
useCallback(async (roomId: string) => mx.joinRoom(roomId), [mx])
|
useCallback((roomId: string) => joinEstateRoomOrThrow(mx, roomId), [mx])
|
||||||
);
|
);
|
||||||
|
|
||||||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ export function SplashScreen({ children }: SplashScreenProps) {
|
||||||
justifyContent="Center"
|
justifyContent="Center"
|
||||||
>
|
>
|
||||||
<Text size="H2" align="Center">
|
<Text size="H2" align="Center">
|
||||||
Cinny
|
Ratatoskr
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
14
src/app/features/estate/EstateNavigator.css.ts
Normal file
|
|
@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
326
src/app/features/estate/EstateNavigator.tsx
Normal file
|
|
@ -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<typeof useMatrixClient>, 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 (
|
||||||
|
<NavItem variant="Background" radii="400">
|
||||||
|
<Box grow="Yes" alignItems="Center" gap="200" style={{ padding: '0 8px', minHeight: 38 }}>
|
||||||
|
<NavItemContent>
|
||||||
|
<Text as="span" size="Inherit" truncate>
|
||||||
|
{room.name}
|
||||||
|
</Text>
|
||||||
|
</NavItemContent>
|
||||||
|
{state.status === AsyncStatus.Error && (
|
||||||
|
<Text
|
||||||
|
as="span"
|
||||||
|
size="T200"
|
||||||
|
style={{ color: color.Critical.Main, overflowWrap: 'anywhere' }}
|
||||||
|
>
|
||||||
|
{(state.error as Error).message}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Chip
|
||||||
|
variant={room.state === 'invited' ? 'Primary' : 'Secondary'}
|
||||||
|
fill="Soft"
|
||||||
|
radii="Pill"
|
||||||
|
before={busy ? <Spinner size="100" /> : <Icon src={Icons.Plus} size="50" />}
|
||||||
|
onClick={join}
|
||||||
|
disabled={busy}
|
||||||
|
aria-label={`Join ${room.name}`}
|
||||||
|
>
|
||||||
|
<Text size="B300">Join</Text>
|
||||||
|
</Chip>
|
||||||
|
</Box>
|
||||||
|
</NavItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <JoinRow room={room} />;
|
||||||
|
return (
|
||||||
|
<RoomNavItem
|
||||||
|
room={mxRoom}
|
||||||
|
selected={selectedRoomId === room.roomId}
|
||||||
|
direct={room.direct}
|
||||||
|
linkPath={estateRoomPath(mx, room)}
|
||||||
|
notificationMode={getRoomNotificationMode(notificationPreferences, room.roomId)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<HTMLButtonElement> = () =>
|
||||||
|
setToggled({ type: toggled.has(key) ? 'DELETE' : 'PUT', categoryId: key });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NavCategory>
|
||||||
|
<NavCategoryHeader>
|
||||||
|
<RoomNavCategoryButton closed={closed} onClick={handleClick}>
|
||||||
|
{label}
|
||||||
|
</RoomNavCategoryButton>
|
||||||
|
</NavCategoryHeader>
|
||||||
|
{!closed && children}
|
||||||
|
</NavCategory>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tier({ tier }: { tier: EstateTier }) {
|
||||||
|
const allRooms = useMemo(() => [...tier.loose, ...tier.groups.flatMap((g) => g.rooms)], [tier]);
|
||||||
|
return (
|
||||||
|
<Collapsible categoryId={tier.id} label={tier.label} rooms={allRooms}>
|
||||||
|
{tier.loose.map((r) => (
|
||||||
|
<EstateRow key={r.roomId} room={r} />
|
||||||
|
))}
|
||||||
|
{tier.groups.map((g) => (
|
||||||
|
<Collapsible
|
||||||
|
key={g.mind}
|
||||||
|
categoryId={`${tier.id}|${g.mind}`}
|
||||||
|
label={g.label}
|
||||||
|
rooms={g.rooms}
|
||||||
|
>
|
||||||
|
{g.rooms.map((r) => (
|
||||||
|
<EstateRow key={r.roomId} room={r} />
|
||||||
|
))}
|
||||||
|
</Collapsible>
|
||||||
|
))}
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EntryPoints() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
return (
|
||||||
|
<NavCategory>
|
||||||
|
<NavItem variant="Background" radii="400">
|
||||||
|
<NavButton onClick={() => navigate(getHomeCreatePath())}>
|
||||||
|
<NavItemContent>
|
||||||
|
<Box as="span" grow="Yes" alignItems="Center" gap="200">
|
||||||
|
<Avatar size="200" radii="400">
|
||||||
|
<Icon src={Icons.Plus} size="100" />
|
||||||
|
</Avatar>
|
||||||
|
<Text as="span" size="Inherit" truncate>
|
||||||
|
Create Room
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</NavItemContent>
|
||||||
|
</NavButton>
|
||||||
|
</NavItem>
|
||||||
|
<UseStateProvider initial={false}>
|
||||||
|
{(open, setOpen) => (
|
||||||
|
<>
|
||||||
|
<NavItem variant="Background" radii="400">
|
||||||
|
<NavButton onClick={() => setOpen(true)}>
|
||||||
|
<NavItemContent>
|
||||||
|
<Box as="span" grow="Yes" alignItems="Center" gap="200">
|
||||||
|
<Avatar size="200" radii="400">
|
||||||
|
<Icon src={Icons.Link} size="100" />
|
||||||
|
</Avatar>
|
||||||
|
<Text as="span" size="Inherit" truncate>
|
||||||
|
Join with Address
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</NavItemContent>
|
||||||
|
</NavButton>
|
||||||
|
</NavItem>
|
||||||
|
{open && (
|
||||||
|
<JoinAddressPrompt
|
||||||
|
onCancel={() => setOpen(false)}
|
||||||
|
onOpen={(roomIdOrAlias, viaServers, eventId) => {
|
||||||
|
setOpen(false);
|
||||||
|
const path = getHomeRoomPath(roomIdOrAlias, eventId);
|
||||||
|
navigate(
|
||||||
|
viaServers
|
||||||
|
? withSearchParam<_RoomSearchParams>(path, {
|
||||||
|
viaServers: encodeSearchParamValueArray(viaServers),
|
||||||
|
})
|
||||||
|
: path
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</UseStateProvider>
|
||||||
|
</NavCategory>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A short message that wraps inside the navigator; never a raw request error. */
|
||||||
|
function EstateError({ message }: { message: string }) {
|
||||||
|
return (
|
||||||
|
<Text size="T300" style={{ color: color.Critical.Main, padding: 16, overflowWrap: 'anywhere' }}>
|
||||||
|
{message}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EstateTiers({ rootId }: { rootId: string }) {
|
||||||
|
const { fetching, error, tiers } = useEstate(rootId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{tiers.map((tier) => (
|
||||||
|
<Tier key={tier.id} tier={tier} />
|
||||||
|
))}
|
||||||
|
{fetching && (
|
||||||
|
<Box justifyContent="Center" style={{ padding: 16 }}>
|
||||||
|
<Spinner />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<EstateError message="Could not load the room list. Check your connection and reload." />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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<HTMLDivElement>(null);
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const title = (rootId && mx.getRoom(rootId)?.name) || 'GameCult';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageNav>
|
||||||
|
<PageNavHeader>
|
||||||
|
<Box alignItems="Center" grow="Yes" gap="300" className={css.Targets}>
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Text size="H4" truncate>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<IconButton
|
||||||
|
variant="Background"
|
||||||
|
aria-label="Find a room"
|
||||||
|
onClick={() => openRoomFinder(true)}
|
||||||
|
>
|
||||||
|
<Icon src={Icons.Search} size="200" />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
</PageNavHeader>
|
||||||
|
<PageNavContent scrollRef={scrollRef}>
|
||||||
|
<Box direction="Column" gap="300" className={css.Targets}>
|
||||||
|
<EntryPoints />
|
||||||
|
{rootId && <EstateTiers rootId={rootId} />}
|
||||||
|
{!rootId && error && (
|
||||||
|
<EstateError message="Could not find the GameCult space. Check your connection and reload." />
|
||||||
|
)}
|
||||||
|
{!rootId && !error && (
|
||||||
|
<Box justifyContent="Center" style={{ padding: 16 }}>
|
||||||
|
<Spinner />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</PageNavContent>
|
||||||
|
<Box
|
||||||
|
shrink="No"
|
||||||
|
alignItems="Center"
|
||||||
|
justifyContent="Center"
|
||||||
|
gap="200"
|
||||||
|
style={{ padding: 8 }}
|
||||||
|
className={css.Targets}
|
||||||
|
>
|
||||||
|
<UnverifiedTab />
|
||||||
|
<InboxTab />
|
||||||
|
<SettingsTab />
|
||||||
|
</Box>
|
||||||
|
</PageNav>
|
||||||
|
);
|
||||||
|
}
|
||||||
326
src/app/features/estate/buildEstate.test.ts
Normal file
|
|
@ -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<string, string> = {}
|
||||||
|
): 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> = {}): EstateInput => ({
|
||||||
|
rootId: ROOT,
|
||||||
|
hierarchy,
|
||||||
|
membership: new Map(),
|
||||||
|
directRoomIds: [],
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
const find = (tiers: ReturnType<typeof buildEstate>, 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<string, 'join' | 'invite'>([
|
||||||
|
[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<EstateInput>) =>
|
||||||
|
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 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
186
src/app/features/estate/buildEstate.ts
Normal file
|
|
@ -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<string, 'join' | 'invite'>;
|
||||||
|
/** 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<string, string>;
|
||||||
|
/** Display names for Minds, keyed by localpart. */
|
||||||
|
mindLabels?: ReadonlyMap<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<string, 'join' | 'invite'>
|
||||||
|
): 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<string, EstateGroup>();
|
||||||
|
|
||||||
|
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] : []),
|
||||||
|
];
|
||||||
|
};
|
||||||
9
src/app/features/estate/config.test.ts
Normal file
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
1
src/app/features/estate/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './EstateNavigator';
|
||||||
352
src/app/features/estate/joinEstateRoom.test.ts
Normal file
|
|
@ -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<string, unknown>, {
|
||||||
|
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<string, string>;
|
||||||
|
}) => {
|
||||||
|
let direct: Record<string, string[]> | undefined;
|
||||||
|
const writes: Record<string, string[]>[] = [];
|
||||||
|
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<string, string[]>) => {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
151
src/app/features/estate/joinEstateRoom.ts
Normal file
|
|
@ -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<typeof addRoomIdToMDirect>[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<EstateRoom, 'roomId' | 'state'> & { via?: string[] }
|
||||||
|
): Promise<EstateActivation> => {
|
||||||
|
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<EstateActivation> =>
|
||||||
|
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<string> => {
|
||||||
|
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<void> => {
|
||||||
|
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<boolean> =>
|
||||||
|
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);
|
||||||
|
});
|
||||||
122
src/app/features/estate/useEstate.ts
Normal file
|
|
@ -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<typeof useMatrixClient>,
|
||||||
|
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<string, 'join' | 'invite'>();
|
||||||
|
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<string, string>();
|
||||||
|
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<string, string>();
|
||||||
|
[...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 };
|
||||||
|
};
|
||||||
|
|
@ -200,16 +200,12 @@ type HierarchyItemMenuProps = {
|
||||||
joined: boolean;
|
joined: boolean;
|
||||||
powerLevels?: IPowerLevels;
|
powerLevels?: IPowerLevels;
|
||||||
canEditChild: boolean;
|
canEditChild: boolean;
|
||||||
pinned?: boolean;
|
|
||||||
onTogglePin?: (roomId: string) => void;
|
|
||||||
};
|
};
|
||||||
export function HierarchyItemMenu({
|
export function HierarchyItemMenu({
|
||||||
item,
|
item,
|
||||||
joined,
|
joined,
|
||||||
powerLevels,
|
powerLevels,
|
||||||
canEditChild,
|
canEditChild,
|
||||||
pinned,
|
|
||||||
onTogglePin,
|
|
||||||
}: HierarchyItemMenuProps) {
|
}: HierarchyItemMenuProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||||
|
|
@ -264,20 +260,6 @@ export function HierarchyItemMenu({
|
||||||
<Menu style={{ maxWidth: toRem(150), width: '100vw' }}>
|
<Menu style={{ maxWidth: toRem(150), width: '100vw' }}>
|
||||||
{joined && (
|
{joined && (
|
||||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||||
{onTogglePin && (
|
|
||||||
<MenuItem
|
|
||||||
size="300"
|
|
||||||
radii="300"
|
|
||||||
onClick={() => {
|
|
||||||
onTogglePin(item.roomId);
|
|
||||||
handleRequestClose();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text as="span" size="T300" truncate>
|
|
||||||
{pinned ? 'Unpin from Sidebar' : 'Pin to Sidebar'}
|
|
||||||
</Text>
|
|
||||||
</MenuItem>
|
|
||||||
)}
|
|
||||||
<InviteMenuItem
|
<InviteMenuItem
|
||||||
item={item}
|
item={item}
|
||||||
requestClose={handleRequestClose}
|
requestClose={handleRequestClose}
|
||||||
|
|
|
||||||
|
|
@ -42,14 +42,6 @@ import { CanDropCallback, useDnDMonitor } from './DnD';
|
||||||
import { ASCIILexicalTable, orderKeys } from '../../utils/ASCIILexicalTable';
|
import { ASCIILexicalTable, orderKeys } from '../../utils/ASCIILexicalTable';
|
||||||
import { getStateEvent } from '../../utils/room';
|
import { getStateEvent } from '../../utils/room';
|
||||||
import { useClosedLobbyCategoriesAtom } from '../../state/hooks/closedLobbyCategories';
|
import { useClosedLobbyCategoriesAtom } from '../../state/hooks/closedLobbyCategories';
|
||||||
import {
|
|
||||||
makeCinnySpacesContent,
|
|
||||||
sidebarItemWithout,
|
|
||||||
useSidebarItems,
|
|
||||||
} from '../../hooks/useSidebarItems';
|
|
||||||
import { useOrphanSpaces } from '../../state/hooks/roomList';
|
|
||||||
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
|
||||||
import { AccountDataEvent } from '../../../types/matrix/accountData';
|
|
||||||
import { useRoomMembers } from '../../hooks/useRoomMembers';
|
import { useRoomMembers } from '../../hooks/useRoomMembers';
|
||||||
import { SpaceHierarchy } from './SpaceHierarchy';
|
import { SpaceHierarchy } from './SpaceHierarchy';
|
||||||
import { useGetRoom } from '../../hooks/useGetRoom';
|
import { useGetRoom } from '../../hooks/useGetRoom';
|
||||||
|
|
@ -167,18 +159,6 @@ export function Lobby() {
|
||||||
const screenSize = useScreenSizeContext();
|
const screenSize = useScreenSizeContext();
|
||||||
const [onTop, setOnTop] = useState(true);
|
const [onTop, setOnTop] = useState(true);
|
||||||
const [closedCategories, setClosedCategories] = useAtom(useClosedLobbyCategoriesAtom());
|
const [closedCategories, setClosedCategories] = useAtom(useClosedLobbyCategoriesAtom());
|
||||||
const [sidebarItems] = useSidebarItems(
|
|
||||||
useOrphanSpaces(mx, allRoomsAtom, useAtomValue(roomToParentsAtom))
|
|
||||||
);
|
|
||||||
const sidebarSpaces = useMemo(() => {
|
|
||||||
const sideSpaces = sidebarItems.flatMap((item) => {
|
|
||||||
if (typeof item === 'string') return item;
|
|
||||||
return item.content;
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Set(sideSpaces);
|
|
||||||
}, [sidebarItems]);
|
|
||||||
|
|
||||||
const [spacesItems, setSpacesItem] = useState<Map<string, IHierarchyRoom>>(() => new Map());
|
const [spacesItems, setSpacesItem] = useState<Map<string, IHierarchyRoom>>(() => new Map());
|
||||||
|
|
||||||
useElementSizeObserver(
|
useElementSizeObserver(
|
||||||
|
|
@ -415,18 +395,6 @@ export function Lobby() {
|
||||||
navigate(getSpaceRoomPath(pSpaceIdOrAlias, getCanonicalAliasOrRoomId(mx, rId)));
|
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 (
|
return (
|
||||||
<PowerLevelsContextProvider value={spacePowerLevels}>
|
<PowerLevelsContextProvider value={spacePowerLevels}>
|
||||||
<Box grow="Yes">
|
<Box grow="Yes">
|
||||||
|
|
@ -499,8 +467,6 @@ export function Lobby() {
|
||||||
disabledReorder={reordering}
|
disabledReorder={reordering}
|
||||||
nextSpaceId={nextSpaceId}
|
nextSpaceId={nextSpaceId}
|
||||||
getRoom={getRoom}
|
getRoom={getRoom}
|
||||||
pinned={sidebarSpaces.has(item.space.roomId)}
|
|
||||||
togglePinToSidebar={togglePinToSidebar}
|
|
||||||
onSpacesFound={handleSpacesFound}
|
onSpacesFound={handleSpacesFound}
|
||||||
onOpenRoom={handleOpenRoom}
|
onOpenRoom={handleOpenRoom}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ import {
|
||||||
toRem,
|
toRem,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import FocusTrap from 'focus-trap-react';
|
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 { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces';
|
||||||
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
||||||
import { SequenceCard } from '../../components/sequence-card';
|
import { SequenceCard } from '../../components/sequence-card';
|
||||||
|
|
@ -38,6 +38,7 @@ import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../../utils/room';
|
||||||
import { ItemDraggableTarget, useDraggableItem } from './DnD';
|
import { ItemDraggableTarget, useDraggableItem } from './DnD';
|
||||||
import { mxcUrlToHttp } from '../../utils/matrix';
|
import { mxcUrlToHttp } from '../../utils/matrix';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
|
import { joinEstateRoomOrThrow } from '../estate/joinEstateRoom';
|
||||||
|
|
||||||
type RoomJoinButtonProps = {
|
type RoomJoinButtonProps = {
|
||||||
roomId: string;
|
roomId: string;
|
||||||
|
|
@ -46,8 +47,8 @@ type RoomJoinButtonProps = {
|
||||||
function RoomJoinButton({ roomId, via }: RoomJoinButtonProps) {
|
function RoomJoinButton({ roomId, via }: RoomJoinButtonProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
|
|
||||||
const [joinState, join] = useAsyncCallback<Room, MatrixError, []>(
|
const [joinState, join] = useAsyncCallback<string, Error, []>(
|
||||||
useCallback(() => mx.joinRoom(roomId, { viaServers: via }), [mx, roomId, via])
|
useCallback(() => joinEstateRoomOrThrow(mx, roomId, via), [mx, roomId, via])
|
||||||
);
|
);
|
||||||
|
|
||||||
const canJoin = joinState.status === AsyncStatus.Idle || joinState.status === AsyncStatus.Error;
|
const canJoin = joinState.status === AsyncStatus.Idle || joinState.status === AsyncStatus.Error;
|
||||||
|
|
@ -60,9 +61,8 @@ function RoomJoinButton({ roomId, via }: RoomJoinButtonProps) {
|
||||||
<Tooltip variant="Critical" style={{ maxWidth: toRem(200) }}>
|
<Tooltip variant="Critical" style={{ maxWidth: toRem(200) }}>
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Text style={{ wordBreak: 'break-word' }} size="T400">
|
<Text style={{ wordBreak: 'break-word' }} size="T400">
|
||||||
{joinState.error.data?.error || joinState.error.message}
|
{joinState.error.message}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="T200">{joinState.error.name}</Text>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
}
|
}
|
||||||
|
|
@ -75,7 +75,7 @@ function RoomJoinButton({ roomId, via }: RoomJoinButtonProps) {
|
||||||
size="400"
|
size="400"
|
||||||
filled
|
filled
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-label={joinState.error.data?.error || joinState.error.message}
|
aria-label={joinState.error.message}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|
|
||||||
|
|
@ -35,8 +35,6 @@ type SpaceHierarchyProps = {
|
||||||
disabledReorder?: boolean;
|
disabledReorder?: boolean;
|
||||||
nextSpaceId?: string;
|
nextSpaceId?: string;
|
||||||
getRoom: (roomId: string) => Room | undefined;
|
getRoom: (roomId: string) => Room | undefined;
|
||||||
pinned: boolean;
|
|
||||||
togglePinToSidebar: (roomId: string) => void;
|
|
||||||
onSpacesFound: (spaceItems: IHierarchyRoom[]) => void;
|
onSpacesFound: (spaceItems: IHierarchyRoom[]) => void;
|
||||||
onOpenRoom: MouseEventHandler<HTMLButtonElement>;
|
onOpenRoom: MouseEventHandler<HTMLButtonElement>;
|
||||||
};
|
};
|
||||||
|
|
@ -58,8 +56,6 @@ export const SpaceHierarchy = forwardRef<HTMLDivElement, SpaceHierarchyProps>(
|
||||||
disabledReorder,
|
disabledReorder,
|
||||||
nextSpaceId,
|
nextSpaceId,
|
||||||
getRoom,
|
getRoom,
|
||||||
pinned,
|
|
||||||
togglePinToSidebar,
|
|
||||||
onOpenRoom,
|
onOpenRoom,
|
||||||
onSpacesFound,
|
onSpacesFound,
|
||||||
},
|
},
|
||||||
|
|
@ -136,8 +132,6 @@ export const SpaceHierarchy = forwardRef<HTMLDivElement, SpaceHierarchyProps>(
|
||||||
canEditChild={
|
canEditChild={
|
||||||
!!parentPermissions?.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId())
|
!!parentPermissions?.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId())
|
||||||
}
|
}
|
||||||
pinned={pinned}
|
|
||||||
onTogglePin={togglePinToSidebar}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ import {
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
import classNames from 'classnames';
|
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 { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces';
|
||||||
import { HierarchyItem } from '../../hooks/useSpaceHierarchy';
|
import { HierarchyItem } from '../../hooks/useSpaceHierarchy';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
|
@ -38,6 +38,7 @@ import { useOpenCreateSpaceModal } from '../../state/hooks/createSpaceModal';
|
||||||
import { AddExistingModal } from '../add-existing';
|
import { AddExistingModal } from '../add-existing';
|
||||||
import { CreateRoomType } from '../../components/create-room/types';
|
import { CreateRoomType } from '../../components/create-room/types';
|
||||||
import { BetaNoticeBadge } from '../../components/BetaNoticeBadge';
|
import { BetaNoticeBadge } from '../../components/BetaNoticeBadge';
|
||||||
|
import { joinEstateRoomOrThrow } from '../estate/joinEstateRoom';
|
||||||
|
|
||||||
function SpaceProfileLoading() {
|
function SpaceProfileLoading() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -112,8 +113,8 @@ function UnjoinedSpaceProfile({
|
||||||
}: UnjoinedSpaceProfileProps) {
|
}: UnjoinedSpaceProfileProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
|
|
||||||
const [joinState, join] = useAsyncCallback<Room, MatrixError, []>(
|
const [joinState, join] = useAsyncCallback<string, Error, []>(
|
||||||
useCallback(() => mx.joinRoom(roomId, { viaServers: via }), [mx, roomId, via])
|
useCallback(() => joinEstateRoomOrThrow(mx, roomId, via), [mx, roomId, via])
|
||||||
);
|
);
|
||||||
|
|
||||||
const canJoin = joinState.status === AsyncStatus.Idle || joinState.status === AsyncStatus.Error;
|
const canJoin = joinState.status === AsyncStatus.Idle || joinState.status === AsyncStatus.Error;
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { Membership } from '../../../types/matrix/room';
|
||||||
import { RoomInputPlaceholder } from './RoomInputPlaceholder';
|
import { RoomInputPlaceholder } from './RoomInputPlaceholder';
|
||||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||||
import { getViaServers } from '../../plugins/via-servers';
|
import { getViaServers } from '../../plugins/via-servers';
|
||||||
|
import { joinEstateRoomOrThrow } from '../estate/joinEstateRoom';
|
||||||
|
|
||||||
type RoomTombstoneProps = { roomId: string; body?: string; replacementRoomId: string };
|
type RoomTombstoneProps = { roomId: string; body?: string; replacementRoomId: string };
|
||||||
export function RoomTombstone({ roomId, body, replacementRoomId }: RoomTombstoneProps) {
|
export function RoomTombstone({ roomId, body, replacementRoomId }: RoomTombstoneProps) {
|
||||||
|
|
@ -18,16 +19,14 @@ export function RoomTombstone({ roomId, body, replacementRoomId }: RoomTombstone
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
const currentRoom = mx.getRoom(roomId);
|
const currentRoom = mx.getRoom(roomId);
|
||||||
const via = currentRoom ? getViaServers(currentRoom) : [];
|
const via = currentRoom ? getViaServers(currentRoom) : [];
|
||||||
return mx.joinRoom(replacementRoomId, {
|
return joinEstateRoomOrThrow(mx, replacementRoomId, via);
|
||||||
viaServers: via,
|
|
||||||
});
|
|
||||||
}, [mx, roomId, replacementRoomId])
|
}, [mx, roomId, replacementRoomId])
|
||||||
);
|
);
|
||||||
const replacementRoom = mx.getRoom(replacementRoomId);
|
const replacementRoom = mx.getRoom(replacementRoomId);
|
||||||
|
|
||||||
const handleOpen = () => {
|
const handleOpen = () => {
|
||||||
if (replacementRoom) navigateRoom(replacementRoom.roomId);
|
if (replacementRoom) navigateRoom(replacementRoom.roomId);
|
||||||
if (joinState.status === AsyncStatus.Success) navigateRoom(joinState.data.roomId);
|
if (joinState.status === AsyncStatus.Success) navigateRoom(joinState.data);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
79
src/app/features/scene/README.md
Normal file
|
|
@ -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.
|
||||||
443
src/app/features/scene/Scene.test.ts
Normal file
|
|
@ -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<string, Fn> = {};
|
||||||
|
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<string, unknown>, {
|
||||||
|
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<typeof fakeCanvas>) => 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<Parameters<typeof createScene>[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);
|
||||||
|
});
|
||||||
|
});
|
||||||
638
src/app/features/scene/Scene.ts
Normal file
|
|
@ -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<PassName, number>;
|
||||||
|
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<PassName, WebGLProgram>;
|
||||||
|
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<WebGLProgram, Map<string, WebGLUniformLocation | null>>();
|
||||||
|
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<number, Partial<Record<PassName, number>>>();
|
||||||
|
|
||||||
|
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<PassName, number>;
|
||||||
|
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));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
41
src/app/features/scene/budget.test.ts
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
59
src/app/features/scene/budget.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
42
src/app/features/scene/camera.test.ts
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
56
src/app/features/scene/camera.ts
Normal file
|
|
@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
27
src/app/features/scene/creatures.test.ts
Normal file
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
37
src/app/features/scene/creatures.ts
Normal file
|
|
@ -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<Record<SizeBucket, number>> = {
|
||||||
|
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<Creature, 'sizeBucket' | 'neutral'>): number | null {
|
||||||
|
if (c.neutral || c.sizeBucket === undefined) return null;
|
||||||
|
return Object.prototype.hasOwnProperty.call(BUCKET_SCALE, c.sizeBucket) ? BUCKET_SCALE[c.sizeBucket] : null;
|
||||||
|
}
|
||||||
82
src/app/features/scene/cultmathTrim.test.ts
Normal file
|
|
@ -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'));
|
||||||
|
});
|
||||||
|
});
|
||||||
81
src/app/features/scene/cultmathTrim.ts
Normal file
|
|
@ -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<string, Decl[]>();
|
||||||
|
decls.forEach((d) => byName.set(d.name, [...(byName.get(d.name) ?? []), d]));
|
||||||
|
|
||||||
|
const kept = new Set<Decl>();
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
33
src/app/features/scene/index.ts
Normal file
|
|
@ -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<SceneOptions, 'tier' | 'patchSource' | 'onCompileFail'> & {
|
||||||
|
/** 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<Scene | null> {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
27
src/app/features/scene/palette.ts
Normal file
|
|
@ -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;
|
||||||
41
src/app/features/scene/shaderSource.ts
Normal file
|
|
@ -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<Stage, string> = {
|
||||||
|
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]}`;
|
||||||
|
}
|
||||||
99
src/app/features/scene/shaders/CultMath.Phacelle.glsl
Normal file
|
|
@ -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
|
||||||
1035
src/app/features/scene/shaders/CultMath.glsl
Normal file
19
src/app/features/scene/shaders/README.md
Normal file
|
|
@ -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.
|
||||||
90
src/app/features/scene/shaders/atmosphere.frag.glsl
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
31
src/app/features/scene/shaders/common.glsl
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
26
src/app/features/scene/shaders/composite.frag.glsl
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
8
src/app/features/scene/shaders/fullscreen.vert.glsl
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
57
src/app/features/scene/shaders/prepass.frag.glsl
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
41
src/app/features/scene/shaders/resolve.frag.glsl
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
95
src/app/features/scene/shaders/sdf.glsl
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
108
src/app/features/scene/shaders/terrain.frag.glsl
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
26
src/app/features/scene/tier.test.ts
Normal file
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
49
src/app/features/scene/tier.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
40
src/app/features/scene/world.test.ts
Normal file
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
117
src/app/features/scene/world.ts
Normal file
|
|
@ -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');
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,7 @@ import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||||
import { SequenceCard } from '../../../components/sequence-card';
|
import { SequenceCard } from '../../../components/sequence-card';
|
||||||
import { SequenceCardStyle } from '../styles.css';
|
import { SequenceCardStyle } from '../styles.css';
|
||||||
import { SettingTile } from '../../../components/setting-tile';
|
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 { clearCacheAndReload } from '../../../../client/initMatrix';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
|
||||||
|
|
@ -38,23 +38,23 @@ export function About({ requestClose }: AboutProps) {
|
||||||
<Box shrink="No">
|
<Box shrink="No">
|
||||||
<img
|
<img
|
||||||
style={{ width: toRem(60), height: toRem(60) }}
|
style={{ width: toRem(60), height: toRem(60) }}
|
||||||
src={CinnySVG}
|
src={RatatoskrSVG}
|
||||||
alt="Cinny logo"
|
alt="Ratatoskr logo"
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<Box direction="Column" gap="300">
|
<Box direction="Column" gap="300">
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Box gap="100" alignItems="End">
|
<Box gap="100" alignItems="End">
|
||||||
<Text size="H3">Cinny</Text>
|
<Text size="H3">Ratatoskr</Text>
|
||||||
<Text size="T200">v4.12.8</Text>
|
<Text size="T200">v4.12.8</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Text>Yet another matrix client.</Text>
|
<Text>GameCult's Matrix client.</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box gap="200" wrap="Wrap">
|
<Box gap="200" wrap="Wrap">
|
||||||
<Button
|
<Button
|
||||||
as="a"
|
as="a"
|
||||||
href="https://github.com/cinnyapp/cinny"
|
href="https://eureka.gamecult.org/forge/GameCult/Ratatoskr"
|
||||||
rel="noreferrer noopener"
|
rel="noreferrer noopener"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
variant="Secondary"
|
variant="Secondary"
|
||||||
|
|
@ -65,20 +65,19 @@ export function About({ requestClose }: AboutProps) {
|
||||||
>
|
>
|
||||||
<Text size="B300">Source Code</Text>
|
<Text size="B300">Source Code</Text>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
as="a"
|
|
||||||
href="https://cinny.in/#sponsor"
|
|
||||||
rel="noreferrer noopener"
|
|
||||||
target="_blank"
|
|
||||||
variant="Critical"
|
|
||||||
fill="Soft"
|
|
||||||
size="300"
|
|
||||||
radii="300"
|
|
||||||
before={<Icon src={Icons.Heart} size="100" filled />}
|
|
||||||
>
|
|
||||||
<Text size="B300">Support</Text>
|
|
||||||
</Button>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
<Text size="T300">
|
||||||
|
Based on{' '}
|
||||||
|
<a
|
||||||
|
href="https://github.com/cinnyapp/cinny"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
>
|
||||||
|
Cinny
|
||||||
|
</a>{' '}
|
||||||
|
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.
|
||||||
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ function ExportKeys() {
|
||||||
const blob = new Blob([encKeys], {
|
const blob = new Blob([encKeys], {
|
||||||
type: 'text/plain;charset=us-ascii',
|
type: 'text/plain;charset=us-ascii',
|
||||||
});
|
});
|
||||||
FileSaver.saveAs(blob, 'cinny-keys.txt');
|
FileSaver.saveAs(blob, 'ratatoskr-keys.txt');
|
||||||
},
|
},
|
||||||
[mx]
|
[mx]
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ function EmailNotification() {
|
||||||
device_display_name: email,
|
device_display_name: email,
|
||||||
lang: 'en',
|
lang: 'en',
|
||||||
data: {
|
data: {
|
||||||
brand: 'Cinny',
|
brand: 'Ratatoskr',
|
||||||
},
|
},
|
||||||
append: true,
|
append: true,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
12
src/app/features/soundscape/device.ts
Normal file
|
|
@ -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);
|
||||||
108
src/app/features/soundscape/frame.ts
Normal file
|
|
@ -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 = <T extends string>(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<string, unknown>;
|
||||||
|
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<string, ActivityFrame>();
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/app/features/soundscape/scene.ts
Normal file
|
|
@ -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<Size, number> = { 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);
|
||||||
47
src/app/features/soundscape/socket.ts
Normal file
|
|
@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||