diff --git a/app/game-api/src/router/general/index.ts b/app/game-api/src/router/general/index.ts index 4405b25..3d9886d 100644 --- a/app/game-api/src/router/general/index.ts +++ b/app/game-api/src/router/general/index.ts @@ -15,6 +15,7 @@ const zGeneralSettings = z.object({ }); const zGeneralLogType = z.enum(['generalHistory', 'battleDetail', 'battleResult', 'generalAction']); +const FRONT_RECORD_LIMIT = 15; const readNumber = (value: unknown, fallback: number): number => { if (typeof value === 'number' && Number.isFinite(value)) { @@ -319,4 +320,49 @@ export const generalRouter = router({ })), }; }), + getFrontRecords: authedProcedure.query(async ({ ctx }) => { + const me = await getMyGeneral(ctx); + const select = { + id: true, + text: true, + } as const; + const orderBy = { id: 'desc' } as const; + + const [global, general, history] = await Promise.all([ + ctx.db.logEntry.findMany({ + where: { + scope: LogScope.SYSTEM, + category: LogCategory.ACTION, + }, + select, + orderBy, + take: FRONT_RECORD_LIMIT, + }), + ctx.db.logEntry.findMany({ + where: { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + generalId: me.id, + }, + select, + orderBy, + take: FRONT_RECORD_LIMIT, + }), + ctx.db.logEntry.findMany({ + where: { + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + }, + select, + orderBy, + take: FRONT_RECORD_LIMIT, + }), + ]); + + return { + global, + general, + history, + }; + }), }); 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..966cc4f 100644 --- a/app/game-api/test/inGameMenuPermissions.test.ts +++ b/app/game-api/test/inGameMenuPermissions.test.ts @@ -186,6 +186,42 @@ 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.getFrontRecords()).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: 'ACTION' }, + orderBy: { id: 'desc' }, + take: 15, + }) + ); + expect(fixture.db.logEntry.findMany).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + where: { scope: 'GENERAL', category: 'ACTION', generalId: 7 }, + orderBy: { id: 'desc' }, + take: 15, + }) + ); + expect(fixture.db.logEntry.findMany).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + where: { scope: 'SYSTEM', category: 'HISTORY' }, + orderBy: { id: 'desc' }, + take: 15, + }) + ); + }); }); 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/stores/mainDashboard.ts b/app/game-frontend/src/stores/mainDashboard.ts index 9f5803d..70c8dbd 100644 --- a/app/game-frontend/src/stores/mainDashboard.ts +++ b/app/game-frontend/src/stores/mainDashboard.ts @@ -24,11 +24,13 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { type CommandTable = Awaited>; type MessageBundle = Awaited>; type MessageContacts = Awaited>; + type FrontRecords = Awaited>; type BoardAccess = Awaited>; type ReservedTurnView = Awaited>[number]; const loading = ref(false); const error = ref(null); + const frontRecordsError = ref(null); const realtimeEnabled = ref(true); const realtimeStatus = ref<'idle' | 'connected' | 'paused'>('idle'); @@ -39,6 +41,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { const commandTable = ref(null); const messages = ref(null); const messageContacts = ref(null); + const frontRecords = ref(null); const boardAccess = ref(null); const reservedGeneralTurns = ref(null); const reservedNationTurns = ref(null); @@ -197,6 +200,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { } loading.value = true; error.value = null; + frontRecordsError.value = null; try { const context = await trpc.general.me.query(); @@ -217,7 +221,11 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { context.general.nationId > 0 && context.general.officerLevel >= 5 ? trpc.turns.reserved.getNation.query({ generalId: id }) : Promise.resolve(null); - const [layout, lobby, map, commands, messageData, contacts, access, generalTurns, nationTurns] = + const frontRecordsPromise = trpc.general.getFrontRecords.query().catch((err: unknown) => { + frontRecordsError.value = resolveErrorMessage(err); + return null; + }); + const [layout, lobby, map, commands, messageData, contacts, access, records, generalTurns, nationTurns] = await Promise.all([ layoutPromise, trpc.lobby.info.query(), @@ -226,6 +234,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { trpc.messages.getRecent.query({ generalId: id }), trpc.messages.getContacts.query({ generalId: id }), trpc.board.getAccess.query(), + frontRecordsPromise, generalTurnsPromise, nationTurnsPromise, ]); @@ -237,6 +246,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { messages.value = messageData; messageContacts.value = contacts; boardAccess.value = access; + frontRecords.value = records; reservedGeneralTurns.value = generalTurns; reservedNationTurns.value = nationTurns; if (initializedMailboxGeneralId !== id) { @@ -587,6 +597,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { return { loading, error, + frontRecordsError, realtimeEnabled, realtimeStatus, generalContext, @@ -600,6 +611,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { commandTable, messages, messageContacts, + frontRecords, boardAccess, reservedGeneralTurns, reservedNationTurns, diff --git a/app/game-frontend/src/views/MainView.vue b/app/game-frontend/src/views/MainView.vue index e1592f2..db7db34 100644 --- a/app/game-frontend/src/views/MainView.vue +++ b/app/game-frontend/src/views/MainView.vue @@ -11,6 +11,7 @@ import CityBasicCard from '../components/main/CityBasicCard.vue'; import NationBasicCard from '../components/main/NationBasicCard.vue'; import MessagePanel from '../components/main/MessagePanel.vue'; import SelectedCityPanel from '../components/main/SelectedCityPanel.vue'; +import RecentLogList from '../components/main/RecentLogList.vue'; import { useSessionStore } from '../stores/session'; import { useMainDashboardStore } from '../stores/mainDashboard'; import { trpc } from '../utils/trpc'; @@ -35,16 +36,17 @@ const tournamentStage = ref(0); const { loading, error, + frontRecordsError, realtimeEnabled, general, city, nation, - lobbyInfo, worldMap, mapLayout, selectedCity, commandTable, messages, + frontRecords, boardAccess, reservedGeneralTurns, reservedNationTurns, @@ -206,19 +208,18 @@ watch(
-
장수 동향은 실시간 스트림으로 연결 예정
+ +
-
개인 기록 영역
+ +
-
-
유저 {{ lobbyInfo?.userCnt ?? '-' }} / {{ lobbyInfo?.maxUserCnt ?? '-' }}
-
NPC {{ lobbyInfo?.npcCnt ?? '-' }}
-
세력 {{ lobbyInfo?.nationCnt ?? '-' }}
-
+ +
@@ -255,12 +256,9 @@ watch( - -
-
유저 {{ lobbyInfo?.userCnt ?? '-' }} / {{ lobbyInfo?.maxUserCnt ?? '-' }}
-
NPC {{ lobbyInfo?.npcCnt ?? '-' }}
-
세력 {{ lobbyInfo?.nationCnt ?? '-' }}
-
+ + +
@@ -284,7 +282,8 @@ watch( -
장수 동향은 실시간 스트림으로 연결 예정
+ +
@@ -294,7 +293,8 @@ watch( -
개인 기록 영역
+ +
{ -
-
유저 {{ 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..dcfb87e --- /dev/null +++ b/tools/frontend-legacy-parity/map-trend.spec.ts @@ -0,0 +1,289 @@ +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.getFrontRecords') { + 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('.recent-log-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}`); }); });