diff --git a/app/game-api/src/router/public/index.ts b/app/game-api/src/router/public/index.ts index cfde31a..a85677b 100644 --- a/app/game-api/src/router/public/index.ts +++ b/app/game-api/src/router/public/index.ts @@ -1,5 +1,6 @@ import { TRPCError } from '@trpc/server'; import { asRecord } from '@sammo-ts/common'; +import { LogCategory, LogScope } from '@sammo-ts/infra'; import { z } from 'zod'; import type { GameApiContext } from '../../context.js'; @@ -241,14 +242,45 @@ export const publicRouter = router({ return loadMapLayout(ctx.profile.scenario); }), getCachedMap: procedure.query(async ({ ctx }) => { - const map = await loadPublicMap(ctx, true); + const cacheKey = buildPublicCacheKey(ctx, 'cachedMapWithHistory'); + const cached = await ctx.redis.get(cacheKey); + if (cached) { + try { + return JSON.parse(cached) as NonNullable>> & { + history: { id: number; text: string }[]; + }; + } catch { + // Ignore cache parse errors. + } + } + + const [map, history] = await Promise.all([ + loadPublicMap(ctx, true), + ctx.db.logEntry.findMany({ + where: { + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + }, + select: { + id: true, + text: true, + }, + orderBy: { id: 'desc' }, + take: 10, + }), + ]); if (!map) { throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'World state is not initialized.', }); } - return map; + const snapshot = { + ...map, + history, + }; + await ctx.redis.set(cacheKey, JSON.stringify(snapshot), { EX: PUBLIC_CACHE_TTL_SECONDS }); + return snapshot; }), getWorldTrend: procedure.query(async ({ ctx }) => { return loadCachedWorldTrend(ctx); diff --git a/app/game-api/test/inGameMenuPermissions.test.ts b/app/game-api/test/inGameMenuPermissions.test.ts index ea2263c..d4a480f 100644 --- a/app/game-api/test/inGameMenuPermissions.test.ts +++ b/app/game-api/test/inGameMenuPermissions.test.ts @@ -186,6 +186,50 @@ describe('in-game my information ownership', () => { }) ); }); + + it('returns the three legacy front-page record streams for the session-owned general', async () => { + const fixture = createContext({}); + const caller = appRouter.createCaller(fixture.context); + + await expect( + caller.general.getRecentRecords({ + lastGeneralRecordId: 0, + lastWorldHistoryId: 0, + }) + ).resolves.toEqual({ + global: [{ id: 1, text: '기록' }], + general: [{ id: 1, text: '기록' }], + history: [{ id: 1, text: '기록' }], + }); + + expect(fixture.db.logEntry.findMany).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + where: { scope: 'SYSTEM', category: 'SUMMARY', id: { gte: 0 } }, + orderBy: { id: 'desc' }, + take: 16, + select: { id: true, text: true }, + }) + ); + expect(fixture.db.logEntry.findMany).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + where: { scope: 'GENERAL', category: 'ACTION', generalId: 7, id: { gte: 0 } }, + orderBy: { id: 'desc' }, + take: 16, + select: { id: true, text: true }, + }) + ); + expect(fixture.db.logEntry.findMany).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + where: { scope: 'SYSTEM', category: 'HISTORY', id: { gte: 0 } }, + orderBy: { id: 'desc' }, + take: 16, + select: { id: true, text: true }, + }) + ); + }); }); describe('battle-center general and user permissions', () => { diff --git a/app/game-api/test/publicCachedMapHistory.test.ts b/app/game-api/test/publicCachedMapHistory.test.ts new file mode 100644 index 0000000..fce501a --- /dev/null +++ b/app/game-api/test/publicCachedMapHistory.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; +import type { RedisConnector } from '@sammo-ts/infra'; + +import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js'; +import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js'; +import { InMemoryFlushStore } from '../src/auth/flushStore.js'; +import type { DatabaseClient, GameApiContext, GameProfile } from '../src/context.js'; +import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js'; +import { appRouter } from '../src/router.js'; + +const profile: GameProfile = { + id: 'che', + scenario: 'default', + name: 'che:default', +}; + +const buildContext = () => { + const redis = { + get: vi.fn(async () => null), + set: vi.fn(async () => 'OK'), + }; + const db = { + worldState: { + findFirst: vi.fn(async () => ({ + currentYear: 190, + currentMonth: 3, + config: {}, + meta: { scenarioMeta: { startYear: 184 } }, + })), + }, + logEntry: { + findMany: vi.fn(async () => [ + { id: 9, text: '최근 정세' }, + { id: 8, text: '이전 정세' }, + ]), + }, + $queryRaw: vi + .fn() + .mockResolvedValueOnce([ + { id: 1, level: 5, nationId: 1, region: 1, supplyState: 1, meta: { state: 0 } }, + ]) + .mockResolvedValueOnce([ + { id: 1, name: '촉', color: '#ff0000', capitalCityId: 1, meta: {} }, + ]), + }; + const context: GameApiContext = { + db: db as unknown as DatabaseClient, + redis: redis as unknown as RedisConnector['client'], + turnDaemon: new InMemoryTurnDaemonTransport(), + battleSim: new InMemoryBattleSimTransport(), + profile, + auth: null as GameSessionTokenPayload | null, + uploadDir: 'uploads', + uploadPath: '/uploads', + uploadPublicUrl: null, + accessTokenStore: new RedisAccessTokenStore(redis as unknown as RedisConnector['client'], profile.name), + flushStore: new InMemoryFlushStore(), + gameTokenSecret: 'test-secret', + }; + return { context, db, redis }; +}; + +describe('public.getCachedMap', () => { + it('caches the neutral map and ten latest public history rows as one snapshot', async () => { + const fixture = buildContext(); + const result = await appRouter.createCaller(fixture.context).public.getCachedMap(); + + expect(result).toMatchObject({ + year: 190, + month: 3, + history: [ + { id: 9, text: '최근 정세' }, + { id: 8, text: '이전 정세' }, + ], + }); + expect(fixture.db.logEntry.findMany).toHaveBeenCalledWith({ + where: { scope: 'SYSTEM', category: 'HISTORY' }, + select: { id: true, text: true }, + orderBy: { id: 'desc' }, + take: 10, + }); + expect(fixture.redis.set).toHaveBeenCalledWith( + 'sammo:public:cachedMapWithHistory:che:default', + expect.stringContaining('최근 정세'), + { EX: 600 } + ); + }); +}); diff --git a/app/game-frontend/src/components/main/MapViewer.vue b/app/game-frontend/src/components/main/MapViewer.vue index 1755d54..8e9aa4f 100644 --- a/app/game-frontend/src/components/main/MapViewer.vue +++ b/app/game-frontend/src/components/main/MapViewer.vue @@ -1,7 +1,7 @@ + + + + diff --git a/app/game-frontend/src/views/PublicView.vue b/app/game-frontend/src/views/PublicView.vue index 1624fcf..8df4da9 100644 --- a/app/game-frontend/src/views/PublicView.vue +++ b/app/game-frontend/src/views/PublicView.vue @@ -4,6 +4,7 @@ import { useMediaQuery } from '@vueuse/core'; import PanelCard from '../components/ui/PanelCard.vue'; import SkeletonLines from '../components/ui/SkeletonLines.vue'; import MapViewer from '../components/main/MapViewer.vue'; +import RecentLogList from '../components/main/RecentLogList.vue'; import { trpc } from '../utils/trpc'; import { useSessionStore } from '../stores/session'; @@ -121,12 +122,7 @@ onMounted(() => { -
-
유저 {{ worldTrend?.userCnt ?? '-' }} / {{ worldTrend?.maxUserCnt ?? '-' }}
-
NPC {{ worldTrend?.npcCnt ?? '-' }}
-
세력 {{ worldTrend?.nationCnt ?? '-' }}
-
상성 {{ worldTrend?.fictionMode ?? '-' }}
-
+
@@ -193,13 +189,7 @@ onMounted(() => { -
-
유저 {{ worldTrend?.userCnt ?? '-' }} / {{ worldTrend?.maxUserCnt ?? '-' }}
-
NPC {{ worldTrend?.npcCnt ?? '-' }}
-
세력 {{ worldTrend?.nationCnt ?? '-' }}
-
상성 {{ worldTrend?.fictionMode ?? '-' }}
-
기타 {{ worldTrend?.otherTextInfo ?? '-' }}
-
+
diff --git a/app/game-frontend/src/views/YearbookView.vue b/app/game-frontend/src/views/YearbookView.vue index 9c38a94..b650c79 100644 --- a/app/game-frontend/src/views/YearbookView.vue +++ b/app/game-frontend/src/views/YearbookView.vue @@ -11,7 +11,7 @@ type MapLayout = Awaited>; type HistoryData = { year: number; month: number; - map: Awaited>; + map: Omit>, 'history'>; nations: Array<{ id: number; name: string; diff --git a/app/gateway-frontend/src/utils/formatLog.ts b/app/gateway-frontend/src/utils/formatLog.ts new file mode 100644 index 0000000..0cc2bc1 --- /dev/null +++ b/app/gateway-frontend/src/utils/formatLog.ts @@ -0,0 +1,26 @@ +const logRegex = /<([RBGMCLSODYW]1?|1|\/)>/g; + +const colorMap: Record = { + R: 'red', + B: 'blue', + G: 'green', + M: 'magenta', + C: 'cyan', + L: 'limegreen', + S: 'skyblue', + O: 'orangered', + D: 'orangered', + Y: 'yellow', + W: 'white', +}; + +export const formatLog = (text: string): string => + text.replace(logRegex, (_all, tag: string) => { + if (tag === '/') { + return ''; + } + const color = colorMap[tag[0] ?? '']; + const small = tag.includes('1'); + const styles = [color ? `color: ${color}` : '', small ? 'font-size: 0.9em' : ''].filter(Boolean).join('; '); + return ``; + }); diff --git a/app/gateway-frontend/src/views/HomeView.vue b/app/gateway-frontend/src/views/HomeView.vue index 8145ed1..34e6c41 100644 --- a/app/gateway-frontend/src/views/HomeView.vue +++ b/app/gateway-frontend/src/views/HomeView.vue @@ -8,6 +8,7 @@ import MapPreview from '../components/MapPreview.vue'; import DefaultLayout from '../layouts/DefaultLayout.vue'; import { createGameTrpc, type GameRouter } from '../utils/gameTrpc'; import { trpc } from '../utils/trpc'; +import { formatLog } from '../utils/formatLog'; type GatewayOutput = inferRouterOutputs; type GameOutput = inferRouterOutputs; @@ -173,6 +174,11 @@ const handlePasswordReset = async (): Promise => {
  • 유저 {{ info.userCnt }}명 · NPC {{ info.npcCnt }}명 · {{ info.nationCnt }}국 경쟁중
  • {{ info.turnTerm }}분 턴 서버
  • +
    + + +
    +
    @@ -303,6 +309,15 @@ const handlePasswordReset = async (): Promise => { background: #000; } +.status-history { + border-top: 1px solid #444; + padding: 8px 12px; + color: #ddd; + font-family: 'Times New Roman', serif; + font-size: 14px; + line-height: 1.35; +} + .status-card > header { display: flex; justify-content: space-between; diff --git a/tools/frontend-legacy-parity/map-trend.spec.ts b/tools/frontend-legacy-parity/map-trend.spec.ts new file mode 100644 index 0000000..adfec83 --- /dev/null +++ b/tools/frontend-legacy-parity/map-trend.spec.ts @@ -0,0 +1,291 @@ +import { expect, test, type Page, type Route } from '@playwright/test'; +import { readFile } from 'node:fs/promises'; +import { dirname, extname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { canonicalFrontendFixture as fixture } from './fixtures/canonical'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); +const imageRoots = [resolve(repositoryRoot, '../image'), resolve(repositoryRoot, '../../image')]; +const artifactRoot = process.env.FRONTEND_PARITY_ARTIFACT_DIR; + +const response = (data: unknown) => ({ result: { data } }); +const errorResponse = (path: string, message: string) => ({ + error: { + message, + code: -32000, + data: { code: 'INTERNAL_SERVER_ERROR', httpStatus: 500, path }, + }, +}); + +const operationNames = (route: Route): string[] => { + const pathname = new URL(route.request().url()).pathname; + return decodeURIComponent(pathname.slice(pathname.lastIndexOf('/trpc/') + 6)).split(','); +}; + +const fulfill = async (route: Route, results: unknown[]) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(results), + }); +}; + +const installImages = async (page: Page) => { + await page.route('**/image/**', async (route) => { + const pathname = decodeURIComponent(new URL(route.request().url()).pathname); + const relative = pathname.replace(/^\/image\//, ''); + const candidates = imageRoots.flatMap((root) => [resolve(root, relative), resolve(root, 'game', relative)]); + for (const candidate of candidates) { + try { + const body = await readFile(candidate); + await route.fulfill({ + status: 200, + contentType: extname(candidate).toLowerCase() === '.png' ? 'image/png' : 'image/jpeg', + body, + }); + return; + } catch { + // Worktree 위치에 따라 다음 image root를 확인한다. + } + } + await route.abort('failed'); + }); +}; + +const cachedHistory = [ + { id: 31, text: '유비가 촉을 건국하였습니다.' }, + { id: 30, text: '낙양에 새로운 소식이 있습니다.' }, +]; + +const frontRecords = { + global: [ + { id: 51, text: '관우가 장수 동향을 남겼습니다.' }, + { id: 50, text: '조조가 이동하였습니다.' }, + ], + general: [{ id: 41, text: '유비의 개인 기록입니다.' }], + history: cachedHistory, +}; + +const generalContext = { + general: { + id: 1, + name: '유비', + npcState: 0, + nationId: 1, + cityId: 1, + troopId: 0, + picture: null, + imageServer: 0, + officerLevel: 1, + stats: { leadership: 80, strength: 70, intelligence: 75 }, + gold: 1000, + rice: 1200, + crew: 500, + train: 80, + atmos: 90, + injury: 0, + experience: 100, + dedication: 200, + items: { horse: null, weapon: null, book: null, item: null }, + }, + city: { + id: 1, + name: '낙양', + level: 7, + nationId: 1, + population: 10000, + agriculture: 100, + commerce: 100, + security: 100, + defence: 100, + wall: 100, + supplyState: 1, + frontState: 0, + }, + nation: { + id: 1, + name: '촉', + color: '#d32f2f', + level: 3, + gold: 10000, + rice: 12000, + tech: 500, + typeCode: 'che_법가', + capitalCityId: 1, + }, + settings: {}, + penalties: {}, +}; + +const emptyMessages = { + private: [], + public: [], + national: [], + diplomacy: [], + permission: 0, + latestRead: { private: 0, diplomacy: 0 }, + canRespondDiplomacy: false, +}; + +const installGatewayMapFixture = async (page: Page) => { + await installImages(page); + await page.route('**/gateway/api/trpc/**', async (route) => { + const results = operationNames(route).map((operation) => { + if (operation === 'me') return response(null); + if (operation === 'lobby.profiles') return response([fixture.gateway.profile]); + return response(null); + }); + await fulfill(route, results); + }); + await page.route('**/che/api/trpc/**', async (route) => { + const results = operationNames(route).map((operation) => { + if (operation === 'lobby.info') return response(fixture.game.lobby); + if (operation === 'public.getMapLayout') return response(fixture.game.mapLayout); + if (operation === 'public.getCachedMap') { + return response({ ...fixture.game.map, history: cachedHistory }); + } + return errorResponse(operation, `Unhandled gateway map operation: ${operation}`); + }); + await fulfill(route, results); + }); +}; + +const installMainFixture = async (page: Page, failRecords = false) => { + await installImages(page); + await page.addInitScript( + ({ token, profile }) => { + window.localStorage.setItem('sammo-game-token', token); + window.localStorage.setItem('sammo-game-profile', profile); + }, + { token: fixture.game.session.gameToken, profile: fixture.game.session.profile } + ); + await page.route('**/che/api/trpc/**', async (route) => { + const results = operationNames(route).map((operation) => { + if (operation === 'lobby.info') { + return response({ ...fixture.game.lobby, myGeneral: fixture.game.session.general }); + } + if (operation === 'general.me') return response(generalContext); + if (operation === 'world.getMapLayout') return response(fixture.game.mapLayout); + if (operation === 'world.getMap') { + return response({ + ...fixture.game.map, + spyList: {}, + shownByGeneralList: [], + myCity: 1, + myNation: 1, + }); + } + if (operation === 'turns.getCommandTable') return response({ general: [], nation: [] }); + if (operation === 'turns.reserved.getGeneral') return response([]); + if (operation === 'messages.getRecent') return response(emptyMessages); + if (operation === 'messages.getContacts') return response([]); + if (operation === 'board.getAccess') { + return response({ canMeeting: false, canSecret: false, permission: 0 }); + } + if (operation === 'general.getRecentRecords') { + return failRecords + ? errorResponse(operation, '동향 정보를 불러오지 못했습니다.') + : response(frontRecords); + } + if (operation === 'tournament.getState') return response({ stage: 0 }); + if (operation === 'public.recordAccess') return response({ recorded: true }); + return errorResponse(operation, `Unhandled main operation: ${operation}`); + }); + await fulfill(route, results); + }); +}; + +test('shows the representative cached map and cached history before login', async ({ page }) => { + await installGatewayMapFixture(page); + await page.setViewportSize({ width: 1280, height: 900 }); + await page.goto('http://127.0.0.1:15100/gateway/'); + + await expect(page.locator('.map-preview-body')).toBeVisible(); + await expect(page.locator('.status-history')).toContainText('유비가 촉을 건국하였습니다.'); + const historyStyle = await page.locator('.status-history').evaluate((element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { width: rect.width, fontFamily: style.fontFamily, fontSize: style.fontSize }; + }); + expect(historyStyle.width).toBeCloseTo(698, 0); + expect(historyStyle.fontFamily).toContain('Times New Roman'); + expect(historyStyle.fontSize).toBe('14px'); + await expect(page.locator('.status-history span').first()).toHaveCSS('color', 'rgb(255, 255, 0)'); + + if (artifactRoot) { + await page.screenshot({ + path: resolve(artifactRoot, 'gateway-cached-map-history.png'), + fullPage: true, + animations: 'disabled', + }); + } +}); + +test('shows the current in-game map and all three recent record streams', async ({ page }) => { + await installMainFixture(page); + await page.setViewportSize({ width: 1440, height: 1000 }); + await page.goto('http://127.0.0.1:15102/che/'); + + await expect(page.locator('.map-area')).toBeVisible(); + await expect(page.getByText('관우가 장수 동향을 남겼습니다.')).toBeVisible(); + await expect(page.getByText('유비의 개인 기록입니다.')).toBeVisible(); + await expect(page.getByText('유비가 촉을 건국하였습니다.')).toBeVisible(); + await expect(page.getByText('실시간 스트림으로 연결 예정')).toHaveCount(0); + + const geometry = await page.locator('.map-area').evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { width: rect.width, height: rect.height }; + }); + expect(geometry).toEqual({ width: 700, height: 500 }); + await expect(page.locator('[data-record-bucket="global"] .record-line span').first()).toHaveCSS( + 'color', + 'rgb(255, 255, 0)' + ); + + if (artifactRoot) { + await page.screenshot({ + path: resolve(artifactRoot, 'ingame-map-trends-desktop.png'), + fullPage: true, + animations: 'disabled', + }); + } +}); + +test('keeps the current map visible when the recent record request fails', async ({ page }) => { + await installMainFixture(page, true); + await page.setViewportSize({ width: 1440, height: 1000 }); + await page.goto('http://127.0.0.1:15102/che/'); + + await expect(page.locator('.map-area')).toBeVisible(); + await expect(page.getByRole('alert').first()).toContainText('동향 정보를 불러오지 못했습니다.'); + await expect(page.getByRole('link', { name: '낙양', exact: true })).toBeVisible(); +}); + +test('shows map and trend tabs in the mobile in-game layout', async ({ page }) => { + await installMainFixture(page); + await page.setViewportSize({ width: 500, height: 900 }); + await page.goto('http://127.0.0.1:15102/che/'); + + await expect(page.locator('.map-area')).toBeVisible(); + const mapGeometry = await page.locator('.map-area').evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { width: rect.width, height: rect.height }; + }); + expect(mapGeometry.width).toBe(438); + expect(mapGeometry.height).toBeCloseTo(312.86, 1); + expect(mapGeometry.width / mapGeometry.height).toBeCloseTo(7 / 5, 2); + + await page.getByRole('button', { name: '동향', exact: true }).click(); + await expect(page.getByText('관우가 장수 동향을 남겼습니다.')).toBeVisible(); + await expect(page.getByText('유비의 개인 기록입니다.')).toBeVisible(); + await expect(page.getByText('유비가 촉을 건국하였습니다.')).toBeVisible(); + + if (artifactRoot) { + await page.screenshot({ + path: resolve(artifactRoot, 'ingame-map-trends-mobile.png'), + fullPage: true, + animations: 'disabled', + }); + } +}); diff --git a/tools/frontend-legacy-parity/playwright.config.mjs b/tools/frontend-legacy-parity/playwright.config.mjs index 9447360..b282f35 100644 --- a/tools/frontend-legacy-parity/playwright.config.mjs +++ b/tools/frontend-legacy-parity/playwright.config.mjs @@ -17,6 +17,7 @@ export default defineConfig({ 'tournament-betting.spec.ts', 'dynasty-parity.spec.ts', 'inheritance-management.spec.ts', + 'map-trend.spec.ts', ], fullyParallel: false, workers: 1, diff --git a/tools/frontend-legacy-parity/visual-parity.spec.ts b/tools/frontend-legacy-parity/visual-parity.spec.ts index 1ad90f5..1643b64 100644 --- a/tools/frontend-legacy-parity/visual-parity.spec.ts +++ b/tools/frontend-legacy-parity/visual-parity.spec.ts @@ -174,6 +174,7 @@ const installAuthenticatedGameFixture = async (page: Page): Promise => { return { ok: true }; } if (operation === 'vote.getAdminStatus') return { ok: false }; + if (operation === 'public.recordAccess') return { recorded: true }; throw new Error(`Unhandled authenticated game fixture operation: ${operation}`); }); });