import { randomUUID } from 'node:crypto'; import { createGamePostgresConnector, GamePrisma, type GamePrismaClient } from '@sammo-ts/infra'; import type { TurnDaemonCommand, TurnDaemonCommandResult } from '../lifecycle/types.js'; import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js'; export interface AuctionBidder { bid( command: Extract, 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; interface AuctionRow { id: number; type: AuctionType; hostGeneralId: number; detail: unknown; status: AuctionStatus; closeAt: Date; latestEventId: string; } interface AuctionBidRow { id: number; generalId: number; amount: number; meta: unknown; } interface AuctionDetail { isReverse?: boolean; startBidAmount?: number; finishBidAmount?: number | null; availableLatestBidCloseDate?: string | null; } const parseDetail = (detail: unknown): AuctionDetail => { if (!detail || typeof detail !== 'object') { return {}; } return detail as AuctionDetail; }; const toFiniteNumber = (value: unknown): number => { const parsed = typeof value === 'string' ? Number(value) : value; return typeof parsed === 'number' && Number.isFinite(parsed) ? parsed : 0; }; const readRankTrackedAmount = (bid: AuctionBidRow | null): number => { if (!bid?.meta || typeof bid.meta !== 'object' || Array.isArray(bid.meta)) return 0; return Math.max(0, toFiniteNumber((bid.meta as Record).inheritSpentTrackedAmount)); }; const extendCloseDate = (options: { now: Date; closeAt: Date; turnMinutes: number; availableLatestBidCloseDate?: Date | null; }): Date => { const { now, closeAt, turnMinutes, availableLatestBidCloseDate } = options; const extendMinutes = Math.max(MIN_EXTENSION_MINUTES_PER_BID, turnMinutes * COEFF_EXTENSION_MINUTES_PER_BID); const extended = new Date(now.getTime() + extendMinutes * 60 * 1000); if (extended.getTime() <= closeAt.getTime()) { return closeAt; } if (availableLatestBidCloseDate && extended.getTime() > availableLatestBidCloseDate.getTime()) { return availableLatestBidCloseDate; } return extended; }; const shouldUsePrevBid = (highestBid: AuctionBidRow | null, myPrevBid: AuctionBidRow | null): AuctionBidRow | null => { if (!myPrevBid) { return null; } if (!highestBid) { return myPrevBid; } if (highestBid.id !== myPrevBid.id) { return null; } return myPrevBid; }; type QueryClient = Pick; const loadAuction = async (prisma: QueryClient, auctionId: number): Promise => { const rows = await prisma.$queryRaw( GamePrisma.sql` SELECT id, type, host_general_id as "hostGeneralId", detail, status, close_at as "closeAt", latest_event_id as "latestEventId" FROM auction WHERE id = ${auctionId} FOR UPDATE ` ); return rows[0] ?? null; }; const loadHighestBid = async ( prisma: QueryClient, auctionId: number, isReverse: boolean ): Promise => { const rows = await prisma.$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 ` ); return rows[0] ?? null; }; const loadMyPrevBid = async ( prisma: QueryClient, auctionId: number, generalId: number, isReverse: boolean ): Promise => { const rows = await prisma.$queryRaw( isReverse ? GamePrisma.sql` SELECT id, general_id as "generalId", amount, meta FROM auction_bid WHERE auction_id = ${auctionId} AND general_id = ${generalId} 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} AND general_id = ${generalId} ORDER BY amount DESC, id ASC LIMIT 1 ` ); return rows[0] ?? null; }; const resolveUserId = async (prisma: QueryClient, generalId: number): Promise => { const rows = await prisma.$queryRaw<{ userId: string | null }[]>( GamePrisma.sql`SELECT user_id as "userId" FROM general WHERE id = ${generalId}` ); return rows[0]?.userId ?? null; }; export const createAuctionBidder = async (options: { databaseUrl: string; world: InMemoryTurnWorld; }): Promise => { const connector = createGamePostgresConnector({ url: options.databaseUrl }); await connector.connect(); const prisma = connector.prisma; const world = options.world; return { bid: async (command, commandDb): Promise => { const db = commandDb ?? prisma; const auction = await loadAuction(db, command.auctionId); if (!auction) { return { type: 'auctionBid', ok: false, auctionId: command.auctionId, reason: '경매가 없습니다.' }; } if (auction.status !== 'OPEN') { return { type: 'auctionBid', ok: false, auctionId: command.auctionId, reason: '경매가 종료되었습니다.', }; } const now = world.getGameNow(new Date()); if (auction.closeAt.getTime() <= now.getTime()) { return { type: 'auctionBid', ok: false, auctionId: command.auctionId, reason: '경매가 종료되었습니다.', }; } const detail = parseDetail(auction.detail); const isReverse = detail.isReverse === true; const highestBid = await loadHighestBid(db, command.auctionId, isReverse); const myPrevBidRaw = await loadMyPrevBid(db, command.auctionId, command.generalId, isReverse); const myPrevBid = shouldUsePrevBid(highestBid, myPrevBidRaw); if (highestBid) { if (!isReverse && command.amount <= highestBid.amount) { return { type: 'auctionBid', ok: false, auctionId: command.auctionId, reason: '현재입찰가보다 높게 입찰해야 합니다.', }; } if (isReverse && command.amount >= highestBid.amount) { return { type: 'auctionBid', ok: false, auctionId: command.auctionId, reason: '현재입찰가보다 낮게 입찰해야 합니다.', }; } } else if (detail.startBidAmount && command.amount < detail.startBidAmount) { return { type: 'auctionBid', ok: false, auctionId: command.auctionId, reason: '시작가보다 낮습니다.', }; } if (!isReverse && detail.finishBidAmount != null && command.amount > detail.finishBidAmount) { return { type: 'auctionBid', ok: false, auctionId: command.auctionId, reason: '즉시판매가보다 높을 수 없습니다.', }; } if (isReverse && detail.finishBidAmount != null && command.amount < detail.finishBidAmount) { return { type: 'auctionBid', ok: false, auctionId: command.auctionId, reason: '즉시판매가보다 낮을 수 없습니다.', }; } if (auction.type === 'UNIQUE_ITEM' && highestBid) { if (command.amount < highestBid.amount * 1.01) { return { type: 'auctionBid', ok: false, auctionId: command.auctionId, reason: '현재입찰가보다 1% 높게 입찰해야 합니다.', }; } if (command.amount < highestBid.amount + 10) { return { type: 'auctionBid', ok: false, auctionId: command.auctionId, reason: '현재입찰가보다 10 포인트 높게 입찰해야 합니다.', }; } } const morePoint = command.amount - (myPrevBid?.amount ?? 0); if (morePoint <= 0) { return { type: 'auctionBid', ok: false, auctionId: command.auctionId, reason: '입찰가가 유효하지 않습니다.', }; } const general = world.getGeneralById(command.generalId); if (!general) { return { type: 'auctionBid', ok: false, auctionId: command.auctionId, reason: '장수 정보를 찾을 수 없습니다.', }; } if (auction.type !== 'UNIQUE_ITEM' && auction.hostGeneralId === general.id) { return { type: 'auctionBid', ok: false, auctionId: command.auctionId, reason: '자신이 연 경매에 입찰할 수 없습니다.', }; } if (auction.type === 'BUY_RICE' && general.gold < morePoint) { return { type: 'auctionBid', ok: false, auctionId: command.auctionId, reason: '금이 부족합니다.', }; } if (auction.type === 'SELL_RICE' && general.rice < morePoint) { return { type: 'auctionBid', ok: false, auctionId: command.auctionId, reason: '쌀이 부족합니다.', }; } if ( auction.type !== 'UNIQUE_ITEM' && highestBid && highestBid.generalId !== command.generalId && !myPrevBid ) { const prev = world.getGeneralById(highestBid.generalId); if (!prev) { return { type: 'auctionBid', ok: false, auctionId: command.auctionId, reason: '환불 대상을 찾을 수 없습니다.', }; } } const turnMinutes = Math.max(1, Math.round(world.getState().tickSeconds / 60)); const availableLatestBidCloseDate = detail.availableLatestBidCloseDate ? new Date(detail.availableLatestBidCloseDate) : null; let nextCloseAt = extendCloseDate({ now, closeAt: auction.closeAt, turnMinutes, availableLatestBidCloseDate, }); if ( auction.type !== 'UNIQUE_ITEM' && detail.finishBidAmount != null && command.amount === detail.finishBidAmount ) { nextCloseAt = new Date(now.getTime() + turnMinutes * 60_000); } const eventId = randomUUID(); const eventAt = now; const rankTrackedAmount = auction.type === 'UNIQUE_ITEM' ? readRankTrackedAmount(myPrevBid) + morePoint : 0; const previousRankTrackedAmount = readRankTrackedAmount(highestBid); try { const persistBid = async (tx: GamePrisma.TransactionClient): Promise => { await tx.$executeRaw( GamePrisma.sql` INSERT INTO auction_bid (auction_id, general_id, amount, event_id, event_at, meta) VALUES ( ${command.auctionId}, ${command.generalId}, ${command.amount}, ${eventId}, ${eventAt}, ${JSON.stringify({ tryExtendCloseDate: command.tryExtendCloseDate ?? true, ...(auction.type === 'UNIQUE_ITEM' ? { inheritSpentTrackedAmount: rankTrackedAmount } : {}), })}::jsonb ) ` ); // 같은 논리 tick의 연속 입찰은 시각이 같으므로, UUID 정렬이 아니라 // 읽어 둔 이벤트 ID를 버전 토큰으로 사용해 경합만 거절한다. const updated = await tx.$executeRaw( GamePrisma.sql` UPDATE auction SET close_at = ${nextCloseAt}, close_tick = ${BigInt(world.dateToGameTick(nextCloseAt))}, latest_event_id = ${eventId}, latest_event_at = ${eventAt}, updated_at = ${eventAt} WHERE id = ${command.auctionId} AND status = 'OPEN' AND latest_event_id = ${auction.latestEventId} ` ); if (updated === 0) { throw new Error('CONFLICT'); } if (auction.type === 'UNIQUE_ITEM') { const userId = await resolveUserId(tx, command.generalId); if (!userId) { throw new Error('USER_NOT_FOUND'); } const deductedRows = await tx.$queryRaw>( GamePrisma.sql` UPDATE inheritance_point SET value = value - ${morePoint}, updated_at = ${eventAt} WHERE user_id = ${userId} AND key = 'previous' AND value >= ${morePoint} RETURNING value ` ); if (deductedRows.length === 0) { throw new Error('INSUFFICIENT_POINT'); } await tx.$executeRaw( GamePrisma.sql` INSERT INTO rank_data (nation_id, general_id, type, value) SELECT nation_id, id, 'inherit_spent_dyn', ${morePoint} FROM general WHERE id = ${command.generalId} ON CONFLICT (general_id, type) DO UPDATE SET nation_id = EXCLUDED.nation_id, value = rank_data.value + EXCLUDED.value ` ); if (highestBid && highestBid.generalId !== command.generalId && !myPrevBid) { const prevUserId = await resolveUserId(tx, highestBid.generalId); if (!prevUserId) { throw new Error('USER_NOT_FOUND'); } await tx.$executeRaw( GamePrisma.sql` INSERT INTO inheritance_point (user_id, key, value, updated_at) VALUES (${prevUserId}, 'previous', ${highestBid.amount}, ${eventAt}) ON CONFLICT (user_id, key) DO UPDATE SET value = inheritance_point.value + EXCLUDED.value, updated_at = EXCLUDED.updated_at ` ); await tx.$executeRaw( GamePrisma.sql` UPDATE rank_data SET value = GREATEST(0, value - ${previousRankTrackedAmount}) WHERE general_id = ${highestBid.generalId} AND type = 'inherit_spent_dyn' ` ); } } }; if (commandDb) { await commandDb.$executeRawUnsafe('SAVEPOINT auction_bid_attempt'); try { await persistBid(commandDb); await commandDb.$executeRawUnsafe('RELEASE SAVEPOINT auction_bid_attempt'); } catch (error) { await commandDb.$executeRawUnsafe('ROLLBACK TO SAVEPOINT auction_bid_attempt'); await commandDb.$executeRawUnsafe('RELEASE SAVEPOINT auction_bid_attempt'); throw error; } } else { await prisma.$transaction(persistBid); } } catch (error) { const reason = error instanceof Error ? error.message : 'CONFLICT'; if (reason === 'INSUFFICIENT_POINT') { return { type: 'auctionBid', ok: false, auctionId: command.auctionId, reason: '유산포인트가 부족합니다.', }; } if (reason === 'USER_NOT_FOUND') { return { type: 'auctionBid', ok: false, auctionId: command.auctionId, reason: '장수 정보를 찾을 수 없습니다.', }; } return { type: 'auctionBid', ok: false, auctionId: command.auctionId, reason: '경매가 취소되었습니다.', }; } if (auction.type === 'UNIQUE_ITEM') { world.updateGeneral(command.generalId, { inheritancePoints: { ...general.inheritancePoints, previous: toFiniteNumber(general.inheritancePoints?.previous) - morePoint, }, meta: { ...general.meta, inherit_spent_dyn: toFiniteNumber(general.meta.inherit_spent_dyn) + morePoint, }, }); if (highestBid && highestBid.generalId !== command.generalId && !myPrevBid) { const prev = world.getGeneralById(highestBid.generalId); if (prev) { world.updateGeneral(prev.id, { inheritancePoints: { ...prev.inheritancePoints, previous: toFiniteNumber(prev.inheritancePoints?.previous) + highestBid.amount, }, meta: { ...prev.meta, inherit_spent_dyn: Math.max( 0, toFiniteNumber(prev.meta.inherit_spent_dyn) - previousRankTrackedAmount ), }, }); } } } else { const resourceType = auction.type === 'BUY_RICE' ? 'gold' : 'rice'; world.updateGeneral(command.generalId, { gold: resourceType === 'gold' ? general.gold - morePoint : general.gold, rice: resourceType === 'rice' ? general.rice - morePoint : general.rice, }); if (highestBid && highestBid.generalId !== command.generalId && !myPrevBid) { const prev = world.getGeneralById(highestBid.generalId); if (prev) { world.updateGeneral(highestBid.generalId, { gold: resourceType === 'gold' ? prev.gold + highestBid.amount : prev.gold, rice: resourceType === 'rice' ? prev.rice + highestBid.amount : prev.rice, }); } } } return { type: 'auctionBid', ok: true, auctionId: command.auctionId, closeAt: nextCloseAt.toISOString(), }; }, close: async (): Promise => { await connector.disconnect(); }, }; };