Show authenticated unique online player count on home screen
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
export const PRESENCE_AUTH_REFRESH_MS = 60000;
|
||||||
@@ -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-actions > .primary { min-height: 68px; font-size: 28px; }
|
||||||
body.player-app .home .stat-mode > div { gap: 16px; }
|
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; }
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
<div class="app">
|
<div class="app">
|
||||||
<main id="main">
|
<main id="main">
|
||||||
<section id="home" class="home">
|
<section id="home" class="home">
|
||||||
|
<span id="online-count" class="online-count" role="status">접속 확인 중</span>
|
||||||
<div class="home-stage"><h1 aria-label="쎄쎄쎄:기공대전"><span>쎄쎄쎄</span><span>:기공대전</span></h1><div id="home-fighters" aria-hidden="true"></div></div>
|
<div class="home-stage"><h1 aria-label="쎄쎄쎄:기공대전"><span>쎄쎄쎄</span><span>:기공대전</span></h1><div id="home-fighters" aria-hidden="true"></div></div>
|
||||||
<div class="home-menu">
|
<div class="home-menu">
|
||||||
<div class="home-actions"><button id="bot-start" class="primary" disabled><span>봇과 대전</span><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M4 12h15m-6-6 6 6-6 6" stroke="currentColor" stroke-width="3" stroke-linecap="square" stroke-linejoin="miter"/></svg></button><div class="split"><button id="room-create" disabled><span>방 만들기</span><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M12 4v16M4 12h16" stroke="currentColor" stroke-width="2"/></svg></button><button id="join-open" disabled><span>방 참가</span><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M13 4h7v16h-7M3 12h12m-5-5 5 5-5 5" stroke="currentColor" stroke-width="2"/></svg></button></div></div>
|
<div class="home-actions"><button id="bot-start" class="primary" disabled><span>봇과 대전</span><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M4 12h15m-6-6 6 6-6 6" stroke="currentColor" stroke-width="3" stroke-linecap="square" stroke-linejoin="miter"/></svg></button><div class="split"><button id="room-create" disabled><span>방 만들기</span><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M12 4v16M4 12h16" stroke="currentColor" stroke-width="2"/></svg></button><button id="join-open" disabled><span>방 참가</span><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M13 4h7v16h-7M3 12h12m-5-5 5 5-5 5" stroke="currentColor" stroke-width="2"/></svg></button></div></div>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { createLobbyFeatures } from './lobby-features.js';
|
|||||||
import { createGlobalSettingsCache } from './global-settings.js';
|
import { createGlobalSettingsCache } from './global-settings.js';
|
||||||
import { createAppFooter } from './app-footer.js';
|
import { createAppFooter } from './app-footer.js';
|
||||||
import { installSoundControls } from './sound.js';
|
import { installSoundControls } from './sound.js';
|
||||||
|
import { createPresence } from './presence.js';
|
||||||
import './game.css';
|
import './game.css';
|
||||||
import './match.css';
|
import './match.css';
|
||||||
import './home.css';
|
import './home.css';
|
||||||
@@ -30,6 +31,7 @@ const footer = createAppFooter(el('app-footer'), {
|
|||||||
rules: el<HTMLDialogElement>('rules-dialog'), privacy: el<HTMLDialogElement>('privacy-dialog'), contact: el<HTMLDialogElement>('contact-dialog'),
|
rules: el<HTMLDialogElement>('rules-dialog'), privacy: el<HTMLDialogElement>('privacy-dialog'), contact: el<HTMLDialogElement>('contact-dialog'),
|
||||||
});
|
});
|
||||||
installSoundControls();
|
installSoundControls();
|
||||||
|
const presence = createPresence(el('online-count'));
|
||||||
const globalSettings = createGlobalSettingsCache({
|
const globalSettings = createGlobalSettingsCache({
|
||||||
getItem: key => localStorage.getItem(key), setItem: (key, value) => localStorage.setItem(key, value),
|
getItem: key => localStorage.getItem(key), setItem: (key, value) => localStorage.setItem(key, value),
|
||||||
});
|
});
|
||||||
@@ -244,6 +246,7 @@ async function boot() {
|
|||||||
let token: string | undefined;
|
let token: string | undefined;
|
||||||
try { token = localStorage.getItem(GUEST_TOKEN_KEY) ?? undefined; } catch { storageAvailable = false; }
|
try { token = localStorage.getItem(GUEST_TOKEN_KEY) ?? undefined; } catch { storageAvailable = false; }
|
||||||
const [session] = await Promise.all([api.ensureSession(token), globalSettings.refresh()]);
|
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; }
|
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);
|
reporter?.destroy(); reporter = createResultReporter({ gameSessionId: session.gameSessionId, record: value => api.record(value) }, storage);
|
||||||
analytics.ready(); stats(session.stats); connection('접속됨', true); el('reconnect').hidden = true;
|
analytics.ready(); stats(session.stats); connection('접속됨', true); el('reconnect').hidden = true;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import { createGameSessionStore } from './game-session-store.js';
|
|||||||
import { createRoomStore } from './rooms.js';
|
import { createRoomStore } from './rooms.js';
|
||||||
import { registerAnalyticsApi, registerGameApi, registerRoomApi } from './game-api.js';
|
import { registerAnalyticsApi, registerGameApi, registerRoomApi } from './game-api.js';
|
||||||
import { registerSignaling, type RtcServerOptions } from './signaling.js';
|
import { registerSignaling, type RtcServerOptions } from './signaling.js';
|
||||||
|
import { registerPresence } from './presence.js';
|
||||||
import { createRankedService } from './ranked-service.js';
|
import { createRankedService } from './ranked-service.js';
|
||||||
import { registerRankedApi } from './ranked-api.js';
|
import { registerRankedApi } from './ranked-api.js';
|
||||||
import { createGlobalSettingsStore } from './global-settings.js';
|
import { createGlobalSettingsStore } from './global-settings.js';
|
||||||
@@ -32,6 +33,7 @@ export async function createGameServer(options: ServerOptions): Promise<FastifyI
|
|||||||
? { revision: current.revision } : { revision: current.revision, settings: current };
|
? { revision: current.revision } : { revision: current.revision, settings: current };
|
||||||
});
|
});
|
||||||
const gameSessions = await createGameSessionStore(store.db, () => clock.now(), options.environment ?? 'production');
|
const gameSessions = await createGameSessionStore(store.db, () => clock.now(), options.environment ?? 'production');
|
||||||
|
registerPresence(io, gameSessions);
|
||||||
const peerRooms = await createRoomStore(store.db, () => clock.now());
|
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 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());
|
const signaling = registerSignaling(io, gameSessions, peerRooms, options.rtc, () => clock.now());
|
||||||
|
|||||||
@@ -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(); });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -42,6 +42,7 @@ async function setup(kind: 'expired' | 'orphan' | 'rematch-wait' | 'result') {
|
|||||||
createGlobalSettingsCache: () => createGlobalSettingsCache(storage, send),
|
createGlobalSettingsCache: () => createGlobalSettingsCache(storage, send),
|
||||||
createAppFooter: () => ({ setConnection() {}, openRules() {} }),
|
createAppFooter: () => ({ setConnection() {}, openRules() {} }),
|
||||||
installSoundControls() {},
|
installSoundControls() {},
|
||||||
|
createPresence: () => ({ connect() {} }),
|
||||||
createClientAnalytics: () => ({ ready() {}, screen() {}, gameFinished() {}, gameStarted() {}, error() {}, click() {} }),
|
createClientAnalytics: () => ({ ready() {}, screen() {}, gameFinished() {}, gameStarted() {}, error() {}, click() {} }),
|
||||||
createResultReporter: (api: any) => ({ destroy() {}, resumePending: async () => {}, submit: api.record }),
|
createResultReporter: (api: any) => ({ destroy() {}, resumePending: async () => {}, submit: api.record }),
|
||||||
createMatchView: () => ({ update() {}, destroy() {} }), createPeer: () => ({}),
|
createMatchView: () => ({ update() {}, destroy() {} }), createPeer: () => ({}),
|
||||||
|
|||||||
@@ -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<void>(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<number>((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<void>(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<void>(resolve => io.close(() => resolve())); }
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user