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/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/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-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/e2e/inGameMenus.spec.ts b/app/game-frontend/e2e/inGameMenus.spec.ts index 54ab7cd7..afd6bcc7 100644 --- a/app/game-frontend/e2e/inGameMenus.spec.ts +++ b/app/game-frontend/e2e/inGameMenus.spec.ts @@ -1231,7 +1231,9 @@ test('내 정보&설정의 지난 플레이는 기본 탐색과 분리되어 오 const actions = element.querySelector('.title-actions')!.getBoundingClientRect(); const navigation = element.querySelector('.navigation-actions')!.getBoundingClientRect(); const back = element.querySelector('.navigation-actions a')!.getBoundingClientRect(); - const refresh = element.querySelector('.navigation-actions button')!.getBoundingClientRect(); + const refresh = element + .querySelector('.navigation-actions button')! + .getBoundingClientRect(); const past = element.querySelector('.past-plays-link')!.getBoundingClientRect(); const pastStyle = getComputedStyle(element.querySelector('.past-plays-link')!); return { @@ -1269,6 +1271,77 @@ test('내 정보&설정의 지난 플레이는 기본 탐색과 분리되어 오 } }); +test('내 정보&설정에서 모바일 메인 패널을 드래그하거나 버튼으로 재정렬하고 Ref 순서로 복원한다', async ({ page }) => { + const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] }; + await install(page, state); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto('my-page'); + + await page.getByRole('button', { name: '순서 바꾸기', exact: true }).click(); + const dialog = page.getByRole('dialog', { name: '모바일 레이아웃 순서 바꾸기' }); + await expect(dialog).toBeVisible(); + const readOrder = () => + dialog + .locator('[data-mobile-layout-id]') + .evaluateAll((elements) => elements.map((element) => element.getAttribute('data-mobile-layout-id'))); + const defaultOrder = [ + 'commands', + 'nation-menu', + 'nation', + 'general', + 'city', + 'map', + 'records', + 'global-menu', + 'messages', + ]; + await expect.poll(readOrder).toEqual(defaultOrder); + + await dialog + .locator('[data-mobile-layout-id="messages"]') + .dragTo(dialog.locator('[data-mobile-layout-id="commands"]')); + await expect + .poll(readOrder) + .toEqual(['messages', 'commands', 'nation-menu', 'nation', 'general', 'city', 'map', 'records', 'global-menu']); + await dialog.getByRole('button', { name: '지도 위로' }).click(); + await expect + .poll(readOrder) + .toEqual(['messages', 'commands', 'nation-menu', 'nation', 'general', 'map', 'city', 'records', 'global-menu']); + + const dialogGeometry = await dialog.evaluate((element) => { + 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/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/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/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 - }} [이전 서버] -import { formatServerDateTime } from '@sammo-ts/common'; +import { formatServerDateTime, JosaUtil } from '@sammo-ts/common'; import { computed, onMounted, reactive, ref } from 'vue'; import { trpc } from '../utils/trpc'; @@ -379,7 +379,8 @@ const buyRandomUnique = async () => { }; const openUniqueAuction = async () => { - if (!uniqueForm.itemId.trim()) { + const selectedItem = status.value?.availableUnique.find((item) => item.key === uniqueForm.itemId.trim()); + if (!selectedItem) { actionError.value = '유니크를 선택해주세요.'; return; } @@ -388,15 +389,20 @@ const openUniqueAuction = async () => { actionError.value = '입찰 포인트를 입력해주세요.'; return; } - if (!window.confirm(`유니크 경매를 ${amount} 포인트로 신청하시겠습니까?`)) { + if (previousPoint.value < amount) { + actionError.value = '유산 포인트가 부족합니다.'; + return; + } + const itemJosa = JosaUtil.pick(selectedItem.rawName, '을'); + if (!window.confirm(`${amount} 포인트로 ${selectedItem.name}${itemJosa} 입찰하겠습니까?`)) { return; } await runAction(async () => { await trpc.inherit.openUniqueAuction.mutate({ - itemId: uniqueForm.itemId.trim(), + itemId: selectedItem.key, amount, }); - }); + }, '성공했습니다. 경매장을 확인해주세요.'); }; const checkOwner = async () => { 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); +}); diff --git a/packages/logic/src/rewards/legacyUniqueItemPool.ts b/packages/logic/src/rewards/legacyUniqueItemPool.ts index b6f866d9..00835f14 100644 --- a/packages/logic/src/rewards/legacyUniqueItemPool.ts +++ b/packages/logic/src/rewards/legacyUniqueItemPool.ts @@ -1,5 +1,6 @@ +import { createItemModuleRegistry, ItemLoader, ITEM_KEYS, loadItemModules } from '@sammo-ts/logic/items/index.js'; import type { ItemModule } from '@sammo-ts/logic/items/types.js'; -import type { UniqueItemPool } from './uniqueLottery.js'; +import { resolveUniqueConfig, type UniqueItemPool, type UniqueLotteryConfig } from './uniqueLottery.js'; const LEGACY_UNIQUE_ITEM_KEYS: Readonly> = { horse: [ @@ -125,3 +126,38 @@ export const buildLegacyDefaultUniqueItemPool = (itemRegistry: Map | null = null; + +const cloneUniqueItemPool = (pool: UniqueItemPool): UniqueItemPool => + Object.fromEntries(Object.entries(pool).map(([slot, entries]) => [slot, { ...entries }])); + +export const loadLegacyDefaultUniqueItemPool = async (loader?: ItemLoader): Promise => { + if (loader) { + const modules = await loadItemModules([...ITEM_KEYS], loader); + return buildLegacyDefaultUniqueItemPool(createItemModuleRegistry(modules)); + } + + legacyDefaultUniqueItemPoolPromise ??= loadItemModules([...ITEM_KEYS], new ItemLoader()).then((modules) => + buildLegacyDefaultUniqueItemPool(createItemModuleRegistry(modules)) + ); + return cloneUniqueItemPool(await legacyDefaultUniqueItemPoolPromise); +}; + +/** + * Ref의 GameConst 기본값은 시나리오가 allItems를 덮어쓰지 않아도 항상 존재합니다. + * 오래된 Core snapshot의 생략값/문자열 빈 객체도 같은 기본 풀로 해석합니다. + */ +export const resolveLegacyCompatibleUniqueConfig = async ( + configConst: Record, + loader?: ItemLoader +): Promise => { + const config = resolveUniqueConfig(configConst); + if (Object.keys(config.allItems).length > 0) { + return config; + } + return { + ...config, + allItems: await loadLegacyDefaultUniqueItemPool(loader), + }; +}; diff --git a/packages/logic/test/legacyUniqueItemPool.test.ts b/packages/logic/test/legacyUniqueItemPool.test.ts new file mode 100644 index 00000000..6d339e3a --- /dev/null +++ b/packages/logic/test/legacyUniqueItemPool.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveLegacyCompatibleUniqueConfig } from '../src/rewards/legacyUniqueItemPool.js'; + +describe('legacy-compatible unique item pool', () => { + it.each([undefined, {}, '{}'] as const)('restores the Ref default pool when allItems is %j', async (allItems) => { + const config = await resolveLegacyCompatibleUniqueConfig(allItems === undefined ? {} : { allItems }); + + expect(Object.keys(config.allItems)).toEqual(['horse', 'weapon', 'book', 'item']); + expect(config.allItems.weapon?.che_무기_12_칠성검).toBe(2); + expect(config.allItems.item?.che_의술_청낭서).toBe(1); + expect(config.allItems.weapon?.che_무기_01_단도).toBeUndefined(); + }); + + it('preserves an explicit scenario pool, including its counts', async () => { + const config = await resolveLegacyCompatibleUniqueConfig({ + allItems: { + weapon: { + che_무기_12_칠성검: 7, + }, + }, + }); + + expect(config.allItems).toEqual({ + weapon: { + che_무기_12_칠성검: 7, + }, + }); + }); +}); diff --git a/tools/frontend-legacy-parity/inheritance-management.spec.ts b/tools/frontend-legacy-parity/inheritance-management.spec.ts index 3758c333..2546e890 100644 --- a/tools/frontend-legacy-parity/inheritance-management.spec.ts +++ b/tools/frontend-legacy-parity/inheritance-management.spec.ts @@ -87,6 +87,12 @@ const statusFixture = { rawName: '칠성검', info: '무력을 올려주는 유니크 무기입니다.', }, + { + key: 'che_서적_07_논어', + name: '논어(+7)', + rawName: '논어', + info: '지력을 올려주는 유니크 서적입니다.', + }, ], availableTargetGenerals: [{ id: 8, name: '조조' }], turnTimeZones: ['00:00'], @@ -98,6 +104,7 @@ const statusFixture = { const installFixture = async (page: Page, options: { failBuff?: boolean } = {}) => { let buffMutationCount = 0; let resetTurnMutationCount = 0; + const uniqueAuctionRequests: unknown[] = []; await installImages(page); await page.addInitScript(() => { window.localStorage.setItem('sammo-game-token', 'ga_inherit-visual-token'); @@ -105,6 +112,7 @@ const installFixture = async (page: Page, options: { failBuff?: boolean } = {}) }); await page.route('**/che/api/trpc/**', async (route) => { const names = operations(route); + const requestBody: unknown = route.request().postData() ? route.request().postDataJSON() : null; if (options.failBuff && names.includes('inherit.buyHiddenBuff')) { await route.fulfill({ status: 500, @@ -145,6 +153,10 @@ const installFixture = async (page: Page, options: { failBuff?: boolean } = {}) resetTurnMutationCount += 1; return response({ ok: true, nextTurnTimeBase: 302.5143852464758, nextTurnTimeLabel: '00:05' }); } + if (name === 'inherit.openUniqueAuction') { + uniqueAuctionRequests.push(requestBody); + return response({ ok: true, auctionId: 31, closeAt: '2026-07-27T00:00:00.000Z' }); + } throw new Error(`Unhandled inheritance fixture operation: ${name}`); }); await route.fulfill({ @@ -156,6 +168,7 @@ const installFixture = async (page: Page, options: { failBuff?: boolean } = {}) return { buffMutationCount: () => buffMutationCount, resetTurnMutationCount: () => resetTurnMutationCount, + uniqueAuctionRequests, }; }; @@ -290,6 +303,28 @@ test.describe('inheritance management legacy parity', () => { await expect(page.locator('#inherit_previous_value')).toHaveValue('12,000'); }); + test('selects a Ref default unique and starts its auction from the inheritance page', async ({ page }) => { + const fixture = await installFixture(page); + await page.goto(gameUrl); + + await page.locator('#specific-unique').selectOption('che_서적_07_논어'); + await page.locator('#specific-unique-amount').fill('6000'); + page.once('dialog', async (dialog) => { + expect(dialog.message()).toBe('6000 포인트로 논어(+7)를 입찰하겠습니까?'); + await dialog.accept(); + }); + await page + .locator('.shop-item') + .filter({ has: page.locator('#specific-unique') }) + .getByRole('button', { name: '경매 시작' }) + .click(); + + await expect.poll(() => fixture.uniqueAuctionRequests.length).toBe(1); + expect(JSON.stringify(fixture.uniqueAuctionRequests[0])).toContain('che_서적_07_논어'); + expect(JSON.stringify(fixture.uniqueAuctionRequests[0])).toContain('6000'); + await expect(page.locator('.notice.success')).toHaveText('성공했습니다. 경매장을 확인해주세요.'); + }); + test('keeps controls usable and renders an API mutation error', async ({ page }) => { await installFixture(page, { failBuff: true }); page.on('dialog', (dialog) => dialog.accept()); diff --git a/tools/integration-tests/test/auctionFlow.test.ts b/tools/integration-tests/test/auctionFlow.test.ts index 13232b65..e111fa96 100644 --- a/tools/integration-tests/test/auctionFlow.test.ts +++ b/tools/integration-tests/test/auctionFlow.test.ts @@ -26,15 +26,8 @@ import { resolveRedisConfigFromEnv, GamePrisma, } from '@sammo-ts/infra'; -import { - buildNeutralResourceAuctionPlan, - ItemLoader, - ITEM_KEYS, -} from '@sammo-ts/logic'; -import { - createItemInventoryFromSlots, - serializeItemInventory, -} from '@sammo-ts/logic/items/inventory.js'; +import { buildNeutralResourceAuctionPlan, ItemLoader, ITEM_KEYS } from '@sammo-ts/logic'; +import { createItemInventoryFromSlots, serializeItemInventory } from '@sammo-ts/logic/items/inventory.js'; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const workspaceRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); @@ -301,6 +294,7 @@ describe('auction integration flow', () => { databaseUrl: resolvePostgresConfigFromEnv({ schema: 'che' }).url, adminUser: bootstrap.user, installOptions: { + serverId: 'auction-integration-908', turnTermMinutes: 1, sync: false, fiction: 0, @@ -676,6 +670,69 @@ describe('auction integration flow', () => { turnDaemonLoop = turnDaemon.lifecycle.start(); await sleep(500); + const directTransport = new DatabaseTurnDaemonTransport(prisma, 30_000); + await expect( + directTransport.requestCommand( + { + type: 'auctionBid', + auctionId: auction.id, + generalId: validBidder.generalId, + amount: 400, + }, + 30_000 + ) + ).resolves.toMatchObject({ + type: 'auctionBid', + ok: false, + reason: '이미 다른 유니크를 가지고 있습니다.', + }); + + const siblingCloseAt = new Date(turnDaemon.world.getGameNow(new Date()).getTime() + 10 * 60_000); + const siblingAuction = await prisma.auction.create({ + data: { + type: 'UNIQUE_ITEM', + targetCode: uniquePair.keyB, + hostGeneralId: 0, + hostName: '시스템', + detail: { + startBidAmount: 200, + isReverse: false, + availableLatestBidCloseDate: siblingCloseAt.toISOString(), + }, + status: 'OPEN', + closeAt: siblingCloseAt, + closeTick: BigInt(turnDaemon.world.dateToGameTick(siblingCloseAt)), + }, + }); + await prisma.auctionBid.create({ + data: { + auctionId: siblingAuction.id, + generalId: spareBidder.generalId, + amount: 350, + eventId: `same-slot-race-${siblingAuction.id}`, + eventAt: turnDaemon.world.getGameNow(new Date()), + }, + }); + await expect( + directTransport.requestCommand( + { + type: 'auctionBid', + auctionId: auction.id, + generalId: spareBidder.generalId, + amount: 400, + }, + 30_000 + ) + ).resolves.toMatchObject({ + type: 'auctionBid', + ok: false, + reason: '1순위 입찰자인 경매중에 같은 부위가 있습니다.', + }); + await prisma.auction.update({ + where: { id: siblingAuction.id }, + data: { status: 'CANCELED', finishedAt: new Date() }, + }); + const finalizeAt = new Date(turnDaemon!.world.getGameNow(new Date()).getTime() - 1000); const finalizeTick = turnDaemon!.world.dateToGameTick(finalizeAt); await prisma.auction.update({ @@ -684,7 +741,6 @@ describe('auction integration flow', () => { }); await redis.zAdd(keys.timerKey, [{ score: finalizeTick, value: String(auction.id) }]); - const transport = new DatabaseTurnDaemonTransport(prisma, 30_000); await prisma.$executeRaw( GamePrisma.sql` UPDATE auction @@ -694,15 +750,16 @@ describe('auction integration flow', () => { WHERE id = ${auction.id} ` ); - const result = await transport.requestCommand({ type: 'auctionFinalize', auctionId: auction.id }, 30_000); + const result = await directTransport.requestCommand({ type: 'auctionFinalize', auctionId: auction.id }, 30_000); expect(result).toMatchObject({ type: 'auctionFinalize', ok: false }); const reopened = await prisma.auction.findUnique({ where: { id: auction.id }, select: { status: true, closeAt: true }, }); - expect(reopened?.status).toBe('OPEN'); - expect(reopened?.closeAt.getTime()).toBeGreaterThan(finalizeAt.getTime()); + expect(reopened).not.toBeNull(); + expect(reopened!.status).toBe('OPEN'); + expect(reopened!.closeAt.getTime() - finalizeAt.getTime()).toBe(24 * 60_000); }, 60_000); it('unique auction: two bidders extend until limit, then winner gets item', async () => { @@ -1013,4 +1070,154 @@ describe('auction integration flow', () => { meta: expect.objectContaining({ neutralAuctionRegistrationKey: '180-02' }), }); }, 60_000); + + it('starts from inheritance, accepts another user bid, and awards a default-pool unique', async () => { + if (!gameConnector || !redisConnector || !gameServer) { + throw new Error('runtime not ready'); + } + const prisma = gameConnector.prisma; + const redis = redisConnector.client; + const [host, bidder] = userSessions; + if (!host || !bidder) { + throw new Error('not enough bidders'); + } + + if (turnDaemon) { + await turnDaemon.lifecycle.stop('integration-test'); + await turnDaemon.close(); + await turnDaemonLoop; + } + + const uniquePair = await findUniqueItemPair(); + const slotField = resolveSlotField(uniquePair.slot); + if (!slotField) { + throw new Error('unsupported item slot'); + } + const state = await prisma.worldState.findFirstOrThrow(); + const config = + state.config && typeof state.config === 'object' && !Array.isArray(state.config) ? state.config : {}; + const configConst = + config.const && typeof config.const === 'object' && !Array.isArray(config.const) ? config.const : {}; + await prisma.worldState.update({ + where: { id: state.id }, + data: { + currentYear: 180, + currentMonth: 4, + config: { + ...config, + const: { + ...configConst, + // 표준 Ref 시나리오는 GameConst 기본값을 상속한다. 오래된 + // Core snapshot의 문자열 빈 객체도 같은 입력으로 검증한다. + allItems: '{}', + }, + }, + }, + }); + await prisma.auction.updateMany({ + where: { type: 'UNIQUE_ITEM', status: { in: ['OPEN', 'FINALIZING'] } }, + data: { status: 'CANCELED', finishedAt: new Date() }, + }); + + for (const session of [host, bidder]) { + const row = await prisma.general.findUniqueOrThrow({ + where: { id: session.generalId }, + select: { meta: true }, + }); + const meta = row.meta && typeof row.meta === 'object' && !Array.isArray(row.meta) ? row.meta : {}; + await prisma.general.update({ + where: { id: session.generalId }, + data: { + weaponCode: 'None', + bookCode: 'None', + horseCode: 'None', + itemCode: 'None', + meta: { + ...meta, + itemInventory: serializeItemInventory( + createItemInventoryFromSlots({ horse: null, weapon: null, book: null, item: null }) + ), + }, + }, + }); + await prisma.inheritancePoint.upsert({ + where: { userId_key: { userId: session.userId, key: 'previous' } }, + update: { value: 100_000 }, + create: { userId: session.userId, key: 'previous', value: 100_000 }, + }); + } + await prisma.general.updateMany({ + where: { [slotField]: uniquePair.keyA } as GamePrisma.GeneralWhereInput, + data: { [slotField]: 'None' } as GamePrisma.GeneralUpdateManyMutationInput, + }); + + const gameDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'che' }).url; + const gatewayDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'public' }).url; + turnDaemon = await createTurnDaemonRuntime({ + profile: 'che', + profileName: 'che:908', + databaseUrl: gameDatabaseUrl, + gatewayDatabaseUrl, + }); + turnDaemonLoop = turnDaemon.lifecycle.start(); + await sleep(500); + + const hostClient = createGameClient(gameUrl, gameServer.config.trpcPath, { value: host.accessToken }); + const bidderClient = createGameClient(gameUrl, gameServer.config.trpcPath, { value: bidder.accessToken }); + const inheritStatus = await hostClient.inherit.getStatus.query(); + expect(inheritStatus.availableUnique.length).toBeGreaterThan(80); + expect(inheritStatus.availableUnique).toEqual( + expect.arrayContaining([expect.objectContaining({ key: uniquePair.keyA })]) + ); + + const opened = await hostClient.inherit.openUniqueAuction.mutate({ + itemId: uniquePair.keyA, + amount: 5_000, + }); + const auction = await prisma.auction.findUniqueOrThrow({ where: { id: opened.auctionId } }); + expect(auction).toMatchObject({ + type: 'UNIQUE_ITEM', + targetCode: uniquePair.keyA, + hostGeneralId: host.generalId, + status: 'OPEN', + }); + const timerKeys = buildAuctionTimerKeys(gameServer.config.profileName); + await expect(redis.zScore(timerKeys.timerKey, String(auction.id))).resolves.not.toBeNull(); + + await bidderClient.auction.bidUnique.mutate({ auctionId: auction.id, amount: 5_100 }); + await expect( + prisma.inheritancePoint.findUniqueOrThrow({ + where: { userId_key: { userId: host.userId, key: 'previous' } }, + }) + ).resolves.toMatchObject({ value: 100_000 }); + + const finalizeAt = new Date(turnDaemon.world.getGameNow(new Date()).getTime() - 1_000); + const finalizeTick = turnDaemon.world.dateToGameTick(finalizeAt); + await prisma.auction.update({ + where: { id: auction.id }, + data: { + closeAt: finalizeAt, + closeTick: BigInt(finalizeTick), + status: 'FINALIZING', + finalizingAt: new Date(), + }, + }); + const transport = new DatabaseTurnDaemonTransport(prisma, 30_000); + const result = await transport.requestCommand({ type: 'auctionFinalize', auctionId: auction.id }, 30_000); + expect(result).toMatchObject({ type: 'auctionFinalize', ok: true }); + + await expect(prisma.auction.findUniqueOrThrow({ where: { id: auction.id } })).resolves.toMatchObject({ + status: 'FINISHED', + }); + const winner = await prisma.general.findUniqueOrThrow({ + where: { id: bidder.generalId }, + select: { weaponCode: true, bookCode: true, horseCode: true, itemCode: true }, + }); + expect(Object.values(winner)).toContain(uniquePair.keyA); + await expect( + prisma.inheritancePoint.findUniqueOrThrow({ + where: { userId_key: { userId: bidder.userId, key: 'previous' } }, + }) + ).resolves.toMatchObject({ value: 94_900 }); + }, 60_000); });