Compare commits

..
Author SHA1 Message Date
Void
dd1e9268f2 Docs: the repo is GameCult/Ratatoskr now; the background scene is on in the dev config
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m3s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 16:31:25 +00:00
b6eda08d4d Merge pull request 'Background scene on in the repo config (tier full)' (#91) from void/scene-tier-full into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m54s
2026-11-11 16:30:30 +00:00
Void
abb1ee988c Background scene on by default in the repo config (tier full; the device ceiling still lowers it)
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m36s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 16:00:45 +00:00
a5e82090b5 Merge pull request 'Default homeserver is chat.gamecult.org' (#90) from void/default-homeserver-chat into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m57s
2026-11-11 15:53:07 +00:00
Void
90250ca076 Default homeserver is chat.gamecult.org: gamecult.org serves no .well-known/matrix/client, so discovery 404s
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m10s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 15:42:15 +00:00
50a2a37d6b Merge pull request 'Pose follows message sentiment (local classifier)' (#89) from void/sentiment-pose into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m7s
2026-11-11 14:26:09 +00:00
Void
7c97b79956 Classifier outputs only the shared pose set
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m30s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 12:18:31 +00:00
1be9e76012 Bubble pose follows message sentiment when the event carries no pose label
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 12:09:46 +00:00
8 changed files with 91 additions and 15 deletions

View file

@ -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

View file

@ -1,6 +1,7 @@
{
"defaultHomeserver": 0,
"homeserverList": [
"chat.gamecult.org",
"gamecult.org",
"converser.eu",
"matrix.org",
@ -26,7 +27,7 @@
},
"backgroundScene": {
"tier": "off"
"tier": "full"
},
"dings": {

View file

@ -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`).

View file

@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import { classifyPose, messagePose } from './sentiment';
describe('classifyPose', () => {
it.each([
['annoyed', 'ugh, this again'],
['surprised', 'wow, no way'],
['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');
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 never outputs a signature label', () => {
expect(messagePose('aqua', { body: 'thanks!' })).toBe('happy');
expect(messagePose('kiko', { body: 'sorry' })).toBe('neutral');
expect(messagePose('aqua', {})).toBe('neutral');
});
});

View file

@ -0,0 +1,35 @@
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 "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][] = [
['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, unknown>): string =>
content[POSE_KEY] !== undefined
? resolvePose(mind, content[POSE_KEY])
: resolvePose(mind, classifyPose(content.body));

View file

@ -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 && (
<PoseAvatar
mind={mind}
pose={resolvePose(mind, mEvent.getContent()[POSE_KEY])}
pose={messagePose(mind, mEvent.getContent())}
userId={senderId}
alt={poseAlt}
enter={enterDelay !== undefined}

View file

@ -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');
});
});

View file

@ -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;