From 809de8ace640324a739923299429c9ff41b15a9f Mon Sep 17 00:00:00 2001 From: hided62 Date: Fri, 31 Jul 2026 15:28:16 +0000 Subject: [PATCH] fix(gateway): show legacy season states --- app/game-api/src/context.ts | 1 + app/game-api/src/router/lobby/index.ts | 2 +- app/game-api/src/router/public/index.ts | 2 +- app/game-api/test/directoryRouter.test.ts | 23 ++- app/game-api/test/lobbyRouter.test.ts | 39 +++++ .../src/turn/monthlyInvaderAction.ts | 3 +- .../test/monthlyInvaderAction.test.ts | 3 +- ...thlyInvaderPersistence.integration.test.ts | 6 +- .../e2e/lobby-game-auth.spec.ts | 154 ++++++++++++++++-- .../src/utils/serverSeasonStatus.ts | 32 ++++ app/gateway-frontend/src/views/HomeView.vue | 11 +- app/gateway-frontend/src/views/LobbyView.vue | 13 +- 12 files changed, 246 insertions(+), 43 deletions(-) create mode 100644 app/game-api/test/lobbyRouter.test.ts create mode 100644 app/gateway-frontend/src/utils/serverSeasonStatus.ts diff --git a/app/game-api/src/context.ts b/app/game-api/src/context.ts index 45a8306..dc09885 100644 --- a/app/game-api/src/context.ts +++ b/app/game-api/src/context.ts @@ -45,6 +45,7 @@ export const zWorldStateMeta = z.object({ turntime: z.string().optional(), otherTextInfo: z.string().optional(), isUnited: z.number().optional(), + isunited: z.number().optional(), autorun_user: z .object({ limit_minutes: z.number().optional(), diff --git a/app/game-api/src/router/lobby/index.ts b/app/game-api/src/router/lobby/index.ts index bde6e37..e6653e7 100644 --- a/app/game-api/src/router/lobby/index.ts +++ b/app/game-api/src/router/lobby/index.ts @@ -52,7 +52,7 @@ export const lobbyRouter = router({ opentime: worldState.meta.opentime ?? '', turntime: worldState.meta.turntime ?? '', otherTextInfo: worldState.meta.otherTextInfo ?? '', - isUnited: worldState.meta.isUnited ?? 0, + isUnited: worldState.meta.isunited ?? worldState.meta.isUnited ?? 0, selectionPoolEnabled: isSelectionPoolWorld(rawWorldState), npcPossessionEnabled: worldState.config.npcMode === 1, myGeneral, diff --git a/app/game-api/src/router/public/index.ts b/app/game-api/src/router/public/index.ts index b60c2a7..d252303 100644 --- a/app/game-api/src/router/public/index.ts +++ b/app/game-api/src/router/public/index.ts @@ -88,7 +88,7 @@ const loadWorldTrendSnapshot = async (ctx: GameApiContext): Promise } => { +const createContext = ( + options: { + auth?: GameSessionTokenPayload | null; + me?: GeneralRow | null; + isUnited?: number; + } = {} +): { context: GameApiContext; requestCommand: ReturnType } => { const token = options.auth === undefined ? auth() : options.auth; const me = options.me === undefined ? actor({ userId: token?.user.id ?? 'user-1' }) : options.me; const requestCommand = vi.fn(); @@ -280,12 +282,7 @@ describe('legacy global nation/general directories', () => { ambassadorNames: ['군주', '외교관'], auditorCount: 1, }); - expect(result[1]?.generals.map((general) => general.name)).toEqual([ - '군주', - '외교관', - '제재외교관', - '조언자', - ]); + expect(result[1]?.generals.map((general) => general.name)).toEqual(['군주', '외교관', '제재외교관', '조언자']); expect(result[1]?.officers[0]).toMatchObject({ officerLevel: 12, general: { id: 10, name: '군주' }, @@ -311,9 +308,9 @@ describe('legacy global nation/general directories', () => { expect(defaultResult.generals.find((general) => general.id === 10)?.ownerName).toBeNull(); }); - it('reveals only the legacy owner display name after unification', async () => { + it.each([1, 2, 3])('reveals only the legacy owner display name in united state %i', async (isUnited) => { const result = await appRouter - .createCaller(createContext({ isUnited: 1 }).context) + .createCaller(createContext({ isUnited }).context) .world.getGeneralDirectory({ sort: 1 }); expect(result.generals.find((general) => general.id === 10)?.ownerName).toBe('통일유저'); expect(JSON.stringify(result)).not.toContain('user-1'); diff --git a/app/game-api/test/lobbyRouter.test.ts b/app/game-api/test/lobbyRouter.test.ts new file mode 100644 index 0000000..3ba1b6f --- /dev/null +++ b/app/game-api/test/lobbyRouter.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { DatabaseClient, GameApiContext } from '../src/context.js'; +import { appRouter } from '../src/router.js'; + +const buildContext = (meta: Record): GameApiContext => + ({ + auth: null, + db: { + worldState: { + findFirst: vi.fn(async () => ({ + id: 1, + scenarioCode: 'default', + currentYear: 200, + currentMonth: 1, + tickSeconds: 3_600, + config: {}, + meta, + updatedAt: new Date('2026-07-31T00:00:00.000Z'), + })), + }, + general: { + count: vi.fn(async () => 0), + }, + nation: { + count: vi.fn(async () => 0), + }, + } as unknown as DatabaseClient, + }) as GameApiContext; + +describe('lobby season state', () => { + it.each([0, 1, 2, 3])('returns legacy isunited state %i', async (isunited) => { + const result = await appRouter + .createCaller(buildContext({ isUnited: isunited === 0 ? 2 : 0, isunited })) + .lobby.info(); + + expect(result.isUnited).toBe(isunited); + }); +}); diff --git a/app/game-engine/src/turn/monthlyInvaderAction.ts b/app/game-engine/src/turn/monthlyInvaderAction.ts index 68bfbbe..5a454bf 100644 --- a/app/game-engine/src/turn/monthlyInvaderAction.ts +++ b/app/game-engine/src/turn/monthlyInvaderAction.ts @@ -155,7 +155,7 @@ export const createRaiseInvaderHandler = (options: { } npcEachCount = Math.max(10, toInteger(npcEachCount)); - world.updateWorldMeta({ isunited: 1 }); + world.updateWorldMeta({ isunited: 1, isUnited: 1 }); const totalGeneralCount = npcEachCount * invaderCities.length + world.listGenerals().length; const maxGeneralsPerMinute = options.maxGeneralsPerMinute ?? readNumber(world.getState().meta.maxGeneralsPerMinute, 1_000); @@ -531,6 +531,7 @@ export const createInvaderEndingHandler = (options: { } world.updateWorldMeta({ isunited: 3, + isUnited: 3, refreshLimit: readNumber(meta.refreshLimit) * 100, }); world.removeEvent(environment.currentEventID); diff --git a/app/game-engine/test/monthlyInvaderAction.test.ts b/app/game-engine/test/monthlyInvaderAction.test.ts index 12f8f28..ae4e8ad 100644 --- a/app/game-engine/test/monthlyInvaderAction.test.ts +++ b/app/game-engine/test/monthlyInvaderAction.test.ts @@ -255,6 +255,7 @@ describe('invader monthly actions', () => { ]); expect(harness.world.getState().meta).toMatchObject({ isunited: 1, + isUnited: 1, block_change_scout: false, lastNationId: 10, }); @@ -363,7 +364,7 @@ describe('invader monthly actions', () => { await world.advanceMonth(new Date('0200-01-01T00:00:00.000Z')); - expect(world.getState().meta).toMatchObject({ isunited: 3, refreshLimit: 300 }); + expect(world.getState().meta).toMatchObject({ isunited: 3, isUnited: 3, refreshLimit: 300 }); expect(world.listEvents()).toHaveLength(0); expect(world.peekDirtyState().logs.map((log) => log.text)).toEqual([ '【이벤트】이민족을 모두 소탕했습니다!', diff --git a/app/game-engine/test/monthlyInvaderPersistence.integration.test.ts b/app/game-engine/test/monthlyInvaderPersistence.integration.test.ts index 3732691..3001ec2 100644 --- a/app/game-engine/test/monthlyInvaderPersistence.integration.test.ts +++ b/app/game-engine/test/monthlyInvaderPersistence.integration.test.ts @@ -443,7 +443,7 @@ integration('RaiseInvader database persistence', () => { }) ).toBe(3); expect(await db.worldState.findUniqueOrThrow({ where: { id: stateRow.id } })).toMatchObject({ - meta: expect.objectContaining({ isunited: 1, block_change_scout: false }), + meta: expect.objectContaining({ isunited: 1, isUnited: 1, block_change_scout: false }), }); if (referenceTrace) { expect(referenceTrace.phases.afterRaise).toMatchObject({ @@ -451,6 +451,7 @@ integration('RaiseInvader database persistence', () => { diplomacyCountsPerNation: [2], diplomacyStates: ['1:24'], isunited: 1, + isUnited: 1, blockChangeScout: false, }); } @@ -539,7 +540,7 @@ integration('RaiseInvader database persistence', () => { expect(await db.event.findUnique({ where: { id: createdEventIds[1]! } })).toBeNull(); expect(await db.worldState.findUniqueOrThrow({ where: { id: stateRow.id } })).toMatchObject({ - meta: expect.objectContaining({ isunited: 3, refreshLimit: 300 }), + meta: expect.objectContaining({ isunited: 3, isUnited: 3, refreshLimit: 300 }), }); expect( await db.logEntry.findMany({ @@ -556,6 +557,7 @@ integration('RaiseInvader database persistence', () => { result: 'Deleted', endingEventPresent: false, isunited: 3, + isUnited: 3, refreshLimit: 300, logs: [ '●200년 4월:【이벤트】이민족을 모두 소탕했습니다!', diff --git a/app/gateway-frontend/e2e/lobby-game-auth.spec.ts b/app/gateway-frontend/e2e/lobby-game-auth.spec.ts index aca9589..3592e7a 100644 --- a/app/gateway-frontend/e2e/lobby-game-auth.spec.ts +++ b/app/gateway-frontend/e2e/lobby-game-auth.spec.ts @@ -1,6 +1,14 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + import { expect, test, type Page, type Route } from '@playwright/test'; const response = (data: unknown) => ({ result: { data } }); +const artifactRoot = process.env.GATEWAY_STATUS_ARTIFACT_DIR; + +if (artifactRoot) { + mkdirSync(artifactRoot, { recursive: true }); +} const operationNames = (route: Route): string[] => { const url = new URL(route.request().url()); @@ -19,6 +27,7 @@ const fulfillTrpc = async (route: Route, results: unknown[]): Promise => { }; type LobbyFixtureOptions = { + authenticated?: boolean; canCreateGeneral?: boolean; myGeneral?: { name: string; @@ -29,10 +38,16 @@ type LobbyFixtureOptions = { npcPossessionEnabled?: boolean; userCnt?: number; maxUserCnt?: number; + nationCnt?: number; + isUnited?: number; + starttime?: string; + opentime?: string; + turntime?: string; }; const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) => { const { + authenticated = true, canCreateGeneral = true, myGeneral = { name: '선택장수', @@ -43,22 +58,33 @@ const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) => npcPossessionEnabled = false, userCnt = 1, maxUserCnt = 500, + nationCnt = 0, + isUnited = 0, + starttime = '2026-07-30 00:00:00', + opentime = '2026-07-30 00:00:00', + turntime = '2026-07-30 00:05:00', } = options; const gameOperations: Array<{ operation: string; authorization: string | undefined }> = []; - await page.addInitScript(() => { - window.localStorage.setItem('sammo-session-token', 'gateway-lobby-session'); - }); + if (authenticated) { + await page.addInitScript(() => { + window.localStorage.setItem('sammo-session-token', 'gateway-lobby-session'); + }); + } await page.route('**/gateway/api/trpc/**', async (route) => { const results = operationNames(route).map((operation) => { if (operation === 'me') { - return response({ - id: 'lobby-user', - username: 'lobby-user', - displayName: '로비사용자', - roles: ['user'], - kakaoVerified: true, - createdAt: '2026-07-30T00:00:00.000Z', - }); + return response( + authenticated + ? { + id: 'lobby-user', + username: 'lobby-user', + displayName: '로비사용자', + roles: ['user'], + kakaoVerified: true, + createdAt: '2026-07-30T00:00:00.000Z', + } + : null + ); } if (operation === 'lobby.notice') { return response(''); @@ -119,14 +145,14 @@ const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) => userCnt, maxUserCnt, npcCnt: 0, - nationCnt: 0, + nationCnt, turnTerm: 5, fictionMode: '가상', - starttime: '2026-07-30 00:00:00', - opentime: '2026-07-30 00:00:00', - turntime: '2026-07-30 00:05:00', + starttime, + opentime, + turntime, otherTextInfo: '', - isUnited: 0, + isUnited, selectionPoolEnabled, npcPossessionEnabled, myGeneral, @@ -231,3 +257,99 @@ test('shows registration closed instead of acquisition actions at the Ref capaci await expect(row.getByRole('button', { name: '장수생성' })).toHaveCount(0); await expect(row.getByRole('button', { name: '장수빙의' })).toHaveCount(0); }); + +for (const season of [ + { + name: 'competition', + isUnited: 0, + opentime: '2000-01-01 00:00:00', + label: '<4국 경쟁중>', + period: '2026-07-30 00:00:00 ~', + }, + { + name: 'preopen', + isUnited: 0, + opentime: '2099-01-01 00:00:00', + label: '-가오픈 중-', + period: '2026-07-30 00:00:00 ~', + }, + { + name: 'event running', + isUnited: 1, + opentime: '2099-01-01 00:00:00', + label: '§이벤트 진행중§', + period: '2026-07-30 00:00:00 ~', + }, + { + name: 'united', + isUnited: 2, + opentime: '2099-01-01 00:00:00', + label: '§천하통일§', + period: '2026-07-30 00:00:00\n~ 2026-07-30 00:05:00', + }, + { + name: 'event finished', + isUnited: 3, + opentime: '2099-01-01 00:00:00', + label: '§이벤트 종료§', + period: '2026-07-30 00:00:00\n~ 2026-07-30 00:05:00', + }, +] as const) { + test(`renders the Ref ${season.name} season status without changing entry actions`, async ({ page }) => { + await installFixture(page, { + isUnited: season.isUnited, + opentime: season.opentime, + nationCnt: 4, + }); + + await page.goto('lobby'); + const row = page.locator('tbody tr').filter({ hasText: 'hwe섭' }); + const status = row.getByText(season.label, { exact: true }); + await expect(status).toBeVisible(); + await expect(row.locator('td').first().locator('[title]')).toHaveAttribute('title', season.period); + await expect(row.getByRole('button', { name: '입장' })).toBeVisible(); + + if (artifactRoot) { + const [viewport, rowGeometry, statusGeometry] = await Promise.all([ + page.evaluate(() => ({ + width: window.innerWidth, + height: window.innerHeight, + devicePixelRatio: window.devicePixelRatio, + })), + row.evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; + }), + status.evaluate((element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + color: style.color, + fontFamily: style.fontFamily, + fontSize: style.fontSize, + lineHeight: style.lineHeight, + textAlign: style.textAlign, + }; + }), + ]); + const geometry = { viewport, row: rowGeometry, status: statusGeometry }; + const slug = season.name.replaceAll(' ', '-'); + writeFileSync(resolve(artifactRoot, `gateway-${slug}.json`), `${JSON.stringify(geometry, null, 2)}\n`); + await page.screenshot({ path: resolve(artifactRoot, `gateway-${slug}.png`), fullPage: true }); + } + }); +} + +test('renders the same Ref season status on the public gateway page', async ({ page }) => { + await installFixture(page, { authenticated: false, isUnited: 3, nationCnt: 4 }); + + await page.goto(''); + const status = page.locator('.season-status'); + await expect(status).toHaveText('§이벤트 종료§'); + await expect(status).toHaveAttribute('title', '2026-07-30 00:00:00\n~ 2026-07-30 00:05:00'); + await expect(page.getByRole('button', { name: '현황 새로고침' })).toBeEnabled(); +}); diff --git a/app/gateway-frontend/src/utils/serverSeasonStatus.ts b/app/gateway-frontend/src/utils/serverSeasonStatus.ts new file mode 100644 index 0000000..451a6cb --- /dev/null +++ b/app/gateway-frontend/src/utils/serverSeasonStatus.ts @@ -0,0 +1,32 @@ +export interface ServerSeasonStatusInput { + isUnited: number; + nationCnt: number; + opentime: string; + starttime: string; + turntime: string; +} + +export interface ServerSeasonStatus { + code: 'COMPETING' | 'PREOPEN' | 'EVENT_RUNNING' | 'UNITED' | 'EVENT_FINISHED'; + label: string; + period: string; +} + +export const resolveServerSeasonStatus = (info: ServerSeasonStatusInput, now = new Date()): ServerSeasonStatus => { + const finishedPeriod = `${info.starttime}\n~ ${info.turntime}`; + if (info.isUnited === 3) { + return { code: 'EVENT_FINISHED', label: '§이벤트 종료§', period: finishedPeriod }; + } + if (info.isUnited === 1) { + return { code: 'EVENT_RUNNING', label: '§이벤트 진행중§', period: `${info.starttime} ~` }; + } + if (info.isUnited === 2) { + return { code: 'UNITED', label: '§천하통일§', period: finishedPeriod }; + } + + const openAt = new Date(info.opentime); + if (Number.isFinite(openAt.getTime()) && openAt.getTime() > now.getTime()) { + return { code: 'PREOPEN', label: '-가오픈 중-', period: `${info.starttime} ~` }; + } + return { code: 'COMPETING', label: `<${info.nationCnt}국 경쟁중>`, period: `${info.starttime} ~` }; +}; diff --git a/app/gateway-frontend/src/views/HomeView.vue b/app/gateway-frontend/src/views/HomeView.vue index 1aa8e3f..7fcb4d0 100644 --- a/app/gateway-frontend/src/views/HomeView.vue +++ b/app/gateway-frontend/src/views/HomeView.vue @@ -10,6 +10,7 @@ import { createGameTrpc, type GameRouter } from '../utils/gameTrpc'; import { trpc } from '../utils/trpc'; import { formatLog } from '../utils/formatLog'; import { sealPassword } from '../utils/passwordEnvelope'; +import { resolveServerSeasonStatus } from '../utils/serverSeasonStatus'; type GatewayOutput = inferRouterOutputs; type GameOutput = inferRouterOutputs; @@ -37,6 +38,7 @@ const dateText = computed(() => { } return `西紀 ${info.value.year}年 ${info.value.month}月`; }); +const seasonStatus = computed(() => (info.value ? resolveServerSeasonStatus(info.value) : null)); const loadPublicStatus = async (): Promise => { statusLoading.value = true; @@ -153,9 +155,7 @@ const handlePasswordReset = async (): Promise => { - + @@ -174,7 +174,10 @@ const handlePasswordReset = async (): Promise => {
  • 서버: {{ profile?.korName }} / 시나리오: {{ profile?.scenario }}
  • -
  • 유저 {{ info.userCnt }}명 · NPC {{ info.npcCnt }}명 · {{ info.nationCnt }}국 경쟁중
  • +
  • + 유저 {{ info.userCnt }}명 · NPC {{ info.npcCnt }}명 · + {{ seasonStatus?.label }} +
  • {{ info.turnTerm }}분 턴 서버
diff --git a/app/gateway-frontend/src/views/LobbyView.vue b/app/gateway-frontend/src/views/LobbyView.vue index 1af4a12..6618434 100644 --- a/app/gateway-frontend/src/views/LobbyView.vue +++ b/app/gateway-frontend/src/views/LobbyView.vue @@ -8,6 +8,7 @@ import MapPreview from '../components/MapPreview.vue'; import { trpc } from '../utils/trpc'; import { createGameTrpc } from '../utils/gameTrpc'; import type { GameRouter } from '../utils/gameTrpc'; +import { resolveServerSeasonStatus } from '../utils/serverSeasonStatus'; type GatewayRouterOutput = inferRouterOutputs; type GameRouterOutput = inferRouterOutputs; @@ -43,6 +44,7 @@ const userIconBaseUrl = import.meta.env.VITE_GATEWAY_USER_ICON_BASE_URL ?? '/gat const formatGraceEndsAt = (value: string | null | undefined): string => value ? new Date(value).toLocaleString('ko-KR') : ''; +const serverSeasonStatus = (info: LobbyInfo) => resolveServerSeasonStatus(info); const encodeLegacyIconPath = (value: string): string => value .split('/') @@ -273,15 +275,18 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => { :style="{ color: profile.color }" class="text-lg font-bold cursor-help" :title=" - profileDetails[profile.profileName]?.starttime - ? `시작일: ${profileDetails[profile.profileName]?.starttime}` + profileDetails[profile.profileName] + ? serverSeasonStatus(profileDetails[profile.profileName]!).period : '' " > {{ profile.korName }}섭
-
- <{{ profileDetails[profile.profileName]?.nationCnt }}국 경쟁중> +
+ {{ serverSeasonStatus(profileDetails[profile.profileName]!).label }}