diff --git a/server/utils/party/server.ts b/server/utils/party/server.ts index 059ec2d4..a9c7dbd1 100644 --- a/server/utils/party/server.ts +++ b/server/utils/party/server.ts @@ -44,6 +44,11 @@ interface Scene { zones: Zone[] } +interface Names { + adjectives: string[] + nouns: string[] +} + interface Conn { peer: PartyPeer room: Room @@ -65,8 +70,8 @@ const STALE_AFTER = 75_000 const BROADCAST_INTERVAL = 33 const PERSIST_INTERVAL = 1000 -const ADJECTIVES = ['Brave', 'Sleepy', 'Jolly', 'Sneaky', 'Fuzzy', 'Clever', 'Bouncy', 'Cosy', 'Zippy', 'Plucky', 'Witty', 'Sunny', 'Mighty', 'Curious', 'Dapper', 'Nimble', 'Gentle', 'Merry', 'Bold', 'Quirky'] -const NOUNS = ['Knedlík', 'Trdelník', 'Golem', 'Krtek', 'Pivo', 'Koláček', 'Rohlík', 'Vltava', 'Klobása', 'Švejk', 'Beaver', 'Otter', 'Lynx', 'Heron', 'Badger', 'Hedgehog', 'Stork', 'Marten', 'Owl', 'Carp'] +const DEFAULT_ADJECTIVES = ['Brave', 'Sleepy', 'Jolly', 'Sneaky', 'Fuzzy', 'Clever', 'Bouncy', 'Cosy', 'Zippy', 'Plucky', 'Witty', 'Sunny', 'Mighty', 'Curious', 'Dapper', 'Nimble', 'Gentle', 'Merry', 'Bold', 'Quirky'] +const DEFAULT_NOUNS = ['Otter', 'Heron', 'Badger', 'Hedgehog', 'Owl', 'Fox', 'Raven', 'Squirrel', 'Beaver', 'Rabbit', 'Moose', 'Falcon', 'Wolf', 'Robin', 'Lynx', 'Marten', 'Stork', 'Carp', 'Frog', 'Seal'] const COLORS = ['#ff5d8f', '#ffb347', '#ffe156', '#8ce99a', '#38d9a9', '#4dabf7', '#748ffc', '#b197fc', '#f783ac', '#ff8787', '#63e6be', '#74c0fc', '#ffa94d', '#d0bfff', '#a9e34b', '#66d9e8'] const clamp = (n: unknown) => typeof n === 'number' && Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : 0.5 @@ -277,6 +282,10 @@ class CursorsRoom extends Room { flushTimer?: ReturnType persistTimer?: ReturnType + async activeNames () { + return resolveNames(await this.store.get(this.key('names')) ?? {}) + } + async join (conn: Conn, params: URLSearchParams) { conn.host = params.get('role') === 'host' && this.options.authorise(params.get('token')) const scene = await this.store.get(this.key('scene')) @@ -327,7 +336,8 @@ class CursorsRoom extends Room { let changed = false if (!cursor) { const n = await this.store.incr(this.key('assigned')) - 1 - cursor = { id, name: makeName(n), color: COLORS[n % COLORS.length]!, x: 0.5, y: 0.5 } + const names = await this.activeNames() + cursor = { id, name: makeName(n, names), color: COLORS[n % COLORS.length]!, x: 0.5, y: 0.5 } changed = true } if (typeof msg.name === 'string' && msg.name.trim()) { @@ -382,6 +392,12 @@ class CursorsRoom extends Room { case 'result': await this.publish({ to: 'phone', ms: [JSON.stringify({ t: 'result', zoneId: typeof msg.zoneId === 'string' ? msg.zoneId : null })] }) break + case 'names': { + const names = resolveNames(msg) + if (sameNames(names, await this.activeNames())) break + await this.store.set(this.key('names'), names) + break + } case 'reset': { const cursors = await this.store.hgetall(this.key('cursors')) for (const cursor of Object.values(cursors)) { @@ -503,12 +519,39 @@ class CursorsRoom extends Room { } } -function makeName (n: number) { - const adj = ADJECTIVES[n % ADJECTIVES.length] - const noun = NOUNS[(n * 7 + Math.floor(n / ADJECTIVES.length)) % NOUNS.length] +function makeName (n: number, names: Names) { + const adjectives = names.adjectives + const nouns = names.nouns + const adj = adjectives[n % adjectives.length] + const noun = nouns[(n * 7 + Math.floor(n / adjectives.length)) % nouns.length] return `${adj} ${noun}` } +function sanitiseList (value: unknown) { + if (!Array.isArray(value)) return [] + return value + .filter((entry): entry is string => typeof entry === 'string') + .map(entry => entry.trim().slice(0, 24)) + .filter(Boolean) + .slice(0, 100) +} + +function resolveNames (input: { adjectives?: unknown, nouns?: unknown }): Names { + const adjectives = sanitiseList(input.adjectives) + const nouns = sanitiseList(input.nouns) + return { + adjectives: adjectives.length ? adjectives : [...DEFAULT_ADJECTIVES], + nouns: nouns.length ? nouns : [...DEFAULT_NOUNS], + } +} + +function sameNames (a: Names, b: Names) { + return a.adjectives.length === b.adjectives.length + && a.adjectives.every((name, i) => name === b.adjectives[i]) + && a.nouns.length === b.nouns.length + && a.nouns.every((name, i) => name === b.nouns[i]) +} + function sanitiseScene (scene: unknown): Scene | null { if (!scene || typeof scene !== 'object') return null const s = scene as Record diff --git a/test/unit/party.spec.ts b/test/unit/party.spec.ts index 58a35a27..da427f50 100644 --- a/test/unit/party.spec.ts +++ b/test/unit/party.spec.ts @@ -111,7 +111,7 @@ describe('party rooms', () => { await settle() const you = phone.json().find(m => m.t === 'you') - expect(you).toMatchObject({ id: 'a-phone-identifier', name: 'Brave Knedlík', x: 0.5, y: 0.5 }) + expect(you).toMatchObject({ id: 'a-phone-identifier', name: 'Brave Otter', x: 0.5, y: 0.5 }) expect(host.json()).toEqual([ { t: 'sync', cursors: [] }, { t: 'scene', scene: null }, @@ -124,6 +124,40 @@ describe('party rooms', () => { expect(host.json().at(-1)).toEqual({ t: 'leave', id: 'a-phone-identifier' }) }) + it('themes new cursor names from the host', async () => { + const [a, b] = createInstances() + const host = createPeer() + const phone = createPeer() + await a!.open(host, url('cursors', `?role=host&token=${TOKEN}`)) + await b!.open(phone, url('cursors')) + + await a!.message(host, JSON.stringify({ t: 'names', adjectives: [' Brave ', 42, ''], nouns: ['Ajolote', 'Quetzal'] })) + await b!.message(phone, JSON.stringify({ t: 'hello', id: 'a-phone-identifier' })) + + expect(phone.json().find(m => m.t === 'you')).toMatchObject({ id: 'a-phone-identifier', name: 'Brave Ajolote' }) + }) + + it('ignores name lists from phones and keeps them through clear', async () => { + const [a] = createInstances(1) + const host = createPeer() + const phone = createPeer() + await a!.open(host, url('cursors', `?role=host&token=${TOKEN}`)) + await a!.open(phone, url('cursors')) + + await a!.message(host, JSON.stringify({ t: 'names', adjectives: ['Brave', 'Sleepy'], nouns: ['Ajolote', 'Quetzal'] })) + await a!.message(phone, JSON.stringify({ t: 'names', adjectives: ['Nope'], nouns: ['Nope'] })) + await a!.message(phone, JSON.stringify({ t: 'hello', id: 'a-phone-identifier' })) + expect(phone.json().find(m => m.t === 'you')).toMatchObject({ name: 'Brave Ajolote' }) + + await a!.message(host, JSON.stringify({ t: 'clear' })) + expect(phone.closed).toBe(4000) + + const next = createPeer() + await a!.open(next, url('cursors')) + await a!.message(next, JSON.stringify({ t: 'hello', id: 'next-phone-identifier' })) + expect(next.json().find(m => m.t === 'you')).toMatchObject({ name: 'Brave Ajolote' }) + }) + it('keeps a cursor online while it is still connected to another instance', async () => { const [a, b] = createInstances() const host = createPeer()