Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions websites/G/GhoulApp/metadata.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
{
"$schema": "https://schemas.premid.app/metadata/1.17",
"apiVersion": 1,
"author": {
"name": "Bluenchanter",
"id": "107164966972321792"
},
"service": "GhoulApp",
"description": {
"de": "GhoulApp ist eine Anwendung zur Charaktererstellung und Chronikverwaltung für Vampire: The Masquerade.",
"en": "GhoulApp is a character creator and chronicle management application for Vampire: The Masquerade.",
"es": "GhoulApp es una aplicación para crear personajes y gestionar crónicas de Vampire: The Masquerade.",
"fr": "GhoulApp est une application de création de personnages et de gestion de chroniques pour Vampire: The Masquerade.",
"tr": "GhoulApp, Vampire: The Masquerade için karakter oluşturma ve kronik yönetimi uygulamasıdır."
},
"url": "ghoulapp.com",
"regExp": "^https?[:][/][/](?:www[.])?ghoulapp[.]com[/]",
"version": "1.0.0",
"logo": "https://i.imgur.com/Iz99bsW.png",
"thumbnail": "https://i.imgur.com/teyiTDA.png",
"color": "#DC2626",
"category": "games",
"tags": [
"ttrpg",
"vampire-the-masquerade",
"vtm",
"roleplaying",
"tabletop"
],
"settings": [
{
"id": "showChronicleName",
"title": "Show chronicle name",
"icon": "fa-solid fa-book",
"value": false,
"description": "Display the current chronicle name in Discord"
},
{
"id": "showEdition",
"title": "Show edition",
"icon": "fa-solid fa-book-open",
"value": true,
"description": "Display the active game edition"
},
{
"id": "showRole",
"title": "Show table role",
"icon": "fa-solid fa-user-tag",
"value": false,
"description": "Display your role in the current chronicle"
}
]
}
252 changes: 252 additions & 0 deletions websites/G/GhoulApp/presence.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
import { ActivityType } from 'premid'

const presence = new Presence({
clientId: '1536454244410859580',
})

enum ActivityAssets {
Logo = 'https://i.imgur.com/Iz99bsW.png',
}

const BRIDGE_ID = 'ghoulapp-discord-activity-bridge'
const BRIDGE_MAX_LENGTH = 1024
const LABEL_MAX_LENGTH = 64
const DISCORD_TEXT_MAX_LENGTH = 128

const activityLabels = {
browsing: 'Browsing GhoulApp',
character_creation: 'Creating a character',
character_sheet: 'Viewing a character sheet',
wiki: 'Reading the rules wiki',
dice_tools: 'Using dice tools',
community: 'Browsing the community',
chronicles: 'Browsing chronicles',
storyteller_tools: 'Using Storyteller tools',
chronicle_dashboard: 'Viewing a chronicle dashboard',
story: 'Planning the chronicle story',
npcs: 'Managing chronicle NPCs',
chronicle_reference: 'Reading chronicle references',
map: 'Viewing the chronicle map',
characters: 'Viewing chronicle characters',
relationships: 'Viewing chronicle relationships',
planning: 'Planning a chronicle',
battles: 'Preparing a battle',
boons: 'Reviewing boons',
xp: 'Reviewing experience',
members: 'Viewing chronicle members',
chronicle_settings: 'Managing chronicle settings',
notes: 'Reviewing chronicle notes',
live_session: 'In a live session',
map_presenting: 'Presenting a chronicle map',
session_broadcast: 'Presenting a live session',
} as const

type ActivityKey = keyof typeof activityLabels
type Edition = 'V5' | 'V20' | 'CUSTOM' | 'DAV20' | 'DA' | 'V6'
type Role = 'storyteller' | 'co_storyteller' | 'moderator' | 'player' | 'observer'

interface BridgeActivity {
kind: 'activity'
activity: ActivityKey
chronicleName?: string
edition?: Edition
role?: Role
}

type BridgeResult = BridgeActivity | { kind: 'clear' } | { kind: 'missing' }

const editions = new Set<Edition>(['V5', 'V20', 'CUSTOM', 'DAV20', 'DA', 'V6'])
const roles = new Set<Role>(['storyteller', 'co_storyteller', 'moderator', 'player', 'observer'])
const activities = new Set<ActivityKey>(Object.keys(activityLabels) as ActivityKey[])
const unsafeLabelCharacters = /[\p{Cc}\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]/gu

function clamp(value: string, maximum: number): string {
return Array.from(value).slice(0, maximum).join('')
}

function sanitizeLabel(value: unknown): string | undefined {
if (typeof value !== 'string')
return undefined

try {
const sanitized = value
.normalize('NFC')
.replace(unsafeLabelCharacters, '')
.replace(/\s+/gu, ' ')
.trim()
return sanitized ? clamp(sanitized, LABEL_MAX_LENGTH) : undefined
}
catch {
return undefined
}
}

function readBridge(): BridgeResult {
const bridges = document.querySelectorAll<HTMLElement>(`#${BRIDGE_ID}`)
if (bridges.length === 0)
return { kind: 'missing' }
if (bridges.length !== 1)
return { kind: 'clear' }

const raw = bridges[0]?.dataset.payload
if (!raw || raw.length > BRIDGE_MAX_LENGTH)
return { kind: 'clear' }

try {
const parsed: unknown = JSON.parse(raw)
if (!parsed || typeof parsed !== 'object')
return { kind: 'clear' }

const value = parsed as Record<string, unknown>
if (value.v !== 1)
return { kind: 'clear' }
if (value.mode === 'clear')
return { kind: 'clear' }
if (value.mode !== 'activity' || typeof value.activity !== 'string')
return { kind: 'clear' }
if (!activities.has(value.activity as ActivityKey))
return { kind: 'clear' }

const chronicleName = sanitizeLabel(value.chronicleName)
const edition = typeof value.edition === 'string' && editions.has(value.edition as Edition)
? value.edition as Edition
: undefined
const role = typeof value.role === 'string' && roles.has(value.role as Role)
? value.role as Role
: undefined

return {
kind: 'activity',
activity: value.activity as ActivityKey,
...(chronicleName ? { chronicleName } : {}),
...(edition ? { edition } : {}),
...(role ? { role } : {}),
}
}
catch {
return { kind: 'clear' }
}
}

function stripEditionPrefix(pathname: string): string[] {
const segments = pathname.split('/').filter(Boolean)
if (['v5', 'v20', 'v6', 'da', 'dav20'].includes(segments[0]?.toLowerCase() ?? ''))
segments.shift()
return segments
}

function fallbackFromPath(pathname: string): BridgeActivity | { kind: 'clear' } {
const segments = stripEditionPrefix(pathname)
const root = segments[0]?.toLowerCase() ?? ''

if (root === 'settings' || root === 'join' || root === 'invite')
return { kind: 'clear' }
if (root === 'create' || root === 'fastbuild' || root === 'bloodtest')
return { kind: 'activity', activity: 'character_creation' }
if (root === 'character' || root === 'sheet')
return { kind: 'activity', activity: 'character_sheet' }
if (root === 'diceroller')
return { kind: 'activity', activity: 'dice_tools' }
if (root === 'wiki' || root === 'started' || root === 'v6-explained')
return { kind: 'activity', activity: 'wiki' }
if (root === 'community' || root === 'id')
return { kind: 'activity', activity: 'community' }
if (root === 'tool' || root === 'tools' || root === 'storyteller')
return { kind: 'activity', activity: 'storyteller_tools' }

if (root === 'chronicle') {
if (!segments[1] || segments[1]?.toLowerCase() === 'new')
return { kind: 'activity', activity: 'chronicles' }

const section = segments[2]?.toLowerCase() ?? 'dashboard'
const chronicleSections: Record<string, ActivityKey> = {
'dashboard': 'chronicle_dashboard',
'story': 'story',
'chapters': 'story',
'sessions': 'story',
'npcs': 'npcs',
'wiki': 'chronicle_reference',
'handbook': 'chronicle_reference',
'quickref': 'chronicle_reference',
'map': 'map',
'locations': 'map',
'characters': 'characters',
'coteries': 'characters',
'relationships': 'relationships',
'hierarchy': 'relationships',
'planning': 'planning',
'calendar': 'planning',
'battles': 'battles',
'boons': 'boons',
'harpy-ledger': 'boons',
'xp': 'xp',
'members': 'members',
'settings': 'chronicle_settings',
'notes': 'notes',
'live-session': 'live_session',
}
return {
kind: 'activity',
activity: chronicleSections[section] ?? 'chronicle_dashboard',
}
}

return { kind: 'activity', activity: 'browsing' }
}

async function getBooleanSetting(id: string, fallback: boolean): Promise<boolean> {
try {
const value = await presence.getSetting<boolean>(id)
return typeof value === 'boolean' ? value : fallback
}
catch {
return fallback
}
}

function roleLabel(role: Role): string {
const labels: Record<Role, string> = {
storyteller: 'Storyteller',
co_storyteller: 'Co-Storyteller',
moderator: 'Moderator',
player: 'Player',
observer: 'Observer',
}
return labels[role]
}

presence.on('UpdateData', async () => {
const [showChronicleName, showEdition, showRole] = await Promise.all([
getBooleanSetting('showChronicleName', false),
getBooleanSetting('showEdition', true),
getBooleanSetting('showRole', false),
])

const bridge = readBridge()
const resolved = bridge.kind === 'missing'
? fallbackFromPath(document.location.pathname)
: bridge

if (resolved.kind === 'clear') {
presence.clearActivity()
return
}

const stateParts: string[] = []
if (showChronicleName && resolved.chronicleName)
stateParts.push(resolved.chronicleName)
if (showEdition && resolved.edition)
stateParts.push(resolved.edition)
if (showRole && resolved.role)
stateParts.push(roleLabel(resolved.role))

const presenceData: PresenceData = {
type: ActivityType.Playing,
largeImageKey: ActivityAssets.Logo,
details: clamp(activityLabels[resolved.activity], DISCORD_TEXT_MAX_LENGTH),
}
if (stateParts.length > 0)
presenceData.state = clamp(stateParts.join(' • '), DISCORD_TEXT_MAX_LENGTH)

await presence.setActivity(presenceData)
})
Loading