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 @@