diff --git a/src/conformance/common.ts b/src/conformance/common.ts index d18af1f..6396e1d 100644 --- a/src/conformance/common.ts +++ b/src/conformance/common.ts @@ -5,6 +5,7 @@ import { AuthProvider, ClientAuth } from '../runtypes/client_auth'; import { LobbyState } from '../state/lobby'; import { BindPublishers } from '../util'; import { ClientAuthWebSocket, TaggedClientAuth, createMessageHandler } from '../ws_handlers'; +import { RoomName, RoomNameSuccess } from '../room_tracker'; export type MockSentMessage = { topic: string | null; @@ -17,6 +18,7 @@ export const createTestEnv = ( createRoomId?: () => string, createChatId?: () => string, createStatsId?: () => string, + createRandomRoomName?: (userSuppliedName: string | null) => RoomName, ) => { const debug = () => {}; @@ -55,7 +57,7 @@ export const createTestEnv = ( createChatId, ); - const lobby = new LobbyState(publishers, devMode, createRoomId, createChatId, createStatsId); + const lobby = new LobbyState(publishers, devMode, createRoomId, createChatId, createStatsId, createRandomRoomName); const { signToken, verifyToken } = createJwtFns('test secret', 'test refresh'); diff --git a/src/conformance/lobby.test.ts b/src/conformance/lobby.test.ts index 11c6402..b412e26 100644 --- a/src/conformance/lobby.test.ts +++ b/src/conformance/lobby.test.ts @@ -2,6 +2,7 @@ import { M, NT } from '@noita-together/nt-message'; import { SYSTEM_USER } from '../state/lobby'; import { ClientAuthWebSocket } from '../ws_handlers'; import { createTestEnv, MockSentMessage } from './common'; +import { RoomNameSuccess } from '../room_tracker'; const uuidRE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; @@ -295,6 +296,7 @@ describe('lobby conformance tests', () => { () => 'room', () => 'chat', () => 'stats', + (name: string | null): RoomNameSuccess => ({ ok: true, name: "host's room", release: () => {} }), ); const host = testSocket('hostId', 'host'); const player = testSocket('playerId', 'player'); @@ -422,7 +424,7 @@ describe('lobby conformance tests', () => { gamemode: 0, locked: false, maxUsers: 3, - name: "user1's room", + name: 'auto-generated', users: [ { name: 'user1', @@ -444,7 +446,7 @@ describe('lobby conformance tests', () => { id: 'room1', locked: false, maxUsers: 3, - name: "user1's room", + name: 'auto-generated', owner: 'user1', protected: false, }, @@ -494,7 +496,7 @@ describe('lobby conformance tests', () => { id: 'room1', locked: false, maxUsers: 3, - name: "user1's room", + name: 'auto-generated', users: [ { name: 'user1', @@ -563,7 +565,7 @@ describe('lobby conformance tests', () => { gamemode: 0, locked: false, maxUsers: 3, - name: "user1's room", + name: 'auto-generated', users: [ { name: 'user1', @@ -585,7 +587,7 @@ describe('lobby conformance tests', () => { id: 'room1', locked: false, maxUsers: 3, - name: "user1's room", + name: 'auto-generated', owner: 'user1', protected: true, }, @@ -617,7 +619,7 @@ describe('lobby conformance tests', () => { id: 'room1', locked: false, maxUsers: 3, - name: "user1's room", + name: 'auto-generated', users: [ { name: 'user1', @@ -686,7 +688,7 @@ describe('lobby conformance tests', () => { gamemode: 0, locked: false, maxUsers: 3, - name: "user1's room", + name: 'auto-generated', users: [ { name: 'user1', @@ -708,7 +710,7 @@ describe('lobby conformance tests', () => { id: 'room1', locked: false, maxUsers: 3, - name: "user1's room", + name: 'auto-generated', owner: 'user1', protected: true, }, @@ -749,7 +751,7 @@ describe('lobby conformance tests', () => { gamemode: 0, locked: false, maxUsers: 3, - name: "user1's room", + name: 'auto-generated', users: [ { name: 'user1', @@ -771,7 +773,7 @@ describe('lobby conformance tests', () => { id: 'room1', locked: false, maxUsers: 3, - name: "user1's room", + name: 'auto-generated', owner: 'user1', protected: true, }, @@ -894,7 +896,7 @@ describe('lobby conformance tests', () => { gamemode: 0, locked: false, maxUsers: 3, - name: "user1's room", + name: 'auto-generated', users: [ { name: 'user1', @@ -916,7 +918,7 @@ describe('lobby conformance tests', () => { id: 'room1', locked: false, maxUsers: 3, - name: "user1's room", + name: 'auto-generated', owner: 'user1', protected: false, }, @@ -962,7 +964,7 @@ describe('lobby conformance tests', () => { gamemode: 0, locked: false, maxUsers: 3, - name: "user1's room", + name: 'auto-generated', users: [ { name: 'user1', @@ -984,7 +986,7 @@ describe('lobby conformance tests', () => { id: 'room1', locked: false, maxUsers: 3, - name: "user1's room", + name: 'auto-generated', owner: 'user1', protected: false, }, @@ -1032,7 +1034,7 @@ describe('lobby conformance tests', () => { gamemode: 0, locked: false, maxUsers: 3, - name: "user1's room", + name: 'auto-generated', users: [ { name: 'user1', @@ -1054,7 +1056,7 @@ describe('lobby conformance tests', () => { id: 'room1', locked: false, maxUsers: 3, - name: "user1's room", + name: 'auto-generated', owner: 'user1', protected: false, }, @@ -1075,7 +1077,7 @@ describe('lobby conformance tests', () => { gamemode: 0, locked: false, maxUsers: 3, - name: "user1's room", + name: 'auto-generated', users: [ { name: 'user1', @@ -1097,7 +1099,7 @@ describe('lobby conformance tests', () => { id: 'room2', locked: false, maxUsers: 3, - name: "user1's room", + name: 'auto-generated', owner: 'user1', protected: false, }, @@ -1247,7 +1249,7 @@ describe('lobby conformance tests', () => { id: 'room1', locked: false, maxUsers: 3, - name: "user1's room", + name: 'auto-generated', users: [ { name: 'user1', @@ -1354,7 +1356,7 @@ describe('lobby conformance tests', () => { id: 'room1', locked: false, maxUsers: 3, - name: "user1's room", + name: 'auto-generated', users: [ { name: 'user1', @@ -1401,7 +1403,7 @@ describe('lobby conformance tests', () => { M.cRoomCreate({ gamemode: 0, maxUsers: 3, - name: "user2's room", + name: 'auto-generated', }), ], [ @@ -1421,7 +1423,7 @@ describe('lobby conformance tests', () => { gamemode: 0, locked: false, maxUsers: 3, - name: "user2's room", + name: 'auto-generated', users: [ { name: 'user2', @@ -1443,7 +1445,7 @@ describe('lobby conformance tests', () => { id: 'room2', locked: false, maxUsers: 3, - name: "user2's room", + name: 'auto-generated', owner: 'user2', protected: false, }, @@ -1482,7 +1484,7 @@ describe('lobby conformance tests', () => { id: 'room1', locked: false, maxUsers: 3, - name: "user1's room", + name: 'auto-generated', users: [ { name: 'user1', @@ -1723,7 +1725,7 @@ describe('lobby conformance tests', () => { gamemode: 0, locked: false, maxUsers: 3, - name: "user1's room", + name: 'auto-generated', users: [ { name: 'user1', @@ -1745,7 +1747,7 @@ describe('lobby conformance tests', () => { id: 'room1', locked: false, maxUsers: 3, - name: "user1's room", + name: 'auto-generated', owner: 'user1', protected: false, }, @@ -1789,7 +1791,7 @@ describe('lobby conformance tests', () => { gamemode: 0, locked: false, maxUsers: 3, - name: "user1's room", + name: 'auto-generated', users: [ { name: 'user1', @@ -1811,7 +1813,7 @@ describe('lobby conformance tests', () => { id: 'room1', locked: false, maxUsers: 3, - name: "user1's room", + name: 'auto-generated', owner: 'user1', protected: false, }, @@ -1841,7 +1843,7 @@ describe('lobby conformance tests', () => { id: 'room1', locked: false, maxUsers: 3, - name: "user1's room", + name: 'auto-generated', owner: 'user1', protected: false, }, @@ -1970,7 +1972,7 @@ describe('lobby conformance tests', () => { id: 'room1', locked: false, maxUsers: 3, - name: "user1's room", + name: 'auto-generated', owner: 'user1', protected: true, }, @@ -2172,7 +2174,7 @@ describe('lobby conformance tests', () => { id: 'room1', locked: false, maxUsers: 3, - name: "user1's room", + name: 'auto-generated', users: [ { name: 'user1', @@ -2289,7 +2291,7 @@ describe('lobby conformance tests', () => { id: 'room1', locked: false, maxUsers: 3, - name: "user1's room", + name: 'auto-generated', users: [ { name: 'user1', @@ -2675,6 +2677,7 @@ describe('lobby conformance tests', () => { () => `room${roomId++}`, () => `chat${chatId++}`, () => `stats${statsId++}`, + (name: string | null) => ({ ok: true, name: name ?? 'auto-generated', release: () => {} }), ); const { sentMessages, testSocket, handleOpen, handleMessage } = env; diff --git a/src/room_tracker.test.ts b/src/room_tracker.test.ts new file mode 100644 index 0000000..6c56525 --- /dev/null +++ b/src/room_tracker.test.ts @@ -0,0 +1,146 @@ +import { Bitmap, RandomBitmap, RoomNameFailure, RoomNameSuccess, RoomTracker, SequentialBitmap } from './room_tracker'; + +describe('Bitmap', () => { + it('correctly implements bitmap tracking', () => { + const bmp = new Bitmap(129); + for (let i = 0; i < 129; i++) { + bmp['set'](i); + expect(bmp['has'](i)).toEqual(true); + bmp['clear'](i); + expect(bmp['has'](i)).toEqual(false); + } + }); + it.each([ + [0, null], + [128, null], + [Infinity, TypeError], + [NaN, TypeError], + [1.23, TypeError], + [-1, RangeError], + [130, RangeError], + ] as [v: number, err: Error | null][])('enforces valid `code` values: %s %s', (code, errorClass) => { + const bmp = new Bitmap(129); + if (errorClass === null) { + expect(() => bmp['assertValidCode'](code)).not.toThrow(); + } else { + expect(() => bmp['assertValidCode'](code)).toThrow(errorClass); + } + }); +}); +describe('RandomBitmap', () => { + it('returns -1 when full', () => { + const bmp = new RandomBitmap(1); + expect(bmp.acquire()).toEqual(0); + expect(bmp.acquire()).toEqual(-1); + }); + it('correctly releases codes', () => { + const bmp = new RandomBitmap(2); + const code1 = bmp.acquire(); + const code2 = bmp.acquire(); + expect(code1).not.toEqual(code2); + + expect(code1).not.toEqual(-1); + expect(code2).not.toEqual(-1); + + bmp.release(code1); + bmp.release(code1); + expect(bmp.acquire()).toEqual(code1); + expect(bmp.acquire()).toEqual(-1); + }); +}); +describe('SequentialBitmap', () => { + it('returns -1 when full', () => { + const bmp = new SequentialBitmap(1); + expect(bmp.acquire()).toEqual(0); + expect(bmp.acquire()).toEqual(-1); + }); + it('correctly releases codes', () => { + const bmp = new SequentialBitmap(2); + + const code = bmp.acquire(); + expect(code).toEqual(0); + bmp.release(code); + expect(bmp.acquire()).toEqual(0); + }); + + it.each([ + [-1, [~0]], + [0, [~0b00000000000000000000000000000001]], + [1, [~0b00000000000000000000000000000010]], + [7, [~0b00000000000000000000000010000000]], + [8, [~0b00000000000000000000000100000000]], + [31, [~0b10000000000000000000000000000000]], + [32, [~0, ~0b00000000000000000000000000000001]], + ] as [number, number[]][])('correctly finds the next free code: %s', (code, bitmap) => { + const size = Math.max(1, (Math.floor(code / 32) + 1) * 32); + const bmp = new SequentialBitmap(size); + for (const [idx, v] of bitmap.entries()) { + bmp['bitmap'][idx] = v >>> 0; + } + bmp['minFree'] = 0; + expect(bmp['nextFree']()).toEqual(code); + }); + + it('correctly returns the lowest available value', () => { + const bmp = new SequentialBitmap(2); + const code1 = bmp.acquire(); + const code2 = bmp.acquire(); + bmp.release(code1); + expect(bmp.acquire()).toEqual(0); + }); + it('does not return an invalid code from the last byte of a bitmap', () => { + const bmp = new SequentialBitmap(33); + bmp['bitmap'][0] = ~0 >>> 0; + bmp['bitmap'][1] = 1; + bmp['minFree'] = 0; + expect(bmp['nextFree']()).toEqual(-1); + }); +}); +describe('RoomTracker', () => { + it('returns names -> numbers -> failure', () => { + const rt = new RoomTracker([['a'], ['b']], 1); + const r1 = rt.acquire() as RoomNameSuccess; + expect(r1.ok).toEqual(true); + expect(r1.name).toEqual('a b'); + + const r2 = rt.acquire() as RoomNameSuccess; + expect(r2.ok).toEqual(true); + expect(r2.name).toEqual('Room #1'); + + const r3 = rt.acquire() as RoomNameFailure; + expect(r3.ok).toEqual(false); + expect(r3.error).toEqual('No available room names'); + }); + it('correctly releases assigned names', () => { + const rt = new RoomTracker([['a'], ['b']], 1); + const r1 = rt.acquire() as RoomNameSuccess; + expect(r1.ok).toEqual(true); + expect(r1.name).toEqual('a b'); + + const r2 = rt.acquire() as RoomNameSuccess; + expect(r2.ok).toEqual(true); + expect(r2.name).toEqual('Room #1'); + + r1.release(); + const r3 = rt.acquire() as RoomNameSuccess; + expect(r3.ok).toEqual(true); + expect(r3.name).toEqual('a b'); + + r2.release(); + const r4 = rt.acquire() as RoomNameSuccess; + expect(r4.ok).toEqual(true); + expect(r4.name).toEqual('Room #1'); + }); + it('works with no wordlists', () => { + const rt = new RoomTracker([], 1); + const r1 = rt.acquire() as RoomNameSuccess; + expect(r1.ok).toEqual(true); + expect(r1.name).toEqual('Room #1'); + }); + it('works with no wordlists and no capacity', () => { + const rt = new RoomTracker([], 0); + const r1 = rt.acquire() as RoomNameFailure; + expect(r1.ok).toEqual(false); + expect(r1.error).toEqual('No available room names'); + }); +}); diff --git a/src/room_tracker.ts b/src/room_tracker.ts new file mode 100644 index 0000000..4bf69a8 --- /dev/null +++ b/src/room_tracker.ts @@ -0,0 +1,191 @@ +export class Bitmap { + protected size: number; + protected bitmap: Uint32Array; + + constructor(size: number) { + if (size >= 2 ** 32) { + throw new Error('Pool too large'); + } + this.size = size; + this.bitmap = new Uint32Array(Math.ceil(this.size / 32)); + } + + protected assertValidCode(code: number): void { + if (!Number.isInteger(code)) { + throw new TypeError('code is not an integer'); + } + if (code < 0 || code >= this.size) { + throw new RangeError(`code out of range (${code} not in 0-${this.size - 1})`); + } + } + + protected has(code: number): boolean { + this.assertValidCode(code); + const idx = code >> 5; + const mask = 1 << (code & 0x1f); + return (this.bitmap[idx] & mask) !== 0; + } + protected set(code: number): void { + this.assertValidCode(code); + const idx = code >> 5; + const mask = 1 << (code & 0x1f); + this.bitmap[idx] |= mask; + } + protected clear(code: number): void { + this.assertValidCode(code); + const idx = code >> 5; + const mask = 1 << (code & 0x1f); + this.bitmap[idx] &= ~mask; + } +} + +export class RandomBitmap extends Bitmap { + acquire(): number { + for (let i = 0; i < 10; i++) { + const code = Math.floor(Math.random() * this.size); + if (!this.has(code)) { + this.set(code); + return code; + } + } + return -1; + } + release(code: number): void { + this.clear(code); + } +} + +const rightmostFreeBit = (n: number) => Math.log2(((n + 1) & ~n) >>> 0); + +export class SequentialBitmap extends Bitmap { + private minFree: number = 0; + + private nextFree(): number { + const nextN = this.minFree + 1; + if (nextN === this.size) return -1; + + if (!this.has(nextN)) return nextN; + + const start = nextN >> 5; + const freeIdx = this.bitmap.slice(start).findIndex((v) => v !== 0xffffffff) + start; + if (freeIdx === -1) return -1; + + const bitPos = rightmostFreeBit(this.bitmap[freeIdx]); + const nextFree = ((freeIdx << 5) | bitPos) >>> 0; + return nextFree >= this.size ? -1 : nextFree; + } + acquire(): number { + if (this.minFree === -1) return -1; + const n = this.minFree; + this.set(n); + this.minFree = this.nextFree(); + return n; + } + release(code: number): void { + this.clear(code); + this.minFree = this.minFree === -1 ? code : Math.min(this.minFree, code); + } +} + +export type RoomNameSuccess = { + ok: true; + name: string; + release: () => void; +}; +export type RoomNameFailure = { + ok: false; + error: string; +}; +export type RoomName = RoomNameSuccess | RoomNameFailure; + +export class RoomTracker { + protected wordSize: number; + protected words: RandomBitmap; + protected numberSize: number; + protected numbers: SequentialBitmap; + protected custom: Set; + + constructor( + protected wordlists: string[][], + numberSize: number = 1000, + ) { + const filtered = wordlists.filter((v) => v.length > 0); + this.wordSize = filtered.length > 0 ? wordlists.reduce((acc, cur) => acc * cur.length, 1) : 0; + this.numberSize = numberSize; + + if ( + !Number.isInteger(this.wordSize) || + !Number.isInteger(this.numberSize) || + this.wordSize < 0 || + this.numberSize < 0 + ) { + throw new Error('Invalid word/number size'); + } + + if (this.wordSize > Number.MAX_SAFE_INTEGER) { + // with enough words in each list, or enough combinations, the code that + // represents a word will get too large and our code will fail. but we'll + // probably run out of memory before that happens... + throw new Error('Too many combinations to represent!'); + } + + this.words = new RandomBitmap(this.wordSize); + this.numbers = new SequentialBitmap(this.numberSize); + this.custom = new Set(); + } + + protected strname(code: number): string { + const strs: string[] = []; + let v = code; + for (const list of this.wordlists) { + const len = list.length; + strs.push(list[v % len]); + v = Math.floor(v / len); + } + return strs.join(' '); + } + + public acquire(name: string | null = null): RoomName { + // custom name, if given + if (name) { + if (this.custom.has(name)) { + return { + ok: false, + error: 'Room name in use', + }; + } + this.custom.add(name); + return { + ok: true, + name, + release: () => this.custom.delete(name), + }; + } + if (this.wordSize > 0) { + const combo = this.words.acquire(); + if (combo > -1) { + return { + ok: true, + name: this.strname(combo), + release: () => this.words.release(combo), + }; + } + } + + if (this.numberSize > 0) { + const num = this.numbers.acquire(); + if (num > -1) { + return { + ok: true, + name: `Room #${num + 1}`, + release: () => this.numbers.release(num), + }; + } + } + + return { + ok: false, + error: 'No available room names', + }; + } +} diff --git a/src/runtypes/room_opts.ts b/src/runtypes/room_opts.ts index db158e9..c15b895 100644 --- a/src/runtypes/room_opts.ts +++ b/src/runtypes/room_opts.ts @@ -2,6 +2,7 @@ import { Static, Type as T } from '@sinclair/typebox'; import { Value } from '@sinclair/typebox/value'; import Debug from 'debug'; +import { randomRoomName } from '../util'; const debug = Debug('nt:runtypes:room_options'); // https://github.com/validatorjs/validator.js/blob/b958bd7d1026a434ad3bf90064d3dcb8b775f1a9/src/lib/isAscii.js#L7 @@ -48,10 +49,9 @@ export const validateRoomOpts = < Schema extends typeof CreateRoomOpts | typeof CreateBigRoomOpts | typeof UpdateRoomOpts | typeof UpdateBigRoomOpts, >( schema: Schema, - { name, password, ...rest }: Static, + { password, ...rest }: Static, ): string | Static => { const opts: Static = { ...rest }; - if (name) opts.name = name.trim(); if (password) opts.password = password.trim(); if (!Value.Check(schema, opts)) { diff --git a/src/state/lobby.ts b/src/state/lobby.ts index 5f75924..009fb76 100644 --- a/src/state/lobby.ts +++ b/src/state/lobby.ts @@ -1,10 +1,11 @@ import { M, NT } from '@noita-together/nt-message'; import { ClientAuthWebSocket } from '../ws_handlers'; -import { Deferred, Publishers, formatDuration, makeDeferred } from '../util'; +import { Publishers } from '../util'; import { LobbyActionHandlers } from '../types'; import { IUser, UserState } from './user'; import { RoomState, RoomStateUpdateOpts } from './room'; +import { RoomName } from '../room_tracker'; export const SYSTEM_USER: IUser = { id: '-1', name: '[SYSTEM]' }; export const ANNOUNCEMENT: IUser = { id: '-2', name: '[ANNOUNCEMENT]' }; @@ -44,6 +45,7 @@ export class LobbyState implements LobbyActionHandlers { private createRoomId?: () => string, private createChatId?: () => string, private createStatsId?: () => string, + private createRoomName?: (userSuppliedName: string | null) => RoomName, ) { this.publishers = publishers; this.broadcast = publishers.broadcast(this.topic); @@ -225,6 +227,7 @@ export class LobbyState implements LobbyActionHandlers { ...(this.createRoomId ? { roomId: this.createRoomId() } : {}), ...(this.createChatId ? { createChatId: this.createChatId } : {}), ...(this.createStatsId ? { createStatsId: this.createStatsId } : {}), + ...(this.createRoomName ? { createRoomName: this.createRoomName } : {}), }); } diff --git a/src/state/room.ts b/src/state/room.ts index 664b5fa..85dc32a 100644 --- a/src/state/room.ts +++ b/src/state/room.ts @@ -1,3 +1,5 @@ +import { M, NT, tagPlayerMove } from '@noita-together/nt-message'; + import { CreateBigRoomOpts, CreateRoomOpts, @@ -5,8 +7,7 @@ import { UpdateRoomOpts, validateRoomOpts, } from '../runtypes/room_opts'; -import { Deferred, Publishers, createChat, formatDuration, makeDeferred } from '../util'; -import { M, NT, tagPlayerMove } from '@noita-together/nt-message'; +import { Deferred, Publishers, createChat, formatDuration, makeDeferred, randomRoomName } from '../util'; import { GameActionHandlers } from '../types'; import { defaultEnv, statsUrl } from '../env_vars'; const { DRAIN_GRACE_TIMEOUT_MS, DRAIN_NOTIFY_INTERVAL_MS } = defaultEnv; @@ -14,6 +15,7 @@ const { DRAIN_GRACE_TIMEOUT_MS, DRAIN_NOTIFY_INTERVAL_MS } = defaultEnv; import { IUser, UserState } from './user'; import { LobbyState, SYSTEM_USER } from './lobby'; import { StatsEvent, StatsRecorder } from './stats_recorder'; +import { RoomName } from '../room_tracker'; import { v4 as uuidv4 } from 'uuid'; @@ -85,6 +87,7 @@ export class RoomState implements GameActionHandlers<'cPlayerMove'> { owner: UserState, { name, password, gamemode, maxUsers }: RoomStateCreateOpts, publishers: Publishers, + private releaseRoomName: () => void, roomId?: string, createChatId?: () => string, private createStatsId?: () => string, @@ -178,6 +181,7 @@ export class RoomState implements GameActionHandlers<'cPlayerMove'> { roomId, createChatId, createStatsId, + createRoomName, }: { lobby: LobbyState; owner: UserState; @@ -187,6 +191,7 @@ export class RoomState implements GameActionHandlers<'cPlayerMove'> { roomId?: string; createChatId?: () => string; createStatsId?: () => string; + createRoomName?: (userSuppliedName: string | null) => RoomName; }): RoomState | void { let opts: RoomStateCreateOpts | string; @@ -195,6 +200,19 @@ export class RoomState implements GameActionHandlers<'cPlayerMove'> { return; } + // if the room name is specified *and* the user has access, use the name as-is. otherwise, + // pull a random room name from the pool + const trimmed = owner.uaccess > 0 ? _opts.name?.trim() : null; + const roomName: RoomName = (createRoomName ?? randomRoomName)(trimmed); + + if (roomName.ok === false) { + // no available room names + owner.send(M.sRoomCreateFailed({ reason: roomName.error })); + return; + } + + _opts.name = roomName.name; + opts = validateRoomOpts(owner.uaccess > 1 ? CreateBigRoomOpts : CreateRoomOpts, _opts); if (typeof opts === 'string') { @@ -205,11 +223,7 @@ export class RoomState implements GameActionHandlers<'cPlayerMove'> { // user owned another room. probably they reconnected. destroy old room. owner.room()?.delete(owner); - if (owner.uaccess === 0) { - opts.name = `${owner.name}'s room`; - } - - const room = new RoomState(lobby, owner, opts, publishers, roomId, createChatId, createStatsId); + const room = new RoomState(lobby, owner, opts, publishers, roomName.release, roomId, createChatId, createStatsId); debug(room.id, 'created'); room.users.add(owner); @@ -618,6 +632,8 @@ export class RoomState implements GameActionHandlers<'cPlayerMove'> { * be used only by system processes. */ destroy() { + // give this room's name back to the pool + this.releaseRoomName(); this.broadcast(M.sRoomDeleted({ id: this.id })); // this.playerPositions.destroy(); diff --git a/src/util.ts b/src/util.ts index eb3efc6..52452ad 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,4 +1,7 @@ import { createHmac, randomBytes } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + import { TemplatedApp, WebSocket } from 'uWebSockets.js'; import { v4 as uuidv4 } from 'uuid'; import { M, NT } from '@noita-together/nt-message'; @@ -6,6 +9,7 @@ import { M, NT } from '@noita-together/nt-message'; import { IUser } from './state/user'; import Debug from 'debug'; +import { RoomTracker } from './room_tracker'; const debug = Debug('nt:util'); type HasPublish = Pick, 'publish'>; @@ -106,3 +110,16 @@ export const formatBytes = (bytes: number) => { } return `${bytes.toFixed(2)} B`; }; + +export const randomRoomName = (() => { + const load = (filename: string) => + readFileSync(resolve(__dirname, 'wordlists', filename), 'utf-8') + .split(/[\r\n]+/) + .filter((v) => !!v); + + const spells = load('allspells-beta.txt'); + const perks = load('allperks-beta.txt'); + + const rt = new RoomTracker([spells, perks]); + return (userSuppliedName: string | null) => rt.acquire(userSuppliedName); +})(); diff --git a/src/wordlists/allperks-beta.txt b/src/wordlists/allperks-beta.txt new file mode 100644 index 0000000..f4fc3fd --- /dev/null +++ b/src/wordlists/allperks-beta.txt @@ -0,0 +1,106 @@ +Critical Hit +Breathless +Greed +Trick Greed +Gold Is Forever +Trick Blood Money +Exploding Gold +Strong Levitation +Faster Levitation +Faster Movement +Never Skip Leg Day +Telekinetic Kick +Repelling Cape +Exploding Corpses +Saving Grace +Invisibility +More Blood +All-Seeing Eye +Levitation Trail +Vampirism +Extra Health +Stronger Hearts +Glass Cannon +Living On The Edge +Extra Life +Worm Attractor +Enemy Radar +Eat Your Vegetables +Iron Stomach +Wand Radar +Item Radar +Moon Radar +Spatial Awareness +Fire Immunity +Toxic Immunity +Explosion Immunity +Melee Immunity +Electricity Immunity +Teleportitis +Teleportitis Dodge +Stainless Armour +Tinker With Wands Everywhere +No Wand Tinkering +Wand Experimenter +Healthy Exploration +Bombs Materialized +Homing Shots +Boomerang Spells +Unlimited Spells +Freeze Field +Gas Fire +Dissolve Powders +Slime Blood +Oil Blood +Gas Blood +Permanent Shield +Revenge Explosion +Revenge Tentacle +Revenge Rats +Revenge Bullets +Lukki Mutation +Leggy Mutation +Plague Rats +Spontaneous Generation +Cordyceps +Fungal Colony +Feared By Worms +Projectile Repulsion Field +Close Call +Fungal Disease +Projectile Slower +Projectile Repulsion Sector +Projectile Eater +Phasing +Angry Ghost +Hungry Ghost +Mournful Spirit +Homunculus +Lukki Minion +Electricity +Attract Gold +Extra Knockback On Spells +Concentrated Spells +Low Recoil +Bouncing Spells +Faster Projectiles +Always Cast +High Mana, Low Capacity +No More Shuffle +No More Knockback +Projectile Duplication +Faster Wands +Extra Wand Capacity +Contact Damage +Extra Perk +Perk Lottery +Gamble +Extra Item In Holy Mountain +More Hatred +More Love +Peace With Gods +Kills To Mana +Rage-Fueled Levitation +Pinpointer +Personal Plasma Beam +Summon Sädekivi \ No newline at end of file diff --git a/src/wordlists/allspells-beta.txt b/src/wordlists/allspells-beta.txt new file mode 100644 index 0000000..0fa03f3 --- /dev/null +++ b/src/wordlists/allspells-beta.txt @@ -0,0 +1,368 @@ +Bomb +Spark Bolt +Magic Arrow +Magic Bolt +Burst Of Air +Energy Orb +Hookbolt +Black Hole +White Hole +Giga Black Hole +Giga White Hole +Omega Black Hole +Omega White Hole +Eldritch Portal +Spitter Bolt +Large Spitter Bolt +Giant Spitter Bolt +Bubble Spark +Disc Projectile +Giga Disc Projectile +Summon Omega Sawblade +Energy Sphere +Bouncing Burst +Arrow +Pollen +Glowing Lance +Holy Lance +Magic Missile +Large Magic Missile +Giant Magic Missile +Firebolt +Large Firebolt +Giant Firebolt +Odd Firebolt +Dropper Bolt +Unstable Crystal +Dormant Crystal +Summon Fish +Summon Deercoy +Flock Of Ducks +Worm Launcher +Explosive Detonator +Concentrated Light +Intense Concentrated Light +Lightning Bolt +Ball Lightning +Plasma Beam +Plasma Beam Cross +Plasma Cutter +Digging Bolt +Digging Blast +Chainsaw +Luminous Drill +Summon Tentacle +Healing Bolt +Deadly Heal +Spiral Shot +Magic Guard +Big Magic Guard +Chain Bolt +Fireball +Meteor +Flamethrower +Iceball +Slimeball +Path Of Dark Flame +Summon Missile +Summon Rock Spirit +Dynamite +Glitter Bomb +Triplicate Bolt +Freezing Gaze +Pinpoint Of Light +Prickly Spore Pod +Glue Ball +Holy Bomb +Giga Holy Bomb +Propane Tank +Bomb Cart +Cursed Sphere +Expanding Sphere +Earthquake +Rock +Summon Egg +Summon Hollow Egg +Summon Explosive Box +Summon Large Explosive Box +Summon Fly Swarm +Summon Firebug Swarm +Summon Wasp Swarm +Summon Friendly Fly +Acid Ball +Thunder Charge +Firebomb +Chunk Of Soil +Death Cross +Giga Death Cross +Infestation +Horizontal Barrier +Vertical Barrier +Square Barrier +Summon Wall +Summon Platform +Glittering Field +Delayedcast +Long-Distance Cast +Teleporting Cast +Warp Cast +Inner +Toxic Mist +Mist Of Spirits +Slime Mist +Blood Mist +Circle Of Fire +Circle Of Acid +Circle Of Oil +Circle Of Water +Water +Oil +Blood +Acid +Cement +Teleport Bolt +Small Teleport Bolt +Return +Swapper +Homebringer Teleport Bolt +Nuke +Giga Nuke +Fireworks +Summon Taikasauva +Touch Of Gold +Touch Of Water +Touch Of Oil +Touch Of Spirits +Touch Of Gold +Touch Of Grass +Touch Of Blood +Touch Of Smoke +Destruction +Double +Triple +Quadruple +Octuple +Myriad +Double Scatter +Triple Scatter +Quadruple Scatter +Behind Your Back +Bifurcated +Above And Below +Trifurcated +Hexagon +Pentagon +Iplicate +Reduce Spread +Heavy Spread +Reduce Recharge +Increase Lifetime +Reduce Lifetime +Nolla +Slow But Steady +Remove Explosion +Concentrated Explosion +Plasma Beam Enhancer +Add Mana +Blood Magic +Gold To Power +Blood To Power +Spell Duplication +Quantum Split +Gravity +Anti-Gravity +Slithering Path +Chaotic Path +Ping-Pong Path +Avoiding Arc +Floating Arc +Fly Downwards +Fly Upwards +Horizontal Path +Linear Arc +Orbiting Arc +Spiral Arc +Phasing Arc +True Orbit +Bounce +Remove Bounce +Homing +Anti Homing +Wand Homing +Short-Range Homing +Rotate Towards Foes +Boomerang +Auto-Aim +Accelerative Homing +Aiming Arc +Projectile Area Teleport +Piercing Shot +Drilling Shot +Damage Plus +Random Damage +Bloodlust +Mana To Damage +Critical Plus +Damage Field +Spells To Power +Essence To Power +Null Shot +Heavy Shot +Light Shot +Knockback +Recoil +Recoil Damper +Speed Up +Accelerating Shot +Decelerating Shot +Explosive Projectile +Clusterbolt +Water To Poison +Blood To Acid +Lava To Blood +Liquid Detonation +Toxic Sludge To Acid +Ground To Sand +Chaotic Transmutation +Chaos Magic +Necromancy +Light +Explosion +Magical Explosion +Explosion Of Brimstone +Explosion Of Poison +Explosion Of Spirits +Explosion Of Thunder +Circle Of Fervour +Circle Of Transmogrification +Circle Of Unstable Metamorphosis +Circle Of Thunder +Circle Of Stillness +Circle Of Vigour +Circle Of Displacement +Circle Of Buoyancy +Circle Of Shielding +Projectile Transmutation Field +Projectile Thunder Field +Projectile Gravity Field +Powder Vacuum Field +Liquid Vacuum Field +Vacuum Field +Sea Of Lava +Sea Of Alcohol +Sea Of Oil +Sea Of Water +Summon Swamp +Sea Of Acid +Sea Of Flammable Gas +Sea Of Mimicium +Rain Cloud +Oil Cloud +Blood Cloud +Acid Cloud +Thundercloud +Electric Charge +Matter Eater +Freeze Charge +Critical On Burning +Critical On Wet Enemies +Critical On Oiled Enemies +Critical On Bloody Enemies +Charm On Toxic Sludge +Explosion On Slimy Enemies +Explosion On Drunk Enemies +Petrify +Downwards Bolt Bundle +Octagonal Bolt Bundle +Fizzle +Explosive Bounce +Bubbly Bounce +Concentrated Light Bounce +Plasma Beam Bounce +Larpa Bounce +Sparkly Bounce +Lightning Bounce +Vacuum Bounce +Fireball Thrower +Lightning Thrower +Tentacler +Plasma Beam Thrower +Two-Way Fireball Thrower +Personal Fireball Thrower +Personal Lightning Caster +Personal Tentacler +Personal Gravity Field +Venomous Curse +Weakening Curse +Sawblade Orbit +Fireball Orbit +Nuke Orbit +Plasma Beam Orbit +Orbit Larpa +Chain +Electric Arc +Fire Arc +Gunpowder Arc +Poison Arc +Earthquake Shot +All-Seeing Eye +Firecrackers +Acid Trail +Poison Trail +Oil Trail +Water Trail +Gunpowder Trail +Fire Trail +Burning Trail +Torch +Electric Torch +Energy Shield +Energy Shield Sector +Projectile Energy Shield +Summon Tiny Ghost +Ocarina +Kantele +Random +Random Projectile +Random Modifier +Random Static Projectile +Copy Random +Copy Random Thrice +Copy Three Randoms +Spells To Nukes +Spells To Giga Sawblades +Spells To Magic Missiles +Spells To Death Crosses +Spells To Black Holes +Spells To Acid +The End Of Everything +Summon Portal +Add Trigger +Add Timer +Add Expiration Trigger +Chaos Larpa +Downwards Larpa +Upwards Larpa +Copy Trail +Larpa Explosion +Alpha +Gamma +Tau +Omega +Mu +Phi +Sigma +Zeta +Meteorisade +Matosade +Wand Refresh +Red Glimmer +Orange Glimmer +Green Glimmer +Yellow Glimmer +Purple Glimmer +Blue Glimmer +Rainbow Glimmer +Invisible +Rainbow Trail +Cessation \ No newline at end of file diff --git a/src/wordlists/enemies-beta.txt b/src/wordlists/enemies-beta.txt new file mode 100644 index 0000000..d03fed1 --- /dev/null +++ b/src/wordlists/enemies-beta.txt @@ -0,0 +1,184 @@ +Minä +Lammas +Lentolammas +Suhiseva lammas +Skorpioni +Eväkäs +Suureväkäs +Ankka +Susi +Nelikoipi +Poro +Nahkiainen +Heikkohurtta +Hurtta +Tappurahiisiläinen +Tappurahiisi +Tulihiisi +Jouluhiisi +Kokkihiisi +Sähikäismenninkäinen +Heikko haulikkohiisi +Haulikkohiisi +Rynkkyhiisi +Kranuhiisi +Miinankylväjä +Parantajahiisi +Liimahiisi +Häivehiisi +Kilpihiisi +Myrkkyhiisi +Isohiisi +Toimari +Puistokemisti +Snipuhiisi +Märkiäinen +Raukka +Liekkari +Jäähdytyslaite +Mätänevä ruumis +Mätänevä kroppa +Mätänevä pää +Heikko limanuljaska +Limanuljaska +Heikko happonuljaska +Happonuljaska +Mulkkio +Heikko äitinuljaska +Äitinuljaska +Möykky +Kiukkumöykky +Murkku +Rotta +Lepakko +Suurlepakko +Pikkutulikärpänen +Suurtulikärpänen +Puska +Plasmakukka +Amppari +Konna +Jättikonna +Laahustussieni +Nuijamalikka +Huhtasieni +Varjokupla +Toukka +Kallorotta +Kallokärpänen +Pikkuturso +Turso +Sylkyri +Hiidenkivi +Lohkare +Hämis +Pikkuhämähäkki +Hämähäkki +Lukki +Kasvoton Lukki +Kammolukki +Pikkumato +Mato +Jättimato +Kalmamato +Helvetinmato +Lennokki +Jättilaser-lennokki +Turvalennokki +Tarkkailija +Vakoilija +Pysäyttäjä +Teloittaja +Korjauslennokki +Robottikyttä +Kyrmyniska +Salamurhaajarobotti +Peitsivartija +KK-Tankki +IT-Tankki +Laser-tankki +Torjuntalaite +Munkki +Heinäsirkka +Tuonelankone +Marraskone +Liekkiö +Jäätiö +Sähkiö +Stendari +Eldari +Pakkasukko +Ukko +Suur-Ukko +Turvonnu velho +Sokaisunmestari +Siirtäjämestari +Muodonmuutosmestari +Vaihdosmestari +Maadoittajamestari +Palauttajamestari +Haavoittajamestari +Kohdennusmestari +Turvattomuusmestari +Sätkymestari +Valaistunut alkemisti +Kadotettu alkemisti +Epäalkemisti +Hyypiö +Ukkoshyypiö +Hohtava hyypiö +Patsas +Hohtonaamio +Haamukivi +Elvytyskristalli +Houre +Taikasauva +Olematon +Kummitus +Spiraalikalma +Kiukkukalma +Utu-Aave +Viha-Aave +Kaamo-Aave +Neva-Aave +Hahmonvaihtaja +Helvetinkatse +Taivaankatse +Helvetin sylkijä +Kirottu kristalli +Verikristalli +Taivaskristalli +Matkija +Jalkamatkatavara +Hornantappurahiisi +Hornahiisi +Hornasnipuhiisi +Pahan muisto +Valhe +Stevari +Skoude +Suomuhauki +Kolmisilmän koipi +Kolmisilmän sydän +Ylialkemisti +Alkemistin Varjo +Unohdettu +Häive +Tapion vasalli +Sauvojen tuntija +Kolmisilmän silmä +Syväolento +Limatoukka +Kolmisilmän Kätyri +Pienkätyri +Veska +Molari +Mokke +Seula +Mestarien mestari +Kolmisilmä +Kauhuhirviö +Toveri +Mätäryömijä +Lohkare +Henkevä potu \ No newline at end of file