import { createGamePostgresConnector, GamePrisma } from '@sammo-ts/infra'; 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'; import type { TurnDaemonCommandResult } from '../lifecycle/types.js'; import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js'; import type { LogEntryDraft } from '@sammo-ts/logic'; export interface AuctionFinalizer { finalize(auctionId: number, db?: GamePrisma.TransactionClient): Promise; close(): Promise; } type AuctionType = 'BUY_RICE' | 'SELL_RICE' | 'UNIQUE_ITEM'; 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; type: AuctionType; targetCode: string | null; hostGeneralId: number; hostName: string | null; detail: unknown; status: AuctionStatus; closeAt: Date; } interface AuctionBidRow { id: number; generalId: number; amount: number; meta: unknown; } interface AuctionDetailBase { title?: string; isReverse?: boolean; tryExtendCloseDate?: boolean; availableLatestBidCloseDate?: string | null; remainCloseDateExtensionCnt?: number | null; } interface AuctionDetailResource extends AuctionDetailBase { amount?: number; } const parseDetail = (detail: unknown): AuctionDetailResource => { if (!detail || typeof detail !== 'object') { return {}; } return detail as AuctionDetailResource; }; const toTurnMinutes = (tickSeconds: number): number => Math.max(1, Math.round(tickSeconds / 60)); type AuctionDb = GamePrisma.TransactionClient; const resolveTurnMinutes = async (prisma: AuctionDb): Promise => { const rows = (await prisma.$queryRaw( GamePrisma.sql`SELECT tick_seconds as "tickSeconds" FROM world_state ORDER BY id LIMIT 1` )) as Array<{ tickSeconds: number }>; return toTurnMinutes(rows[0]?.tickSeconds ?? 60); }; const pushLogs = (world: InMemoryTurnWorld, logs: LogEntryDraft[]): void => { for (const log of logs) { world.pushLog(log); } }; const refundInheritancePoint = async (options: { prisma: AuctionDb; userId: string; amount: number; }): Promise => { const { prisma, userId, amount } = options; if (!userId || amount <= 0) { return; } await prisma.$executeRaw( GamePrisma.sql` INSERT INTO inheritance_point (user_id, key, value, updated_at) VALUES (${userId}, 'previous', ${amount}, ${new Date()}) ON CONFLICT (user_id, key) DO UPDATE SET value = inheritance_point.value + EXCLUDED.value, updated_at = EXCLUDED.updated_at ` ); }; export const createAuctionFinalizer = async (options: { databaseUrl: string; world: InMemoryTurnWorld; }): Promise => { const connector = createGamePostgresConnector({ url: options.databaseUrl }); await connector.connect(); const prisma = connector.prisma; const world = options.world; const itemLoader = new ItemLoader(); const getGeneralUserId = async (db: AuctionDb, generalId: number): Promise => { const rows = await db.$queryRaw<{ userId: string | null }[]>( GamePrisma.sql`SELECT user_id as "userId" FROM general WHERE id = ${generalId}` ); return rows[0]?.userId ?? null; }; return { finalize: async (auctionId: number, commandDb): Promise => { const db = commandDb ?? prisma; const rows = await db.$queryRaw( GamePrisma.sql` SELECT id, type, target_code as "targetCode", host_general_id as "hostGeneralId", host_name as "hostName", detail, status, close_at as "closeAt" FROM auction WHERE id = ${auctionId} ` ); const auction = rows[0]; if (!auction) { return { type: 'auctionFinalize', ok: false, auctionId, reason: '경매 정보를 찾을 수 없습니다.', }; } if (auction.status === 'FINISHED' || auction.status === 'CANCELED') { return { type: 'auctionFinalize', ok: true, auctionId }; } if (auction.status !== 'FINALIZING') { return { type: 'auctionFinalize', ok: false, auctionId, reason: '경매가 확정 대기 상태가 아닙니다.', }; } const detail = parseDetail(auction.detail); const isReverse = detail.isReverse === true; const bidRows = await db.$queryRaw( isReverse ? GamePrisma.sql` SELECT id, general_id as "generalId", amount, meta FROM auction_bid WHERE auction_id = ${auctionId} ORDER BY amount ASC, id ASC LIMIT 1 ` : GamePrisma.sql` SELECT id, general_id as "generalId", amount, meta FROM auction_bid WHERE auction_id = ${auctionId} ORDER BY amount DESC, id ASC LIMIT 1 ` ); const highestBid = bidRows[0] ?? null; const now = world.getGameNow(new Date()); const logs: LogEntryDraft[] = []; const globalLogger = new ActionLogger(); const finalizeStatus = async (status: AuctionStatus) => { await db.$executeRaw( GamePrisma.sql` UPDATE auction SET status = ${status}, finished_at = ${now}, updated_at = ${now} WHERE id = ${auctionId} ` ); }; if (!highestBid) { if (auction.type === 'BUY_RICE' || auction.type === 'SELL_RICE') { const amount = detail.amount; if (!amount || amount <= 0) { await finalizeStatus('CANCELED'); return { type: 'auctionFinalize', ok: false, auctionId, reason: '경매 거래량 정보가 없습니다.', }; } if (auction.hostGeneralId > 0) { const host = world.getGeneralById(auction.hostGeneralId); if (!host) { await finalizeStatus('CANCELED'); return { type: 'auctionFinalize', ok: false, auctionId, reason: '경매 주최자를 찾을 수 없습니다.', }; } const resourceKey = auction.type === 'BUY_RICE' ? 'rice' : 'gold'; world.updateGeneral(host.id, { [resourceKey]: host[resourceKey] + amount, }); const hostLogger = new ActionLogger({ generalId: host.id, nationId: host.nationId }); hostLogger.pushGeneralActionLog( `경매가 유찰되어 ${resourceKey === 'rice' ? '쌀' : '금'} ${amount}을 회수했습니다.`, LogFormat.PLAIN ); logs.push(...hostLogger.flush()); globalLogger.pushGlobalActionLog(`경매 ${auctionId}번이 유찰되었습니다.`, LogFormat.PLAIN); } } logs.push(...globalLogger.flush()); pushLogs(world, logs); await finalizeStatus('FINISHED'); return { type: 'auctionFinalize', ok: true, auctionId }; } if (auction.type === 'UNIQUE_ITEM') { const bidMeta = parseDetail(highestBid.meta); const remainExtension = detail.remainCloseDateExtensionCnt ?? 0; if (bidMeta.tryExtendCloseDate === true && remainExtension > 0) { const turnMinutes = await resolveTurnMinutes(db); const nextCloseAt = new Date(auction.closeAt.getTime() + Math.max(5, turnMinutes) * 60_000); const nextLatestBidCloseAt = new Date( nextCloseAt.getTime() + Math.max(MIN_EXTENSION_MINUTES_PER_BID, turnMinutes * COEFF_EXTENSION_MINUTES_PER_BID) * 60_000 ); const nextDetail = { ...detail, remainCloseDateExtensionCnt: remainExtension - 1, availableLatestBidCloseDate: nextLatestBidCloseAt.toISOString(), }; await db.$executeRaw( GamePrisma.sql` UPDATE auction SET status = 'OPEN', detail = ${JSON.stringify(nextDetail)}::jsonb, close_at = ${nextCloseAt}, close_tick = ${BigInt(world.dateToGameTick(nextCloseAt))}, updated_at = ${now} WHERE id = ${auctionId} ` ); return { type: 'auctionFinalize', ok: false, auctionId, reason: '입찰자의 요청으로 경매 종료가 연장되었습니다.', }; } } const bidder = world.getGeneralById(highestBid.generalId); if (!bidder) { await finalizeStatus('CANCELED'); return { type: 'auctionFinalize', ok: false, auctionId, reason: '입찰자를 찾을 수 없습니다.', }; } if (auction.type === 'BUY_RICE' || auction.type === 'SELL_RICE') { const amount = detail.amount; if (!amount || amount <= 0) { await finalizeStatus('CANCELED'); return { type: 'auctionFinalize', ok: false, auctionId, reason: '경매 거래량 정보가 없습니다.', }; } const hostResource = auction.type === 'BUY_RICE' ? 'rice' : 'gold'; const bidderResource = auction.type === 'BUY_RICE' ? 'gold' : 'rice'; if (auction.hostGeneralId > 0) { const host = world.getGeneralById(auction.hostGeneralId); if (!host) { await finalizeStatus('CANCELED'); return { type: 'auctionFinalize', ok: false, auctionId, reason: '경매 주최자를 찾을 수 없습니다.', }; } world.updateGeneral(host.id, { [bidderResource]: host[bidderResource] + highestBid.amount, }); const hostLogger = new ActionLogger({ generalId: host.id, nationId: host.nationId }); const hostJosa = JosaUtil.pick(host.name, '이'); hostLogger.pushGeneralActionLog( `${auctionId}번 경매 성사: ${host.name}${hostJosa} ${hostResource === 'rice' ? '쌀' : '금'} ${amount}을 판매했습니다.`, LogFormat.PLAIN ); logs.push(...hostLogger.flush()); } world.updateGeneral(bidder.id, { [hostResource]: bidder[hostResource] + amount, }); const bidderLogger = new ActionLogger({ generalId: bidder.id, nationId: bidder.nationId }); bidderLogger.pushGeneralActionLog( `${auctionId}번 경매 성사: ${hostResource === 'rice' ? '쌀' : '금'} ${amount}을 획득했습니다.`, LogFormat.PLAIN ); logs.push(...bidderLogger.flush()); globalLogger.pushGlobalActionLog(`경매 ${auctionId}번 거래가 성사되었습니다.`, LogFormat.PLAIN); } else if (auction.type === 'UNIQUE_ITEM') { const itemKey = auction.targetCode; if (!itemKey) { await finalizeStatus('CANCELED'); return { type: 'auctionFinalize', ok: false, auctionId, reason: '유니크 아이템 정보가 없습니다.', }; } if (!isItemKey(itemKey)) { await finalizeStatus('CANCELED'); return { type: 'auctionFinalize', ok: false, auctionId, reason: '아이템 키가 올바르지 않습니다.', }; } const itemModule = await itemLoader.load(itemKey).catch(() => null); if (!itemModule) { await finalizeStatus('CANCELED'); await refundInheritancePoint({ prisma: db, userId: (await getGeneralUserId(db, bidder.id)) ?? '', amount: highestBid.amount, }); return { type: 'auctionFinalize', ok: false, auctionId, reason: '아이템 정보를 불러올 수 없습니다.', }; } const state = world.getState(); 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) ? scenarioMeta.startYear : state.currentYear; const relativeYear = state.currentYear - startYear; let uniqueLimit = 1; for (const [targetYear, targetLimit] of config.maxUniqueItemLimit) { if (relativeYear < targetYear) { break; } uniqueLimit = targetLimit; } uniqueLimit = Math.min(uniqueLimit, Object.keys(config.allItems).length); let equippedUniqueCount = 0; for (const equippedKey of Object.values(bidder.role.items)) { if (!equippedKey || equippedKey === 'None' || !isItemKey(equippedKey)) { continue; } const equippedModule = await itemLoader.load(equippedKey).catch(() => null); if (equippedModule && !equippedModule.buyable) { equippedUniqueCount += 1; } } if (equippedUniqueCount >= uniqueLimit) { const turnMinutes = await resolveTurnMinutes(db); const nextCloseAt = new Date( auction.closeAt.getTime() + Math.max( MIN_EXTENSION_MINUTES_BY_EXTENSION_QUERY, turnMinutes * COEFF_EXTENSION_MINUTES_LIMIT_UNIQUE_COUNT ) * 60_000 ); const nextLatestBidCloseAt = new Date( nextCloseAt.getTime() + Math.max(MIN_EXTENSION_MINUTES_PER_BID, turnMinutes * COEFF_EXTENSION_MINUTES_PER_BID) * 60_000 ); const nextDetail = { ...detail, availableLatestBidCloseDate: nextLatestBidCloseAt.toISOString(), }; await db.$executeRaw( GamePrisma.sql` UPDATE auction SET status = 'OPEN', host_general_id = ${bidder.id === auction.hostGeneralId ? auction.hostGeneralId : 0}, host_name = ${bidder.id === auction.hostGeneralId ? auction.hostName : '(상인)'}, detail = ${JSON.stringify(nextDetail)}::jsonb, close_at = ${nextCloseAt}, close_tick = ${BigInt(world.dateToGameTick(nextCloseAt))}, updated_at = ${now} WHERE id = ${auctionId} ` ); globalLogger.pushGlobalActionLog( `유니크 경매 ${auctionId}번이 전체 보유 제한으로 연장되었습니다.`, LogFormat.PLAIN ); logs.push(...globalLogger.flush()); pushLogs(world, logs); return { type: 'auctionFinalize', ok: false, auctionId, reason: '유니크 아이템 소유 제한 상태입니다. 종료 시간이 연장됩니다.', }; } const slot = itemModule.slot; const currentItem = bidder.role.items?.[slot] ?? null; if (currentItem && currentItem !== 'None' && isItemKey(currentItem)) { const currentModule = await itemLoader.load(currentItem).catch(() => null); if (currentModule && !currentModule.buyable) { 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 ); const nextLatestBidCloseAt = new Date( nextCloseAt.getTime() + Math.max(MIN_EXTENSION_MINUTES_PER_BID, turnMinutes * COEFF_EXTENSION_MINUTES_PER_BID) * 60_000 ); const nextDetail = { ...detail, availableLatestBidCloseDate: nextLatestBidCloseAt.toISOString(), }; await db.$executeRaw( GamePrisma.sql` UPDATE auction SET status = 'OPEN', host_general_id = ${bidder.id === auction.hostGeneralId ? auction.hostGeneralId : 0}, host_name = ${bidder.id === auction.hostGeneralId ? auction.hostName : '(상인)'}, detail = ${JSON.stringify(nextDetail)}::jsonb, close_at = ${nextCloseAt}, close_tick = ${BigInt(world.dateToGameTick(nextCloseAt))}, updated_at = ${now} WHERE id = ${auctionId} ` ); globalLogger.pushGlobalActionLog( `유니크 경매 ${auctionId}번이 보유 제한으로 연장되었습니다.`, LogFormat.PLAIN ); logs.push(...globalLogger.flush()); pushLogs(world, logs); return { type: 'auctionFinalize', ok: false, auctionId, reason: '유니크 보유 제한으로 연장', }; } } const nextBidder = { ...bidder, role: { ...bidder.role, items: { ...bidder.role.items } }, itemInventory: cloneItemInventory(ensureItemInventory(bidder)), }; equipNewItem(nextBidder, slot, itemKey, { ...(itemModule.initialCharges === undefined ? {} : { charges: itemModule.initialCharges }), }); world.updateGeneral(bidder.id, { role: nextBidder.role, itemInventory: nextBidder.itemInventory, }); const bidderLogger = new ActionLogger({ generalId: bidder.id, nationId: bidder.nationId }); const josaUl = JosaUtil.pick(itemModule.rawName, '을'); bidderLogger.pushGeneralActionLog( `유니크 경매 낙찰로 ${itemModule.name}${josaUl} 획득했습니다.`, LogFormat.PLAIN ); logs.push(...bidderLogger.flush()); const bidderUserId = await getGeneralUserId(db, bidder.id); if (bidderUserId) { const userIdNum = Number(bidderUserId); if (Number.isFinite(userIdNum)) { const userLogger = new UserLogger(userIdNum); userLogger.push( `유니크 ${itemModule.name} 경매로 ${highestBid.amount} 포인트 사용`, 'inheritPoint' ); logs.push(...userLogger.flush()); } } globalLogger.pushGlobalActionLog(`유니크 경매 ${auctionId}번이 성사되었습니다.`, LogFormat.PLAIN); } logs.push(...globalLogger.flush()); pushLogs(world, logs); await finalizeStatus('FINISHED'); return { type: 'auctionFinalize', ok: true, auctionId }; }, close: async () => { await connector.disconnect(); }, }; };