Compare commits
|
|
@ -1,8 +1,3 @@
|
|||
// Airbnb's own bans, kept: a `no-restricted-syntax` entry below replaces the whole list.
|
||||
const airbnbRestrictedSyntax = require('eslint-config-airbnb-base/rules/style').rules[
|
||||
'no-restricted-syntax'
|
||||
];
|
||||
|
||||
module.exports = {
|
||||
env: {
|
||||
browser: true,
|
||||
|
|
@ -67,21 +62,6 @@ module.exports = {
|
|||
"@typescript-eslint/no-shadow": "error"
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
// The one join path: a join is `joinEstateRoom`, which files the DM before it sends the join.
|
||||
files: ['src/**/*.ts', 'src/**/*.tsx'],
|
||||
excludedFiles: ['src/app/features/estate/joinEstateRoom.ts', 'src/**/*.test.*'],
|
||||
rules: {
|
||||
'no-restricted-syntax': [
|
||||
...airbnbRestrictedSyntax,
|
||||
{
|
||||
selector: "CallExpression[callee.property.name='joinRoom']",
|
||||
message:
|
||||
'Join a room through joinEstateRoom (features/estate/joinEstateRoom.ts), the one join path.',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['*.ts'],
|
||||
rules: {
|
||||
|
|
|
|||
20
.github/workflows/build-pull-request.yml
vendored
|
|
@ -4,14 +4,12 @@ on:
|
|||
pull_request:
|
||||
types: ['opened', 'synchronize']
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-pr-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-pull-request:
|
||||
name: Build pull request
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PR_NUMBER: ${{github.event.number}}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
|
@ -26,3 +24,17 @@ jobs:
|
|||
env:
|
||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
||||
run: npm run build
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: preview
|
||||
path: dist
|
||||
retention-days: 1
|
||||
- name: Save pr number
|
||||
run: echo ${PR_NUMBER} > ./pr.txt
|
||||
- name: Upload pr number
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: pr
|
||||
path: ./pr.txt
|
||||
retention-days: 1
|
||||
|
|
|
|||
36
.github/workflows/cla.yml
vendored
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
name: 'CLA Assistant'
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_target:
|
||||
types: [opened, closed, synchronize]
|
||||
|
||||
jobs:
|
||||
CLAssistant:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 'CLA Assistant'
|
||||
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
|
||||
# Beta Release
|
||||
uses: cla-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# the below token should have repo scope and must be manually added by you in the repository's secret
|
||||
PERSONAL_ACCESS_TOKEN: ${{ secrets.CLA_PAT }}
|
||||
with:
|
||||
path-to-signatures: 'signatures.json'
|
||||
path-to-document: 'https://github.com/cinnyapp/cla/blob/main/cla.md' # e.g. a CLA or a DCO document
|
||||
# branch should not be protected
|
||||
branch: 'main'
|
||||
allowlist: ajbura,bot*
|
||||
|
||||
#below are the optional inputs - If the optional inputs are not given, then default values will be taken
|
||||
remote-organization-name: cinnyapp
|
||||
remote-repository-name: cla
|
||||
#create-file-commit-message: 'For example: Creating file for storing CLA Signatures'
|
||||
#signed-commit-message: 'For example: $contributorName has signed the CLA in #$pullRequestNo'
|
||||
#custom-notsigned-prcomment: 'pull request comment with Introductory message to ask new contributors to sign'
|
||||
#custom-pr-sign-comment: 'The signature to be committed in order to sign the CLA'
|
||||
#custom-allsigned-prcomment: 'pull request comment when all contributors has signed, defaults to **CLA Assistant Lite bot** All Contributors have signed the CLA.'
|
||||
#lock-pullrequest-aftermerge: false - if you don't want this bot to automatically lock the pull request after merging (default - true)
|
||||
#use-dco-flag: true - If you are using DCO instead of CLA
|
||||
60
.github/workflows/deploy-pull-request.yml
vendored
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
name: Deploy PR to Netlify
|
||||
run-name: "Deploy PR to Netlify (${{ github.event.workflow_run.head_branch }})"
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Build pull request"]
|
||||
types: [completed]
|
||||
|
||||
jobs:
|
||||
deploy-pull-request:
|
||||
name: Deploy pull request
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
steps:
|
||||
- name: Download pr number
|
||||
uses: dawidd6/action-download-artifact@d63b86af1b34672e53c440b1b83979861906bad7 # v24
|
||||
with:
|
||||
workflow: ${{ github.event.workflow.id }}
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
name: pr
|
||||
- name: Validate and output pr number
|
||||
id: pr
|
||||
run: |
|
||||
PR_ID=$(<pr.txt)
|
||||
if ! [[ "${PR_ID}" =~ ^[0-9]+$ ]]; then
|
||||
echo "::error::pr.txt contains non-numeric content: ${PR_ID}"
|
||||
exit 1
|
||||
fi
|
||||
echo "id=${PR_ID}" >> "${GITHUB_OUTPUT}"
|
||||
- name: Download artifact
|
||||
uses: dawidd6/action-download-artifact@d63b86af1b34672e53c440b1b83979861906bad7 # v24
|
||||
with:
|
||||
workflow: ${{ github.event.workflow.id }}
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
name: preview
|
||||
path: dist
|
||||
- name: Deploy to Netlify
|
||||
id: netlify
|
||||
uses: nwtgck/actions-netlify@d22a32a27c918fe470bbc562e984f80ec48c2668 # v4.0.0
|
||||
with:
|
||||
publish-dir: dist
|
||||
deploy-message: "Deploy PR ${{ steps.pr.outputs.id }}"
|
||||
alias: ${{ steps.pr.outputs.id }}
|
||||
# These don't work because we're in workflow_run
|
||||
enable-pull-request-comment: false
|
||||
enable-commit-comment: false
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN_PR }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID_PR_CINNY }}
|
||||
timeout-minutes: 1
|
||||
- name: Comment preview on PR
|
||||
uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
|
||||
with:
|
||||
number: ${{ steps.pr.outputs.id }}
|
||||
message: |
|
||||
Preview: ${{ steps.netlify.outputs.deploy-url }}
|
||||
⚠️ Exercise caution. Use test accounts. ⚠️
|
||||
5
.github/workflows/docker-pr.yml
vendored
|
|
@ -5,10 +5,7 @@ on:
|
|||
paths:
|
||||
- 'Dockerfile'
|
||||
- '.github/workflows/docker-pr.yml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-pr-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
- '.github/workflows/prod-deploy.yml'
|
||||
|
||||
jobs:
|
||||
docker-build:
|
||||
|
|
|
|||
4
.github/workflows/lockfile.yml
vendored
|
|
@ -5,10 +5,6 @@ on:
|
|||
paths:
|
||||
- 'package-lock.json'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-pr-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
lockfile_changes:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
|
|||
39
.github/workflows/netlify-dev.yml
vendored
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
name: Deploy to Netlify (dev)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
|
||||
jobs:
|
||||
deploy-to-netlify:
|
||||
name: Deploy to Netlify
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version-file: ".node-version"
|
||||
package-manager-cache: false
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Build app
|
||||
env:
|
||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
||||
run: npm run build
|
||||
- name: Deploy to Netlify
|
||||
uses: nwtgck/actions-netlify@d22a32a27c918fe470bbc562e984f80ec48c2668 # v4.0.0
|
||||
with:
|
||||
publish-dir: dist
|
||||
deploy-message: 'Dev deploy ${{ github.sha }}'
|
||||
enable-commit-comment: false
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
production-deploy: true
|
||||
github-deployment-environment: nightly
|
||||
github-deployment-description: 'Nightly deployment on each commit to dev branch'
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID_DEV }}
|
||||
timeout-minutes: 1
|
||||
15
.github/workflows/pr-title.yml
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
name: Check PR title
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
100
.github/workflows/prod-deploy.yml
vendored
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
name: Production deploy
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
deploy-and-tarball:
|
||||
name: Netlify deploy and tarball
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version-file: '.node-version'
|
||||
package-manager-cache: false
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Build app
|
||||
env:
|
||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
||||
run: npm run build
|
||||
- name: Deploy to Netlify
|
||||
uses: nwtgck/actions-netlify@d22a32a27c918fe470bbc562e984f80ec48c2668 # v4.0.0
|
||||
with:
|
||||
publish-dir: dist
|
||||
deploy-message: 'Prod deploy ${{ github.ref_name }}'
|
||||
enable-commit-comment: false
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
production-deploy: true
|
||||
github-deployment-environment: stable
|
||||
github-deployment-description: 'Stable deployment on each release'
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID_APP }}
|
||||
timeout-minutes: 1
|
||||
- name: Get version from tag
|
||||
id: vars
|
||||
run: echo "tag=${GITHUB_REF#refs/*/}" >> $GITHUB_OUTPUT
|
||||
- name: Create tar.gz
|
||||
run: tar -czvf cinny-${{ steps.vars.outputs.tag }}.tar.gz dist
|
||||
- name: Sign tar.gz
|
||||
run: |
|
||||
echo '${{ secrets.GNUPG_KEY }}' | gpg --batch --import
|
||||
# Sadly a few lines in the private key match a few lines in the public key,
|
||||
# As a result just --export --armor gives us a few lines replaced with ***
|
||||
# making it useless for importing the signing key. Instead, we dump it as
|
||||
# non-armored and hex-encode it so that its printable.
|
||||
echo "PGP Signing key, in raw PGP format in hex. Import with cat ... | xxd -r -p - | gpg --import"
|
||||
gpg --export | xxd -p
|
||||
echo '${{ secrets.GNUPG_PASSPHRASE }}' | gpg --batch --yes --pinentry-mode loopback --passphrase-fd 0 --armor --detach-sign cinny-${{ steps.vars.outputs.tag }}.tar.gz
|
||||
- name: Upload tagged release
|
||||
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
files: |
|
||||
cinny-${{ steps.vars.outputs.tag }}.tar.gz
|
||||
cinny-${{ steps.vars.outputs.tag }}.tar.gz.asc
|
||||
|
||||
publish-image:
|
||||
name: Push Docker image to Docker Hub, GHCR
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4.3.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
- name: Login to Docker Hub #Do not update this action from a outside PR
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Login to the Github Container registry #Do not update this action from a outside PR
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Extract metadata (tags, labels) for Docker, GHCR
|
||||
id: meta
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_USERNAME }}/cinny
|
||||
ghcr.io/${{ github.repository }}
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
46
.github/workflows/rat-dev-preview.yml
vendored
|
|
@ -1,46 +0,0 @@
|
|||
name: Rat dev preview
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['dev']
|
||||
|
||||
# One run per ref wins: a newer push to dev cancels the older queued or running one, so the run
|
||||
# that builds rat-dev-dist is always the newest. Pull-request runs are other workflows and other
|
||||
# groups; they never cancel it.
|
||||
concurrency:
|
||||
group: rat-dev-preview
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build dev preview
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version-file: ".node-version"
|
||||
package-manager-cache: false
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Build app
|
||||
env:
|
||||
CINNY_BASE: /void/rat-dev/
|
||||
VITE_PREVIEW_DEMO: '1'
|
||||
NODE_OPTIONS: '--max_old_space_size=3072'
|
||||
run: npm run build
|
||||
- name: Set background scene tier and the pretend homeserver
|
||||
run: node scripts/preview-config.js low --demo
|
||||
- name: Add the demo pose art
|
||||
run: mkdir -p dist/public/poses && cp -r preview-demo/poses/. dist/public/poses/
|
||||
- name: Upload artifact
|
||||
# v3 protocol: the v4 client keeps only the origin of ACTIONS_RESULTS_URL, dropping this forge's /forge subpath
|
||||
# (nginx 404 page, runs 170-171), and upstream v4+ refuses non-GitHub servers outright (run 169).
|
||||
uses: forgejo/upload-artifact@v3
|
||||
with:
|
||||
name: rat-dev-dist
|
||||
path: dist
|
||||
retention-days: 3
|
||||
5
.gitignore
vendored
|
|
@ -4,7 +4,4 @@ node_modules
|
|||
devAssets
|
||||
|
||||
.DS_Store
|
||||
.idea
|
||||
# Pose art is synced from the cast-art catalogue (npm run sync:poses), not committed.
|
||||
public/poses/*
|
||||
!public/poses/.gitkeep
|
||||
.idea
|
||||
|
|
@ -4,4 +4,3 @@ package.json
|
|||
package-lock.json
|
||||
LICENSE
|
||||
harness
|
||||
src/app/components/message/pose/catalogue.json
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
# Contributing to Ratatoskr
|
||||
# Contributing to Cinny
|
||||
|
||||
First off, thanks for taking the time to contribute! ❤️
|
||||
|
||||
This document describes the process of contributing to Ratatoskr. It is intended
|
||||
This document describes the process of contributing to Cinny. It is intended
|
||||
for anyone considering opening an **issue**, **discussion** or **pull request**.
|
||||
For people who are interested in developing Cinny and technical details behind
|
||||
it, please check out our ["Developing Cinny"](HACKING.md) document as well.
|
||||
|
|
@ -24,7 +24,7 @@ explain what your changes do and how they interact with the greater system, do n
|
|||
|
||||
## AI Usage
|
||||
|
||||
The Cinny project strictly does not allow any AI generated code. Any pull request containing it will be rejected. **This is very important.**
|
||||
The Cinny project stictly does not allow any AI generated code. Any pull request containing it will be rejected. **This is very important.**
|
||||
|
||||
## Quick Guide
|
||||
|
||||
|
|
@ -35,7 +35,7 @@ The Cinny project strictly does not allow any AI generated code. Any pull reques
|
|||
> When contributing to this project, you must agree that you have authored 100%
|
||||
> of the content, that you have the necessary rights to the content and that
|
||||
> the content you contribute may be provided under the project license. You will
|
||||
> also be asked to [sign the CLA] ([why CLA?]) upon submitting your pull request.
|
||||
> also be asked to [sign the CLA] ([why CLA?]) upon submiting your pull request.
|
||||
|
||||
[All issues are actionable](#issues-are-actionable). Pick one and start
|
||||
working on it. Thank you. If you need help or guidance, comment on the issue.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Developing Ratatoskr
|
||||
# Developing Cinny
|
||||
|
||||
> [!TIP]
|
||||
> We recommend using a version manager as versions change very quickly.
|
||||
|
|
@ -36,13 +36,13 @@ source and serves it with Nginx on port 80. To use this locally, you can
|
|||
build the container like so:
|
||||
|
||||
```
|
||||
docker build -t ratatoskr:latest .
|
||||
docker build -t cinny:latest .
|
||||
```
|
||||
|
||||
You can then run the container you've built with a command similar to this:
|
||||
|
||||
```
|
||||
docker run -p 8080:80 ratatoskr:latest
|
||||
docker run -p 8080:80 cinny:latest
|
||||
```
|
||||
|
||||
This will forward your `localhost` port 8080 to the container's port 80.
|
||||
|
|
|
|||
140
README.md
|
|
@ -1,43 +1,125 @@
|
|||
# Ratatoskr
|
||||
# Cinny
|
||||
<p>
|
||||
<a href="https://github.com/ajbura/cinny/releases">
|
||||
<img alt="GitHub release downloads" src="https://img.shields.io/github/downloads/ajbura/cinny/total?logo=github&style=social"></a>
|
||||
<a href="https://hub.docker.com/r/ajbura/cinny">
|
||||
<img alt="DockerHub downloads" src="https://img.shields.io/docker/pulls/ajbura/cinny?logo=docker&style=social"></a>
|
||||
<a href="https://fosstodon.org/@cinnyapp">
|
||||
<img alt="Follow on Mastodon" src="https://img.shields.io/mastodon/follow/106845779685925461?domain=https%3A%2F%2Ffosstodon.org&logo=mastodon&style=social"></a>
|
||||
<a href="https://twitter.com/intent/follow?screen_name=cinnyapp">
|
||||
<img alt="Follow on Twitter" src="https://img.shields.io/twitter/follow/cinnyapp?logo=twitter&style=social"></a>
|
||||
<a href="https://cinny.in/#sponsor">
|
||||
<img alt="Sponsor Cinny" src="https://img.shields.io/opencollective/all/cinny?logo=opencollective&style=social"></a>
|
||||
</p>
|
||||
|
||||
GameCult's Matrix chat client, a fork of [Cinny](https://github.com/cinnyapp/cinny).
|
||||
It opens on GameCult's estate (spaces, project rooms grouped by steward, Minds,
|
||||
and your own place in it) instead of an invite list. Mobile first.
|
||||
A Matrix client focusing primarily on simple, elegant and secure interface.
|
||||
The main goal is to have an instant messaging application that is easy on
|
||||
people and has a modern touch.
|
||||
- [Roadmap](https://github.com/orgs/cinnyapp/projects/1)
|
||||
- [Contributing](./CONTRIBUTING.md)
|
||||
|
||||
**Live now:** the Cinny engine (matrix-js-sdk, Rust crypto, sync) under a
|
||||
Ratatoskr UI: branding, GameCult theme, estate navigation, pose portraits
|
||||
(opt-in setting), a background scene and soundscape (opt-in, off by default).
|
||||
**Planned:** see [`docs/gamecult/ratatoskr-target.md`](docs/gamecult/ratatoskr-target.md)
|
||||
for the target and cut sequence. Internal names that are protocol or stored
|
||||
state (`cinny_*` localStorage keys, the `in.cinny.room.power_level_tags` event
|
||||
type, `CINNY_BASE`) are unchanged on purpose.
|
||||
> [!IMPORTANT]
|
||||
We are currently in the process of [replacing] the matrix-js-sdk with our
|
||||
own SDK. As a result, we will not be accepting any pull requests until
|
||||
further notice. Thank you for your understanding.
|
||||
|
||||
Entry points: `src/app/` (client UI), `src/app/features/scene/` (background
|
||||
scene), `src/app/features/soundscape/` (audio socket), `harness/` (estate
|
||||
drive scripts), `scripts/gen-brand.mjs` (regenerates the logo and icons).
|
||||
[replacing]: https://github.com/cinnyapp/cinny/issues/257#issuecomment-3714406704
|
||||
|
||||
## Upstream
|
||||
Ratatoskr is based on Cinny by Ajay Bura and contributors, licensed AGPL-3.0.
|
||||
Cinny's own README, desktop app and releases live at
|
||||
<https://github.com/cinnyapp/cinny>. We do not ship its releases or images.
|
||||
<img align="center" src="https://raw.githubusercontent.com/cinnyapp/cinny-site/main/assets/preview2-light.png" height="380">
|
||||
|
||||
## Developing
|
||||
See [HACKING.md](HACKING.md). Contribution notes are in [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
## Getting started
|
||||
The web app is available at [app.cinny.in] and gets updated on each new
|
||||
release. The `dev` branch is continuously deployed at [dev.cinny.in]
|
||||
but keep in mind that it could have things broken.
|
||||
|
||||
You can also download our desktop app from the [cinny-desktop repository].
|
||||
|
||||
[app.cinny.in]: https://app.cinny.in
|
||||
[dev.cinny.in]: https://dev.cinny.in
|
||||
[cinny-desktop repository]: https://github.com/cinnyapp/cinny-desktop
|
||||
|
||||
## Contributing and Developing
|
||||
|
||||
If you have any ideas, issues, etc. regarding Cinny, or would like to
|
||||
contribute to Cinny through pull requests, please check out our
|
||||
["Contributing to Cinny"](CONTRIBUTING.md) document. Those who would like
|
||||
to get involved with Cinny's development as well should also read the
|
||||
["Developing Cinny"](HACKING.md) document for more technical details.
|
||||
|
||||
## Self-hosting
|
||||
Build with `npm ci && npm run build` and serve `dist/` with your webserver.
|
||||
To host Cinny on your own, simply download the tarball from
|
||||
[GitHub releases], and serve the files from `dist/` using your preferred
|
||||
webserver. Alternatively, you can just pull the docker image from
|
||||
[DockerHub] or [GitHub Container Registry].
|
||||
|
||||
* The default homeservers and explore pages are defined in [`config.json`](config.json).
|
||||
* Serving the assets needs redirects to `index.html`. Examples:
|
||||
[netlify](netlify.toml), [nginx](contrib/nginx/ratatoskr.domain.tld.conf),
|
||||
[caddy](contrib/caddy/caddyfile). Without them you can
|
||||
[enable hash routing](config.json) (`hashRouter`); URLs then carry `/#/`.
|
||||
* To deploy on a subdirectory, rebuild with the `base` path set, either in
|
||||
[`build.config.ts`](build.config.ts) or through `CINNY_BASE`
|
||||
(for example `CINNY_BASE=/app/ npm run build`).
|
||||
|
||||
* You need to set up redirects to serve the assests. Example configurations;
|
||||
[netlify](netlify.toml), [nginx](contrib/nginx/cinny.domain.tld.conf),
|
||||
[caddy](contrib/caddy/caddyfile).
|
||||
* If you have trouble configuring redirects you can
|
||||
[enable hash routing](config.json#L35) — the url in the browser will have
|
||||
a `/#/` between the domain and open channel (ie. `app.cinny.in/#/home/`
|
||||
instead of `app.cinny.in/home/`) but you won't have to configure your webserver.
|
||||
|
||||
* To deploy on subdirectory, you need to rebuild the app youself after
|
||||
updating the `base` path in [`build.config.ts`](build.config.ts).
|
||||
* For example, if you want to deploy on `https://cinny.in/app`, then
|
||||
set `base: '/app'`.
|
||||
|
||||
[GitHub releases]: https://github.com/cinnyapp/cinny/releases/latest
|
||||
[DockerHub]: https://hub.docker.com/r/ajbura/cinny
|
||||
[GitHub Container Registry]: https://github.com/cinnyapp/cinny/pkgs/container/cinny
|
||||
|
||||
<details><summary><b>PGP Public Key to verify tarball</b></summary>
|
||||
|
||||
```
|
||||
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
|
||||
mQGNBGJw/g0BDAC8qQeLqDMzYzfPyOmRlHVEoguVTo+eo1aVdQH2X7OELdjjBlyj
|
||||
6d6c1adv/uF2g83NNMoQY7GEeHjRnXE4m8kYSaarb840pxrYUagDc0dAbJOGaCBY
|
||||
FKTo7U1Kvg0vdiaRuus0pvc1NVdXSxRNQbFXBSwduD+zn66TI3HfcEHNN62FG1cE
|
||||
K1jWDwLAU0P3kKmj8+CAc3h9ZklPu0k/+t5bf/LJkvdBJAUzGZpehbPL5f3u3BZ0
|
||||
leZLIrR8uV7PiV5jKFahxlKR5KQHld8qQm+qVhYbUzpuMBGmh419I6UvTzxuRcvU
|
||||
Frn9ttCEzV55Y+so4X2e4ZnB+5gOnNw+ecifGVdj/+UyWnqvqqDvLrEjjK890nLb
|
||||
Pil4siecNMEpiwAN6WSmKpWaCwQAHEGDVeZCc/kT0iYfj5FBcsTVqWiO6eaxkUlm
|
||||
jnulqWqRrlB8CJQQvih/g//uSEBdzIibo+ro+3Jpe120U/XVUH62i9HoRQEm6ADG
|
||||
4zS5hIq4xyA8fL8AEQEAAbQdQ2lubnlBcHAgPGNpbm55YXBwQGdtYWlsLmNvbT6J
|
||||
AdQEEwEIAD4CGwMFCwkIBwIGFQoJCAsCBBYCAwECHgECF4AWIQSRri2MHidaaZv+
|
||||
vvuUMwx6UK/M8wUCZqEDwAUJFvwIswAKCRCUMwx6UK/M877qC/4lxXOQIoWnLLkK
|
||||
YiRCTkGsH6NdxgeYr6wpXT4xuQ45ZxCytwHpOGQmO/5up5961TxWW8D1frRIJHjj
|
||||
AZGoRCL3EKEuY8nt3D99fpf3DvZrs1uoVAhiyn737hRlZAg+QsJheeGCmdSJ0hX5
|
||||
Yud8SE+9zxLS1+CEjMrsUd/RGre/phme+wNXfaHfREAC9ewolgVChPIbMxG2f+vs
|
||||
K8Xv52BFng7ta9fgsl1XuOjpuaSbQv6g+4ONk/lxKF0SmnhEGM3dmIYPONxW47Yf
|
||||
atnIjRra/YhPTNwrNBGMmG4IFKaOsMbjW/eakjWTWOVKKJNBMoDdRcYYWIMCpLy8
|
||||
AQUrMtQEsHSnqCwrw818S5A6rrhcfVGk36RGm0nOy6LS5g5jmqaYsvbCcBGY9B2c
|
||||
SUAVNm17oo7TtEajk8hcSXoZod1t++pyjcVKEmSn3nFK7v5m3V+cPhNTxZMK459P
|
||||
3x1Ucqj/kTqrxKw6s2Uknuk0ajmw0ljV+BQwgL6maguo9BKgCNW5AY0EYnD+DQEM
|
||||
ANOu/d6ZMF8bW+Df9RDCUQKytbaZfa+ZbIHBus7whCD/SQMOhPKntv3HX7SmMCs+
|
||||
5i27kJMu4YN623JCS7hdCoXVO1R5kXCEcneW/rPBMDutaM472YvIWMIqK9Wwl5+0
|
||||
Piu2N+uTkKhe9uS2u7eN+Khef3d7xfjGRxoppM+xI9dZO+jhYiy8LuC0oBohTjJq
|
||||
QPqfGDpowBwRkkOsGz/XVcesJ1Pzg4bKivTS9kZjZSyT9RRSY8As0sVUN57AwYul
|
||||
s1+eh00n/tVpi2Jj9pCm7S0csSXvXj8v2OTdK1jt4YjpzR0/rwh4+/xlOjDjZEqH
|
||||
vMPhpzpbgnwkxZ3X8BFne9dJ3maC5zQ3LAeCP5m1W0hXzagYhfyjo74slJgD1O8c
|
||||
LDf2Oxc5MyM8Y/UK497zfqSPfgT3NhQmhHzk83DjXw3I6Z3A3U+Jp61w0eBRI1nx
|
||||
H1UIG+gldcAKUTcfwL0lghoT3nmi9JAbvek0Smhz00Bbo8/dx8vwQRxDUxlt7Exx
|
||||
NwARAQABiQG8BBgBCAAmAhsMFiEEka4tjB4nWmmb/r77lDMMelCvzPMFAmahA9IF
|
||||
CRb8CMUACgkQlDMMelCvzPPQgQv/d5/z+fxgKqgfhQX+V49X4WgTVxZ/CzztDoJ1
|
||||
XAq1dzTNEy8AFguXIo6eVXPSpMxec7ZreN3+UPQBnCf3eR5YxWNYOYKmk0G4E8D2
|
||||
KGUJept7TSA42/8N2ov6tToXFg4CgzKZj0fYLwgutly7K8eiWmSU6ptaO8aEQBHB
|
||||
gTGIOO3h6vJMGVycmoeRnHjv4wV84YWSVFSoJ7cY0he4Z9UznJBbE/KHZjrkXsPo
|
||||
N+Gg5lDuOP5xjKzM5SogV9lhxBAhMWAg3URUF15yruZBiA8uV1FOK8sal/9C1G7V
|
||||
M6ygA6uOZqXlZtcdA94RoSsW2pZ9eLVPsxz2B3Zko7tu11MpNP/wYmfGTI3KxZBj
|
||||
n/eodvwjJSgHpGOFSmbNzvPJo3to5nNlp7wH1KxIMc6Uuu9hgfDfwkFZgV2bnFIa
|
||||
Q6gyF548Ub48z7Dz83+WwLgbX19ve4oZx+dqSdczP6ILHRQomtrzrkkP2LU52oI5
|
||||
mxFo+ioe/ABCufSmyqFye0psX3Sp
|
||||
=WtqZ
|
||||
-----END PGP PUBLIC KEY BLOCK-----
|
||||
```
|
||||
</details>
|
||||
|
||||
## License
|
||||
Ratatoskr is a fork of the Cinny project
|
||||
Cinny Project
|
||||
Copyright © 2024–present Ajay Bura
|
||||
https://cinny.in
|
||||
|
||||
|
|
|
|||
29
config.json
|
|
@ -1,40 +1,21 @@
|
|||
{
|
||||
"defaultHomeserver": 0,
|
||||
"homeserverList": [
|
||||
"gamecult.org",
|
||||
"converser.eu",
|
||||
"matrix.org",
|
||||
"mozilla.org",
|
||||
"unredacted.org",
|
||||
"xmr.se"
|
||||
],
|
||||
"defaultHomeserver": 1,
|
||||
"homeserverList": ["converser.eu", "matrix.org", "mozilla.org", "unredacted.org", "xmr.se"],
|
||||
"allowCustomHomeservers": true,
|
||||
|
||||
"featuredCommunities": {
|
||||
"openAsDefault": false,
|
||||
"spaces": ["#gamecult:gamecult.org"],
|
||||
"rooms": [
|
||||
"#tuwunel:grin.hu",
|
||||
"#free-software-movement:matrix.org",
|
||||
"#freesoftware:matrix.org",
|
||||
"#gentoo:matrix.org"
|
||||
],
|
||||
"servers": ["activematrixrooms.com", "matrixrooms.info", "matrix.org", "mozilla.org", "unredacted.org"]
|
||||
"servers": ["matrixrooms.info", "matrix.org", "mozilla.org", "unredacted.org"]
|
||||
},
|
||||
|
||||
"hashRouter": {
|
||||
"enabled": false,
|
||||
"basename": "/"
|
||||
},
|
||||
|
||||
"backgroundScene": {
|
||||
"tier": "off"
|
||||
},
|
||||
|
||||
"dings": {
|
||||
"enabled": false
|
||||
},
|
||||
|
||||
"registration": {
|
||||
"enabled": false,
|
||||
"url": ""
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# more info: https://caddyserver.com/docs/caddyfile/patterns#single-page-apps-spas
|
||||
ratatoskr.domain.tld {
|
||||
root * /path/to/ratatoskr/dist
|
||||
cinny.domain.tld {
|
||||
root * /path/to/cinny/dist
|
||||
try_files {path} / index.html
|
||||
file_server
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name ratatoskr.domain.tld;
|
||||
server_name cinny.domain.tld;
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
|
|
@ -15,10 +15,10 @@ server {
|
|||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl;
|
||||
server_name ratatoskr.domain.tld;
|
||||
server_name cinny.domain.tld;
|
||||
|
||||
location / {
|
||||
root /opt/ratatoskr/dist/;
|
||||
root /opt/cinny/dist/;
|
||||
|
||||
rewrite ^/config.json$ /config.json break;
|
||||
rewrite ^/manifest.json$ /manifest.json break;
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
# File zone: design sketch
|
||||
|
||||
Status: design only. No code exists. Everything below is a proposal; items marked **UNKNOWN** are not yet verified against the Body.
|
||||
|
||||
Source: Meta ruling 2026-10-31, eureka mind `ratatoskr:ruling:file-zone-looks-like-file-sharing`.
|
||||
|
||||
## What it is
|
||||
|
||||
A file-sharing space beside the room timeline, not a view of it. The zone is **one ordinary Matrix room with threaded discussions, one thread per Mind**. The UI presents each thread as an **album** (title, item count, date) and each post in it as a **file** with an inline player or preview. Matrix is the storage and the fallback: another client sees a normal room with threads and never needs the zone. No homeserver change.
|
||||
|
||||
First use (Aqua): listen to audio in place, mark each clip keep / fix / drop with a one-line reason, export the marks as a file. See `/opt/commons/handoff/aqua-listen-kits-20261108/` (static `listen.html` kits that emit `verdict.json` + `verdict.md`); the zone replaces sending that page around.
|
||||
|
||||
## Matrix event shapes (stock events only)
|
||||
|
||||
- **Zone** = one room, chosen by the client (**UNKNOWN:** how the client knows which room is the zone: a room-account-data flag, a `m.room.create` type, or a configured room id in `config.json`; config is simplest).
|
||||
- **Album** = a thread root: a text `m.room.message` posted by a Mind, `body` = the album title (the Mind's name, e.g. `void`). One thread per Mind, owned by authorship (the root's sender). **UNKNOWN:** whether a Mind may open more than one (e.g. per kit); the shape allows it with no change, the zone would then list several albums per author. Count and last-activity come from the SDK's thread summary (`unsigned.m.relations.m.thread`); album date = root `origin_server_ts`. Rename = `m.replace` on the root.
|
||||
- **File** = a post in the thread: an `m.room.message` with `msgtype` `m.audio` / `m.image` / `m.video` / `m.file` and `m.relates_to: { rel_type: "m.thread", event_id: <root>, is_falling_back: true, "m.in_reply_to": { event_id: <last thread event> } }`. The builders in `src/app/features/room/msgContent.ts` already make the content. Delete = redact.
|
||||
- **Album vs chat thread**: with a single-purpose room, every thread is an album. No marker key is needed. A non-media reply in a thread (chat, a mark) is not a file and is not listed as one.
|
||||
- **Cover** = first image file in the thread, if any. Loose room messages outside any thread are not shown in the zone; they remain in the timeline.
|
||||
|
||||
## Marks (reactions, plus an optional reply)
|
||||
|
||||
A mark is stock Matrix, so every client shows it. Two reaction sets on the **file post**, and one optional reply:
|
||||
|
||||
- **Verdict** = an `m.annotation` reaction (`m.reaction` event, `m.relates_to: { rel_type: "m.annotation", event_id: <file post>, key }`) with key **👍 keep**, **🔧 fix**, **🗑 drop**. One per person per file: choosing another replaces the old one (the client redacts the sender's previous verdict reaction, then sends the new one); tapping the active one clears it.
|
||||
- **Landed** = a second reaction set, keys **1️⃣ 2️⃣ 3️⃣ 4️⃣ 5️⃣** (keycap digits, 1-5). Also one per person per file, same replace rule. The two sets are independent: a file can have a verdict, a landed score, both or neither.
|
||||
- **Reason** = an ordinary thread reply under the file: `m.room.message` `m.text`, `m.relates_to: { rel_type: "m.thread", event_id: <album root>, "m.in_reply_to": { event_id: <file post> } }`. Optional. **Keep needs none**; the UI prompts for one mainly after fix or drop. The latest such reply by the sender under that file is the reason (edits via `m.replace` count). No special key: the reply text is the reason, so nothing is lost if another client wrote it.
|
||||
- **No private mode.** Marks are room events the moment they are tapped; there is no local-only draft and no "Share" step. Offline taps queue in the SDK's normal send queue.
|
||||
- Who marked what is read from the reactions' senders, so each listener is distinguishable and the album can show tallies per file.
|
||||
- Why reactions: one tap, readable in any client, no custom event type, and they can be changed with no history growth beyond a redaction. Reaction keys carry no text, hence the separate reply for the reason.
|
||||
- **UNKNOWN:** emoji variation selectors. 🗑 and 🔧 and the keycaps may arrive with or without U+FE0F from different clients; match by normalizing (strip U+FE0F) before comparing keys. Also **UNKNOWN:** servers' per-event limits on distinct reaction keys; a handful per file is far below any default.
|
||||
|
||||
## Export
|
||||
|
||||
Client-side only. "Export" builds Aqua's verdict file from the verdict and landed reactions and the reply under each file in one album (one listener's marks per export; that sender is the `listener`) and either downloads it or uploads it into the album. Two files, as the kits write them: `verdict.json` and `verdict.md`.
|
||||
|
||||
`verdict.json` is schema **`aqua.listen.verdict.v1`**, field for field what `buildVerdict` in the kit (`dings/listen.html`, section `buildVerdict`) emits, so Aqua's tooling reads it unchanged:
|
||||
|
||||
```
|
||||
{ schema: "aqua.listen.verdict.v1",
|
||||
kit: { id, title }, // album root: id = root event id (or the kit id if the upload carried one), title = root body
|
||||
exportedAt, // ISO time of export
|
||||
seed, consent, // kit-only: zone writes seed: 0 (or omits), consent: true for the sender's own export (see UNKNOWN)
|
||||
listener, // sender display name
|
||||
device, // headphones|speakers|null, the sender's most-used mark device, or null
|
||||
notes, // free text; zone leaves "" in v1
|
||||
summary: { total, keep, fix, drop, unanswered },
|
||||
clips: [{ position, id, title, group, feel, file, pair, slot, verdict, reason, landed, device }],
|
||||
pairs: [{ a, b, preferred }] } // empty array when the album has no pairs
|
||||
```
|
||||
|
||||
- `clips` has one row per file post in the album, in thread order (`position` = 1-based). The join per file, for the exporting listener: `verdict` = their 👍/🔧/🗑 reaction (keep/fix/drop) or null; `landed` = their keycap reaction (1-5) or null; `reason` = text of their latest reply under the file, or "". A file with no verdict reaction counts as `unanswered` even if it has a reply. `device` per clip is null (reactions cannot carry it); the top-level `device` (headphones|speakers|null) is asked once in the export dialog.
|
||||
- `pairs`, `seed`, `consent` are optional for the zone: `pairs: []`, no `slot`/`pair` (null) unless the clip metadata supplies them.
|
||||
- **Where clip metadata comes from.** `id` is the clip id from `org.gamecult.clip`, else the filename stem. `title` = file post `body`; `file` = the post's `filename`/`body`. `group`, `feel`, `pair`, `slot` are kit data that Matrix has no slot for: carry them on the file post as one extra key `org.gamecult.clip: { id, group, feel, pair, slot }` written by whoever uploads the kit (a small uploader script, **UNKNOWN**, not in the first slice; without it those columns export as ""/null and the id falls back to the filename stem).
|
||||
- `verdict.md` follows `verdictMarkdown` from the same file: header line, count line, the clip table (`# | clip | group | intended feel | verdict | landed (1-5) | device | reason`), pairs table when `pairs` is nonempty, notes.
|
||||
- To avoid drift, the exporter should be a port of `buildVerdict`/`verdictMarkdown` with the schema string and field order pinned by a test that compares against a fixture produced by the kit. **UNKNOWN:** whether to share code (the kit's `logic.cjs`) rather than port; recommend port, ~80 lines, because the kit is a standalone page.
|
||||
- **UNKNOWN:** `consent` is a kit-side checkbox before listening. The zone has no such step; recommend writing `true` only if the sender ticks a one-time "include my name in exports" toggle, else `false` and `listener: ""`.
|
||||
- JSON here is a xenos-boundary export, not state; the room events are the state.
|
||||
|
||||
## Mobile-first layout
|
||||
|
||||
- Phone width is the design width; desktop adds columns.
|
||||
- Level 1, album list: one column, each row = cover/icon, title, "N files", date. Tap opens.
|
||||
- Level 2, album: list by default (title, duration/size), grid toggle for images. Audio rows play in place, one at a time (starting a clip stops the last), a persistent mini-player at the bottom with seek.
|
||||
- Mark controls on each audio row: three large tap targets (👍 keep / 🔧 fix / 🗑 drop) with tallies, a row of five "landed" chips (1-5), and after fix or drop an inline one-line reason field (skippable). Thumb-reachable, no modal.
|
||||
- Back is one level up; the room timeline stays one tap away (the zone is a sibling view, not a replacement). **UNKNOWN:** where the entry lives in the room header; check `RoomViewHeader.tsx`, `Room.tsx` for how the member drawer and pin menu attach.
|
||||
|
||||
## Upload
|
||||
|
||||
Reuse the composer's pipeline, pointed at an album instead of the main timeline:
|
||||
|
||||
- `createUploadAtom` / `useBindUploadAtom` (`src/app/state/upload.ts`), `UploadCard`, `upload-board` for progress.
|
||||
- On success, the msgContent builder produces the event; the zone adds the thread relation to the album root before sending (the composer already does this for thread replies, `RoomInput.tsx`).
|
||||
- "New album" = send the root text event, then upload into its thread. Multi-file drop = N events, sent in order.
|
||||
- Size limit from `MediaConfigLoader` (server `m.upload.size`). **UNKNOWN:** whether the 32 MB `shelf` kit's clips (42 x ~0.75 MB) should be uploaded individually or batched; individually is the only option without a server change.
|
||||
|
||||
## Existing code to reuse (verified present in the repo)
|
||||
|
||||
| need | where |
|
||||
|---|---|
|
||||
| audio player (play/seek/volume, encrypted download) | `src/app/components/message/content/AudioContent.tsx` (210 lines) |
|
||||
| video, image, generic file | `.../content/VideoContent.tsx`, `ImageContent.tsx`, `FileContent.tsx` |
|
||||
| full-screen image viewer | `src/app/components/image-viewer/ImageViewer.tsx` |
|
||||
| media dispatch by msgtype | `src/app/components/RenderMessageContent.tsx`, `message/MsgTypeRenderers.tsx` |
|
||||
| upload + progress | `state/upload.ts`, `components/upload-card`, `upload-board` |
|
||||
| event content builders | `features/room/msgContent.ts` |
|
||||
| thread plumbing | `features/room/message/Message.tsx`, `RoomTimeline.tsx`, `RoomInput.tsx` (thread relation on send) |
|
||||
| room-scoped file/pin precedent | `features/room/room-pin-menu/RoomPinMenu.tsx` |
|
||||
|
||||
Nothing named "gallery" exists as a feature; `AudioContent` takes its controls as render props (`renderMediaControl`), so it can be rehosted in a row without a fork. **UNKNOWN:** whether `AudioContent` can be rendered outside a timeline event context (it needs only `url`, `info`, `mimeType`, `encInfo`, from the event content, so probably yes).
|
||||
|
||||
## First slice (what to build first)
|
||||
|
||||
Rough lines of TypeScript, tests excluded.
|
||||
|
||||
1. **Read-only albums + album view** (~250 lines): list the room's thread roots as albums (grouped or filterable by author = Mind), open one to list its file posts, rehost `AudioContent` and `ImageContent`. One route/panel plus a header entry. Depends on the thread list the SDK already keeps.
|
||||
2. **Marks + export** (~240 lines, with the schema port): verdict and landed reaction sets with the replace-one rule, optional reason reply, per-file tallies, the join into `aqua.listen.verdict.v1` and `verdict.json`/`verdict.md` download. This is Aqua's workflow end to end.
|
||||
3. **Upload into an album** (~150 lines): thread-related send through the existing upload atoms, "new album".
|
||||
|
||||
Slices 1+2 serve the first use case. Each is its own PR. Because marks are ordinary thread replies, there is no separate "publish" slice.
|
||||
|
||||
## Open questions for the Meta
|
||||
|
||||
- How the client identifies the zone room (config id recommended).
|
||||
- Whether exports are per listener (proposed) or a merged all-listeners file.
|
||||
- Where `device` comes from, now that reactions cannot carry it (export dialog, proposed).
|
||||
|
|
@ -1,242 +0,0 @@
|
|||
# Mind feedback pages
|
||||
|
||||
> The desk reads `gamecult.fork.*` now, not the `org.gamecult.feedback.*` projections named below; see CultLib `contracts/cultnet/governance/fork-card.md` (src/app/features/desk/forkContract.ts).
|
||||
|
||||
Design only. No code exists for this; nothing here is built. Drafted by Kiko Self (2026-11-07) with Heimdall's draft rulings
|
||||
on guests and hiding; placement ruled for this repo by Void. Follow-up `eureka-body:follow_up:ratatoskr-mind-feedback-pages`.
|
||||
Every number below is provisional.
|
||||
|
||||
**Schema owner.** The Huginn kinds, their fields, keys and refusals are specified in `huginn-feedback-kinds-proposal.md` (Eureka Self, 2026-11-07, in
|
||||
`/opt/commons/handoff`), adopted for this page by Void's Persona. Where this page names a field of those kinds it mirrors that proposal; if they differ, the proposal wins.
|
||||
This repo owns only the client: a typed interface mirroring those shapes, and fixtures.
|
||||
|
||||
Each Mind's section in the estate navigator opens on that Mind's **feedback page**: the Mind's open questions with options, its
|
||||
recommendation and what each waits on, answerable by the operator and anyone with an account. It replaces the old Fork Desk
|
||||
(taken from the brief; no trace of it was found in this repo or in the Minds' state).
|
||||
|
||||
## 1. Placement
|
||||
|
||||
Each Mind's navigator section **opens on its feedback page**, rooms below it. A cross-Mind list of everything waiting on a person
|
||||
is wanted later and is not part of this spec. On phones the page folds each question's options and the answer form behind its title,
|
||||
so the section root stays a list of questions, not a wall of forms.
|
||||
|
||||
## 2. Authority
|
||||
|
||||
| Concern | Owner | The page's part |
|
||||
|---|---|---|
|
||||
| The question | a typed `question` document in the **owning Mind's own mind**, key `<campaign>:question:<label>` | a view of it; the page stores and edits nothing |
|
||||
| Which questions appear | the owning Mind, by a `feedback_listing` (the opt-in act) carrying `audience` and `close_grants`; un-listing is a `Withdrawn` resolution on it | shows only what has a listing in force |
|
||||
| An answer | its author. The **receiver (Bifrost) holds the text**; the Mind holds a **text-free `feedback_answer` record** | submits the answer to the receiver |
|
||||
| The ruling | the owning Mind, recorded by her | **never writes one**; links to it once she has |
|
||||
| Status (open, answered, closed) | derived in the Mind from its documents (`closed` is a `Withdrawn` resolution on the question), never written | shows it |
|
||||
| Who answered, and as what | the Bifrost account behind the Heimdall session, plus its role at the moment of the answer | shows it; never takes it from the client |
|
||||
| Close | a `feedback_close_request` the owning Mind honours or declines; it never changes the question's status by itself | offers Close only to a grant holder; **the page writes no ruling** |
|
||||
| Hiding | the owning Mind alone, as a `feedback_hide`; unhide is a `Withdrawn` resolution on it | shows the labelled stub; hides nothing itself |
|
||||
| Erase | Bifrost's erase of an account; the receiver drops the text | shows "erased account" |
|
||||
|
||||
An answer is input to a Mind, not a vote and not a decision. **The page never tallies**: no counts, no totals, no "leading" option, no
|
||||
ordering by popularity, no count of reports or close requests. Only the Mind's ruling picks anything.
|
||||
|
||||
## 3. Receiver and carriers
|
||||
|
||||
- **Schema owner: `huginn-feedback-kinds-proposal.md`.** Four new Huginn kinds, all Eureka's: `feedback_listing` (audience, `close_grants` with no Guest
|
||||
grant, the opt-in act), `feedback_answer` (a text-free record), `feedback_close_request` and `feedback_hide`. Unhide, un-list and "request declined"
|
||||
are `resolution`s on those kinds. This page proposes no kinds.
|
||||
- **Bifrost is the receiver** (Heimdall's side). It verifies the Heimdall session, reads the account, stamps `answerer`, `kind`, weight and `time`,
|
||||
**stores the answer text**, delivers it, serves the projection the page reads, and **does the erase**. A Matrix sender is a different
|
||||
identity authority and never becomes `answerer`.
|
||||
- **Huginn is RUDP-only.** The client never talks to Huginn, and the receiver never writes to it: an admission declared for another instance is refused
|
||||
(`ForeignInstance`), so a receiver "admitting as Kiko" would be unauthenticated by construction.
|
||||
- **Delivery is by mouth.** The receiver delivers each answer or close request to the owning Mind's Persona through the mouth's pending/ack path (a new
|
||||
delivery document, `gamecult.persona.feedback_delivery.v1`, owned by Bifrost). **The Persona admits the text-free record into her own mind** and acks only
|
||||
after it is admitted; a restart redelivers the same record id, which is `AlreadyAdmitted`. The text reaches her turn once, in the delivery, and is never
|
||||
admitted.
|
||||
- Dependencies that must ship first, in the proposal's order: Huginn readers (daemon, CLI, clients), then the Persona organ, then Bifrost's receiver.
|
||||
|
||||
## 4. Mock
|
||||
|
||||
```
|
||||
Kiko > Feedback [synced 2 min ago]
|
||||
--------------------------------------------------------------------------------------
|
||||
Open questions [ Open ] [ Settled ]
|
||||
|
||||
OPEN | asked 6 Nov 2026 09:12 | waiting 1 day
|
||||
Which source feeds audio_spectrum? [ Details v ]
|
||||
The visualizer contract assumes a host-side spectrum ... (3 lines, then "Show more")
|
||||
Waits on: "Overlay-wide effect budget" (open, links to that question)
|
||||
|
||||
( ) A Overlay's own audio only
|
||||
( ) B OBS captured stream Kiko recommends this
|
||||
( ) C Loopback of the mix, microphone excluded
|
||||
( ) Something else: [ up to 2000 characters ]
|
||||
Answers are visible to everyone who can see this question.
|
||||
[ Send answer ]
|
||||
[ Ask Kiko to close this question ] <- only if you hold the close grant on it
|
||||
|
||||
Answers (every one, oldest first)
|
||||
Meta operator B OBS captured stream 6 Nov 21:04
|
||||
Aqua Mind B OBS captured stream 6 Nov 21:40
|
||||
Nibu member C Loopback of the mix 7 Nov 08:12
|
||||
Heimdall Mind "Either, if latency is measured first." 7 Nov 09:02
|
||||
a guest guest A Overlay's own audio only 7 Nov 09:15
|
||||
(answer hidden) guest Hidden by Kiko, 7 Nov, spam
|
||||
Earlier answers by the same person fold under their latest.
|
||||
--------------------------------------------------------------------------------------
|
||||
SETTLED | Kiko ruled: "..." (link to the ruling) | 5 Nov
|
||||
```
|
||||
|
||||
- Each row is the author's name and **kind**, the choice or text, and the time. Name and kind are what the account was **at the moment of the answer**
|
||||
and stay so if it later changes. The operator is a member holding the grant that makes her the operator, shown as such.
|
||||
- The recommendation is stated in words, not colour, and is never preselected.
|
||||
- Answering a settled question is not offered.
|
||||
|
||||
## 5. Data contract
|
||||
|
||||
The **Huginn shapes belong to the proposal** (section 2 there): `feedback_listing`, `feedback_answer`, `feedback_close_request`, `feedback_hide`. The client
|
||||
declares a typed interface that mirrors them as the page's projection and imports nothing it cannot decode from them.
|
||||
|
||||
**Question projection** (`org.gamecult.feedback.question.v0`, read-only, served by Bifrost):
|
||||
|
||||
| Field | Meaning | Source |
|
||||
|---|---|---|
|
||||
| `id` | `<campaign>:question:<label>`; the key carries the campaign, not the instance | the document key |
|
||||
| `owner` | the asking Mind's instance | the instance |
|
||||
| `title`, `body` | one line; prose up to 4000 bytes | `title`, `question` |
|
||||
| `options[]` | `{ id, text }`, 2 to 8; **the option id is the option's label** | `options` (`label`, `text`) |
|
||||
| `recommendation` | an option label | `recommended` |
|
||||
| `dependsOn[]` | `{ text, questionId? }`; an id only where the line cites one verbatim | `depends` (free text) |
|
||||
| `askedAt` | the question's receipt `committed_at` | the receipt; `asked_on` is only a date |
|
||||
| `audience`, `closeGrants[]` | `AccountHolders` or `MembersOnly`, no default; grantees are accounts or the kinds `Member` and `Mind`, **never `Guest`** | the in-force `feedback_listing` |
|
||||
| `status` | `open`, `answered` (with a `rulingId`) or `closed`, derived | the question's resolutions |
|
||||
|
||||
**Answer row** (`org.gamecult.feedback.answer.v0`, assembled by the receiver from its stored record and the Mind's text-free `feedback_answer`):
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `recordId` | `<question-label>.a<N>`, N the receiver's per-question sequence |
|
||||
| `answerer` | the **Bifrost account id** (stable; the authenticator handle is cleared on erase); never a display name, never client-supplied |
|
||||
| `kind` | `Guest`, `User`, `Patron`, `Member` or `Mind`, read from Bifrost's account state **at answer time**; the session carries identity, never role |
|
||||
| `weightPermille` | stamped 0 for a guest, 1000 otherwise; **the page sums nothing** |
|
||||
| `choice` / `text` | an option label, free text up to 2000 characters, or both; at least one. Text exists only in the receiver's store and the projection, never in a mind |
|
||||
| `time` | when the receiver accepted it |
|
||||
|
||||
Invariants: the receiver takes `answerer`, `kind` and weight from the verified session and account state, never from the request body; an unknown option
|
||||
label is refused; the latest record per account is its standing answer, derived at read time; display names are looked up for display and are never keys. An
|
||||
account may answer only while its status is `active`, read at answer time. A guest cannot request close, and a guest grant cannot be written. Answers to a
|
||||
settled question are kept. Nothing is edited or deleted, except the receiver's redaction on erase (section 7).
|
||||
|
||||
## 6. Flow
|
||||
|
||||
```
|
||||
owning Mind's mind --(Bifrost projection)--> page page --(answer, Heimdall session)--> receiver (Bifrost; holds the text)
|
||||
receiver --(mouth delivery)--> owning Mind's Persona --(admits text-free feedback_answer)--> her mind
|
||||
the Mind decides and records the ruling herself --> status derives --> projection updates --> page moves it to Settled
|
||||
```
|
||||
|
||||
1. **View.** The page shows the projection of the questions with a `feedback_listing` in force, filtered by the viewer's kind against `audience`. The mind
|
||||
stays the truth; the projection is rebuilt, never hand-edited, and republished on a heartbeat so freshness is visible.
|
||||
2. **Answer.** The page submits with the person's Heimdall session; the receiver stamps, stores the text and appends the record.
|
||||
3. **Return.** The receiver delivers by mouth; the Persona admits the text-free record and reads the text once. Each arrives as **quoted, attributed data**
|
||||
(record id, author, kind, time), never in an instruction position: text from a guest, user or patron is untrusted, and she records a ruling from it, never
|
||||
an action. The receiver does no keyword screening. She does not quote answer text into memory or a ruling; she cites the record id.
|
||||
4. **Decide.** She records the ruling in her mind. The next projection carries `answered` and the ruling link.
|
||||
5. **Close.** A grant holder's request is delivered as a `feedback_close_request`. The Mind honours it by resolving the question, or declines with a
|
||||
`Recorded` resolution on the request; the request never changes the status itself. The page shows who asked and never how many.
|
||||
|
||||
## 7. Guests, erasure and hiding
|
||||
|
||||
- **Guests may answer** and see what the Mind opted in for their audience; they may not close (refused at the receiver and again by Huginn, and no listing can
|
||||
name a Guest grantee). A guest answer carries weight 0 by construction and is tallied nowhere. Guests see display name and kind only, never a Matrix id,
|
||||
provider handle or GitHub login.
|
||||
- **Erase redacts.** Records are append-only and are **never edited, but may be redacted**. A mind cannot redact: every admission's receipt keeps its exact
|
||||
payload bytes, which is why the text never enters one. When Bifrost erases an account, **the receiver drops that account's answer text and name** and keeps record
|
||||
id, kind and time only. **A content hash is not kept**: an unsalted hash would confirm a guessed text. The author shows as "erased account". The receiver delivers an erase notice and the Persona admits a `feedback_hide` with
|
||||
reason `ErasedAuthor`, the Mind's acknowledgement rather than a moderation call. The honest residue: a Mind's own memory or ruling may already quote an
|
||||
answer, and Huginn cannot redact it; the rule that she cites record ids, never text, is a prompt, not an invariant.
|
||||
- **Only the owning Mind hides**, as a `feedback_hide`: target (an answer or a question), reason from the closed set (`PrivateMatter`, `Abuse`, `Spam`,
|
||||
`OffTopic`, `AuthorRequest`, `ErasedAuthor`) and a line of at most 200 bytes, with its admission receipt as the audit trail. Unhide is a `Withdrawn`
|
||||
resolution on the hide. No count of reports ever hides anything and there is no auto-hide threshold; the operator orders a hide in conversation and the
|
||||
Mind records it.
|
||||
- **Anyone with an account may report** an answer; the receiver delivers it to the Mind as input. The page shows nothing of it and never a count. An author
|
||||
reporting their own answer uses `AuthorRequest` and the Mind honours it.
|
||||
- **What a hidden item shows.** A hidden answer keeps its row (author, kind, time, "Hidden by <Mind>, <date>, <reason>") with its text withheld for every
|
||||
reason. A hidden question shows a stub with its title, body, options and answers withheld; taking a question off the page is a `Withdrawn` resolution on its
|
||||
listing. The answer box says answers are visible to everyone who can see the question.
|
||||
- **Private matter** is never listed. An answer carrying someone's private matter is hidden with `PrivateMatter`.
|
||||
- Guest answers are collected for nothing but delivery to the owning Mind.
|
||||
- **No content hash anywhere.** No answer, close-request or redaction shape in this page carries one. The proposal's `feedback_answer` lists a `content_hash`
|
||||
field; per Heimdall it must go, and Eureka owns that change.
|
||||
|
||||
## 7a. Receiver: credential, store, names
|
||||
|
||||
Source: Bifrost PR #10, `docs/feedback-receiver.md` (design, nothing implemented).
|
||||
|
||||
- **Credential: route 2.** Ratatoskr presents the **OIDC access token Bifrost already issues to chat**, and Bifrost verifies it itself. The client holds no new
|
||||
secret and needs no Heimdall token. The account is the token's subject, `UserAccount.Id`. **The claims check is still open**: which claims the token carries
|
||||
and which Bifrost must verify for this endpoint have not been read.
|
||||
- **Store: a CultCache file** (Eureka's pick), owned and written only by the receiver. **Backups need a stated purge or expiry before any deploy**, so that
|
||||
erased text does not survive in them; the number is left to Eureka and Heimdall.
|
||||
- **Display names.** The receiver must not reuse `CurrentBifrostActor.DisplayName`, which falls back to the GitHub login and then to "Guest". It stores the
|
||||
name at answer time from the member nickname or account display name only, and "an account" when both are empty. Never a provider handle, GitHub login,
|
||||
Heimdall id or Matrix localpart.
|
||||
- **No guest exists until Bifrost PR #9 lands.** On main an active membership is a member, an authenticated or pending one a user, and nobody is a guest, so the
|
||||
guest rules in this page have no account to apply to until then. An answer needs an active account, read at answer time.
|
||||
|
||||
## 8. States
|
||||
|
||||
- **Empty:** "Nothing is waiting on you from Kiko", when it last synced, a link to Settled. Never blank.
|
||||
- **No answers:** "No answers yet", with the question's age.
|
||||
- **Stale projection:** past two heartbeats (provisionally 30 minutes) the header reads "may be out of date"; answering stays possible. A projection that
|
||||
never arrived says "Kiko's questions have not been published yet", not "no questions".
|
||||
- **Long open:** past 14 days (provisional) shows its age; it is not demoted or hidden.
|
||||
- **Blocked:** shows the dependency chip with that question's status; answering stays allowed.
|
||||
- **Settled while answering:** the answer is kept for the Mind and the page says it arrived after the question settled.
|
||||
- **Signed out, expired, or not active:** answers are shown, the form asks to sign in or says why it cannot; nothing typed is lost.
|
||||
- **Send failed:** the text stays in the field with the reason and a retry.
|
||||
|
||||
## 9. Build order
|
||||
|
||||
1. The client's typed interface, mirroring the proposal's shapes and the projections in section 5, and **fixtures** for every state in section 8 plus the
|
||||
guest, erased and hidden rows. The page renders from fixtures; no network.
|
||||
2. The proposal's kinds and migration (Eureka: readers first, then the Persona organ), and the Bifrost account publisher, receiver and mouth delivery
|
||||
(Heimdall). The page switches from fixtures to the projection.
|
||||
|
||||
## 10. Accessibility
|
||||
|
||||
- Semantic structure: `main` with a heading; each question an `article` with its title as a heading; options a `fieldset`/`legend` radio group; the
|
||||
free-text field labelled, with a character counter announced politely. The phone fold is a button with `aria-expanded`.
|
||||
- No meaning by colour alone: status, recommendation, kind, hidden and close-grant are text.
|
||||
- Keyboard: everything reachable in a sensible order; after Send, focus goes to a confirmation naming the question and the answer.
|
||||
- New answers and status changes arrive in an `aria-live="polite"` region, never as a focus steal.
|
||||
- Targets at least 44 px; reduced motion respected; contrast per theme.
|
||||
- Names and answers are user text: `dir="auto"`, control and bidi characters stripped, long words wrap.
|
||||
|
||||
## 11. Still open
|
||||
|
||||
1. **Emergency withhold.** If the owning Mind is away and an answer shows something illegal or dangerous, may the host withhold the text at the receiver?
|
||||
Heimdall leans no: it is a second owner. Meta's fork.
|
||||
2. **How a Mind authenticates to answer** (kind `Mind`): Bifrost has no `Mind` role and the account publisher `gamecult.bifrost.account.v1` does not exist yet.
|
||||
3. **Erase is bounded.** Text a Mind has already quoted into her own documents cannot be redacted; absolute erase would mean the text never enters her turn.
|
||||
4. **`AccountId` grammar**, pinned to Bifrost's account id shape when that publisher lands.
|
||||
5. **Receiver claims.** The OIDC access token's claims for this endpoint (section 7a) are unread.
|
||||
6. **Reader ordering.** Whether an old Huginn reader skips or fails on a mind holding a kind it does not know is unverified (proposal section 6.4); it decides
|
||||
when the first Mind may list a question.
|
||||
|
||||
## 12. The desk: the page as the Fork Desk's descendant
|
||||
|
||||
Added 2026-11-10 by Void from Meta's brief and the original Fork Desk (`/opt/commons/handoff/fork-desk-original/`, Nibu's newest copy; working note `/opt/commons/handoff/ratatoskr-desk-spec-20261110.md`, section 11). The feedback page **is** the desk: one surface, not two. Design only.
|
||||
|
||||
**Keep from the original.**
|
||||
- Questions stream as rows (not one at a time): title, body as context, options with the recommended one marked **in words**, the `depends` line, a free-text note carrying the person's words verbatim, and a "recorded as rulings" list of settled questions with their ruling id. A pick is optional; the note can answer alone.
|
||||
- A **booth Persona**: the owning Mind's pose beside the rows on wide screens, a small fixed corner image on phones, reacting by mood: no forks (neutral), all answered (triumph), none answered (thinking), partway (determined, "N to go"), a pick equal to the recommendation (happy), a different pick (surprised, "a line on why helps"), a cleared pick (note), a failed send (concerned), a new fork arriving (surprised). The rules are a pure function of the forks and the last event (`deskMood`), tested exactly. Poses come from the shared cast art; a Mind without art shows no booth rather than another's face.
|
||||
- Answers save as the person acts, and she can close the page and come back.
|
||||
|
||||
**What changes.**
|
||||
- The row is the section 5 question projection. The page changes nothing about who owns a question, a ruling or a status (section 2).
|
||||
- Phones: each row's options and note fold behind its title (section 1); the entry point is a **"Desk · N open"** line on the Mind's group header in the navigator, hidden at zero. Chromeless: hairlines are allowed, panels, cards and pills are not.
|
||||
- **Answer rule and wire.** The answer rule (the body the client may send, `options_hash`, the counting order, retraction, the stored `desk_answer`) is owned by Heimdall's rule file `/opt/commons/handoff/fork-desk-answer-binding-20261110.md`; the wire is owned by CultLib #72. This section restates neither. The client's part: it sends only what the rule's body allows (never `answerer` or authority), recomputes `options_hash` from what it rendered and refuses to send on a mismatch with the projection's.
|
||||
- **A pick sends at once** (one tap, one record; the latest per account stands, section 5). **The note autosaves as a local draft** and is sent on blur or an explicit Send, never per keystroke. "Saved" says which: sent, or kept on this device.
|
||||
- **Clear pick is a retraction**, a revision of the answer under the retraction rule in Heimdall's rule file (section 1.3a there); the one exception to section 5's "at least one of choice or text" is that rule's, not this page's. Until the receiver implements it, Clear pick clears only an unsent draft.
|
||||
- "Self leans" and a provisional flag exist in the original; the question has no field for either, so the desk shows neither until one exists.
|
||||
|
||||
**Order.** Section 9 step 1 stands: typed interface and fixtures first. The desk's first slice is read-only (note field disabled with a plain sentence that answering opens with sign-in), and answering waits on Bifrost sign-in reaching chat, the wire (CultLib #72) and the receiver.
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
// 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 = [];
|
||||
let asserted = 0;
|
||||
exports.expect = (label, ok, detail = '') => {
|
||||
asserted += 1;
|
||||
console.log(`[ASSERT] ${ok ? 'PASS' : 'FAIL'} ${label}${ok ? '' : ` :: ${detail}`}`);
|
||||
if (!ok) fails.push(label);
|
||||
};
|
||||
/** Exit code for the drive: 4 when any assertion failed, 5 when none ran (a drive that asserts nothing proves nothing). */
|
||||
exports.done = () => {
|
||||
if (fails.length) return 4;
|
||||
return asserted === 0 ? 5 : 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);
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
// @vitest-environment node
|
||||
import { createRequire } from 'module';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// A drive that asserts nothing proves nothing: its exit code must say so.
|
||||
const fresh = () => {
|
||||
const require = createRequire(import.meta.url);
|
||||
const path = require.resolve('./check.cjs');
|
||||
delete require.cache[path];
|
||||
return require('./check.cjs') as {
|
||||
expect: (label: string, ok: boolean, detail?: string) => void;
|
||||
done: () => number;
|
||||
};
|
||||
};
|
||||
|
||||
describe('estate drive exit code', () => {
|
||||
it('is 5 when no assertion ran', () => {
|
||||
expect(fresh().done()).toBe(5);
|
||||
});
|
||||
|
||||
it('is 0 when assertions ran and all held', () => {
|
||||
vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
const c = fresh();
|
||||
c.expect('holds', true);
|
||||
expect(c.done()).toBe(0);
|
||||
});
|
||||
|
||||
it('is 4 when any assertion failed', () => {
|
||||
vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
const c = fresh();
|
||||
c.expect('holds', true);
|
||||
c.expect('breaks', false, 'detail');
|
||||
expect(c.done()).toBe(4);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
// 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);
|
||||
|
|
@ -20,10 +19,9 @@ setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 240000);
|
|||
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 tierOf = (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);
|
||||
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 tiers = async (tag) => { await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2500); for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } log('TIER', tag, '| Friend DM', await tierOf('Friend DM'), '| Slow DM', await tierOf('Slow DM')); };
|
||||
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);
|
||||
|
|
@ -40,33 +38,23 @@ setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 240000);
|
|||
};
|
||||
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);
|
||||
const bb = await body(); const k = bb.search(/Internal server error|Could not join|Joined\./); log('AFTER_FAIL', k >= 0 ? bb.slice(Math.max(0, k - 40), k + 80) : 'no message');
|
||||
await tap('Friend DM'); await pg.waitForTimeout(6000); await tiers('after retry');
|
||||
} 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);
|
||||
await tap('Friend DM'); await pg.waitForTimeout(4000);
|
||||
const bb = await body(); const k = bb.search(/fetch|Failed|network|Could not join|Joined\./i); log('AFTER_DROP', k >= 0 ? bb.slice(Math.max(0, k - 60), k + 80) : 'no message');
|
||||
await pg.waitForTimeout(6000); await tiers('after drop'); await open(); await tiers('after reload');
|
||||
} 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);
|
||||
await tap('Friend DM'); await pg.waitForTimeout(6000); await tiers('admin estate');
|
||||
} 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);
|
||||
await acceptInInbox('Friend DM'); await pg.waitForTimeout(6000); await tiers('admin inbox');
|
||||
} 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);
|
||||
await pg.waitForTimeout(30000); await tiers('inbox reload');
|
||||
} 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));
|
||||
const l = pg.locator('[aria-label="Join Slow DM"]'); log('SLOW_DM_STILL_JOINABLE', await l.count());
|
||||
}
|
||||
await quit(done());
|
||||
await quit(0);
|
||||
})().catch((e) => { log('ERROR', String(e.stack || e).slice(0, 300)); quit(1); });
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
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'];
|
||||
|
|
@ -13,30 +12,22 @@ setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 120000);
|
|||
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;
|
||||
const tierOf = (n) => 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'; }, n);
|
||||
const tiers = async (tag) => { await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2500); for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } log('TIER', tag, '| Friend DM', await tierOf('Friend DM')); };
|
||||
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; }
|
||||
if (await j.count()) { 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);
|
||||
await tiers('after deeplink join');
|
||||
} 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);
|
||||
log('JOINCTRL', await l.count()); await l.first().click(); log('CLICK', t()); await pg.waitForTimeout(4000);
|
||||
const bb = await body(); const k = bb.search(/504|Gateway|connection dropped|Could not join/i); log('AFTER_504', k >= 0 ? bb.slice(Math.max(0, k - 60), k + 120) : 'no message');
|
||||
await pg.waitForTimeout(4000); await tiers('after 504 (join delivered by sync)');
|
||||
}
|
||||
await quit(done());
|
||||
await quit(0);
|
||||
})().catch((e) => { log('ERROR', String(e.stack || e).slice(0, 300)); quit(1); });
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
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 });
|
||||
|
|
@ -15,14 +13,14 @@ let tierBefore;
|
|||
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 tierOf = (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);
|
||||
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);
|
||||
log('TIER before', await tierOf('Friend DM'));
|
||||
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);
|
||||
|
|
@ -37,12 +35,7 @@ let tierBefore;
|
|||
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();
|
||||
for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } }
|
||||
log('TIER Slow DM', await tierOf('Slow DM'), '| Late DM', await tierOf('Late DM'), '| Friend DM', await tierOf('Friend DM'));
|
||||
await b.close();
|
||||
})().catch((e) => { log('ERROR', String(e.stack || e)); process.exitCode = 1; });
|
||||
|
|
|
|||
|
|
@ -8,19 +8,19 @@ 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/
|
||||
cp /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"
|
||||
MODE=$m node /tmp/pw/drive.cjs; echo "DRIVE $m RC=$?"
|
||||
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"
|
||||
MODE=$m node /tmp/pw/${DRIVER:-drive-failures}.cjs; echo "DRIVE $m RC=$?"
|
||||
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
|
||||
|
|
@ -31,5 +31,4 @@ 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" ]
|
||||
echo DONE
|
||||
|
|
|
|||
|
|
@ -1,22 +0,0 @@
|
|||
# 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 gradAxis.cjs <shaders dir> # numeric check that noise gradients are (d/dx, d/dz-as-.y, ...): terrainNormal axis
|
||||
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.
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
// Bundles entry.ts with the scene's ?raw GLSL imports into $OUT/bundle.js plus an index.html.
|
||||
// usage: node bundle.cjs <outdir> [demo|ridge] (esbuild from NODE_PATH). `demo` bundles the ray-march
|
||||
// standalone page; `ridge` the ridge scene's page (open it with ?probe=1 for the cadence readout).
|
||||
const esbuild = require('esbuild');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const out = process.argv[2];
|
||||
const demo = process.argv[3] === 'demo';
|
||||
const ridge = process.argv[3] === 'ridge';
|
||||
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, ridge ? 'ridgeDemo.ts' : demo ? 'demo.ts' : 'entry.ts')],
|
||||
bundle: true,
|
||||
outfile: path.join(out, 'bundle.js'),
|
||||
plugins: [rawPlugin],
|
||||
format: 'iife',
|
||||
target: 'es2020',
|
||||
})
|
||||
.then(() => {
|
||||
if (ridge) {
|
||||
fs.writeFileSync(
|
||||
path.join(out, 'index.html'),
|
||||
'<!doctype html><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1"><title>Ratatoskr ridge scene</title><body style="margin:0;background:#07111a"><div id=stage style="position:relative;width:100vw;height:100vh;overflow:hidden"><canvas id=c style="width:100%;height:100%;display:block"></canvas></div><script src=bundle.js></script>'
|
||||
);
|
||||
return;
|
||||
}
|
||||
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>'
|
||||
);
|
||||
});
|
||||
|
|
@ -1,102 +0,0 @@
|
|||
// 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();
|
||||
|
|
@ -1,309 +0,0 @@
|
|||
// 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);
|
||||
});
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
// 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 };
|
||||
};
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
// Probe: the gradient cultmath_fbm_grad / cultmath_phacelle return for vec3(xz * f, c) is (d/dx, d/dz, d/dc, value),
|
||||
// so the world-z slope is .y, not .z. Compares analytic gradients with central differences at one point.
|
||||
// Usage (playwright + swiftshader): node harness/scene/gradAxis.cjs <repo>/src/app/features/scene/shaders
|
||||
const { chromium } = require('playwright'); const fs=require('fs');
|
||||
const S=process.argv[2];
|
||||
const cm=fs.readFileSync(S+'/CultMath.glsl','utf8'), ph=fs.readFileSync(S+'/CultMath.Phacelle.glsl','utf8');
|
||||
const fs_=`#version 300 es
|
||||
precision highp float; precision highp int;
|
||||
${cm}
|
||||
${ph}
|
||||
out vec4 o;
|
||||
const float F=1.0/520.0;
|
||||
float fb(vec2 p){return cultmath_fbm_grad(vec3(p*F,0.0),4,2.0,0.45).w;}
|
||||
float pc(vec2 p){return cultmath_phacelle(vec3(p/40.0,0.5),vec3(1.5,0.5,0.0),0.0,0.5).cos.w;}
|
||||
void main(){
|
||||
vec2 p=vec2(1234.5,-777.7); float e=0.5;
|
||||
int k=int(gl_FragCoord.x);
|
||||
if(k==0){vec4 g=cultmath_fbm_grad(vec3(p*F,0.0),4,2.0,0.45); o=vec4(g.xyz*F,0.0);} // analytic per metre: x,y,z
|
||||
else if(k==1){o=vec4((fb(p+vec2(e,0))-fb(p-vec2(e,0)))/(2.0*e),(fb(p+vec2(0,e))-fb(p-vec2(0,e)))/(2.0*e),0,0);}
|
||||
else if(k==2){vec4 g=cultmath_phacelle(vec3(p/40.0,0.5),vec3(1.5,0.5,0.0),0.0,0.5).cos; o=vec4(g.xyz/40.0,0.0);}
|
||||
else {o=vec4((pc(p+vec2(e,0))-pc(p-vec2(e,0)))/(2.0*e),(pc(p+vec2(0,e))-pc(p-vec2(0,e)))/(2.0*e),0,0);}
|
||||
}`;
|
||||
(async()=>{const b=await chromium.launch({args:['--use-gl=angle','--use-angle=swiftshader','--enable-unsafe-swiftshader']});
|
||||
const pg=await b.newPage();
|
||||
const r=await pg.evaluate(([src])=>{const c=document.createElement('canvas');const g=c.getContext('webgl2');g.getExtension('EXT_color_buffer_float');
|
||||
const mk=(t,s)=>{const x=g.createShader(t);g.shaderSource(x,s);g.compileShader(x);if(!g.getShaderParameter(x,g.COMPILE_STATUS))throw g.getShaderInfoLog(x);return x};
|
||||
const p=g.createProgram();g.attachShader(p,mk(g.VERTEX_SHADER,'#version 300 es\nvoid main(){vec2 p=vec2(float((gl_VertexID<<1)&2),float(gl_VertexID&2));gl_Position=vec4(p*2.0-1.0,0.0,1.0);}'));g.attachShader(p,mk(g.FRAGMENT_SHADER,src));g.linkProgram(p);g.useProgram(p);
|
||||
const t=g.createTexture();g.bindTexture(g.TEXTURE_2D,t);g.texImage2D(g.TEXTURE_2D,0,g.RGBA32F,4,1,0,g.RGBA,g.FLOAT,null);const f=g.createFramebuffer();g.bindFramebuffer(g.FRAMEBUFFER,f);g.framebufferTexture2D(g.FRAMEBUFFER,g.COLOR_ATTACHMENT0,g.TEXTURE_2D,t,0);g.viewport(0,0,4,1);g.drawArrays(g.TRIANGLES,0,3);
|
||||
const o=new Float32Array(16);g.readPixels(0,0,4,1,g.RGBA,g.FLOAT,o);return Array.from(o)},[fs_]).catch(e=>'ERR '+String(e).slice(0,500));
|
||||
console.log(Array.isArray(r)?['fbm analytic x,y,z','fbm FD dx,dz','phac analytic','phac FD'].map((n,i)=>n+': '+r.slice(i*4,i*4+3).map(v=>v.toExponential(3)).join(' ')).join('\n'):r);await b.close()})();
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
// Standalone page for the ridge scene: full window, no host app. `?tier=still|low|full` picks the
|
||||
// tier, `?probe=1` shows the rAF median/p95 readout. The bake record (each chunk's cost, from
|
||||
// the scene's `onBake`) and the long tasks the page saw are on `window.bakes` and `window.longTasks`
|
||||
// for a driver to read. Not shipped.
|
||||
import { startBackgroundScene } from '../../src/app/features/scene';
|
||||
import type { SceneTier } from '../../src/app/features/scene';
|
||||
|
||||
const q = new URLSearchParams(location.search);
|
||||
const tier = (q.get('tier') ?? 'full') as SceneTier;
|
||||
const canvas = document.getElementById('c') as HTMLCanvasElement;
|
||||
const w = window as unknown as { scene: unknown; bakes: unknown[]; longTasks: number[]; firstViewMs: number };
|
||||
w.bakes = [];
|
||||
w.longTasks = [];
|
||||
try {
|
||||
new PerformanceObserver((l) => l.getEntries().forEach((e) => w.longTasks.push(e.duration))).observe({
|
||||
entryTypes: ['longtask'],
|
||||
});
|
||||
} catch {
|
||||
// No long-task entries in this browser.
|
||||
}
|
||||
const t0 = performance.now();
|
||||
startBackgroundScene({ tier }, {}, {
|
||||
canvas,
|
||||
onBake: (b: unknown) => w.bakes.push(b),
|
||||
} as never).then((scene) => {
|
||||
w.firstViewMs = performance.now() - t0;
|
||||
w.scene = scene;
|
||||
document.title = scene ? 'ridge: running' : 'ridge: unavailable (CSS ground)';
|
||||
});
|
||||
24
index.html
|
|
@ -4,29 +4,33 @@
|
|||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" />
|
||||
<title>Ratatoskr</title>
|
||||
<meta name="name" content="Ratatoskr" />
|
||||
<meta name="author" content="GameCult" />
|
||||
<title>Cinny</title>
|
||||
<meta name="name" content="Cinny" />
|
||||
<meta name="author" content="Ajay Bura" />
|
||||
<meta
|
||||
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."
|
||||
/>
|
||||
<meta name="keywords" content="ratatoskr, gamecult, matrix, matrix client, matrix.org" />
|
||||
<meta
|
||||
name="keywords"
|
||||
content="cinny, cinnyapp, cinnychat, matrix, matrix client, matrix.org, element"
|
||||
/>
|
||||
|
||||
<meta property="og:title" content="Ratatoskr" />
|
||||
<meta property="og:url" content="https://gamecult.org" />
|
||||
<meta property="og:title" content="Cinny" />
|
||||
<meta property="og:url" content="https://cinny.in" />
|
||||
<meta property="og:image" content="https://cinny.in/assets/favicon-48x48.png" />
|
||||
<meta
|
||||
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."
|
||||
/>
|
||||
<meta name="theme-color" content="#07111a" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
|
||||
<link id="favicon" rel="shortcut icon" href="./public/favicon.ico" />
|
||||
|
||||
<link rel="manifest" href="./manifest.json" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="application-name" content="Ratatoskr" />
|
||||
<meta name="apple-mobile-web-app-title" content="Ratatoskr" />
|
||||
<meta name="application-name" content="Cinny" />
|
||||
<meta name="apple-mobile-web-app-title" content="Cinny" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
|
||||
|
|
|
|||
16
package-lock.json
generated
|
|
@ -1,18 +1,17 @@
|
|||
{
|
||||
"name": "ratatoskr",
|
||||
"name": "cinny",
|
||||
"version": "4.12.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ratatoskr",
|
||||
"name": "cinny",
|
||||
"version": "4.12.8",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@atlaskit/pragmatic-drag-and-drop": "1.1.6",
|
||||
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
|
||||
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
|
||||
"@fontsource-variable/nunito": "5.3.0",
|
||||
"@fontsource/ibm-plex-mono": "5.2.5",
|
||||
"@fontsource/inter": "4.5.14",
|
||||
"@fontsource/montserrat": "5.2.5",
|
||||
|
|
@ -86,7 +85,6 @@
|
|||
"@types/ua-parser-js": "0.7.36",
|
||||
"@typescript-eslint/eslint-plugin": "5.46.1",
|
||||
"@typescript-eslint/parser": "5.46.1",
|
||||
"@vanilla-extract/integration": "6.5.0",
|
||||
"@vitejs/plugin-react": "4.2.0",
|
||||
"buffer": "6.0.3",
|
||||
"cz-conventional-changelog": "3.3.0",
|
||||
|
|
@ -2194,15 +2192,6 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/nunito": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/nunito/-/nunito-5.3.0.tgz",
|
||||
"integrity": "sha512-tw0ch7evIDSwoeUy+0XzocYHfbRWLeCRPJy22KgbrLjDLAbNw/fErBiil10Ssrs3Sjr55zaZrr36a975j8cBkQ==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
|
|
@ -5096,7 +5085,6 @@
|
|||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@vanilla-extract/integration/-/integration-6.5.0.tgz",
|
||||
"integrity": "sha512-E2YcfO8vA+vs+ua+gpvy1HRqvgWbI+MTlUpxA8FvatOvybuNcWAY0CKwQ/Gpj7rswYKtC6C7+xw33emM6/ImdQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.20.7",
|
||||
"@babel/plugin-syntax-typescript": "^7.20.0",
|
||||
|
|
|
|||
17
package.json
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "ratatoskr",
|
||||
"name": "cinny",
|
||||
"version": "4.12.8",
|
||||
"description": "Yet another matrix client",
|
||||
"main": "index.js",
|
||||
|
|
@ -10,7 +10,6 @@
|
|||
"scripts": {
|
||||
"start": "vite",
|
||||
"build": "vite build",
|
||||
"build:preview": "vite build && node scripts/preview-config.js low",
|
||||
"preview": "vite preview",
|
||||
"lint": "npm run check:eslint && npm run check:prettier",
|
||||
"check:eslint": "eslint src/*",
|
||||
|
|
@ -20,9 +19,7 @@
|
|||
"prepare": "husky install",
|
||||
"commit": "git-cz",
|
||||
"bump": "node scripts/update-version.js",
|
||||
"bake:sky": "node scripts/bake-sky.mjs && prettier --write src/sky/baked.ts",
|
||||
"test": "vitest run",
|
||||
"sync:poses": "node scripts/sync-poses.mjs"
|
||||
"test": "vitest run"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{ts,tsx,js,jsx}": "eslint",
|
||||
|
|
@ -40,11 +37,7 @@
|
|||
"@atlaskit/pragmatic-drag-and-drop": "1.1.6",
|
||||
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
|
||||
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
|
||||
"@fontsource-variable/nunito": "5.3.0",
|
||||
"@fontsource/ibm-plex-mono": "5.2.5",
|
||||
"@fontsource/inter": "4.5.14",
|
||||
"@fontsource/montserrat": "5.2.5",
|
||||
"@fontsource/ubuntu": "5.2.5",
|
||||
"@tanstack/react-query": "5.24.1",
|
||||
"@tanstack/react-query-devtools": "5.24.1",
|
||||
"@tanstack/react-virtual": "3.2.0",
|
||||
|
|
@ -95,7 +88,10 @@
|
|||
"slate-dom": "0.123.0",
|
||||
"slate-history": "0.113.1",
|
||||
"slate-react": "0.123.0",
|
||||
"ua-parser-js": "1.0.35"
|
||||
"ua-parser-js": "1.0.35",
|
||||
"@fontsource/ubuntu": "5.2.5",
|
||||
"@fontsource/montserrat": "5.2.5",
|
||||
"@fontsource/ibm-plex-mono": "5.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@element-hq/element-call-embedded": "0.20.1",
|
||||
|
|
@ -114,7 +110,6 @@
|
|||
"@types/ua-parser-js": "0.7.36",
|
||||
"@typescript-eslint/eslint-plugin": "5.46.1",
|
||||
"@typescript-eslint/parser": "5.46.1",
|
||||
"@vanilla-extract/integration": "6.5.0",
|
||||
"@vitejs/plugin-react": "4.2.0",
|
||||
"buffer": "6.0.3",
|
||||
"cz-conventional-changelog": "3.3.0",
|
||||
|
|
|
|||
|
|
@ -1,13 +0,0 @@
|
|||
# Demo pose thumbnails
|
||||
|
||||
These 24 PNGs (about 200 KB, 160 px wide, palette-quantised) are downscaled copies of GameCult's own
|
||||
Persona art from `/opt/commons/cast-art/poses`. That directory carries no provenance or licence note:
|
||||
the image model that made the art and the terms it was made under are not recorded.
|
||||
|
||||
They exist only so the dev preview's pretend homeserver (`src/preview-demo/`) can show pose portraits.
|
||||
The dev workflow copies them into `dist/public/poses`; real builds sync the full art with
|
||||
`npm run sync:poses` and do not use this folder.
|
||||
|
||||
Strip them if their terms turn out not to allow this: delete this folder and the `Add the demo pose
|
||||
art` step in `.github/workflows/rat-dev-preview.yml`. Pose portraits in the demo then show broken
|
||||
images; nothing else depends on them.
|
||||
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
|
@ -1,17 +0,0 @@
|
|||
# Dings
|
||||
|
||||
Pre-rendered earcons, served at `public/dings/` and read at runtime by `src/app/features/dings`.
|
||||
Aqua renders the clips; nothing here is synthesised in the browser.
|
||||
|
||||
dings/manifest.json
|
||||
dings/senders/<mxid-localpart>/<motif>.ogg
|
||||
dings/neutral/<motif>.ogg
|
||||
|
||||
`manifest.json` is `{ "neutral": { <motif>: "<path>" }, "senders": { <localpart>: { <motif>: "<path>" } } }`
|
||||
with motif one of `mention`, `dm`, `message`, `invite`; paths are relative to this folder, `.ogg` or `.mp3`.
|
||||
A sender's own clip wins, else the neutral clip, else silence. Without a `manifest.json` the client keeps
|
||||
its stock notification sounds; once one exists, a motif with no clip is silent.
|
||||
|
||||
The set here is Aqua's v4 (AquaSynth merge 21e83ec, staged at minds.gamecult.org/aqua/dings-voices-dd9be66),
|
||||
Nibu on proposal 3 (8 kHz 5-bit smoothed sine) for all four events. Nobody has heard it. The client plays none of it unless the
|
||||
client config sets `"dings": { "enabled": true }` (default off, as in `config.json`).
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
{
|
||||
"neutral": {
|
||||
"mention": "neutral/mention.ogg",
|
||||
"dm": "neutral/dm.ogg",
|
||||
"message": "neutral/message.ogg",
|
||||
"invite": "neutral/invite.ogg"
|
||||
},
|
||||
"senders": {
|
||||
"aqua": {
|
||||
"mention": "senders/aqua/mention.ogg",
|
||||
"dm": "senders/aqua/dm.ogg",
|
||||
"message": "senders/aqua/message.ogg",
|
||||
"invite": "senders/aqua/invite.ogg"
|
||||
},
|
||||
"eureka": {
|
||||
"mention": "senders/eureka/mention.ogg",
|
||||
"dm": "senders/eureka/dm.ogg",
|
||||
"message": "senders/eureka/message.ogg",
|
||||
"invite": "senders/eureka/invite.ogg"
|
||||
},
|
||||
"heimdall": {
|
||||
"mention": "senders/heimdall/mention.ogg",
|
||||
"dm": "senders/heimdall/dm.ogg",
|
||||
"message": "senders/heimdall/message.ogg",
|
||||
"invite": "senders/heimdall/invite.ogg"
|
||||
},
|
||||
"kiko": {
|
||||
"mention": "senders/kiko/mention.ogg",
|
||||
"dm": "senders/kiko/dm.ogg",
|
||||
"message": "senders/kiko/message.ogg",
|
||||
"invite": "senders/kiko/invite.ogg"
|
||||
},
|
||||
"libby": {
|
||||
"mention": "senders/libby/mention.ogg",
|
||||
"dm": "senders/libby/dm.ogg",
|
||||
"message": "senders/libby/message.ogg",
|
||||
"invite": "senders/libby/invite.ogg"
|
||||
},
|
||||
"nibu": {
|
||||
"mention": "senders/nibu/mention.ogg",
|
||||
"dm": "senders/nibu/dm.ogg",
|
||||
"message": "senders/nibu/message.ogg",
|
||||
"invite": "senders/nibu/invite.ogg"
|
||||
},
|
||||
"void": {
|
||||
"mention": "senders/void/mention.ogg",
|
||||
"dm": "senders/void/dm.ogg",
|
||||
"message": "senders/void/message.ogg",
|
||||
"invite": "senders/void/invite.ogg"
|
||||
},
|
||||
"weksa": {
|
||||
"mention": "senders/weksa/mention.ogg",
|
||||
"dm": "senders/weksa/dm.ogg",
|
||||
"message": "senders/weksa/message.ogg",
|
||||
"invite": "senders/weksa/invite.ogg"
|
||||
}
|
||||
}
|
||||
}
|
||||