Files
core2026/app/game-engine/src/auction/finalizer.ts
T
Hide_D db3b8e8f08 fix: 유산 유니크 경매 기본 풀과 제한을 복구
Ref 기본 유니크 풀을 상태 조회·개설·마감에서 공유하고 API와 데몬 양쪽에서 입찰 부위 제한을 재검증한다. 실제 Chromium 및 개설·경쟁 입찰·마감 통합 회귀를 추가한다.
2026-08-20 15:54:20 +00:00

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();
},
};
};