From 3a52bb7830d9e7dcb880214cca5410ea360d71b2 Mon Sep 17 00:00:00 2001 From: hided62 Date: Mon, 17 Aug 2026 15:32:48 +0000 Subject: [PATCH 1/5] =?UTF-8?q?fix:=20=EB=A9=94=EC=9D=B8=20=EA=B0=B1?= =?UTF-8?q?=EC=8B=A0=20=EC=A4=91=20=ED=81=B4=EB=A6=AD=EC=9D=84=20toast?= =?UTF-8?q?=EB=A1=9C=20=EC=95=88=EB=82=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 실시간 또는 수동 갱신 중에도 직접 갱신 버튼의 활성 시각 상태를 유지한다. 중복 클릭은 추가 조회 없이 공통 안내 toast로 전달하고 데스크톱·모바일 Chromium 회귀 검증을 보강한다. --- app/game-frontend/e2e/mainNavigation.spec.ts | 26 +++++++++++++++++-- .../components/main/MainMobileBottomBar.vue | 7 ----- app/game-frontend/src/views/MainView.vue | 15 ++++++++--- 3 files changed, 36 insertions(+), 12 deletions(-) diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index 57383be0..ee9cbfc4 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -2107,16 +2107,24 @@ test('mobile bottom controls share Ref pressed geometry while their color bases await expect .poll(() => page.locator(manualRefreshSelector).evaluate((element) => getComputedStyle(element).boxShadow)) .not.toBe('none'); + const callsBeforeManualRefresh = state.generalMeCalls; await page.locator(manualRefreshSelector).click(); - await expect(page.locator(manualRefreshSelector)).toBeDisabled(); + await expect(page.locator(manualRefreshSelector)).toBeEnabled(); + await expect(page.locator(manualRefreshSelector)).toHaveAttribute('aria-busy', 'true'); + await page.locator(manualRefreshSelector).click(); + await expect(page.getByTestId('game-toast')).toContainText('이미 정보를 갱신하고 있습니다.'); + await page.mouse.move(1, 1); expect(await buttonStyle(manualRefreshSelector)).toMatchObject({ backgroundColor: 'rgb(33, 37, 41)', borderBottomWidth: '4px', marginTop: '0px', height: 45, }); - await expect(page.locator(manualRefreshSelector)).toHaveCSS('opacity', '0.55'); + await expect(page.locator(manualRefreshSelector)).toHaveCSS('opacity', '1'); + await page.screenshot({ path: testInfo.outputPath('mobile-refresh-busy-toast.png'), fullPage: true }); + await expect(page.locator(manualRefreshSelector)).toHaveAttribute('aria-busy', 'false'); await expect(page.locator(manualRefreshSelector)).toBeEnabled(); + expect(state.generalMeCalls).toBe(callsBeforeManualRefresh + 1); await persistArtifact(page, `${basePath.slice(1)}-mobile-bottom-ref-buttons`); }); @@ -2685,6 +2693,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl const operationsBeforeChangedBurst = state.operations.length; state.generalName = '부드럽게갱신된장수'; + state.refreshDelayMs = 1_000; await page.evaluate(() => { const emit = (window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }) .__emitMainRealtime; @@ -2706,7 +2715,20 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl } }); + const manualRefresh = page.getByRole('button', { name: '갱 신' }); + await expect(manualRefresh).toHaveAttribute('aria-busy', 'true'); + await expect(manualRefresh).toBeEnabled(); + await manualRefresh.click(); + await expect(page.getByTestId('game-toast')).toContainText('이미 정보를 갱신하고 있습니다.'); + if (autoRefreshArtifactRoot) { + await page.screenshot({ + path: resolve(autoRefreshArtifactRoot, 'auto-refresh-busy-feedback.png'), + fullPage: true, + }); + } + await expect.poll(() => state.generalMeCalls, { timeout: 3_000 }).toBe(callsBeforeRefresh + 1); + state.refreshDelayMs = 300; await expect(page.locator('[data-main-target="general"] .skeleton-line')).toHaveCount(0); await expect(page.locator('[data-main-target="city"] .skeleton-line')).toHaveCount(0); await expect(page.getByRole('button', { name: '갱 신' })).toHaveAttribute('aria-busy', 'false'); diff --git a/app/game-frontend/src/components/main/MainMobileBottomBar.vue b/app/game-frontend/src/components/main/MainMobileBottomBar.vue index 11deec41..f3de99be 100644 --- a/app/game-frontend/src/components/main/MainMobileBottomBar.vue +++ b/app/game-frontend/src/components/main/MainMobileBottomBar.vue @@ -226,7 +226,6 @@ const onQuick = (item: QuickNavigationItem) => { data-bottom-menu="manual-refresh" aria-label="직접 갱신" title="직접 갱신" - :disabled="refreshing" :aria-busy="refreshing" @click="emit('refresh')" > @@ -306,12 +305,6 @@ const onQuick = (item: QuickNavigationItem) => { line-height: 1; } -.manual-refresh-trigger:disabled { - cursor: wait; - filter: grayscale(0.6); - opacity: 0.55; -} - .bottom-trigger.legacy-button:not(:disabled, [aria-disabled='true']):hover, .bottom-trigger.legacy-button:not(:disabled, [aria-disabled='true'])[aria-expanded='true'] { height: 44px; diff --git a/app/game-frontend/src/views/MainView.vue b/app/game-frontend/src/views/MainView.vue index 1e4daab3..3ab408fe 100644 --- a/app/game-frontend/src/views/MainView.vue +++ b/app/game-frontend/src/views/MainView.vue @@ -20,11 +20,13 @@ import type { QuickNavigationItem } from '../components/main/mainNavigation'; import { formatLog } from '../utils/formatLog'; import { useSessionStore } from '../stores/session'; import { useMainDashboardStore } from '../stores/mainDashboard'; +import { useGameFeedback } from '../composables/useGameFeedback'; import { trpc } from '../utils/trpc'; import type { CommandPatternEntry } from '../components/command/types'; const session = useSessionStore(); const dashboard = useMainDashboardStore(); +const { info: showInfoToast } = useGameFeedback(); const isMobile = useMediaQuery('(max-width: 939.98px)'); const npcMode = ref(0); @@ -115,6 +117,14 @@ const loadMainData = async () => { npcMode.value = worldState?.config.npcMode ?? 0; }; +const requestManualRefresh = () => { + if (refreshing.value) { + showInfoToast('이미 정보를 갱신하고 있습니다.'); + return; + } + void loadMainData(); +}; + const moveLobby = () => { window.location.replace(import.meta.env.VITE_GATEWAY_WEB_URL?.trim() || '/gateway/'); }; @@ -154,9 +164,8 @@ watch( @@ -451,7 +460,7 @@ watch( :npc-mode="npcMode" :realtime-enabled="realtimeEnabled" :refreshing="refreshing" - @refresh="loadMainData" + @refresh="requestManualRefresh" @toggle-realtime="dashboard.setRealtimeEnabled(!realtimeEnabled)" @lobby="moveLobby" @quick="moveQuick" From 39cc52a80d7fef1d428beb6207eceed82927603a Mon Sep 17 00:00:00 2001 From: hided62 Date: Mon, 17 Aug 2026 15:51:42 +0000 Subject: [PATCH 2/5] =?UTF-8?q?fix(release):=20=EC=A0=91=EC=86=8D=20?= =?UTF-8?q?=EB=B0=B0=EC=B9=98=20migration=20head=EB=A5=BC=20=EB=A6=B4?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=EC=97=90=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 새 general_access_batch migration과 release manifest의 game schema head를 맞추고 정합성 회귀 기대값을 갱신한다. --- app/gateway-api/test/releaseManifest.test.ts | 2 +- release-manifest.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/gateway-api/test/releaseManifest.test.ts b/app/gateway-api/test/releaseManifest.test.ts index b707a5d7..c006a405 100644 --- a/app/gateway-api/test/releaseManifest.test.ts +++ b/app/gateway-api/test/releaseManifest.test.ts @@ -39,7 +39,7 @@ describe('readReleaseManifest', () => { await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({ controllerProtocol: RELEASE_CONTROLLER_PROTOCOL, gatewaySchemaHead: '20260813000000_split_gateway_profile_identity', - gameSchemaHead: '20260816000000_add_read_model_change_journal', + gameSchemaHead: '20260817000000_add_general_access_batch', }); }); diff --git a/release-manifest.json b/release-manifest.json index e5fda16b..e9fda898 100644 --- a/release-manifest.json +++ b/release-manifest.json @@ -2,6 +2,6 @@ "formatVersion": 1, "controllerProtocol": 2, "gatewaySchemaHead": "20260813000000_split_gateway_profile_identity", - "gameSchemaHead": "20260816000000_add_read_model_change_journal", + "gameSchemaHead": "20260817000000_add_general_access_batch", "components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"] } From fc7de05017d7865277efcd8b88a40a2e6e4969f8 Mon Sep 17 00:00:00 2001 From: hided62 Date: Mon, 17 Aug 2026 15:55:32 +0000 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20=EB=A0=88=EA=B1=B0=EC=8B=9C=20?= =?UTF-8?q?=EC=9E=AC=EC=9D=B4=EA=B4=80=EA=B3=BC=20=EA=B3=84=EC=A0=95=20?= =?UTF-8?q?=EB=B3=B5=EA=B5=AC=20=EA=B8=B0=EB=B0=98=EC=9D=84=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 중앙 이전 기록 스키마와 버전 정규화기를 도입하고, 재실행 시 현행 계정 상태를 보존한다. 카카오 인증 뒤 이관 비밀번호를 1회 설정하는 흐름과 비카카오 계정용 안전한 CLI 복구 경로를 추가한다. --- .../src/auth/inMemoryUserRepository.ts | 3 + .../src/auth/localAccountPolicy.ts | 17 +- app/gateway-api/src/auth/oauthSessionStore.ts | 15 +- .../src/auth/postgresUserRepository.ts | 6 + app/gateway-api/src/auth/userRepository.ts | 3 +- app/gateway-api/src/router.ts | 105 +++++- app/gateway-api/test/authFlow.test.ts | 116 ++++++- .../test/localAccountPolicy.test.ts | 20 ++ .../test/oauthSessionStore.test.ts | 6 +- .../test/passwordCredential.test.ts | 4 + app/gateway-api/test/releaseManifest.test.ts | 10 +- .../e2e/kakao-account-recovery.spec.ts | 62 ++++ .../src/views/OAuthCallbackView.vue | 94 ++++- docs/legacy-db-migration.md | 117 +++++-- packages/common/src/index.ts | 1 + .../legacyArchive/ArchivedGeneralSnapshot.ts | 321 ++++++++++++++++++ .../migration.sql | 6 + packages/infra/prisma/gateway.prisma | 1 + .../migration.sql | 125 +++++++ pnpm-lock.yaml | 3 + release-manifest.json | 4 +- tools/legacy-db-migration/README.md | 28 +- tools/legacy-db-migration/package.json | 1 + tools/legacy-db-migration/src/cli.ts | 16 +- tools/legacy-db-migration/src/db.ts | 16 +- tools/legacy-db-migration/src/game.ts | 222 ++++++++---- tools/legacy-db-migration/src/gateway.ts | 108 +++++- .../test/archiveGeneral.test.ts | 71 ++++ tools/legacy-db-migration/test/db.test.ts | 24 ++ .../che-old-general-legacy-flat-v0.json | 50 +++ .../fixtures/hwe-old-general-ref-flat-v1.json | 36 ++ tools/legacy-db-migration/test/game.test.ts | 146 ++++++++ .../legacy-db-migration/test/gateway.test.ts | 113 ++++++ 33 files changed, 1720 insertions(+), 150 deletions(-) create mode 100644 packages/common/src/legacyArchive/ArchivedGeneralSnapshot.ts create mode 100644 packages/infra/prisma/gateway-migrations/20260817000000_add_password_reset_required/migration.sql create mode 100644 packages/infra/prisma/migrations/20260817001000_add_dedicated_legacy_archive/migration.sql create mode 100644 tools/legacy-db-migration/test/archiveGeneral.test.ts create mode 100644 tools/legacy-db-migration/test/db.test.ts create mode 100644 tools/legacy-db-migration/test/fixtures/che-old-general-legacy-flat-v0.json create mode 100644 tools/legacy-db-migration/test/fixtures/hwe-old-general-ref-flat-v1.json create mode 100644 tools/legacy-db-migration/test/game.test.ts create mode 100644 tools/legacy-db-migration/test/gateway.test.ts diff --git a/app/gateway-api/src/auth/inMemoryUserRepository.ts b/app/gateway-api/src/auth/inMemoryUserRepository.ts index 87adb3ea..ab4779fd 100644 --- a/app/gateway-api/src/auth/inMemoryUserRepository.ts +++ b/app/gateway-api/src/auth/inMemoryUserRepository.ts @@ -133,6 +133,7 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass kakaoGraceStartedAt: now.toISOString(), passwordSalt: password.salt, passwordHash: password.hash, + passwordResetRequired: false, createdAt: now.toISOString(), }; usersByName.set(input.username, user); @@ -150,6 +151,7 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass const upgraded = await hasher.hash(password); user.passwordSalt = upgraded.salt; user.passwordHash = upgraded.hash; + user.passwordResetRequired = false; } return verified.ok; }, @@ -159,6 +161,7 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass const next = await hasher.hash(password); user.passwordSalt = next.salt; user.passwordHash = next.hash; + user.passwordResetRequired = false; return; } } diff --git a/app/gateway-api/src/auth/localAccountPolicy.ts b/app/gateway-api/src/auth/localAccountPolicy.ts index d1f81476..7d26d42e 100644 --- a/app/gateway-api/src/auth/localAccountPolicy.ts +++ b/app/gateway-api/src/auth/localAccountPolicy.ts @@ -35,7 +35,9 @@ export const hasActiveSpecialAccountGrant = ( grants: readonly SpecialAccountAccessGrantRecord[], now: Date = new Date() ): boolean => - grants.some((grant) => !grant.revokedAt && (!grant.expiresAt || new Date(grant.expiresAt).getTime() > now.getTime())); + grants.some( + (grant) => !grant.revokedAt && (!grant.expiresAt || new Date(grant.expiresAt).getTime() > now.getTime()) + ); const appliesToProfile = (grant: SpecialAccountAccessGrantRecord, profile: string, profileName: string): boolean => grant.profiles.length === 0 || grant.profiles.includes(profile) || grant.profiles.includes(profileName); @@ -67,9 +69,7 @@ const resolveSpecialAccess = (options: { const selected = active.find((grant) => grant.allowsGeneralCreation) ?? active[0]!; const expiresAt = active.some((grant) => !grant.expiresAt) ? null - : active - .map((grant) => grant.expiresAt!) - .sort((left, right) => right.localeCompare(left))[0] ?? null; + : (active.map((grant) => grant.expiresAt!).sort((left, right) => right.localeCompare(left))[0] ?? null); return { kind: selected.kind, grantId: selected.id, @@ -98,7 +98,10 @@ export const resolveLocalAccountProfilePolicy = (options: { 'localAccountGeneralCreationGraceDays', generalCreationDefault ); - const kakaoVerified = options.user.oauthType === 'KAKAO' && Boolean(options.user.kakaoVerifiedAt); + const kakaoVerified = + options.user.oauthType === 'KAKAO' && + Boolean(options.user.oauthId?.trim()) && + Boolean(options.user.kakaoVerifiedAt); const graceStartedAt = new Date(options.user.kakaoGraceStartedAt); const now = options.now ?? new Date(); const specialAccess = resolveSpecialAccess({ @@ -116,7 +119,9 @@ export const resolveLocalAccountProfilePolicy = (options: { const generalCreationEndsAt = new Date(graceStartedAt.getTime() + generalCreationGraceDays * DAY_MS); const accessAllowed = kakaoVerified || specialAccess !== null || now < accessEndsAt; const canCreateGeneral = - kakaoVerified || specialAccess?.allowsGeneralCreation === true || (accessAllowed && now < generalCreationEndsAt); + kakaoVerified || + specialAccess?.allowsGeneralCreation === true || + (accessAllowed && now < generalCreationEndsAt); return { requiresKakaoVerification: !kakaoVerified && specialAccess === null, diff --git a/app/gateway-api/src/auth/oauthSessionStore.ts b/app/gateway-api/src/auth/oauthSessionStore.ts index 3a3c3bfb..8dbe6a74 100644 --- a/app/gateway-api/src/auth/oauthSessionStore.ts +++ b/app/gateway-api/src/auth/oauthSessionStore.ts @@ -14,7 +14,7 @@ export interface OAuthPendingState { export interface OAuthSession { id: string; mode: OAuthMode; - intent?: 'register' | 'link_existing' | 'rejoin'; + intent?: 'register' | 'link_existing' | 'rejoin' | 'password_setup'; targetUserId?: string; kakaoId: string; email: string; @@ -93,6 +93,14 @@ end return cjson.encode({ status = 'verified', userId = challenge.userId }) `; +const consumeOnceScript = ` +local raw = redis.call('GET', KEYS[1]) +if raw then + redis.call('DEL', KEYS[1]) +end +return raw +`; + export class RedisOAuthSessionStore implements OAuthSessionStore { private readonly client: RedisClientLike; private readonly prefix: string; @@ -161,12 +169,11 @@ export class RedisOAuthSessionStore implements OAuthSessionStore { async consumeSession(sessionId: string): Promise { const key = this.sessionKey(sessionId); - const raw = await this.client.get(key); + const raw = await this.client.eval(consumeOnceScript, { keys: [key], arguments: [] }); if (!raw) { return null; } - await this.client.del(key); - return parseJson(raw); + return typeof raw === 'string' ? parseJson(raw) : null; } async getLoginChallengeForUser(userId: string): Promise { diff --git a/app/gateway-api/src/auth/postgresUserRepository.ts b/app/gateway-api/src/auth/postgresUserRepository.ts index 606b9741..d9c6a123 100644 --- a/app/gateway-api/src/auth/postgresUserRepository.ts +++ b/app/gateway-api/src/auth/postgresUserRepository.ts @@ -59,6 +59,7 @@ const mapUser = (row: { displayName: string; passwordHash: string; passwordSalt: string; + passwordResetRequired: boolean; roles: GatewayPrisma.JsonValue; sanctions: GatewayPrisma.JsonValue; oauthType: 'NONE' | 'KAKAO'; @@ -107,6 +108,7 @@ const mapUser = (row: { deleteAfter: row.deleteAfter?.toISOString(), passwordHash: row.passwordHash, passwordSalt: row.passwordSalt, + passwordResetRequired: row.passwordResetRequired, createdAt: row.createdAt.toISOString(), legacyMemberNo: readLegacyMemberNo(row.legacyData), legacyGrade: readLegacyGrade(row.legacyData), @@ -250,6 +252,7 @@ export const createPostgresUserRepository = ( displayName: input.displayName ?? input.username, passwordHash: password.hash, passwordSalt: password.salt, + passwordResetRequired: false, roles: ['user'] satisfies GatewayPrisma.JsonArray, sanctions: {} satisfies GatewayPrisma.JsonObject, oauthType, @@ -274,10 +277,12 @@ export const createPostgresUserRepository = ( data: { passwordHash: upgraded.hash, passwordSalt: upgraded.salt, + passwordResetRequired: false, }, }); user.passwordHash = upgraded.hash; user.passwordSalt = upgraded.salt; + user.passwordResetRequired = false; } return verified.ok; }, @@ -288,6 +293,7 @@ export const createPostgresUserRepository = ( data: { passwordHash: next.hash, passwordSalt: next.salt, + passwordResetRequired: false, }, }); }, diff --git a/app/gateway-api/src/auth/userRepository.ts b/app/gateway-api/src/auth/userRepository.ts index a8076005..796b81c9 100644 --- a/app/gateway-api/src/auth/userRepository.ts +++ b/app/gateway-api/src/auth/userRepository.ts @@ -24,6 +24,7 @@ export interface UserRecord { deleteAfter?: string; passwordHash: string; passwordSalt: string; + passwordResetRequired: boolean; createdAt: string; legacyMemberNo?: number; legacyGrade?: number; @@ -128,7 +129,7 @@ export const toPublicUser = (user: UserRecord): PublicUser => ({ displayName: user.displayName, roles: user.roles, picture: user.picture, - kakaoVerified: user.oauthType === 'KAKAO' && Boolean(user.kakaoVerifiedAt), + kakaoVerified: user.oauthType === 'KAKAO' && Boolean(user.oauthId?.trim()) && Boolean(user.kakaoVerifiedAt), kakaoGraceStartedAt: user.kakaoGraceStartedAt, createdAt: user.createdAt, }); diff --git a/app/gateway-api/src/router.ts b/app/gateway-api/src/router.ts index 80acea61..9d72138f 100644 --- a/app/gateway-api/src/router.ts +++ b/app/gateway-api/src/router.ts @@ -91,6 +91,42 @@ const finishKakaoLogin = async ( }; }; +const finishKakaoLoginOrRequestPasswordSetup = async ( + ctx: GatewayApiContext, + user: UserRecord, + accessToken: string, + successStatus: T +) => { + if (!user.passwordResetRequired) { + return finishKakaoLogin(ctx, user, accessToken, successStatus); + } + if (user.oauthType !== 'KAKAO' || !user.oauthId || !user.email) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: '카카오 계정 연결 정보가 올바르지 않아 비밀번호를 설정할 수 없습니다.', + }); + } + const oauthInfo = user.oauthInfo ?? {}; + const passwordSetup = await ctx.oauthSessions.createSession({ + mode: successStatus === 'verified' ? 'verify' : 'login', + intent: 'password_setup', + targetUserId: user.id, + kakaoId: user.oauthId, + email: user.email, + accessToken, + refreshToken: oauthInfo.refreshToken, + accessTokenValidUntil: oauthInfo.accessTokenValidUntil ?? new Date().toISOString(), + refreshTokenValidUntil: oauthInfo.refreshTokenValidUntil, + createdAt: new Date().toISOString(), + }); + return { + status: 'password_setup' as const, + oauthSessionId: passwordSetup.id, + email: passwordSetup.email, + successStatus, + }; +}; + export const appRouter = router({ health: router({ ping: procedure.query(() => ({ @@ -348,7 +384,7 @@ export const appRouter = router({ } const refreshed = (await ctx.users.findById(verified.id)) ?? verified; await ctx.flushPublisher.publishUserFlush(refreshed.id, 'kakao-verified'); - return finishKakaoLogin(ctx, refreshed, token.accessToken, 'verified'); + return finishKakaoLoginOrRequestPasswordSetup(ctx, refreshed, token.accessToken, 'verified'); } if (pending.mode === 'change_pw') { @@ -415,7 +451,7 @@ export const appRouter = router({ cause: error, }); } - return finishKakaoLogin(ctx, synced, token.accessToken, 'login'); + return finishKakaoLoginOrRequestPasswordSetup(ctx, synced, token.accessToken, 'login'); } const joinOauthInfo = oauthInfoFromToken(token, tokenIssuedAt); @@ -562,7 +598,70 @@ export const appRouter = router({ }); } await ctx.flushPublisher.publishUserFlush(linked.id, 'kakao-account-relinked'); - return finishKakaoLogin(ctx, linked, oauthSession.accessToken, 'login'); + return finishKakaoLoginOrRequestPasswordSetup(ctx, linked, oauthSession.accessToken, 'login'); + }), + kakaoSetPassword: procedure + .input( + z.object({ + oauthSessionId: z.string().uuid(), + credential: zPasswordEnvelope, + }) + ) + .mutation(async ({ ctx, input }) => { + const password = openPassword(ctx.passwordEnvelope, input.credential); + const oauthSession = await ctx.oauthSessions.consumeSession(input.oauthSessionId); + if (!oauthSession || oauthSession.intent !== 'password_setup' || !oauthSession.targetUserId) { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: '비밀번호 설정 세션이 만료되었습니다. 카카오 로그인을 다시 진행해 주세요.', + }); + } + const user = await ctx.users.findById(oauthSession.targetUserId); + if ( + !user || + user.oauthType !== 'KAKAO' || + user.oauthId !== oauthSession.kakaoId || + user.email?.toLowerCase() !== oauthSession.email.toLowerCase() || + !user.passwordResetRequired + ) { + throw new TRPCError({ + code: 'CONFLICT', + message: '카카오 계정 연결 상태가 변경되었습니다. 처음부터 다시 진행해 주세요.', + }); + } + if (user.deleteAfter) { + throw new TRPCError({ code: 'FORBIDDEN', message: 'Account deletion is pending.' }); + } + if (isLoginBanned(user.sanctions)) { + throw new TRPCError({ code: 'FORBIDDEN', message: 'Account login is blocked.' }); + } + let verifiedProfile; + try { + verifiedProfile = readVerifiedKakaoProfile(await ctx.kakaoClient.getMe(oauthSession.accessToken)); + } catch (error) { + return throwKakaoVerificationError(error); + } + if ( + verifiedProfile.kakaoId !== oauthSession.kakaoId || + verifiedProfile.email !== oauthSession.email.toLowerCase() + ) { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: '카카오 계정 정보가 비밀번호 설정 세션과 일치하지 않습니다.', + }); + } + await ctx.users.updatePassword(user.id, password); + const refreshed = await ctx.users.findById(user.id); + if (!refreshed) { + throw new TRPCError({ code: 'NOT_FOUND', message: '계정을 찾지 못했습니다.' }); + } + await ctx.flushPublisher.publishUserFlush(refreshed.id, 'password-changed'); + return finishKakaoLogin( + ctx, + refreshed, + oauthSession.accessToken, + oauthSession.mode === 'verify' ? 'verified' : 'login' + ); }), register: procedure .input( diff --git a/app/gateway-api/test/authFlow.test.ts b/app/gateway-api/test/authFlow.test.ts index f10c0dba..660f15de 100644 --- a/app/gateway-api/test/authFlow.test.ts +++ b/app/gateway-api/test/authFlow.test.ts @@ -631,7 +631,7 @@ describe('gateway auth flow', () => { }); it('asks before relinking a new Kakao identity to the permanently retained email owner', async () => { - const { caller, users, kakaoProfile, sentTalkMessages, flushPublisher } = buildCaller(); + const { caller, users, kakaoProfile, sealPassword, sentTalkMessages, flushPublisher } = buildCaller(); const emailOwner = await users.createUser({ username: 'email-owner', password: 'owner-password', @@ -642,6 +642,7 @@ describe('gateway auth flow', () => { info: {}, }, }); + emailOwner.passwordResetRequired = true; await users.markKakaoTalkVerified(emailOwner.id, new Date(Date.now() + 60_000)); kakaoProfile.id = 'different-kakao-id'; @@ -659,7 +660,14 @@ describe('gateway auth flow', () => { oauthSessionId: recovery.oauthSessionId, action: 'link_existing', }); - expect(linked.status).toBe('otp'); + expect(linked.status).toBe('password_setup'); + if (linked.status !== 'password_setup') throw new Error('Expected migrated password setup.'); + expect(sentTalkMessages).toHaveLength(0); + const passwordSet = await caller.auth.kakaoSetPassword({ + oauthSessionId: linked.oauthSessionId, + credential: sealPassword('replacement-password'), + }); + expect(passwordSet.status).toBe('otp'); expect(sentTalkMessages).toHaveLength(1); expect(await users.findByOauthId('KAKAO', 'original-kakao-id')).toBeNull(); expect(await users.findByOauthId('KAKAO', 'different-kakao-id')).toMatchObject({ @@ -668,6 +676,108 @@ describe('gateway auth flow', () => { email: 'tester@example.com', }); expect(flushPublisher.publishUserFlush).toHaveBeenCalledWith(emailOwner.id, 'kakao-account-relinked'); + expect(flushPublisher.publishUserFlush).toHaveBeenCalledWith(emailOwner.id, 'password-changed'); + }); + + it('requires a one-time password setup before an imported Kakao account can receive a session', async () => { + const { caller, users, sessions, kakaoProfile, sealPassword, sentTalkMessages } = buildCaller({ + kakaoId: 'imported-kakao-id', + kakaoEmail: 'imported@example.com', + }); + const user = await users.createUser({ + username: 'imported-kakao-user', + password: 'legacy-password', + oauth: { + type: 'KAKAO', + id: kakaoProfile.id, + email: kakaoProfile.email, + info: {}, + }, + }); + user.passwordResetRequired = true; + const createSession = vi.spyOn(sessions, 'createSession'); + + const start = await caller.auth.kakaoStart({ mode: 'login' }); + const login = await caller.auth.kakaoExchange({ code: 'oauth-code', state: start.state }); + expect(login).toMatchObject({ + status: 'password_setup', + email: 'imported@example.com', + successStatus: 'login', + }); + if (login.status !== 'password_setup') throw new Error('Expected migrated password setup.'); + expect(login).not.toHaveProperty('sessionToken'); + expect(createSession).not.toHaveBeenCalled(); + expect(sentTalkMessages).toHaveLength(0); + + const setup = await caller.auth.kakaoSetPassword({ + oauthSessionId: login.oauthSessionId, + credential: sealPassword('new-imported-password'), + }); + expect(setup.status).toBe('otp'); + expect((await users.findById(user.id))?.passwordResetRequired).toBe(false); + expect(await users.verifyPassword(user, 'new-imported-password')).toBe(true); + await expect( + caller.auth.kakaoSetPassword({ + oauthSessionId: login.oauthSessionId, + credential: sealPassword('another-password'), + }) + ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + }); + + it('rechecks sanctions before consuming a migrated password setup', async () => { + const { caller, users, kakaoProfile, sealPassword } = buildCaller({ + kakaoId: 'sanctioned-setup-id', + kakaoEmail: 'sanctioned-setup@example.com', + }); + const user = await users.createUser({ + username: 'sanctioned-setup-user', + password: 'legacy-password', + oauth: { type: 'KAKAO', id: kakaoProfile.id, email: kakaoProfile.email, info: {} }, + }); + user.passwordResetRequired = true; + const start = await caller.auth.kakaoStart({ mode: 'login' }); + const login = await caller.auth.kakaoExchange({ code: 'oauth-code', state: start.state }); + if (login.status !== 'password_setup') throw new Error('Expected migrated password setup.'); + await users.updateSanctions(user.id, { bannedUntil: '2099-01-01T00:00:00.000Z' }); + + await expect( + caller.auth.kakaoSetPassword({ + oauthSessionId: login.oauthSessionId, + credential: sealPassword('blocked-password'), + }) + ).rejects.toMatchObject({ code: 'FORBIDDEN' }); + expect((await users.findById(user.id))?.passwordResetRequired).toBe(true); + }); + + it('consumes password setup when the provider identity changes before submission', async () => { + const { caller, users, kakaoProfile, sealPassword } = buildCaller({ + kakaoId: 'setup-target-id', + kakaoEmail: 'setup-target@example.com', + }); + const user = await users.createUser({ + username: 'setup-target-user', + password: 'legacy-password', + oauth: { type: 'KAKAO', id: kakaoProfile.id, email: kakaoProfile.email, info: {} }, + }); + user.passwordResetRequired = true; + const start = await caller.auth.kakaoStart({ mode: 'login' }); + const login = await caller.auth.kakaoExchange({ code: 'oauth-code', state: start.state }); + if (login.status !== 'password_setup') throw new Error('Expected migrated password setup.'); + kakaoProfile.id = 'changed-provider-id'; + + await expect( + caller.auth.kakaoSetPassword({ + oauthSessionId: login.oauthSessionId, + credential: sealPassword('new-target-password'), + }) + ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + expect((await users.findById(user.id))?.passwordResetRequired).toBe(true); + await expect( + caller.auth.kakaoSetPassword({ + oauthSessionId: login.oauthSessionId, + credential: sealPassword('new-target-password'), + }) + ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); }); it('asks for rejoin confirmation when Kakao is already registered but no retained email owner exists', async () => { @@ -1147,6 +1257,7 @@ describe('account self service', () => { username: 'self-service', password: 'current-password', }); + user.passwordResetRequired = true; const session = await sessions.createSession(user); await expect( @@ -1165,6 +1276,7 @@ describe('account self service', () => { const refreshed = await users.findById(user.id); expect(refreshed && (await users.verifyPassword(refreshed, 'next-password'))).toBe(true); + expect(refreshed?.passwordResetRequired).toBe(false); }); it('revokes the session and schedules deletion after 30 days', async () => { diff --git a/app/gateway-api/test/localAccountPolicy.test.ts b/app/gateway-api/test/localAccountPolicy.test.ts index 261c6dc0..976a5349 100644 --- a/app/gateway-api/test/localAccountPolicy.test.ts +++ b/app/gateway-api/test/localAccountPolicy.test.ts @@ -16,6 +16,7 @@ const buildLocalUser = (graceStartedAt: Date): UserRecord => ({ kakaoGraceStartedAt: graceStartedAt.toISOString(), passwordHash: 'unused', passwordSalt: '', + passwordResetRequired: false, createdAt: graceStartedAt.toISOString(), }); @@ -90,6 +91,25 @@ describe('local account profile policy', () => { }); }); + it('does not trust a migrated Kakao marker without a valid provider ID', () => { + const user = buildLocalUser(new Date('2020-01-01T00:00:00.000Z')); + user.oauthType = 'KAKAO'; + user.oauthId = ' '; + user.kakaoVerifiedAt = '2026-07-26T00:00:00.000Z'; + const policy = resolveLocalAccountProfilePolicy({ + profile: 'che', + defaultGraceDays: 0, + user, + now: new Date('2026-07-26T00:00:00.000Z'), + }); + + expect(policy).toMatchObject({ + kakaoVerified: false, + requiresKakaoVerification: true, + accessAllowed: false, + }); + }); + it('extends account access with an administrator override without widening general creation grace', () => { const user = buildLocalUser(new Date('2026-07-20T00:00:00.000Z')); user.kakaoGraceUntil = '2026-08-20T00:00:00.000Z'; diff --git a/app/gateway-api/test/oauthSessionStore.test.ts b/app/gateway-api/test/oauthSessionStore.test.ts index 681935de..6dc7b89b 100644 --- a/app/gateway-api/test/oauthSessionStore.test.ts +++ b/app/gateway-api/test/oauthSessionStore.test.ts @@ -61,13 +61,15 @@ describe.skipIf(!redisUrl)('RedisOAuthSessionStore Kakao state', () => { }); sessionIds.add(session.id); - await expect(store.consumeSession(session.id)).resolves.toMatchObject({ + await expect(client.ttl(`${prefix}:oauth-session:${session.id}`)).resolves.toBeGreaterThan(0); + const consumed = await Promise.all([store.consumeSession(session.id), store.consumeSession(session.id)]); + expect(consumed.filter((value) => value !== null)).toHaveLength(1); + expect(consumed.find((value) => value !== null)).toMatchObject({ id: session.id, intent: 'link_existing', targetUserId, email: 'retained@example.test', }); - await expect(store.consumeSession(session.id)).resolves.toBeNull(); }); it('atomically consumes a successful code once', async () => { diff --git a/app/gateway-api/test/passwordCredential.test.ts b/app/gateway-api/test/passwordCredential.test.ts index 3d85f2f4..4d6c9945 100644 --- a/app/gateway-api/test/passwordCredential.test.ts +++ b/app/gateway-api/test/passwordCredential.test.ts @@ -34,10 +34,12 @@ describe('password credential compatibility', () => { }); user.passwordSalt = 'core-salt'; user.passwordHash = createHash('sha256').update('core-salt:current-password').digest('hex'); + user.passwordResetRequired = true; expect(await users.verifyPassword(user, 'current-password')).toBe(true); expect(user.passwordHash.startsWith('$argon2id$')).toBe(true); expect(user.passwordSalt).toBe(''); + expect(user.passwordResetRequired).toBe(false); }); it('upgrades an imported ref double-SHA-512 credential after a successful login', async () => { @@ -52,10 +54,12 @@ describe('password credential compatibility', () => { const browserHash = createHash('sha512').update(`${globalSalt}current-password${globalSalt}`).digest('hex'); user.passwordSalt = userSalt; user.passwordHash = createHash('sha512').update(`${userSalt}${browserHash}${userSalt}`).digest('hex'); + user.passwordResetRequired = true; expect(await users.verifyPassword(user, 'current-password')).toBe(true); expect(user.passwordHash.startsWith('$argon2id$')).toBe(true); expect(user.passwordSalt).toBe(''); + expect(user.passwordResetRequired).toBe(false); }); it('does not accept an imported ref credential without the matching global salt', async () => { diff --git a/app/gateway-api/test/releaseManifest.test.ts b/app/gateway-api/test/releaseManifest.test.ts index b707a5d7..df4b3012 100644 --- a/app/gateway-api/test/releaseManifest.test.ts +++ b/app/gateway-api/test/releaseManifest.test.ts @@ -38,8 +38,8 @@ describe('readReleaseManifest', () => { await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({ controllerProtocol: RELEASE_CONTROLLER_PROTOCOL, - gatewaySchemaHead: '20260813000000_split_gateway_profile_identity', - gameSchemaHead: '20260816000000_add_read_model_change_journal', + gatewaySchemaHead: '20260817000000_add_password_reset_required', + gameSchemaHead: '20260817001000_add_dedicated_legacy_archive', }); }); @@ -61,11 +61,7 @@ describe('readReleaseManifest', () => { it('allows only the explicit controller self-upgrade boundary to cross protocol versions', async () => { const futureProtocol = RELEASE_CONTROLLER_PROTOCOL + 1; - const workspace = await createWorkspace( - '20260801000000_gateway', - '20260801000000_game', - futureProtocol - ); + const workspace = await createWorkspace('20260801000000_gateway', '20260801000000_game', futureProtocol); await expect(readReleaseManifest(workspace)).rejects.toThrow( `Release requires controller protocol ${futureProtocol}` diff --git a/app/gateway-frontend/e2e/kakao-account-recovery.spec.ts b/app/gateway-frontend/e2e/kakao-account-recovery.spec.ts index e2b8ffb2..0c0325c6 100644 --- a/app/gateway-frontend/e2e/kakao-account-recovery.spec.ts +++ b/app/gateway-frontend/e2e/kakao-account-recovery.spec.ts @@ -1,3 +1,5 @@ +import { generateKeyPairSync } from 'node:crypto'; + import { expect, test, type Page, type Route } from '@playwright/test'; const response = (data: unknown) => ({ result: { data } }); @@ -6,6 +8,43 @@ const operationNames = (route: Route): string[] => { return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(','); }; +const { publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); +const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }).toString(); + +const installPasswordSetupFixture = async (page: Page) => { + const calls: string[] = []; + await page.route('**/gateway/api/trpc/**', async (route) => { + const results = operationNames(route).map((operation) => { + calls.push(operation); + if (operation === 'me') return response(null); + if (operation === 'lobby.notice') return response(''); + if (operation === 'lobby.profiles') return response([]); + if (operation === 'auth.passwordKey') { + return response({ keyId: 'password-setup-key', publicKeyPem, algorithm: 'RSA-OAEP-256' }); + } + if (operation === 'auth.kakaoExchange') { + return response({ + status: 'password_setup', + oauthSessionId: '11111111-1111-4111-8111-111111111112', + email: 'migrated@example.test', + successStatus: 'login', + }); + } + if (operation === 'auth.kakaoSetPassword') { + return response({ + status: 'otp', + challengeId: '11111111-1111-4111-8111-111111111111', + expiresAt: '2026-08-17T12:03:00.000Z', + attemptsRemaining: 3, + }); + } + throw new Error(`Unhandled password setup fixture operation: ${operation}`); + }); + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) }); + }); + return calls; +}; + const installFixture = async (page: Page, action: 'link_existing' | 'rejoin') => { const calls: string[] = []; await page.route('**/gateway/api/trpc/**', async (route) => { @@ -108,4 +147,27 @@ for (const viewport of [ expect(calls.filter((operation) => operation === 'auth.kakaoResolveAccount')).toHaveLength(1); expect(geometry.width).toBe(viewport.name === 'desktop' ? 698 : 372); }); + + test(`sets a migrated password before opening the OTP dialog on ${viewport.name}`, async ({ page }) => { + const calls = await installPasswordSetupFixture(page); + await page.setViewportSize(viewport); + await page.goto('/gateway/oauth/callback?code=oauth-code&state=oauth-state'); + + const form = page.getByRole('form', { name: '새 비밀번호 설정' }); + await expect(form).toBeVisible(); + await expect(form).toContainText('카카오 인증으로 기존 계정을 확인했습니다.'); + await expect(form.getByLabel('카카오 이메일')).toHaveValue('migrated@example.test'); + const geometry = await form.evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { width: rect.width, right: rect.right }; + }); + await form.getByLabel('새 비밀번호').fill('new-password-value'); + await form.getByLabel('비밀번호 확인').fill('new-password-value'); + await form.getByRole('button', { name: '새 비밀번호 설정' }).click(); + + await expect(page.getByRole('dialog', { name: '인증 코드 필요' })).toBeVisible(); + expect(calls.filter((operation) => operation === 'auth.kakaoSetPassword')).toHaveLength(1); + expect(geometry.width).toBeGreaterThan(300); + expect(geometry.right).toBeLessThanOrEqual(viewport.width); + }); } diff --git a/app/gateway-frontend/src/views/OAuthCallbackView.vue b/app/gateway-frontend/src/views/OAuthCallbackView.vue index e925c558..6e674667 100644 --- a/app/gateway-frontend/src/views/OAuthCallbackView.vue +++ b/app/gateway-frontend/src/views/OAuthCallbackView.vue @@ -14,6 +14,8 @@ const submitting = ref(false); const errorMessage = ref(''); const infoMessage = ref(''); const oauthSessionId = ref(''); +const passwordSetupSessionId = ref(''); +const passwordSetupSuccessStatus = ref<'login' | 'verified'>('login'); const email = ref(''); const username = ref(''); const password = ref(''); @@ -60,6 +62,12 @@ const completeExchange = async (): Promise => { infoMessage.value = '카카오톡으로 임시 비밀번호를 보냈습니다.'; return; } + if (result.status === 'password_setup') { + passwordSetupSessionId.value = result.oauthSessionId; + passwordSetupSuccessStatus.value = result.successStatus; + email.value = result.email; + return; + } if (result.status === 'account_recovery') { accountRecovery.value = result; email.value = result.email; @@ -94,6 +102,12 @@ const resolveAccount = async (): Promise => { await router.replace('/lobby'); return; } + if (result.status === 'password_setup') { + passwordSetupSessionId.value = result.oauthSessionId; + passwordSetupSuccessStatus.value = result.successStatus; + email.value = result.email; + return; + } oauthSessionId.value = result.oauthSessionId; email.value = result.email; } catch (error) { @@ -103,6 +117,36 @@ const resolveAccount = async (): Promise => { } }; +const setMigratedPassword = async (): Promise => { + errorMessage.value = ''; + if (password.value !== confirmPassword.value) { + errorMessage.value = '비밀번호 확인이 일치하지 않습니다.'; + return; + } + submitting.value = true; + try { + const credential = await sealPassword(password.value); + const result = await trpc.auth.kakaoSetPassword.mutate({ + oauthSessionId: passwordSetupSessionId.value, + credential, + }); + password.value = ''; + confirmPassword.value = ''; + passwordSetupSessionId.value = ''; + if (result.status === 'otp') { + otpChallenge.value = result; + otpSuccessStatus.value = passwordSetupSuccessStatus.value; + return; + } + window.localStorage.setItem('sammo-session-token', result.sessionToken); + await router.replace(result.status === 'verified' ? '/lobby?verified=1' : '/lobby'); + } catch (error) { + errorMessage.value = error instanceof Error ? error.message : '새 비밀번호를 설정하지 못했습니다.'; + } finally { + submitting.value = false; + } +}; + const register = async (): Promise => { errorMessage.value = ''; if (password.value !== confirmPassword.value) { @@ -156,7 +200,15 @@ onMounted(() => {

삼국지 모의전투 HiDCHe

-

{{ accountRecovery ? '카카오 계정 연결 확인' : '회원가입' }}

+

+ {{ + accountRecovery + ? '카카오 계정 연결 확인' + : passwordSetupSessionId + ? '새 비밀번호 설정' + : '회원가입' + }} +

카카오 인증을 확인하는 중...

{{ infoMessage }}

@@ -181,6 +233,46 @@ onMounted(() => { 취소
+
+

+ 카카오 인증으로 기존 계정을 확인했습니다. 앞으로 사용할 새 비밀번호를 설정해 주세요. +

+
+ + +
+
+ + +
+
+ + +
+ + 취소 +
diff --git a/docs/legacy-db-migration.md b/docs/legacy-db-migration.md index 5c6509bf..3261972c 100644 --- a/docs/legacy-db-migration.md +++ b/docs/legacy-db-migration.md @@ -9,6 +9,13 @@ PostgreSQL advisory locks serialize an apply per profile, and every target row uses a stable legacy key with `ON CONFLICT`, so an interrupted run is repeatable. +Gateway apply is one PostgreSQL transaction. A game apply records a +`legacy_archive.import_run`: archive and current-user projection writes commit +together with `COMPLETED`, while a rollback leaves a `FAILED` run record. A +repeat import updates archive-owned rows but does not replace a live Gateway +account's password, reset status, login/display identity, OAuth connection, +roles, sanctions, consent, icon or login timestamps. + The source of truth for eligibility is the checked ref schema, not every table that happens to exist in a dump. Tables outside that schema remain only in the recovery dump. @@ -28,18 +35,26 @@ Legacy member numbers map to deterministic UUIDs. Existing rows are updated by that UUID, so references such as `ng_old_generals.owner` remain stable even when an old account was deleted before the dump. -Kakao members retain `oauth_id`, email and metadata. A parseable legacy +Kakao members retain `oauth_id`, email and metadata. A non-empty provider ID is +required before an imported row is marked Kakao-verified. A parseable legacy `token_valid_until` is copied to `kakao_talk_verified_until`, preserving the remaining KakaoTalk ownership-proof interval instead of forcing an immediate -message at cutover. Cutover also sets `kakao_verified_at` and +message at cutover. Valid provider rows also receive `kakao_verified_at`; all +rows receive `kakao_grace_started_at` to the migration time and starts the local-account verification grace period there. Source rows without an OAuth ID retain their -metadata, but the importer does not invent a provider identifier. +metadata but are not treated as verified, and the importer never invents a +provider identifier. -Legacy password hashes remain usable when gateway-api has -`GATEWAY_LEGACY_PASSWORD_GLOBAL_SALT`; a successful login upgrades the stored -value to Argon2id. A test-only account can instead be reset with the CLI and a -mode-0600 password file: +Every imported 128-hex legacy password is marked `password_reset_required`. +The dump contains the per-user salt but not Ref's installation-wide salt, so +the dump alone cannot validate the old plaintext password. If the original +`GATEWAY_LEGACY_PASSWORD_GLOBAL_SALT` is recovered through the runtime secret, +a successful password login upgrades the value to Argon2id and clears the +flag. Otherwise, a Kakao login (including a confirmed retained-email relink) +issues a one-time password-setup challenge before any normal session; the new +password is sent in the existing RSA envelope and clears the flag. Accounts +without usable Kakao recovery require the CLI and a mode-0600 password file: ```sh GATEWAY_DATABASE_URL=... pnpm migrate:legacy -- \ @@ -50,24 +65,43 @@ The password is never accepted as an argument or printed. ### Game profiles -| Legacy table | Target | Policy | -| ------------------------------- | ------------------------ | --------------------------------------------------------------------- | -| `ng_games` | `ng_games` | Preserve completed season metadata | -| `hall` | `hall` | Preserve hall-of-fame rows | -| `ng_old_generals` | `ng_old_generals` | Preserve full JSON snapshots and owner | -| `ng_old_nations` | `ng_old_nations` | Preserve all versions, including duplicate server/nation pairs | -| `emperior` | `emperior` | Preserve dynasty detail and legacy key | -| `inheritance_result` | `inheritance_result` | Preserve result JSON/string and legacy key | -| `user_record` | `inheritance_log` | Preserve complete long-lived user record | -| persistent `storage` namespaces | `legacy_game_storage` | Preserve raw `inheritance_*` and `user_*` rows before projection | -| `storage:inheritance_point` | `inheritance_point` | Project the numeric first tuple item; retain the tuple in raw storage | -| `storage:user` | `inheritance_user_state` | Project known current inheritance state; retain raw storage | -| `ng_history` | `yearbook_history` | Preserve map, nation, global history and global action snapshots | +| Legacy table | Dedicated target | Policy | +| ------------------------------- | ----------------------------- | --------------------------------------------------------------------- | +| `ng_games` | `legacy_archive.game_history` | Preserve source profile, opening date, scenario and raw environment | +| `hall` | `legacy_archive.hall` | Preserve hall-of-fame rows without mixing current records | +| `ng_old_generals` | `legacy_archive.general` | Preserve canonical V1 plus private raw JSON and owner | +| `ng_old_nations` | `legacy_archive.nation` | Preserve all versions with profile and legacy primary key | +| `emperior` | `legacy_archive.emperor` | Preserve dynasty detail under a central archive ID | +| `inheritance_result` | `inheritance_result` | Preserve result JSON/string and legacy key | +| `user_record` | `inheritance_log` | Preserve complete long-lived user record | +| persistent `storage` namespaces | `legacy_game_storage` | Preserve raw `inheritance_*` and `user_*` rows before projection | +| `storage:inheritance_point` | `inheritance_point` | Project the numeric first tuple item; retain the tuple in raw storage | +| `storage:user` | `inheritance_user_state` | Project known current inheritance state; retain raw storage | +| `ng_history` | `legacy_archive.yearbook` | Preserve map, nation, global history and global action snapshots | + +The archive schema is shared by all game-profile schemas in the PostgreSQL +database. Every natural key contains `source_profile`; the accepted profiles +are `che`, `kwe`, `pwe`, `twe`, `nya`, `pya`, and `hwe`. This prevents equal +legacy IDs from different servers from colliding while allowing any profile API +to read one central archive. + +`ng_games.date` is retained as `legacy_date`. The displayed opening date uses +`env.opentime`, then `env.starttime`, then `ng_games.date`. The dumps do not +carry a trustworthy completion timestamp, so `completed_at` remains null +instead of treating the opening date as completion. + +`ng_old_generals.data` is adapted at import time to +`ArchivedGeneralSnapshotV1`. Both old `leader/power` with +`dex0/10/20/30/40` and newer `leadership/strength` with `dex1..5` map to one +shape. Missing battle aggregates and logs are `null` plus explicit +`availability`, never fabricated zeroes. The source JSON remains in +`legacy_archive.general.raw_data` for recovery, but no API returns it. The source contains legitimate duplicate `(server_id, nation)` old-nation rows and `(server_id, year, month)` history rows. `source_id` is consequently part of -the archive unique keys. Runtime-generated rows use `source_id = 0`; migrated -rows use the legacy primary key. This avoids a lossy last-row-wins upsert. +their current-schema archive keys, while the dedicated legacy archive uses +`(source_profile, legacy_id)` from the original primary key. This avoids a lossy +last-row-wins upsert and keeps runtime current archives separate. Current-season actor/world/queue/lock/message/market/vote state is explicitly excluded. In particular, `general`, `city`, `nation`, their turn queues, @@ -108,35 +142,44 @@ season or as a substitute for the long-lived archive cutover procedure. archive owner from the game session and never accepts an owner ID from the browser. -- `archive.myPastPlays` combines the owner's `ng_old_generals` rows with - `ng_games`, the latest matching `ng_old_nations` snapshot and an optional - `emperior` row. It returns summary fields and a link target for the existing - public dynasty/nation detail. -- `archive.myPastPlayDetail(serverId, generalNo)` includes the session owner in - the database predicate before returning `data.history`. A foreign or missing - record uses the same not-found response. -- Legacy `data.history` may be either an array or a `
`-joined string. The API - normalizes both to a newest-first string array, and the frontend renders plain - text rather than archived markup. +- `archive.myPastPlays` reads the central legacy archive across profiles and + current runtime archives, tags each source, and suppresses a current-schema + duplicate when the central legacy copy exists. +- `archive.myPastPlayDetail(source, sourceProfile, serverId, generalNo)` includes + the session owner in the database predicate. A foreign or missing record uses + the same not-found response. +- The detail DTO feeds the same `GeneralBasicCard`, battle summary, + `LegacyGeneralProgress`, and record panels used by My Page/Battle Center. + Missing battle/mastery/log channels show an explicit not-preserved state. +- Legacy `data.history` may be either an array or a `
`-joined string. It is + normalized to plain-text archive entries; archived markup is never rendered + as trusted HTML. - Runtime death and unification archival writes the current general `GENERAL/HISTORY` rows into the same `data.history` field, so newly completed seasons remain compatible with imported rows. +Hall-of-fame and dynasty APIs and pages take an explicit `current` or `legacy` +source. Legacy results use the central archive and show the source profile; +they are never merged into the current rankings or current dynasty list. + ## Cutover procedure 1. Keep the original compressed dumps immutable and restore each source to a private MariaDB instance. 2. Deploy the gateway and game Prisma migrations to empty staging databases. -3. Run gateway and each non-empty profile without `--apply`; archive the JSON +3. Run gateway and each non-empty official profile without `--apply`; archive the JSON counts and excluded-table reasons. 4. Compare source counts, malformed JSON checks and duplicate natural-key counts. Stop on unexplained drift. 5. Put the affected target in maintenance mode, take a PostgreSQL backup, then run the same commands with `--apply`. -6. Repeat each apply. Counts must remain unchanged. -7. Verify Kakao migration timestamps including `kakao_talk_verified_until`, password-hash shapes, archive ownership, - old-nation/history duplicate preservation, `/past-plays` list/detail access, - foreign-owner denial and the dynasty link. +6. Repeat each apply. Counts must remain unchanged; verify the newest + `legacy_archive.import_run` is `COMPLETED` and current Gateway credentials + are unchanged. +7. Verify valid/invalid Kakao-ID classification, password-reset-required rows, + Kakao password setup, CLI fallback, archive ownership, canonical source + format counts, opening dates, `/past-plays`, foreign-owner denial, legacy + Hall and legacy Dynasty source switches. 8. Retain the MariaDB dumps as rollback evidence. Rollback restores the pre-cutover PostgreSQL backup; it does not reverse individual importer upserts. diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 023e0239..3d68b05e 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -25,6 +25,7 @@ export * from './ranking/types.js'; export * from './ranking/legacyColor.js'; export * from './auth/accountIconProjection.js'; export * from './logging/formatLegacyLogHtml.js'; +export * from './legacyArchive/ArchivedGeneralSnapshot.js'; export * from './gateway/profileStatus.js'; export * from './game/accessPenalty.js'; export * from './http/trpcTransport.js'; diff --git a/packages/common/src/legacyArchive/ArchivedGeneralSnapshot.ts b/packages/common/src/legacyArchive/ArchivedGeneralSnapshot.ts new file mode 100644 index 00000000..dab9e2b0 --- /dev/null +++ b/packages/common/src/legacyArchive/ArchivedGeneralSnapshot.ts @@ -0,0 +1,321 @@ +export type ArchivedJsonValue = + null | boolean | number | string | ArchivedJsonValue[] | { [key: string]: ArchivedJsonValue }; + +export const ARCHIVED_GENERAL_SCHEMA_VERSION = 1 as const; + +export const LEGACY_ARCHIVE_PROFILES = ['che', 'kwe', 'pwe', 'twe', 'nya', 'pya', 'hwe'] as const; +export type LegacyArchiveProfile = (typeof LEGACY_ARCHIVE_PROFILES)[number]; + +export const isLegacyArchiveProfile = (value: string): value is LegacyArchiveProfile => + (LEGACY_ARCHIVE_PROFILES as readonly string[]).includes(value); + +export type ArchivedGeneralSourceFormat = 'legacy-flat-v0' | 'ref-flat-v1' | 'core-snapshot-v1' | 'unknown'; + +export interface ArchivedGeneralSnapshotV1 { + schemaVersion: typeof ARCHIVED_GENERAL_SCHEMA_VERSION; + identity: { + name: string; + picture: string | null; + imageServer: number | null; + npcState: number | null; + nationId: number | null; + cityId: number | null; + officerLevel: number | null; + officerCity: number | null; + }; + stats: { + leadership: number | null; + strength: number | null; + intelligence: number | null; + leadershipExperience: number | null; + strengthExperience: number | null; + intelligenceExperience: number | null; + }; + progression: { + experience: number | null; + experienceLevel: number | null; + dedication: number | null; + dedicationLevel: number | null; + age: number | null; + startAge: number | null; + bornYear: number | null; + deadYear: number | null; + }; + traits: { + personality: string | null; + specialDomestic: string | null; + specialWar: string | null; + }; + resources: { + gold: number | null; + rice: number | null; + crew: number | null; + crewType: string | null; + train: number | null; + morale: number | null; + injury: number | null; + }; + items: { + horse: string | null; + weapon: string | null; + book: string | null; + item: string | null; + }; + mastery: { + infantry: number | null; + archery: number | null; + cavalry: number | null; + special: number | null; + siege: number | null; + }; + battle: { + battles: number | null; + wins: number | null; + losses: number | null; + fireSuccesses: number | null; + kills: number | null; + deaths: number | null; + killedCrew: number | null; + lostCrew: number | null; + winRate: number | null; + killRate: number | null; + recentWar: string | null; + tactics: { + total: { wins: number | null; draws: number | null; losses: number | null }; + leadership: { wins: number | null; draws: number | null; losses: number | null }; + intelligence: { wins: number | null; draws: number | null; losses: number | null }; + }; + }; + history: string[]; + availability: { + mastery: boolean; + battleAggregates: boolean; + tactics: boolean; + history: boolean; + battleDetailLogs: false; + battleResultLogs: false; + }; +} + +type JsonRecord = Record; + +const asRecord = (value: ArchivedJsonValue | undefined): JsonRecord => + value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as JsonRecord) : {}; + +const finiteNumber = (value: ArchivedJsonValue | undefined): number | null => { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string' && value.trim() !== '') { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +}; + +const firstNumber = (...values: Array): number | null => { + for (const value of values) { + const parsed = finiteNumber(value); + if (parsed !== null) return parsed; + } + return null; +}; + +const text = (value: ArchivedJsonValue | undefined): string | null => { + if (typeof value === 'string') return value.trim() === '' ? null : value; + if (typeof value === 'number' && Number.isFinite(value)) return String(value); + return null; +}; + +const firstText = (...values: Array): string | null => { + for (const value of values) { + const parsed = text(value); + if (parsed !== null) return parsed; + } + return null; +}; + +const historyLines = (value: ArchivedJsonValue | undefined): string[] => { + if (Array.isArray(value)) { + return value.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0); + } + if (typeof value !== 'string') return []; + return value + .split(//iu) + .map((entry) => entry.trim()) + .filter(Boolean); +}; + +const rate = (numerator: number | null, denominator: number | null): number | null => + numerator === null || denominator === null || denominator <= 0 + ? null + : Math.round((numerator / denominator) * 10_000) / 100; + +export const detectArchivedGeneralSourceFormat = (raw: ArchivedJsonValue): ArchivedGeneralSourceFormat => { + const data = asRecord(raw); + if (Object.keys(asRecord(data.stats)).length > 0 || data.schemaVersion === 1) return 'core-snapshot-v1'; + if ('leadership' in data || 'strength' in data || 'dex1' in data) return 'ref-flat-v1'; + if ('leader' in data || 'power' in data || 'dex0' in data) return 'legacy-flat-v0'; + return 'unknown'; +}; + +export const normalizeArchivedGeneral = ( + raw: ArchivedJsonValue, + fallbackName: string +): { sourceFormat: ArchivedGeneralSourceFormat; snapshot: ArchivedGeneralSnapshotV1 } => { + const data = asRecord(raw); + const identity = asRecord(data.identity); + const stats = asRecord(data.stats); + const role = asRecord(data.role); + const items = asRecord(data.items); + const progression = asRecord(data.progression); + const traits = asRecord(data.traits); + const resources = asRecord(data.resources); + const nestedMastery = asRecord(data.mastery); + const nestedBattle = asRecord(data.battle); + const nestedTactics = asRecord(nestedBattle.tactics); + const totalTactics = asRecord(nestedTactics.total); + const leadershipTactics = asRecord(nestedTactics.leadership); + const intelligenceTactics = asRecord(nestedTactics.intelligence); + const oldMastery = 'dex0' in data; + const masteryValues = oldMastery + ? [data.dex0, data.dex10, data.dex20, data.dex30, data.dex40] + : [ + data.dex1 ?? nestedMastery.infantry, + data.dex2 ?? nestedMastery.archery, + data.dex3 ?? nestedMastery.cavalry, + data.dex4 ?? nestedMastery.special, + data.dex5 ?? nestedMastery.siege, + ]; + const mastery = masteryValues.map(finiteNumber); + const battles = firstNumber(data.warnum, nestedBattle.battles); + const wins = firstNumber(data.killnum, nestedBattle.wins); + const losses = firstNumber(data.deathnum, nestedBattle.losses); + const killedCrew = firstNumber(data.killcrew, nestedBattle.killedCrew); + const lostCrew = firstNumber(data.deathcrew, nestedBattle.lostCrew); + const history = historyLines(data.history); + const tacticValues = [ + data.ttw ?? totalTactics.wins, + data.ttd ?? totalTactics.draws, + data.ttl ?? totalTactics.losses, + data.tlw ?? leadershipTactics.wins, + data.tld ?? leadershipTactics.draws, + data.tll ?? leadershipTactics.losses, + data.tiw ?? intelligenceTactics.wins, + data.tid ?? intelligenceTactics.draws, + data.til ?? intelligenceTactics.losses, + ]; + const tacticsAvailable = tacticValues.some((value) => finiteNumber(value) !== null); + + return { + sourceFormat: detectArchivedGeneralSourceFormat(raw), + snapshot: { + schemaVersion: ARCHIVED_GENERAL_SCHEMA_VERSION, + identity: { + name: firstText(data.name, identity.name) ?? fallbackName, + picture: firstText(data.picture, identity.picture), + imageServer: firstNumber(data.imageServer, data.imgsvr, identity.imageServer), + npcState: firstNumber(data.npcState, data.npc, identity.npcState), + nationId: firstNumber(data.nationId, data.nation, identity.nationId), + cityId: firstNumber(data.cityId, data.city, identity.cityId), + officerLevel: firstNumber(data.officerLevel, data.officer_level, data.level, identity.officerLevel), + officerCity: firstNumber(data.officerCity, data.officer_city, identity.officerCity), + }, + stats: { + leadership: firstNumber(data.leadership, data.leader, stats.leadership), + strength: firstNumber(data.strength, data.power, stats.strength), + intelligence: firstNumber(data.intelligence, data.intel, stats.intelligence), + leadershipExperience: firstNumber( + data.leadershipExperience, + data.leadership_exp, + stats.leadershipExperience + ), + strengthExperience: firstNumber(data.strengthExperience, data.strength_exp, stats.strengthExperience), + intelligenceExperience: firstNumber( + data.intelligenceExperience, + data.intel_exp, + stats.intelligenceExperience + ), + }, + progression: { + experience: firstNumber(data.experience, progression.experience), + experienceLevel: firstNumber(data.experienceLevel, data.explevel, progression.experienceLevel), + dedication: firstNumber(data.dedication, progression.dedication), + dedicationLevel: firstNumber(data.dedicationLevel, data.dedlevel, progression.dedicationLevel), + age: firstNumber(data.age, progression.age), + startAge: firstNumber(data.startAge, data.startage, progression.startAge), + bornYear: firstNumber(data.bornYear, data.bornyear, progression.bornYear), + deadYear: firstNumber(data.deadYear, data.deadyear, progression.deadYear), + }, + traits: { + personality: firstText(data.personalCode, data.personal, role.personality, traits.personality), + specialDomestic: firstText( + data.specialCode, + data.special, + role.specialDomestic, + traits.specialDomestic + ), + specialWar: firstText(data.special2Code, data.special2, role.specialWar, traits.specialWar), + }, + resources: { + gold: firstNumber(data.gold, resources.gold), + rice: firstNumber(data.rice, resources.rice), + crew: firstNumber(data.crew, resources.crew), + crewType: firstText(data.crewType, data.crewtype, resources.crewType), + train: firstNumber(data.train, resources.train), + morale: firstNumber(data.morale, data.atmos, resources.morale), + injury: firstNumber(data.injury, resources.injury), + }, + items: { + horse: firstText(items.horse, data.horse), + weapon: firstText(items.weapon, data.weapon, data.weap), + book: firstText(items.book, data.book), + item: firstText(items.item, data.item), + }, + mastery: { + infantry: mastery[0] ?? null, + archery: mastery[1] ?? null, + cavalry: mastery[2] ?? null, + special: mastery[3] ?? null, + siege: mastery[4] ?? null, + }, + battle: { + battles, + wins, + losses, + fireSuccesses: firstNumber(data.firenum, nestedBattle.fireSuccesses), + kills: firstNumber(nestedBattle.kills, wins), + deaths: firstNumber(nestedBattle.deaths, losses), + killedCrew, + lostCrew, + winRate: firstNumber(nestedBattle.winRate) ?? rate(wins, battles), + killRate: firstNumber(nestedBattle.killRate) ?? rate(killedCrew, lostCrew), + recentWar: firstText(data.recentWar, data.recent_war, nestedBattle.recentWar), + tactics: { + total: { + wins: firstNumber(data.ttw, totalTactics.wins), + draws: firstNumber(data.ttd, totalTactics.draws), + losses: firstNumber(data.ttl, totalTactics.losses), + }, + leadership: { + wins: firstNumber(data.tlw, leadershipTactics.wins), + draws: firstNumber(data.tld, leadershipTactics.draws), + losses: firstNumber(data.tll, leadershipTactics.losses), + }, + intelligence: { + wins: firstNumber(data.tiw, intelligenceTactics.wins), + draws: firstNumber(data.tid, intelligenceTactics.draws), + losses: firstNumber(data.til, intelligenceTactics.losses), + }, + }, + }, + history, + availability: { + mastery: mastery.some((value) => value !== null), + battleAggregates: [battles, wins, losses, killedCrew, lostCrew].some((value) => value !== null), + tactics: tacticsAvailable, + history: history.length > 0, + battleDetailLogs: false, + battleResultLogs: false, + }, + }, + }; +}; diff --git a/packages/infra/prisma/gateway-migrations/20260817000000_add_password_reset_required/migration.sql b/packages/infra/prisma/gateway-migrations/20260817000000_add_password_reset_required/migration.sql new file mode 100644 index 00000000..524f5d9f --- /dev/null +++ b/packages/infra/prisma/gateway-migrations/20260817000000_add_password_reset_required/migration.sql @@ -0,0 +1,6 @@ +ALTER TABLE "app_user" + ADD COLUMN "password_reset_required" BOOLEAN NOT NULL DEFAULT FALSE; + +UPDATE "app_user" +SET "password_reset_required" = TRUE +WHERE "password_hash" ~ '^[[:xdigit:]]{128}$'; diff --git a/packages/infra/prisma/gateway.prisma b/packages/infra/prisma/gateway.prisma index 351cee68..8f2b1435 100644 --- a/packages/infra/prisma/gateway.prisma +++ b/packages/infra/prisma/gateway.prisma @@ -82,6 +82,7 @@ model AppUser { displayName String @unique @map("display_name") passwordHash String @map("password_hash") passwordSalt String @map("password_salt") + passwordResetRequired Boolean @default(false) @map("password_reset_required") roles Json @default(dbgenerated("'[]'::jsonb")) sanctions Json @default(dbgenerated("'{}'::jsonb")) oauthType OAuthType @default(NONE) @map("oauth_type") diff --git a/packages/infra/prisma/migrations/20260817001000_add_dedicated_legacy_archive/migration.sql b/packages/infra/prisma/migrations/20260817001000_add_dedicated_legacy_archive/migration.sql new file mode 100644 index 00000000..70e8d2a0 --- /dev/null +++ b/packages/infra/prisma/migrations/20260817001000_add_dedicated_legacy_archive/migration.sql @@ -0,0 +1,125 @@ +CREATE SCHEMA IF NOT EXISTS "legacy_archive"; + +CREATE TABLE IF NOT EXISTS "legacy_archive"."import_run" ( + "id" BIGSERIAL PRIMARY KEY, + "source_profile" TEXT NOT NULL, + "status" TEXT NOT NULL, + "started_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "finished_at" TIMESTAMP(3), + "counts" JSONB NOT NULL DEFAULT '{}'::jsonb, + "source_format_summary" JSONB NOT NULL DEFAULT '{}'::jsonb, + "error" TEXT, + CONSTRAINT "legacy_archive_import_run_profile_check" + CHECK ("source_profile" IN ('che', 'kwe', 'pwe', 'twe', 'nya', 'pya', 'hwe')), + CONSTRAINT "legacy_archive_import_run_status_check" + CHECK ("status" IN ('RUNNING', 'COMPLETED', 'FAILED')) +); + +CREATE INDEX IF NOT EXISTS "legacy_archive_import_run_profile_started" + ON "legacy_archive"."import_run" ("source_profile", "started_at" DESC); + +CREATE TABLE IF NOT EXISTS "legacy_archive"."game_history" ( + "source_profile" TEXT NOT NULL, + "server_id" TEXT NOT NULL, + "legacy_id" INTEGER NOT NULL, + "opened_at" TIMESTAMP(3) NOT NULL, + "completed_at" TIMESTAMP(3), + "legacy_date" TIMESTAMP(3) NOT NULL, + "winner_nation" INTEGER, + "map" TEXT, + "season" INTEGER NOT NULL, + "scenario" INTEGER NOT NULL, + "scenario_name" TEXT NOT NULL, + "raw_env" JSONB NOT NULL DEFAULT '{}'::jsonb, + "import_run_id" BIGINT NOT NULL REFERENCES "legacy_archive"."import_run" ("id"), + PRIMARY KEY ("source_profile", "server_id") +); + +CREATE INDEX IF NOT EXISTS "legacy_archive_game_history_opened" + ON "legacy_archive"."game_history" ("source_profile", "opened_at" DESC); + +CREATE TABLE IF NOT EXISTS "legacy_archive"."general" ( + "source_profile" TEXT NOT NULL, + "server_id" TEXT NOT NULL, + "general_no" INTEGER NOT NULL, + "legacy_id" INTEGER NOT NULL, + "owner" TEXT, + "name" TEXT NOT NULL, + "last_yearmonth" INTEGER NOT NULL, + "turntime" TIMESTAMP(3) NOT NULL, + "schema_version" INTEGER NOT NULL DEFAULT 1, + "source_format" TEXT NOT NULL, + "data" JSONB NOT NULL, + "raw_data" JSONB NOT NULL, + "import_run_id" BIGINT NOT NULL REFERENCES "legacy_archive"."import_run" ("id"), + PRIMARY KEY ("source_profile", "server_id", "general_no"), + CONSTRAINT "legacy_archive_general_schema_version_check" CHECK ("schema_version" = 1) +); + +CREATE INDEX IF NOT EXISTS "legacy_archive_general_owner_opened" + ON "legacy_archive"."general" ("owner", "source_profile", "server_id"); +CREATE INDEX IF NOT EXISTS "legacy_archive_general_name" + ON "legacy_archive"."general" ("source_profile", "server_id", "name"); + +CREATE TABLE IF NOT EXISTS "legacy_archive"."nation" ( + "source_profile" TEXT NOT NULL, + "legacy_id" INTEGER NOT NULL, + "server_id" TEXT NOT NULL, + "nation" INTEGER NOT NULL, + "data" JSONB NOT NULL, + "archived_at" TIMESTAMP(3) NOT NULL, + "import_run_id" BIGINT NOT NULL REFERENCES "legacy_archive"."import_run" ("id"), + PRIMARY KEY ("source_profile", "legacy_id") +); + +CREATE INDEX IF NOT EXISTS "legacy_archive_nation_server" + ON "legacy_archive"."nation" ("source_profile", "server_id", "nation", "archived_at" DESC); + +CREATE TABLE IF NOT EXISTS "legacy_archive"."hall" ( + "source_profile" TEXT NOT NULL, + "legacy_id" INTEGER NOT NULL, + "server_id" TEXT NOT NULL, + "season" INTEGER NOT NULL, + "scenario" INTEGER NOT NULL, + "general_no" INTEGER NOT NULL, + "type" TEXT NOT NULL, + "value" DOUBLE PRECISION NOT NULL, + "owner" TEXT, + "aux" JSONB NOT NULL DEFAULT '{}'::jsonb, + "import_run_id" BIGINT NOT NULL REFERENCES "legacy_archive"."import_run" ("id"), + PRIMARY KEY ("source_profile", "server_id", "type", "general_no") +); + +CREATE INDEX IF NOT EXISTS "legacy_archive_hall_scenario" + ON "legacy_archive"."hall" ("source_profile", "season", "scenario", "type", "value" DESC); + +CREATE TABLE IF NOT EXISTS "legacy_archive"."emperor" ( + "id" BIGSERIAL PRIMARY KEY, + "source_profile" TEXT NOT NULL, + "legacy_id" INTEGER NOT NULL, + "server_id" TEXT, + "data" JSONB NOT NULL, + "import_run_id" BIGINT NOT NULL REFERENCES "legacy_archive"."import_run" ("id"), + CONSTRAINT "legacy_archive_emperor_source_key" UNIQUE ("source_profile", "legacy_id") +); + +CREATE INDEX IF NOT EXISTS "legacy_archive_emperor_server" + ON "legacy_archive"."emperor" ("source_profile", "server_id", "id" DESC); + +CREATE TABLE IF NOT EXISTS "legacy_archive"."yearbook" ( + "source_profile" TEXT NOT NULL, + "legacy_id" INTEGER NOT NULL, + "profile_name" TEXT NOT NULL, + "year" INTEGER NOT NULL, + "month" INTEGER NOT NULL, + "map" JSONB NOT NULL DEFAULT '{}'::jsonb, + "nations" JSONB NOT NULL DEFAULT '[]'::jsonb, + "global_history" JSONB NOT NULL DEFAULT '[]'::jsonb, + "global_action" JSONB NOT NULL DEFAULT '[]'::jsonb, + "content_hash" TEXT NOT NULL, + "import_run_id" BIGINT NOT NULL REFERENCES "legacy_archive"."import_run" ("id"), + PRIMARY KEY ("source_profile", "legacy_id") +); + +CREATE INDEX IF NOT EXISTS "legacy_archive_yearbook_month" + ON "legacy_archive"."yearbook" ("source_profile", "profile_name", "year", "month", "legacy_id"); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9707828c..bc274d90 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -535,6 +535,9 @@ importers: tools/legacy-db-migration: dependencies: + '@sammo-ts/common': + specifier: workspace:* + version: link:../../packages/common mariadb: specifier: 3.5.3 version: 3.5.3 diff --git a/release-manifest.json b/release-manifest.json index e5fda16b..dda2c243 100644 --- a/release-manifest.json +++ b/release-manifest.json @@ -1,7 +1,7 @@ { "formatVersion": 1, "controllerProtocol": 2, - "gatewaySchemaHead": "20260813000000_split_gateway_profile_identity", - "gameSchemaHead": "20260816000000_add_read_model_change_journal", + "gatewaySchemaHead": "20260817000000_add_password_reset_required", + "gameSchemaHead": "20260817001000_add_dedicated_legacy_archive", "components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"] } diff --git a/tools/legacy-db-migration/README.md b/tools/legacy-db-migration/README.md index 2752fbda..e8994fac 100644 --- a/tools/legacy-db-migration/README.md +++ b/tools/legacy-db-migration/README.md @@ -5,9 +5,10 @@ into the core2026 PostgreSQL schemas. It is CLI-only; no HTTP or administrator route invokes it. The default mode is a read-only dry-run. `--apply` is required before any target -write. PostgreSQL advisory locks prevent two applies for the same target. Every -write uses a stable legacy key and `ON CONFLICT`, so a completed or interrupted -run can be repeated. +write. PostgreSQL advisory locks prevent two applies for the same target. +Gateway writes are transactional. Game archive writes and their completed +`legacy_archive.import_run` record are transactional. Stable legacy keys make +completed or interrupted runs repeatable. ## Source restore @@ -37,6 +38,13 @@ LEGACY_GAME_DATABASE_URL=... pnpm --filter @sammo-ts/legacy-db-migration migrate After reviewing the JSON counts and excluded-table reasons, add `GATEWAY_DATABASE_URL` or `GAME_DATABASE_URL` and repeat with `--apply`. +For game archives, `GAME_DATABASE_URL` points at that profile's Core schema. +The importer writes completed-history data to the shared +`legacy_archive` PostgreSQL schema and writes only inheritance projections to +the selected current profile schema. Accepted profiles are +`che,kwe,pwe,twe,nya,pya,hwe`; run them separately against the same PostgreSQL +database. + ### Isolated current-season comparison fixture `current-season-fixture` is separate from the long-lived archive migration. It @@ -77,13 +85,17 @@ listed in the JSON result. This fixture is evidence for persisted-state and GUI comparison, not proof that the two engines consume RNG identically after the next turn. -Kakao members retain their OAuth ID, email, and OAuth metadata. -`kakao_verified_at` and `kakao_grace_started_at` are set to the migration time. +Kakao members retain their OAuth ID, email, and OAuth metadata. Only a row with +a non-empty OAuth ID receives `kakao_verified_at`. +`kakao_grace_started_at` is set to the migration time. The existing `token_valid_until` is copied to `kakao_talk_verified_until` for Kakao rows so a still-current “send to me” proof remains current after cutover. -Legacy password hashes and salts are retained and upgraded to Argon2id after -the first successful login when -`GATEWAY_LEGACY_PASSWORD_GLOBAL_SALT` is configured in gateway-api. +Imported 128-hex password hashes are marked for reset. They can be upgraded to +Argon2id after the first successful login only when the DB-external +`GATEWAY_LEGACY_PASSWORD_GLOBAL_SALT` is safely recovered. Otherwise, a verified +Kakao flow requires a new password before session issuance. A non-Kakao account +uses the CLI reset below. Reapplying a dump preserves the target account's +current credential, OAuth, identity, roles, sanctions, consent, and login state. Only tables present in the checked ref schemas are eligible. Extra tables found in a dump, such as an old root `config` table, are left in the recovery dump and diff --git a/tools/legacy-db-migration/package.json b/tools/legacy-db-migration/package.json index 4f53c8cc..428b3e85 100644 --- a/tools/legacy-db-migration/package.json +++ b/tools/legacy-db-migration/package.json @@ -15,6 +15,7 @@ "migrate": "tsx src/cli.ts" }, "dependencies": { + "@sammo-ts/common": "workspace:*", "mariadb": "3.5.3", "pg": "^8.16.3" }, diff --git a/tools/legacy-db-migration/src/cli.ts b/tools/legacy-db-migration/src/cli.ts index 69e93dc8..9a5b0dcb 100644 --- a/tools/legacy-db-migration/src/cli.ts +++ b/tools/legacy-db-migration/src/cli.ts @@ -5,7 +5,7 @@ import path from 'node:path'; import process from 'node:process'; import { createMariaPool, createPostgresPool } from './db.js'; -import { migrateGame } from './game.js'; +import { isLegacyArchiveProfile, LEGACY_ARCHIVE_PROFILES, migrateGame } from './game.js'; import { migrateGateway } from './gateway.js'; import { hashPasswordForReset } from './password.js'; import { migrateCurrentSeasonFixture } from './currentSeason.js'; @@ -122,7 +122,10 @@ const resetPassword = async (options: CliOptions): Promise => { const migratedAt = new Date(); if (options.command === 'gateway') { const source = createMariaPool(requireEnvironment('LEGACY_ROOT_DATABASE_URL')); - const target = options.apply ? createPostgresPool(requireEnvironment('GATEWAY_DATABASE_URL')) : null; + const targetUrl = process.env.GATEWAY_DATABASE_URL?.trim(); + if (options.apply && !targetUrl) { + throw new Error('GATEWAY_DATABASE_URL is required with --apply'); + } + const target = targetUrl ? createPostgresPool(targetUrl) : null; try { const summary = await migrateGateway(source, target, options.apply, migratedAt); console.log(JSON.stringify(summary, null, 2)); @@ -156,6 +163,9 @@ const run = async (): Promise => { if (!options.profile || !/^[a-z][a-z0-9_-]{1,31}$/.test(options.profile)) { throw new Error(`${options.command} requires a safe --profile value\n\n${usage}`); } + if (options.command === 'game' && !isLegacyArchiveProfile(options.profile)) { + throw new Error(`game requires --profile ${LEGACY_ARCHIVE_PROFILES.join('|')}\n\n${usage}`); + } const source = createMariaPool(requireEnvironment('LEGACY_GAME_DATABASE_URL')); const target = options.apply || options.command === 'current-season-fixture' diff --git a/tools/legacy-db-migration/src/db.ts b/tools/legacy-db-migration/src/db.ts index f969e5eb..8d06d62a 100644 --- a/tools/legacy-db-migration/src/db.ts +++ b/tools/legacy-db-migration/src/db.ts @@ -24,6 +24,14 @@ const quoteIdentifier = (value: string): string => { return `"${value}"`; }; +export const quoteQualifiedIdentifier = (value: string): string => { + const parts = value.split('.'); + if (parts.length < 1 || parts.length > 2 || parts.some((part) => !IDENTIFIER.test(part))) { + throw new Error(`Unsafe SQL identifier: ${value}`); + } + return parts.map((part) => `"${part}"`).join('.'); +}; + export const createMariaPool = (uri: string): MariaPool => mariadb.createPool(uri); export const createPostgresPool = (connectionString: string): pg.Pool => { @@ -94,7 +102,8 @@ export const upsertRows = async ( client: PoolClient, table: string, rows: readonly TargetRow[], - conflictColumns: readonly string[] + conflictColumns: readonly string[], + options: { preserveOnConflict?: readonly string[] } = {} ): Promise => { if (rows.length === 0) { return; @@ -116,12 +125,13 @@ export const upsertRows = async ( }); return `(${placeholders.join(', ')})`; }); + const preserved = new Set(options.preserveOnConflict ?? []); const updates = columns - .filter((column) => !conflictColumns.includes(column)) + .filter((column) => !conflictColumns.includes(column) && !preserved.has(column)) .map((column) => `${quoteIdentifier(column)} = EXCLUDED.${quoteIdentifier(column)}`); const conflictAction = updates.length ? `DO UPDATE SET ${updates.join(', ')}` : 'DO NOTHING'; await client.query( - `INSERT INTO ${quoteIdentifier(table)} (${columns.map(quoteIdentifier).join(', ')}) + `INSERT INTO ${quoteQualifiedIdentifier(table)} (${columns.map(quoteIdentifier).join(', ')}) VALUES ${tuples.join(', ')} ON CONFLICT (${conflictColumns.map(quoteIdentifier).join(', ')}) ${conflictAction}`, values diff --git a/tools/legacy-db-migration/src/game.ts b/tools/legacy-db-migration/src/game.ts index 2d5b27cc..6fcf0a85 100644 --- a/tools/legacy-db-migration/src/game.ts +++ b/tools/legacy-db-migration/src/game.ts @@ -3,6 +3,16 @@ import { createHash } from 'node:crypto'; import type { Pool as MariaPool } from 'mariadb'; import type { Pool as PgPool, PoolClient } from 'pg'; +import { + isLegacyArchiveProfile, + normalizeArchivedGeneral, + type ArchivedGeneralSourceFormat, + type ArchivedJsonValue, + type LegacyArchiveProfile, +} from '@sammo-ts/common'; + +export { isLegacyArchiveProfile, LEGACY_ARCHIVE_PROFILES, type LegacyArchiveProfile } from '@sammo-ts/common'; + import { paginateSource, jsonParameter, @@ -29,6 +39,12 @@ import { const batchSize = 250; +interface ArchiveMigrationContext { + profile: LegacyArchiveProfile; + importRunId: string; + sourceFormats: Record; +} + const parseNullableJson = (value: unknown, fallback: JsonValue, context: string): JsonValue => value === null || value === undefined ? fallback : parseJson(value, context); @@ -43,17 +59,17 @@ const ownerId = (value: unknown): string | null => { return memberNo > 0 ? legacyUserId(memberNo) : null; }; -const hashYearbook = (row: TargetRow): string => - createHash('sha256') - .update( - JSON.stringify({ - map: row.map, - nations: row.nations, - globalHistory: row.global_history, - globalAction: row.global_action, - }) - ) - .digest('hex'); +const hashYearbook = (map: JsonValue, nations: JsonValue, globalHistory: JsonValue, globalAction: JsonValue): string => + createHash('sha256').update(JSON.stringify({ map, nations, globalHistory, globalAction })).digest('hex'); + +const asJsonRecord = (value: JsonValue): Record => + value !== null && !Array.isArray(value) && typeof value === 'object' ? value : {}; + +export const resolveLegacyGameOpenedAt = (env: JsonValue, legacyDate: Date, context: string): Date => { + const record = asJsonRecord(env); + const candidate = record.opentime ?? record.starttime; + return candidate === null || candidate === undefined || candidate === '' ? legacyDate : toDate(candidate, context); +}; const migrateSimpleTable = async ( source: MariaPool, @@ -75,17 +91,24 @@ const migrateSimpleTable = async ( } }; -const migrateHall = (source: MariaPool, target: PoolClient | null, counts: Record): Promise => +const migrateHall = ( + source: MariaPool, + target: PoolClient | null, + counts: Record, + archive: ArchiveMigrationContext +): Promise => migrateSimpleTable( source, target, 'hall', 'id', - 'hall', - ['server_id', 'type', 'general_no'], + 'legacy_archive.hall', + ['source_profile', 'server_id', 'type', 'general_no'], (row) => { const sourceId = toNumber(row.id, 'hall.id'); return { + source_profile: archive.profile, + legacy_id: sourceId, server_id: toStringValue(row.server_id, `hall.${sourceId}.server_id`), season: toNumber(row.season, `hall.${sourceId}.season`), scenario: toNumber(row.scenario, `hall.${sourceId}.scenario`), @@ -94,24 +117,36 @@ const migrateHall = (source: MariaPool, target: PoolClient | null, counts: Recor value: toFloat(row.value, `hall.${sourceId}.value`), owner: ownerId(row.owner), aux: parseJson(row.aux, `hall.${sourceId}.aux`), + import_run_id: archive.importRunId, }; }, counts ); -const migrateGames = (source: MariaPool, target: PoolClient | null, counts: Record): Promise => +const migrateGames = ( + source: MariaPool, + target: PoolClient | null, + counts: Record, + archive: ArchiveMigrationContext +): Promise => migrateSimpleTable( source, target, 'ng_games', 'id', - 'ng_games', - ['server_id'], + 'legacy_archive.game_history', + ['source_profile', 'server_id'], (row) => { const sourceId = toNumber(row.id, 'ng_games.id'); + const legacyDate = toDate(row.date, `ng_games.${sourceId}.date`); + const env = parseJson(row.env, `ng_games.${sourceId}.env`); return { + source_profile: archive.profile, server_id: toStringValue(row.server_id, `ng_games.${sourceId}.server_id`), - date: toDate(row.date, `ng_games.${sourceId}.date`), + legacy_id: sourceId, + opened_at: resolveLegacyGameOpenedAt(env, legacyDate, `ng_games.${sourceId}.opened_at`), + completed_at: null, + legacy_date: legacyDate, winner_nation: row.winner_nation === null ? null @@ -120,7 +155,8 @@ const migrateGames = (source: MariaPool, target: PoolClient | null, counts: Reco season: toNumber(row.season, `ng_games.${sourceId}.season`), scenario: toNumber(row.scenario, `ng_games.${sourceId}.scenario`), scenario_name: toStringValue(row.scenario_name, `ng_games.${sourceId}.scenario_name`), - env: parseJson(row.env, `ng_games.${sourceId}.env`), + raw_env: jsonParameter(env), + import_run_id: archive.importRunId, }; }, counts @@ -129,25 +165,36 @@ const migrateGames = (source: MariaPool, target: PoolClient | null, counts: Reco const migrateOldGenerals = ( source: MariaPool, target: PoolClient | null, - counts: Record + counts: Record, + archive: ArchiveMigrationContext ): Promise => migrateSimpleTable( source, target, 'ng_old_generals', 'id', - 'ng_old_generals', - ['server_id', 'general_no'], + 'legacy_archive.general', + ['source_profile', 'server_id', 'general_no'], (row) => { const sourceId = toNumber(row.id, 'ng_old_generals.id'); + const name = toStringValue(row.name, `ng_old_generals.${sourceId}.name`); + const rawData = parseJson(row.data, `ng_old_generals.${sourceId}.data`); + const normalized = normalizeArchivedGeneral(rawData as ArchivedJsonValue, name); + archive.sourceFormats[normalized.sourceFormat] += 1; return { + source_profile: archive.profile, server_id: toStringValue(row.server_id, `ng_old_generals.${sourceId}.server_id`), general_no: toNumber(row.general_no, `ng_old_generals.${sourceId}.general_no`), + legacy_id: sourceId, owner: ownerId(row.owner), - name: toStringValue(row.name, `ng_old_generals.${sourceId}.name`), + name, last_yearmonth: toNumber(row.last_yearmonth, `ng_old_generals.${sourceId}.last_yearmonth`), turntime: toDate(row.turntime, `ng_old_generals.${sourceId}.turntime`), - data: parseJson(row.data, `ng_old_generals.${sourceId}.data`), + schema_version: normalized.snapshot.schemaVersion, + source_format: normalized.sourceFormat, + data: jsonParameter(normalized.snapshot), + raw_data: jsonParameter(rawData), + import_run_id: archive.importRunId, }; }, counts @@ -156,41 +203,47 @@ const migrateOldGenerals = ( const migrateOldNations = ( source: MariaPool, target: PoolClient | null, - counts: Record + counts: Record, + archive: ArchiveMigrationContext ): Promise => migrateSimpleTable( source, target, 'ng_old_nations', 'id', - 'ng_old_nations', - ['server_id', 'nation', 'source_id'], + 'legacy_archive.nation', + ['source_profile', 'legacy_id'], (row) => { const sourceId = toNumber(row.id, 'ng_old_nations.id'); return { + source_profile: archive.profile, + legacy_id: sourceId, server_id: toStringValue(row.server_id, `ng_old_nations.${sourceId}.server_id`), nation: toNumber(row.nation, `ng_old_nations.${sourceId}.nation`), - source_id: sourceId, - data: parseJson(row.data, `ng_old_nations.${sourceId}.data`), - date: toDate(row.date, `ng_old_nations.${sourceId}.date`), + data: jsonParameter(parseJson(row.data, `ng_old_nations.${sourceId}.data`)), + archived_at: toDate(row.date, `ng_old_nations.${sourceId}.date`), + import_run_id: archive.importRunId, }; }, counts ); -const migrateEmperors = (source: MariaPool, target: PoolClient | null, counts: Record): Promise => +const migrateEmperors = ( + source: MariaPool, + target: PoolClient | null, + counts: Record, + archive: ArchiveMigrationContext +): Promise => migrateSimpleTable( source, target, 'emperior', 'no', - 'emperior', - ['legacy_id'], + 'legacy_archive.emperor', + ['source_profile', 'legacy_id'], (row) => { const id = toNumber(row.no, 'emperior.no'); - return { - legacy_id: id, - server_id: toNullableString(row.server_id), + const data = { phase: toNullableString(row.phase), nation_count: toNullableString(row.nation_count), nation_name: toNullableString(row.nation_name), @@ -232,6 +285,13 @@ const migrateEmperors = (source: MariaPool, target: PoolClient | null, counts: R history: parseNullableJson(row.history, [], `emperior.${id}.history`), aux: parseNullableJson(row.aux, {}, `emperior.${id}.aux`), }; + return { + source_profile: archive.profile, + legacy_id: id, + server_id: toNullableString(row.server_id), + data: jsonParameter(data), + import_run_id: archive.importRunId, + }; }, counts ); @@ -295,30 +355,35 @@ const migrateUserRecords = ( const migrateYearbook = async ( source: MariaPool, target: PoolClient | null, - counts: Record + counts: Record, + archive: ArchiveMigrationContext ): Promise => { await migrateSimpleTable( source, target, 'ng_history', 'no', - 'yearbook_history', - ['profile_name', 'year', 'month', 'source_id'], + 'legacy_archive.yearbook', + ['source_profile', 'legacy_id'], (row) => { const id = toNumber(row.no, 'ng_history.no'); + const map = parseNullableJson(row.map, {}, `ng_history.${id}.map`); + const nations = parseNullableJson(row.nations, [], `ng_history.${id}.nations`); + const globalHistory = parseNullableJson(row.global_history, [], `ng_history.${id}.global_history`); + const globalAction = parseNullableJson(row.global_action, [], `ng_history.${id}.global_action`); const mapped: TargetRow = { + source_profile: archive.profile, + legacy_id: id, profile_name: toStringValue(row.server_id, `ng_history.${id}.server_id`), - source_id: id, year: toNumber(row.year, `ng_history.${id}.year`), month: toNumber(row.month, `ng_history.${id}.month`), - map: parseNullableJson(row.map, {}, `ng_history.${id}.map`), - nations: parseNullableJson(row.nations, [], `ng_history.${id}.nations`), - global_history: parseNullableJson(row.global_history, [], `ng_history.${id}.global_history`), - global_action: parseNullableJson(row.global_action, [], `ng_history.${id}.global_action`), - hash: '', - created_at: new Date(0), + map: jsonParameter(map), + nations: jsonParameter(nations), + global_history: jsonParameter(globalHistory), + global_action: jsonParameter(globalAction), + content_hash: hashYearbook(map, nations, globalHistory, globalAction), + import_run_id: archive.importRunId, }; - mapped.hash = hashYearbook(mapped); return mapped; }, counts, @@ -388,7 +453,16 @@ export const migrateGame = async ( apply: boolean, profile: string ): Promise => { + if (!isLegacyArchiveProfile(profile)) { + throw new Error(`Unsupported legacy archive profile: ${profile}`); + } const counts: Record = {}; + const sourceFormats: Record = { + 'legacy-flat-v0': 0, + 'ref-flat-v1': 0, + 'core-snapshot-v1': 0, + unknown: 0, + }; const excluded = { general: 'Current-season actor state is intentionally not transferred.', city: 'Current-season world state is intentionally not transferred.', @@ -421,25 +495,59 @@ export const migrateGame = async ( 'storage:season-state': 'Only inheritance_* and user_* long-lived namespaces are archived or projected.', }; const client = apply && targetPool ? await targetPool.connect() : null; + let importRunId: string | null = null; try { - const run = async (): Promise => { - await migrateGames(source, client, counts); - await migrateHall(source, client, counts); - await migrateOldGenerals(source, client, counts); - await migrateOldNations(source, client, counts); - await migrateEmperors(source, client, counts); + const run = async (archive: ArchiveMigrationContext): Promise => { + await migrateGames(source, client, counts, archive); + await migrateHall(source, client, counts, archive); + await migrateOldGenerals(source, client, counts, archive); + await migrateOldNations(source, client, counts, archive); + await migrateEmperors(source, client, counts, archive); await migrateInheritanceResults(source, client, counts); await migrateUserRecords(source, client, counts); await migrateStorage(source, client, counts); - await migrateYearbook(source, client, counts); + await migrateYearbook(source, client, counts, archive); }; if (client) { - await withMigrationLock(client, `sammo-legacy-game-v1:${profile}`, run); + await withMigrationLock(client, `sammo-legacy-archive-v2:${profile}`, async () => { + const created = await client.query<{ id: string }>( + `INSERT INTO "legacy_archive"."import_run" ("source_profile", "status") + VALUES ($1, 'RUNNING') RETURNING "id"`, + [profile] + ); + importRunId = created.rows[0]?.id ?? null; + if (!importRunId) throw new Error('Failed to create legacy archive import run'); + const archive: ArchiveMigrationContext = { profile, importRunId, sourceFormats }; + await client.query('BEGIN'); + try { + await run(archive); + await client.query( + `UPDATE "legacy_archive"."import_run" + SET "status" = 'COMPLETED', "finished_at" = CURRENT_TIMESTAMP, + "counts" = $2::jsonb, "source_format_summary" = $3::jsonb + WHERE "id" = $1`, + [importRunId, JSON.stringify(counts), JSON.stringify(sourceFormats)] + ); + await client.query('COMMIT'); + } catch (error) { + await client.query('ROLLBACK'); + const message = + error instanceof Error ? error.message.slice(0, 2000) : String(error).slice(0, 2000); + await client.query( + `UPDATE "legacy_archive"."import_run" + SET "status" = 'FAILED', "finished_at" = CURRENT_TIMESTAMP, + "counts" = $2::jsonb, "source_format_summary" = $3::jsonb, "error" = $4 + WHERE "id" = $1`, + [importRunId, JSON.stringify(counts), JSON.stringify(sourceFormats), message] + ); + throw error; + } + }); } else { - await run(); + await run({ profile, importRunId: '0', sourceFormats }); } } finally { client?.release(); } - return { command: 'game', apply, counts, excluded }; + return { command: 'game', apply, counts, excluded, importRunId, sourceFormatSummary: sourceFormats }; }; diff --git a/tools/legacy-db-migration/src/gateway.ts b/tools/legacy-db-migration/src/gateway.ts index 21f8c06e..2dc56dd6 100644 --- a/tools/legacy-db-migration/src/gateway.ts +++ b/tools/legacy-db-migration/src/gateway.ts @@ -24,17 +24,83 @@ export interface MigrationSummary { apply: boolean; counts: Record; excluded: Record; + importRunId?: string | null; + sourceFormatSummary?: Record; } const batchSize = 500; -const mapMember = (row: SourceRow, migratedAt: Date, lastLoginAt: Date | null): TargetRow => { +export const MEMBER_PRESERVED_COLUMNS = [ + 'login_id', + 'display_name', + 'password_hash', + 'password_salt', + 'password_reset_required', + 'roles', + 'sanctions', + 'oauth_type', + 'oauth_id', + 'email', + 'oauth_info', + 'picture', + 'image_server', + 'icon_updated_at', + 'third_party_use', + 'terms_accepted_at', + 'privacy_accepted_at', + 'kakao_verified_at', + 'kakao_talk_verified_until', + 'kakao_grace_started_at', + 'delete_after', + 'updated_at', + 'last_login_at', + 'created_at', +] as const; + +export const preflightMemberConflicts = async (target: PoolClient, rows: readonly TargetRow[]): Promise => { + if (rows.length === 0) return; + const ids = rows.map((row) => String(row.id)); + const loginIds = rows.map((row) => String(row.login_id)); + const displayNames = rows.map((row) => String(row.display_name)); + const emails = rows.map((row) => row.email).filter((value): value is string => typeof value === 'string'); + const existing = await target.query<{ + id: string; + login_id: string; + display_name: string; + email: string | null; + }>( + `SELECT "id", "login_id", "display_name", "email" + FROM "app_user" + WHERE "id" = ANY($1::text[]) + OR "login_id" = ANY($2::text[]) + OR "display_name" = ANY($3::text[]) + OR "email" = ANY($4::text[])`, + [ids, loginIds, displayNames, emails] + ); + for (const row of rows) { + const id = String(row.id); + const collision = existing.rows.find( + (candidate) => + candidate.id !== id && + (candidate.login_id === row.login_id || + candidate.display_name === row.display_name || + (row.email !== null && candidate.email === row.email)) + ); + if (collision) { + throw new Error('Target account identity collision in legacy member batch'); + } + } +}; + +export const mapMember = (row: SourceRow, migratedAt: Date, lastLoginAt: Date | null): TargetRow => { const memberNo = toNumber(row.NO, 'member.NO'); const grade = toNumber(row.GRADE, `member.${memberNo}.GRADE`); const acl = parseJson(row.acl, `member.${memberNo}.acl`); const penalty = parseJson(row.penalty, `member.${memberNo}.penalty`); const oauthInfo = parseJson(row.oauth_info, `member.${memberNo}.oauth_info`); const oauthType = row.oauth_type === 'KAKAO' ? 'KAKAO' : 'NONE'; + const oauthId = toNullableString(row.oauth_id)?.trim() || null; + const passwordHash = toStringValue(row.PW, `member.${memberNo}.PW`); const legacyData: JsonValue = { memberNo, grade, @@ -49,12 +115,13 @@ const mapMember = (row: SourceRow, migratedAt: Date, lastLoginAt: Date | null): id: legacyUserId(memberNo), login_id: toStringValue(row.ID, `member.${memberNo}.ID`).toLowerCase(), display_name: toStringValue(row.NAME, `member.${memberNo}.NAME`), - password_hash: toStringValue(row.PW, `member.${memberNo}.PW`), + password_hash: passwordHash, password_salt: toStringValue(row.salt, `member.${memberNo}.salt`), + password_reset_required: /^[a-f0-9]{128}$/i.test(passwordHash), roles: jsonParameter(mapLegacyRoles(grade, acl)), sanctions: jsonParameter(mapLegacySanctions(grade, penalty)), oauth_type: oauthType, - oauth_id: toNullableString(row.oauth_id), + oauth_id: oauthId, email: toNullableString(row.EMAIL)?.toLowerCase() ?? null, oauth_info: jsonParameter(oauthInfo), picture: toNullableString(row.PICTURE) ?? 'default.jpg', @@ -63,9 +130,9 @@ const mapMember = (row: SourceRow, migratedAt: Date, lastLoginAt: Date | null): third_party_use: toNumber(row.third_use ?? 0, `member.${memberNo}.third_use`) !== 0, terms_accepted_at: null, privacy_accepted_at: null, - kakao_verified_at: oauthType === 'KAKAO' ? migratedAt : null, + kakao_verified_at: oauthType === 'KAKAO' && oauthId ? migratedAt : null, kakao_talk_verified_until: - oauthType === 'KAKAO' + oauthType === 'KAKAO' && oauthId ? toNullableDate(row.token_valid_until, `member.${memberNo}.token_valid_until`) : null, kakao_grace_started_at: migratedAt, @@ -93,6 +160,7 @@ const loadLastLogins = async (source: MariaPool): Promise> => const processMembers = async ( source: MariaPool, target: PoolClient | null, + apply: boolean, migratedAt: Date, counts: Record ): Promise => { @@ -103,7 +171,10 @@ const processMembers = async ( return mapMember(row, migratedAt, lastLogins.get(memberNo) ?? null); }); if (target) { - await upsertRows(target, 'app_user', mapped, ['id']); + await preflightMemberConflicts(target, mapped); + } + if (target && apply) { + await upsertRows(target, 'app_user', mapped, ['id'], { preserveOnConflict: MEMBER_PRESERVED_COLUMNS }); } counts.member = (counts.member ?? 0) + mapped.length; } @@ -206,17 +277,26 @@ export const migrateGateway = async ( login_token: 'Legacy bearer tokens, IP addresses, and expired sessions are not valid in the Redis session model.', }; - const client = apply && targetPool ? await targetPool.connect() : null; + const client = targetPool ? await targetPool.connect() : null; try { const run = async (): Promise => { - await processMembers(source, client, migratedAt, counts); - await processMemberLogs(source, client, counts); - await processBannedMembers(source, client, counts); - await processRootKeyValues(source, client, counts); - await processSystem(source, client, counts); + await processMembers(source, client, apply, migratedAt, counts); + await processMemberLogs(source, apply ? client : null, counts); + await processBannedMembers(source, apply ? client : null, counts); + await processRootKeyValues(source, apply ? client : null, counts); + await processSystem(source, apply ? client : null, counts); }; - if (client) { - await withMigrationLock(client, 'sammo-legacy-gateway-v1', run); + if (client && apply) { + await withMigrationLock(client, 'sammo-legacy-gateway-v1', async () => { + await client.query('BEGIN'); + try { + await run(); + await client.query('COMMIT'); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } + }); } else { await run(); } diff --git a/tools/legacy-db-migration/test/archiveGeneral.test.ts b/tools/legacy-db-migration/test/archiveGeneral.test.ts new file mode 100644 index 00000000..b77e838e --- /dev/null +++ b/tools/legacy-db-migration/test/archiveGeneral.test.ts @@ -0,0 +1,71 @@ +import { readFile } from 'node:fs/promises'; + +import { describe, expect, it } from 'vitest'; + +import { normalizeArchivedGeneral, type ArchivedJsonValue } from '@sammo-ts/common'; + +const fixture = async (name: string): Promise => + JSON.parse(await readFile(new URL(`./fixtures/${name}`, import.meta.url), 'utf8')) as ArchivedJsonValue; + +describe('normalizeArchivedGeneral', () => { + it('normalizes the sanitized CHE legacy-flat keyset without leaking connection metadata', async () => { + const { sourceFormat, snapshot } = normalizeArchivedGeneral( + await fixture('che-old-general-legacy-flat-v0.json'), + 'fallback' + ); + + expect(sourceFormat).toBe('legacy-flat-v0'); + expect(snapshot).toMatchObject({ + schemaVersion: 1, + identity: { name: '구형테스트장수', nationId: 3 }, + stats: { leadership: 81, strength: 73, intelligence: 66 }, + mastery: { infantry: 101, archery: 202, cavalry: 303, special: 404, siege: 505 }, + battle: { + battles: 20, + wins: 12, + losses: 8, + winRate: 60, + killRate: 125, + tactics: { total: { wins: 3, draws: 1, losses: 2 } }, + }, + history: ['●첫 기록', '●둘째 기록'], + availability: { mastery: true, battleAggregates: true, tactics: true }, + }); + expect(JSON.stringify(snapshot)).not.toMatch(/"(?:ip|lastconnect|refresh)"/iu); + }); + + it('normalizes the sanitized HWE ref-flat keyset and marks absent battle records unavailable', async () => { + const { sourceFormat, snapshot } = normalizeArchivedGeneral( + await fixture('hwe-old-general-ref-flat-v1.json'), + 'fallback' + ); + + expect(sourceFormat).toBe('ref-flat-v1'); + expect(snapshot).toMatchObject({ + identity: { name: '신형테스트장수', officerLevel: 7 }, + stats: { + leadership: 91, + strength: 82, + intelligence: 74, + leadershipExperience: 11, + }, + traits: { personality: 'che_의리', specialDomestic: 'che_상재', specialWar: 'che_신산' }, + mastery: { infantry: 111, archery: 222, cavalry: 333, special: 444, siege: 555 }, + battle: { battles: null, wins: null, losses: null, winRate: null, killRate: null }, + availability: { mastery: true, battleAggregates: false, tactics: false }, + }); + expect(snapshot.availability.battleDetailLogs).toBe(false); + expect(snapshot.availability.battleResultLogs).toBe(false); + }); + + it('keeps an already-normalized version 1 snapshot stable', async () => { + const first = normalizeArchivedGeneral(await fixture('che-old-general-legacy-flat-v0.json'), 'fallback'); + const second = normalizeArchivedGeneral( + first.snapshot as unknown as ArchivedJsonValue, + first.snapshot.identity.name + ); + + expect(second.sourceFormat).toBe('core-snapshot-v1'); + expect(second.snapshot).toEqual(first.snapshot); + }); +}); diff --git a/tools/legacy-db-migration/test/db.test.ts b/tools/legacy-db-migration/test/db.test.ts new file mode 100644 index 00000000..90d9f90d --- /dev/null +++ b/tools/legacy-db-migration/test/db.test.ts @@ -0,0 +1,24 @@ +import type { PoolClient } from 'pg'; +import { describe, expect, it, vi } from 'vitest'; + +import { quoteQualifiedIdentifier, upsertRows } from '../src/db.js'; + +describe('qualified archive identifiers', () => { + it('quotes a schema-qualified table and still parameterizes values', async () => { + const query = vi.fn().mockResolvedValue({}); + await upsertRows( + { query } as unknown as PoolClient, + 'legacy_archive.general', + [{ id: 1, data: { ok: true } }], + ['id'] + ); + expect(query).toHaveBeenCalledOnce(); + expect(query.mock.calls[0]?.[0]).toContain('INSERT INTO "legacy_archive"."general"'); + expect(query.mock.calls[0]?.[1]).toEqual([1, JSON.stringify({ ok: true })]); + }); + + it.each(['legacy_archive.general.extra', 'legacy-archive.general', 'legacy_archive.General', 'public.;drop'])( + 'rejects unsafe qualified identifier %s', + (value) => expect(() => quoteQualifiedIdentifier(value)).toThrow('Unsafe SQL identifier') + ); +}); diff --git a/tools/legacy-db-migration/test/fixtures/che-old-general-legacy-flat-v0.json b/tools/legacy-db-migration/test/fixtures/che-old-general-legacy-flat-v0.json new file mode 100644 index 00000000..38e144fd --- /dev/null +++ b/tools/legacy-db-migration/test/fixtures/che-old-general-legacy-flat-v0.json @@ -0,0 +1,50 @@ +{ + "name": "구형테스트장수", + "leader": 81, + "power": 73, + "intel": 66, + "leader2": 4, + "power2": 5, + "intel2": 6, + "nation": 3, + "city": 7, + "level": 8, + "personal": 2, + "special": 4, + "special2": 5, + "experience": 12345, + "explevel": 8, + "dedication": 765, + "dedlevel": 4, + "dex0": 101, + "dex10": 202, + "dex20": 303, + "dex30": 404, + "dex40": 505, + "warnum": 20, + "killnum": 12, + "deathnum": 8, + "firenum": 7, + "killcrew": 2500, + "deathcrew": 2000, + "ttw": 3, + "ttd": 1, + "ttl": 2, + "tlw": 4, + "tld": 0, + "tll": 1, + "tiw": 5, + "tid": 2, + "til": 3, + "picture": "default.jpg", + "imgsvr": 0, + "horse": 1, + "weap": 2, + "book": 3, + "item": 4, + "history": "●첫 기록
●둘째 기록
", + "recent_war": "2020-01-02 03:04:05", + "ip": "192.0.2.1", + "lastconnect": "2020-01-02 03:04:05", + "refresh": 10 +} diff --git a/tools/legacy-db-migration/test/fixtures/hwe-old-general-ref-flat-v1.json b/tools/legacy-db-migration/test/fixtures/hwe-old-general-ref-flat-v1.json new file mode 100644 index 00000000..ae398572 --- /dev/null +++ b/tools/legacy-db-migration/test/fixtures/hwe-old-general-ref-flat-v1.json @@ -0,0 +1,36 @@ +{ + "name": "신형테스트장수", + "leadership": 91, + "strength": 82, + "intel": 74, + "leadership_exp": 11, + "strength_exp": 12, + "intel_exp": 13, + "nation": 4, + "city": 8, + "officer_level": 7, + "officer_city": 8, + "personal": "che_의리", + "special": "che_상재", + "special2": "che_신산", + "experience": 22222, + "explevel": 9, + "dedication": 999, + "dedlevel": 5, + "dex1": 111, + "dex2": 222, + "dex3": 333, + "dex4": 444, + "dex5": 555, + "picture": "default.jpg", + "imgsvr": 1, + "horse": "che_적토마", + "weapon": "che_청룡언월도", + "book": null, + "item": null, + "history": ["●최신 기록", "●이전 기록"], + "recent_war": null, + "aux": "", + "ip": "198.51.100.1", + "lastconnect": "2026-01-02 03:04:05" +} diff --git a/tools/legacy-db-migration/test/game.test.ts b/tools/legacy-db-migration/test/game.test.ts new file mode 100644 index 00000000..407d32ed --- /dev/null +++ b/tools/legacy-db-migration/test/game.test.ts @@ -0,0 +1,146 @@ +import type { Pool as MariaPool } from 'mariadb'; +import type { Pool as PgPool, PoolClient, QueryResult } from 'pg'; +import { describe, expect, it, vi } from 'vitest'; + +import { isLegacyArchiveProfile, migrateGame, resolveLegacyGameOpenedAt } from '../src/game.js'; + +const sourceRows = { + ng_games: [ + { + id: 1, + server_id: 'che_fixture_001', + date: new Date('2020-01-01T00:00:00.000Z'), + winner_nation: 3, + map: 'che', + season: 1, + scenario: 2, + scenario_name: 'fixture', + env: JSON.stringify({ opentime: '2020-01-02T00:00:00.000Z', starttime: '2020-01-03T00:00:00.000Z' }), + }, + ], + ng_old_generals: [ + { + id: 2, + server_id: 'che_fixture_001', + general_no: 10, + owner: 42, + name: 'fixture-general', + last_yearmonth: 22012, + turntime: new Date('2020-02-01T00:00:00.000Z'), + data: JSON.stringify({ leader: 80, power: 70, intel: 60, history: 'first
second
' }), + }, + ], +} satisfies Record>>; + +const sourcePool = (): MariaPool => { + const seen = new Set(); + return { + query: vi.fn(async (sql: string) => { + const table = /FROM `([a-z_]+)`/u.exec(sql)?.[1] ?? ''; + if (seen.has(table)) return []; + seen.add(table); + return sourceRows[table as keyof typeof sourceRows] ?? []; + }), + } as unknown as MariaPool; +}; + +const targetPool = (failPattern?: string) => { + const queries: Array<{ sql: string; values: readonly unknown[] }> = []; + const query = vi.fn(async (sql: string, values: readonly unknown[] = []) => { + queries.push({ sql, values }); + if (failPattern && sql.includes(failPattern)) { + failPattern = undefined; + throw new Error('synthetic archive write failure'); + } + if (sql.includes('INSERT INTO "legacy_archive"."import_run"')) { + return { rows: [{ id: '77' }], rowCount: 1 } as QueryResult<{ id: string }>; + } + return { rows: [], rowCount: 0 } as unknown as QueryResult; + }); + const client = { query, release: vi.fn() } as unknown as PoolClient; + return { + pool: { connect: vi.fn(async () => client) } as unknown as PgPool, + queries, + }; +}; + +describe('legacy archive game migration', () => { + it('uses the official profile allowlist and resolves the best opening timestamp', () => { + expect(isLegacyArchiveProfile('che')).toBe(true); + expect(isLegacyArchiveProfile('hwe')).toBe(true); + expect(isLegacyArchiveProfile('custom')).toBe(false); + expect( + resolveLegacyGameOpenedAt( + { opentime: '2020-01-02T00:00:00.000Z', starttime: '2020-01-03T00:00:00.000Z' }, + new Date('2020-01-01T00:00:00.000Z'), + 'fixture' + ).toISOString() + ).toBe('2020-01-02T00:00:00.000Z'); + expect( + resolveLegacyGameOpenedAt( + { starttime: '2020-01-03T00:00:00.000Z' }, + new Date('2020-01-01T00:00:00.000Z'), + 'fixture' + ).toISOString() + ).toBe('2020-01-03T00:00:00.000Z'); + expect(resolveLegacyGameOpenedAt({}, new Date('2020-01-01T00:00:00.000Z'), 'fixture').toISOString()).toBe( + '2020-01-01T00:00:00.000Z' + ); + }); + + it('keeps dry-run target-read-only while reporting normalized formats', async () => { + const summary = await migrateGame(sourcePool(), null, false, 'che'); + expect(summary).toMatchObject({ + apply: false, + importRunId: null, + counts: { ng_games: 1, ng_old_generals: 1 }, + sourceFormatSummary: { 'legacy-flat-v0': 1 }, + }); + }); + + it('records a completed import run and writes only archive tables for historical snapshots', async () => { + const target = targetPool(); + const summary = await migrateGame(sourcePool(), target.pool, true, 'che'); + const sql = target.queries.map((entry) => entry.sql).join('\n'); + + expect(summary.importRunId).toBe('77'); + expect(sql).toContain('INSERT INTO "legacy_archive"."game_history"'); + expect(sql).toContain('INSERT INTO "legacy_archive"."general"'); + expect(sql).not.toContain('INSERT INTO "ng_games"'); + expect(sql).not.toContain('INSERT INTO "ng_old_generals"'); + expect(sql).toContain(`SET "status" = 'COMPLETED'`); + expect(target.queries.some((entry) => entry.sql === 'BEGIN')).toBe(true); + expect(target.queries.some((entry) => entry.sql === 'COMMIT')).toBe(true); + expect(target.queries.findIndex((entry) => entry.sql.includes(`SET "status" = 'COMPLETED'`))).toBeLessThan( + target.queries.findIndex((entry) => entry.sql === 'COMMIT') + ); + }); + + it('rolls back archive writes and records a failed import run', async () => { + const target = targetPool('INSERT INTO "legacy_archive"."general"'); + await expect(migrateGame(sourcePool(), target.pool, true, 'che')).rejects.toThrow( + 'synthetic archive write failure' + ); + const sql = target.queries.map((entry) => entry.sql).join('\n'); + expect(target.queries.some((entry) => entry.sql === 'ROLLBACK')).toBe(true); + expect(sql).toContain(`SET "status" = 'FAILED'`); + expect(sql).not.toContain(`SET "status" = 'COMPLETED'`); + }); + + it('rolls back archive writes when completing the import run fails', async () => { + const target = targetPool(`SET "status" = 'COMPLETED'`); + await expect(migrateGame(sourcePool(), target.pool, true, 'che')).rejects.toThrow( + 'synthetic archive write failure' + ); + + expect(target.queries.some((entry) => entry.sql === 'COMMIT')).toBe(false); + expect(target.queries.some((entry) => entry.sql === 'ROLLBACK')).toBe(true); + expect(target.queries.some((entry) => entry.sql.includes(`SET "status" = 'FAILED'`))).toBe(true); + }); + + it('rejects an unsupported profile before reading or writing', async () => { + await expect(migrateGame(sourcePool(), null, false, 'custom')).rejects.toThrow( + 'Unsupported legacy archive profile' + ); + }); +}); diff --git a/tools/legacy-db-migration/test/gateway.test.ts b/tools/legacy-db-migration/test/gateway.test.ts new file mode 100644 index 00000000..e5e94704 --- /dev/null +++ b/tools/legacy-db-migration/test/gateway.test.ts @@ -0,0 +1,113 @@ +import type { PoolClient } from 'pg'; +import { describe, expect, it, vi } from 'vitest'; + +import { upsertRows } from '../src/db.js'; +import { mapMember, MEMBER_PRESERVED_COLUMNS, preflightMemberConflicts } from '../src/gateway.js'; + +const memberRow = (overrides: Record = {}) => ({ + NO: 7, + GRADE: 1, + acl: '{}', + penalty: '{}', + oauth_info: '{}', + oauth_type: 'KAKAO', + oauth_id: null, + PW: 'a'.repeat(128), + salt: 'member-salt', + ID: 'LegacyUser', + NAME: '레거시유저', + EMAIL: 'USER@EXAMPLE.TEST', + PICTURE: 'default.jpg', + IMGSVR: 0, + third_use: 0, + token_valid_until: null, + delete_after: null, + REG_DATE: '2020-01-01 00:00:00', + REG_NUM: 0, + BLOCK_NUM: 0, + BLOCK_DATE: null, + ...overrides, +}); + +describe('legacy gateway member migration', () => { + it('marks imported SHA-512 credentials for reset without trusting a missing Kakao ID', () => { + const mapped = mapMember(memberRow(), new Date('2026-08-17T00:00:00.000Z'), null); + + expect(mapped).toMatchObject({ + login_id: 'legacyuser', + email: 'user@example.test', + password_reset_required: true, + oauth_type: 'KAKAO', + oauth_id: null, + kakao_verified_at: null, + }); + }); + + it('preserves target-owned credentials and OAuth state on a repeated member upsert', async () => { + const query = vi.fn(async (_sql: string, _values?: unknown[]) => ({ rows: [], rowCount: 0 })); + const client = { query } as unknown as PoolClient; + + await upsertRows( + client, + 'app_user', + [ + { + id: 'legacy-id', + login_id: 'legacy-user', + password_hash: 'legacy-hash', + oauth_info: '{}', + legacy_data: '{}', + }, + ], + ['id'], + { preserveOnConflict: ['password_hash', 'oauth_info'] } + ); + + const sql = String(query.mock.calls[0]?.[0]); + expect(sql).toContain('"login_id" = EXCLUDED."login_id"'); + expect(sql).toContain('"legacy_data" = EXCLUDED."legacy_data"'); + expect(sql).not.toContain('"password_hash" = EXCLUDED."password_hash"'); + expect(sql).not.toContain('"oauth_info" = EXCLUDED."oauth_info"'); + }); + + it('preserves renamed login and display identities along with every live credential field', () => { + expect(MEMBER_PRESERVED_COLUMNS).toEqual( + expect.arrayContaining([ + 'login_id', + 'display_name', + 'password_hash', + 'password_salt', + 'password_reset_required', + 'roles', + 'sanctions', + 'oauth_id', + 'email', + 'updated_at', + 'last_login_at', + 'created_at', + ]) + ); + expect(MEMBER_PRESERVED_COLUMNS).not.toContain('legacy_data'); + }); + + it('rejects a source member when another target account already owns its identity', async () => { + const query = vi.fn(async (..._args: unknown[]) => ({ + rows: [ + { + id: 'another-target-id', + login_id: 'legacyuser', + display_name: '다른사용자', + email: 'other@example.test', + }, + ], + rowCount: 1, + })); + const client = { query } as unknown as PoolClient; + const mapped = mapMember(memberRow({ oauth_id: 'stable-kakao-id' }), new Date('2026-08-17T00:00:00Z'), null); + + await expect(preflightMemberConflicts(client, [mapped])).rejects.toThrow( + 'Target account identity collision in legacy member batch' + ); + expect(String(query.mock.calls[0]?.[0])).toContain('FROM "app_user"'); + }); +}); From d6a25c63b01d50672f68bce61e08a93f665f47d2 Mon Sep 17 00:00:00 2001 From: hided62 Date: Mon, 17 Aug 2026 15:55:43 +0000 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20=EC=9D=B4=EC=A0=84=20=EC=84=9C?= =?UTF-8?q?=EB=B2=84=20=EA=B8=B0=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=EC=9D=84=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 지난 플레이를 현재 장수 카드와 전투·숙련도·기록 컴포넌트로 표시하고, 중앙 archive의 소유자 기반 조회를 연결한다. 명예의 전당과 왕조에 현재/이전 서버 source 분리를 추가한다. --- app/game-api/src/router/archive/index.ts | 581 +++++++++++---- app/game-api/src/router/dynasty/index.ts | 160 +++- app/game-api/src/router/ranking/index.ts | 691 ++++++++++-------- .../src/services/legacyArchiveStore.ts | 264 +++++++ app/game-api/test/archiveRouter.test.ts | 197 ++++- app/game-api/test/dynastyRouter.test.ts | 99 +++ app/game-api/test/rankingRouter.test.ts | 83 ++- .../e2e/legacyArchiveViews.spec.ts | 162 ++++ app/game-frontend/e2e/pastPlays.spec.ts | 133 +++- app/game-frontend/e2e/playwright.config.mjs | 1 + .../src/components/generalRecords.ts | 10 + .../components/main/GeneralBattleSummary.vue | 122 ++++ .../components/main/GeneralRecordPanels.vue | 100 +++ .../src/views/BattleCenterView.vue | 149 +--- .../src/views/DynastyDetailView.vue | 15 +- .../src/views/DynastyListView.vue | 60 +- .../src/views/HallOfFameView.vue | 70 +- app/game-frontend/src/views/PastPlaysView.vue | 474 ++++++++---- 18 files changed, 2594 insertions(+), 777 deletions(-) create mode 100644 app/game-api/src/services/legacyArchiveStore.ts create mode 100644 app/game-frontend/e2e/legacyArchiveViews.spec.ts create mode 100644 app/game-frontend/src/components/generalRecords.ts create mode 100644 app/game-frontend/src/components/main/GeneralBattleSummary.vue create mode 100644 app/game-frontend/src/components/main/GeneralRecordPanels.vue diff --git a/app/game-api/src/router/archive/index.ts b/app/game-api/src/router/archive/index.ts index 275be3b7..49dab2b7 100644 --- a/app/game-api/src/router/archive/index.ts +++ b/app/game-api/src/router/archive/index.ts @@ -1,124 +1,326 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; -import { asRecord } from '@sammo-ts/common'; +import { + asRecord, + normalizeArchivedGeneral, + type ArchivedGeneralSnapshotV1, + type ArchivedJsonValue, +} from '@sammo-ts/common'; -import { resolveOfficerLevelName, sanitizeInternalDisplayCode } from '../../services/gameDisplayNames.js'; +import { + loadCrewTypeDisplayNames, + loadItemDisplayNames, + resolveDedicationLevelName, + resolveOfficerLevelName, + sanitizeInternalDisplayCode, +} from '../../services/gameDisplayNames.js'; +import { + findLegacyEmperors, + findLegacyGeneral, + findLegacyGeneralsByOwner, + findLegacyGames, + findLegacyNations, + LEGACY_ARCHIVE_PROFILES, + type LegacyArchiveProfile, +} from '../../services/legacyArchiveStore.js'; import { readOnlyAuthedProcedure, router } from '../../trpc.js'; import { loadTraitNames } from '../nation/shared.js'; const numberOrNull = (value: unknown): number | null => typeof value === 'number' && Number.isFinite(value) ? value : null; -const firstNumber = (record: Record, ...keys: string[]): number | null => { - for (const key of keys) { - const value = numberOrNull(record[key]); - if (value !== null) { - return value; - } - } +const textOrNull = (value: unknown): string | null => { + if (typeof value === 'string' && value.trim()) return value; + if (typeof value === 'number' && Number.isFinite(value)) return String(value); return null; }; -const displayTextOrNull = (value: unknown): string | null => { - if (typeof value === 'string') { - return value; - } - return typeof value === 'number' && Number.isFinite(value) ? String(value) : null; +const numericText = (value: string | null): number | null => { + if (value === null || value.trim() === '') return null; + const parsed = Number(value); + return Number.isFinite(parsed) ? Math.trunc(parsed) : null; }; -const parseHistory = (value: unknown): string[] => { - if (Array.isArray(value)) { - return value.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0); - } - if (typeof value !== 'string') { - return []; - } - return value - .split(//i) - .map((entry) => entry.trim()) - .filter(Boolean); -}; +const canonicalSnapshot = (value: unknown, fallbackName: string): ArchivedGeneralSnapshotV1 => + normalizeArchivedGeneral(value as ArchivedJsonValue, fallbackName).snapshot; const zPastPlayDetailInput = z.object({ + source: z.enum(['current', 'legacy']).default('current'), + sourceProfile: z.enum(LEGACY_ARCHIVE_PROFILES).optional(), serverId: z.string().trim().min(1).max(64), generalNo: z.number().int().positive(), }); +type ArchiveSource = 'current' | 'legacy'; + +interface GeneralArchiveEntry { + source: ArchiveSource; + sourceProfile: string; + serverId: string; + generalNo: number; + name: string; + lastYearMonth: number; + turnTime: Date; + snapshot: ArchivedGeneralSnapshotV1; +} + +interface ArchiveNationEntry { + source: ArchiveSource; + sourceProfile: string; + serverId: string; + nation: number; + archivedAt: Date; + data: Record; +} + +const key = (source: ArchiveSource, sourceProfile: string, serverId: string): string => + `${source}:${sourceProfile}:${serverId}`; + +const nationKey = (source: ArchiveSource, sourceProfile: string, serverId: string, nation: number): string => + `${key(source, sourceProfile, serverId)}:${nation}`; + +const resolveNation = ( + nations: Map, + entry: GeneralArchiveEntry +): { nationId: number; name: string; color: string; level: number | null } => { + const nationId = entry.snapshot.identity.nationId ?? 0; + const archived = nations.get(nationKey(entry.source, entry.sourceProfile, entry.serverId, nationId)); + return { + nationId, + name: textOrNull(archived?.data.name) ?? (nationId === 0 ? '재야' : '미상'), + color: textOrNull(archived?.data.color) ?? '#000000', + level: numberOrNull(archived?.data.level), + }; +}; + +const resolveDisplayResources = async (entries: GeneralArchiveEntry[]) => { + const [personalityNames, domesticNames, warNames, itemNames] = await Promise.all([ + loadTraitNames( + entries.map((entry) => entry.snapshot.traits.personality), + 'personality' + ), + loadTraitNames( + entries.map((entry) => entry.snapshot.traits.specialDomestic), + 'domestic' + ), + loadTraitNames( + entries.map((entry) => entry.snapshot.traits.specialWar), + 'war' + ), + loadItemDisplayNames( + entries.flatMap((entry) => [ + entry.snapshot.items.horse, + entry.snapshot.items.weapon, + entry.snapshot.items.book, + entry.snapshot.items.item, + ]) + ), + ]); + const traitName = (code: string | null, names: Awaited>): string => + code ? (names.get(code)?.name ?? sanitizeInternalDisplayCode(code)) : '-'; + const itemName = (code: string | null): string => + code ? (itemNames.get(code) ?? sanitizeInternalDisplayCode(code)) : '-'; + return { personalityNames, domesticNames, warNames, traitName, itemName }; +}; + +const buildGeneralDetail = async (entry: GeneralArchiveEntry, nation: ReturnType) => { + const snapshot = entry.snapshot; + const display = await resolveDisplayResources([entry]); + const crewTypeId = numericText(snapshot.resources.crewType); + const crewTypeNames = await loadCrewTypeDisplayNames(null, entry.sourceProfile); + const dedicationLevel = snapshot.progression.dedicationLevel ?? 0; + const maxDedicationLevel = 30; + return { + id: entry.generalNo, + name: snapshot.identity.name || entry.name, + picture: snapshot.identity.picture, + imageServer: snapshot.identity.imageServer, + npcState: snapshot.identity.npcState ?? 0, + officerLevel: snapshot.identity.officerLevel ?? 0, + officerLevelText: resolveOfficerLevelName(snapshot.identity.officerLevel ?? 0, nation.level ?? undefined), + stats: { + leadership: snapshot.stats.leadership ?? 0, + strength: snapshot.stats.strength ?? 0, + intelligence: snapshot.stats.intelligence ?? 0, + }, + gold: snapshot.resources.gold ?? 0, + rice: snapshot.resources.rice ?? 0, + crew: snapshot.resources.crew ?? 0, + train: snapshot.resources.train ?? 0, + atmos: snapshot.resources.morale ?? 0, + injury: snapshot.resources.injury ?? 0, + experience: snapshot.progression.experience ?? 0, + dedication: snapshot.progression.dedication ?? 0, + ...(snapshot.progression.age === null ? {} : { age: snapshot.progression.age }), + turnTime: entry.turnTime.toISOString(), + ...(crewTypeId === null ? {} : { crewTypeId }), + crewTypeName: crewTypeId === null ? '-' : (crewTypeNames.get(crewTypeId) ?? '-'), + traits: { + personal: display.traitName(snapshot.traits.personality, display.personalityNames), + specialDomestic: display.traitName(snapshot.traits.specialDomestic, display.domesticNames), + specialWar: display.traitName(snapshot.traits.specialWar, display.warNames), + }, + itemNames: { + horse: display.itemName(snapshot.items.horse), + weapon: display.itemName(snapshot.items.weapon), + book: display.itemName(snapshot.items.book), + item: display.itemName(snapshot.items.item), + }, + progression: { + experienceLevel: snapshot.progression.experienceLevel ?? 0, + dedicationLevel, + dedicationText: resolveDedicationLevelName(dedicationLevel, maxDedicationLevel), + statExperience: { + leadership: snapshot.stats.leadershipExperience ?? 0, + strength: snapshot.stats.strengthExperience ?? 0, + intelligence: snapshot.stats.intelligenceExperience ?? 0, + }, + statUpgradeLimit: 30, + dex: [ + snapshot.mastery.infantry, + snapshot.mastery.archery, + snapshot.mastery.cavalry, + snapshot.mastery.special, + snapshot.mastery.siege, + ].map((value) => value ?? 0), + }, + }; +}; + export const archiveRouter = router({ myPastPlays: readOnlyAuthedProcedure.query(async ({ ctx }) => { const owner = ctx.auth?.user.id; - if (!owner) { - throw new Error('Authenticated archive query is missing its user identity'); - } - const generals = await ctx.db.oldGeneral.findMany({ - where: { owner }, - orderBy: [{ lastYearMonth: 'desc' }, { serverId: 'desc' }, { generalNo: 'asc' }], - }); - if (!generals.length) { - return { seasons: [] }; - } + if (!owner) throw new Error('Authenticated archive query is missing its user identity'); - const serverIds = [...new Set(generals.map((general) => general.serverId))]; - const [games, nations, emperors] = await Promise.all([ - ctx.db.gameHistory.findMany({ - where: { serverId: { in: serverIds } }, - }), - ctx.db.oldNation.findMany({ - where: { serverId: { in: serverIds } }, - orderBy: [{ date: 'desc' }, { id: 'desc' }], - }), - ctx.db.emperor.findMany({ - where: { serverId: { in: serverIds } }, - orderBy: { id: 'desc' }, - select: { id: true, serverId: true }, + const [legacyRows, currentRows] = await Promise.all([ + findLegacyGeneralsByOwner(ctx.db, owner), + ctx.db.oldGeneral.findMany({ + where: { owner }, + orderBy: [{ lastYearMonth: 'desc' }, { serverId: 'desc' }, { generalNo: 'asc' }], }), ]); + const legacyIdentity = new Set( + legacyRows.map((row) => `${row.sourceProfile}:${row.serverId}:${row.generalNo}`) + ); + const entries: GeneralArchiveEntry[] = [ + ...legacyRows.map((row) => ({ + source: 'legacy' as const, + sourceProfile: row.sourceProfile, + serverId: row.serverId, + generalNo: row.generalNo, + name: row.name, + lastYearMonth: row.lastYearMonth, + turnTime: row.turnTime, + snapshot: canonicalSnapshot(row.data, row.name), + })), + ...currentRows + .filter((row) => !legacyIdentity.has(`${ctx.profile.id}:${row.serverId}:${row.generalNo}`)) + .map((row) => ({ + source: 'current' as const, + sourceProfile: ctx.profile.id, + serverId: row.serverId, + generalNo: row.generalNo, + name: row.name, + lastYearMonth: row.lastYearMonth, + turnTime: row.turnTime, + snapshot: canonicalSnapshot(row.data, row.name), + })), + ]; + if (entries.length === 0) return { seasons: [] }; - const gameByServer = new Map(games.map((game) => [game.serverId, game])); - const emperorByServer = new Map(); - for (const emperor of emperors) { - if (emperor.serverId && !emperorByServer.has(emperor.serverId)) { - emperorByServer.set(emperor.serverId, emperor.id); - } + const legacyKeys = Array.from( + new Map( + entries + .filter((entry) => entry.source === 'legacy') + .map((entry) => [key(entry.source, entry.sourceProfile, entry.serverId), entry]) + ).values() + ).map((entry) => ({ sourceProfile: entry.sourceProfile as LegacyArchiveProfile, serverId: entry.serverId })); + const currentServerIds = Array.from( + new Set(entries.filter((entry) => entry.source === 'current').map((entry) => entry.serverId)) + ); + const [legacyGames, legacyNationRows, legacyEmperors, currentGames, currentNationRows, currentEmperors] = + await Promise.all([ + findLegacyGames(ctx.db, legacyKeys), + findLegacyNations(ctx.db, legacyKeys), + findLegacyEmperors(ctx.db, legacyKeys), + currentServerIds.length + ? ctx.db.gameHistory.findMany({ where: { serverId: { in: currentServerIds } } }) + : [], + currentServerIds.length + ? ctx.db.oldNation.findMany({ + where: { serverId: { in: currentServerIds } }, + orderBy: [{ date: 'desc' }, { id: 'desc' }], + }) + : [], + currentServerIds.length + ? ctx.db.emperor.findMany({ + where: { serverId: { in: currentServerIds } }, + orderBy: { id: 'desc' }, + select: { id: true, serverId: true }, + }) + : [], + ]); + + const games = new Map(); + for (const row of legacyGames) { + games.set(key('legacy', row.sourceProfile, row.serverId), row); } - const nationByServerAndId = new Map(); - for (const nation of nations) { - const key = `${nation.serverId}:${nation.nation}`; - if (!nationByServerAndId.has(key)) { - nationByServerAndId.set(key, nation); - } + for (const row of currentGames) { + games.set(key('current', ctx.profile.id, row.serverId), { + openedAt: row.date, + season: row.season, + scenario: row.scenario, + scenarioName: row.scenarioName, + }); } - const archivedRoles = generals.map((general) => { - const data = asRecord(general.data); - const role = asRecord(data.role); - return { - personal: displayTextOrNull(data.personalCode ?? data.personal ?? role.personality), - special: displayTextOrNull(data.specialCode ?? data.special ?? role.specialDomestic), - special2: displayTextOrNull(data.special2Code ?? data.special2 ?? role.specialWar), + const nations = new Map(); + for (const row of legacyNationRows) { + const entry: ArchiveNationEntry = { + source: 'legacy', + sourceProfile: row.sourceProfile, + serverId: row.serverId, + nation: row.nation, + archivedAt: row.archivedAt, + data: asRecord(row.data), }; - }); - const [personalityNames, domesticNames, warNames] = await Promise.all([ - loadTraitNames( - archivedRoles.map((role) => role.personal), - 'personality' - ), - loadTraitNames( - archivedRoles.map((role) => role.special), - 'domestic' - ), - loadTraitNames( - archivedRoles.map((role) => role.special2), - 'war' - ), - ]); - const displayRole = (value: string | null, names: Awaited>): string | null => - value ? (names.get(value)?.name ?? sanitizeInternalDisplayCode(value)) : null; + const entryKey = nationKey(entry.source, entry.sourceProfile, entry.serverId, entry.nation); + if (!nations.has(entryKey)) nations.set(entryKey, entry); + } + for (const row of currentNationRows) { + const entry: ArchiveNationEntry = { + source: 'current', + sourceProfile: ctx.profile.id, + serverId: row.serverId, + nation: row.nation, + archivedAt: row.date, + data: asRecord(row.data), + }; + const entryKey = nationKey(entry.source, entry.sourceProfile, entry.serverId, entry.nation); + if (!nations.has(entryKey)) nations.set(entryKey, entry); + } + const dynastyIds = new Map(); + for (const row of legacyEmperors) { + if (row.serverId) { + const entryKey = key('legacy', row.sourceProfile, row.serverId); + if (!dynastyIds.has(entryKey)) dynastyIds.set(entryKey, Number(row.id)); + } + } + for (const row of currentEmperors) { + if (row.serverId) { + const entryKey = key('current', ctx.profile.id, row.serverId); + if (!dynastyIds.has(entryKey)) dynastyIds.set(entryKey, row.id); + } + } + const display = await resolveDisplayResources(entries); const seasons = new Map< string, { + source: ArchiveSource; + sourceProfile: string; serverId: string; + openedAt: string | null; date: string | null; season: number | null; scenario: number | null; @@ -145,85 +347,186 @@ export const archiveRouter = router({ }>; } >(); - - for (const [generalIndex, general] of generals.entries()) { - const game = gameByServer.get(general.serverId); - let season = seasons.get(general.serverId); + for (const entry of entries) { + const entryKey = key(entry.source, entry.sourceProfile, entry.serverId); + const game = games.get(entryKey); + let season = seasons.get(entryKey); if (!season) { + const openedAt = game?.openedAt.toISOString() ?? null; season = { - serverId: general.serverId, - date: game?.date.toISOString() ?? null, + source: entry.source, + sourceProfile: entry.sourceProfile, + serverId: entry.serverId, + openedAt, + date: openedAt, season: game?.season ?? null, scenario: game?.scenario ?? null, scenarioName: game?.scenarioName ?? null, - dynastyId: emperorByServer.get(general.serverId) ?? null, + dynastyId: dynastyIds.get(entryKey) ?? null, generals: [], }; - seasons.set(general.serverId, season); + seasons.set(entryKey, season); } - - const data = asRecord(general.data); - const stats = asRecord(data.stats); - const nationId = firstNumber(data, 'nationId', 'nation') ?? 0; - const nation = nationByServerAndId.get(`${general.serverId}:${nationId}`); - const nationData = asRecord(nation?.data); - const officerLevel = firstNumber(data, 'officerLevel', 'officer_level'); - const nationLevel = firstNumber(nationData, 'level', 'nationLevel'); - const archivedRole = archivedRoles[generalIndex]!; + const snapshot = entry.snapshot; + const nation = resolveNation(nations, entry); + const officerLevel = snapshot.identity.officerLevel; season.generals.push({ - generalNo: general.generalNo, - name: general.name, - lastYearMonth: general.lastYearMonth, - nationId, - nationName: displayTextOrNull(nationData.name) ?? (nationId === 0 ? '재야' : '미상'), - nationColor: displayTextOrNull(nationData.color) ?? '#000000', - leadership: firstNumber(data, 'leadership', 'leader') ?? numberOrNull(stats.leadership), - strength: firstNumber(data, 'strength', 'power') ?? numberOrNull(stats.strength), - intel: firstNumber(data, 'intel', 'intelligence') ?? numberOrNull(stats.intelligence), - experience: numberOrNull(data.experience), - dedication: numberOrNull(data.dedication), + generalNo: entry.generalNo, + name: snapshot.identity.name || entry.name, + lastYearMonth: entry.lastYearMonth, + nationId: nation.nationId, + nationName: nation.name, + nationColor: nation.color, + leadership: snapshot.stats.leadership, + strength: snapshot.stats.strength, + intel: snapshot.stats.intelligence, + experience: snapshot.progression.experience, + dedication: snapshot.progression.dedication, officerLevel, officerLevelText: - officerLevel === null - ? null - : resolveOfficerLevelName(officerLevel, nationLevel === null ? undefined : nationLevel), - personal: displayRole(archivedRole.personal, personalityNames), - special: displayRole(archivedRole.special, domesticNames), - special2: displayRole(archivedRole.special2, warNames), - historyCount: parseHistory(data.history).length, + officerLevel === null ? null : resolveOfficerLevelName(officerLevel, nation.level ?? undefined), + personal: display.traitName(snapshot.traits.personality, display.personalityNames), + special: display.traitName(snapshot.traits.specialDomestic, display.domesticNames), + special2: display.traitName(snapshot.traits.specialWar, display.warNames), + historyCount: snapshot.history.length, }); } return { seasons: [...seasons.values()].sort((left, right) => { - const leftTime = left.date ? new Date(left.date).getTime() : 0; - const rightTime = right.date ? new Date(right.date).getTime() : 0; + const leftTime = left.openedAt ? new Date(left.openedAt).getTime() : 0; + const rightTime = right.openedAt ? new Date(right.openedAt).getTime() : 0; return rightTime - leftTime || right.serverId.localeCompare(left.serverId); }), }; }), + myPastPlayDetail: readOnlyAuthedProcedure.input(zPastPlayDetailInput).query(async ({ ctx, input }) => { const owner = ctx.auth?.user.id; - if (!owner) { - throw new Error('Authenticated archive query is missing its user identity'); - } - const general = await ctx.db.oldGeneral.findFirst({ - where: { - owner, - serverId: input.serverId, - generalNo: input.generalNo, - }, - }); - if (!general) { + if (!owner) throw new Error('Authenticated archive query is missing its user identity'); + const sourceProfile = input.sourceProfile ?? ctx.profile.id; + if (input.source === 'current' && sourceProfile !== ctx.profile.id) { throw new TRPCError({ code: 'NOT_FOUND', message: '지난 장수 기록을 찾을 수 없습니다.' }); } - const data = asRecord(general.data); + + let entry: GeneralArchiveEntry | null = null; + let nationRows: ArchiveNationEntry[] = []; + let dynastyId: number | null = null; + if (input.source === 'legacy') { + if (!LEGACY_ARCHIVE_PROFILES.includes(sourceProfile as LegacyArchiveProfile)) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '지원하지 않는 이전 서버 프로필입니다.' }); + } + const profile = sourceProfile as LegacyArchiveProfile; + const row = await findLegacyGeneral(ctx.db, { + owner, + sourceProfile: profile, + serverId: input.serverId, + generalNo: input.generalNo, + }); + if (row) { + entry = { + source: 'legacy', + sourceProfile: row.sourceProfile, + serverId: row.serverId, + generalNo: row.generalNo, + name: row.name, + lastYearMonth: row.lastYearMonth, + turnTime: row.turnTime, + snapshot: canonicalSnapshot(row.data, row.name), + }; + const keyInput = [{ sourceProfile: profile, serverId: input.serverId }]; + const [nations, emperors] = await Promise.all([ + findLegacyNations(ctx.db, keyInput), + findLegacyEmperors(ctx.db, keyInput), + ]); + nationRows = nations.map((nation) => ({ + source: 'legacy', + sourceProfile: nation.sourceProfile, + serverId: nation.serverId, + nation: nation.nation, + archivedAt: nation.archivedAt, + data: asRecord(nation.data), + })); + dynastyId = Number(emperors[0]?.id ?? 0) || null; + } + } else { + const row = await ctx.db.oldGeneral.findFirst({ + where: { owner, serverId: input.serverId, generalNo: input.generalNo }, + }); + if (row) { + entry = { + source: 'current', + sourceProfile: ctx.profile.id, + serverId: row.serverId, + generalNo: row.generalNo, + name: row.name, + lastYearMonth: row.lastYearMonth, + turnTime: row.turnTime, + snapshot: canonicalSnapshot(row.data, row.name), + }; + const [nations, emperor] = await Promise.all([ + ctx.db.oldNation.findMany({ + where: { serverId: input.serverId }, + orderBy: [{ date: 'desc' }, { id: 'desc' }], + }), + ctx.db.emperor.findFirst({ where: { serverId: input.serverId }, orderBy: { id: 'desc' } }), + ]); + nationRows = nations.map((nation) => ({ + source: 'current', + sourceProfile: ctx.profile.id, + serverId: nation.serverId, + nation: nation.nation, + archivedAt: nation.date, + data: asRecord(nation.data), + })); + dynastyId = emperor?.id ?? null; + } + } + if (!entry) { + throw new TRPCError({ code: 'NOT_FOUND', message: '지난 장수 기록을 찾을 수 없습니다.' }); + } + + const nationMap = new Map(); + for (const nation of nationRows) { + const entryKey = nationKey(nation.source, nation.sourceProfile, nation.serverId, nation.nation); + if (!nationMap.has(entryKey)) nationMap.set(entryKey, nation); + } + const nation = resolveNation(nationMap, entry); + const snapshot = entry.snapshot; + const general = await buildGeneralDetail(entry, nation); + const logs = { + generalHistory: { + available: snapshot.availability.history, + entries: snapshot.history.map((text, index) => ({ id: index + 1, text })), + }, + battleDetail: { available: snapshot.availability.battleDetailLogs, entries: [] }, + battleResult: { available: snapshot.availability.battleResultLogs, entries: [] }, + generalAction: { available: false, entries: [] }, + }; return { - serverId: general.serverId, - generalNo: general.generalNo, - name: general.name, - lastYearMonth: general.lastYearMonth, - history: parseHistory(data.history), + source: entry.source, + sourceProfile: entry.sourceProfile, + serverId: entry.serverId, + generalNo: entry.generalNo, + sourceFormat: input.source === 'legacy' ? 'normalized-v1' : 'current-archive', + dynastyPath: + dynastyId === null ? null : `/dynasty/${dynastyId}${entry.source === 'legacy' ? '?source=legacy' : ''}`, + nation: { id: nation.nationId, name: nation.name, color: nation.color }, + general, + masteryAvailable: snapshot.availability.mastery, + battle: { + available: snapshot.availability.battleAggregates, + warnum: snapshot.battle.battles, + wins: snapshot.battle.wins, + losses: snapshot.battle.losses, + strategies: snapshot.battle.fireSuccesses, + killCrew: snapshot.battle.killedCrew, + deathCrew: snapshot.battle.lostCrew, + winRate: snapshot.battle.winRate, + killRate: snapshot.battle.killRate, + recentWar: snapshot.battle.recentWar, + }, + logs, }; }), }); diff --git a/app/game-api/src/router/dynasty/index.ts b/app/game-api/src/router/dynasty/index.ts index bf96f0bf..89cd8a4c 100644 --- a/app/game-api/src/router/dynasty/index.ts +++ b/app/game-api/src/router/dynasty/index.ts @@ -4,11 +4,24 @@ import { z } from 'zod'; import { asRecord } from '@sammo-ts/common'; import { procedure, router } from '../../trpc.js'; +import { + findLegacyEmperor, + findLegacyEmperors, + findLegacyGeneralsForServer, + findLegacyNations, +} from '../../services/legacyArchiveStore.js'; const zDynastyDetailInput = z.object({ emperorId: z.number().int().positive(), + source: z.enum(['current', 'legacy']).default('current'), }); +const zDynastyListInput = z + .object({ + source: z.enum(['current', 'legacy']).default('current'), + }) + .optional(); + const parseNumberArray = (value: unknown): number[] => Array.isArray(value) ? value.filter((item): item is number => typeof item === 'number' && Number.isFinite(item)) @@ -44,6 +57,41 @@ const firstFiniteNumber = (...values: unknown[]): number | null => { return null; }; +const firstText = (...values: unknown[]): string => { + for (const value of values) { + if (typeof value === 'string') return value; + if (typeof value === 'number' && Number.isFinite(value)) return String(value); + } + return ''; +}; + +const legacyEmperorListEntry = (row: Awaited>[number]) => { + const data = asRecord(row.data); + return { + id: Number(row.id), + source: 'legacy' as const, + sourceProfile: row.sourceProfile, + serverId: row.serverId ?? '', + phase: firstText(data.phase), + name: firstText(data.name), + year: firstFiniteNumber(data.year) ?? 0, + month: firstFiniteNumber(data.month) ?? 0, + color: firstText(data.color) || '#000000', + type: firstText(data.type), + power: firstFiniteNumber(data.power) ?? 0, + gennum: firstFiniteNumber(data.gennum) ?? 0, + citynum: firstFiniteNumber(data.citynum) ?? 0, + l12name: firstText(data.l12name), + l11name: firstText(data.l11name), + l10name: firstText(data.l10name), + l9name: firstText(data.l9name), + l8name: firstText(data.l8name), + l7name: firstText(data.l7name), + l6name: firstText(data.l6name), + l5name: firstText(data.l5name), + }; +}; + const firstDisplayArray = (...values: unknown[]): Array => { for (const value of values) { const parsed = parseDisplayArray(value); @@ -82,7 +130,15 @@ const formatNationLevel = (level: number | null): string => { }; export const dynastyRouter = router({ - getList: procedure.query(async ({ ctx }) => { + getList: procedure.input(zDynastyListInput).query(async ({ ctx, input }) => { + if ((input?.source ?? 'current') === 'legacy') { + const rows = await findLegacyEmperors(ctx.db); + return { + source: 'legacy' as const, + current: null, + entries: rows.map(legacyEmperorListEntry), + }; + } const [worldState, rows] = await Promise.all([ ctx.db.worldState.findFirst({ select: { @@ -96,6 +152,7 @@ export const dynastyRouter = router({ ]); return { + source: 'current' as const, current: worldState ? { year: worldState.currentYear, @@ -104,6 +161,8 @@ export const dynastyRouter = router({ : null, entries: rows.map((row) => ({ id: row.id, + source: 'current' as const, + sourceProfile: ctx.profile.id, serverId: row.serverId ?? '', phase: row.phase ?? '', name: row.name ?? '', @@ -126,6 +185,103 @@ export const dynastyRouter = router({ }; }), getDetail: procedure.input(zDynastyDetailInput).query(async ({ ctx, input }) => { + if (input.source === 'legacy') { + const archived = await findLegacyEmperor(ctx.db, input.emperorId); + if (!archived) { + throw new TRPCError({ code: 'NOT_FOUND', message: '이전 서버 왕조 정보를 찾을 수 없습니다.' }); + } + const emperor = asRecord(archived.data); + const aux = asRecord(emperor.aux); + const winnerNationId = firstFiniteNumber(aux.winnerNationId, aux.winner_nation_id); + const serverId = archived.serverId ?? ''; + const oldNationRows = serverId + ? await findLegacyNations(ctx.db, [{ sourceProfile: archived.sourceProfile, serverId }]) + : []; + const nationEntries = oldNationRows + .map((row) => { + const normalized = normalizeOldNationData(row.data); + const { data } = normalized; + const nationId = row.nation ?? firstFiniteNumber(data.nation) ?? 0; + return { + archiveId: row.legacyId, + nation: nationId, + isWinner: winnerNationId !== null && nationId === winnerNationId, + name: typeof data.name === 'string' ? data.name : nationId === 0 ? '재야' : '미상', + color: typeof data.color === 'string' ? data.color : '#000000', + type: normalized.typeCode, + typeName: formatNationType(normalized.typeCode), + level: firstFiniteNumber(data.level), + tech: normalized.tech, + maxPower: normalized.maxPower, + maxCrew: normalized.maxCrew, + maxCities: normalized.maxCities, + generals: parseNumberArray(data.generals), + history: parseTextArray(data.history), + date: row.archivedAt.toISOString(), + }; + }) + .filter((entry) => entry.nation !== 0); + const generalIds = Array.from(new Set(nationEntries.flatMap((entry) => entry.generals))); + const generalRows = serverId + ? await findLegacyGeneralsForServer(ctx.db, { + sourceProfile: archived.sourceProfile, + serverId, + generalNos: generalIds, + }) + : []; + const generalMap = new Map( + generalRows.map((row) => [row.generalNo, { name: row.name, lastYearMonth: row.lastYearMonth }]) + ); + return { + source: 'legacy' as const, + sourceProfile: archived.sourceProfile, + emperor: { + id: Number(archived.id), + serverId, + winnerNationId, + phase: firstText(emperor.phase), + nationCount: firstText(emperor.nation_count, emperor.nationCount), + nationName: firstText(emperor.nation_name, emperor.nationName), + nationHist: firstText(emperor.nation_hist, emperor.nationHist), + genCount: firstText(emperor.gen_count, emperor.genCount), + personalHist: firstText(emperor.personal_hist, emperor.personalHist), + specialHist: firstText(emperor.special_hist, emperor.specialHist), + name: firstText(emperor.name), + type: firstText(emperor.type), + color: firstText(emperor.color) || '#000000', + year: firstFiniteNumber(emperor.year) ?? 0, + month: firstFiniteNumber(emperor.month) ?? 0, + power: firstFiniteNumber(emperor.power) ?? 0, + gennum: firstFiniteNumber(emperor.gennum) ?? 0, + citynum: firstFiniteNumber(emperor.citynum) ?? 0, + pop: firstText(emperor.pop) || '0', + poprate: firstText(emperor.poprate), + gold: firstFiniteNumber(emperor.gold) ?? 0, + rice: firstFiniteNumber(emperor.rice) ?? 0, + l12name: firstText(emperor.l12name), + l11name: firstText(emperor.l11name), + l10name: firstText(emperor.l10name), + l9name: firstText(emperor.l9name), + l8name: firstText(emperor.l8name), + l7name: firstText(emperor.l7name), + l6name: firstText(emperor.l6name), + l5name: firstText(emperor.l5name), + tiger: firstText(emperor.tiger), + eagle: firstText(emperor.eagle), + gen: firstText(emperor.gen), + history: parseTextArray(emperor.history), + }, + nations: nationEntries.map((entry) => ({ + ...entry, + levelName: formatNationLevel(entry.level), + generalsFull: entry.generals.map((id) => ({ + generalNo: id, + name: generalMap.get(id)?.name ?? `#${id}`, + lastYearMonth: generalMap.get(id)?.lastYearMonth ?? null, + })), + })), + }; + } const emperor = await ctx.db.emperor.findUnique({ where: { id: input.emperorId }, }); @@ -192,6 +348,8 @@ export const dynastyRouter = router({ })); return { + source: 'current' as const, + sourceProfile: ctx.profile.id, emperor: { id: emperor.id, serverId, diff --git a/app/game-api/src/router/ranking/index.ts b/app/game-api/src/router/ranking/index.ts index 8c8d7522..f4e5917a 100644 --- a/app/game-api/src/router/ranking/index.ts +++ b/app/game-api/src/router/ranking/index.ts @@ -7,6 +7,11 @@ import { buildLegacyDefaultUniqueItemPool } from '@sammo-ts/logic/rewards/legacy import { resolveUniqueConfig } from '@sammo-ts/logic/rewards/uniqueLottery.js'; import { accessAuthedInputProcedure, accessInputProcedure, procedure, router } from '../../trpc.js'; +import { + findLegacyHallOptions, + findLegacyHallRows, + LEGACY_ARCHIVE_PROFILES, +} from '../../services/legacyArchiveStore.js'; const DEFAULT_BG_COLOR = '#330000'; const DEFAULT_FG_COLOR = '#ffffff'; @@ -82,372 +87,446 @@ const loadUniqueItems = () => { export const rankingRouter = router({ getBestGeneral: accessAuthedInputProcedure( - z - .object({ - view: z.enum(['user', 'npc']).optional(), - }) - .optional() - ) - .query(async ({ ctx, input }) => { - const worldState = await ctx.db.worldState.findFirst({ - select: { meta: true, config: true }, - }); - const meta = asRecord(worldState?.meta); - const isUnited = typeof meta.isUnited === 'number' && meta.isUnited !== 0; + z + .object({ + view: z.enum(['user', 'npc']).optional(), + }) + .optional() + ).query(async ({ ctx, input }) => { + const worldState = await ctx.db.worldState.findFirst({ + select: { meta: true, config: true }, + }); + const meta = asRecord(worldState?.meta); + const isUnited = typeof meta.isUnited === 'number' && meta.isUnited !== 0; - const view = input?.view ?? 'user'; - const npcFilter = view === 'npc' ? { gte: 2 } : { lt: 2 }; + const view = input?.view ?? 'user'; + const npcFilter = view === 'npc' ? { gte: 2 } : { lt: 2 }; - const [nations, generals] = await Promise.all([ - ctx.db.nation.findMany({ select: { id: true, name: true, color: true } }), - ctx.db.general.findMany({ - where: { npcState: npcFilter }, - orderBy: { id: 'asc' }, - select: { - id: true, - name: true, - nationId: true, - userId: true, - picture: true, - imageServer: true, - meta: true, - experience: true, - dedication: true, - horseCode: true, - weaponCode: true, - bookCode: true, - itemCode: true, - }, - }), - ]); + const [nations, generals] = await Promise.all([ + ctx.db.nation.findMany({ select: { id: true, name: true, color: true } }), + ctx.db.general.findMany({ + where: { npcState: npcFilter }, + orderBy: { id: 'asc' }, + select: { + id: true, + name: true, + nationId: true, + userId: true, + picture: true, + imageServer: true, + meta: true, + experience: true, + dedication: true, + horseCode: true, + weaponCode: true, + bookCode: true, + itemCode: true, + }, + }), + ]); - const nationMap = new Map(nations.map((nation) => [nation.id, nation])); - const generalIds = generals.map((general) => general.id); - const rankRows = await ctx.db.rankData.findMany({ - where: { generalId: { in: generalIds } }, - select: { generalId: true, type: true, value: true }, - }); - const rankMap = new Map>(); - for (const row of rankRows) { - const entry = rankMap.get(row.generalId) ?? {}; - entry[row.type] = row.value; - rankMap.set(row.generalId, entry); - } + const nationMap = new Map(nations.map((nation) => [nation.id, nation])); + const generalIds = generals.map((general) => general.id); + const rankRows = await ctx.db.rankData.findMany({ + where: { generalId: { in: generalIds } }, + select: { generalId: true, type: true, value: true }, + }); + const rankMap = new Map>(); + for (const row of rankRows) { + const entry = rankMap.get(row.generalId) ?? {}; + entry[row.type] = row.value; + rankMap.set(row.generalId, entry); + } - const types: Array<[string, 'int' | 'percent', (general: typeof generals[number], ranks: Record) => number]> = [ - ['명 성', 'int', (g) => g.experience], - ['계 급', 'int', (g) => g.dedication], - ['계 략 성 공', 'int', (_g, r) => r.firenum ?? 0], - ['전 투 횟 수', 'int', (_g, r) => r.warnum ?? 0], - ['승 리', 'int', (_g, r) => r.killnum ?? 0], - ['승 률', 'percent', (_g, r) => { + const types: Array< + [string, 'int' | 'percent', (general: (typeof generals)[number], ranks: Record) => number] + > = [ + ['명 성', 'int', (g) => g.experience], + ['계 급', 'int', (g) => g.dedication], + ['계 략 성 공', 'int', (_g, r) => r.firenum ?? 0], + ['전 투 횟 수', 'int', (_g, r) => r.warnum ?? 0], + ['승 리', 'int', (_g, r) => r.killnum ?? 0], + [ + '승 률', + 'percent', + (_g, r) => { const warnum = r.warnum ?? 0; if (warnum < 10) { return 0; } return (r.killnum ?? 0) / Math.max(1, warnum); - }], - ['점 령', 'int', (_g, r) => r.occupied ?? 0], - ['사 살', 'int', (_g, r) => r.killcrew ?? 0], - ['살 상 률', 'percent', (_g, r) => { + }, + ], + ['점 령', 'int', (_g, r) => r.occupied ?? 0], + ['사 살', 'int', (_g, r) => r.killcrew ?? 0], + [ + '살 상 률', + 'percent', + (_g, r) => { const warnum = r.warnum ?? 0; if (warnum < 10) { return 0; } return (r.killcrew ?? 0) / Math.max(1, r.deathcrew ?? 0); - }], - ['대 인 사 살', 'int', (_g, r) => r.killcrew_person ?? 0], - ['대 인 살 상 률', 'percent', (_g, r) => { + }, + ], + ['대 인 사 살', 'int', (_g, r) => r.killcrew_person ?? 0], + [ + '대 인 살 상 률', + 'percent', + (_g, r) => { const warnum = r.warnum ?? 0; if (warnum < 10) { return 0; } return (r.killcrew_person ?? 0) / Math.max(1, r.deathcrew_person ?? 0); - }], - ['보 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex1)], - ['궁 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex2)], - ['기 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex3)], - ['귀 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex4)], - ['차 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex5)], - ['전 력 전 승 률', 'percent', (_g, r) => { + }, + ], + ['보 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex1)], + ['궁 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex2)], + ['기 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex3)], + ['귀 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex4)], + ['차 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex5)], + [ + '전 력 전 승 률', + 'percent', + (_g, r) => { const total = (r.ttw ?? 0) + (r.ttd ?? 0) + (r.ttl ?? 0); if (total < 50) { return 0; } return (r.ttw ?? 0) / Math.max(1, total); - }], - ['통 솔 전 승 률', 'percent', (_g, r) => { + }, + ], + [ + '통 솔 전 승 률', + 'percent', + (_g, r) => { const total = (r.tlw ?? 0) + (r.tld ?? 0) + (r.tll ?? 0); if (total < 50) { return 0; } return (r.tlw ?? 0) / Math.max(1, total); - }], - ['일 기 토 승 률', 'percent', (_g, r) => { + }, + ], + [ + '일 기 토 승 률', + 'percent', + (_g, r) => { const total = (r.tsw ?? 0) + (r.tsd ?? 0) + (r.tsl ?? 0); if (total < 50) { return 0; } return (r.tsw ?? 0) / Math.max(1, total); - }], - ['설 전 승 률', 'percent', (_g, r) => { + }, + ], + [ + '설 전 승 률', + 'percent', + (_g, r) => { const total = (r.tiw ?? 0) + (r.tid ?? 0) + (r.til ?? 0); if (total < 50) { return 0; } return (r.tiw ?? 0) / Math.max(1, total); - }], - ['베 팅 투 자 액', 'int', (_g, r) => r.betgold ?? 0], - ['베 팅 당 첨', 'int', (_g, r) => r.betwin ?? 0], - ['베 팅 수 익 금', 'int', (_g, r) => r.betwingold ?? 0], - ['베 팅 수 익 률', 'percent', (_g, r) => { + }, + ], + ['베 팅 투 자 액', 'int', (_g, r) => r.betgold ?? 0], + ['베 팅 당 첨', 'int', (_g, r) => r.betwin ?? 0], + ['베 팅 수 익 금', 'int', (_g, r) => r.betwingold ?? 0], + [ + '베 팅 수 익 률', + 'percent', + (_g, r) => { const betgold = r.betgold ?? 0; if (betgold < 1000) { return 0; } return (r.betwingold ?? 0) / Math.max(1, betgold); - }], - ['유 산 소 모 량', 'int', (_g, r) => r.inherit_spent ?? 0], - ['유 산 획 득 량', 'int', (_g, r) => r.inherit_earned ?? 0], - ]; + }, + ], + ['유 산 소 모 량', 'int', (_g, r) => r.inherit_spent ?? 0], + ['유 산 획 득 량', 'int', (_g, r) => r.inherit_earned ?? 0], + ]; - const sections = types.map(([title, valueType, valueFn]) => { - const entries = generals + const sections = types.map(([title, valueType, valueFn]) => { + const entries = generals + .map((general) => { + const ranks = rankMap.get(general.id) ?? {}; + const value = valueFn(general, ranks); + const nation = nationMap.get(general.nationId) ?? null; + const bgColor = nation?.color ?? (general.nationId === 0 ? NEUTRAL_BG_COLOR : DEFAULT_BG_COLOR); + let display = { + id: general.id, + name: general.name, + ownerName: isUnited ? readOwnerDisplayName(general.meta) : null, + nationName: nation?.name ?? '재야', + bgColor, + fgColor: resolveLegacyTextColor(bgColor), + picture: general.picture ?? null, + imageServer: general.imageServer ?? 0, + value, + printValue: valueType === 'percent' ? percentText(value) : formatLegacyRankingNumber(value), + }; + + if ( + !isUnited && + (title === '계 략 성 공' || title === '유 산 소 모 량' || title === '유 산 획 득 량') + ) { + display = { + ...display, + name: '???', + ownerName: null, + nationName: '???', + bgColor: DEFAULT_BG_COLOR, + fgColor: resolveLegacyTextColor(DEFAULT_BG_COLOR), + picture: null, + imageServer: 0, + }; + } + return display; + }) + .filter((entry) => entry.value > 0) + .sort((a, b) => b.value - a.value) + .slice(0, 10); + + return { title, valueType, entries }; + }); + + const uniqueItems = await loadUniqueItems(); + const itemRegistry = new Map(uniqueItems.map((item) => [item.key, item])); + const uniqueConfig = resolveUniqueConfig(asRecord(asRecord(worldState?.config).const)); + if (Object.keys(uniqueConfig.allItems).length === 0) { + uniqueConfig.allItems = buildLegacyDefaultUniqueItemPool(itemRegistry); + } + const activeAuctions = await ctx.db.auction.findMany({ + where: { + type: 'UNIQUE_ITEM', + status: { in: ['OPEN', 'FINALIZING'] }, + targetCode: { not: null }, + }, + select: { targetCode: true }, + }); + const auctionCounts = new Map(); + for (const auction of activeAuctions) { + if (auction.targetCode) { + auctionCounts.set(auction.targetCode, (auctionCounts.get(auction.targetCode) ?? 0) + 1); + } + } + const slotTitles = { + horse: '명 마', + weapon: '명 검', + book: '명 서', + item: '도 구', + } as const; + const itemEntries = (['horse', 'weapon', 'book', 'item'] as const).map((slot) => { + const configuredItems = Object.entries(uniqueConfig.allItems[slot] ?? {}).reverse(); + const entries = configuredItems.flatMap(([itemKey, rawCount]) => { + const item = itemRegistry.get(itemKey); + if (!item || item.buyable) { + return []; + } + const owners = generals + .filter((general) => { + if (slot === 'horse') { + return general.horseCode === itemKey; + } + if (slot === 'weapon') { + return general.weaponCode === itemKey; + } + if (slot === 'book') { + return general.bookCode === itemKey; + } + return general.itemCode === itemKey; + }) .map((general) => { - const ranks = rankMap.get(general.id) ?? {}; - const value = valueFn(general, ranks); const nation = nationMap.get(general.nationId) ?? null; - const bgColor = - nation?.color ?? (general.nationId === 0 ? NEUTRAL_BG_COLOR : DEFAULT_BG_COLOR); - let display = { + const bgColor = nation?.color ?? (general.nationId === 0 ? NEUTRAL_BG_COLOR : DEFAULT_BG_COLOR); + return { id: general.id, name: general.name, - ownerName: isUnited ? readOwnerDisplayName(general.meta) : null, nationName: nation?.name ?? '재야', bgColor, fgColor: resolveLegacyTextColor(bgColor), picture: general.picture ?? null, imageServer: general.imageServer ?? 0, - value, - printValue: - valueType === 'percent' ? percentText(value) : formatLegacyRankingNumber(value), }; - - if (!isUnited && (title === '계 략 성 공' || title === '유 산 소 모 량' || title === '유 산 획 득 량')) { - display = { - ...display, - name: '???', - ownerName: null, - nationName: '???', - bgColor: DEFAULT_BG_COLOR, - fgColor: resolveLegacyTextColor(DEFAULT_BG_COLOR), - picture: null, - imageServer: 0, - }; - } - return display; - }) - .filter((entry) => entry.value > 0) - .sort((a, b) => b.value - a.value) - .slice(0, 10); - - return { title, valueType, entries }; - }); - - const uniqueItems = await loadUniqueItems(); - const itemRegistry = new Map(uniqueItems.map((item) => [item.key, item])); - const uniqueConfig = resolveUniqueConfig(asRecord(asRecord(worldState?.config).const)); - if (Object.keys(uniqueConfig.allItems).length === 0) { - uniqueConfig.allItems = buildLegacyDefaultUniqueItemPool(itemRegistry); - } - const activeAuctions = await ctx.db.auction.findMany({ - where: { - type: 'UNIQUE_ITEM', - status: { in: ['OPEN', 'FINALIZING'] }, - targetCode: { not: null }, - }, - select: { targetCode: true }, - }); - const auctionCounts = new Map(); - for (const auction of activeAuctions) { - if (auction.targetCode) { - auctionCounts.set(auction.targetCode, (auctionCounts.get(auction.targetCode) ?? 0) + 1); + }); + for (let index = 0; index < (auctionCounts.get(itemKey) ?? 0); index += 1) { + owners.push({ + id: 0, + name: '경매중', + nationName: '-', + bgColor: '#00582c', + fgColor: '#ffffff', + picture: null, + imageServer: 0, + }); } - } - const slotTitles = { - horse: '명 마', - weapon: '명 검', - book: '명 서', - item: '도 구', - } as const; - const itemEntries = (['horse', 'weapon', 'book', 'item'] as const).map((slot) => { - const configuredItems = Object.entries(uniqueConfig.allItems[slot] ?? {}).reverse(); - const entries = configuredItems.flatMap(([itemKey, rawCount]) => { - const item = itemRegistry.get(itemKey); - if (!item || item.buyable) { - return []; - } - const owners = generals - .filter((general) => { - if (slot === 'horse') { - return general.horseCode === itemKey; - } - if (slot === 'weapon') { - return general.weaponCode === itemKey; - } - if (slot === 'book') { - return general.bookCode === itemKey; - } - return general.itemCode === itemKey; - }) - .map((general) => { - const nation = nationMap.get(general.nationId) ?? null; - const bgColor = - nation?.color ?? (general.nationId === 0 ? NEUTRAL_BG_COLOR : DEFAULT_BG_COLOR); - return { - id: general.id, - name: general.name, - nationName: nation?.name ?? '재야', - bgColor, - fgColor: resolveLegacyTextColor(bgColor), - picture: general.picture ?? null, - imageServer: general.imageServer ?? 0, - }; - }); - for (let index = 0; index < (auctionCounts.get(itemKey) ?? 0); index += 1) { - owners.push({ - id: 0, - name: '경매중', - nationName: '-', - bgColor: '#00582c', - fgColor: '#ffffff', - picture: null, - imageServer: 0, - }); - } - const count = Math.max(0, Math.floor(rawCount)); - return Array.from({ length: count }, (_, index) => ({ - itemKey, - itemName: item.name, - itemInfo: item.info, - owner: owners[index] ?? { - id: 0, - name: '미발견', - nationName: '-', - bgColor: DEFAULT_BG_COLOR, - fgColor: resolveLegacyTextColor(DEFAULT_BG_COLOR), - picture: null, - imageServer: 0, - }, - })); - }); - return { title: slotTitles[slot], slot, entries }; + const count = Math.max(0, Math.floor(rawCount)); + return Array.from({ length: count }, (_, index) => ({ + itemKey, + itemName: item.name, + itemInfo: item.info, + owner: owners[index] ?? { + id: 0, + name: '미발견', + nationName: '-', + bgColor: DEFAULT_BG_COLOR, + fgColor: resolveLegacyTextColor(DEFAULT_BG_COLOR), + picture: null, + imageServer: 0, + }, + })); }); - - return { - isUnited, - sections, - uniqueItems: itemEntries, - }; - }), - getHallOfFameOptions: procedure.query(async ({ ctx }) => { - const rows = await ctx.db.gameHistory.findMany({ - select: { season: true, scenario: true, scenarioName: true }, - orderBy: [{ season: 'desc' }, { scenario: 'asc' }], + return { title: slotTitles[slot], slot, entries }; }); - const seasonMap = new Map }>(); - for (const row of rows) { - const entry = seasonMap.get(row.season) ?? { season: row.season, scenarios: [] }; - const scenario = entry.scenarios.find((item) => item.id === row.scenario); - if (scenario) { - scenario.count += 1; - } else { - entry.scenarios.push({ id: row.scenario, name: row.scenarioName, count: 1 }); - } - seasonMap.set(row.season, entry); - } - return Array.from(seasonMap.values()); + + return { + isUnited, + sections, + uniqueItems: itemEntries, + }; }), - getHallOfFame: accessInputProcedure( - z.object({ - season: z.number().int(), - scenario: z.number().int().optional(), - }) - ) + getHallOfFameOptions: procedure + .input(z.object({ source: z.enum(['current', 'legacy']).default('current') }).optional()) .query(async ({ ctx, input }) => { - const baseWhere = { - season: input.season, - ...(input.scenario !== undefined ? { scenario: input.scenario } : {}), - }; - - const types: Array<{ key: HallOfFameType; title: string; type: 'int' | 'percent' }> = [ - { key: 'experience', title: '명 성', type: 'int' }, - { key: 'dedication', title: '계 급', type: 'int' }, - { key: 'firenum', title: '계 략 성 공', type: 'int' }, - { key: 'warnum', title: '전 투 횟 수', type: 'int' }, - { key: 'killnum', title: '승 리', type: 'int' }, - { key: 'winrate', title: '승 률', type: 'percent' }, - { key: 'occupied', title: '점 령', type: 'int' }, - { key: 'killcrew', title: '사 살', type: 'int' }, - { key: 'killrate', title: '살 상 률', type: 'percent' }, - { key: 'killcrew_person', title: '대 인 사 살', type: 'int' }, - { key: 'killrate_person', title: '대 인 살 상 률', type: 'percent' }, - { key: 'dex1', title: '보 병 숙 련 도', type: 'int' }, - { key: 'dex2', title: '궁 병 숙 련 도', type: 'int' }, - { key: 'dex3', title: '기 병 숙 련 도', type: 'int' }, - { key: 'dex4', title: '귀 병 숙 련 도', type: 'int' }, - { key: 'dex5', title: '차 병 숙 련 도', type: 'int' }, - { key: 'ttrate', title: '전 력 전 승 률', type: 'percent' }, - { key: 'tlrate', title: '통 솔 전 승 률', type: 'percent' }, - { key: 'tsrate', title: '일 기 토 승 률', type: 'percent' }, - { key: 'tirate', title: '설 전 승 률', type: 'percent' }, - { key: 'betgold', title: '베 팅 투 자 액', type: 'int' }, - { key: 'betwin', title: '베 팅 당 첨', type: 'int' }, - { key: 'betwingold', title: '베 팅 수 익 금', type: 'int' }, - { key: 'betrate', title: '베 팅 수 익 률', type: 'percent' }, - ]; - const allowedTypes = new Set(HALL_OF_FAME_TYPES); - - const sections = await Promise.all( - types.map(async (type) => { - if (!allowedTypes.has(type.key)) { - return { title: type.title, valueType: type.type, entries: [] }; + if ((input?.source ?? 'current') === 'legacy') { + const rows = await findLegacyHallOptions(ctx.db); + const optionMap = new Map< + string, + { + sourceProfile: (typeof LEGACY_ARCHIVE_PROFILES)[number]; + season: number; + scenarios: Array<{ id: number; name: string; count: number }>; } - const rows = await ctx.db.hallOfFame.findMany({ - where: { ...baseWhere, type: type.key }, - orderBy: { value: 'desc' }, - take: 10, - }); - const entries = rows.map((row) => { - const aux = asRecord(row.aux); - return { - generalId: row.generalNo, - name: String(aux.name ?? ''), - ownerName: - typeof aux.ownerDisplayName === 'string' && aux.ownerDisplayName.length > 0 - ? aux.ownerDisplayName - : null, - nationName: String(aux.nationName ?? ''), - bgColor: String(aux.bgColor ?? DEFAULT_BG_COLOR), - fgColor: String(aux.fgColor ?? DEFAULT_FG_COLOR), - picture: typeof aux.picture === 'string' ? aux.picture : null, - imageServer: readMetaNumber(aux.imgsvr), - value: row.value, - printValue: - type.type === 'percent' - ? percentText(row.value) - : formatLegacyRankingNumber(row.value), - serverName: String(aux.serverName ?? ''), - serverIdx: readMetaNumber(aux.serverIdx), - scenarioName: String(aux.scenarioName ?? ''), - startTime: typeof aux.startTime === 'string' ? aux.startTime : null, - unitedTime: typeof aux.unitedTime === 'string' ? aux.unitedTime : null, - }; - }); - return { title: type.title, valueType: type.type, entries }; - }) - ); - - return { sections }; + >(); + for (const row of rows) { + const key = `${row.sourceProfile}:${row.season}`; + const entry = optionMap.get(key) ?? { + sourceProfile: row.sourceProfile, + season: row.season, + scenarios: [], + }; + entry.scenarios.push({ id: row.scenario, name: row.scenarioName, count: Number(row.count) }); + optionMap.set(key, entry); + } + return Array.from(optionMap.values()); + } + const rows = await ctx.db.gameHistory.findMany({ + select: { season: true, scenario: true, scenarioName: true }, + orderBy: [{ season: 'desc' }, { scenario: 'asc' }], + }); + const seasonMap = new Map< + number, + { sourceProfile: string; season: number; scenarios: Array<{ id: number; name: string; count: number }> } + >(); + for (const row of rows) { + const entry = seasonMap.get(row.season) ?? { + sourceProfile: ctx.profile.id, + season: row.season, + scenarios: [], + }; + const scenario = entry.scenarios.find((item) => item.id === row.scenario); + if (scenario) { + scenario.count += 1; + } else { + entry.scenarios.push({ id: row.scenario, name: row.scenarioName, count: 1 }); + } + seasonMap.set(row.season, entry); + } + return Array.from(seasonMap.values()); }), + getHallOfFame: accessInputProcedure( + z.object({ + source: z.enum(['current', 'legacy']).default('current'), + sourceProfile: z.enum(LEGACY_ARCHIVE_PROFILES).optional(), + season: z.number().int(), + scenario: z.number().int().optional(), + }) + ).query(async ({ ctx, input }) => { + const baseWhere = { + season: input.season, + ...(input.scenario !== undefined ? { scenario: input.scenario } : {}), + }; + + const types: Array<{ key: HallOfFameType; title: string; type: 'int' | 'percent' }> = [ + { key: 'experience', title: '명 성', type: 'int' }, + { key: 'dedication', title: '계 급', type: 'int' }, + { key: 'firenum', title: '계 략 성 공', type: 'int' }, + { key: 'warnum', title: '전 투 횟 수', type: 'int' }, + { key: 'killnum', title: '승 리', type: 'int' }, + { key: 'winrate', title: '승 률', type: 'percent' }, + { key: 'occupied', title: '점 령', type: 'int' }, + { key: 'killcrew', title: '사 살', type: 'int' }, + { key: 'killrate', title: '살 상 률', type: 'percent' }, + { key: 'killcrew_person', title: '대 인 사 살', type: 'int' }, + { key: 'killrate_person', title: '대 인 살 상 률', type: 'percent' }, + { key: 'dex1', title: '보 병 숙 련 도', type: 'int' }, + { key: 'dex2', title: '궁 병 숙 련 도', type: 'int' }, + { key: 'dex3', title: '기 병 숙 련 도', type: 'int' }, + { key: 'dex4', title: '귀 병 숙 련 도', type: 'int' }, + { key: 'dex5', title: '차 병 숙 련 도', type: 'int' }, + { key: 'ttrate', title: '전 력 전 승 률', type: 'percent' }, + { key: 'tlrate', title: '통 솔 전 승 률', type: 'percent' }, + { key: 'tsrate', title: '일 기 토 승 률', type: 'percent' }, + { key: 'tirate', title: '설 전 승 률', type: 'percent' }, + { key: 'betgold', title: '베 팅 투 자 액', type: 'int' }, + { key: 'betwin', title: '베 팅 당 첨', type: 'int' }, + { key: 'betwingold', title: '베 팅 수 익 금', type: 'int' }, + { key: 'betrate', title: '베 팅 수 익 률', type: 'percent' }, + ]; + const allowedTypes = new Set(HALL_OF_FAME_TYPES); + + const sections = await Promise.all( + types.map(async (type) => { + if (!allowedTypes.has(type.key)) { + return { title: type.title, valueType: type.type, entries: [] }; + } + const rows = + input.source === 'legacy' + ? input.sourceProfile + ? await findLegacyHallRows(ctx.db, { + sourceProfile: input.sourceProfile, + season: input.season, + ...(input.scenario === undefined ? {} : { scenario: input.scenario }), + type: type.key, + take: 10, + }) + : [] + : await ctx.db.hallOfFame.findMany({ + where: { ...baseWhere, type: type.key }, + orderBy: { value: 'desc' }, + take: 10, + }); + const entries = rows.map((row) => { + const aux = asRecord(row.aux); + return { + generalId: row.generalNo, + name: String(aux.name ?? ''), + ownerName: + typeof aux.ownerDisplayName === 'string' && aux.ownerDisplayName.length > 0 + ? aux.ownerDisplayName + : null, + nationName: String(aux.nationName ?? ''), + bgColor: String(aux.bgColor ?? DEFAULT_BG_COLOR), + fgColor: String(aux.fgColor ?? DEFAULT_FG_COLOR), + picture: typeof aux.picture === 'string' ? aux.picture : null, + imageServer: readMetaNumber(aux.imgsvr), + value: row.value, + printValue: + type.type === 'percent' ? percentText(row.value) : formatLegacyRankingNumber(row.value), + serverName: String(aux.serverName ?? ''), + serverIdx: readMetaNumber(aux.serverIdx), + scenarioName: String(aux.scenarioName ?? ''), + startTime: typeof aux.startTime === 'string' ? aux.startTime : null, + unitedTime: typeof aux.unitedTime === 'string' ? aux.unitedTime : null, + }; + }); + return { title: type.title, valueType: type.type, entries }; + }) + ); + + return { source: input.source, sourceProfile: input.sourceProfile ?? ctx.profile.id, sections }; + }), }); diff --git a/app/game-api/src/services/legacyArchiveStore.ts b/app/game-api/src/services/legacyArchiveStore.ts new file mode 100644 index 00000000..b20ef0fa --- /dev/null +++ b/app/game-api/src/services/legacyArchiveStore.ts @@ -0,0 +1,264 @@ +import { GamePrisma } from '@sammo-ts/infra'; +import { isLegacyArchiveProfile, LEGACY_ARCHIVE_PROFILES, type LegacyArchiveProfile } from '@sammo-ts/common'; + +import type { DatabaseClient } from '../context.js'; + +export { isLegacyArchiveProfile, LEGACY_ARCHIVE_PROFILES, type LegacyArchiveProfile }; + +export type LegacyArchiveDatabase = Pick; + +export interface LegacyGameHistoryRow { + sourceProfile: LegacyArchiveProfile; + serverId: string; + legacyId: number; + openedAt: Date; + completedAt: Date | null; + legacyDate: Date; + winnerNation: number | null; + map: string | null; + season: number; + scenario: number; + scenarioName: string; + rawEnv: unknown; +} + +export interface LegacyGeneralRow { + sourceProfile: LegacyArchiveProfile; + serverId: string; + generalNo: number; + legacyId: number; + owner: string | null; + name: string; + lastYearMonth: number; + turnTime: Date; + schemaVersion: number; + sourceFormat: string; + data: unknown; +} + +export interface LegacyNationRow { + sourceProfile: LegacyArchiveProfile; + legacyId: number; + serverId: string; + nation: number; + data: unknown; + archivedAt: Date; +} + +export interface LegacyEmperorRow { + id: bigint; + sourceProfile: LegacyArchiveProfile; + legacyId: number; + serverId: string | null; + data: unknown; +} + +export interface LegacyHallOptionRow { + sourceProfile: LegacyArchiveProfile; + season: number; + scenario: number; + scenarioName: string; + count: bigint; +} + +export interface LegacyHallRow { + sourceProfile: LegacyArchiveProfile; + serverId: string; + generalNo: number; + type: string; + value: number; + owner: string | null; + aux: unknown; +} + +export const findLegacyGeneralsByOwner = async ( + db: LegacyArchiveDatabase, + owner: string +): Promise => + db.$queryRaw(GamePrisma.sql` + SELECT + "source_profile" AS "sourceProfile", + "server_id" AS "serverId", + "general_no" AS "generalNo", + "legacy_id" AS "legacyId", + "owner", + "name", + "last_yearmonth" AS "lastYearMonth", + "turntime" AS "turnTime", + "schema_version" AS "schemaVersion", + "source_format" AS "sourceFormat", + "data" + FROM "legacy_archive"."general" + WHERE "owner" = ${owner} + ORDER BY "last_yearmonth" DESC, "source_profile", "server_id" DESC, "general_no" + `); + +export const findLegacyGeneral = async ( + db: LegacyArchiveDatabase, + input: { owner: string; sourceProfile: LegacyArchiveProfile; serverId: string; generalNo: number } +): Promise => { + const rows = await db.$queryRaw(GamePrisma.sql` + SELECT + "source_profile" AS "sourceProfile", + "server_id" AS "serverId", + "general_no" AS "generalNo", + "legacy_id" AS "legacyId", + "owner", + "name", + "last_yearmonth" AS "lastYearMonth", + "turntime" AS "turnTime", + "schema_version" AS "schemaVersion", + "source_format" AS "sourceFormat", + "data" + FROM "legacy_archive"."general" + WHERE "owner" = ${input.owner} + AND "source_profile" = ${input.sourceProfile} + AND "server_id" = ${input.serverId} + AND "general_no" = ${input.generalNo} + LIMIT 1 + `); + return rows[0] ?? null; +}; + +export const findLegacyGeneralsForServer = async ( + db: LegacyArchiveDatabase, + input: { sourceProfile: LegacyArchiveProfile; serverId: string; generalNos: number[] } +): Promise>> => { + if (input.generalNos.length === 0) return []; + return db.$queryRaw>>(GamePrisma.sql` + SELECT + "general_no" AS "generalNo", + "name", + "last_yearmonth" AS "lastYearMonth" + FROM "legacy_archive"."general" + WHERE "source_profile" = ${input.sourceProfile} + AND "server_id" = ${input.serverId} + AND "general_no" IN (${GamePrisma.join(input.generalNos)}) + `); +}; + +export const findLegacyGames = async ( + db: LegacyArchiveDatabase, + keys?: Array<{ sourceProfile: LegacyArchiveProfile; serverId: string }> +): Promise => { + if (keys && keys.length === 0) return []; + const condition = keys + ? GamePrisma.sql`WHERE ("source_profile", "server_id") IN (${GamePrisma.join( + keys.map(({ sourceProfile, serverId }) => GamePrisma.sql`(${sourceProfile}, ${serverId})`) + )})` + : GamePrisma.empty; + return db.$queryRaw(GamePrisma.sql` + SELECT + "source_profile" AS "sourceProfile", + "server_id" AS "serverId", + "legacy_id" AS "legacyId", + "opened_at" AS "openedAt", + "completed_at" AS "completedAt", + "legacy_date" AS "legacyDate", + "winner_nation" AS "winnerNation", + "map", + "season", + "scenario", + "scenario_name" AS "scenarioName", + "raw_env" AS "rawEnv" + FROM "legacy_archive"."game_history" + ${condition} + `); +}; + +export const findLegacyNations = async ( + db: LegacyArchiveDatabase, + keys: Array<{ sourceProfile: LegacyArchiveProfile; serverId: string }> +): Promise => { + if (keys.length === 0) return []; + return db.$queryRaw(GamePrisma.sql` + SELECT + "source_profile" AS "sourceProfile", + "legacy_id" AS "legacyId", + "server_id" AS "serverId", + "nation", + "data", + "archived_at" AS "archivedAt" + FROM "legacy_archive"."nation" + WHERE ("source_profile", "server_id") IN (${GamePrisma.join( + keys.map(({ sourceProfile, serverId }) => GamePrisma.sql`(${sourceProfile}, ${serverId})`) + )}) + ORDER BY "archived_at" DESC, "legacy_id" DESC + `); +}; + +export const findLegacyEmperors = async ( + db: LegacyArchiveDatabase, + keys?: Array<{ sourceProfile: LegacyArchiveProfile; serverId: string }> +): Promise => { + if (keys && keys.length === 0) return []; + const condition = keys + ? GamePrisma.sql`WHERE ("source_profile", "server_id") IN (${GamePrisma.join( + keys.map(({ sourceProfile, serverId }) => GamePrisma.sql`(${sourceProfile}, ${serverId})`) + )})` + : GamePrisma.empty; + return db.$queryRaw(GamePrisma.sql` + SELECT + "id", + "source_profile" AS "sourceProfile", + "legacy_id" AS "legacyId", + "server_id" AS "serverId", + "data" + FROM "legacy_archive"."emperor" + ${condition} + ORDER BY "id" DESC + `); +}; + +export const findLegacyEmperor = async (db: LegacyArchiveDatabase, id: number): Promise => { + const rows = await db.$queryRaw(GamePrisma.sql` + SELECT + "id", + "source_profile" AS "sourceProfile", + "legacy_id" AS "legacyId", + "server_id" AS "serverId", + "data" + FROM "legacy_archive"."emperor" + WHERE "id" = ${id} + LIMIT 1 + `); + return rows[0] ?? null; +}; + +export const findLegacyHallOptions = async (db: LegacyArchiveDatabase): Promise => + db.$queryRaw(GamePrisma.sql` + SELECT + "source_profile" AS "sourceProfile", + "season", + "scenario", + MAX("scenario_name") AS "scenarioName", + COUNT(*)::bigint AS "count" + FROM "legacy_archive"."game_history" + GROUP BY "source_profile", "season", "scenario" + ORDER BY "season" DESC, "source_profile", "scenario" + `); + +export const findLegacyHallRows = async ( + db: LegacyArchiveDatabase, + input: { sourceProfile: LegacyArchiveProfile; season: number; scenario?: number; type: string; take: number } +): Promise => { + const scenarioCondition = + input.scenario === undefined ? GamePrisma.empty : GamePrisma.sql`AND "scenario" = ${input.scenario}`; + return db.$queryRaw(GamePrisma.sql` + SELECT + "source_profile" AS "sourceProfile", + "server_id" AS "serverId", + "general_no" AS "generalNo", + "type", + "value", + "owner", + "aux" + FROM "legacy_archive"."hall" + WHERE "source_profile" = ${input.sourceProfile} + AND "season" = ${input.season} + ${scenarioCondition} + AND "type" = ${input.type} + ORDER BY "value" DESC + LIMIT ${input.take} + `); +}; diff --git a/app/game-api/test/archiveRouter.test.ts b/app/game-api/test/archiveRouter.test.ts index 2451600a..a0978383 100644 --- a/app/game-api/test/archiveRouter.test.ts +++ b/app/game-api/test/archiveRouter.test.ts @@ -25,8 +25,132 @@ const auth: GameSessionTokenPayload = { sanctions: {}, }; -const context = (session: GameSessionTokenPayload | null): GameApiContext => { +const context = (session: GameSessionTokenPayload | null, includeLegacy = false): GameApiContext => { const db = { + $queryRaw: async (query: { strings?: readonly string[] }) => { + if (!includeLegacy) return []; + const sql = query.strings?.join(' ') ?? ''; + if (sql.includes('legacy_archive"."general')) { + return [ + { + sourceProfile: 'hwe', + serverId: 'hwe_archive_1', + generalNo: 21, + legacyId: 21, + owner: 'user-1', + name: '이전서버장수', + lastYearMonth: 21012, + turnTime: new Date('2020-01-02T00:00:00.000Z'), + schemaVersion: 1, + sourceFormat: 'legacy-flat-v0', + data: { + schemaVersion: 1, + identity: { + name: '이전서버장수', + picture: null, + imageServer: 0, + npcState: 0, + nationId: 2, + cityId: 1, + officerLevel: 7, + officerCity: 1, + }, + stats: { + leadership: 91, + strength: 81, + intelligence: 71, + leadershipExperience: 3, + strengthExperience: 4, + intelligenceExperience: 5, + }, + progression: { + experience: 900, + experienceLevel: 3, + dedication: 800, + dedicationLevel: 2, + age: 30, + startAge: 20, + bornYear: 180, + deadYear: 250, + }, + traits: { personality: null, specialDomestic: null, specialWar: null }, + resources: { + gold: 100, + rice: 200, + crew: 300, + crewType: '1', + train: 90, + morale: 80, + injury: 0, + }, + items: { horse: null, weapon: null, book: null, item: null }, + mastery: { infantry: 1000, archery: 2000, cavalry: 3000, special: 4000, siege: 5000 }, + battle: { + battles: 10, + wins: 6, + losses: 4, + fireSuccesses: 2, + kills: 6, + deaths: 4, + killedCrew: 1000, + lostCrew: 500, + winRate: 60, + killRate: 200, + recentWar: null, + tactics: { + total: { wins: null, draws: null, losses: null }, + leadership: { wins: null, draws: null, losses: null }, + intelligence: { wins: null, draws: null, losses: null }, + }, + }, + history: ['이전 서버 열전'], + availability: { + mastery: true, + battleAggregates: true, + tactics: false, + history: true, + battleDetailLogs: false, + battleResultLogs: false, + }, + }, + }, + ]; + } + if (sql.includes('legacy_archive"."game_history')) { + return [ + { + sourceProfile: 'hwe', + serverId: 'hwe_archive_1', + legacyId: 1, + openedAt: new Date('2019-09-21T00:00:00.000Z'), + completedAt: null, + legacyDate: new Date('2019-09-21T00:00:00.000Z'), + winnerNation: 2, + map: 'legacy', + season: 1, + scenario: 7, + scenarioName: '이전 시나리오', + rawEnv: {}, + }, + ]; + } + if (sql.includes('legacy_archive"."nation')) { + return [ + { + sourceProfile: 'hwe', + legacyId: 1, + serverId: 'hwe_archive_1', + nation: 2, + data: { name: '이전국', color: '#0000ff', level: 7 }, + archivedAt: new Date('2020-01-02T00:00:00.000Z'), + }, + ]; + } + if (sql.includes('legacy_archive"."emperor')) { + return [{ id: 99n, sourceProfile: 'hwe', legacyId: 1, serverId: 'hwe_archive_1', data: {} }]; + } + return []; + }, oldGeneral: { findMany: async ({ where }: { where: { owner: string } }) => where.owner === 'user-1' @@ -82,6 +206,11 @@ const context = (session: GameSessionTokenPayload | null): GameApiContext => { lastYearMonth: 22012, turnTime: new Date('2025-01-01T00:00:00.000Z'), data: { + nation: 3, + leader: 80, + power: 70, + intel: 60, + officer_level: 8, history: '●첫 기록
●둘째 기록
', }, } @@ -116,6 +245,7 @@ const context = (session: GameSessionTokenPayload | null): GameApiContext => { }, emperor: { findMany: async () => [{ id: 7, serverId: 'che_legacy_1' }], + findFirst: async () => ({ id: 7, serverId: 'che_legacy_1' }), }, }; const redis = { @@ -146,6 +276,8 @@ describe('archive.myPastPlays', () => { const result = await appRouter.createCaller(context(auth)).archive.myPastPlays(); expect(result.seasons).toEqual([ expect.objectContaining({ + source: 'current', + sourceProfile: 'che', serverId: 'che_legacy_1', scenarioName: '테스트', dynastyId: 7, @@ -185,12 +317,27 @@ describe('archive.myPastPlays', () => { }); const result = await appRouter.createCaller(context(auth)).archive.myPastPlayDetail(input); - expect(result).toEqual({ + expect(result).toMatchObject({ + source: 'current', + sourceProfile: 'che', serverId: 'che_legacy_1', generalNo: 10, - name: '과거장수', - lastYearMonth: 22012, - history: ['●첫 기록', '●둘째 기록'], + dynastyPath: '/dynasty/7', + nation: { id: 3, name: '촉', color: '#ff0000' }, + general: expect.objectContaining({ id: 10, name: '과거장수' }), + battle: expect.objectContaining({ available: false }), + logs: { + generalHistory: { + available: true, + entries: [ + { id: 1, text: '●첫 기록' }, + { id: 2, text: '●둘째 기록' }, + ], + }, + battleDetail: { available: false, entries: [] }, + battleResult: { available: false, entries: [] }, + generalAction: { available: false, entries: [] }, + }, }); const otherUser = { @@ -202,4 +349,44 @@ describe('archive.myPastPlays', () => { code: 'NOT_FOUND', }); }); + + it('returns normalized previous-server detail from the dedicated archive without exposing raw data', async () => { + const caller = appRouter.createCaller(context(auth, true)); + const list = await caller.archive.myPastPlays(); + expect(list.seasons).toContainEqual( + expect.objectContaining({ + source: 'legacy', + sourceProfile: 'hwe', + serverId: 'hwe_archive_1', + openedAt: '2019-09-21T00:00:00.000Z', + dynastyId: 99, + generals: [expect.objectContaining({ name: '이전서버장수', nationName: '이전국' })], + }) + ); + + const detail = await caller.archive.myPastPlayDetail({ + source: 'legacy', + sourceProfile: 'hwe', + serverId: 'hwe_archive_1', + generalNo: 21, + }); + expect(detail).toMatchObject({ + source: 'legacy', + sourceProfile: 'hwe', + dynastyPath: '/dynasty/99?source=legacy', + nation: { id: 2, name: '이전국', color: '#0000ff' }, + general: expect.objectContaining({ + id: 21, + name: '이전서버장수', + stats: { leadership: 91, strength: 81, intelligence: 71 }, + progression: expect.objectContaining({ dex: [1000, 2000, 3000, 4000, 5000] }), + }), + battle: expect.objectContaining({ available: true, warnum: 10, wins: 6, winRate: 60 }), + logs: expect.objectContaining({ + generalHistory: { available: true, entries: [{ id: 1, text: '이전 서버 열전' }] }, + battleDetail: { available: false, entries: [] }, + }), + }); + expect(JSON.stringify(detail)).not.toContain('raw_data'); + }); }); diff --git a/app/game-api/test/dynastyRouter.test.ts b/app/game-api/test/dynastyRouter.test.ts index aa833c96..e4cc7f25 100644 --- a/app/game-api/test/dynastyRouter.test.ts +++ b/app/game-api/test/dynastyRouter.test.ts @@ -123,6 +123,72 @@ const buildContext = ( oldNations: Array> = [oldNation, deletedOldNation] ): GameApiContext => { const db = { + $queryRaw: async (query: { strings?: readonly string[] }) => { + const sql = query.strings?.join(' ') ?? ''; + if (sql.includes('legacy_archive"."emperor')) { + return [ + { + id: 101n, + sourceProfile: 'hwe', + legacyId: 7, + serverId: emperor.serverId, + data: { + phase: '이전 훼2기', + nation_count: emperor.nationCount, + nation_name: emperor.nationName, + nation_hist: emperor.nationHist, + gen_count: emperor.genCount, + personal_hist: emperor.personalHist, + special_hist: emperor.specialHist, + name: emperor.name, + type: emperor.type, + color: emperor.color, + year: emperor.year, + month: emperor.month, + power: emperor.power, + gennum: emperor.gennum, + citynum: emperor.citynum, + pop: emperor.pop, + poprate: emperor.poprate, + gold: emperor.gold, + rice: emperor.rice, + l12name: emperor.l12name, + l11name: emperor.l11name, + l10name: emperor.l10name, + l9name: emperor.l9name, + l8name: emperor.l8name, + l7name: emperor.l7name, + l6name: emperor.l6name, + l5name: emperor.l5name, + tiger: emperor.tiger, + eagle: emperor.eagle, + gen: emperor.gen, + history: emperor.history, + aux: emperor.aux, + }, + }, + ]; + } + if (sql.includes('legacy_archive"."nation')) { + return [ + { + sourceProfile: 'hwe', + legacyId: oldNation.id, + serverId: oldNation.serverId, + nation: oldNation.nation, + data: oldNation.data, + archivedAt: oldNation.date, + }, + ]; + } + if (sql.includes('legacy_archive"."general')) { + return [ + { generalNo: 11, name: '유비', lastYearMonth: 21504 }, + { generalNo: 12, name: '제갈량', lastYearMonth: 21504 }, + ]; + } + return []; + }, worldState: { findFirst: async () => ({ currentYear: 220, currentMonth: 1 }), }, @@ -186,6 +252,39 @@ describe('dynasty public read model', () => { ]); }); + it('reads previous-server dynasties only when the archive source is selected', async () => { + const caller = appRouter.createCaller(buildContext(null)); + const list = await caller.dynasty.getList({ source: 'legacy' }); + expect(list).toMatchObject({ + source: 'legacy', + current: null, + entries: [ + expect.objectContaining({ + id: 101, + source: 'legacy', + sourceProfile: 'hwe', + phase: '이전 훼2기', + }), + ], + }); + + const detail = await caller.dynasty.getDetail({ emperorId: 101, source: 'legacy' }); + expect(detail).toMatchObject({ + source: 'legacy', + sourceProfile: 'hwe', + emperor: expect.objectContaining({ id: 101, phase: '이전 훼2기', name: '촉' }), + nations: [ + expect.objectContaining({ + name: '촉', + generalsFull: [ + { generalNo: 11, name: '유비', lastYearMonth: 21504 }, + { generalNo: 12, name: '제갈량', lastYearMonth: 21504 }, + ], + }), + ], + }); + }); + it('exposes the same public DTO to anonymous, general owners and admins', async () => { const anonymous = appRouter.createCaller(buildContext(null)); const owner = appRouter.createCaller(buildContext(authFor('owner-a'))); diff --git a/app/game-api/test/rankingRouter.test.ts b/app/game-api/test/rankingRouter.test.ts index 18a688b4..59cdb936 100644 --- a/app/game-api/test/rankingRouter.test.ts +++ b/app/game-api/test/rankingRouter.test.ts @@ -110,6 +110,42 @@ const buildContext = (options?: { }): GameApiContext => { const selectedGeneralRows = options?.generals ?? generalRows; const db = { + $queryRaw: async (query: { strings?: readonly string[]; values?: unknown[] }) => { + const sql = query.strings?.join(' ') ?? ''; + if (sql.includes('legacy_archive"."game_history')) { + return [ + { + sourceProfile: 'hwe', + season: 1, + scenario: 7, + scenarioName: '이전 시나리오', + count: 2n, + }, + ]; + } + if (sql.includes('legacy_archive"."hall')) { + return query.values?.includes('experience') + ? [ + { + sourceProfile: 'hwe', + serverId: 'hwe-old-1', + generalNo: 9, + type: 'experience', + value: 777, + owner: 'private-legacy-owner-id', + aux: { + name: '과거장수', + ownerDisplayName: '과거소유자', + nationName: '과거국', + bgColor: '#330000', + fgColor: '#ffffff', + }, + }, + ] + : []; + } + return []; + }, worldState: { findFirst: async () => ({ meta: { isUnited: options?.isUnited ? 1 : 0 }, @@ -289,8 +325,14 @@ describe('ranking.getBestGeneral', () => { value: type === 'warnum' || type === 'deathcrew' || type === 'deathcrew_person' ? 1_000 - : type === 'ttd' || type === 'ttl' || type === 'tld' || type === 'tll' || - type === 'tsd' || type === 'tsl' || type === 'tid' || type === 'til' || + : type === 'ttd' || + type === 'ttl' || + type === 'tld' || + type === 'tll' || + type === 'tsd' || + type === 'tsl' || + type === 'tid' || + type === 'til' || type === 'betgold' ? 1_000 : general.id * 1_000, @@ -304,11 +346,14 @@ describe('ranking.getBestGeneral', () => { for (const section of result.sections) { expect(section.entries, section.title).toHaveLength(10); expect(new Set(section.entries.map((entry) => entry.id)).size, section.title).toBe(10); - expect(section.entries.every((entry) => entry.value > 0), section.title).toBe(true); + expect( + section.entries.every((entry) => entry.value > 0), + section.title + ).toBe(true); } - expect(result.sections.find((section) => section.title === '계 략 성 공')?.entries.map((entry) => entry.id)).toEqual([ - 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, - ]); + expect( + result.sections.find((section) => section.title === '계 략 성 공')?.entries.map((entry) => entry.id) + ).toEqual([12, 11, 10, 9, 8, 7, 6, 5, 4, 3]); }); it('matches PHP number_format rounding and the legacy fixed color table', () => { @@ -326,12 +371,38 @@ describe('ranking hall of fame', () => { .ranking.getHallOfFameOptions(); expect(options).toEqual([ { + sourceProfile: 'che', season: 3, scenarios: [{ id: 22, name: '가상모드22', count: 2 }], }, ]); }); + it('keeps previous-server options and rankings in the dedicated archive source', async () => { + const caller = appRouter.createCaller(buildContext({ authenticated: false })); + await expect(caller.ranking.getHallOfFameOptions({ source: 'legacy' })).resolves.toEqual([ + { + sourceProfile: 'hwe', + season: 1, + scenarios: [{ id: 7, name: '이전 시나리오', count: 2 }], + }, + ]); + + const result = await caller.ranking.getHallOfFame({ + source: 'legacy', + sourceProfile: 'hwe', + season: 1, + scenario: 7, + }); + expect(result.source).toBe('legacy'); + expect(result.sourceProfile).toBe('hwe'); + expect(result.sections[0]).toMatchObject({ + title: '명 성', + entries: [expect.objectContaining({ generalId: 9, name: '과거장수', ownerName: '과거소유자' })], + }); + expect(JSON.stringify(result)).not.toContain('private-legacy-owner-id'); + }); + it('returns an explicit display name but never exposes the stored account identifier', async () => { const result = await appRouter .createCaller(buildContext({ authenticated: false, includeOwnerDisplayName: true })) diff --git a/app/game-frontend/e2e/legacyArchiveViews.spec.ts b/app/game-frontend/e2e/legacyArchiveViews.spec.ts new file mode 100644 index 00000000..3fbac055 --- /dev/null +++ b/app/game-frontend/e2e/legacyArchiveViews.spec.ts @@ -0,0 +1,162 @@ +import { expect, test, type Page, type Route } from '@playwright/test'; + +import { gameProfile, gameTrpcRoute } from './gameTestPaths.js'; + +const response = (data: unknown) => ({ result: { data } }); +const operationNames = (route: Route) => + decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(','); + +const isLegacyRequest = (route: Route): boolean => + decodeURIComponent(`${route.request().url()} ${route.request().postData() ?? ''}`).includes('legacy'); + +const installArchiveViews = async (page: Page) => { + await page.addInitScript((profile) => { + localStorage.setItem('sammo-game-token', 'ga_archive_views'); + localStorage.setItem('sammo-game-profile', profile); + }, gameProfile); + await page.route(gameTrpcRoute, async (route) => { + const legacy = isLegacyRequest(route); + const results = operationNames(route).map((operation) => { + if (operation === 'auth.status') return response({ ok: true }); + if (operation === 'lobby.info') return response({ myGeneral: null }); + if (operation === 'ranking.getHallOfFameOptions') { + return response([ + { + sourceProfile: legacy ? 'hwe' : 'che', + season: legacy ? 1 : 2, + scenarios: [{ id: 7, name: legacy ? '이전 시나리오' : '현재 시나리오', count: 1 }], + }, + ]); + } + if (operation === 'ranking.getHallOfFame') { + return response({ + source: legacy ? 'legacy' : 'current', + sourceProfile: legacy ? 'hwe' : 'che', + sections: [ + { + title: '명 성', + valueType: 'int', + entries: [ + { + generalId: 1, + name: legacy ? '이전장수' : '현재장수', + ownerName: null, + nationName: legacy ? '이전국' : '현재국', + bgColor: '#330000', + fgColor: '#ffffff', + picture: null, + imageServer: 0, + value: 100, + printValue: '100', + }, + ], + }, + ], + }); + } + if (operation === 'dynasty.getList') { + return response({ + source: legacy ? 'legacy' : 'current', + current: legacy ? null : { year: 220, month: 1 }, + entries: [ + { + id: legacy ? 101 : 1, + source: legacy ? 'legacy' : 'current', + sourceProfile: legacy ? 'hwe' : 'che', + serverId: legacy ? 'hwe-old-1' : 'che-current-1', + phase: legacy ? '이전 1기' : '현재 1기', + name: '촉', + year: 215, + month: 4, + color: '#800000', + type: '병가', + power: 100, + gennum: 5, + citynum: 3, + l12name: '유비', + l11name: '제갈량', + l10name: '관우', + l9name: '방통', + l8name: '장비', + l7name: '법정', + l6name: '조운', + l5name: '마량', + }, + ], + }); + } + if (operation === 'dynasty.getDetail') { + return response({ + source: 'legacy', + sourceProfile: 'hwe', + emperor: { + id: 101, + serverId: 'hwe-old-1', + winnerNationId: 1, + phase: '이전 1기', + nationCount: '1 / 2', + nationName: '촉', + nationHist: '병가', + genCount: '5 / 10', + personalHist: '의리', + specialHist: '상재', + name: '촉', + type: '병가', + color: '#800000', + year: 215, + month: 4, + power: 100, + gennum: 5, + citynum: 3, + pop: '1000', + poprate: '100%', + gold: 100, + rice: 100, + l12name: '유비', + l11name: '제갈량', + l10name: '관우', + l9name: '방통', + l8name: '장비', + l7name: '법정', + l6name: '조운', + l5name: '마량', + tiger: '', + eagle: '', + gen: '', + history: [], + }, + nations: [], + }); + } + return { error: { message: `unhandled ${operation}`, data: { code: 'BAD_REQUEST' } } }; + }); + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) }); + }); +}; + +test('명예의 전당은 현재 기록과 이전 서버 기록을 분리해 조회한다', async ({ page }) => { + await installArchiveViews(page); + await page.setViewportSize({ width: 1000, height: 800 }); + await page.goto('hall-of-fame'); + + await expect(page.getByText('현재장수')).toBeVisible(); + await page.getByLabel('기록 구분').selectOption('legacy'); + await expect(page.getByText('이전장수')).toBeVisible(); + await expect(page.getByLabel('시나리오 검색')).toContainText('HWE / 이전 시나리오'); + await expect(page.locator('.legacy-hall-page')).toHaveCSS('width', '1000px'); +}); + +test('왕조 일람과 상세는 이전 서버 source와 profile을 유지한다', async ({ page }) => { + await installArchiveViews(page); + await page.setViewportSize({ width: 1200, height: 800 }); + await page.goto('dynasty'); + + await expect(page.getByText('현재 1기')).toBeVisible(); + await page.getByLabel('기록 구분').selectOption('legacy'); + await expect(page.getByText(/이전 1기.*HWE 이전 서버/)).toBeVisible(); + const detailLink = page.getByRole('link', { name: '자세히' }); + await expect(detailLink).toHaveAttribute('href', /dynasty\/101\?source=legacy$/); + await detailLink.click(); + await expect(page.getByText(/이전 1기.*HWE 이전 서버/)).toBeVisible(); + await expect(page.locator('.dynasty-page')).toHaveCSS('width', '1000px'); +}); diff --git a/app/game-frontend/e2e/pastPlays.spec.ts b/app/game-frontend/e2e/pastPlays.spec.ts index c696e4be..d1965ee9 100644 --- a/app/game-frontend/e2e/pastPlays.spec.ts +++ b/app/game-frontend/e2e/pastPlays.spec.ts @@ -8,7 +8,7 @@ const response = (data: unknown) => ({ result: { data } }); const operationNames = (route: Route) => decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(','); -const installArchive = async (page: Page) => { +const installArchive = async (page: Page, options: { battleAvailable?: boolean } = {}) => { await page.addInitScript((profile) => { localStorage.setItem('sammo-game-token', 'ga_archive'); localStorage.setItem('sammo-game-profile', profile); @@ -21,7 +21,10 @@ const installArchive = async (page: Page) => { return response({ seasons: [ { + sourceProfile: 'che', + source: 'legacy', serverId: 'che_2024_01', + openedAt: '2024-01-31T00:00:00.000Z', date: '2024-01-31T00:00:00.000Z', season: 51, scenario: 2, @@ -54,11 +57,67 @@ const installArchive = async (page: Page) => { } if (operation === 'archive.myPastPlayDetail') { return response({ + sourceProfile: 'che', + source: 'legacy', serverId: 'che_2024_01', generalNo: 17, - name: '관우', - lastYearMonth: 21403, - history: ['●214년 3월: 촉에 임관', '●214년 1월: 성도에서 거병'], + dynastyPath: '/dynasty/7?source=legacy', + nation: { name: '촉', color: '#800000' }, + general: { + id: 17, + name: '관우', + picture: null, + imageServer: 0, + npcState: 0, + officerLevel: 12, + officerLevelText: '황제', + generalType: '용장', + stats: { leadership: 91, strength: 98, intelligence: 77 }, + gold: 12_000, + rice: 8_000, + crew: 7_000, + train: 100, + atmos: 100, + injury: 0, + experience: 23_000, + dedication: 1_200, + crewTypeId: 1, + crewTypeName: '보병', + traits: { personal: '대담', specialDomestic: '상재', specialWar: '신산' }, + progression: { + experienceLevel: 12, + dedicationLevel: 8, + dedicationText: '황제', + statExperience: { leadership: 12, strength: 14, intelligence: 8 }, + statUpgradeLimit: 30, + dex: [125_000, 250_000, 375_000, 500_000, 625_000], + }, + }, + masteryAvailable: true, + battle: { + available: options.battleAvailable ?? true, + warnum: 16, + wins: 10, + losses: 6, + strategies: 4, + killCrew: 12_000, + deathCrew: 8_000, + winRate: 62.5, + killRate: 75, + recentWar: '2024-01-30T03:00:00.000Z', + }, + logs: { + generalHistory: { + available: true, + entries: [ + { id: 2, text: '●214년 3월: 촉에 임관' }, + { id: 1, text: '●214년 1월: 성도에서 거병' }, + ], + }, + battleDetail: { available: false, entries: [] }, + battleResult: { available: false, entries: [] }, + generalAction: { available: false, entries: [] }, + }, }); } return { error: { message: `unhandled ${operation}`, data: { code: 'BAD_REQUEST' } } }; @@ -76,6 +135,15 @@ test('지난 플레이 관직은 숫자 대신 저장된 Ref 표시명으로 나 await expect(generalRow).not.toContainText('che_'); }); +test('보존되지 않은 과거 전투 집계는 0으로 꾸미지 않고 가용성 경계를 표시한다', async ({ page }) => { + await installArchive(page, { battleAvailable: false }); + await page.goto('past-plays'); + await page.locator('.detail-toggle').click(); + + await expect(page.locator('[data-general-battle-summary]')).toHaveText('전투 집계가 보존되지 않았습니다.'); + await expect(page.locator('[data-general-battle-summary]')).not.toContainText('승률'); +}); + test('past plays is available without a current general and preserves desktop interaction geometry', async ({ page, }) => { @@ -87,19 +155,46 @@ test('past plays is available without a current general and preserves desktop in await expect(root).toBeVisible(); await expect(page.getByRole('heading', { name: '내 지난 플레이 보기' })).toBeVisible(); await expect(page.getByText('천하쟁패 · 51기')).toBeVisible(); + await expect(page.getByText('이전 서버 기록')).toBeVisible(); + await expect(page.getByText('che', { exact: true })).toBeVisible(); + await expect(page.getByText(/2024.*개장/)).toBeVisible(); await expect(page.locator('.general-name')).toHaveText('관우'); await expect(page.locator('tbody tr').filter({ hasText: '관우' })).toContainText('황제'); - await expect(page.getByRole('link', { name: '이 기수 국가 정보' })).toHaveAttribute('href', gamePath('/dynasty/7')); - const historyToggle = page.locator('.history-toggle'); - await expect(historyToggle).toHaveText('보기 (2)'); - await historyToggle.click(); + const detailToggle = page.locator('.detail-toggle'); + await expect(detailToggle).toHaveText('상세 보기'); + await detailToggle.hover(); + const beforePress = await detailToggle.boundingBox(); + await page.mouse.down(); + expect(await detailToggle.evaluate((element) => getComputedStyle(element).transform)).not.toBe('none'); + await page.mouse.up(); + expect((await detailToggle.boundingBox())?.y).toBeCloseTo(beforePress?.y ?? 0, 0); await expect(page.getByText('214년 3월: 촉에 임관')).toBeVisible(); - await expect(historyToggle).toHaveAttribute('aria-expanded', 'true'); + await expect(detailToggle).toHaveAttribute('aria-expanded', 'true'); + await expect(page.locator('.archive-general-card')).toHaveAttribute('data-general-basic-card', ''); + await expect(page.locator('.archive-general-card [role="progressbar"]')).toHaveCount(14); + await expect(page.locator('[data-general-battle-summary]')).toContainText('승률62.5%'); + await expect(page.locator('[data-general-battle-summary]')).toContainText('살상률75.0%'); + await expect(page.locator('[data-log-type="battleDetail"]')).toContainText( + '이 기수에는 전투 기록이 보존되지 않았습니다.' + ); + await expect(page.locator('[data-log-type="battleResult"]')).toContainText( + '이 기수에는 전투 결과가 보존되지 않았습니다.' + ); + await expect(page.locator('[data-log-type="generalAction"]')).toContainText( + '이 기수에는 개인 기록이 보존되지 않았습니다.' + ); + await expect(page.locator('[data-log-type="generalHistory"] C')).toHaveCount(0); + await expect(page.getByRole('link', { name: '이 기수 국가 정보' })).toHaveAttribute( + 'href', + `${gamePath('/dynasty/7')}?source=legacy` + ); const geometry = await root.evaluate((element) => { const rect = element.getBoundingClientRect(); const titleRect = element.querySelector('.title-row')!.getBoundingClientRect(); const tableRect = element.querySelector('table')!.getBoundingClientRect(); + const detailGrid = element.querySelector('.detail-grid')!; + const card = element.querySelector('[data-general-basic-card]')!.getBoundingClientRect(); const style = getComputedStyle(element); return { x: rect.x, @@ -107,6 +202,8 @@ test('past plays is available without a current general and preserves desktop in minHeight: rect.height, title: { x: titleRect.x, y: titleRect.y, width: titleRect.width }, tableWidth: tableRect.width, + detailColumns: getComputedStyle(detailGrid).gridTemplateColumns, + cardWidth: card.width, color: style.color, backgroundColor: style.backgroundColor, }; @@ -116,13 +213,14 @@ test('past plays is available without a current general and preserves desktop in width: 1000, title: { x: 100, y: 0, width: 1000 }, tableWidth: 1000, + cardWidth: 497, color: 'rgb(238, 238, 238)', - backgroundColor: 'rgb(21, 21, 21)', + backgroundColor: 'rgb(48, 32, 22)', }); const refresh = page.getByRole('button', { name: '새로고침' }); await refresh.hover(); - expect(await refresh.evaluate((element) => getComputedStyle(element).color)).toBe('rgb(135, 206, 235)'); + await expect(refresh).toHaveCSS('color', 'rgb(135, 206, 235)'); await refresh.focus(); expect(await refresh.evaluate((element) => document.activeElement === element)).toBe(true); @@ -140,6 +238,9 @@ test('past plays keeps the legacy-width table scrollable on a mobile viewport', await page.setViewportSize({ width: 390, height: 844 }); await page.goto('past-plays'); + await page.locator('.detail-toggle').click(); + await expect(page.locator('.archive-general-card')).toBeVisible(); + const scroll = page.locator('.table-scroll'); const metrics = await scroll.evaluate((element) => ({ clientWidth: element.clientWidth, @@ -149,4 +250,14 @@ test('past plays keeps the legacy-width table scrollable on a mobile viewport', // The shared legacy shell keeps its historical 500 px minimum canvas. expect(metrics).toEqual({ clientWidth: 500, scrollWidth: 940, overflowX: 'auto' }); await expect(page.locator('.title-row')).toHaveCSS('flex-direction', 'column'); + await expect(page.locator('.detail-grid')).toHaveCSS('grid-template-columns', '498px'); + const detailMetrics = await page.locator('.detail-shell').evaluate((element) => ({ + width: element.getBoundingClientRect().width, + scrollWidth: element.scrollWidth, + recordBottom: element.querySelector('[data-general-record-panels]')!.getBoundingClientRect().bottom, + shellBottom: element.getBoundingClientRect().bottom, + })); + expect(detailMetrics.width).toBe(498); + expect(detailMetrics.scrollWidth).toBe(498); + expect(detailMetrics.recordBottom).toBeLessThanOrEqual(detailMetrics.shellBottom); }); diff --git a/app/game-frontend/e2e/playwright.config.mjs b/app/game-frontend/e2e/playwright.config.mjs index cf19f087..6c766ef7 100644 --- a/app/game-frontend/e2e/playwright.config.mjs +++ b/app/game-frontend/e2e/playwright.config.mjs @@ -26,6 +26,7 @@ export default defineConfig({ 'legacyLogHtml.spec.ts', 'directoryLists.spec.ts', 'pastPlays.spec.ts', + 'legacyArchiveViews.spec.ts', 'nationGeneralSecret.spec.ts', 'npcPolicy.spec.ts', 'auction.spec.ts', diff --git a/app/game-frontend/src/components/generalRecords.ts b/app/game-frontend/src/components/generalRecords.ts new file mode 100644 index 00000000..0cde138d --- /dev/null +++ b/app/game-frontend/src/components/generalRecords.ts @@ -0,0 +1,10 @@ +export const GENERAL_RECORD_TYPES = ['generalHistory', 'battleDetail', 'battleResult', 'generalAction'] as const; + +export type GeneralRecordType = (typeof GENERAL_RECORD_TYPES)[number]; + +export type GeneralRecordEntry = { + id: number | string; + content: string; +}; + +export type GeneralRecordCollection = Partial>; diff --git a/app/game-frontend/src/components/main/GeneralBattleSummary.vue b/app/game-frontend/src/components/main/GeneralBattleSummary.vue new file mode 100644 index 00000000..d67bb894 --- /dev/null +++ b/app/game-frontend/src/components/main/GeneralBattleSummary.vue @@ -0,0 +1,122 @@ + + + + + diff --git a/app/game-frontend/src/components/main/GeneralRecordPanels.vue b/app/game-frontend/src/components/main/GeneralRecordPanels.vue new file mode 100644 index 00000000..7e756914 --- /dev/null +++ b/app/game-frontend/src/components/main/GeneralRecordPanels.vue @@ -0,0 +1,100 @@ + + + + + diff --git a/app/game-frontend/src/views/BattleCenterView.vue b/app/game-frontend/src/views/BattleCenterView.vue index 759b5101..41790f26 100644 --- a/app/game-frontend/src/views/BattleCenterView.vue +++ b/app/game-frontend/src/views/BattleCenterView.vue @@ -3,9 +3,15 @@ import { formatServerDateTime } from '@sammo-ts/common'; import { computed, onMounted, reactive, ref, watch } from 'vue'; import { useRoute } from 'vue-router'; import PanelCard from '../components/ui/PanelCard.vue'; -import SkeletonLines from '../components/ui/SkeletonLines.vue'; import LegacyGeneralProgress from '../components/ui/LegacyGeneralProgress.vue'; import GeneralBasicCard from '../components/main/GeneralBasicCard.vue'; +import GeneralBattleSummary from '../components/main/GeneralBattleSummary.vue'; +import GeneralRecordPanels from '../components/main/GeneralRecordPanels.vue'; +import { + GENERAL_RECORD_TYPES, + type GeneralRecordCollection, + type GeneralRecordType, +} from '../components/generalRecords'; import { trpc } from '../utils/trpc'; import { getNpcColor } from '../utils/npcColor'; import { formatLog } from '../utils/formatLog'; @@ -13,17 +19,10 @@ import { formatLog } from '../utils/formatLog'; type BattleCenterResponse = Awaited>; type GeneralEntry = BattleCenterResponse['generals'][number]; -type LogType = 'generalHistory' | 'battleResult' | 'battleDetail' | 'generalAction'; +type LogType = GeneralRecordType; type LogLine = { id: number; html: string }; -const logTypes: LogType[] = ['generalHistory', 'battleDetail', 'battleResult', 'generalAction']; - -const logLabels: Record = { - generalHistory: '장수 열전', - battleDetail: '전투 기록', - battleResult: '전투 결과', - generalAction: '개인 기록', -}; +const logTypes: LogType[] = [...GENERAL_RECORD_TYPES]; const orderOptions = [ { key: 'recentWar', label: '최근 전투' }, @@ -49,6 +48,12 @@ const logs = reactive>({ generalAction: [], }); +const currentRecords = computed(() => + Object.fromEntries( + logTypes.map((type) => [type, logs[type].map((entry) => ({ id: entry.id, content: entry.html }))]) + ) +); + const resolveErrorMessage = (value: unknown): string => { if (value instanceof Error) { return value.message; @@ -275,27 +280,20 @@ onMounted(() => { :nation-color="data?.nation.color" > @@ -304,16 +302,7 @@ onMounted(() => {
-
-
-
{{ logLabels[type] }}
- - -
-
+
@@ -354,81 +343,6 @@ onMounted(() => { font: inherit; } -.battle-general-extra { - display: grid; - grid-template-columns: repeat(6, 1fr); -} - -.battle-general-extra > * { - min-height: 24px; - box-sizing: border-box; - border-right: 1px solid #777; - border-bottom: 1px solid #777; - padding: 2px 5px; -} - -.battle-general-extra > span { - background-color: rgb(20 75 42 / 70%); - text-align: center; -} - -.battle-general-extra > strong { - overflow: hidden; - font-weight: 500; - text-align: right; - text-overflow: ellipsis; - white-space: nowrap; -} - -.battle-general-extra > .battle-general-extra__recent-label { - grid-column: 1; -} - -.battle-general-extra > .battle-general-extra__recent-value { - grid-column: 2 / -1; - text-align: left; -} - -.log-grid { - display: contents; -} - -.log-block { - border: 1px solid #666; - padding: 0; - background-color: #302016; - background-image: var(--sammo-texture-walnut); - min-height: 0; -} - -.log-title { - min-height: 34px; - margin: 0; - display: flex; - align-items: center; - justify-content: center; - border-bottom: 1px solid #666; - color: orange; - background: #000; - font-size: 1.3em; - font-weight: 500; -} - -.log-line { - padding: 2px 8px; - border-bottom: 0; -} - -.log-line :deep(.hidden_but_copyable) { - color: transparent !important; - font-size: 0; -} - -.empty { - padding: 2px 8px; - color: #999; -} - /* PanelCard is retained as a data wrapper, but its presentation follows the flat bootstrap rows used by the reference page. */ :deep(.panel-card) { @@ -466,8 +380,7 @@ onMounted(() => { font-size: 18px; font-weight: 500; } -:deep(.panel-header), -.log-title { +:deep(.panel-header) { background-image: var(--sammo-texture-green); } diff --git a/app/game-frontend/src/views/DynastyDetailView.vue b/app/game-frontend/src/views/DynastyDetailView.vue index 50acff83..ce963b3c 100644 --- a/app/game-frontend/src/views/DynastyDetailView.vue +++ b/app/game-frontend/src/views/DynastyDetailView.vue @@ -14,6 +14,7 @@ const router = useRouter(); const loading = ref(false); const errorMessage = ref(''); const data = ref(null); +const source = computed<'current' | 'legacy'>(() => (route.query.source === 'legacy' ? 'legacy' : 'current')); const emperorId = computed(() => { const idParam = route.params.id; @@ -39,7 +40,7 @@ const loadDetail = async (): Promise => { loading.value = true; errorMessage.value = ''; try { - data.value = await trpc.dynasty.getDetail.query({ emperorId: emperorId.value }); + data.value = await trpc.dynasty.getDetail.query({ emperorId: emperorId.value, source: source.value }); } catch (error) { data.value = null; errorMessage.value = error instanceof Error ? error.message : '왕조 정보를 불러오지 못했습니다.'; @@ -50,7 +51,7 @@ const loadDetail = async (): Promise => { const formatArchiveDate = (value: string): string => formatServerDateTime(value); -watch(emperorId, loadDetail); +watch([emperorId, source], loadDetail); onMounted(loadDetail); @@ -63,7 +64,8 @@ onMounted(loadDetail); 역 대 왕 조
- @@ -88,7 +90,12 @@ onMounted(loadDetail); - {{ data.emperor.phase }} + + {{ data.emperor.phase }} + + diff --git a/app/game-frontend/src/views/DynastyListView.vue b/app/game-frontend/src/views/DynastyListView.vue index ff4464ef..cec29466 100644 --- a/app/game-frontend/src/views/DynastyListView.vue +++ b/app/game-frontend/src/views/DynastyListView.vue @@ -1,6 +1,6 @@