feat: complete legacy-compatible auction system

This commit is contained in:
2026-07-25 10:43:49 +00:00
parent 26c3d5ce16
commit 93ae4df519
21 changed files with 1355 additions and 216 deletions
+58 -40
View File
@@ -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<Auct
GamePrisma.sql`
SELECT id,
type,
host_general_id as "hostGeneralId",
detail,
status,
close_at as "closeAt"
FROM auction
WHERE id = ${auctionId}
FOR UPDATE
`
);
return rows[0] ?? null;
@@ -218,6 +222,22 @@ export const createAuctionBidder = async (options: {
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) {
@@ -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<Array<{ value: number }>>(
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
`
);
}
}
}
+148 -56
View File
@@ -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<number> => {
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<AuctionBidRow[]>(
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}
+304
View File
@@ -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<TurnDaemonCommand, { type: 'auctionOpen' }>;
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<string, unknown>, 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<TurnDaemonCommandResult> => {
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<TurnDaemonCommandResult> => {
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<Array<{ userId: string | null }>>(
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<Array<{ value: number }>>(
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(
`<C><b>【보물수배】</b></>누군가가 <C>${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<TurnDaemonCommandResult> => {
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);
};
@@ -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,
@@ -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<Map<string, ItemModule>> | null = null;
@@ -663,6 +664,13 @@ async function handleAuctionFinalize(
return ctx.auctionFinalizer.finalize(command.auctionId, ctx.commandDb);
}
async function handleAuctionOpen(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'auctionOpen' }>
): Promise<TurnDaemonCommandResult> {
return openAuction(command, ctx.world, ctx.commandDb);
}
async function handleAuctionBid(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'auctionBid' }>
@@ -1168,6 +1176,8 @@ export const createTurnDaemonCommandHandler = (options: {
dropItem: (command) => handleDropItem(ctx, command as Extract<TurnDaemonCommand, { type: 'dropItem' }>),
auctionFinalize: (command) =>
handleAuctionFinalize(ctx, command as Extract<TurnDaemonCommand, { type: 'auctionFinalize' }>),
auctionOpen: (command) =>
handleAuctionOpen(ctx, command as Extract<TurnDaemonCommand, { type: 'auctionOpen' }>),
auctionBid: (command) => handleAuctionBid(ctx, command as Extract<TurnDaemonCommand, { type: 'auctionBid' }>),
changePermission: (command) =>
handleChangePermission(ctx, command as Extract<TurnDaemonCommand, { type: 'changePermission' }>),