diff --git a/app/game-api/src/maps/worldMap.ts b/app/game-api/src/maps/worldMap.ts index f23d750..39f4dd6 100644 --- a/app/game-api/src/maps/worldMap.ts +++ b/app/game-api/src/maps/worldMap.ts @@ -43,6 +43,7 @@ type GeneralCityRow = { const MAP_VERSION = 0 as const; const BASE_MAP_TTL_SECONDS = 30; +const PUBLIC_MAP_TTL_SECONDS = 600; const isRecord = (value: unknown): value is Record => value !== null && typeof value === 'object' && !Array.isArray(value); @@ -94,11 +95,21 @@ const resolveSpyList = (meta: Record): Record = return {}; }; -const buildBaseMapCacheKey = (ctx: GameApiContext): string => - `sammo:map:base:${ctx.profile.id}:${ctx.profile.scenario}`; +const buildBaseMapCacheKey = (ctx: GameApiContext, scope: 'base' | 'public' = 'base'): string => + `sammo:map:${scope}:${ctx.profile.id}:${ctx.profile.scenario}`; + +const loadBaseMap = async ( + ctx: GameApiContext, + options?: { + useCache?: boolean; + cacheKey?: string; + ttlSeconds?: number; + } +): Promise => { + const useCache = options?.useCache ?? true; + const cacheKey = options?.cacheKey ?? buildBaseMapCacheKey(ctx); + const ttlSeconds = options?.ttlSeconds ?? BASE_MAP_TTL_SECONDS; -const loadBaseMap = async (ctx: GameApiContext, useCache: boolean): Promise => { - const cacheKey = buildBaseMapCacheKey(ctx); if (useCache) { const cached = await ctx.redis.get(cacheKey); if (cached) { @@ -161,13 +172,21 @@ const loadBaseMap = async (ctx: GameApiContext, useCache: boolean): Promise => { + return loadBaseMap(ctx, { + useCache, + cacheKey: buildBaseMapCacheKey(ctx, 'public'), + ttlSeconds: PUBLIC_MAP_TTL_SECONDS, + }); +}; + export const loadWorldMap = async ( ctx: GameApiContext, options: { @@ -177,7 +196,7 @@ export const loadWorldMap = async ( useCache?: boolean; } ): Promise => { - const baseMap = await loadBaseMap(ctx, options.useCache ?? true); + const baseMap = await loadBaseMap(ctx, { useCache: options.useCache ?? true }); if (!baseMap) { return null; } diff --git a/app/game-api/src/router.ts b/app/game-api/src/router.ts index 3e2ccd9..b6cee6b 100644 --- a/app/game-api/src/router.ts +++ b/app/game-api/src/router.ts @@ -7,6 +7,7 @@ import { joinRouter } from './router/join/index.js'; import { lobbyRouter } from './router/lobby/index.js'; import { messagesRouter } from './router/messages/index.js'; import { nationRouter } from './router/nation/index.js'; +import { publicRouter } from './router/public/index.js'; import { troopRouter } from './router/troop/index.js'; import { turnDaemonRouter } from './router/turnDaemon/index.js'; import { turnsRouter } from './router/turns/index.js'; @@ -15,6 +16,7 @@ import { worldRouter } from './router/world/index.js'; export const appRouter = router({ health: healthRouter, lobby: lobbyRouter, + public: publicRouter, join: joinRouter, battle: battleRouter, world: worldRouter, diff --git a/app/game-api/src/router/public/index.ts b/app/game-api/src/router/public/index.ts new file mode 100644 index 0000000..044e157 --- /dev/null +++ b/app/game-api/src/router/public/index.ts @@ -0,0 +1,211 @@ +import { TRPCError } from '@trpc/server'; + +import type { GameApiContext } from '../../context.js'; +import { zWorldStateConfig, zWorldStateMeta } from '../../context.js'; +import { loadMapLayout } from '../../maps/mapLayout.js'; +import { loadPublicMap } from '../../maps/worldMap.js'; +import { procedure, router } from '../../trpc.js'; + +type WorldTrendSnapshot = { + year: number; + month: number; + userCnt: number; + maxUserCnt: number; + npcCnt: number; + nationCnt: number; + turnTerm: number; + fictionMode: string; + starttime: string; + opentime: string; + turntime: string; + otherTextInfo: string; + isUnited: number; +}; + +type NationSummary = { + id: number; + name: string; + color: string; + level: number; + capitalCityId: number; + generalCount: number; + cityCount: number; +}; + +type NationCountRow = { + nationId: number; + count: number; +}; + +const PUBLIC_CACHE_TTL_SECONDS = 600; + +const buildPublicCacheKey = (ctx: GameApiContext, key: string): string => + `sammo:public:${key}:${ctx.profile.id}:${ctx.profile.scenario}`; + +const loadWorldTrendSnapshot = async (ctx: GameApiContext): Promise => { + const rawWorldState = await ctx.db.worldState.findFirst(); + if (!rawWorldState) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'World state not found', + }); + } + + const config = zWorldStateConfig.parse(rawWorldState.config); + const meta = zWorldStateMeta.parse(rawWorldState.meta); + + const [userCnt, npcCnt, nationCnt] = await Promise.all([ + ctx.db.general.count({ where: { npcState: 0 } }), + ctx.db.general.count({ where: { npcState: { gt: 0 } } }), + ctx.db.nation.count({ where: { level: { gt: 0 } } }), + ]); + + return { + year: rawWorldState.currentYear, + month: rawWorldState.currentMonth, + userCnt, + maxUserCnt: config.maxUserCnt ?? 500, + npcCnt, + nationCnt, + turnTerm: rawWorldState.tickSeconds / 60, + fictionMode: config.fictionMode ?? '사실', + starttime: meta.starttime ?? '', + opentime: meta.opentime ?? '', + turntime: meta.turntime ?? '', + otherTextInfo: meta.otherTextInfo ?? '', + isUnited: meta.isUnited ?? 0, + }; +}; + +const loadCachedWorldTrend = async (ctx: GameApiContext): Promise => { + const cacheKey = buildPublicCacheKey(ctx, 'worldTrend'); + const cached = await ctx.redis.get(cacheKey); + if (cached) { + try { + return JSON.parse(cached) as WorldTrendSnapshot; + } catch { + // Ignore cache parse errors. + } + } + + const snapshot = await loadWorldTrendSnapshot(ctx); + await ctx.redis.set(cacheKey, JSON.stringify(snapshot), { EX: PUBLIC_CACHE_TTL_SECONDS }); + return snapshot; +}; + +const loadCachedNationList = async (ctx: GameApiContext): Promise => { + const cacheKey = buildPublicCacheKey(ctx, 'nationList'); + const cached = await ctx.redis.get(cacheKey); + if (cached) { + try { + return JSON.parse(cached) as NationSummary[]; + } catch { + // Ignore cache parse errors. + } + } + + const [nations, generalCounts, cityCounts] = await Promise.all([ + ctx.db.nation.findMany({ + select: { + id: true, + name: true, + color: true, + level: true, + capitalCityId: true, + }, + orderBy: [{ level: 'desc' }, { id: 'asc' }], + }), + ctx.db.$queryRaw` + SELECT nation_id as "nationId", COUNT(*)::int as "count" + FROM general + GROUP BY nation_id + `, + ctx.db.$queryRaw` + SELECT nation_id as "nationId", COUNT(*)::int as "count" + FROM city + GROUP BY nation_id + `, + ]); + + const generalCountMap = new Map(); + for (const row of generalCounts) { + generalCountMap.set(row.nationId, row.count); + } + + const cityCountMap = new Map(); + for (const row of cityCounts) { + cityCountMap.set(row.nationId, row.count); + } + + const summary = nations.map((nation) => ({ + id: nation.id, + name: nation.name, + color: nation.color, + level: nation.level, + capitalCityId: nation.capitalCityId ?? 0, + generalCount: generalCountMap.get(nation.id) ?? 0, + cityCount: cityCountMap.get(nation.id) ?? 0, + })); + + await ctx.redis.set(cacheKey, JSON.stringify(summary), { EX: PUBLIC_CACHE_TTL_SECONDS }); + return summary; +}; + +export const publicRouter = router({ + getMapLayout: procedure.query(async ({ ctx }) => { + return loadMapLayout(ctx.profile.scenario); + }), + getCachedMap: procedure.query(async ({ ctx }) => { + const map = await loadPublicMap(ctx, true); + if (!map) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: 'World state is not initialized.', + }); + } + return map; + }), + getWorldTrend: procedure.query(async ({ ctx }) => { + return loadCachedWorldTrend(ctx); + }), + getNationList: procedure.query(async ({ ctx }) => { + return loadCachedNationList(ctx); + }), + getGeneralList: procedure.query(async ({ ctx }) => { + const [generals, nations] = await Promise.all([ + ctx.db.general.findMany({ + select: { + id: true, + name: true, + npcState: true, + nationId: true, + leadership: true, + strength: true, + intel: true, + }, + }), + ctx.db.nation.findMany({ + select: { + id: true, + name: true, + }, + }), + ]); + + const nationMap = new Map(); + for (const nation of nations) { + nationMap.set(nation.id, nation.name); + } + + return generals.map((general) => ({ + id: general.id, + name: general.name, + npcState: general.npcState, + nationId: general.nationId, + nationName: nationMap.get(general.nationId) ?? '무주', + leadership: general.leadership, + strength: general.strength, + intelligence: general.intel, + })); + }), +}); diff --git a/app/game-frontend/src/views/PublicView.vue b/app/game-frontend/src/views/PublicView.vue index 26ec321..7a8f154 100644 --- a/app/game-frontend/src/views/PublicView.vue +++ b/app/game-frontend/src/views/PublicView.vue @@ -1,10 +1,415 @@ - + + + diff --git a/app/gateway-frontend/src/components/MapPreview.vue b/app/gateway-frontend/src/components/MapPreview.vue new file mode 100644 index 0000000..71a749f --- /dev/null +++ b/app/gateway-frontend/src/components/MapPreview.vue @@ -0,0 +1,143 @@ + + + + + diff --git a/app/gateway-frontend/src/views/LobbyView.vue b/app/gateway-frontend/src/views/LobbyView.vue index af4a380..4a66ba1 100644 --- a/app/gateway-frontend/src/views/LobbyView.vue +++ b/app/gateway-frontend/src/views/LobbyView.vue @@ -4,6 +4,7 @@ import { useRouter } from 'vue-router'; import type { inferRouterOutputs } from '@trpc/server'; import type { AppRouter } from '@sammo-ts/gateway-api'; import DefaultLayout from '../layouts/DefaultLayout.vue'; +import MapPreview from '../components/MapPreview.vue'; import { trpc } from '../utils/trpc'; import { createGameTrpc } from '../utils/gameTrpc'; import type { GameRouter } from '../utils/gameTrpc'; @@ -13,12 +14,19 @@ type GameRouterOutput = inferRouterOutputs; type MeOutput = GatewayRouterOutput['me']; type LobbyProfile = GatewayRouterOutput['lobby']['profiles'][number]; type LobbyInfo = GameRouterOutput['lobby']['info']; +type PublicMap = GameRouterOutput['public']['getCachedMap']; +type PublicMapLayout = GameRouterOutput['public']['getMapLayout']; +type MapPreviewBundle = { + mapData: PublicMap; + mapLayout: PublicMapLayout; +}; const router = useRouter(); const me = ref(null); const notice = ref(''); const profiles = ref([]); const profileDetails = ref>({}); +const profileMapPreviews = ref>({}); onMounted(async () => { try { @@ -31,18 +39,32 @@ onMounted(async () => { notice.value = await trpc.lobby.notice.query(); profiles.value = await trpc.lobby.profiles.query(); - // Fetch details for each profile - for (const profile of profiles.value) { - if (profile.status === 'RUNNING' || profile.status === 'PREOPEN') { - try { - const gameTrpc = createGameTrpc(profile.apiPort); - const info = await gameTrpc.lobby.info.query(); - profileDetails.value[profile.profileName] = info; - } catch (e) { - console.error(`Failed to fetch info for ${profile.profileName}`, e); - } + const detailTasks = profiles.value.map(async (profile) => { + if (profile.status !== 'RUNNING' && profile.status !== 'PREOPEN') { + return; } - } + const gameTrpc = createGameTrpc(profile.apiPort); + const [infoResult, layoutResult, mapResult] = await Promise.allSettled([ + gameTrpc.lobby.info.query(), + gameTrpc.public.getMapLayout.query(), + gameTrpc.public.getCachedMap.query(), + ]); + + if (infoResult.status === 'fulfilled') { + profileDetails.value[profile.profileName] = infoResult.value; + } else { + console.error(`Failed to fetch info for ${profile.profileName}`, infoResult.reason); + } + + if (layoutResult.status === 'fulfilled' && mapResult.status === 'fulfilled') { + profileMapPreviews.value[profile.profileName] = { + mapLayout: layoutResult.value, + mapData: mapResult.value, + }; + } + }); + + await Promise.all(detailTasks); } catch (e) { console.error('Failed to load lobby', e); } @@ -221,6 +243,46 @@ const handleLogout = async () => { +
+
+ 공개 지도 미리보기 +
+
+
+
+ + {{ profile.korName }}섭 + + {{ profile.status }} +
+
+
+ +
+ 유저 {{ profileDetails[profile.profileName]?.userCnt ?? '-' }} / + {{ profileDetails[profile.profileName]?.maxUserCnt ?? '-' }} · + {{ profileDetails[profile.profileName]?.nationCnt ?? '-' }}국 · + {{ profileDetails[profile.profileName]?.turnTerm ?? '-' }}분 턴 +
+
+
+ 지도를 불러오는 중... +
+
+
- 폐 쇄 중 -
+
+
+
+