diff --git a/constants/presence.ts b/constants/presence.ts new file mode 100644 index 0000000..0f2aa5c --- /dev/null +++ b/constants/presence.ts @@ -0,0 +1 @@ +export const PRESENCE_AUTH_REFRESH_MS = 60000; diff --git a/src/client/home.css b/src/client/home.css index 7c27854..d068553 100644 --- a/src/client/home.css +++ b/src/client/home.css @@ -175,3 +175,4 @@ body.player-app:is([data-screen=home], [data-screen=join]) #reconnect { position body.player-app .home-actions > .primary { min-height: 68px; font-size: 28px; } body.player-app .home .stat-mode > div { gap: 16px; } } +.online-count { position: absolute; top: max(16px, env(safe-area-inset-top)); right: max(20px, env(safe-area-inset-right)); z-index: 5; color: #45534a; font-size: 13px; font-variant-numeric: tabular-nums; } diff --git a/src/client/index.html b/src/client/index.html index f3c1142..15a288f 100644 --- a/src/client/index.html +++ b/src/client/index.html @@ -5,6 +5,7 @@
+ 접속 확인 중

쎄쎄쎄:기공대전

diff --git a/src/client/main.ts b/src/client/main.ts index 2c317d5..1fcd08f 100644 --- a/src/client/main.ts +++ b/src/client/main.ts @@ -18,6 +18,7 @@ import { createLobbyFeatures } from './lobby-features.js'; import { createGlobalSettingsCache } from './global-settings.js'; import { createAppFooter } from './app-footer.js'; import { installSoundControls } from './sound.js'; +import { createPresence } from './presence.js'; import './game.css'; import './match.css'; import './home.css'; @@ -30,6 +31,7 @@ const footer = createAppFooter(el('app-footer'), { rules: el('rules-dialog'), privacy: el('privacy-dialog'), contact: el('contact-dialog'), }); installSoundControls(); +const presence = createPresence(el('online-count')); const globalSettings = createGlobalSettingsCache({ getItem: key => localStorage.getItem(key), setItem: (key, value) => localStorage.setItem(key, value), }); @@ -244,6 +246,7 @@ async function boot() { let token: string | undefined; try { token = localStorage.getItem(GUEST_TOKEN_KEY) ?? undefined; } catch { storageAvailable = false; } const [session] = await Promise.all([api.ensureSession(token), globalSettings.refresh()]); + presence.connect(session); if (session.guestToken) try { localStorage.setItem(GUEST_TOKEN_KEY, session.guestToken); } catch { storageAvailable = false; } reporter?.destroy(); reporter = createResultReporter({ gameSessionId: session.gameSessionId, record: value => api.record(value) }, storage); analytics.ready(); stats(session.stats); connection('접속됨', true); el('reconnect').hidden = true; diff --git a/src/client/presence.ts b/src/client/presence.ts new file mode 100644 index 0000000..e8d62e2 --- /dev/null +++ b/src/client/presence.ts @@ -0,0 +1,22 @@ +import { io, type Socket } from 'socket.io-client'; +import type { GameSessionAuth } from '../shared/p2p-types.js'; + +export function createPresence(counter: HTMLElement) { + let socket: Socket | undefined, sessionId = ''; + const unknown = () => { counter.textContent = '접속 확인 중'; }; + window.addEventListener('pagehide', () => { socket?.disconnect(); unknown(); }); + window.addEventListener('pageshow', () => { socket?.connect(); }); + return { + connect(auth: GameSessionAuth) { + if (socket && sessionId === auth.gameSessionId) { socket.connect(); return; } + socket?.removeAllListeners(); socket?.disconnect(); unknown(); + sessionId = auth.gameSessionId; + socket = io('/presence', { auth: { gameSessionId: auth.gameSessionId, token: auth.token }, forceNew: true, autoConnect: false }); + socket.on('presence:count', (count: unknown) => { + if (Number.isSafeInteger(count) && (count as number) >= 0) counter.textContent = `접속 ${count}명`; + }); + socket.on('disconnect', unknown); socket.on('connect_error', unknown); + socket.connect(); + }, + }; +} diff --git a/src/server/create-server.ts b/src/server/create-server.ts index 5255d43..d1af481 100644 --- a/src/server/create-server.ts +++ b/src/server/create-server.ts @@ -12,6 +12,7 @@ import { createGameSessionStore } from './game-session-store.js'; import { createRoomStore } from './rooms.js'; import { registerAnalyticsApi, registerGameApi, registerRoomApi } from './game-api.js'; import { registerSignaling, type RtcServerOptions } from './signaling.js'; +import { registerPresence } from './presence.js'; import { createRankedService } from './ranked-service.js'; import { registerRankedApi } from './ranked-api.js'; import { createGlobalSettingsStore } from './global-settings.js'; @@ -32,6 +33,7 @@ export async function createGameServer(options: ServerOptions): Promise clock.now(), options.environment ?? 'production'); + registerPresence(io, gameSessions); const peerRooms = await createRoomStore(store.db, () => clock.now()); const ranked = await createRankedService(store.db, clock, options.environment ?? 'production', () => logSafeError(app.log, 'ranked_background', undefined)); const signaling = registerSignaling(io, gameSessions, peerRooms, options.rtc, () => clock.now()); diff --git a/src/server/presence.ts b/src/server/presence.ts new file mode 100644 index 0000000..60d7a89 --- /dev/null +++ b/src/server/presence.ts @@ -0,0 +1,33 @@ +import type { Server } from 'socket.io'; +import type { GameSessionStore } from './game-session-store.js'; +import { PRESENCE_AUTH_REFRESH_MS } from '../../constants/presence.js'; + +// The application is deployed as one process, as is the ranked match service. +export function registerPresence(io: Server, sessions: GameSessionStore) { + const namespace = io.of('/presence'); + let count = 0; + function publish() { + const next = new Set([...namespace.sockets.values()].filter(socket => socket.connected).map(socket => socket.data.playerId)).size; + if (next !== count) { count = next; namespace.emit('presence:count', count); } + } + namespace.use((socket, next) => { + const { gameSessionId, token } = socket.handshake.auth; + void sessions.get({ gameSessionId, token }).then(session => { + socket.data.playerId = session.playerId ?? session.gameSessionId; + next(); + }, () => next(new Error('INVALID_SESSION'))); + }); + namespace.on('connection', socket => { + const before = count; publish(); + if (before === count) socket.emit('presence:count', count); + let checking = false; + const timer = setInterval(() => { + if (checking) return; + checking = true; + const { gameSessionId, token } = socket.handshake.auth; + void sessions.get({ gameSessionId, token }).catch(() => socket.disconnect(true)).finally(() => { checking = false; }); + }, PRESENCE_AUTH_REFRESH_MS); + timer.unref(); + socket.on('disconnect', () => { clearInterval(timer); publish(); }); + }); +} diff --git a/tests/main-recovery.test.ts b/tests/main-recovery.test.ts index 666ceef..9bb305c 100644 --- a/tests/main-recovery.test.ts +++ b/tests/main-recovery.test.ts @@ -42,6 +42,7 @@ async function setup(kind: 'expired' | 'orphan' | 'rematch-wait' | 'result') { createGlobalSettingsCache: () => createGlobalSettingsCache(storage, send), createAppFooter: () => ({ setConnection() {}, openRules() {} }), installSoundControls() {}, + createPresence: () => ({ connect() {} }), createClientAnalytics: () => ({ ready() {}, screen() {}, gameFinished() {}, gameStarted() {}, error() {}, click() {} }), createResultReporter: (api: any) => ({ destroy() {}, resumePending: async () => {}, submit: api.record }), createMatchView: () => ({ update() {}, destroy() {} }), createPeer: () => ({}), diff --git a/tests/presence.test.ts b/tests/presence.test.ts new file mode 100644 index 0000000..5702538 --- /dev/null +++ b/tests/presence.test.ts @@ -0,0 +1,35 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { Server } from 'socket.io'; +import { io as client, type Socket } from 'socket.io-client'; +import { registerPresence } from '../src/server/presence.js'; +import type { GameSessionStore } from '../src/server/game-session-store.js'; + +test('presence counts authenticated players once across tabs and removes disconnected players', async () => { + const http = createServer(), io = new Server(http); + registerPresence(io, { async get(auth: { token: string; gameSessionId: string }) { + if (auth.token !== 'valid') throw new Error('INVALID_SESSION'); + return { playerId: auth.gameSessionId.startsWith('a') ? 'a' : 'b' }; + } } as unknown as GameSessionStore); + await new Promise(resolve => http.listen(0, '127.0.0.1', resolve)); + const url = `http://127.0.0.1:${(http.address() as { port: number }).port}/presence`; + const sockets: Socket[] = []; + const open = (id: string, token = 'valid') => { + const socket = client(url, { autoConnect: false, forceNew: true, auth: { gameSessionId: id, token }, reconnection: false }); + sockets.push(socket); return socket; + }; + const next = (socket: Socket) => new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('PRESENCE_TIMEOUT')), 3000); + socket.once('presence:count', n => { clearTimeout(timer); resolve(n); }); + }); + try { + const a = open('a1'), aFirst = next(a); a.connect(); assert.equal(await aFirst, 1); + const secondTab = open('a2'), tabFirst = next(secondTab); secondTab.connect(); assert.equal(await tabFirst, 1); + const b = open('b1'), bFirst = next(b), aUpdate = next(a); b.connect(); + assert.equal(await bFirst, 2); assert.equal(await aUpdate, 2); + const bad = open('bad', 'invalid'); + const rejected = new Promise(resolve => bad.once('connect_error', () => resolve())); bad.connect(); await rejected; + const lastA = next(secondTab); a.disconnect(); b.disconnect(); assert.equal(await lastA, 1); + } finally { for (const socket of sockets) socket.disconnect(); await new Promise(resolve => io.close(() => resolve())); } +});