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