feat: implement real-time event handling and SSE support; add event publishing and subscription mechanisms
This commit is contained in:
@@ -2,6 +2,7 @@ export interface GameApiConfig {
|
|||||||
host: string;
|
host: string;
|
||||||
port: number;
|
port: number;
|
||||||
trpcPath: string;
|
trpcPath: string;
|
||||||
|
eventsPath: string;
|
||||||
profile: string;
|
profile: string;
|
||||||
scenario: string;
|
scenario: string;
|
||||||
profileName: string;
|
profileName: string;
|
||||||
@@ -37,6 +38,7 @@ export const resolveGameApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.env
|
|||||||
host: env.GAME_API_HOST ?? '0.0.0.0',
|
host: env.GAME_API_HOST ?? '0.0.0.0',
|
||||||
port: parseNumber(env.GAME_API_PORT, 14000, 'GAME_API_PORT'),
|
port: parseNumber(env.GAME_API_PORT, 14000, 'GAME_API_PORT'),
|
||||||
trpcPath: env.TRPC_PATH ?? '/trpc',
|
trpcPath: env.TRPC_PATH ?? '/trpc',
|
||||||
|
eventsPath: env.GAME_API_EVENTS_PATH ?? '/events',
|
||||||
profile,
|
profile,
|
||||||
scenario,
|
scenario,
|
||||||
profileName,
|
profileName,
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import type { RedisConnector } from '@sammo-ts/infra';
|
||||||
|
import type { RealtimeEvent } from '@sammo-ts/common';
|
||||||
|
|
||||||
|
export type RealtimeListener = (event: RealtimeEvent) => void;
|
||||||
|
|
||||||
|
export const parseRealtimeEvent = (message: string): RealtimeEvent | null => {
|
||||||
|
if (!message) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(message) as RealtimeEvent;
|
||||||
|
if (!parsed || typeof parsed !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof parsed.type !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Redis pub/sub 이벤트를 SSE 구독자에게 전달하는 중계 허브.
|
||||||
|
export class RedisRealtimeEventHub {
|
||||||
|
private readonly listeners = new Set<RealtimeListener>();
|
||||||
|
private subscribed = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly redis: RedisConnector['client'],
|
||||||
|
private readonly channel: string
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
if (this.subscribed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.redis.subscribe(this.channel, (message) => {
|
||||||
|
const event = parseRealtimeEvent(message);
|
||||||
|
if (!event) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const listener of this.listeners) {
|
||||||
|
listener(event);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.subscribed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(listener: RealtimeListener): () => void {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
this.listeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
if (this.subscribed) {
|
||||||
|
await this.redis.unsubscribe(this.channel);
|
||||||
|
this.subscribed = false;
|
||||||
|
}
|
||||||
|
await this.redis.quit();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { RedisConnector } from '@sammo-ts/infra';
|
||||||
|
import { buildGameEventChannel, type RealtimeEvent } from '@sammo-ts/common';
|
||||||
|
|
||||||
|
// 게임 서버의 실시간 이벤트를 Redis pub/sub 채널로 송신한다.
|
||||||
|
export const publishRealtimeEvent = async (
|
||||||
|
redis: RedisConnector['client'],
|
||||||
|
profileName: string,
|
||||||
|
event: RealtimeEvent
|
||||||
|
): Promise<void> => {
|
||||||
|
const channel = buildGameEventChannel(profileName);
|
||||||
|
await redis.publish(channel, JSON.stringify(event));
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
export interface SseFrame {
|
||||||
|
event?: string;
|
||||||
|
data?: string;
|
||||||
|
id?: string;
|
||||||
|
retry?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const splitLines = (value: string): string[] => value.split(/\r?\n/);
|
||||||
|
|
||||||
|
export const formatSseFrame = (frame: SseFrame): string => {
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
if (frame.event) {
|
||||||
|
lines.push(`event: ${frame.event}`);
|
||||||
|
}
|
||||||
|
if (frame.id) {
|
||||||
|
lines.push(`id: ${frame.id}`);
|
||||||
|
}
|
||||||
|
if (frame.retry !== undefined) {
|
||||||
|
lines.push(`retry: ${frame.retry}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataLines = splitLines(frame.data ?? '');
|
||||||
|
for (const line of dataLines) {
|
||||||
|
lines.push(`data: ${line}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('');
|
||||||
|
return lines.join('\n');
|
||||||
|
};
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
insertMessage,
|
insertMessage,
|
||||||
type MessageView,
|
type MessageView,
|
||||||
} from '../../messages/store.js';
|
} from '../../messages/store.js';
|
||||||
|
import { publishRealtimeEvent } from '../../realtime/publisher.js';
|
||||||
|
|
||||||
const zMessageType = z.enum(['private', 'public', 'national', 'diplomacy']);
|
const zMessageType = z.enum(['private', 'public', 'national', 'diplomacy']);
|
||||||
|
|
||||||
@@ -255,6 +256,19 @@ export const messagesRouter = router({
|
|||||||
draft
|
draft
|
||||||
);
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await publishRealtimeEvent(ctx.redis, ctx.profile.name, {
|
||||||
|
type: 'messageCreated',
|
||||||
|
at: now.toISOString(),
|
||||||
|
mailbox: input.mailbox,
|
||||||
|
msgType,
|
||||||
|
messageId: result.receiverId,
|
||||||
|
senderId: general.id,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// 실시간 알림 실패는 메시지 전송 실패로 취급하지 않는다.
|
||||||
|
}
|
||||||
|
|
||||||
return { msgType, msgId: result.receiverId };
|
return { msgType, msgId: result.receiverId };
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import fastify, { type FastifyRequest } from 'fastify';
|
import fastify, { type FastifyRequest } from 'fastify';
|
||||||
import cors from '@fastify/cors';
|
import cors from '@fastify/cors';
|
||||||
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
|
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
|
||||||
|
import { buildGameEventChannel, type GameSessionTokenPayload } from '@sammo-ts/common';
|
||||||
import {
|
import {
|
||||||
createGamePostgresConnector,
|
createGamePostgresConnector,
|
||||||
createRedisConnector,
|
createRedisConnector,
|
||||||
@@ -12,11 +13,13 @@ import { resolveGameApiConfigFromEnv } from './config.js';
|
|||||||
import { createGameApiContext, type DatabaseClient as _DatabaseClient } from './context.js';
|
import { createGameApiContext, type DatabaseClient as _DatabaseClient } from './context.js';
|
||||||
import { buildTurnDaemonStreamKeys } from './daemon/streamKeys.js';
|
import { buildTurnDaemonStreamKeys } from './daemon/streamKeys.js';
|
||||||
import { RedisTurnDaemonTransport } from './daemon/redisTransport.js';
|
import { RedisTurnDaemonTransport } from './daemon/redisTransport.js';
|
||||||
import { InMemoryFlushStore, RedisGatewayFlushSubscriber } from './auth/flushStore.js';
|
import { InMemoryFlushStore, RedisGatewayFlushSubscriber, type FlushStore } from './auth/flushStore.js';
|
||||||
import { RedisAccessTokenStore } from './auth/accessTokenStore.js';
|
import { RedisAccessTokenStore } from './auth/accessTokenStore.js';
|
||||||
import { appRouter } from './router.js';
|
import { appRouter } from './router.js';
|
||||||
import { buildBattleSimQueueKeys } from './battleSim/keys.js';
|
import { buildBattleSimQueueKeys } from './battleSim/keys.js';
|
||||||
import { RedisBattleSimTransport } from './battleSim/redisTransport.js';
|
import { RedisBattleSimTransport } from './battleSim/redisTransport.js';
|
||||||
|
import { RedisRealtimeEventHub } from './realtime/eventHub.js';
|
||||||
|
import { formatSseFrame } from './realtime/sse.js';
|
||||||
|
|
||||||
const extractBearerToken = (value: string | string[] | undefined): string | null => {
|
const extractBearerToken = (value: string | string[] | undefined): string | null => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
@@ -33,6 +36,25 @@ const extractBearerToken = (value: string | string[] | undefined): string | null
|
|||||||
return header.trim();
|
return header.trim();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resolveAuthFromToken = async (
|
||||||
|
token: string | null,
|
||||||
|
accessTokenStore: RedisAccessTokenStore,
|
||||||
|
flushStore: FlushStore
|
||||||
|
): Promise<GameSessionTokenPayload | null> => {
|
||||||
|
if (!token) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const stored = await accessTokenStore.get(token);
|
||||||
|
if (!stored) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const flushedAt = flushStore.getFlushedAt(stored.user.id);
|
||||||
|
if (flushedAt && new Date(stored.issuedAt) <= flushedAt) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return stored;
|
||||||
|
};
|
||||||
|
|
||||||
export const createGameApiServer = async () => {
|
export const createGameApiServer = async () => {
|
||||||
const config = resolveGameApiConfigFromEnv();
|
const config = resolveGameApiConfigFromEnv();
|
||||||
const postgres = createGamePostgresConnector(resolvePostgresConfigFromEnv({ schema: config.profile }));
|
const postgres = createGamePostgresConnector(resolvePostgresConfigFromEnv({ schema: config.profile }));
|
||||||
@@ -56,6 +78,13 @@ export const createGameApiServer = async () => {
|
|||||||
const flushSubscriber = new RedisGatewayFlushSubscriber(flushSubscriberClient, config.flushChannel, flushStore);
|
const flushSubscriber = new RedisGatewayFlushSubscriber(flushSubscriberClient, config.flushChannel, flushStore);
|
||||||
await flushSubscriber.start();
|
await flushSubscriber.start();
|
||||||
const accessTokenStore = new RedisAccessTokenStore(redis.client, config.profileName);
|
const accessTokenStore = new RedisAccessTokenStore(redis.client, config.profileName);
|
||||||
|
const realtimeSubscriberClient = redis.client.duplicate();
|
||||||
|
await realtimeSubscriberClient.connect();
|
||||||
|
const realtimeHub = new RedisRealtimeEventHub(
|
||||||
|
realtimeSubscriberClient,
|
||||||
|
buildGameEventChannel(config.profileName)
|
||||||
|
);
|
||||||
|
await realtimeHub.start();
|
||||||
|
|
||||||
const app = fastify({
|
const app = fastify({
|
||||||
logger: true,
|
logger: true,
|
||||||
@@ -72,14 +101,7 @@ export const createGameApiServer = async () => {
|
|||||||
router: appRouter,
|
router: appRouter,
|
||||||
createContext: async ({ req }: { req: FastifyRequest }) => {
|
createContext: async ({ req }: { req: FastifyRequest }) => {
|
||||||
const token = extractBearerToken(req.headers.authorization);
|
const token = extractBearerToken(req.headers.authorization);
|
||||||
let auth = null;
|
const auth = await resolveAuthFromToken(token, accessTokenStore, flushStore);
|
||||||
if (token) {
|
|
||||||
const stored = await accessTokenStore.get(token);
|
|
||||||
if (stored) {
|
|
||||||
const flushedAt = flushStore.getFlushedAt(stored.user.id);
|
|
||||||
auth = flushedAt && new Date(stored.issuedAt) <= flushedAt ? null : stored;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return createGameApiContext({
|
return createGameApiContext({
|
||||||
db: postgres.prisma,
|
db: postgres.prisma,
|
||||||
redis: redis.client,
|
redis: redis.client,
|
||||||
@@ -99,6 +121,69 @@ export const createGameApiServer = async () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get(config.eventsPath, async (request, reply) => {
|
||||||
|
const query = request.query as { token?: string };
|
||||||
|
const tokenFromHeader = extractBearerToken(request.headers.authorization);
|
||||||
|
const tokenFromQuery = typeof query.token === 'string' ? query.token : null;
|
||||||
|
const auth = await resolveAuthFromToken(tokenFromHeader ?? tokenFromQuery, accessTokenStore, flushStore);
|
||||||
|
|
||||||
|
if (!auth) {
|
||||||
|
await reply.status(401).send({ ok: false, error: 'unauthorized' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.hijack();
|
||||||
|
reply.raw.setHeader('Content-Type', 'text/event-stream');
|
||||||
|
reply.raw.setHeader('Cache-Control', 'no-cache');
|
||||||
|
reply.raw.setHeader('Connection', 'keep-alive');
|
||||||
|
reply.raw.setHeader('X-Accel-Buffering', 'no');
|
||||||
|
request.raw.setTimeout(0);
|
||||||
|
reply.raw.setTimeout?.(0);
|
||||||
|
reply.raw.flushHeaders?.();
|
||||||
|
|
||||||
|
const sendFrame = (payload: string) => {
|
||||||
|
try {
|
||||||
|
reply.raw.write(payload);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sendFrame(
|
||||||
|
formatSseFrame({
|
||||||
|
event: 'ready',
|
||||||
|
data: JSON.stringify({ at: new Date().toISOString() }),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const unsubscribe = realtimeHub.subscribe((event) => {
|
||||||
|
sendFrame(
|
||||||
|
formatSseFrame({
|
||||||
|
event: event.type,
|
||||||
|
data: JSON.stringify(event),
|
||||||
|
id: event.at,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const heartbeat = setInterval(() => {
|
||||||
|
sendFrame(
|
||||||
|
formatSseFrame({
|
||||||
|
event: 'ping',
|
||||||
|
data: JSON.stringify({ at: new Date().toISOString() }),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}, 15000);
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
clearInterval(heartbeat);
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
|
||||||
|
request.raw.on('close', close);
|
||||||
|
request.raw.on('aborted', close);
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/healthz', async () => ({
|
app.get('/healthz', async () => ({
|
||||||
ok: true,
|
ok: true,
|
||||||
profile: config.profileName,
|
profile: config.profileName,
|
||||||
@@ -107,6 +192,7 @@ export const createGameApiServer = async () => {
|
|||||||
app.addHook('onClose', async () => {
|
app.addHook('onClose', async () => {
|
||||||
await flushSubscriber.stop();
|
await flushSubscriber.stop();
|
||||||
await flushSubscriberClient.quit();
|
await flushSubscriberClient.quit();
|
||||||
|
await realtimeHub.stop();
|
||||||
await redis.disconnect();
|
await redis.disconnect();
|
||||||
await postgres.disconnect();
|
await postgres.disconnect();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { buildGameEventChannel, type RealtimeEvent } from '@sammo-ts/common';
|
||||||
|
import { parseRealtimeEvent } from '../src/realtime/eventHub.js';
|
||||||
|
import { formatSseFrame } from '../src/realtime/sse.js';
|
||||||
|
|
||||||
|
describe('formatSseFrame', () => {
|
||||||
|
it('renders basic SSE payloads', () => {
|
||||||
|
const output = formatSseFrame({
|
||||||
|
event: 'ping',
|
||||||
|
id: '1',
|
||||||
|
retry: 1500,
|
||||||
|
data: 'ok',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(output).toBe(['event: ping', 'id: 1', 'retry: 1500', 'data: ok', ''].join('\n'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('splits multiline data', () => {
|
||||||
|
const output = formatSseFrame({
|
||||||
|
event: 'notice',
|
||||||
|
data: 'first\nsecond',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(output).toBe(['event: notice', 'data: first', 'data: second', ''].join('\n'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseRealtimeEvent', () => {
|
||||||
|
it('accepts valid realtime events', () => {
|
||||||
|
const payload: RealtimeEvent = {
|
||||||
|
type: 'turnCompleted',
|
||||||
|
at: '2026-01-01T00:00:00.000Z',
|
||||||
|
result: {
|
||||||
|
lastTurnTime: '2026-01-01T00:00:00.000Z',
|
||||||
|
processedGenerals: 2,
|
||||||
|
processedTurns: 1,
|
||||||
|
durationMs: 1200,
|
||||||
|
partial: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const parsed = parseRealtimeEvent(JSON.stringify(payload));
|
||||||
|
|
||||||
|
expect(parsed?.type).toBe('turnCompleted');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid payloads', () => {
|
||||||
|
expect(parseRealtimeEvent('not-json')).toBeNull();
|
||||||
|
expect(parseRealtimeEvent(JSON.stringify({}))).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildGameEventChannel', () => {
|
||||||
|
it('namespaces channels by profile', () => {
|
||||||
|
expect(buildGameEventChannel('che:default')).toBe('sammo:che:default:realtime:events');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { TurnCommandProfile, TurnSchedule } from '@sammo-ts/logic';
|
import type { TurnCommandProfile, TurnSchedule } from '@sammo-ts/logic';
|
||||||
|
import { buildGameEventChannel, type RealtimeEvent } from '@sammo-ts/common';
|
||||||
import { createRedisConnector, resolveRedisConfigFromEnv } from '@sammo-ts/infra';
|
import { createRedisConnector, resolveRedisConfigFromEnv } from '@sammo-ts/infra';
|
||||||
|
|
||||||
import { SystemClock } from '../lifecycle/clock.js';
|
import { SystemClock } from '../lifecycle/clock.js';
|
||||||
@@ -148,6 +149,7 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
|
|||||||
const clock = options.clock ?? new SystemClock();
|
const clock = options.clock ?? new SystemClock();
|
||||||
|
|
||||||
let hooks: TurnDaemonHooks | undefined;
|
let hooks: TurnDaemonHooks | undefined;
|
||||||
|
let publishRealtimeEvent: ((event: RealtimeEvent) => Promise<void>) | null = null;
|
||||||
let close = async () => {};
|
let close = async () => {};
|
||||||
let redisCommandStream: RedisTurnDaemonCommandStream | null = null;
|
let redisCommandStream: RedisTurnDaemonCommandStream | null = null;
|
||||||
let redisConnector: ReturnType<typeof createRedisConnector> | null = null;
|
let redisConnector: ReturnType<typeof createRedisConnector> | null = null;
|
||||||
@@ -214,10 +216,35 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
|
|||||||
if (redisConfig) {
|
if (redisConfig) {
|
||||||
redisConnector = createRedisConnector(redisConfig);
|
redisConnector = createRedisConnector(redisConfig);
|
||||||
await redisConnector.connect();
|
await redisConnector.connect();
|
||||||
redisCommandStream = new RedisTurnDaemonCommandStream(redisConnector.client, {
|
const redisClient = redisConnector.client;
|
||||||
|
redisCommandStream = new RedisTurnDaemonCommandStream(redisClient, {
|
||||||
keys: buildTurnDaemonStreamKeys(options.profileName ?? options.profile),
|
keys: buildTurnDaemonStreamKeys(options.profileName ?? options.profile),
|
||||||
startId: options.commandStreamStartId,
|
startId: options.commandStreamStartId,
|
||||||
});
|
});
|
||||||
|
const realtimeChannel = buildGameEventChannel(options.profileName ?? options.profile);
|
||||||
|
publishRealtimeEvent = async (event: RealtimeEvent) => {
|
||||||
|
await redisClient.publish(realtimeChannel, JSON.stringify(event));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (publishRealtimeEvent) {
|
||||||
|
const basePublishEvents = hooks?.publishEvents;
|
||||||
|
// 턴 처리 완료 이벤트를 실시간 채널로 전파한다.
|
||||||
|
hooks = {
|
||||||
|
...hooks,
|
||||||
|
publishEvents: async (result) => {
|
||||||
|
try {
|
||||||
|
await publishRealtimeEvent({
|
||||||
|
type: 'turnCompleted',
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
result,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// 실시간 이벤트 전송 실패는 턴 처리 결과에 영향을 주지 않는다.
|
||||||
|
}
|
||||||
|
await basePublishEvents?.(result);
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseClose = close;
|
const baseClose = close;
|
||||||
|
|||||||
Vendored
+1
@@ -9,6 +9,7 @@ declare module '*.vue' {
|
|||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly VITE_GATEWAY_API_URL?: string;
|
readonly VITE_GATEWAY_API_URL?: string;
|
||||||
readonly VITE_GAME_API_URL?: string;
|
readonly VITE_GAME_API_URL?: string;
|
||||||
|
readonly VITE_GAME_SSE_URL?: string;
|
||||||
readonly VITE_GAME_ASSET_URL?: string;
|
readonly VITE_GAME_ASSET_URL?: string;
|
||||||
readonly VITE_GAME_PROFILE?: string;
|
readonly VITE_GAME_PROFILE?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { computed, ref } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { MESSAGE_MAILBOX_NATIONAL_BASE, MESSAGE_MAILBOX_PUBLIC, type MessageType } from '@sammo-ts/logic';
|
import { MESSAGE_MAILBOX_NATIONAL_BASE, MESSAGE_MAILBOX_PUBLIC, type MessageType } from '@sammo-ts/logic';
|
||||||
|
import type { RealtimeEvent } from '@sammo-ts/common';
|
||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
import { useMapViewerStore } from './mapViewer';
|
import { useMapViewerStore } from './mapViewer';
|
||||||
|
import { useSessionStore } from './session';
|
||||||
|
|
||||||
const resolveErrorMessage = (value: unknown): string => {
|
const resolveErrorMessage = (value: unknown): string => {
|
||||||
if (value instanceof Error) {
|
if (value instanceof Error) {
|
||||||
@@ -25,7 +27,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
const realtimeEnabled = ref(true);
|
const realtimeEnabled = ref(true);
|
||||||
const realtimeStatus = ref<'idle' | 'connected' | 'paused'>('connected');
|
const realtimeStatus = ref<'idle' | 'connected' | 'paused'>('idle');
|
||||||
|
|
||||||
const generalContext = ref<GeneralContext | null>(null);
|
const generalContext = ref<GeneralContext | null>(null);
|
||||||
const lobbyInfo = ref<LobbyInfo | null>(null);
|
const lobbyInfo = ref<LobbyInfo | null>(null);
|
||||||
@@ -43,6 +45,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
const generalId = computed(() => general.value?.id ?? null);
|
const generalId = computed(() => general.value?.id ?? null);
|
||||||
const nationId = computed(() => nation.value?.id ?? null);
|
const nationId = computed(() => nation.value?.id ?? null);
|
||||||
const mapViewer = useMapViewerStore();
|
const mapViewer = useMapViewerStore();
|
||||||
|
const session = useSessionStore();
|
||||||
|
|
||||||
const selectedCity = computed(() => {
|
const selectedCity = computed(() => {
|
||||||
const layout = mapLayout.value;
|
const layout = mapLayout.value;
|
||||||
@@ -108,7 +111,9 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
|
|
||||||
const setRealtimeEnabled = (enabled: boolean) => {
|
const setRealtimeEnabled = (enabled: boolean) => {
|
||||||
realtimeEnabled.value = enabled;
|
realtimeEnabled.value = enabled;
|
||||||
realtimeStatus.value = enabled ? 'connected' : 'paused';
|
if (!enabled) {
|
||||||
|
realtimeStatus.value = 'paused';
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadMainData = async () => {
|
const loadMainData = async () => {
|
||||||
@@ -216,6 +221,139 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let realtimeSource: EventSource | null = null;
|
||||||
|
let realtimeToken: string | null = null;
|
||||||
|
|
||||||
|
const isAccessToken = (token: string | null): boolean => Boolean(token?.startsWith('ga_'));
|
||||||
|
|
||||||
|
const buildRealtimeUrl = (token: string): string => {
|
||||||
|
const base = import.meta.env.VITE_GAME_SSE_URL ?? '/events';
|
||||||
|
const url = new URL(base, window.location.origin);
|
||||||
|
url.searchParams.set('token', token);
|
||||||
|
return url.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseRealtimePayload = (raw: MessageEvent): RealtimeEvent | null => {
|
||||||
|
if (!raw.data || typeof raw.data !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw.data) as RealtimeEvent;
|
||||||
|
if (!parsed || typeof parsed !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof parsed.type !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isMailboxRelevant = (mailbox: number): boolean => {
|
||||||
|
if (mailbox === MESSAGE_MAILBOX_PUBLIC) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const currentGeneralId = generalId.value;
|
||||||
|
if (currentGeneralId && mailbox === currentGeneralId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const currentNationId = nationId.value;
|
||||||
|
if (currentNationId && mailbox === MESSAGE_MAILBOX_NATIONAL_BASE + currentNationId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeRealtimeSource = () => {
|
||||||
|
if (!realtimeSource) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
realtimeSource.close();
|
||||||
|
realtimeSource = null;
|
||||||
|
realtimeToken = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureAccessToken = async (): Promise<string | null> => {
|
||||||
|
if (!session.gameToken) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (isAccessToken(session.gameToken)) {
|
||||||
|
return session.gameToken;
|
||||||
|
}
|
||||||
|
const exchanged = await session.exchangeGatewayToken();
|
||||||
|
if (!exchanged) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return session.gameToken && isAccessToken(session.gameToken) ? session.gameToken : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const connectRealtime = async () => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!realtimeEnabled.value || !session.isReady || !session.hasGeneral) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const token = await ensureAccessToken();
|
||||||
|
if (!token) {
|
||||||
|
realtimeStatus.value = 'idle';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (realtimeSource && realtimeToken === token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closeRealtimeSource();
|
||||||
|
realtimeToken = token;
|
||||||
|
realtimeStatus.value = 'idle';
|
||||||
|
|
||||||
|
const source = new EventSource(buildRealtimeUrl(token));
|
||||||
|
realtimeSource = source;
|
||||||
|
|
||||||
|
source.addEventListener('open', () => {
|
||||||
|
realtimeStatus.value = 'connected';
|
||||||
|
});
|
||||||
|
source.addEventListener('error', () => {
|
||||||
|
realtimeStatus.value = realtimeEnabled.value ? 'idle' : 'paused';
|
||||||
|
});
|
||||||
|
source.addEventListener('turnCompleted', () => {
|
||||||
|
void loadMainData();
|
||||||
|
});
|
||||||
|
source.addEventListener('messageCreated', (event) => {
|
||||||
|
const payload = parseRealtimePayload(event);
|
||||||
|
if (!payload || payload.type !== 'messageCreated') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isMailboxRelevant(payload.mailbox)) {
|
||||||
|
void refreshMessages();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
source.addEventListener('ping', () => {
|
||||||
|
if (realtimeEnabled.value) {
|
||||||
|
realtimeStatus.value = 'connected';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [realtimeEnabled.value, session.isReady, session.hasGeneral, session.gameToken],
|
||||||
|
([enabled, ready, hasGeneral]) => {
|
||||||
|
if (!enabled) {
|
||||||
|
closeRealtimeSource();
|
||||||
|
realtimeStatus.value = 'paused';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!ready || !hasGeneral) {
|
||||||
|
closeRealtimeSource();
|
||||||
|
realtimeStatus.value = 'idle';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void connectRealtime();
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
|
|||||||
@@ -11,3 +11,5 @@ export * from './util/RandUtil.js';
|
|||||||
export * from './util/TestRNG.js';
|
export * from './util/TestRNG.js';
|
||||||
export * from './util/sha512.js';
|
export * from './util/sha512.js';
|
||||||
export * from './turnDaemon/types.js';
|
export * from './turnDaemon/types.js';
|
||||||
|
export * from './realtime/keys.js';
|
||||||
|
export * from './realtime/types.js';
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
const normalizeProfileName = (profileName: string): string => {
|
||||||
|
const trimmed = profileName.trim();
|
||||||
|
return trimmed.length > 0 ? trimmed : 'unknown';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildGameEventChannel = (profileName: string): string => {
|
||||||
|
const normalized = normalizeProfileName(profileName);
|
||||||
|
return `sammo:${normalized}:realtime:events`;
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { TurnRunResult } from '../turnDaemon/types.js';
|
||||||
|
|
||||||
|
export type MessageTypeKey = 'public' | 'private' | 'national' | 'diplomacy';
|
||||||
|
|
||||||
|
export type RealtimeEvent =
|
||||||
|
| {
|
||||||
|
type: 'turnCompleted';
|
||||||
|
at: string;
|
||||||
|
result: TurnRunResult;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'messageCreated';
|
||||||
|
at: string;
|
||||||
|
mailbox: number;
|
||||||
|
msgType: MessageTypeKey;
|
||||||
|
messageId: number;
|
||||||
|
senderId: number;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user