From 1be9e76012bf01dc846546980603bf90b9b169a6 Mon Sep 17 00:00:00 2001 From: Void Date: Wed, 11 Nov 2026 12:09:46 +0000 Subject: [PATCH 1/5] Bubble pose follows message sentiment when the event carries no pose label Co-Authored-By: Claude Sonnet 5.5 --- .../components/message/pose/sentiment.test.ts | 42 +++++++++++++++++++ src/app/components/message/pose/sentiment.ts | 34 +++++++++++++++ src/app/features/room/message/Message.tsx | 5 ++- 3 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 src/app/components/message/pose/sentiment.test.ts create mode 100644 src/app/components/message/pose/sentiment.ts diff --git a/src/app/components/message/pose/sentiment.test.ts b/src/app/components/message/pose/sentiment.test.ts new file mode 100644 index 00000000..2897bc49 --- /dev/null +++ b/src/app/components/message/pose/sentiment.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { classifyPose, messagePose } from './sentiment'; + +describe('classifyPose', () => { + it.each([ + ['concerned', 'Sorry, that build failed with an error'], + ['annoyed', 'ugh, this again'], + ['surprised', 'wow, no way'], + ['determined', "on it, I'll fix that"], + ['amused', 'hahaha that is funny'], + ['happy', 'thanks, great work'], + ['thinking', 'should we ship it?'], + ['neutral', 'Merged the branch.'], + ])('%s', (label, text) => expect(classifyPose(text)).toBe(label)); + it('is neutral for empty or non-string text', () => { + expect(classifyPose('')).toBe('neutral'); + expect(classifyPose(undefined)).toBe('neutral'); + expect(classifyPose(5)).toBe('neutral'); + }); + it('is deterministic', () => { + expect(classifyPose('thanks?')).toBe(classifyPose('thanks?')); + }); +}); + +describe('messagePose', () => { + it('lets an explicit label win over the text', () => { + expect(messagePose('aqua', { body: 'thanks!', 'org.gamecult.pose': 'annoyed' })).toBe( + 'annoyed' + ); + }); + it('keeps an unknown explicit label neutral instead of classifying', () => { + expect(messagePose('aqua', { body: 'thanks!', 'org.gamecult.pose': 'conduct' })).toBe( + 'neutral' + ); + }); + it('classifies when there is no label, and drops labels the Mind lacks', () => { + expect(messagePose('aqua', { body: 'thanks!' })).toBe('happy'); + expect(messagePose('aqua', { body: 'sorry' })).toBe('concerned'); + expect(messagePose('kiko', { body: 'sorry' })).toBe('neutral'); + expect(messagePose('aqua', {})).toBe('neutral'); + }); +}); diff --git a/src/app/components/message/pose/sentiment.ts b/src/app/components/message/pose/sentiment.ts new file mode 100644 index 00000000..096c4957 --- /dev/null +++ b/src/app/components/message/pose/sentiment.ts @@ -0,0 +1,34 @@ +import { POSE_KEY, NEUTRAL, resolvePose } from './poses'; + +// Local sentiment -> pose label. A lexicon over the message text, no network, no model: the +// same text always gives the same pose. Rules are tried in order and the first hit wins, so a +// message that says "sorry" and "thanks" reads as concerned. Labels outside a Mind's art fall +// back to neutral in resolvePose, so 'concerned' and 'determined' need no per-Mind table. +const RULES: [string, RegExp][] = [ + ['concerned', /\b(errors?|sorry|apolog\w+|oops|fail(ed|s|ure)?|broke(n)?|crash\w*|bug)\b/i], + ['annoyed', /\b(annoy\w*|ugh+|argh+|frustrat\w*|again\?|why won'?t|stop it)\b/i], + ['surprised', /\b(wow|whoa|woah|omg|no way|unexpected\w*|surpris\w*|wait,? what)\b|[?!]{2,}/i], + [ + 'determined', + /\b(let'?s (go|do|fix|ship)|on it|i'?ll (fix|do|handle)|will do|fixing|resolved?)\b/i, + ], + ['amused', /\b(ha(ha)+|lol|lmao|rofl|funny|hilarious)\b|😂|🤣/i], + [ + 'happy', + /\b(thanks?|thank you|thx|great|awesome|nice|love|perfect|excellent|yay|congrats)\b|🎉|😊/i, + ], + ['thinking', /\?\s*$|\b(hmm+|maybe|perhaps|i wonder|not sure|how (do|does|about)|what if)\b/i], +]; + +export const classifyPose = (text: unknown): string => { + if (typeof text !== 'string') return NEUTRAL; + const hit = RULES.find(([, re]) => re.test(text)); + return hit ? hit[0] : NEUTRAL; +}; + +// An explicit org.gamecult.pose label always wins, even when it is unknown (then neutral). +// Only events with no label are read for sentiment. +export const messagePose = (mind: string, content: Record): string => + content[POSE_KEY] !== undefined + ? resolvePose(mind, content[POSE_KEY]) + : resolvePose(mind, classifyPose(content.body)); diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index 281d4d78..4b3d436f 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -61,7 +61,8 @@ import { import { MessageLayout, MessageSpacing, settingsAtom } from '../../../state/settings'; import { useSetting } from '../../../state/hooks/settings'; import { PoseAvatar } from '../../../components/message/pose/PoseAvatar'; -import { POSE_KEY, MINDS, mindOfSender, resolvePose } from '../../../components/message/pose/poses'; +import { MINDS, mindOfSender } from '../../../components/message/pose/poses'; +import { messagePose } from '../../../components/message/pose/sentiment'; import { useEntranceDelay } from '../../../components/message/pose/entrance'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useRecentEmoji } from '../../../hooks/useRecentEmoji'; @@ -835,7 +836,7 @@ export const Message = as<'div', MessageProps>( const poseJSX = !collapse && mind && ( Date: Wed, 11 Nov 2026 12:18:31 +0000 Subject: [PATCH 2/5] Classifier outputs only the shared pose set Co-Authored-By: Claude Sonnet 5.5 --- src/app/components/message/pose/sentiment.test.ts | 6 ++---- src/app/components/message/pose/sentiment.ts | 7 ++++--- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/app/components/message/pose/sentiment.test.ts b/src/app/components/message/pose/sentiment.test.ts index 2897bc49..bc4a8c28 100644 --- a/src/app/components/message/pose/sentiment.test.ts +++ b/src/app/components/message/pose/sentiment.test.ts @@ -3,14 +3,13 @@ import { classifyPose, messagePose } from './sentiment'; describe('classifyPose', () => { it.each([ - ['concerned', 'Sorry, that build failed with an error'], ['annoyed', 'ugh, this again'], ['surprised', 'wow, no way'], - ['determined', "on it, I'll fix that"], ['amused', 'hahaha that is funny'], ['happy', 'thanks, great work'], ['thinking', 'should we ship it?'], ['neutral', 'Merged the branch.'], + ['neutral', 'sorry, that failed with an error'], ])('%s', (label, text) => expect(classifyPose(text)).toBe(label)); it('is neutral for empty or non-string text', () => { expect(classifyPose('')).toBe('neutral'); @@ -33,9 +32,8 @@ describe('messagePose', () => { 'neutral' ); }); - it('classifies when there is no label, and drops labels the Mind lacks', () => { + it('classifies when there is no label, and never outputs a signature label', () => { expect(messagePose('aqua', { body: 'thanks!' })).toBe('happy'); - expect(messagePose('aqua', { body: 'sorry' })).toBe('concerned'); expect(messagePose('kiko', { body: 'sorry' })).toBe('neutral'); expect(messagePose('aqua', {})).toBe('neutral'); }); diff --git a/src/app/components/message/pose/sentiment.ts b/src/app/components/message/pose/sentiment.ts index 096c4957..bd685a07 100644 --- a/src/app/components/message/pose/sentiment.ts +++ b/src/app/components/message/pose/sentiment.ts @@ -1,11 +1,12 @@ import { POSE_KEY, NEUTRAL, resolvePose } from './poses'; +// catalogue.json beside this file is a vendored copy of /opt/commons/cast-art/poses/catalogue.json +// (npm run sync:poses); nothing else publishes it to the browser. // Local sentiment -> pose label. A lexicon over the message text, no network, no model: the // same text always gives the same pose. Rules are tried in order and the first hit wins, so a -// message that says "sorry" and "thanks" reads as concerned. Labels outside a Mind's art fall -// back to neutral in resolvePose, so 'concerned' and 'determined' need no per-Mind table. +// message that says "wow" and "thanks" reads as surprised. Only the shared labels (identical art +// for all eight Minds, catalogue.json) are ever output; signature labels are explicit-only. const RULES: [string, RegExp][] = [ - ['concerned', /\b(errors?|sorry|apolog\w+|oops|fail(ed|s|ure)?|broke(n)?|crash\w*|bug)\b/i], ['annoyed', /\b(annoy\w*|ugh+|argh+|frustrat\w*|again\?|why won'?t|stop it)\b/i], ['surprised', /\b(wow|whoa|woah|omg|no way|unexpected\w*|surpris\w*|wait,? what)\b|[?!]{2,}/i], [ From 90250ca0764c55b256cd6c80404e429eff89485c Mon Sep 17 00:00:00 2001 From: Void Date: Wed, 11 Nov 2026 15:42:15 +0000 Subject: [PATCH 3/5] Default homeserver is chat.gamecult.org: gamecult.org serves no .well-known/matrix/client, so discovery 404s Co-Authored-By: Claude Sonnet 5.5 --- config.json | 1 + src/app/hooks/useClientConfig.test.ts | 10 +++++----- src/app/hooks/useClientConfig.ts | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/config.json b/config.json index 0ebdf7c3..90ed6dfb 100644 --- a/config.json +++ b/config.json @@ -1,6 +1,7 @@ { "defaultHomeserver": 0, "homeserverList": [ + "chat.gamecult.org", "gamecult.org", "converser.eu", "matrix.org", diff --git a/src/app/hooks/useClientConfig.test.ts b/src/app/hooks/useClientConfig.test.ts index 440ba15c..bafbdbf2 100644 --- a/src/app/hooks/useClientConfig.test.ts +++ b/src/app/hooks/useClientConfig.test.ts @@ -3,9 +3,9 @@ import { ClientConfig, clientDefaultServer } from './useClientConfig'; import config from '../../../config.json'; describe('default homeserver', () => { - it('is gamecult.org when the config names none', () => { - expect(clientDefaultServer({})).toBe('gamecult.org'); - expect(clientDefaultServer({ homeserverList: [] })).toBe('gamecult.org'); + it('is chat.gamecult.org when the config names none', () => { + expect(clientDefaultServer({})).toBe('chat.gamecult.org'); + expect(clientDefaultServer({ homeserverList: [] })).toBe('chat.gamecult.org'); }); it('is the listed server the config points at', () => { @@ -14,7 +14,7 @@ describe('default homeserver', () => { ).toBe('chat.gamecult.org'); }); - it('is gamecult.org in the repo config.json too', () => { - expect(clientDefaultServer(config as ClientConfig)).toBe('gamecult.org'); + it('is chat.gamecult.org in the repo config.json too', () => { + expect(clientDefaultServer(config as ClientConfig)).toBe('chat.gamecult.org'); }); }); diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts index feb38e0a..7f0899be 100644 --- a/src/app/hooks/useClientConfig.ts +++ b/src/app/hooks/useClientConfig.ts @@ -53,7 +53,7 @@ export function useClientConfig(): ClientConfig { } export const clientDefaultServer = (clientConfig: ClientConfig): string => - clientConfig.homeserverList?.[clientConfig.defaultHomeserver ?? 0] ?? 'gamecult.org'; + clientConfig.homeserverList?.[clientConfig.defaultHomeserver ?? 0] ?? 'chat.gamecult.org'; export const clientAllowedServer = (clientConfig: ClientConfig, server: string): boolean => { const { homeserverList, allowCustomHomeservers } = clientConfig; From abb1ee988ce16994e6994a59c220d403008d234f Mon Sep 17 00:00:00 2001 From: Void Date: Wed, 11 Nov 2026 16:00:45 +0000 Subject: [PATCH 4/5] Background scene on by default in the repo config (tier full; the device ceiling still lowers it) Co-Authored-By: Claude Sonnet 5.5 --- config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config.json b/config.json index 90ed6dfb..16955527 100644 --- a/config.json +++ b/config.json @@ -27,7 +27,7 @@ }, "backgroundScene": { - "tier": "off" + "tier": "full" }, "dings": { From dd1e9268f2dfa82c1360cba7efbfed9ecc5059db Mon Sep 17 00:00:00 2001 From: Void Date: Wed, 11 Nov 2026 16:31:25 +0000 Subject: [PATCH 5/5] Docs: the repo is GameCult/Ratatoskr now; the background scene is on in the dev config Co-Authored-By: Claude Sonnet 5.5 --- README.md | 2 +- docs/gamecult/ratatoskr-target.md | 9 ++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index dfc722ad..abeba8b2 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ and your own place in it) instead of an invite list. Mobile first. **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). +(opt-in setting), a background scene (on in the dev config, `backgroundScene.tier`; the device can lower it) and an opt-in soundscape. **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 diff --git a/docs/gamecult/ratatoskr-target.md b/docs/gamecult/ratatoskr-target.md index 166c5c72..b56c90f8 100644 --- a/docs/gamecult/ratatoskr-target.md +++ b/docs/gamecult/ratatoskr-target.md @@ -1,7 +1,7 @@ # Ratatoskr: target and prose map Ratatoskr is GameCult's Matrix chat client: this fork of Cinny, forge -`GameCult/cinny` (to be renamed `GameCult/Ratatoskr`; see the cut sequence). +`GameCult/Ratatoskr` (renamed from `GameCult/cinny`; the forge redirects the old name). Void stewards it. Campaign `ratatoskr` lives in the eureka mind for now, beside the rulings it rests on. Under `eureka-body:ruling:ruling-home-steward-mind` it is handed to Void's mind once, by procedure, as soon as that hand-off procedure @@ -346,7 +346,6 @@ Each later cut is one line, with what it waits on. attention document, scoped to room members; waits on Huginn publishing it. - `rename-ratatoskr`: package name, app name and branding, manifest and titles, the `/preview/` path and its deploy, and every in-repo `cinny` - reference that is ours (not upstream engine paths); waits on Void's forge - rename of `GameCult/cinny` to `GameCult/Ratatoskr` - (`eureka-body:ruling:chat-client-named-ratatoskr`), and the campaign's repo - list moves with it. + reference that is ours (not upstream engine paths). The forge rename of + `GameCult/cinny` to `GameCult/Ratatoskr` is done + (`eureka-body:ruling:chat-client-named-ratatoskr`).