From fd90bf1ebb7f8d7914d782f43c1b7af18facaa06 Mon Sep 17 00:00:00 2001 From: hided62 Date: Thu, 20 Aug 2026 15:44:28 +0000 Subject: [PATCH 1/3] =?UTF-8?q?fix(dynasty):=20=EC=99=95=EC=A1=B0=20?= =?UTF-8?q?=EC=9D=B4=EC=A0=84=20=EA=B8=B0=EB=A1=9D=EC=9D=84=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=ED=95=84=EB=B3=84=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이전 서버 왕조 목록과 직접 상세 조회를 요청 profile로 제한한다. 개발 profile은 fail-closed로 처리하고 API 및 Chromium 회귀 검증을 추가한다. --- app/game-api/src/router/dynasty/index.ts | 17 ++++-- .../src/services/legacyArchiveStore.ts | 24 +++++++- app/game-api/test/dynastyRouter.test.ts | 60 +++++++++++++------ .../e2e/legacyArchiveViews.spec.ts | 39 +++++++++--- .../src/views/DynastyDetailView.vue | 4 +- .../src/views/DynastyListView.vue | 4 +- 6 files changed, 110 insertions(+), 38 deletions(-) diff --git a/app/game-api/src/router/dynasty/index.ts b/app/game-api/src/router/dynasty/index.ts index 89cd8a4c..c7c79380 100644 --- a/app/game-api/src/router/dynasty/index.ts +++ b/app/game-api/src/router/dynasty/index.ts @@ -4,11 +4,13 @@ import { z } from 'zod'; import { asRecord } from '@sammo-ts/common'; import { procedure, router } from '../../trpc.js'; +import type { LegacyEmperorRow } from '../../services/legacyArchiveStore.js'; import { findLegacyEmperor, - findLegacyEmperors, + findLegacyEmperorsByProfile, findLegacyGeneralsForServer, findLegacyNations, + isLegacyArchiveProfile, } from '../../services/legacyArchiveStore.js'; const zDynastyDetailInput = z.object({ @@ -65,7 +67,7 @@ const firstText = (...values: unknown[]): string => { return ''; }; -const legacyEmperorListEntry = (row: Awaited>[number]) => { +const legacyEmperorListEntry = (row: LegacyEmperorRow) => { const data = asRecord(row.data); return { id: Number(row.id), @@ -132,7 +134,9 @@ const formatNationLevel = (level: number | null): string => { export const dynastyRouter = router({ getList: procedure.input(zDynastyListInput).query(async ({ ctx, input }) => { if ((input?.source ?? 'current') === 'legacy') { - const rows = await findLegacyEmperors(ctx.db); + const rows = isLegacyArchiveProfile(ctx.profile.id) + ? await findLegacyEmperorsByProfile(ctx.db, ctx.profile.id) + : []; return { source: 'legacy' as const, current: null, @@ -186,7 +190,12 @@ export const dynastyRouter = router({ }), getDetail: procedure.input(zDynastyDetailInput).query(async ({ ctx, input }) => { if (input.source === 'legacy') { - const archived = await findLegacyEmperor(ctx.db, input.emperorId); + const archived = isLegacyArchiveProfile(ctx.profile.id) + ? await findLegacyEmperor(ctx.db, { + id: input.emperorId, + sourceProfile: ctx.profile.id, + }) + : null; if (!archived) { throw new TRPCError({ code: 'NOT_FOUND', message: '이전 서버 왕조 정보를 찾을 수 없습니다.' }); } diff --git a/app/game-api/src/services/legacyArchiveStore.ts b/app/game-api/src/services/legacyArchiveStore.ts index 8a35346a..b65f932c 100644 --- a/app/game-api/src/services/legacyArchiveStore.ts +++ b/app/game-api/src/services/legacyArchiveStore.ts @@ -270,7 +270,26 @@ export const findLegacyEmperors = async ( `); }; -export const findLegacyEmperor = async (db: LegacyArchiveDatabase, id: number): Promise => { +export const findLegacyEmperorsByProfile = async ( + db: LegacyArchiveDatabase, + sourceProfile: LegacyArchiveProfile +): Promise => + db.$queryRaw(GamePrisma.sql` + SELECT + "id", + "source_profile" AS "sourceProfile", + "legacy_id" AS "legacyId", + "server_id" AS "serverId", + "data" + FROM "legacy_archive"."emperor" + WHERE "source_profile" = ${sourceProfile} + ORDER BY "id" DESC + `); + +export const findLegacyEmperor = async ( + db: LegacyArchiveDatabase, + input: { id: number; sourceProfile: LegacyArchiveProfile } +): Promise => { const rows = await db.$queryRaw(GamePrisma.sql` SELECT "id", @@ -279,7 +298,8 @@ export const findLegacyEmperor = async (db: LegacyArchiveDatabase, id: number): "server_id" AS "serverId", "data" FROM "legacy_archive"."emperor" - WHERE "id" = ${id} + WHERE "id" = ${input.id} + AND "source_profile" = ${input.sourceProfile} LIMIT 1 `); return rows[0] ?? null; diff --git a/app/game-api/test/dynastyRouter.test.ts b/app/game-api/test/dynastyRouter.test.ts index e4cc7f25..c71327f4 100644 --- a/app/game-api/test/dynastyRouter.test.ts +++ b/app/game-api/test/dynastyRouter.test.ts @@ -120,20 +120,24 @@ const authFor = (userId: string, roles: string[] = []): GameSessionTokenPayload const buildContext = ( auth: GameSessionTokenPayload | null, - oldNations: Array> = [oldNation, deletedOldNation] + oldNations: Array> = [oldNation, deletedOldNation], + profileId = profile.id ): GameApiContext => { + const selectedProfile = { ...profile, id: profileId, name: `${profileId}:default` }; const db = { - $queryRaw: async (query: { strings?: readonly string[] }) => { + $queryRaw: async (query: { strings?: readonly string[]; values?: unknown[] }) => { const sql = query.strings?.join(' ') ?? ''; if (sql.includes('legacy_archive"."emperor')) { + if (!query.values?.includes(selectedProfile.id)) return []; + if (sql.includes('WHERE "id"') && !query.values.includes(101)) return []; return [ { id: 101n, - sourceProfile: 'hwe', + sourceProfile: selectedProfile.id, legacyId: 7, serverId: emperor.serverId, data: { - phase: '이전 훼2기', + phase: `이전 ${selectedProfile.id.toUpperCase()} 2기`, nation_count: emperor.nationCount, nation_name: emperor.nationName, nation_hist: emperor.nationHist, @@ -170,9 +174,10 @@ const buildContext = ( ]; } if (sql.includes('legacy_archive"."nation')) { + if (!query.values?.includes(selectedProfile.id)) return []; return [ { - sourceProfile: 'hwe', + sourceProfile: selectedProfile.id, legacyId: oldNation.id, serverId: oldNation.serverId, nation: oldNation.nation, @@ -182,6 +187,7 @@ const buildContext = ( ]; } if (sql.includes('legacy_archive"."general')) { + if (!query.values?.includes(selectedProfile.id)) return []; return [ { generalNo: 11, name: '유비', lastYearMonth: 21504 }, { generalNo: 12, name: '제갈량', lastYearMonth: 21504 }, @@ -217,13 +223,13 @@ const buildContext = ( db: db as unknown as DatabaseClient, turnDaemon: new InMemoryTurnDaemonTransport(), battleSim: new InMemoryBattleSimTransport(), - profile, + profile: selectedProfile, auth, uploadDir: 'uploads', uploadPath: '/uploads', uploadPublicUrl: null, redis, - accessTokenStore: new RedisAccessTokenStore(redis, profile.name), + accessTokenStore: new RedisAccessTokenStore(redis, selectedProfile.name), flushStore: new InMemoryFlushStore(), gameTokenSecret: 'test-secret', }; @@ -252,27 +258,31 @@ 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({ + it('scopes previous-server dynasties and detail to the request profile', async () => { + const cheCaller = appRouter.createCaller(buildContext(null)); + const cheList = await cheCaller.dynasty.getList({ source: 'legacy' }); + expect(cheList).toMatchObject({ source: 'legacy', current: null, entries: [ expect.objectContaining({ id: 101, source: 'legacy', - sourceProfile: 'hwe', - phase: '이전 훼2기', + sourceProfile: 'che', + phase: '이전 CHE 2기', }), ], }); - const detail = await caller.dynasty.getDetail({ emperorId: 101, source: 'legacy' }); - expect(detail).toMatchObject({ + const staleListInput = { source: 'legacy' as const, sourceProfile: 'hwe' as const }; + const staleList = await cheCaller.dynasty.getList(staleListInput); + expect(staleList.entries.map((entry) => entry.sourceProfile)).toEqual(['che']); + + const cheDetail = await cheCaller.dynasty.getDetail({ emperorId: 101, source: 'legacy' }); + expect(cheDetail).toMatchObject({ source: 'legacy', - sourceProfile: 'hwe', - emperor: expect.objectContaining({ id: 101, phase: '이전 훼2기', name: '촉' }), + sourceProfile: 'che', + emperor: expect.objectContaining({ id: 101, phase: '이전 CHE 2기', name: '촉' }), nations: [ expect.objectContaining({ name: '촉', @@ -283,6 +293,22 @@ describe('dynasty public read model', () => { }), ], }); + + const staleDetailInput = { emperorId: 101, source: 'legacy' as const, sourceProfile: 'hwe' as const }; + const staleDetail = await cheCaller.dynasty.getDetail(staleDetailInput); + expect(staleDetail.sourceProfile).toBe('che'); + + const hweCaller = appRouter.createCaller(buildContext(null, undefined, 'hwe')); + const hweList = await hweCaller.dynasty.getList({ source: 'legacy' }); + expect(hweList.entries).toEqual([expect.objectContaining({ sourceProfile: 'hwe', phase: '이전 HWE 2기' })]); + const hweDetail = await hweCaller.dynasty.getDetail({ emperorId: 101, source: 'legacy' }); + expect(hweDetail.sourceProfile).toBe('hwe'); + + const developmentCaller = appRouter.createCaller(buildContext(null, undefined, 'development')); + await expect(developmentCaller.dynasty.getList({ source: 'legacy' })).resolves.toMatchObject({ entries: [] }); + await expect(developmentCaller.dynasty.getDetail({ emperorId: 101, source: 'legacy' })).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); }); it('exposes the same public DTO to anonymous, general owners and admins', async () => { diff --git a/app/game-frontend/e2e/legacyArchiveViews.spec.ts b/app/game-frontend/e2e/legacyArchiveViews.spec.ts index 3e02076b..12b1b42b 100644 --- a/app/game-frontend/e2e/legacyArchiveViews.spec.ts +++ b/app/game-frontend/e2e/legacyArchiveViews.spec.ts @@ -11,6 +11,7 @@ const isLegacyRequest = (route: Route): boolean => const installArchiveViews = async (page: Page) => { const hallRequests: string[] = []; + const dynastyRequests: string[] = []; await page.addInitScript((profile) => { localStorage.setItem('sammo-game-token', 'ga_archive_views'); localStorage.setItem('sammo-game-profile', profile); @@ -21,6 +22,9 @@ const installArchiveViews = async (page: Page) => { if (operations.some((operation) => operation.startsWith('ranking.getHallOfFame'))) { hallRequests.push(decodeURIComponent(`${route.request().url()} ${route.request().postData() ?? ''}`)); } + if (operations.some((operation) => operation.startsWith('dynasty.'))) { + dynastyRequests.push(decodeURIComponent(`${route.request().url()} ${route.request().postData() ?? ''}`)); + } const results = operations.map((operation) => { if (operation === 'auth.status') return response({ ok: true }); if (operation === 'lobby.info') return response({ myGeneral: { id: 1, name: '기록장수' } }); @@ -67,8 +71,8 @@ const installArchiveViews = async (page: Page) => { { id: legacy ? 101 : 1, source: legacy ? 'legacy' : 'current', - sourceProfile: legacy ? 'hwe' : 'che', - serverId: legacy ? 'hwe-old-1' : 'che-current-1', + sourceProfile: 'che', + serverId: legacy ? 'che-old-1' : 'che-current-1', phase: legacy ? '이전 1기' : '현재 1기', name: '촉', year: 215, @@ -93,10 +97,10 @@ const installArchiveViews = async (page: Page) => { if (operation === 'dynasty.getDetail') { return response({ source: 'legacy', - sourceProfile: 'hwe', + sourceProfile: 'che', emperor: { id: 101, - serverId: 'hwe-old-1', + serverId: 'che-old-1', winnerNationId: 1, phase: '이전 1기', nationCount: '1 / 2', @@ -189,7 +193,7 @@ const installArchiveViews = async (page: Page) => { }); await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) }); }); - return { hallRequests }; + return { dynastyRequests, hallRequests }; }; test('명예의 전당은 현재 profile의 현재·이전 서버 기록만 조회한다', async ({ page }, testInfo) => { @@ -217,19 +221,36 @@ test('명예의 전당은 현재 profile의 현재·이전 서버 기록만 조 await page.screenshot({ path: testInfo.outputPath('hall-profile-scope-mobile.png'), fullPage: true }); }); -test('왕조 일람과 상세는 이전 서버 source와 profile을 유지한다', async ({ page }) => { - await installArchiveViews(page); +test('왕조 일람과 상세는 현재 profile의 이전 서버 기록만 조회한다', async ({ page }, testInfo) => { + const state = await installArchiveViews(page); await page.setViewportSize({ width: 1200, height: 800 }); await page.goto('dynasty'); await expect(page.getByText('현재 1기')).toBeVisible(); + await page.getByLabel('기록 구분').focus(); + await expect(page.getByLabel('기록 구분')).toBeFocused(); await page.getByLabel('기록 구분').selectOption('legacy'); - await expect(page.getByText(/이전 1기.*HWE 이전 서버/)).toBeVisible(); + await expect(page.getByText(/이전 1기.*이전 서버/)).toBeVisible(); + await expect(page.getByText(/CHE 이전 서버|HWE 이전 서버/)).toHaveCount(0); + await expect(page.locator('.dynasty-page')).toHaveCSS('width', '1000px'); + await expect(page.locator('.dynasty-table')).toHaveCSS('height', '139px'); + await expect(page.locator('.dynasty-table .phase-heading')).toHaveCSS('background-color', 'rgb(135, 206, 235)'); + await page.screenshot({ path: testInfo.outputPath('dynasty-list-profile-scope-desktop.png'), fullPage: true }); + + await page.setViewportSize({ width: 390, height: 844 }); + await expect(page.locator('.dynasty-page')).toHaveCSS('width', '1000px'); + await page.screenshot({ path: testInfo.outputPath('dynasty-list-profile-scope-mobile.png'), fullPage: true }); + + await page.setViewportSize({ width: 1200, height: 800 }); 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.getByText(/이전 1기.*이전 서버/)).toBeVisible(); + await expect(page.getByText(/CHE 이전 서버|HWE 이전 서버/)).toHaveCount(0); await expect(page.locator('.dynasty-page')).toHaveCSS('width', '1000px'); + expect(state.dynastyRequests.some((request) => request.includes('legacy'))).toBe(true); + expect(state.dynastyRequests.every((request) => !request.includes('sourceProfile'))).toBe(true); + await page.screenshot({ path: testInfo.outputPath('dynasty-detail-profile-scope-desktop.png'), fullPage: true }); }); test('연감 국가 라벨은 밝은 배경에 검정, 어두운 배경에 흰 글자를 사용한다', async ({ page }, testInfo) => { diff --git a/app/game-frontend/src/views/DynastyDetailView.vue b/app/game-frontend/src/views/DynastyDetailView.vue index ce963b3c..cd743757 100644 --- a/app/game-frontend/src/views/DynastyDetailView.vue +++ b/app/game-frontend/src/views/DynastyDetailView.vue @@ -92,9 +92,7 @@ onMounted(loadDetail); {{ data.emperor.phase }} - + diff --git a/app/game-frontend/src/views/DynastyListView.vue b/app/game-frontend/src/views/DynastyListView.vue index cec29466..88da66cf 100644 --- a/app/game-frontend/src/views/DynastyListView.vue +++ b/app/game-frontend/src/views/DynastyListView.vue @@ -98,9 +98,7 @@ watch(selectedSource, loadDynasty); {{ entry.phase - }} [이전 서버] { + const rect = element.getBoundingClientRect(); + const firstItem = element.querySelector('[data-mobile-layout-id]')?.getBoundingClientRect(); + const moveButton = element.querySelector('[aria-label$="아래로"]')?.getBoundingClientRect(); + return { + rect: rect.toJSON(), + firstItem: firstItem?.toJSON() ?? null, + moveButton: moveButton?.toJSON() ?? null, + overflowX: getComputedStyle(element).overflowX, + documentWidth: document.documentElement.scrollWidth, + }; + }); + expect(dialogGeometry.rect.left).toBeGreaterThanOrEqual(0); + expect(dialogGeometry.rect.right).toBeLessThanOrEqual(390); + expect(dialogGeometry.firstItem?.height).toBeGreaterThanOrEqual(44); + expect(dialogGeometry.moveButton?.width).toBeGreaterThanOrEqual(36); + expect(dialogGeometry.documentWidth).toBe(390); + await persistParityArtifact(page, 'core-my-page-mobile-layout-order-dialog', dialogGeometry); + + await dialog.getByRole('button', { name: '적용', exact: true }).click(); + await expect(dialog).toBeHidden(); + await expect + .poll(() => page.evaluate(() => JSON.parse(localStorage.getItem('sam.mobileMainPanelOrder.v1') ?? '[]'))) + .toEqual(['messages', 'commands', 'nation-menu', 'nation', 'general', 'map', 'city', 'records', 'global-menu']); + + await page.getByRole('button', { name: '순서 바꾸기', exact: true }).click(); + await dialog.getByRole('button', { name: 'Ref 초깃값' }).click(); + await expect.poll(readOrder).toEqual(defaultOrder); + await dialog.getByRole('button', { name: '적용', exact: true }).click(); + await expect + .poll(() => page.evaluate(() => JSON.parse(localStorage.getItem('sam.mobileMainPanelOrder.v1') ?? '[]'))) + .toEqual(defaultOrder); +}); + for (const [label, failure] of [ ['daemon timeout', 'TIMEOUT'], ['engine transaction 오류', 'INTERNAL_SERVER_ERROR'], diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index 4e53a936..a7fb2e50 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -95,11 +95,10 @@ type DashboardBundleInput = { const operationInput = (route: Route, index: number): DashboardBundleInput => { const request = route.request(); const queryInput = new URL(request.url()).searchParams.get('input'); - const parsed = (request.postData() - ? request.postDataJSON() - : queryInput - ? JSON.parse(queryInput) - : {}) as Record; + const parsed = (request.postData() ? request.postDataJSON() : queryInput ? JSON.parse(queryInput) : {}) as Record< + string, + unknown + >; const entry = (parsed[String(index)] ?? parsed) as { json?: DashboardBundleInput }; return entry.json ?? (entry as DashboardBundleInput); }; @@ -253,32 +252,32 @@ const commandTableFixture = (large: boolean, blockedCount = 0, refCategories = f general: draftCommands ? draftCommandGroups : refCategories - ? refCommandCategoryFixture - : large - ? ['내정', '군사', '계략'].map((category, categoryIndex) => ({ - category, - values: Array.from({ length: 16 }, (_, localIndex) => { - const index = categoryIndex * 16 + localIndex; - return { - key: `command-${index}`, - name: index === 0 ? '주민 선정과 장기 도시 개발' : `명령 ${index}`, - reqArg: index % 2 === 0, - possible: index >= blockedCount, - status: index >= blockedCount ? 'available' : 'blocked', - inputFields: [ - { - key: 'amount', - label: '수량', - kind: 'number', - required: true, - min: 1, - max: 10_000, - }, - ], - }; - }), - })) - : [], + ? refCommandCategoryFixture + : large + ? ['내정', '군사', '계략'].map((category, categoryIndex) => ({ + category, + values: Array.from({ length: 16 }, (_, localIndex) => { + const index = categoryIndex * 16 + localIndex; + return { + key: `command-${index}`, + name: index === 0 ? '주민 선정과 장기 도시 개발' : `명령 ${index}`, + reqArg: index % 2 === 0, + possible: index >= blockedCount, + status: index >= blockedCount ? 'available' : 'blocked', + inputFields: [ + { + key: 'amount', + label: '수량', + kind: 'number', + required: true, + min: 1, + max: 10_000, + }, + ], + }; + }), + })) + : [], nation: [], inputOptions: { cities: Array.from({ length: 20 }, (_, index) => ({ value: index + 1, label: `도시 ${index + 1}` })), @@ -500,7 +499,7 @@ const installFixture = async (page: Page, state: NavigationFixture) => { ? response({ id: 'user-7', username: 'menu-user', displayName: '메뉴 사용자' }) : operation === 'navigation.get' ? response(runtimeNavigation) - : response({ ok: true }) + : response({ ok: true }) ); await route.fulfill({ status: 200, @@ -1558,10 +1557,7 @@ test('message targets keep reply behavior and use nation-color contrast in label await page.setViewportSize({ width: 500, height: 900 }); const mobilePanel = page.locator('.mobile-message-panel'); - await expect(mobilePanel.locator('.msg-plate[data-id="101"] .msg-target')).toHaveCSS( - 'color', - 'rgb(255, 255, 255)' - ); + await expect(mobilePanel.locator('.msg-plate[data-id="101"] .msg-target')).toHaveCSS('color', 'rgb(255, 255, 255)'); await expect(mobilePanel.locator('.msg-plate[data-id="103"] .msg-target')).toHaveCSS('color', 'rgb(0, 0, 0)'); await expect(mobilePanel.locator('#mailbox_list optgroup[label="밝은국"]')).toHaveCSS('color', 'rgb(0, 0, 0)'); await persistArtifact(page, `${basePath.slice(1)}-message-nation-contrast-mobile-500`); @@ -2217,6 +2213,49 @@ test('the 939/940 boundary switches to the Ref-style 500px single document', asy ]) { await expect(page.locator(selector)).toBeVisible(); } + const readMobilePanelOrder = () => + page + .locator('.layout-mobile > [data-mobile-panel-id]') + .evaluateAll((elements) => elements.map((element) => element.getAttribute('data-mobile-panel-id'))); + await expect + .poll(readMobilePanelOrder) + .toEqual(['commands', 'nation-menu', 'nation', 'general', 'city', 'map', 'records', 'global-menu', 'messages']); + await page.evaluate(() => { + localStorage.setItem( + 'sam.mobileMainPanelOrder.v1', + JSON.stringify([ + 'messages', + 'map', + 'commands', + 'nation-menu', + 'nation', + 'general', + 'city', + 'records', + 'global-menu', + ]) + ); + document.dispatchEvent(new CustomEvent('sam-mobile-main-panel-order-changed')); + }); + await expect + .poll(readMobilePanelOrder) + .toEqual(['messages', 'map', 'commands', 'nation-menu', 'nation', 'general', 'city', 'records', 'global-menu']); + const customPanelGeometry = await page.locator('.layout-mobile > [data-mobile-panel-id]').evaluateAll((elements) => + elements.map((element) => { + const rect = element.getBoundingClientRect(); + return { + id: element.getAttribute('data-mobile-panel-id'), + top: rect.top, + bottom: rect.bottom, + left: rect.left, + right: rect.right, + }; + }) + ); + expect(customPanelGeometry.every(({ left, right }) => left >= 0 && right <= 500)).toBe(true); + expect( + customPanelGeometry.every((panel, index) => index === 0 || panel.top >= customPanelGeometry[index - 1]!.bottom) + ).toBe(true); await persistArtifact(page, `${basePath.slice(1)}-mobile-500`); }); @@ -3040,9 +3079,9 @@ for (const viewport of [ await refreshActivityAndCommands(); await expect(picker.getByLabel('장비 종류', { exact: true })).toHaveValue('weapon'); await expect(picker.getByLabel('장비', { exact: true })).toHaveValue('청룡언월도'); - await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual( - viewport.width - ); + await expect + .poll(() => page.evaluate(() => document.documentElement.scrollWidth)) + .toBeLessThanOrEqual(viewport.width); }); } diff --git a/app/game-frontend/src/utils/mobileMainPanelOrder.ts b/app/game-frontend/src/utils/mobileMainPanelOrder.ts new file mode 100644 index 00000000..b337c833 --- /dev/null +++ b/app/game-frontend/src/utils/mobileMainPanelOrder.ts @@ -0,0 +1,85 @@ +export const MOBILE_MAIN_PANEL_ORDER_STORAGE_KEY = 'sam.mobileMainPanelOrder.v1'; +export const MOBILE_MAIN_PANEL_ORDER_CHANGED_EVENT = 'sam-mobile-main-panel-order-changed'; + +export const MOBILE_MAIN_PANEL_DEFINITIONS = [ + { id: 'commands', label: '명령 목록' }, + { id: 'nation-menu', label: '국가 메뉴' }, + { id: 'nation', label: '국가 정보' }, + { id: 'general', label: '장수 정보' }, + { id: 'city', label: '도시 정보' }, + { id: 'map', label: '지도' }, + { id: 'records', label: '기록 영역' }, + { id: 'global-menu', label: '공통 메뉴' }, + { id: 'messages', label: '서신' }, +] as const; + +export type MobileMainPanelId = (typeof MOBILE_MAIN_PANEL_DEFINITIONS)[number]['id']; + +export const DEFAULT_MOBILE_MAIN_PANEL_ORDER: readonly MobileMainPanelId[] = MOBILE_MAIN_PANEL_DEFINITIONS.map( + ({ id }) => id +); + +const mobilePanelIds = new Set(DEFAULT_MOBILE_MAIN_PANEL_ORDER); + +export const normalizeMobileMainPanelOrder = (value: unknown): MobileMainPanelId[] => { + const source = Array.isArray(value) ? value : []; + const seen = new Set(); + const normalized: MobileMainPanelId[] = []; + + for (const item of source) { + if (typeof item !== 'string' || !mobilePanelIds.has(item) || seen.has(item)) continue; + seen.add(item); + normalized.push(item as MobileMainPanelId); + } + + for (const item of DEFAULT_MOBILE_MAIN_PANEL_ORDER) { + if (!seen.has(item)) normalized.push(item); + } + + return normalized; +}; + +export const parseMobileMainPanelOrder = (raw: string | null): MobileMainPanelId[] => { + if (!raw) return [...DEFAULT_MOBILE_MAIN_PANEL_ORDER]; + try { + return normalizeMobileMainPanelOrder(JSON.parse(raw)); + } catch { + return [...DEFAULT_MOBILE_MAIN_PANEL_ORDER]; + } +}; + +export const loadMobileMainPanelOrder = (storage: Pick = window.localStorage) => + parseMobileMainPanelOrder(storage.getItem(MOBILE_MAIN_PANEL_ORDER_STORAGE_KEY)); + +export const saveMobileMainPanelOrder = ( + value: readonly MobileMainPanelId[], + storage: Pick = window.localStorage +): MobileMainPanelId[] => { + const normalized = normalizeMobileMainPanelOrder(value); + storage.setItem(MOBILE_MAIN_PANEL_ORDER_STORAGE_KEY, JSON.stringify(normalized)); + if (typeof document !== 'undefined') { + document.dispatchEvent(new CustomEvent(MOBILE_MAIN_PANEL_ORDER_CHANGED_EVENT)); + } + return normalized; +}; + +export const moveMobileMainPanel = ( + value: readonly MobileMainPanelId[], + fromIndex: number, + toIndex: number +): MobileMainPanelId[] => { + const normalized = normalizeMobileMainPanelOrder(value); + if ( + fromIndex < 0 || + fromIndex >= normalized.length || + toIndex < 0 || + toIndex >= normalized.length || + fromIndex === toIndex + ) { + return normalized; + } + const [moved] = normalized.splice(fromIndex, 1); + if (!moved) return normalized; + normalized.splice(toIndex, 0, moved); + return normalized; +}; diff --git a/app/game-frontend/src/views/MainView.vue b/app/game-frontend/src/views/MainView.vue index 45c72071..ef828879 100644 --- a/app/game-frontend/src/views/MainView.vue +++ b/app/game-frontend/src/views/MainView.vue @@ -29,6 +29,12 @@ import { useMainDashboardStore } from '../stores/mainDashboard'; import { useGameFeedback } from '../composables/useGameFeedback'; import { trpc } from '../utils/trpc'; import type { CommandPatternEntry } from '../components/command/types'; +import { + loadMobileMainPanelOrder, + MOBILE_MAIN_PANEL_ORDER_CHANGED_EVENT, + MOBILE_MAIN_PANEL_ORDER_STORAGE_KEY, + type MobileMainPanelId, +} from '../utils/mobileMainPanelOrder'; const session = useSessionStore(); const dashboard = useMainDashboardStore(); @@ -38,8 +44,20 @@ const isMobile = useMediaQuery('(max-width: 939.98px)'); const npcMode = ref(0); const globalNavigation = ref(defaultGlobalNavigation); const versionDialog = ref(null); +const mobilePanelOrder = ref(loadMobileMainPanelOrder()); const navigationUrl = (import.meta.env.VITE_GATEWAY_API_URL ?? '/api/trpc').replace(/\/trpc\/?$/u, '/navigation'); +const reloadMobilePanelOrder = () => { + mobilePanelOrder.value = loadMobileMainPanelOrder(); +}; +const handleMobilePanelStorage = (event: StorageEvent) => { + if (event.key === MOBILE_MAIN_PANEL_ORDER_STORAGE_KEY) reloadMobilePanelOrder(); +}; +const isFlushMobilePanel = (panelId: MobileMainPanelId, index: number): boolean => { + const previous = mobilePanelOrder.value[index - 1]; + return (panelId === 'general' && previous === 'nation') || (panelId === 'city' && previous === 'general'); +}; + const { loading, refreshing, @@ -100,10 +118,14 @@ onUnmounted(() => { clearTimeout(surveyNoticeTimer); } dashboard.stopRealtime(); + window.removeEventListener('storage', handleMobilePanelStorage); + document.removeEventListener(MOBILE_MAIN_PANEL_ORDER_CHANGED_EVENT, reloadMobilePanelOrder); }); onMounted(() => { dashboard.startRealtime(); + window.addEventListener('storage', handleMobilePanelStorage); + document.addEventListener(MOBILE_MAIN_PANEL_ORDER_CHANGED_EVENT, reloadMobilePanelOrder); void fetch(navigationUrl, { headers: { Accept: 'application/json' } }) .then(async (response) => { if (!response.ok) throw new Error(`메뉴 설정 조회 실패: HTTP ${response.status}`); @@ -130,10 +152,7 @@ const repeatGeneralTurns = (amount: number) => { }; const loadMainData = async () => { - const [, worldState] = await Promise.all([ - dashboard.loadMainData(), - trpc.world.getState.query().catch(() => null), - ]); + const [, worldState] = await Promise.all([dashboard.loadMainData(), trpc.world.getState.query().catch(() => null)]); npcMode.value = worldState?.config.npcMode ?? 0; }; @@ -239,131 +258,155 @@ watch(
-
- - +
+ + + +
+ +
+ - -
+
-
- + + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+
기록이 없습니다.
+
+ + + + +
+ +
+
기록이 없습니다.
+
+ + + + +
+ +
+
기록이 없습니다.
+
+ +
+ + -
-
- - - - - - - - - -
- -
- - - -
- -
- - - -
- -
-
기록이 없습니다.
-
- - - - -
- -
-
기록이 없습니다.
-
- - - - -
- -
-
기록이 없습니다.
-
- -
- - - -
- -
+
+ +
+
@@ -798,8 +841,8 @@ button { gap: 0; } -.layout-mobile > .mobile-panel:nth-of-type(3) { - gap: 0; +.layout-mobile > .mobile-panel--flush { + margin-top: -4px; } .layout-mobile [data-main-target='commands'] { diff --git a/app/game-frontend/src/views/MyPageView.vue b/app/game-frontend/src/views/MyPageView.vue index ae8f57d7..9798859c 100644 --- a/app/game-frontend/src/views/MyPageView.vue +++ b/app/game-frontend/src/views/MyPageView.vue @@ -9,11 +9,19 @@ import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIc import LegacyGeneralProgress from '../components/ui/LegacyGeneralProgress.vue'; import GeneralBasicCard from '../components/main/GeneralBasicCard.vue'; import { useGameFeedback } from '../composables/useGameFeedback'; +import { + DEFAULT_MOBILE_MAIN_PANEL_ORDER, + loadMobileMainPanelOrder, + MOBILE_MAIN_PANEL_DEFINITIONS, + moveMobileMainPanel, + saveMobileMainPanelOrder, + type MobileMainPanelId, +} from '../utils/mobileMainPanelOrder'; const SCREEN_MODE_KEY = 'sam.screenMode'; const CUSTOM_CSS_KEY = 'sam_customCSS'; const PENDING_DIE_ON_PRESTART_KEY = 'sam.pending.dieOnPrestart'; -const { error: showErrorToast, showDialog } = useGameFeedback(); +const { success: showSuccessToast, error: showErrorToast, showDialog } = useGameFeedback(); type ScreenMode = 'auto' | '500px' | '1000px'; type LogType = 'generalHistory' | 'battleDetail' | 'battleResult' | 'generalAction'; type ItemSlotKey = 'horse' | 'weapon' | 'book' | 'item'; @@ -48,6 +56,9 @@ const screenMode = ref('auto'); const customCss = ref(''); const selectedIconId = ref(''); const cssSaving = ref(false); +const mobileLayoutDialog = ref(null); +const mobileLayoutOrder = ref(loadMobileMainPanelOrder()); +const mobileLayoutDragIndex = ref(null); const session = useSessionStore(); let cssTimer: number | null = null; const readPendingDieOnPrestartId = (): string => { @@ -169,6 +180,43 @@ const items = computed data.value?.iconChoices ?? []); const selectedIcon = computed(() => iconChoices.value.find((icon) => icon.id === selectedIconId.value) ?? null); +const mobileLayoutLabels = Object.fromEntries( + MOBILE_MAIN_PANEL_DEFINITIONS.map(({ id, label }) => [id, label]) +) as Record; + +const openMobileLayoutDialog = () => { + mobileLayoutOrder.value = loadMobileMainPanelOrder(); + mobileLayoutDialog.value?.showModal(); + window.requestAnimationFrame(() => mobileLayoutDialog.value?.querySelector('button')?.focus()); +}; + +const moveMobileLayoutItem = (fromIndex: number, toIndex: number) => { + mobileLayoutOrder.value = moveMobileMainPanel(mobileLayoutOrder.value, fromIndex, toIndex); +}; + +const startMobileLayoutDrag = (event: DragEvent, index: number) => { + mobileLayoutDragIndex.value = index; + event.dataTransfer?.setData('text/plain', mobileLayoutOrder.value[index] ?? ''); + if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move'; +}; + +const dropMobileLayoutItem = (event: DragEvent, targetIndex: number) => { + event.preventDefault(); + const sourceIndex = mobileLayoutDragIndex.value; + mobileLayoutDragIndex.value = null; + if (sourceIndex === null) return; + moveMobileLayoutItem(sourceIndex, targetIndex); +}; + +const resetMobileLayoutOrder = () => { + mobileLayoutOrder.value = [...DEFAULT_MOBILE_MAIN_PANEL_ORDER]; +}; + +const applyMobileLayoutOrder = () => { + mobileLayoutOrder.value = saveMobileMainPanelOrder(mobileLayoutOrder.value); + mobileLayoutDialog.value?.close(); + showSuccessToast('모바일 메인 레이아웃 순서를 저장했습니다.'); +}; const autorunUser = computed(() => asRecord(world.value?.meta.autorun_user)); const showAutoNationTurn = computed(() => asRecord(autorunUser.value.options).chief !== false); @@ -581,6 +629,16 @@ onMounted(() => { +
+ + 모바일 레이아웃 순서 바꾸기
+ 500px 메인 화면의 패널 순서를 이 기기에 저장합니다. +
+ +
+
아이템 파기
+ +
+

항목을 끌어 놓거나 위·아래 버튼으로 상대 순서를 바꿉니다.

+
    +
  1. + + + {{ index + 1 }} + {{ mobileLayoutLabels[panelId] }} + + + + + +
  2. +
+
+ +
+ +
+ @@ -822,6 +935,125 @@ button:disabled { align-items: center; margin: 14px 0; } +.mobile-layout-setting-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 128px; + align-items: center; + gap: 8px; + margin: 14px 0; +} +.mobile-layout-setting-row small { + color: orange; +} +.mobile-layout-open { + min-height: 34px; + background: #315f86; + font-weight: 700; +} +.mobile-layout-dialog { + box-sizing: border-box; + width: min(460px, calc(100vw - 24px)); + max-height: calc(100dvh - 24px); + margin: auto; + overflow: auto; + border: 1px solid #777; + border-radius: 4px; + padding: 12px; + background: #171717 var(--sammo-texture-walnut); + color: #fff; + font: 14px/1.3 var(--sammo-font-sans); +} +.mobile-layout-dialog::backdrop { + background: rgb(0 0 0 / 72%); +} +.mobile-layout-dialog__header, +.mobile-layout-dialog__actions, +.mobile-layout-move-buttons { + display: flex; + align-items: center; +} +.mobile-layout-dialog__header { + justify-content: space-between; + gap: 12px; +} +.mobile-layout-dialog__header h2, +.mobile-layout-dialog p { + margin: 0 0 10px; +} +.mobile-layout-dialog__header h2 { + color: skyblue; + font-size: 18px; +} +.mobile-layout-dialog__header form, +.mobile-layout-dialog__actions form { + margin: 0; +} +.mobile-layout-dialog__header button { + min-width: 32px; + min-height: 32px; + font-size: 20px; +} +.mobile-layout-list { + display: grid; + gap: 6px; + margin: 0; + padding: 0; + list-style: none; +} +.mobile-layout-list > li { + display: grid; + grid-template-columns: 28px minmax(0, 1fr) auto; + min-height: 44px; + align-items: center; + border: 1px solid #777; + background: #172a52 var(--sammo-texture-blue); + cursor: grab; +} +.mobile-layout-list > li:active { + cursor: grabbing; +} +.mobile-layout-handle { + color: #aaa; + text-align: center; + font-size: 20px; +} +.mobile-layout-label { + min-width: 0; + font-weight: 700; +} +.mobile-layout-position { + display: inline-grid; + width: 22px; + height: 22px; + place-items: center; + margin-right: 4px; + border: 1px solid #7186a7; + border-radius: 50%; + font-size: 12px; +} +.mobile-layout-move-buttons { + gap: 4px; + padding-right: 5px; +} +.mobile-layout-move-buttons button { + width: 36px; + min-height: 34px; + background: #315f86; + font-weight: 700; +} +.mobile-layout-dialog__actions { + justify-content: flex-end; + gap: 6px; + margin-top: 12px; +} +.mobile-layout-dialog__actions button { + min-height: 34px; + padding: 4px 10px; +} +.mobile-layout-dialog__actions .mobile-layout-apply { + background: #225500; + font-weight: 700; +} .button-group { display: flex; } @@ -933,6 +1165,12 @@ button:disabled { grid-template-columns: 1fr; gap: 6px; } + .mobile-layout-setting-row { + grid-template-columns: 1fr; + } + .mobile-layout-open { + width: 100%; + } .button-group { overflow-x: auto; } diff --git a/app/game-frontend/test/mobileMainPanelOrder.test.ts b/app/game-frontend/test/mobileMainPanelOrder.test.ts new file mode 100644 index 00000000..288f2b4c --- /dev/null +++ b/app/game-frontend/test/mobileMainPanelOrder.test.ts @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + DEFAULT_MOBILE_MAIN_PANEL_ORDER, + MOBILE_MAIN_PANEL_ORDER_STORAGE_KEY, + loadMobileMainPanelOrder, + moveMobileMainPanel, + normalizeMobileMainPanelOrder, + parseMobileMainPanelOrder, + saveMobileMainPanelOrder, +} from '../src/utils/mobileMainPanelOrder.ts'; + +void test('uses the Ref mobile panel order as the default', () => { + assert.deepEqual(DEFAULT_MOBILE_MAIN_PANEL_ORDER, [ + 'commands', + 'nation-menu', + 'nation', + 'general', + 'city', + 'map', + 'records', + 'global-menu', + 'messages', + ]); + assert.deepEqual(parseMobileMainPanelOrder(null), DEFAULT_MOBILE_MAIN_PANEL_ORDER); +}); + +void test('keeps known unique entries and appends newly introduced panels', () => { + assert.deepEqual(normalizeMobileMainPanelOrder(['messages', 'commands', 'messages', 'unknown']), [ + 'messages', + 'commands', + 'nation-menu', + 'nation', + 'general', + 'city', + 'map', + 'records', + 'global-menu', + ]); + assert.deepEqual(parseMobileMainPanelOrder('{broken'), DEFAULT_MOBILE_MAIN_PANEL_ORDER); +}); + +void test('moves and persists the normalized order', () => { + const values = new Map(); + const storage = { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + }; + const moved = moveMobileMainPanel(DEFAULT_MOBILE_MAIN_PANEL_ORDER, 8, 0); + assert.equal(moved[0], 'messages'); + assert.deepEqual(saveMobileMainPanelOrder(moved, storage), moved); + assert.equal(values.has(MOBILE_MAIN_PANEL_ORDER_STORAGE_KEY), true); + assert.deepEqual(loadMobileMainPanelOrder(storage), moved); +}); From db3b8e8f0841b1742b7bc4e92803475574581451 Mon Sep 17 00:00:00 2001 From: hided62 Date: Thu, 20 Aug 2026 15:54:20 +0000 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=EC=9C=A0=EC=82=B0=20=EC=9C=A0?= =?UTF-8?q?=EB=8B=88=ED=81=AC=20=EA=B2=BD=EB=A7=A4=20=EA=B8=B0=EB=B3=B8=20?= =?UTF-8?q?=ED=92=80=EA=B3=BC=20=EC=A0=9C=ED=95=9C=EC=9D=84=20=EB=B3=B5?= =?UTF-8?q?=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ref 기본 유니크 풀을 상태 조회·개설·마감에서 공유하고 API와 데몬 양쪽에서 입찰 부위 제한을 재검증한다. 실제 Chromium 및 개설·경쟁 입찰·마감 통합 회귀를 추가한다. --- app/game-api/src/router/inherit/index.ts | 9 +- app/game-api/test/inheritRouter.test.ts | 28 ++- app/game-engine/src/auction/bidder.ts | 72 ++++++ app/game-engine/src/auction/finalizer.ts | 16 +- app/game-engine/src/auction/opener.ts | 17 +- app/game-frontend/src/views/InheritView.vue | 16 +- .../logic/src/rewards/legacyUniqueItemPool.ts | 38 ++- .../logic/test/legacyUniqueItemPool.test.ts | 30 +++ .../inheritance-management.spec.ts | 35 +++ .../test/auctionFlow.test.ts | 233 +++++++++++++++++- 10 files changed, 456 insertions(+), 38 deletions(-) create mode 100644 packages/logic/test/legacyUniqueItemPool.test.ts diff --git a/app/game-api/src/router/inherit/index.ts b/app/game-api/src/router/inherit/index.ts index b67d17d8..edbc8bbc 100644 --- a/app/game-api/src/router/inherit/index.ts +++ b/app/game-api/src/router/inherit/index.ts @@ -13,6 +13,7 @@ import { } from '@sammo-ts/logic'; import type { InheritBuffType } from '@sammo-ts/logic'; import { simpleSerialize } from '@sammo-ts/logic/war/utils.js'; +import { resolveLegacyCompatibleUniqueConfig } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js'; import { appendInheritanceLog, buildResetCost, @@ -74,17 +75,17 @@ const readBuffLevel = (buff: Record, key: InheritBuffType): numb }; const loadAvailableUniqueItems = async (worldState: WorldStateRow) => { - const configuredItems = asRecord(asRecord(worldState.config).const).allItems; + const configConst = asRecord(asRecord(worldState.config).const); + const loader = new ItemLoader(); + const { allItems } = await resolveLegacyCompatibleUniqueConfig(configConst, loader); const enabledKeys: Array[0]> = []; - for (const entries of Object.values(asRecord(configuredItems))) { + for (const entries of Object.values(allItems)) { for (const [key, amount] of Object.entries(asRecord(entries))) { if (asNumber(amount, 0) !== 0 && isItemKey(key)) { enabledKeys.push(key); } } } - - const loader = new ItemLoader(); const items = await Promise.all( [...new Set(enabledKeys)].map(async (key) => { const item = await loader.load(key); diff --git a/app/game-api/test/inheritRouter.test.ts b/app/game-api/test/inheritRouter.test.ts index 4235177b..ae39c870 100644 --- a/app/game-api/test/inheritRouter.test.ts +++ b/app/game-api/test/inheritRouter.test.ts @@ -97,6 +97,7 @@ const buildContext = (options: { target?: GeneralRow | null; inheritancePoint?: number; inheritanceLogs?: Array<{ id: number; year: number; month: number; text: string; createdAt: Date }>; + configConst?: Record; }) => { const auth = options.auth === undefined ? buildAuth() : options.auth; const general = options.general === undefined ? buildGeneral() : options.general; @@ -113,10 +114,19 @@ const buildContext = (options: { const logCreate = vi.fn(async () => ({})); const findMany = vi.fn(async () => (target ? [{ id: target.id, name: target.name }] : [])); const inheritanceLogFindMany = vi.fn(async () => options.inheritanceLogs ?? []); + const activeWorldState = + options.configConst === undefined + ? worldState + : { + ...worldState, + config: { + const: options.configConst, + }, + }; const db = { $queryRaw: vi.fn(async () => [{ value: options.inheritancePoint ?? 10_000 }]), worldState: { - findFirst: vi.fn(async () => worldState), + findFirst: vi.fn(async () => activeWorldState), }, general: { findFirst: vi.fn(async ({ where }: { where: { userId: string } }) => @@ -192,6 +202,22 @@ describe('inherit router actor and permission boundaries', () => { }); }); + it.each([{}, { allItems: '{}' }])( + 'restores selectable Ref default uniques for a legacy scenario config: %j', + async (configConst) => { + const fixture = buildContext({ configConst }); + const status = await appRouter.createCaller(fixture.context).inherit.getStatus(); + + expect(status.availableUnique.length).toBeGreaterThan(80); + expect(status.availableUnique).toEqual( + expect.arrayContaining([ + expect.objectContaining({ key: 'che_무기_12_칠성검', rawName: '칠성검' }), + expect.objectContaining({ key: 'che_서적_07_논어', rawName: '논어' }), + ]) + ); + } + ); + it('loads the first inheritance-log page without an out-of-range integer cursor', async () => { const createdAt = new Date('2026-07-26T00:00:00Z'); const fixture = buildContext({ diff --git a/app/game-engine/src/auction/bidder.ts b/app/game-engine/src/auction/bidder.ts index 1372c80b..4f0ac6a0 100644 --- a/app/game-engine/src/auction/bidder.ts +++ b/app/game-engine/src/auction/bidder.ts @@ -1,6 +1,7 @@ import { randomUUID } from 'node:crypto'; import { createGamePostgresConnector, GamePrisma, type GamePrismaClient } from '@sammo-ts/infra'; +import { isItemKey, ItemLoader } from '@sammo-ts/logic'; import type { TurnDaemonCommand, TurnDaemonCommandResult } from '../lifecycle/types.js'; import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js'; @@ -23,6 +24,7 @@ const MIN_EXTENSION_MINUTES_PER_BID = 1; interface AuctionRow { id: number; type: AuctionType; + targetCode: string | null; hostGeneralId: number; detail: unknown; status: AuctionStatus; @@ -99,6 +101,7 @@ const loadAuction = async (prisma: QueryClient, auctionId: number): Promise => { @@ -290,6 +294,74 @@ export const createAuctionBidder = async (options: { reason: '장수 정보를 찾을 수 없습니다.', }; } + if (auction.type === 'UNIQUE_ITEM') { + const itemKey = auction.targetCode; + if (!itemKey || !isItemKey(itemKey)) { + return { + type: 'auctionBid', + ok: false, + auctionId: command.auctionId, + reason: '아이템이 올바르지 않습니다.', + }; + } + const item = await itemLoader.load(itemKey).catch(() => null); + if (!item || item.buyable) { + return { + type: 'auctionBid', + ok: false, + auctionId: command.auctionId, + reason: item ? '구매할 수 있는 아이템입니다.' : '아이템 정보를 불러올 수 없습니다.', + }; + } + + const currentSlotItem = general.role.items[item.slot]; + if (currentSlotItem && currentSlotItem !== 'None' && isItemKey(currentSlotItem)) { + const currentItem = await itemLoader.load(currentSlotItem).catch(() => null); + if (currentItem && !currentItem.buyable) { + return { + type: 'auctionBid', + ok: false, + auctionId: command.auctionId, + reason: + currentSlotItem === itemKey + ? '이미 그 유니크를 가지고 있습니다.' + : '이미 다른 유니크를 가지고 있습니다.', + }; + } + } + + const otherHighestBids = await db.$queryRaw>( + GamePrisma.sql` + SELECT candidate.id as "auctionId", candidate.target_code as "targetCode" + FROM auction candidate + INNER JOIN LATERAL ( + SELECT bid.general_id + FROM auction_bid bid + WHERE bid.auction_id = candidate.id + ORDER BY bid.amount DESC, bid.id ASC + LIMIT 1 + ) highest ON true + WHERE candidate.type = 'UNIQUE_ITEM' + AND candidate.status IN ('OPEN', 'FINALIZING') + AND candidate.id <> ${auction.id} + AND highest.general_id = ${command.generalId} + ` + ); + for (const other of otherHighestBids) { + if (!other.targetCode || !isItemKey(other.targetCode)) { + continue; + } + const otherItem = await itemLoader.load(other.targetCode).catch(() => null); + if (otherItem?.slot === item.slot) { + return { + type: 'auctionBid', + ok: false, + auctionId: command.auctionId, + reason: '1순위 입찰자인 경매중에 같은 부위가 있습니다.', + }; + } + } + } if (auction.type !== 'UNIQUE_ITEM' && auction.hostGeneralId === general.id) { return { type: 'auctionBid', diff --git a/app/game-engine/src/auction/finalizer.ts b/app/game-engine/src/auction/finalizer.ts index 04cde229..1e6d9bea 100644 --- a/app/game-engine/src/auction/finalizer.ts +++ b/app/game-engine/src/auction/finalizer.ts @@ -1,5 +1,6 @@ import { createGamePostgresConnector, GamePrisma } from '@sammo-ts/infra'; -import { ActionLogger, ItemLoader, LogFormat, UserLogger, isItemKey, resolveUniqueConfig } from '@sammo-ts/logic'; +import { ActionLogger, ItemLoader, LogFormat, UserLogger, isItemKey } from '@sammo-ts/logic'; +import { resolveLegacyCompatibleUniqueConfig } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js'; import { cloneItemInventory, ensureItemInventory, equipNewItem } from '@sammo-ts/logic/items/index.js'; import { asRecord, JosaUtil } from '@sammo-ts/common'; @@ -18,6 +19,8 @@ type AuctionStatus = 'OPEN' | 'FINALIZING' | 'FINISHED' | 'CANCELED'; const COEFF_EXTENSION_MINUTES_PER_BID = 1 / 6; const MIN_EXTENSION_MINUTES_PER_BID = 1; const MIN_EXTENSION_MINUTES_LIMIT_BY_BID = 5; +const COEFF_EXTENSION_MINUTES_LIMIT_UNIQUE_COUNT = 24; +const MIN_EXTENSION_MINUTES_BY_EXTENSION_QUERY = 5; interface AuctionRow { id: number; @@ -363,7 +366,10 @@ export const createAuctionFinalizer = async (options: { } const state = world.getState(); - const config = resolveUniqueConfig(asRecord(world.getScenarioConfig().const)); + const config = await resolveLegacyCompatibleUniqueConfig( + asRecord(world.getScenarioConfig().const), + itemLoader + ); const scenarioMeta = asRecord(state.meta.scenarioMeta); const startYear = typeof scenarioMeta.startYear === 'number' && Number.isFinite(scenarioMeta.startYear) @@ -392,7 +398,11 @@ export const createAuctionFinalizer = async (options: { const turnMinutes = await resolveTurnMinutes(db); const nextCloseAt = new Date( auction.closeAt.getTime() + - Math.max(MIN_EXTENSION_MINUTES_LIMIT_BY_BID, turnMinutes * 0.5) * 60_000 + Math.max( + MIN_EXTENSION_MINUTES_BY_EXTENSION_QUERY, + turnMinutes * COEFF_EXTENSION_MINUTES_LIMIT_UNIQUE_COUNT + ) * + 60_000 ); const nextLatestBidCloseAt = new Date( nextCloseAt.getTime() + diff --git a/app/game-engine/src/auction/opener.ts b/app/game-engine/src/auction/opener.ts index 7c47bfd7..e83c43f6 100644 --- a/app/game-engine/src/auction/opener.ts +++ b/app/game-engine/src/auction/opener.ts @@ -2,14 +2,8 @@ import { randomUUID } from 'node:crypto'; import { asRecord, JosaUtil } from '@sammo-ts/common'; import { acquireGameSchemaAdvisoryXactLock, GamePrisma } from '@sammo-ts/infra'; -import { - ActionLogger, - ItemLoader, - LogFormat, - buildAuctionAlias, - isItemKey, - resolveUniqueConfig, -} from '@sammo-ts/logic'; +import { ActionLogger, ItemLoader, LogFormat, buildAuctionAlias, isItemKey } from '@sammo-ts/logic'; +import { resolveLegacyCompatibleUniqueConfig } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js'; import type { TurnDaemonCommand, TurnDaemonCommandResult } from '../lifecycle/types.js'; import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js'; @@ -147,7 +141,8 @@ const openUniqueAuction = async ( return fail(`최소 경매 금액은 ${minimumPoint}입니다.`); } - const item = await new ItemLoader().load(itemKey).catch(() => null); + const itemLoader = new ItemLoader(); + const item = await itemLoader.load(itemKey).catch(() => null); if (!item) { return fail('아이템 정보를 불러올 수 없습니다.'); } @@ -156,7 +151,7 @@ const openUniqueAuction = async ( } const currentSlotItem = general.role.items[item.slot]; if (currentSlotItem && currentSlotItem !== 'None' && isItemKey(currentSlotItem)) { - const currentItem = await new ItemLoader().load(currentSlotItem).catch(() => null); + const currentItem = await itemLoader.load(currentSlotItem).catch(() => null); if (currentItem && !currentItem.buyable) { return fail('이미 가진 아이템이 있습니다.'); } @@ -189,7 +184,7 @@ const openUniqueAuction = async ( return fail('아직 경매가 끝나지 않았습니다.'); } - const uniqueConfig = resolveUniqueConfig(configConst); + const uniqueConfig = await resolveLegacyCompatibleUniqueConfig(configConst, itemLoader); const configuredAmount = uniqueConfig.allItems[item.slot]?.[itemKey] ?? 0; const occupiedAmount = world .listGenerals() diff --git a/app/game-frontend/src/views/InheritView.vue b/app/game-frontend/src/views/InheritView.vue index 9c19c201..4aeb761d 100644 --- a/app/game-frontend/src/views/InheritView.vue +++ b/app/game-frontend/src/views/InheritView.vue @@ -1,5 +1,5 @@