Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd1e9268f2 | ||
| b6eda08d4d | |||
|
|
abb1ee988c | ||
| a5e82090b5 | |||
|
|
90250ca076 | ||
| 50a2a37d6b | |||
|
|
7c97b79956 | ||
| 1be9e76012 |
8 changed files with 91 additions and 15 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
40
src/app/components/message/pose/sentiment.test.ts
Normal file
40
src/app/components/message/pose/sentiment.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
35
src/app/components/message/pose/sentiment.ts
Normal file
35
src/app/components/message/pose/sentiment.ts
Normal 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));
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue