diff --git a/app/game-api/src/auction/open.ts b/app/game-api/src/auction/open.ts new file mode 100644 index 0000000..8032202 --- /dev/null +++ b/app/game-api/src/auction/open.ts @@ -0,0 +1,44 @@ +import { TRPCError } from '@trpc/server'; + +import type { GameApiContext } from '../context.js'; +import { buildAuctionTimerKeys } from './keys.js'; + +export type OpenAuctionInput = + | { + auctionType: 'BUY_RICE' | 'SELL_RICE'; + amount: number; + closeTurnCnt: number; + startBidAmount: number; + finishBidAmount: number; + } + | { + auctionType: 'UNIQUE_ITEM'; + amount: number; + itemKey: string; + }; + +export const openAuctionWithDaemon = async ( + ctx: GameApiContext, + generalId: number, + input: OpenAuctionInput +): Promise<{ auctionId: number; closeAt: string }> => { + const result = await ctx.turnDaemon.requestCommand({ + type: 'auctionOpen', + generalId, + ...input, + }); + if (!result || result.type !== 'auctionOpen') { + throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' }); + } + if (!result.ok) { + throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason }); + } + + const timerKeys = buildAuctionTimerKeys(ctx.profile.name); + const closeAt = new Date(result.closeAt); + await ctx.redis.zAdd(timerKeys.timerKey, [{ score: closeAt.getTime(), value: String(result.auctionId) }]); + return { + auctionId: result.auctionId, + closeAt: result.closeAt, + }; +}; diff --git a/app/game-api/src/router/auction/index.ts b/app/game-api/src/router/auction/index.ts index d1fe2c0..5b2f30d 100644 --- a/app/game-api/src/router/auction/index.ts +++ b/app/game-api/src/router/auction/index.ts @@ -6,6 +6,9 @@ import type { DatabaseClient, GameApiContext, GeneralRow } from '../../context.j import { buildAuctionTimerKeys } from '../../auction/keys.js'; import { GamePrisma } from '@sammo-ts/infra'; import { ItemLoader, isItemKey } from '@sammo-ts/logic'; +import { asRecord } from '@sammo-ts/common'; +import { buildAuctionAlias } from '@sammo-ts/logic'; +import { openAuctionWithDaemon } from '../../auction/open.js'; const zBidInput = z.object({ @@ -17,6 +20,18 @@ const zUniqueBidInput = zBidInput.extend({ tryExtendCloseDate: z.boolean().optional(), }); +const zOpenResourceInput = z.object({ + amount: z.number().int().min(100).max(10_000), + closeTurnCnt: z.number().int().min(1).max(24), + startBidAmount: z.number().int().positive(), + finishBidAmount: z.number().int().positive(), +}); + +const zOpenUniqueInput = z.object({ + itemKey: z.string(), + amount: z.number().int().positive(), +}); + type AuctionType = 'BUY_RICE' | 'SELL_RICE' | 'UNIQUE_ITEM'; interface AuctionRow { @@ -29,7 +44,7 @@ interface AuctionRow { closeAt: Date; } -interface AuctionDetail { +export interface AuctionDetail { title?: string; amount?: number; isReverse?: boolean; @@ -161,6 +176,169 @@ const shouldUsePrevBid = (highestBid: AuctionBidRow | null, myPrevBid: AuctionBi }; export const auctionRouter = router({ + getOverview: authedProcedure.query(async ({ ctx }) => { + const auth = requireAuth(ctx); + const general = await ensureGeneral(ctx.db, auth.user.id); + const [auctions, worldState, point, recentLogs] = await Promise.all([ + ctx.db.auction.findMany({ + where: { + OR: [ + { type: { in: ['BUY_RICE', 'SELL_RICE'] }, status: 'OPEN' }, + { type: 'UNIQUE_ITEM' }, + ], + }, + orderBy: [{ status: 'asc' }, { id: 'desc' }], + take: 120, + include: { + bids: { + orderBy: [{ amount: 'desc' }, { id: 'asc' }], + }, + }, + }), + ctx.db.worldState.findFirst(), + ctx.db.inheritancePoint.findUnique({ + where: { userId_key: { userId: auth.user.id, key: 'previous' } }, + }), + ctx.db.logEntry.findMany({ + where: { text: { contains: '경매' } }, + orderBy: { id: 'desc' }, + take: 20, + select: { id: true, text: true, createdAt: true }, + }), + ]); + const generalIds = new Set(); + for (const auction of auctions) { + if (auction.type !== 'UNIQUE_ITEM' && auction.hostGeneralId > 0) { + generalIds.add(auction.hostGeneralId); + } + for (const bid of auction.bids) { + if (auction.type !== 'UNIQUE_ITEM') { + generalIds.add(bid.generalId); + } + } + } + const names = new Map( + ( + await ctx.db.general.findMany({ + where: { id: { in: [...generalIds] } }, + select: { id: true, name: true }, + }) + ).map((row) => [row.id, row.name]) + ); + const worldMeta = asRecord(worldState?.meta); + const configConst = asRecord(asRecord(worldState?.config).const); + const hiddenSeed = + typeof worldMeta.hiddenSeed === 'string' || typeof worldMeta.hiddenSeed === 'number' + ? worldMeta.hiddenSeed + : worldState?.id ?? 0; + const callerAlias = buildAuctionAlias(general.id, hiddenSeed, configConst); + + const mapped = auctions.map((auction) => { + const detail = parseDetail(auction.detail); + const highestBid = auction.bids[0] ?? null; + const isUnique = auction.type === 'UNIQUE_ITEM'; + return { + id: auction.id, + type: auction.type, + targetCode: auction.targetCode, + status: auction.status, + hostGeneralId: isUnique ? null : auction.hostGeneralId, + hostName: isUnique + ? auction.hostName ?? buildAuctionAlias(auction.hostGeneralId, hiddenSeed, configConst) + : auction.hostName ?? names.get(auction.hostGeneralId) ?? '상인', + isCallerHost: auction.hostGeneralId === general.id, + closeAt: auction.closeAt.toISOString(), + detail, + highestBid: highestBid + ? { + amount: highestBid.amount, + bidderName: isUnique + ? buildAuctionAlias(highestBid.generalId, hiddenSeed, configConst) + : names.get(highestBid.generalId) ?? '상인', + isCaller: highestBid.generalId === general.id, + eventAt: highestBid.eventAt.toISOString(), + } + : null, + }; + }); + + return { + resourceAuctions: mapped.filter((auction) => auction.type !== 'UNIQUE_ITEM'), + uniqueAuctions: mapped.filter((auction) => auction.type === 'UNIQUE_ITEM'), + callerAlias, + remainPoint: point?.value ?? 0, + recentLogs: recentLogs.map((log) => ({ + id: log.id, + text: log.text, + createdAt: log.createdAt.toISOString(), + })), + }; + }), + getUniqueDetail: authedProcedure + .input(z.object({ auctionId: z.number().int().positive() })) + .query(async ({ ctx, input }) => { + const auth = requireAuth(ctx); + const general = await ensureGeneral(ctx.db, auth.user.id); + const [auction, worldState, point] = await Promise.all([ + ctx.db.auction.findFirst({ + where: { id: input.auctionId, type: 'UNIQUE_ITEM' }, + include: { bids: { orderBy: [{ amount: 'desc' }, { id: 'asc' }] } }, + }), + ctx.db.worldState.findFirst(), + ctx.db.inheritancePoint.findUnique({ + where: { userId_key: { userId: auth.user.id, key: 'previous' } }, + }), + ]); + if (!auction) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Auction not found.' }); + } + const worldMeta = asRecord(worldState?.meta); + const configConst = asRecord(asRecord(worldState?.config).const); + const hiddenSeed = + typeof worldMeta.hiddenSeed === 'string' || typeof worldMeta.hiddenSeed === 'number' + ? worldMeta.hiddenSeed + : worldState?.id ?? 0; + return { + auction: { + id: auction.id, + targetCode: auction.targetCode, + status: auction.status, + hostName: + auction.hostName ?? buildAuctionAlias(auction.hostGeneralId, hiddenSeed, configConst), + isCallerHost: auction.hostGeneralId === general.id, + closeAt: auction.closeAt.toISOString(), + detail: parseDetail(auction.detail), + }, + bids: auction.bids.map((bid) => ({ + id: bid.id, + amount: bid.amount, + bidderName: buildAuctionAlias(bid.generalId, hiddenSeed, configConst), + isCaller: bid.generalId === general.id, + eventAt: bid.eventAt.toISOString(), + })), + callerAlias: buildAuctionAlias(general.id, hiddenSeed, configConst), + remainPoint: point?.value ?? 0, + }; + }), + openBuyRice: authedProcedure.input(zOpenResourceInput).mutation(async ({ ctx, input }) => { + const auth = requireAuth(ctx); + const general = await ensureGeneral(ctx.db, auth.user.id); + return openAuctionWithDaemon(ctx, general.id, { auctionType: 'BUY_RICE', ...input }); + }), + openSellRice: authedProcedure.input(zOpenResourceInput).mutation(async ({ ctx, input }) => { + const auth = requireAuth(ctx); + const general = await ensureGeneral(ctx.db, auth.user.id); + return openAuctionWithDaemon(ctx, general.id, { auctionType: 'SELL_RICE', ...input }); + }), + openUnique: authedProcedure.input(zOpenUniqueInput).mutation(async ({ ctx, input }) => { + const auth = requireAuth(ctx); + const general = await ensureGeneral(ctx.db, auth.user.id); + return openAuctionWithDaemon(ctx, general.id, { + auctionType: 'UNIQUE_ITEM', + itemKey: input.itemKey, + amount: input.amount, + }); + }), bidBuyRice: authedProcedure.input(zBidInput).mutation(async ({ ctx, input }) => { const auth = requireAuth(ctx); const general = await ensureGeneral(ctx.db, auth.user.id); @@ -176,6 +354,9 @@ export const auctionRouter = router({ if (auction.closeAt <= now) { throw new TRPCError({ code: 'BAD_REQUEST', message: '경매가 종료되었습니다.' }); } + if (auction.hostGeneralId === general.id) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '자신이 연 경매에 입찰할 수 없습니다.' }); + } const detail = parseDetail(auction.detail); const amount = detail.amount ?? 0; @@ -183,6 +364,9 @@ export const auctionRouter = router({ throw new TRPCError({ code: 'BAD_REQUEST', message: '거래량 정보가 없습니다.' }); } const isReverse = detail.isReverse === true; + if (!isReverse && detail.finishBidAmount != null && input.amount > detail.finishBidAmount) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '즉시판매가보다 높을 수 없습니다.' }); + } const highestBid = await loadHighestBid(ctx.db, auction.id, isReverse); const myPrevBidRaw = await loadMyPrevBid(ctx.db, auction.id, general.id, isReverse); const myPrevBid = shouldUsePrevBid(highestBid, myPrevBidRaw); @@ -241,6 +425,9 @@ export const auctionRouter = router({ if (auction.closeAt <= now) { throw new TRPCError({ code: 'BAD_REQUEST', message: '경매가 종료되었습니다.' }); } + if (auction.hostGeneralId === general.id) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '자신이 연 경매에 입찰할 수 없습니다.' }); + } const detail = parseDetail(auction.detail); const amount = detail.amount ?? 0; @@ -248,6 +435,9 @@ export const auctionRouter = router({ throw new TRPCError({ code: 'BAD_REQUEST', message: '거래량 정보가 없습니다.' }); } const isReverse = detail.isReverse === true; + if (!isReverse && detail.finishBidAmount != null && input.amount > detail.finishBidAmount) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '즉시판매가보다 높을 수 없습니다.' }); + } const highestBid = await loadHighestBid(ctx.db, auction.id, isReverse); const myPrevBidRaw = await loadMyPrevBid(ctx.db, auction.id, general.id, isReverse); const myPrevBid = shouldUsePrevBid(highestBid, myPrevBidRaw); diff --git a/app/game-api/src/router/inherit/index.ts b/app/game-api/src/router/inherit/index.ts index fbdab4d..9397806 100644 --- a/app/game-api/src/router/inherit/index.ts +++ b/app/game-api/src/router/inherit/index.ts @@ -17,6 +17,7 @@ import { writeUserStateMeta, } from '../../services/inheritance.js'; import type { GameApiContext, WorldStateRow } from '../../context.js'; +import { openAuctionWithDaemon } from '../../auction/open.js'; const BUFF_KEYS: InheritBuffType[] = [ 'warAvoidRatio', @@ -749,43 +750,19 @@ export const inheritRouter = router({ if (input.amount < inheritConst.inheritItemUniqueMinPoint) { throw new TRPCError({ code: 'BAD_REQUEST', message: '입찰 포인트가 부족합니다.' }); } - const currentPoint = await readInheritancePoint(ctx.db, userId, 'previous'); - if (currentPoint < input.amount) { - throw new TRPCError({ code: 'BAD_REQUEST', message: '유산 포인트가 부족합니다.' }); - } - const general = await ctx.db.general.findFirst({ where: { userId }, - select: { id: true, meta: true }, + select: { id: true }, }); if (!general) { throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '장수가 존재하지 않습니다.' }); } - const meta = asRecord(general.meta); - if (meta.inheritSpecificUnique) { - throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 유니크 경매 신청이 있습니다.' }); - } - - await patchGeneral(ctx, general.id, { - meta: { - ...meta, - inheritSpecificUnique: JSON.stringify({ - itemId: input.itemId, - amount: input.amount, - requestedAt: new Date().toISOString(), - }), - }, + const result = await openAuctionWithDaemon(ctx, general.id, { + auctionType: 'UNIQUE_ITEM', + itemKey: input.itemId, + amount: input.amount, }); - - await setInheritancePoint(ctx.db, userId, 'previous', currentPoint - input.amount); - await appendInheritanceLog( - ctx.db, - userId, - worldState.currentYear, - worldState.currentMonth, - `${input.amount} 포인트로 유니크 경매 신청` - ); - return { ok: true }; + return { ok: true, ...result }; }), checkOwner: authedProcedure .input( diff --git a/app/game-api/src/router/join/index.ts b/app/game-api/src/router/join/index.ts index bb98a8a..b7aa224 100644 --- a/app/game-api/src/router/join/index.ts +++ b/app/game-api/src/router/join/index.ts @@ -2,7 +2,7 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import { randomBytes } from 'node:crypto'; -import type { WorldStateRow } from '../../context.js'; +import type { DatabaseClient, WorldStateRow } from '../../context.js'; import { authedProcedure, router } from '../../trpc.js'; import { asNumber, asRecord, asStringArray, LiteHashDRBG } from '@sammo-ts/common'; import { @@ -361,7 +361,7 @@ export const joinRouter = router({ ? input.character : 'None'; - return ctx.db.$transaction!(async (db) => { + const createGeneral = async (db: DatabaseClient) => { const existing = await db.general.findFirst({ where: { userId } }); if (existing) { throw new TRPCError({ @@ -514,6 +514,7 @@ export const joinRouter = router({ meta: { createdBy: 'join', ownerName: ctx.auth?.user.displayName ?? '', + killturn: 24, }, }, }); @@ -526,7 +527,9 @@ export const joinRouter = router({ } return { ok: true, generalId: general.id }; - }); + }; + + return ctx.db.$transaction ? ctx.db.$transaction(createGeneral) : createGeneral(ctx.db); }), listPossessCandidates: authedProcedure .input( diff --git a/app/game-api/src/services/inheritance.ts b/app/game-api/src/services/inheritance.ts index cfff4cb..6d08560 100644 --- a/app/game-api/src/services/inheritance.ts +++ b/app/game-api/src/services/inheritance.ts @@ -1,4 +1,5 @@ import { asNumber, asRecord } from '@sammo-ts/common'; +import { GamePrisma } from '@sammo-ts/infra'; import type { DatabaseClient, WorldStateRow, InputJsonValue } from '../context.js'; export type InheritPointKey = @@ -116,18 +117,15 @@ export const readInheritancePoint = async ( userId: string, key: InheritPointKey ): Promise => { - const row = await db.inheritancePoint.findUnique({ - where: { - userId_key: { - userId, - key, - }, - }, - select: { - value: true, - }, - }); - return row?.value ?? 0; + const rows = await db.$queryRaw>( + GamePrisma.sql` + SELECT value + FROM inheritance_point + WHERE user_id = ${userId} AND key = ${key} + FOR UPDATE + ` + ); + return rows[0]?.value ?? 0; }; export const setInheritancePoint = async ( diff --git a/app/game-engine/src/auction/bidder.ts b/app/game-engine/src/auction/bidder.ts index a876f75..ecec430 100644 --- a/app/game-engine/src/auction/bidder.ts +++ b/app/game-engine/src/auction/bidder.ts @@ -23,6 +23,7 @@ const MIN_EXTENSION_MINUTES_PER_BID = 1; interface AuctionRow { id: number; type: AuctionType; + hostGeneralId: number; detail: unknown; status: AuctionStatus; closeAt: Date; @@ -37,6 +38,7 @@ interface AuctionBidRow { interface AuctionDetail { isReverse?: boolean; startBidAmount?: number; + finishBidAmount?: number | null; availableLatestBidCloseDate?: string | null; } @@ -85,11 +87,13 @@ const loadAuction = async (prisma: QueryClient, auctionId: number): Promise 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) { @@ -257,6 +277,14 @@ export const createAuctionBidder = async (options: { 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 { @@ -296,12 +324,19 @@ export const createAuctionBidder = async (options: { const availableLatestBidCloseDate = detail.availableLatestBidCloseDate ? new Date(detail.availableLatestBidCloseDate) : null; - const nextCloseAt = extendCloseDate({ + 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; @@ -347,51 +382,34 @@ export const createAuctionBidder = async (options: { if (!userId) { throw new Error('USER_NOT_FOUND'); } - const current = await tx.inheritancePoint.findUnique({ - where: { - userId_key: { - userId, - key: 'previous', - }, - }, - }); - const prevValue = current?.value ?? 0; - if (prevValue < morePoint) { + 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.inheritancePoint.upsert({ - where: { - userId_key: { - userId, - key: 'previous', - }, - }, - update: { value: prevValue - morePoint }, - create: { userId, key: 'previous', value: prevValue - morePoint }, - }); if (highestBid && highestBid.generalId !== command.generalId && !myPrevBid) { const prevUserId = await resolveUserId(tx, highestBid.generalId); if (prevUserId) { - const prevPoint = await tx.inheritancePoint.findUnique({ - where: { - userId_key: { - userId: prevUserId, - key: 'previous', - }, - }, - }); - const nextValue = (prevPoint?.value ?? 0) + highestBid.amount; - await tx.inheritancePoint.upsert({ - where: { - userId_key: { - userId: prevUserId, - key: 'previous', - }, - }, - update: { value: nextValue }, - create: { userId: prevUserId, key: 'previous', value: nextValue }, - }); + 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 + ` + ); } } } diff --git a/app/game-engine/src/auction/finalizer.ts b/app/game-engine/src/auction/finalizer.ts index 5aa65c2..0d21cf5 100644 --- a/app/game-engine/src/auction/finalizer.ts +++ b/app/game-engine/src/auction/finalizer.ts @@ -1,7 +1,14 @@ import { createGamePostgresConnector, GamePrisma } from '@sammo-ts/infra'; -import { ActionLogger, ItemLoader, LogFormat, UserLogger, isItemKey } from '@sammo-ts/logic'; +import { + ActionLogger, + ItemLoader, + LogFormat, + UserLogger, + isItemKey, + resolveUniqueConfig, +} from '@sammo-ts/logic'; import { cloneItemInventory, ensureItemInventory, equipNewItem } from '@sammo-ts/logic/items/index.js'; -import { JosaUtil } from '@sammo-ts/common'; +import { asRecord, JosaUtil } from '@sammo-ts/common'; import type { TurnDaemonCommandResult } from '../lifecycle/types.js'; import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js'; @@ -17,6 +24,7 @@ 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; interface AuctionRow { id: number; @@ -33,12 +41,15 @@ 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 { @@ -63,24 +74,6 @@ const resolveTurnMinutes = async (prisma: AuctionDb): Promise => { return toTurnMinutes(rows[0]?.tickSeconds ?? 60); }; -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 pushLogs = (world: InMemoryTurnWorld, logs: LogEntryDraft[]): void => { for (const log of logs) { world.pushLog(log); @@ -96,31 +89,16 @@ const refundInheritancePoint = async (options: { if (!userId || amount <= 0) { return; } - const current = await prisma.inheritancePoint.findUnique({ - where: { - userId_key: { - userId, - key: 'previous', - }, - }, - }); - const nextValue = (current?.value ?? 0) + amount; - await prisma.inheritancePoint.upsert({ - where: { - userId_key: { - userId, - key: 'previous', - }, - }, - update: { - value: nextValue, - }, - create: { - userId, - key: 'previous', - value: nextValue, - }, - }); + 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: { @@ -186,14 +164,14 @@ export const createAuctionFinalizer = async (options: { const bidRows = await db.$queryRaw( isReverse ? GamePrisma.sql` - SELECT id, general_id as "generalId", amount + 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 + SELECT id, general_id as "generalId", amount, meta FROM auction_bid WHERE auction_id = ${auctionId} ORDER BY amount DESC, id ASC @@ -261,6 +239,43 @@ export const createAuctionFinalizer = async (options: { 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}, + updated_at = ${now} + WHERE id = ${auctionId} + ` + ); + return { + type: 'auctionFinalize', + ok: false, + auctionId, + reason: '입찰자의 요청으로 경매 종료가 연장되었습니다.', + }; + } + } + const bidder = world.getGeneralById(highestBid.generalId); if (!bidder) { await finalizeStatus('CANCELED'); @@ -355,25 +370,102 @@ export const createAuctionFinalizer = async (options: { }; } + const state = world.getState(); + const config = resolveUniqueConfig(asRecord(world.getScenarioConfig().const)); + 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_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}, + 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 availableLatestBidCloseDate = detail.availableLatestBidCloseDate - ? new Date(detail.availableLatestBidCloseDate) - : null; - const nextCloseAt = extendCloseDate({ - now, - closeAt: auction.closeAt, - turnMinutes, - availableLatestBidCloseDate, - }); + 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}, updated_at = ${now} WHERE id = ${auctionId} diff --git a/app/game-engine/src/auction/opener.ts b/app/game-engine/src/auction/opener.ts new file mode 100644 index 0000000..dda38ef --- /dev/null +++ b/app/game-engine/src/auction/opener.ts @@ -0,0 +1,304 @@ +import { randomUUID } from 'node:crypto'; + +import { asRecord, JosaUtil } from '@sammo-ts/common'; +import { GamePrisma } from '@sammo-ts/infra'; +import { + ActionLogger, + ItemLoader, + LogFormat, + buildAuctionAlias, + isItemKey, + resolveUniqueConfig, +} from '@sammo-ts/logic'; + +import type { TurnDaemonCommand, TurnDaemonCommandResult } from '../lifecycle/types.js'; +import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js'; + +type AuctionOpenCommand = Extract; + +const MIN_AUCTION_AMOUNT = 100; +const MAX_AUCTION_AMOUNT = 10_000; +const MIN_AUCTION_CLOSE_MINUTES = 30; +const COEFF_AUCTION_CLOSE_MINUTES = 24; +const MIN_EXTENSION_MINUTES_LIMIT_BY_BID = 5; +const COEFF_EXTENSION_MINUTES_LIMIT_BY_BID = 0.5; + +const readNumber = (record: Record, key: string, fallback: number): number => { + const value = record[key]; + return typeof value === 'number' && Number.isFinite(value) ? value : fallback; +}; + +const getRelativeMonth = (world: InMemoryTurnWorld): number => { + const state = world.getState(); + const meta = state.meta; + const scenarioMeta = asRecord(meta.scenarioMeta); + const initYear = readNumber(meta, 'initYear', readNumber(scenarioMeta, 'startYear', state.currentYear)); + const initMonth = readNumber(meta, 'initMonth', 1); + return state.currentYear * 12 + state.currentMonth - (initYear * 12 + initMonth); +}; + +const fail = (reason: string): TurnDaemonCommandResult => ({ + type: 'auctionOpen', + ok: false, + reason, +}); + +const openResourceAuction = async ( + command: AuctionOpenCommand, + world: InMemoryTurnWorld, + db: GamePrisma.TransactionClient +): Promise => { + const general = world.getGeneralById(command.generalId); + if (!general) { + return fail('장수 정보를 찾을 수 없습니다.'); + } + const closeTurnCnt = command.closeTurnCnt ?? 0; + const startBidAmount = command.startBidAmount ?? 0; + const finishBidAmount = command.finishBidAmount ?? 0; + if (closeTurnCnt < 1 || closeTurnCnt > 24) { + return fail('종료기한은 1 ~ 24 턴 이어야 합니다.'); + } + if (command.amount < MIN_AUCTION_AMOUNT || command.amount > MAX_AUCTION_AMOUNT) { + return fail(`거래량은 ${MIN_AUCTION_AMOUNT} ~ ${MAX_AUCTION_AMOUNT} 이어야 합니다.`); + } + if (startBidAmount < command.amount * 0.5 || command.amount * 2 < startBidAmount) { + return fail('시작거래가는 50% ~ 200% 이어야 합니다.'); + } + if (finishBidAmount < command.amount * 1.1 || command.amount * 2 < finishBidAmount) { + return fail('즉시거래가는 110% ~ 200% 이어야 합니다.'); + } + if (finishBidAmount < startBidAmount * 1.1) { + return fail('즉시거래가는 시작판매가의 110% 이상이어야 합니다.'); + } + + await db.$executeRaw(GamePrisma.sql`SELECT pg_advisory_xact_lock(${command.generalId}, 41001)`); + const previous = await db.auction.findFirst({ + where: { + hostGeneralId: command.generalId, + status: { in: ['OPEN', 'FINALIZING'] }, + type: { in: ['BUY_RICE', 'SELL_RICE'] }, + }, + select: { id: true }, + }); + if (previous) { + return fail('아직 경매가 끝나지 않았습니다.'); + } + + const configConst = asRecord(world.getScenarioConfig().const); + const hostResource = command.auctionType === 'BUY_RICE' ? 'rice' : 'gold'; + const minimumResource = + hostResource === 'rice' + ? readNumber(configConst, 'generalMinimumRice', 500) + : readNumber(configConst, 'generalMinimumGold', 0); + if (general[hostResource] < command.amount + minimumResource) { + return fail(`기본 ${hostResource === 'rice' ? '쌀' : '금'} ${minimumResource}은 거래할 수 없습니다.`); + } + + const now = new Date(); + const turnMinutes = Math.max(1, Math.round(world.getState().tickSeconds / 60)); + const closeAt = new Date(now.getTime() + closeTurnCnt * turnMinutes * 60_000); + const auction = await db.auction.create({ + data: { + type: command.auctionType, + targetCode: String(command.amount), + hostGeneralId: command.generalId, + hostName: general.name, + detail: { + title: `${hostResource === 'rice' ? '쌀' : '금'} ${command.amount} 경매`, + hostName: general.name, + amount: command.amount, + isReverse: false, + startBidAmount, + finishBidAmount, + }, + status: 'OPEN', + closeAt, + }, + }); + world.updateGeneral(general.id, { + [hostResource]: general[hostResource] - command.amount, + }); + return { + type: 'auctionOpen', + ok: true, + auctionId: auction.id, + closeAt: closeAt.toISOString(), + }; +}; + +const openUniqueAuction = async ( + command: AuctionOpenCommand, + world: InMemoryTurnWorld, + db: GamePrisma.TransactionClient +): Promise => { + const general = world.getGeneralById(command.generalId); + if (!general) { + return fail('장수 정보를 찾을 수 없습니다.'); + } + const itemKey = command.itemKey; + if (!itemKey || !isItemKey(itemKey)) { + return fail('아이템이 올바르지 않습니다.'); + } + const configConst = asRecord(world.getScenarioConfig().const); + const minimumPoint = readNumber(configConst, 'inheritItemUniqueMinPoint', 5000); + if (command.amount < minimumPoint) { + return fail(`최소 경매 금액은 ${minimumPoint}입니다.`); + } + + const item = await new ItemLoader().load(itemKey).catch(() => null); + if (!item) { + return fail('아이템 정보를 불러올 수 없습니다.'); + } + if (item.buyable) { + return fail('구매할 수 있는 아이템입니다.'); + } + const currentSlotItem = general.role.items[item.slot]; + if (currentSlotItem && currentSlotItem !== 'None' && isItemKey(currentSlotItem)) { + const currentItem = await new ItemLoader().load(currentSlotItem).catch(() => null); + if (currentItem && !currentItem.buyable) { + return fail('이미 가진 아이템이 있습니다.'); + } + } + + await db.$executeRaw(GamePrisma.sql`SELECT pg_advisory_xact_lock(hashtext(${`auction:unique:item:${itemKey}`}))`); + await db.$executeRaw(GamePrisma.sql`SELECT pg_advisory_xact_lock(${command.generalId}, 41002)`); + const [sameItemAuction, previousHostAuction] = await Promise.all([ + db.auction.findFirst({ + where: { + type: 'UNIQUE_ITEM', + targetCode: itemKey, + status: { in: ['OPEN', 'FINALIZING'] }, + }, + select: { id: true }, + }), + db.auction.findFirst({ + where: { + type: 'UNIQUE_ITEM', + hostGeneralId: command.generalId, + status: { in: ['OPEN', 'FINALIZING'] }, + }, + select: { id: true }, + }), + ]); + if (sameItemAuction) { + return fail('이미 경매가 진행중입니다.'); + } + if (previousHostAuction) { + return fail('아직 경매가 끝나지 않았습니다.'); + } + + const uniqueConfig = resolveUniqueConfig(configConst); + const configuredAmount = uniqueConfig.allItems[item.slot]?.[itemKey] ?? 0; + const occupiedAmount = world + .listGenerals() + .filter((candidate) => candidate.role.items[item.slot] === itemKey).length; + if (configuredAmount <= occupiedAmount) { + return fail('그 유니크를 더 얻을 수 없습니다.'); + } + + const generalRows = await db.$queryRaw>( + GamePrisma.sql`SELECT user_id as "userId" FROM general WHERE id = ${command.generalId}` + ); + const userId = generalRows[0]?.userId; + if (!userId) { + return fail('장수 소유자 정보를 찾을 수 없습니다.'); + } + const pointRows = await db.$queryRaw>( + GamePrisma.sql` + SELECT value + FROM inheritance_point + WHERE user_id = ${userId} AND key = 'previous' + FOR UPDATE + ` + ); + const currentPoint = pointRows[0]?.value ?? 0; + if (currentPoint < command.amount) { + return fail('경매를 시작할 포인트가 부족합니다.'); + } + + const state = world.getState(); + const turnMinutes = Math.max(1, Math.round(state.tickSeconds / 60)); + const now = new Date(); + const closeMinutes = Math.max(MIN_AUCTION_CLOSE_MINUTES, turnMinutes * COEFF_AUCTION_CLOSE_MINUTES); + const closeAt = new Date(now.getTime() + closeMinutes * 60_000); + const extensionLimitMinutes = Math.max( + MIN_EXTENSION_MINUTES_LIMIT_BY_BID, + turnMinutes * COEFF_EXTENSION_MINUTES_LIMIT_BY_BID + ); + const availableLatestBidCloseDate = new Date(closeAt.getTime() + extensionLimitMinutes * 60_000); + const hiddenSeed = + typeof state.meta.hiddenSeed === 'string' || typeof state.meta.hiddenSeed === 'number' + ? state.meta.hiddenSeed + : state.id; + const alias = buildAuctionAlias(command.generalId, hiddenSeed, configConst); + const eventId = randomUUID(); + const auction = await db.auction.create({ + data: { + type: 'UNIQUE_ITEM', + targetCode: itemKey, + hostGeneralId: command.generalId, + hostName: alias, + detail: { + title: `${item.name} 경매`, + hostName: alias, + amount: 1, + isReverse: false, + startBidAmount: command.amount, + finishBidAmount: null, + remainCloseDateExtensionCnt: 1, + availableLatestBidCloseDate: availableLatestBidCloseDate.toISOString(), + }, + status: 'OPEN', + closeAt, + latestEventId: eventId, + latestEventAt: now, + bids: { + create: { + generalId: command.generalId, + amount: command.amount, + eventId, + eventAt: now, + meta: { obfuscatedName: alias, tryExtendCloseDate: false }, + }, + }, + }, + }); + await db.inheritancePoint.update({ + where: { userId_key: { userId, key: 'previous' } }, + data: { value: currentPoint - command.amount }, + }); + + const logger = new ActionLogger(); + const rawNameJosa = JosaUtil.pick(item.rawName, '라'); + logger.pushGlobalHistoryLog( + `【보물수배】누군가가 ${item.name}${rawNameJosa}는 보물을 구한다는 소문이 들려옵니다.`, + LogFormat.PLAIN + ); + for (const log of logger.flush()) { + world.pushLog(log); + } + + return { + type: 'auctionOpen', + ok: true, + auctionId: auction.id, + closeAt: closeAt.toISOString(), + }; +}; + +export const openAuction = async ( + command: AuctionOpenCommand, + world: InMemoryTurnWorld, + db?: GamePrisma.TransactionClient +): Promise => { + if (!db) { + return fail('경매 등록 트랜잭션이 준비되지 않았습니다.'); + } + if (getRelativeMonth(world) < 3) { + return fail('시작 후 3개월이 지나야 경매를 열 수 있습니다.'); + } + if (command.auctionType === 'UNIQUE_ITEM') { + return openUniqueAuction(command, world, db); + } + return openResourceAuction(command, world, db); +}; diff --git a/app/game-engine/src/turn/commandRegistry.ts b/app/game-engine/src/turn/commandRegistry.ts index 3f80ae8..e7f9add 100644 --- a/app/game-engine/src/turn/commandRegistry.ts +++ b/app/game-engine/src/turn/commandRegistry.ts @@ -36,6 +36,17 @@ const zAuctionFinalize = z.object({ auctionId: zFiniteNumber, }); +const zAuctionOpen = z.object({ + type: z.literal('auctionOpen'), + generalId: zFiniteNumber, + auctionType: z.enum(['BUY_RICE', 'SELL_RICE', 'UNIQUE_ITEM']), + amount: zFiniteNumber, + closeTurnCnt: zFiniteNumber.optional(), + startBidAmount: zFiniteNumber.optional(), + finishBidAmount: zFiniteNumber.optional(), + itemKey: z.string().optional(), +}); + const zAuctionBid = z.object({ type: z.literal('auctionBid'), auctionId: zFiniteNumber, @@ -246,6 +257,14 @@ const normalizeAuctionFinalize: CommandNormalizer<'auctionFinalize'> = (envelope return { ...command, requestId: envelope.requestId }; }; +const normalizeAuctionOpen: CommandNormalizer<'auctionOpen'> = (envelope) => { + const command = parseWith(zAuctionOpen, envelope.command); + if (!command) { + return null; + } + return { ...command, requestId: envelope.requestId }; +}; + const normalizeAuctionBid: CommandNormalizer<'auctionBid'> = (envelope) => { const command = parseWith(zAuctionBid, envelope.command); if (!command) { @@ -444,6 +463,7 @@ const normalizeShutdown: CommandNormalizer<'shutdown'> = (envelope) => { const normalizers: CommandNormalizerMap = { auctionFinalize: normalizeAuctionFinalize, + auctionOpen: normalizeAuctionOpen, auctionBid: normalizeAuctionBid, troopJoin: normalizeTroopJoin, troopExit: normalizeTroopExit, diff --git a/app/game-engine/src/turn/worldCommandHandler.ts b/app/game-engine/src/turn/worldCommandHandler.ts index 5e9d525..bf9be44 100644 --- a/app/game-engine/src/turn/worldCommandHandler.ts +++ b/app/game-engine/src/turn/worldCommandHandler.ts @@ -28,6 +28,7 @@ import { } from '@sammo-ts/logic/items/index.js'; import type { InMemoryTurnWorld } from './inMemoryWorld.js'; import type { TurnGeneral } from './types.js'; +import { openAuction } from '../auction/opener.js'; let itemRegistryPromise: Promise> | null = null; @@ -663,6 +664,13 @@ async function handleAuctionFinalize( return ctx.auctionFinalizer.finalize(command.auctionId, ctx.commandDb); } +async function handleAuctionOpen( + ctx: CommandHandlerContext, + command: Extract +): Promise { + return openAuction(command, ctx.world, ctx.commandDb); +} + async function handleAuctionBid( ctx: CommandHandlerContext, command: Extract @@ -1168,6 +1176,8 @@ export const createTurnDaemonCommandHandler = (options: { dropItem: (command) => handleDropItem(ctx, command as Extract), auctionFinalize: (command) => handleAuctionFinalize(ctx, command as Extract), + auctionOpen: (command) => + handleAuctionOpen(ctx, command as Extract), auctionBid: (command) => handleAuctionBid(ctx, command as Extract), changePermission: (command) => handleChangePermission(ctx, command as Extract), diff --git a/app/game-frontend/src/router/index.ts b/app/game-frontend/src/router/index.ts index a81233d..cd1e813 100644 --- a/app/game-frontend/src/router/index.ts +++ b/app/game-frontend/src/router/index.ts @@ -4,6 +4,7 @@ import PublicView from '../views/PublicView.vue'; import LoginView from '../views/LoginView.vue'; import JoinView from '../views/JoinView.vue'; import InheritView from '../views/InheritView.vue'; +import AuctionView from '../views/AuctionView.vue'; import NationCitiesView from '../views/NationCitiesView.vue'; import NationGeneralsView from '../views/NationGeneralsView.vue'; import NationPersonnelView from '../views/NationPersonnelView.vue'; @@ -60,6 +61,15 @@ const routes = [ requiresGeneral: true, }, }, + { + path: '/auction', + name: 'auction', + component: AuctionView, + meta: { + requiresAuth: true, + requiresGeneral: true, + }, + }, { path: '/nation/cities', name: 'nation-cities', diff --git a/app/game-frontend/src/views/AuctionView.vue b/app/game-frontend/src/views/AuctionView.vue new file mode 100644 index 0000000..ca80e55 --- /dev/null +++ b/app/game-frontend/src/views/AuctionView.vue @@ -0,0 +1,352 @@ + + + + + diff --git a/app/game-frontend/src/views/MainView.vue b/app/game-frontend/src/views/MainView.vue index d2e5f27..6ffe9e4 100644 --- a/app/game-frontend/src/views/MainView.vue +++ b/app/game-frontend/src/views/MainView.vue @@ -105,6 +105,7 @@ watch( 전투 시뮬레이터 내 정보 토너먼트 + 거래장 설문조사 NPC 정책 유산 강화 diff --git a/app/gateway-api/src/orchestrator/seedProfileDatabase.ts b/app/gateway-api/src/orchestrator/seedProfileDatabase.ts index d361afb..e9c6444 100644 --- a/app/gateway-api/src/orchestrator/seedProfileDatabase.ts +++ b/app/gateway-api/src/orchestrator/seedProfileDatabase.ts @@ -134,6 +134,7 @@ const ensureAdminGeneral = async (databaseUrl: string, adminUser: AdminSeedUser) turnTime, meta: { createdBy: 'admin-seed', + killturn: 24, }, }, }); diff --git a/docs/architecture/legacy-engine-auction.md b/docs/architecture/legacy-engine-auction.md index 0328741..381aab0 100644 --- a/docs/architecture/legacy-engine-auction.md +++ b/docs/architecture/legacy-engine-auction.md @@ -71,8 +71,27 @@ Event actions controlling lifecycle: ## Open Questions / Follow-ups -- The exact schedule for `registerAuction()` is outside this file; it is - likely called from monthly or timed maintenance. -- Unique-item auction close-date extension limits depend on - `AuctionUniqueItem` constants and `turnterm`; verify scenarios that override - auction timing. +- `registerAuction()` is called from legacy `func_gamerule.php` during the + monthly game-rule pass. Player-hosted auctions are implemented in core2026; + neutral merchant auction generation remains a separate compatibility item. +- Scenario-specific overrides of unique auction timing have not been found. + Core2026 currently applies the legacy constants against `tickSeconds`. + +## core2026 compatibility implementation (2026-07-25) + +| Contract | core2026 path | Status | +| --- | --- | --- | +| Resource auction list/open/bid | `game-frontend/AuctionView.vue` → `game-api/router/auction` → `auctionOpen`/`auctionBid` engine commands | Confirmed by integration test | +| Resource reservation/refund/settlement | opener reserves host gold/rice; bidder reserves and refunds the previous top bid; finalizer transfers or returns resources | Confirmed by integration test | +| Unique list/detail privacy | API replaces host and bidder identity with deterministic aliases and never returns unique-auction general IDs | Confirmed by API implementation and typecheck | +| Unique open | engine validates configured quantity, slot ownership, one active host/item auction, and atomically inserts the host's first bid while deducting `inheritance_point.previous` | Confirmed by integration test | +| Unique bid | row-locked auction, minimum `max(1%, 10)`, conditional point deduction, previous-top-bid refund, slot/top-bid conflict checks | Confirmed by integration test | +| Unique finalization | requested extension, per-slot and total ownership-limit extension, host neutralization, item inventory update | Confirmed by integration test | +| Timer/worker | API updates the Redis timer after open/bid; existing scheduler claims due rows and sends durable `auctionFinalize` commands | Confirmed by integration test for timer seed and durable command finalization | +| Neutral merchant registration | legacy monthly probabilistic `registerAuction()` | Not implemented | + +Unique auction mutations deliberately run in the turn-engine database +transaction. Point reads use row locks and deductions use conditional updates, +so simultaneous requests cannot spend the same previous-season inheritance +points twice. A failed open command creates neither an auction nor an orphaned +inheritance request marker. diff --git a/packages/common/src/turnDaemon/types.ts b/packages/common/src/turnDaemon/types.ts index dd6a3a5..069e94b 100644 --- a/packages/common/src/turnDaemon/types.ts +++ b/packages/common/src/turnDaemon/types.ts @@ -71,6 +71,17 @@ export type TurnDaemonCommand = } | { type: 'dropItem'; requestId?: string; generalId: number; itemType: string } | { type: 'auctionFinalize'; requestId?: string; auctionId: number } + | { + type: 'auctionOpen'; + requestId?: string; + generalId: number; + auctionType: 'BUY_RICE' | 'SELL_RICE' | 'UNIQUE_ITEM'; + amount: number; + closeTurnCnt?: number; + startBidAmount?: number; + finishBidAmount?: number; + itemKey?: string; + } | { type: 'changePermission'; requestId?: string; @@ -204,6 +215,17 @@ export type TurnDaemonCommandResult = auctionId: number; reason: string; } + | { + type: 'auctionOpen'; + ok: true; + auctionId: number; + closeAt: string; + } + | { + type: 'auctionOpen'; + ok: false; + reason: string; + } | { type: 'troopJoin'; ok: true; diff --git a/packages/infra/src/db.ts b/packages/infra/src/db.ts index d8aa337..16cc89c 100644 --- a/packages/infra/src/db.ts +++ b/packages/infra/src/db.ts @@ -21,6 +21,8 @@ export interface DatabaseClient { nationTurn: GamePrisma.NationTurnDelegate; troop: GamePrisma.TroopDelegate; logEntry: GamePrisma.LogEntryDelegate; + auction: GamePrisma.AuctionDelegate; + auctionBid: GamePrisma.AuctionBidDelegate; inheritancePoint: GamePrisma.InheritancePointDelegate; inheritanceLog: GamePrisma.InheritanceLogDelegate; inheritanceResult: GamePrisma.InheritanceResultDelegate; diff --git a/packages/logic/src/auction/alias.ts b/packages/logic/src/auction/alias.ts new file mode 100644 index 0000000..77bba82 --- /dev/null +++ b/packages/logic/src/auction/alias.ts @@ -0,0 +1,52 @@ +import { LiteHashDRBG, RandUtil } from '@sammo-ts/common'; + +import { simpleSerialize } from '../war/utils.js'; + +const DEFAULT_FIRST_NAMES = [ + '가', '간', '감', '강', '고', '공', '공손', '곽', '관', '괴', '교', '금', '노', '뇌', '능', '도', '동', + '두', '등', '마', '맹', '문', '미', '반', '방', '부', '비', '사', '사마', '서', '설', '성', '소', '손', + '송', '순', '신', '심', '악', '안', '양', '엄', '여', '염', '오', '왕', '요', '우', '원', '위', '유', + '육', '윤', '이', '장', '저', '전', '정', '제갈', '조', '종', '주', '진', '채', '태사', '하', '하후', + '학', '한', '향', '허', '호', '화', '황', '공손', '손', '왕', '유', '장', '조', +] as const; + +const DEFAULT_LAST_NAMES = [ + '가', '간', '강', '거', '건', '검', '견', '경', '공', '광', '권', '규', '녕', '단', '대', '도', '등', + '람', '량', '례', '로', '료', '모', '민', '박', '범', '보', '비', '사', '상', '색', '서', '소', '속', + '송', '수', '순', '습', '승', '양', '연', '영', '온', '옹', '완', '우', '웅', '월', '위', '유', '윤', + '융', '이', '익', '임', '정', '제', '조', '주', '준', '지', '찬', '책', '충', '탁', '택', '통', '패', + '평', '포', '합', '해', '혁', '현', '화', '환', '회', '횡', '후', '훈', '휴', '흠', '흥', +] as const; + +const readNameParts = (value: unknown, fallback: readonly string[]): string[] => { + if (!Array.isArray(value)) { + return [...fallback]; + } + const result = value.filter((entry): entry is string => typeof entry === 'string'); + return result.length > 0 ? result : [...fallback]; +}; + +export const buildAuctionAlias = ( + generalId: number, + hiddenSeed: string | number, + configConst: Record = {} +): string => { + const firstNames = readNameParts(configConst.randGenFirstName, DEFAULT_FIRST_NAMES); + const middleNames = readNameParts(configConst.randGenMiddleName, ['']); + const lastNames = readNameParts(configConst.randGenLastName, DEFAULT_LAST_NAMES); + const pool: string[] = []; + for (const first of firstNames) { + for (const middle of middleNames) { + for (const last of lastNames) { + pool.push(`${first}${middle}${last}`); + } + } + } + const shuffled = new RandUtil( + new LiteHashDRBG(simpleSerialize(hiddenSeed, 'obfuscatedNamePool')) + ).shuffle(pool); + const normalizedId = Math.max(0, Math.floor(generalId)); + const duplicateIndex = Math.floor(normalizedId / shuffled.length); + const name = shuffled[normalizedId % shuffled.length] ?? `익명${normalizedId}`; + return duplicateIndex === 0 ? name : `${name}${duplicateIndex}`; +}; diff --git a/packages/logic/src/index.ts b/packages/logic/src/index.ts index 47d8f1d..9bbc2ed 100644 --- a/packages/logic/src/index.ts +++ b/packages/logic/src/index.ts @@ -1,6 +1,7 @@ export * from './domain/entities.js'; export type { RandomGenerator } from '@sammo-ts/common'; export * from './actions/index.js'; +export * from './auction/alias.js'; export * from './constraints/index.js'; export * from './crewType/index.js'; export * from './diplomacy/index.js'; diff --git a/packages/logic/test/auctionAlias.test.ts b/packages/logic/test/auctionAlias.test.ts new file mode 100644 index 0000000..aec4bfc --- /dev/null +++ b/packages/logic/test/auctionAlias.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; + +import { buildAuctionAlias } from '../src/auction/alias.js'; + +describe('buildAuctionAlias', () => { + it('returns a stable alias for the same world seed and general id', () => { + const first = buildAuctionAlias(17, 'legacy-compatible-seed'); + const second = buildAuctionAlias(17, 'legacy-compatible-seed'); + + expect(second).toBe(first); + expect(first.length).toBeGreaterThan(1); + }); + + it('uses scenario-specific name pools without exposing a general name', () => { + const config = { + randGenFirstName: ['청'], + randGenMiddleName: ['운'], + randGenLastName: ['객', '상'], + }; + + expect(buildAuctionAlias(0, 'seed', config)).toMatch(/^청운(객|상)$/); + expect(buildAuctionAlias(1, 'seed', config)).toMatch(/^청운(객|상)$/); + expect(buildAuctionAlias(2, 'seed', config)).toMatch(/^청운(객|상)1$/); + }); +}); diff --git a/tools/integration-tests/test/auctionFlow.test.ts b/tools/integration-tests/test/auctionFlow.test.ts index 892c929..884b467 100644 --- a/tools/integration-tests/test/auctionFlow.test.ts +++ b/tools/integration-tests/test/auctionFlow.test.ts @@ -13,8 +13,7 @@ import { createGameApiServer, buildAuctionTimerKeys, seedAuctionTimers, - buildTurnDaemonStreamKeys, - RedisTurnDaemonTransport, + DatabaseTurnDaemonTransport, } from '@sammo-ts/game-api'; import { createTurnDaemonRuntime } from '@sammo-ts/game-engine'; import { @@ -221,6 +220,7 @@ describe('auction integration flow', () => { beforeAll(async () => { await loadEnv(); + process.env.SCENARIO = '908'; process.chdir(workspaceRoot); await resetDatabase(); await resetRedis(); @@ -271,15 +271,15 @@ describe('auction integration flow', () => { await gatewayClient.admin.profiles.upsert.mutate({ profile: 'che', - scenario: '2', + scenario: '908', apiPort: Number(process.env.GAME_API_PORT ?? 14000), status: 'RUNNING', }); await gatewayClient.admin.profiles.installNow.mutate({ - profileName: 'che:2', + profileName: 'che:908', install: { - scenarioId: 2, + scenarioId: 908, turnTermMinutes: 1, sync: false, fiction: 0, @@ -300,7 +300,7 @@ describe('auction integration flow', () => { }); const gatewayToken = await gatewayClient.auth.issueGameSession.mutate({ sessionToken: login.sessionToken, - profile: 'che:2', + profile: 'che:908', }); const access = await gameClient.auth.exchangeGatewayToken.mutate({ gatewayToken: gatewayToken.gameToken, @@ -326,7 +326,7 @@ describe('auction integration flow', () => { const gatewayDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'public' }).url; turnDaemon = await createTurnDaemonRuntime({ profile: 'che', - profileName: 'che:2', + profileName: 'che:908', databaseUrl: gameDatabaseUrl, gatewayDatabaseUrl, }); @@ -405,6 +405,9 @@ describe('auction integration flow', () => { where: { id: poorBidder.generalId }, data: { gold: 50, rice: 1000 }, }); + await prisma.worldState.updateMany({ + data: { currentMonth: 4 }, + }); if (turnDaemon) { await turnDaemon.lifecycle.stop('integration-test'); @@ -415,31 +418,24 @@ describe('auction integration flow', () => { const gatewayDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'public' }).url; turnDaemon = await createTurnDaemonRuntime({ profile: 'che', - profileName: 'che:2', + profileName: 'che:908', databaseUrl: gameDatabaseUrl, gatewayDatabaseUrl, }); turnDaemonLoop = turnDaemon.lifecycle.start(); await sleep(500); - const now = new Date(); - const initialCloseAt = new Date(now.getTime() + 10_000); - const auction = await prisma.auction.create({ - data: { - type: 'BUY_RICE', - targetCode: null, - hostGeneralId: 0, - hostName: '시스템', - detail: { - amount: 500, - startBidAmount: 100, - isReverse: false, - availableLatestBidCloseDate: new Date(now.getTime() + 5 * 60_000).toISOString(), - }, - status: 'OPEN', - closeAt: initialCloseAt, - }, + const hostClient = createGameClient(gameUrl, gameServer.config.trpcPath, { + value: bidder3.accessToken, }); + const opened = await hostClient.auction.openBuyRice.mutate({ + amount: 500, + closeTurnCnt: 1, + startBidAmount: 250, + finishBidAmount: 1000, + }); + const auction = await prisma.auction.findUniqueOrThrow({ where: { id: opened.auctionId } }); + const initialCloseAt = auction.closeAt; const keys = buildAuctionTimerKeys(gameServer.config.profileName); await seedAuctionTimers(prisma, redis, keys); @@ -449,7 +445,7 @@ describe('auction integration flow', () => { const bidder1Client = createGameClient(gameUrl, gameServer.config.trpcPath, { value: bidder1.accessToken, }); - await bidder1Client.auction.bidBuyRice.mutate({ auctionId: auction.id, amount: 200 }); + await bidder1Client.auction.bidBuyRice.mutate({ auctionId: auction.id, amount: 300 }); const bidRows = await prisma.auctionBid.findMany({ where: { auctionId: auction.id } }); expect(bidRows).toHaveLength(1); @@ -478,10 +474,7 @@ describe('auction integration flow', () => { }); await redis.zAdd(keys.timerKey, [{ score: finalizeAt.getTime(), value: String(auction.id) }]); - const transport = new RedisTurnDaemonTransport(redis, { - keys: buildTurnDaemonStreamKeys(gameServer.config.profileName), - requestTimeoutMs: 10_000, - }); + const transport = new DatabaseTurnDaemonTransport(prisma, 30_000); await prisma.$executeRaw( GamePrisma.sql` UPDATE auction @@ -491,7 +484,7 @@ describe('auction integration flow', () => { WHERE id = ${auction.id} ` ); - const result = await transport.requestCommand({ type: 'auctionFinalize', auctionId: auction.id }, 10_000); + const result = await transport.requestCommand({ type: 'auctionFinalize', auctionId: auction.id }, 30_000); expect(result?.ok).toBe(true); const finished = await prisma.auction.findUnique({ @@ -613,7 +606,7 @@ describe('auction integration flow', () => { const gatewayDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'public' }).url; turnDaemon = await createTurnDaemonRuntime({ profile: 'che', - profileName: 'che:2', + profileName: 'che:908', databaseUrl: gameDatabaseUrl, gatewayDatabaseUrl, }); @@ -627,10 +620,7 @@ describe('auction integration flow', () => { }); await redis.zAdd(keys.timerKey, [{ score: finalizeAt.getTime(), value: String(auction.id) }]); - const transport = new RedisTurnDaemonTransport(redis, { - keys: buildTurnDaemonStreamKeys(gameServer.config.profileName), - requestTimeoutMs: 10_000, - }); + const transport = new DatabaseTurnDaemonTransport(prisma, 30_000); await prisma.$executeRaw( GamePrisma.sql` UPDATE auction @@ -640,7 +630,7 @@ describe('auction integration flow', () => { WHERE id = ${auction.id} ` ); - const result = await transport.requestCommand({ type: 'auctionFinalize', auctionId: auction.id }, 10_000); + const result = await transport.requestCommand({ type: 'auctionFinalize', auctionId: auction.id }, 30_000); expect(result?.ok).toBe(false); const reopened = await prisma.auction.findUnique({ @@ -677,16 +667,20 @@ describe('auction integration flow', () => { where: { id: bidderB.generalId }, data: { weaponCode: 'None', bookCode: 'None', horseCode: 'None', itemCode: 'None' }, }); + await prisma.general.updateMany({ + where: { [slotField]: uniquePair.keyA } as GamePrisma.GeneralWhereInput, + data: { [slotField]: 'None' } as GamePrisma.GeneralUpdateManyMutationInput, + }); await prisma.inheritancePoint.upsert({ where: { userId_key: { userId: bidderA.userId, key: 'previous' } }, - update: { value: 5000 }, - create: { userId: bidderA.userId, key: 'previous', value: 5000 }, + update: { value: 100_000 }, + create: { userId: bidderA.userId, key: 'previous', value: 100_000 }, }); await prisma.inheritancePoint.upsert({ where: { userId_key: { userId: bidderB.userId, key: 'previous' } }, - update: { value: 5000 }, - create: { userId: bidderB.userId, key: 'previous', value: 5000 }, + update: { value: 100_000 }, + create: { userId: bidderB.userId, key: 'previous', value: 100_000 }, }); if (turnDaemon) { @@ -698,7 +692,7 @@ describe('auction integration flow', () => { const gatewayDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'public' }).url; turnDaemon = await createTurnDaemonRuntime({ profile: 'che', - profileName: 'che:2', + profileName: 'che:908', databaseUrl: gameDatabaseUrl, gatewayDatabaseUrl, }); @@ -720,32 +714,39 @@ describe('auction integration flow', () => { const keys = buildAuctionTimerKeys(gameServer.config.profileName); await redis.del(keys.timerKey); - const limitCloseAt = new Date(now.getTime() + 60_000); - const auction = await prisma.auction.create({ - data: { - type: 'UNIQUE_ITEM', - targetCode: uniquePair.keyA, - hostGeneralId: 0, - hostName: '시스템', - detail: { - startBidAmount: 200, - isReverse: false, - availableLatestBidCloseDate: limitCloseAt.toISOString(), - }, - status: 'OPEN', - closeAt: new Date(now.getTime() + 2000), - }, + const bidderAClient = createGameClient(gameUrl, gameServer.config.trpcPath, { value: bidderA.accessToken }); + const bidderBClient = createGameClient(gameUrl, gameServer.config.trpcPath, { value: bidderB.accessToken }); + const opened = await bidderAClient.auction.openUnique.mutate({ + itemKey: uniquePair.keyA, + amount: 5000, }); + const limitCloseAt = new Date(now.getTime() + 60_000); + await prisma.$executeRaw( + GamePrisma.sql` + UPDATE auction + SET close_at = ${new Date(now.getTime() + 2000)}, + detail = jsonb_set( + jsonb_set( + detail, + '{availableLatestBidCloseDate}', + to_jsonb(${limitCloseAt.toISOString()}::text) + ), + '{remainCloseDateExtensionCnt}', + '0'::jsonb + ), + updated_at = ${now} + WHERE id = ${opened.auctionId} + ` + ); + const auction = await prisma.auction.findUniqueOrThrow({ where: { id: opened.auctionId } }); await seedAuctionTimers(prisma, redis, keys); - const bidderAClient = createGameClient(gameUrl, gameServer.config.trpcPath, { value: bidderA.accessToken }); - const bidderBClient = createGameClient(gameUrl, gameServer.config.trpcPath, { value: bidderB.accessToken }); - - await bidderAClient.auction.bidUnique.mutate({ auctionId: auction.id, amount: 300, tryExtendCloseDate: true }); - await bidderBClient.auction.bidUnique.mutate({ auctionId: auction.id, amount: 350, tryExtendCloseDate: true }); - await bidderAClient.auction.bidUnique.mutate({ auctionId: auction.id, amount: 420, tryExtendCloseDate: true }); - await bidderBClient.auction.bidUnique.mutate({ auctionId: auction.id, amount: 500, tryExtendCloseDate: true }); + await bidderBClient.auction.bidUnique.mutate({ auctionId: auction.id, amount: 5100, tryExtendCloseDate: true }); + await bidderAClient.auction.bidUnique.mutate({ auctionId: auction.id, amount: 5200, tryExtendCloseDate: true }); + await bidderBClient.auction.bidUnique.mutate({ auctionId: auction.id, amount: 5300, tryExtendCloseDate: true }); + await bidderAClient.auction.bidUnique.mutate({ auctionId: auction.id, amount: 5400, tryExtendCloseDate: true }); + await bidderBClient.auction.bidUnique.mutate({ auctionId: auction.id, amount: 5500, tryExtendCloseDate: true }); const afterBids = await prisma.auction.findUnique({ where: { id: auction.id }, @@ -761,10 +762,7 @@ describe('auction integration flow', () => { }); await redis.zAdd(keys.timerKey, [{ score: finalizeAt.getTime(), value: String(auction.id) }]); - const transport = new RedisTurnDaemonTransport(redis, { - keys: buildTurnDaemonStreamKeys(gameServer.config.profileName), - requestTimeoutMs: 10_000, - }); + const transport = new DatabaseTurnDaemonTransport(prisma, 30_000); await prisma.$executeRaw( GamePrisma.sql` UPDATE auction @@ -774,7 +772,7 @@ describe('auction integration flow', () => { WHERE id = ${auction.id} ` ); - const result = await transport.requestCommand({ type: 'auctionFinalize', auctionId: auction.id }, 10_000); + const result = await transport.requestCommand({ type: 'auctionFinalize', auctionId: auction.id }, 30_000); expect(result?.ok).toBe(true); const winner = await prisma.general.findUnique({