From 477862464efc927f0feb7bfa654c2b39b6027db5 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 08:39:35 +0000 Subject: [PATCH 1/3] feat: complete map and trend frontend flows --- app/game-api/src/router/general/index.ts | 46 +++ app/game-api/src/router/public/index.ts | 36 ++- .../test/inGameMenuPermissions.test.ts | 36 +++ .../test/publicCachedMapHistory.test.ts | 90 ++++++ .../src/components/main/MapViewer.vue | 16 +- .../src/components/main/RecentLogList.vue | 57 ++++ app/game-frontend/src/stores/mainDashboard.ts | 14 +- app/game-frontend/src/views/MainView.vue | 36 ++- app/game-frontend/src/views/PublicView.vue | 16 +- app/game-frontend/src/views/YearbookView.vue | 2 +- app/gateway-frontend/src/utils/formatLog.ts | 26 ++ app/gateway-frontend/src/views/HomeView.vue | 15 + .../frontend-legacy-parity/map-trend.spec.ts | 289 ++++++++++++++++++ .../playwright.config.mjs | 1 + .../visual-parity.spec.ts | 1 + 15 files changed, 645 insertions(+), 36 deletions(-) create mode 100644 app/game-api/test/publicCachedMapHistory.test.ts create mode 100644 app/game-frontend/src/components/main/RecentLogList.vue create mode 100644 app/gateway-frontend/src/utils/formatLog.ts create mode 100644 tools/frontend-legacy-parity/map-trend.spec.ts 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}`); }); }); From 918d8f0e4c0deba3b95d4cf01d7b7fad899a23c4 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 08:55:06 +0000 Subject: [PATCH 2/3] feat: restore main record panels --- app/game-api/src/router/general/index.ts | 62 +++ app/game-api/test/mainRecordsRouter.test.ts | 133 +++++++ .../src/components/main/RecordPanel.vue | 41 ++ app/game-frontend/src/stores/mainDashboard.ts | 57 ++- app/game-frontend/src/views/MainView.vue | 161 ++++++-- .../main-records.playwright.config.mjs | 51 +++ .../main-records.spec.ts | 356 ++++++++++++++++++ .../reference-main-records.mjs | 91 +++++ .../run-main-records-live.mjs | 53 +++ 9 files changed, 971 insertions(+), 34 deletions(-) create mode 100644 app/game-api/test/mainRecordsRouter.test.ts create mode 100644 app/game-frontend/src/components/main/RecordPanel.vue create mode 100644 tools/frontend-legacy-parity/main-records.playwright.config.mjs create mode 100644 tools/frontend-legacy-parity/main-records.spec.ts create mode 100644 tools/frontend-legacy-parity/reference-main-records.mjs create mode 100644 tools/frontend-legacy-parity/run-main-records-live.mjs diff --git a/app/game-api/src/router/general/index.ts b/app/game-api/src/router/general/index.ts index 4405b25..8294f99 100644 --- a/app/game-api/src/router/general/index.ts +++ b/app/game-api/src/router/general/index.ts @@ -15,6 +15,18 @@ const zGeneralSettings = z.object({ }); const zGeneralLogType = z.enum(['generalHistory', 'battleDetail', 'battleResult', 'generalAction']); +const MAIN_RECORD_LIMIT = 15; + +const trimRecentRecords = (entries: Entry[], cursor: number): Entry[] => { + if (entries.length === 0) { + return entries; + } + const result = [...entries]; + if (result.at(-1)?.id === cursor || result.length > MAIN_RECORD_LIMIT) { + result.pop(); + } + return result; +}; const readNumber = (value: unknown, fallback: number): number => { if (typeof value === 'number' && Number.isFinite(value)) { @@ -319,4 +331,54 @@ export const generalRouter = router({ })), }; }), + getRecentRecords: authedProcedure + .input( + z.object({ + lastGeneralRecordId: z.number().int().nonnegative().default(0), + lastWorldHistoryId: z.number().int().nonnegative().default(0), + }) + ) + .query(async ({ ctx, input }) => { + const me = await getMyGeneral(ctx); + const take = MAIN_RECORD_LIMIT + 1; + const [global, general, history] = await Promise.all([ + ctx.db.logEntry.findMany({ + where: { + scope: LogScope.SYSTEM, + category: LogCategory.SUMMARY, + id: { gte: input.lastGeneralRecordId }, + }, + orderBy: { id: 'desc' }, + take, + select: { id: true, text: true }, + }), + ctx.db.logEntry.findMany({ + where: { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + generalId: me.id, + id: { gte: input.lastGeneralRecordId }, + }, + orderBy: { id: 'desc' }, + take, + select: { id: true, text: true }, + }), + ctx.db.logEntry.findMany({ + where: { + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + id: { gte: input.lastWorldHistoryId }, + }, + orderBy: { id: 'desc' }, + take, + select: { id: true, text: true }, + }), + ]); + + return { + global: trimRecentRecords(global, input.lastGeneralRecordId), + general: trimRecentRecords(general, input.lastGeneralRecordId), + history: trimRecentRecords(history, input.lastWorldHistoryId), + }; + }), }); diff --git a/app/game-api/test/mainRecordsRouter.test.ts b/app/game-api/test/mainRecordsRouter.test.ts new file mode 100644 index 0000000..157353d --- /dev/null +++ b/app/game-api/test/mainRecordsRouter.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; +import { LogCategory, LogScope } from '@sammo-ts/infra'; + +import type { DatabaseClient, GameApiContext } from '../src/context.js'; +import { appRouter } from '../src/router.js'; + +const auth: GameSessionTokenPayload = { + version: 1, + profile: 'che:default', + issuedAt: '2026-07-26T00:00:00.000Z', + expiresAt: '2026-07-27T00:00:00.000Z', + sessionId: 'session-owner', + user: { + id: 'owner', + username: 'owner', + displayName: 'Owner', + roles: [], + }, + sanctions: {}, +}; + +type LogQuery = { + where: { + scope: LogScope; + category: LogCategory; + generalId?: number; + id: { gte: number }; + }; + orderBy: { id: 'desc' }; + take: number; + select: { id: true; text: true }; +}; + +const buildContext = (findMany: (query: LogQuery) => Promise>) => + ({ + auth, + db: { + general: { + findFirst: vi.fn(async ({ where }: { where: { userId: string } }) => ({ + id: 7, + userId: where.userId, + })), + }, + logEntry: { findMany }, + } as unknown as DatabaseClient, + }) as GameApiContext; + +describe('general.getRecentRecords', () => { + it('derives the general and maps all three legacy dashboard buckets', async () => { + const findMany = vi.fn(async (query: LogQuery) => { + if (query.where.scope === LogScope.GENERAL) { + return [ + { id: 31, text: '개인 최신' }, + { id: 20, text: '개인 cursor' }, + ]; + } + if (query.where.category === LogCategory.SUMMARY) { + return [ + { id: 32, text: '장수 최신' }, + { id: 20, text: '장수 cursor' }, + ]; + } + return [ + { id: 42, text: '중원 최신' }, + { id: 40, text: '중원 cursor' }, + ]; + }); + const caller = appRouter.createCaller(buildContext(findMany)); + + const result = await caller.general.getRecentRecords({ + lastGeneralRecordId: 20, + lastWorldHistoryId: 40, + }); + + expect(result).toEqual({ + global: [{ id: 32, text: '장수 최신' }], + general: [{ id: 31, text: '개인 최신' }], + history: [{ id: 42, text: '중원 최신' }], + }); + expect(findMany).toHaveBeenCalledTimes(3); + expect(findMany).toHaveBeenCalledWith({ + where: { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + generalId: 7, + id: { gte: 20 }, + }, + orderBy: { id: 'desc' }, + take: 16, + select: { id: true, text: true }, + }); + }); + + it('caps an initial bucket at the legacy 15-row limit', async () => { + const rows = Array.from({ length: 16 }, (_, index) => ({ + id: 100 - index, + text: `기록 ${index}`, + })); + const caller = appRouter.createCaller(buildContext(async () => rows)); + + const result = await caller.general.getRecentRecords({ + lastGeneralRecordId: 0, + lastWorldHistoryId: 0, + }); + + expect(result.global).toHaveLength(15); + expect(result.general).toHaveLength(15); + expect(result.history).toHaveLength(15); + expect(result.global.at(-1)?.id).toBe(86); + }); + + it('rejects an authenticated user without an in-game general', async () => { + const context = buildContext(async () => []); + context.db = { + general: { + findFirst: vi.fn(async () => null), + }, + logEntry: { + findMany: vi.fn(async () => []), + }, + } as unknown as DatabaseClient; + const caller = appRouter.createCaller(context); + + await expect( + caller.general.getRecentRecords({ + lastGeneralRecordId: 0, + lastWorldHistoryId: 0, + }) + ).rejects.toMatchObject({ code: 'NOT_FOUND' }); + }); +}); diff --git a/app/game-frontend/src/components/main/RecordPanel.vue b/app/game-frontend/src/components/main/RecordPanel.vue new file mode 100644 index 0000000..b90d672 --- /dev/null +++ b/app/game-frontend/src/components/main/RecordPanel.vue @@ -0,0 +1,41 @@ + + + + + diff --git a/app/game-frontend/src/stores/mainDashboard.ts b/app/game-frontend/src/stores/mainDashboard.ts index 9f5803d..bb0df62 100644 --- a/app/game-frontend/src/stores/mainDashboard.ts +++ b/app/game-frontend/src/stores/mainDashboard.ts @@ -26,9 +26,11 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { type MessageContacts = Awaited>; type BoardAccess = Awaited>; type ReservedTurnView = Awaited>[number]; + type RecentRecord = Awaited>['global'][number]; const loading = ref(false); const error = ref(null); + const recordsError = ref(null); const realtimeEnabled = ref(true); const realtimeStatus = ref<'idle' | 'connected' | 'paused'>('idle'); @@ -42,6 +44,12 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { const boardAccess = ref(null); const reservedGeneralTurns = ref(null); const reservedNationTurns = ref(null); + const globalRecords = ref([]); + const generalRecords = ref([]); + const worldHistory = ref([]); + let lastGeneralRecordId = 0; + let lastWorldHistoryId = 0; + let recordGeneralId: number | null = null; const messageDraftText = ref(''); const targetMailbox = ref(MESSAGE_MAILBOX_PUBLIC); @@ -191,12 +199,30 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { } }; + const mergeRecentRecords = (current: RecentRecord[], incoming: RecentRecord[]): RecentRecord[] => { + const merged = new Map(current.map((entry) => [entry.id, entry])); + for (const entry of incoming) { + merged.set(entry.id, entry); + } + return [...merged.values()].sort((left, right) => right.id - left.id).slice(0, 15); + }; + + const resetRecentRecords = (id: number | null) => { + globalRecords.value = []; + generalRecords.value = []; + worldHistory.value = []; + lastGeneralRecordId = 0; + lastWorldHistoryId = 0; + recordGeneralId = id; + }; + const loadMainData = async () => { if (loading.value) { return; } loading.value = true; error.value = null; + recordsError.value = null; try { const context = await trpc.general.me.query(); @@ -206,18 +232,31 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { reservedGeneralTurns.value = null; reservedNationTurns.value = null; boardAccess.value = null; + resetRecentRecords(null); loading.value = false; return; } const id = context.general.id; + if (recordGeneralId !== id) { + resetRecentRecords(id); + } const layoutPromise = mapLayout.value ? Promise.resolve(mapLayout.value) : trpc.world.getMapLayout.query(); const generalTurnsPromise = trpc.turns.reserved.getGeneral.query({ generalId: id }); const nationTurnsPromise = 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 recordsPromise = trpc.general.getRecentRecords + .query({ + lastGeneralRecordId, + lastWorldHistoryId, + }) + .catch((err: unknown) => { + recordsError.value = resolveErrorMessage(err); + return null; + }); + const [layout, lobby, map, commands, messageData, contacts, access, generalTurns, nationTurns, records] = await Promise.all([ layoutPromise, trpc.lobby.info.query(), @@ -228,6 +267,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { trpc.board.getAccess.query(), generalTurnsPromise, nationTurnsPromise, + recordsPromise, ]); mapLayout.value = layout; @@ -239,6 +279,17 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { boardAccess.value = access; reservedGeneralTurns.value = generalTurns; reservedNationTurns.value = nationTurns; + if (records) { + globalRecords.value = mergeRecentRecords(globalRecords.value, records.global); + generalRecords.value = mergeRecentRecords(generalRecords.value, records.general); + worldHistory.value = mergeRecentRecords(worldHistory.value, records.history); + lastGeneralRecordId = Math.max( + lastGeneralRecordId, + records.global[0]?.id ?? 0, + records.general[0]?.id ?? 0 + ); + lastWorldHistoryId = Math.max(lastWorldHistoryId, records.history[0]?.id ?? 0); + } if (initializedMailboxGeneralId !== id) { targetMailbox.value = MESSAGE_MAILBOX_NATIONAL_BASE + context.general.nationId; initializedMailboxGeneralId = id; @@ -587,6 +638,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { return { loading, error, + recordsError, realtimeEnabled, realtimeStatus, generalContext, @@ -603,6 +655,9 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { boardAccess, reservedGeneralTurns, reservedNationTurns, + globalRecords, + generalRecords, + worldHistory, messageDraftText, targetMailbox, mailboxGroups, diff --git a/app/game-frontend/src/views/MainView.vue b/app/game-frontend/src/views/MainView.vue index e1592f2..e166db4 100644 --- a/app/game-frontend/src/views/MainView.vue +++ b/app/game-frontend/src/views/MainView.vue @@ -11,13 +11,15 @@ 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 RecordPanel from '../components/main/RecordPanel.vue'; +import { formatLog } from '../utils/formatLog'; import { useSessionStore } from '../stores/session'; import { useMainDashboardStore } from '../stores/mainDashboard'; import { trpc } from '../utils/trpc'; const session = useSessionStore(); const dashboard = useMainDashboardStore(); -const isMobile = useMediaQuery('(max-width: 1024px)'); +const isMobile = useMediaQuery('(max-width: 991px)'); const mobileTabs = [ { key: 'map', label: '지도' }, @@ -35,11 +37,11 @@ const tournamentStage = ref(0); const { loading, error, + recordsError, realtimeEnabled, general, city, nation, - lobbyInfo, worldMap, mapLayout, selectedCity, @@ -48,6 +50,9 @@ const { boardAccess, reservedGeneralTurns, reservedNationTurns, + globalRecords, + generalRecords, + worldHistory, messageDraftText, targetMailbox, mailboxGroups, @@ -203,23 +208,49 @@ watch(
    -
    - +
    + -
    장수 동향은 실시간 스트림으로 연결 예정
    - - - -
    개인 기록 영역
    -
    - - -
    -
    유저 {{ lobbyInfo?.userCnt ?? '-' }} / {{ lobbyInfo?.maxUserCnt ?? '-' }}
    -
    NPC {{ lobbyInfo?.npcCnt ?? '-' }}
    -
    세력 {{ lobbyInfo?.nationCnt ?? '-' }}
    + +
    + +
    +
    기록이 없습니다.
    - + + + + +
    + +
    +
    기록이 없습니다.
    +
    + + + + +
    + +
    +
    기록이 없습니다.
    +
    +
    @@ -254,14 +285,6 @@ watch( - - -
    -
    유저 {{ lobbyInfo?.userCnt ?? '-' }} / {{ lobbyInfo?.maxUserCnt ?? '-' }}
    -
    NPC {{ lobbyInfo?.npcCnt ?? '-' }}
    -
    세력 {{ lobbyInfo?.nationCnt ?? '-' }}
    -
    -
    @@ -282,21 +305,57 @@ watch( - - -
    장수 동향은 실시간 스트림으로 연결 예정
    -
    - - -
    개인 기록 영역
    -
    +
    + + + +
    + +
    +
    기록이 없습니다.
    +
    + + + + +
    + +
    +
    기록이 없습니다.
    +
    + + + + +
    + +
    +
    기록이 없습니다.
    +
    + +
    ; +let redis: RedisConnector; +let prisma: GamePrismaClient; +let createdIds: number[] = []; + +const accessKey = (token: string) => `sammo:game:access:che:default:${token}`; +const trpcResponse = (data: unknown) => ({ result: { data } }); +const trpcError = (path: string, message: string) => ({ + error: { + message, + code: -32000, + data: { code: 'INTERNAL_SERVER_ERROR', httpStatus: 500, path }, + }, +}); +const operationNames = (route: Route): string[] => { + const pathname = decodeURIComponent(new URL(route.request().url()).pathname); + return pathname.slice(pathname.lastIndexOf('/trpc/') + 6).split(','); +}; + +const inspectRecordLayout = async (page: Page) => + page.evaluate(() => { + const inspect = (selector: string) => { + const element = document.querySelector(selector); + if (!(element instanceof HTMLElement)) { + throw new Error(`missing ${selector}`); + } + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + rect: { + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + }, + padding: style.padding, + color: style.color, + backgroundImage: style.backgroundImage, + borderTop: style.borderTop, + borderBottom: style.borderBottom, + fontFamily: style.fontFamily, + fontSize: style.fontSize, + fontWeight: style.fontWeight, + lineHeight: style.lineHeight, + textAlign: style.textAlign, + }; + }; + const buckets = [...document.querySelectorAll('[data-record-bucket]')]; + const mobileZone = document.querySelector('.record-zone-mobile'); + const zoneSelector = + mobileZone && getComputedStyle(mobileZone).display !== 'none' ? '.record-zone-mobile' : '.record-zone'; + return { + zone: inspect(zoneSelector), + global: inspect('[data-record-bucket="global"]'), + general: inspect('[data-record-bucket="general"]'), + history: inspect('[data-record-bucket="history"]'), + title: inspect('[data-record-bucket="global"]'), + firstLine: inspect('[data-record-bucket="global"] .record-line'), + panels: buckets.map((bucket) => { + const panel = bucket.closest('.record-panel'); + const title = panel?.querySelector('.record-title'); + if (!panel || !title) throw new Error('record panel structure missing'); + return { + bucket: bucket.dataset.recordBucket, + panel: panel.getBoundingClientRect().toJSON(), + title: title.getBoundingClientRect().toJSON(), + lineCount: bucket.querySelectorAll('.record-line').length, + titleStyle: { + fontSize: getComputedStyle(title).fontSize, + fontWeight: getComputedStyle(title).fontWeight, + lineHeight: getComputedStyle(title).lineHeight, + textAlign: getComputedStyle(title).textAlign, + borderTop: getComputedStyle(title).borderTop, + borderBottom: getComputedStyle(title).borderBottom, + }, + }; + }), + }; + }); + +test.beforeAll(async () => { + postgres = createGamePostgresConnector(resolvePostgresConfigFromEnv({ schema: 'che' })); + redis = createRedisConnector(resolveRedisConfigFromEnv()); + await postgres.connect(); + await redis.connect(); + prisma = postgres.prisma; + + const general = await prisma.general.findFirst({ + where: { userId: { not: null } }, + orderBy: { id: 'asc' }, + select: { id: true, userId: true, name: true }, + }); + if (!general?.userId) { + throw new Error('The live che profile needs an owned general.'); + } + const world = await prisma.worldState.findFirst({ + orderBy: { id: 'asc' }, + select: { currentYear: true, currentMonth: true }, + }); + if (!world) { + throw new Error('The live che profile needs a world state.'); + } + + const drafts = [ + ...Array.from({ length: 15 }, (_, index) => ({ + scope: 'SYSTEM' as const, + category: 'SUMMARY' as const, + text: `●${RECORD_MARKER} 장수 동향 ${15 - index}`, + })), + ...Array.from({ length: 7 }, (_, index) => ({ + scope: 'GENERAL' as const, + category: 'ACTION' as const, + generalId: general.id, + text: `●${RECORD_MARKER} 개인 기록 ${7 - index}`, + })), + ...Array.from({ length: 15 }, (_, index) => ({ + scope: 'SYSTEM' as const, + category: 'HISTORY' as const, + text: `●${RECORD_MARKER} 중원 정세 ${15 - index}`, + })), + ]; + const rows = await Promise.all( + drafts.map((draft) => + prisma.logEntry.create({ + data: { + ...draft, + year: world.currentYear, + month: world.currentMonth, + meta: { fixture: RECORD_MARKER }, + }, + select: { id: true }, + }) + ) + ); + createdIds = rows.map((row: { id: number }) => row.id); + + const issuedAt = new Date(); + const expiresAt = new Date(issuedAt.getTime() + 30 * 60 * 1000); + await redis.client.set( + accessKey(accessToken), + JSON.stringify({ + version: 1, + profile: 'che:default', + issuedAt: issuedAt.toISOString(), + expiresAt: expiresAt.toISOString(), + sessionId: `main-records-${randomUUID()}`, + user: { + id: general.userId, + username: 'main-records-live', + displayName: general.name, + roles: [], + }, + sanctions: {}, + }), + { EX: 1800 } + ); +}); + +test.afterAll(async () => { + if (createdIds.length > 0) { + await prisma.logEntry.deleteMany({ where: { id: { in: createdIds } } }); + } + await redis.client.del(accessKey(accessToken)); + await redis.disconnect(); + await postgres.disconnect(); +}); + +test('renders all three real database record buckets with legacy computed geometry', async ({ page }) => { + const liveResponse = await page.request.get( + `http://127.0.0.1:15113/che/api/trpc/general.getRecentRecords?input=${encodeURIComponent( + JSON.stringify({ + json: { + lastGeneralRecordId: 0, + lastWorldHistoryId: 0, + }, + }) + )}`, + { headers: { authorization: `Bearer ${accessToken}` } } + ); + expect(liveResponse.ok()).toBe(true); + const livePayload = (await liveResponse.json()) as { + result: { data: { global: unknown[]; general: unknown[]; history: unknown[] } }; + }; + const liveRecords = livePayload.result.data; + expect([liveRecords.global.length, liveRecords.general.length, liveRecords.history.length]).toEqual([15, 7, 15]); + let failRecords = false; + + await page.addInitScript( + ({ token }) => { + window.localStorage.setItem('sammo-game-token', token); + window.localStorage.setItem('sammo-game-profile', 'che:default'); + }, + { token: accessToken } + ); + await page.route('**/che/api/trpc/**', async (route) => { + const results = operationNames(route).map((operation) => { + if (operation === 'general.me') { + return trpcResponse({ + general: { + id: 1, + name: '관리자', + npcState: 0, + nationId: 0, + cityId: 1, + troopId: 0, + picture: null, + imageServer: 0, + officerLevel: 0, + stats: { leadership: 55, strength: 55, intelligence: 55 }, + gold: 1000, + rice: 1000, + crew: 0, + train: 0, + atmos: 0, + injury: 0, + experience: 0, + dedication: 0, + items: { horse: null, weapon: null, book: null, item: null }, + }, + city: null, + nation: null, + settings: {}, + penalties: {}, + }); + } + if (operation === 'general.getRecentRecords') { + return failRecords + ? trpcError(operation, '동향 정보를 불러오지 못했습니다.') + : trpcResponse(liveRecords); + } + if (operation === 'lobby.info') { + return trpcResponse({ + year: 190, + month: 1, + userCnt: 1, + npcCnt: 0, + nationCnt: 0, + maxUserCnt: 50, + turnTerm: 60, + fictionMode: '가상', + otherTextInfo: '', + starttime: '0190-01-01T00:00:00.000Z', + myGeneral: { id: 1, name: '관리자', nationId: 0 }, + }); + } + if (operation === 'world.getMapLayout') { + return trpcResponse({ + mapName: 'che', + cityList: [{ id: 1, name: '낙양', level: 7, region: 1, x: 350, y: 245, path: [] }], + regionMap: { 1: '사예' }, + levelMap: { 7: '수도' }, + }); + } + if (operation === 'world.getMap') { + return trpcResponse({ + year: 190, + month: 1, + startYear: 190, + cityList: [[1, 7, 0, 0, 1, 1]], + nationList: [], + spyList: {}, + shownByGeneralList: [], + myCity: 1, + myNation: 0, + }); + } + if (operation === 'turns.getCommandTable') return trpcResponse({ general: [], nation: [] }); + if (operation === 'turns.reserved.getGeneral' || operation === 'turns.reserved.getNation') { + return trpcResponse([]); + } + if (operation === 'messages.getRecent') { + return trpcResponse({ + private: [], + public: [], + national: [], + diplomacy: [], + permission: 0, + latestRead: { private: 0, diplomacy: 0 }, + canRespondDiplomacy: false, + }); + } + if (operation === 'messages.getContacts') return trpcResponse([]); + if (operation === 'board.getAccess') { + return trpcResponse({ canMeeting: false, canSecret: false, permission: 0 }); + } + if (operation === 'tournament.getState') return trpcResponse({ stage: 0 }); + if (operation === 'public.recordAccess') return trpcResponse({ recorded: true }); + throw new Error(`Unhandled live main operation: ${operation}`); + }); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(results), + }); + }); + await page.route('**/image/**', async (route) => { + const source = new URL(route.request().url()); + const response = await route.fetch({ + url: `https://dev-sam-ref.hided.net${source.pathname}`, + }); + await route.fulfill({ response }); + }); + + await page.setViewportSize({ width: 1000, height: 900 }); + await page.goto('./', { waitUntil: 'networkidle' }); + await expect(page.locator('[data-record-bucket="global"] .record-line')).toHaveCount(15); + await expect(page.locator('[data-record-bucket="general"] .record-line')).toHaveCount(7); + await expect(page.locator('[data-record-bucket="history"] .record-line')).toHaveCount(15); + await expect(page.locator('[data-record-bucket="global"]')).toContainText(RECORD_MARKER); + + const desktop = await inspectRecordLayout(page); + expect(desktop.panels.map((panel) => panel.lineCount)).toEqual([15, 7, 15]); + expect(desktop.panels[0]?.panel.width).toBeCloseTo(500, 0); + expect(desktop.panels[1]?.panel.x).toBeCloseTo(500, 0); + expect(desktop.panels[2]?.panel.width).toBeCloseTo(1000, 0); + expect(desktop.panels[0]?.panel.height).toBeCloseTo(338, 0); + expect(desktop.panels[2]?.panel.height).toBeCloseTo(338, 0); + expect(desktop.panels[0]?.title.height).toBeCloseTo(23, 0); + expect(desktop.panels[0]?.titleStyle).toMatchObject({ + fontSize: '14px', + fontWeight: '400', + lineHeight: '21px', + textAlign: 'center', + borderTop: '1px solid rgb(128, 128, 128)', + borderBottom: '1px solid rgb(128, 128, 128)', + }); + + await page.setViewportSize({ width: 500, height: 900 }); + await page.getByRole('button', { name: '동향', exact: true }).click(); + await expect(page.locator('.record-zone-mobile')).toBeVisible(); + const mobile = await inspectRecordLayout(page); + expect(mobile.panels.map((panel) => panel.lineCount)).toEqual([15, 7, 15]); + expect(mobile.panels.every((panel) => Math.round(panel.panel.width) === 500)).toBe(true); + expect(mobile.panels[0]?.panel.height).toBeCloseTo(338, 0); + expect(mobile.panels[1]?.panel.height).toBeCloseTo(170, 0); + expect(mobile.panels[2]?.panel.height).toBeCloseTo(338, 0); + + failRecords = true; + await page.getByRole('button', { name: '새로고침', exact: true }).click(); + await expect(page.getByRole('alert').first()).toContainText('동향 정보를 불러오지 못했습니다.'); + await expect(page.getByRole('heading', { name: '장수 동향', exact: true })).toBeVisible(); +}); diff --git a/tools/frontend-legacy-parity/reference-main-records.mjs b/tools/frontend-legacy-parity/reference-main-records.mjs new file mode 100644 index 0000000..99f67d2 --- /dev/null +++ b/tools/frontend-legacy-parity/reference-main-records.mjs @@ -0,0 +1,91 @@ +import { createHash } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; + +import { chromium } from '@playwright/test'; + +const baseUrl = process.env.REF_MAIN_URL ?? 'http://127.0.0.1:3400/sam/'; +const username = process.env.REF_USER_ID ?? 'refuser1'; +const passwordFile = + process.env.REF_USER_PASSWORD_FILE ?? + '/home/letrhee/sam_rebuild/docker_compose_files/reference/secrets/user1_password'; + +const password = (await readFile(passwordFile, 'utf8')).trim(); +const browser = await chromium.launch({ headless: true }); + +try { + const context = await browser.newContext({ + colorScheme: 'dark', + deviceScaleFactor: 1, + locale: 'ko-KR', + timezoneId: 'UTC', + }); + const page = await context.newPage(); + await page.goto(baseUrl, { waitUntil: 'networkidle' }); + const globalSalt = await page.locator('#global_salt').inputValue(); + const passwordHash = createHash('sha512') + .update(globalSalt + password + globalSalt) + .digest('hex'); + const response = await context.request.post(new URL('api.php?path=Login/LoginByID', baseUrl).toString(), { + data: { username, password: passwordHash }, + }); + if (!response.ok()) { + throw new Error(`reference login failed: HTTP ${response.status()}`); + } + + for (const viewport of [ + { name: 'desktop', width: 1000, height: 900 }, + { name: 'mobile', width: 500, height: 900 }, + ]) { + await page.setViewportSize(viewport); + await page.goto(new URL('hwe/', baseUrl).toString(), { waitUntil: 'networkidle' }); + await page.locator('.RecordZone').waitFor({ state: 'visible' }); + await page.waitForFunction(() => + [...document.querySelectorAll('.RecordZone > div')].every((panel) => panel.children.length > 1) + ); + const measurement = await page.evaluate(() => { + const inspect = (selector) => { + const element = document.querySelector(selector); + if (!(element instanceof HTMLElement)) { + throw new Error(`missing ${selector}`); + } + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + rect: { + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + }, + display: style.display, + padding: style.padding, + color: style.color, + backgroundColor: style.backgroundColor, + backgroundImage: style.backgroundImage, + borderTop: style.borderTop, + fontFamily: style.fontFamily, + fontSize: style.fontSize, + fontWeight: style.fontWeight, + lineHeight: style.lineHeight, + textAlign: style.textAlign, + }; + }; + return { + zone: inspect('.RecordZone'), + global: inspect('.PublicRecord'), + general: inspect('.GeneralLog'), + history: inspect('.WorldHistory'), + title: inspect('.PublicRecord .title'), + firstLine: inspect('.PublicRecord > div:nth-child(2)'), + counts: { + global: document.querySelectorAll('.PublicRecord > div:not(.title)').length, + general: document.querySelectorAll('.GeneralLog > div:not(.title)').length, + history: document.querySelectorAll('.WorldHistory > div:not(.title)').length, + }, + }; + }); + console.log(JSON.stringify({ viewport, measurement })); + } +} finally { + await browser.close(); +} diff --git a/tools/frontend-legacy-parity/run-main-records-live.mjs b/tools/frontend-legacy-parity/run-main-records-live.mjs new file mode 100644 index 0000000..363ef8e --- /dev/null +++ b/tools/frontend-legacy-parity/run-main-records-live.mjs @@ -0,0 +1,53 @@ +import { execFileSync, spawnSync } from 'node:child_process'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); +const pm2Candidates = [ + '/home/letrhee/.nvm/versions/node/v22.15.1/bin/pm2', + '/home/letrhee/.nvm/versions/node/v24.13.0/bin/pm2', +]; + +let processes; +for (const candidate of pm2Candidates) { + try { + const output = execFileSync(candidate, ['jlist'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + processes = JSON.parse(output.slice(output.indexOf('['))); + break; + } catch { + // Try the next locally installed PM2 client. + } +} +if (!processes) { + throw new Error('Unable to read the local PM2 process list.'); +} + +const source = processes.find((process) => process.name === 'sammo-verify-che-api')?.pm2_env; +if (!source) { + throw new Error('sammo-verify-che-api is required as the live environment source.'); +} + +const allowed = /^(DATABASE_URL|POSTGRES_|REDIS_|GAME_|GATEWAY_|PROFILE$|SCENARIO$)/; +const liveEnvironment = Object.fromEntries( + Object.entries(source).filter(([key, value]) => allowed.test(key) && typeof value === 'string') +); +const result = spawnSync( + 'pnpm', + [ + 'exec', + 'playwright', + 'test', + 'main-records.spec.ts', + '--config', + 'tools/frontend-legacy-parity/main-records.playwright.config.mjs', + ], + { + cwd: repositoryRoot, + env: { ...process.env, ...liveEnvironment }, + stdio: 'inherit', + } +); +process.exitCode = result.status ?? 1; From 5d07aae5b90c7093f539d439984290b7e7353603 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 08:59:07 +0000 Subject: [PATCH 3/3] fix: match mobilize people boundaries --- .../src/actions/turn/nation/che_백성동원.ts | 7 +- .../src/turn-differential/coreCommandTrace.ts | 17 ++ ...urnCommandNationMatrix.integration.test.ts | 265 +++++++++++++++++- 3 files changed, 283 insertions(+), 6 deletions(-) diff --git a/packages/logic/src/actions/turn/nation/che_백성동원.ts b/packages/logic/src/actions/turn/nation/che_백성동원.ts index cb22eb0..1c30f82 100644 --- a/packages/logic/src/actions/turn/nation/che_백성동원.ts +++ b/packages/logic/src/actions/turn/nation/che_백성동원.ts @@ -21,13 +21,10 @@ import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js' import { JosaUtil } from '@sammo-ts/common'; import type { NationTurnCommandSpec } from './index.js'; import { z } from 'zod'; -import { parseArgsWithSchema } from '../parseArgs.js'; +import { normalizeLegacyIntegerArg, parseArgsWithSchema } from '../parseArgs.js'; const ARGS_SCHEMA = z.object({ - destCityId: z.preprocess( - (value) => (typeof value === 'number' ? Math.floor(value) : value), - z.number().int().positive() - ), + destCityId: z.preprocess(normalizeLegacyIntegerArg, z.number().int().positive()), }); export type MobilizePeopleArgs = z.infer; diff --git a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts index 7b8e97d..dc7a799 100644 --- a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts +++ b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts @@ -31,6 +31,11 @@ interface GeneralCooldownSelector { actionName: string; } +interface NationCooldownSelector { + nationId: number; + actionName: string; +} + export interface TurnCommandFixtureRequest { kind: 'general' | 'nation'; actorGeneralId: number; @@ -63,6 +68,7 @@ export interface TurnCommandFixtureRequest { logAfterId?: number; messageAfterId?: number; generalCooldowns?: GeneralCooldownSelector[]; + nationCooldowns?: NationCooldownSelector[]; }; } @@ -473,6 +479,7 @@ const projectWorld = ( cityIds: Set; nationIds: Set; generalCooldowns: GeneralCooldownSelector[]; + nationCooldowns: NationCooldownSelector[]; } ): CanonicalTurnSnapshot => { const state = world.getState(); @@ -542,6 +549,15 @@ const projectWorld = ( nextAvailableTurn: typeof raw === 'number' && Number.isFinite(raw) ? raw : null, }; }), + nationCooldowns: selector.nationCooldowns.map(({ nationId, actionName }) => { + const nation = world.getNationById(nationId); + const raw = nation?.meta[`next_execute_${actionName}`]; + return { + nationId, + actionName, + nextAvailableTurn: typeof raw === 'number' && Number.isFinite(raw) ? raw : null, + }; + }), }, generals, rankData: world @@ -656,6 +672,7 @@ export const runCoreTurnCommandTrace = async ( cityIds: new Set(referenceBefore.cities.map((row) => readNumber(row, 'id'))), nationIds: new Set(referenceBefore.nations.map((row) => readNumber(row, 'id'))), generalCooldowns: request.observe?.generalCooldowns ?? [], + nationCooldowns: request.observe?.nationCooldowns ?? [], }; const reservedTurns = new InMemoryReservedTurnStore(emptyDatabaseClient as never, { maxGeneralTurns: 10, diff --git a/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts b/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts index bb33177..8f6496d 100644 --- a/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts +++ b/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts @@ -311,7 +311,17 @@ const buildRequest = ( ], }, observe: { - generalIds: [1, 2, 3, 4, 5, 6], + generalIds: [ + 1, + 2, + 3, + 4, + 5, + 6, + ...Object.keys(fixturePatches.generals ?? {}) + .map(Number) + .filter((id) => ![1, 2, 3, 4, 5, 6].includes(id)), + ], cityIds: [ 3, 70, @@ -321,6 +331,7 @@ const buildRequest = ( ...(fixturePatches.randomFoundingCandidateCityIds ?? []).filter((id) => id !== 3 && id !== 70), ], nationIds: [1, 2], + ...(action === 'che_백성동원' ? { nationCooldowns: [{ nationId: 1, actionName: '백성동원' }] } : {}), logAfterId: 0, messageAfterId: 0, }, @@ -2701,3 +2712,255 @@ integration('nation event research turn, reserve, and duplicate-state boundaries 120_000 ); }); + +const mobilizePeopleBoundaryCases: Array<{ + name: string; + destCityId: unknown; + fixturePatches?: FixturePatches; + completed?: boolean; + coreResolution?: boolean; + expectedDefence?: number; + expectedWall?: number; + expectedStrategicCommandLimit?: number; + expectedPostReqTurn?: number; +}> = [ + { + name: 'rejects a missing destination city', + destCityId: 9999, + }, + { + name: 'accepts a numeric string destination city ID', + destCityId: '70', + completed: true, + expectedPostReqTurn: 63, + }, + { + name: 'rejects a source city occupied by another nation', + destCityId: 70, + fixturePatches: { cities: { 3: { nationId: 2 } } }, + }, + { + name: 'rejects a destination city occupied by another nation', + destCityId: 70, + fixturePatches: { cities: { 70: { nationId: 2 } } }, + }, + { + name: 'rejects an actor below chief rank', + destCityId: 70, + fixturePatches: { generals: { 1: { officerLevel: 4, officerCityId: 0 } } }, + coreResolution: false, + }, + { + name: 'rejects a remaining strategic-command delay', + destCityId: 70, + fixturePatches: { nations: { 1: { strategicCommandLimit: 1 } } }, + }, + { + name: 'allows an unsupplied source city', + destCityId: 70, + fixturePatches: { cities: { 3: { supplyState: 0 } } }, + completed: true, + expectedPostReqTurn: 63, + }, + { + name: 'allows an unsupplied destination city', + destCityId: 70, + fixturePatches: { cities: { 70: { supplyState: 0 } } }, + completed: true, + expectedPostReqTurn: 63, + }, + { + name: 'allows the actor city as destination', + destCityId: 3, + completed: true, + expectedPostReqTurn: 63, + }, + { + name: 'rounds the 80-percent defence and wall floors like MariaDB', + destCityId: 70, + fixturePatches: { + cities: { + 70: { + defence: 1_000, + defenceMax: 5_001, + wall: 1_000, + wallMax: 5_001, + }, + }, + }, + completed: true, + expectedDefence: 4_001, + expectedWall: 4_001, + expectedPostReqTurn: 63, + }, + { + name: 'preserves defence and wall already above their 80-percent floors', + destCityId: 70, + fixturePatches: { + cities: { + 70: { + defence: 4_500, + defenceMax: 5_000, + wall: 4_600, + wallMax: 5_000, + }, + }, + }, + completed: true, + expectedDefence: 4_500, + expectedWall: 4_600, + expectedPostReqTurn: 63, + }, + { + name: 'applies the strategist global-delay modifier', + destCityId: 70, + fixturePatches: { nations: { 1: { typeCode: 'che_종횡가' } } }, + completed: true, + expectedStrategicCommandLimit: 5, + expectedPostReqTurn: 47, + }, + { + name: 'uses the actual eleven-general count for the nation cooldown', + destCityId: 70, + fixturePatches: { + nations: { 1: { generalCount: 11 } }, + generals: { + 4: { nationId: 1 }, + 5: { nationId: 1 }, + 6: { nationId: 1 }, + 7: { nationId: 1 }, + 8: { nationId: 1 }, + 9: { nationId: 1 }, + 10: { nationId: 1 }, + 11: { nationId: 1 }, + 12: { nationId: 1 }, + }, + }, + completed: true, + expectedPostReqTurn: 66, + }, +]; + +integration('nation mobilize-people target, delay, and city-effect boundaries', () => { + it.each(mobilizePeopleBoundaryCases)( + '$name matches legacy fallback, cooldown, effects, logs, RNG, and semantic delta', + async ({ + destCityId, + fixturePatches, + completed = false, + coreResolution = true, + expectedDefence, + expectedWall, + expectedStrategicCommandLimit = 9, + expectedPostReqTurn, + }) => { + const request = buildRequest( + 'che_백성동원', + { destCityID: destCityId }, + { + ...fixturePatches, + cities: { + ...fixturePatches?.cities, + 70: { + nationId: 1, + ...fixturePatches?.cities?.[70], + }, + }, + } + ); + const reference = runReferenceTurnCommandTraceRequest( + workspaceRoot!, + request as unknown as Record + ); + const core = await runCoreTurnCommandTrace(request, reference.before); + const targetCityId = destCityId === 3 ? 3 : 70; + + expect(reference.execution.outcome).toMatchObject({ completed }); + if (coreResolution) { + expect(core.execution.outcome).toMatchObject({ + requestedAction: 'che_백성동원', + actionKey: completed ? 'che_백성동원' : '휴식', + usedFallback: !completed, + }); + } else { + expect(core.execution.outcome).toBeUndefined(); + } + + for (const snapshot of [reference, core]) { + const nationBefore = snapshot.before.nations.find((entry) => entry.id === 1); + const nationAfter = snapshot.after.nations.find((entry) => entry.id === 1); + const generalBefore = snapshot.before.generals.find((entry) => entry.id === 1); + const generalAfter = snapshot.after.generals.find((entry) => entry.id === 1); + const cityBefore = snapshot.before.cities.find((entry) => entry.id === targetCityId); + const cityAfter = snapshot.after.cities.find((entry) => entry.id === targetCityId); + + expect(readNationResource(nationBefore, 'gold') - readNationResource(nationAfter, 'gold')).toBe(0); + expect(readNationResource(nationBefore, 'rice') - readNationResource(nationAfter, 'rice')).toBe(0); + expect( + readNumericField(generalAfter, 'experience') - readNumericField(generalBefore, 'experience') + ).toBe(completed ? 5 : 0); + expect( + readNumericField(generalAfter, 'dedication') - readNumericField(generalBefore, 'dedication') + ).toBe(completed ? 5 : 0); + + if (completed) { + expect(readNumericField(nationAfter, 'strategicCommandLimit')).toBe(expectedStrategicCommandLimit); + if (expectedDefence !== undefined) { + expect(readNumericField(cityAfter, 'defence')).toBe(expectedDefence); + } + if (expectedWall !== undefined) { + expect(readNumericField(cityAfter, 'wall')).toBe(expectedWall); + } + const addedLogs = snapshot.after.logs.slice(snapshot.before.logs.length); + expect(addedLogs.map((entry) => entry.text)).toEqual( + expect.arrayContaining([expect.stringContaining('백성동원 발동')]) + ); + const broadcastFragment = '백성동원을 하였습니다.'; + expect( + addedLogs.some( + (entry) => entry.generalId === 3 && String(entry.text).includes(broadcastFragment) + ) + ).toBe(true); + expect( + addedLogs.some( + (entry) => entry.generalId === 2 && String(entry.text).includes(broadcastFragment) + ) + ).toBe(false); + expect(addedLogs.some((entry) => String(entry.text).includes('에 백성동원을 발동'))).toBe( + true + ); + } else { + expect(readNumericField(nationAfter, 'strategicCommandLimit')).toBe( + readNumericField(nationBefore, 'strategicCommandLimit') + ); + expect(readNumericField(cityAfter, 'defence')).toBe(readNumericField(cityBefore, 'defence')); + expect(readNumericField(cityAfter, 'wall')).toBe(readNumericField(cityBefore, 'wall')); + } + } + + if (completed) { + const expectedNextAvailableTurn = 190 * 12 + expectedPostReqTurn!; + if (expectedPostReqTurn === 66) { + expect(reference.before.generals.filter((entry) => entry.nationId === 1)).toHaveLength(11); + expect(core.before.generals.filter((entry) => entry.nationId === 1)).toHaveLength(11); + } + expect(reference.after.world.nationCooldowns).toEqual([ + { + nationId: 1, + actionName: '백성동원', + nextAvailableTurn: expectedNextAvailableTurn, + }, + ]); + expect(core.after.world.nationCooldowns).toEqual(reference.after.world.nationCooldowns); + } + expect(reference.rng).toEqual([]); + expect(core.rng).toEqual(reference.rng); + expect( + compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { + ignoredPathPatterns: ignoredLifecyclePaths, + }) + ).toEqual([]); + }, + 120_000 + ); +});