fix: Ref 게임 로직과 시나리오 풀 호환을 보정
월 경계, 전투 기술 상한, 연감과 베팅·설문·경매 정산 순서를 Ref 계약에 맞춘다.\n\n시나리오 일반 풀을 ENGINE mutation과 logical tick 기반으로 직렬화하고 914·915 catalog 및 조건부 100기 pool 실행 경계를 추가한다.\n\n경매 worker는 세대별 durable event만 만들고 ENGINE이 row lock 후 상태 전이와 정산을 단일 transaction으로 소유한다.
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { createGamePostgresConnector, GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
|
||||
import { isItemKey, ItemLoader } from '@sammo-ts/logic';
|
||||
import { isItemKey, ItemLoader, type MessageDraft } from '@sammo-ts/logic';
|
||||
|
||||
import type { TurnDaemonCommand, TurnDaemonCommandResult } from '../lifecycle/types.js';
|
||||
import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js';
|
||||
import type { TurnGeneral } from '../turn/types.js';
|
||||
|
||||
export interface AuctionBidder {
|
||||
bid(
|
||||
@@ -20,6 +21,7 @@ type AuctionStatus = 'OPEN' | 'FINALIZING' | 'FINISHED' | 'CANCELED';
|
||||
|
||||
const COEFF_EXTENSION_MINUTES_PER_BID = 1 / 6;
|
||||
const MIN_EXTENSION_MINUTES_PER_BID = 1;
|
||||
export const MIN_AUCTION_REMAINING_RESOURCE = 1_000;
|
||||
|
||||
interface AuctionRow {
|
||||
id: number;
|
||||
@@ -29,6 +31,7 @@ interface AuctionRow {
|
||||
detail: unknown;
|
||||
status: AuctionStatus;
|
||||
closeAt: Date;
|
||||
closeTick: bigint | null;
|
||||
latestEventId: string;
|
||||
}
|
||||
|
||||
@@ -40,12 +43,78 @@ interface AuctionBidRow {
|
||||
}
|
||||
|
||||
interface AuctionDetail {
|
||||
title?: string;
|
||||
isReverse?: boolean;
|
||||
startBidAmount?: number;
|
||||
finishBidAmount?: number | null;
|
||||
availableLatestBidCloseDate?: string | null;
|
||||
}
|
||||
|
||||
export const hasEnoughResourceForAuctionBid = (current: number, additionalBid: number): boolean =>
|
||||
Number.isFinite(current) &&
|
||||
Number.isFinite(additionalBid) &&
|
||||
additionalBid > 0 &&
|
||||
current >= additionalBid + MIN_AUCTION_REMAINING_RESOURCE;
|
||||
|
||||
export const hasAuctionClosePassed = (
|
||||
auction: { closeAt: Date; closeTick: bigint | null },
|
||||
now: Date,
|
||||
nowTick: number | null
|
||||
): boolean =>
|
||||
auction.closeTick !== null && nowTick !== null
|
||||
? auction.closeTick < BigInt(nowTick)
|
||||
: auction.closeAt.getTime() < now.getTime();
|
||||
|
||||
export const resolveAuctionBidTiming = (
|
||||
world: Pick<InMemoryTurnWorld, 'dateToGameTick' | 'gameTickToDate'>,
|
||||
processingNow: Date,
|
||||
acceptedGameTick?: number
|
||||
): { bidAt: Date; bidTick: number } =>
|
||||
acceptedGameTick === undefined
|
||||
? { bidAt: processingNow, bidTick: world.dateToGameTick(processingNow) }
|
||||
: { bidAt: world.gameTickToDate(acceptedGameTick), bidTick: acceptedGameTick };
|
||||
|
||||
export const hasAuctionBidClosePassed = (
|
||||
auction: { closeAt: Date; closeTick: bigint | null },
|
||||
world: Pick<InMemoryTurnWorld, 'dateToGameTick' | 'gameTickToDate'>,
|
||||
processingNow: Date,
|
||||
acceptedGameTick?: number
|
||||
): boolean => {
|
||||
const { bidAt, bidTick } = resolveAuctionBidTiming(world, processingNow, acceptedGameTick);
|
||||
return hasAuctionClosePassed(auction, bidAt, bidTick);
|
||||
};
|
||||
|
||||
export const buildAuctionOutbidRefundMessage = (options: {
|
||||
auctionId: number;
|
||||
title?: string;
|
||||
bidder: TurnGeneral;
|
||||
nation?: { name: string; color: string } | null;
|
||||
time: Date;
|
||||
}): MessageDraft => ({
|
||||
msgType: 'private',
|
||||
src: {
|
||||
generalId: 0,
|
||||
generalName: '',
|
||||
nationId: 0,
|
||||
nationName: 'System',
|
||||
color: '#000000',
|
||||
icon: '',
|
||||
},
|
||||
dest: {
|
||||
generalId: options.bidder.id,
|
||||
generalName: options.bidder.name,
|
||||
nationId: options.bidder.nationId,
|
||||
nationName: options.nation?.name ?? '재야',
|
||||
color: options.nation?.color ?? '#000000',
|
||||
icon: options.bidder.picture ?? '',
|
||||
},
|
||||
text: `${options.auctionId}번 ${options.title ?? '경매'}에 상회입찰자가 나타났습니다.`,
|
||||
time: new Date(options.time.getTime()),
|
||||
validUntil: new Date('9999-12-31T00:00:00.000Z'),
|
||||
option: {},
|
||||
sendDestOnly: true,
|
||||
});
|
||||
|
||||
const parseDetail = (detail: unknown): AuctionDetail => {
|
||||
if (!detail || typeof detail !== 'object') {
|
||||
return {};
|
||||
@@ -106,6 +175,7 @@ const loadAuction = async (prisma: QueryClient, auctionId: number): Promise<Auct
|
||||
detail,
|
||||
status,
|
||||
close_at as "closeAt",
|
||||
close_tick as "closeTick",
|
||||
latest_event_id as "latestEventId"
|
||||
FROM auction
|
||||
WHERE id = ${auctionId}
|
||||
@@ -198,8 +268,9 @@ export const createAuctionBidder = async (options: {
|
||||
reason: '경매가 종료되었습니다.',
|
||||
};
|
||||
}
|
||||
const now = world.getGameNow(new Date());
|
||||
if (auction.closeAt.getTime() <= now.getTime()) {
|
||||
const processingNow = world.getGameNow(new Date());
|
||||
const { bidAt, bidTick } = resolveAuctionBidTiming(world, processingNow, command.acceptedGameTick);
|
||||
if (hasAuctionClosePassed(auction, bidAt, bidTick)) {
|
||||
return {
|
||||
type: 'auctionBid',
|
||||
ok: false,
|
||||
@@ -371,7 +442,7 @@ export const createAuctionBidder = async (options: {
|
||||
};
|
||||
}
|
||||
|
||||
if (auction.type === 'BUY_RICE' && general.gold < morePoint) {
|
||||
if (auction.type === 'BUY_RICE' && !hasEnoughResourceForAuctionBid(general.gold, morePoint)) {
|
||||
return {
|
||||
type: 'auctionBid',
|
||||
ok: false,
|
||||
@@ -379,7 +450,7 @@ export const createAuctionBidder = async (options: {
|
||||
reason: '금이 부족합니다.',
|
||||
};
|
||||
}
|
||||
if (auction.type === 'SELL_RICE' && general.rice < morePoint) {
|
||||
if (auction.type === 'SELL_RICE' && !hasEnoughResourceForAuctionBid(general.rice, morePoint)) {
|
||||
return {
|
||||
type: 'auctionBid',
|
||||
ok: false,
|
||||
@@ -410,7 +481,7 @@ export const createAuctionBidder = async (options: {
|
||||
? new Date(detail.availableLatestBidCloseDate)
|
||||
: null;
|
||||
let nextCloseAt = extendCloseDate({
|
||||
now,
|
||||
now: bidAt,
|
||||
closeAt: auction.closeAt,
|
||||
turnMinutes,
|
||||
availableLatestBidCloseDate,
|
||||
@@ -420,11 +491,11 @@ export const createAuctionBidder = async (options: {
|
||||
detail.finishBidAmount != null &&
|
||||
command.amount === detail.finishBidAmount
|
||||
) {
|
||||
nextCloseAt = new Date(now.getTime() + turnMinutes * 60_000);
|
||||
nextCloseAt = new Date(bidAt.getTime() + turnMinutes * 60_000);
|
||||
}
|
||||
|
||||
const eventId = randomUUID();
|
||||
const eventAt = now;
|
||||
const eventAt = bidAt;
|
||||
const rankTrackedAmount = auction.type === 'UNIQUE_ITEM' ? readRankTrackedAmount(myPrevBid) + morePoint : 0;
|
||||
const previousRankTrackedAmount = readRankTrackedAmount(highestBid);
|
||||
|
||||
@@ -613,6 +684,21 @@ export const createAuctionBidder = async (options: {
|
||||
}
|
||||
}
|
||||
|
||||
if (highestBid && highestBid.generalId !== command.generalId && !myPrevBid) {
|
||||
const previousBidder = world.getGeneralById(highestBid.generalId);
|
||||
if (previousBidder) {
|
||||
world.queueMessage(
|
||||
buildAuctionOutbidRefundMessage({
|
||||
auctionId: auction.id,
|
||||
title: detail.title,
|
||||
bidder: previousBidder,
|
||||
nation: world.getNationById(previousBidder.nationId),
|
||||
time: eventAt,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'auctionBid',
|
||||
ok: true,
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { createGamePostgresConnector, GamePrisma } from '@sammo-ts/infra';
|
||||
import { ActionLogger, ItemLoader, LogFormat, UserLogger, isItemKey } from '@sammo-ts/logic';
|
||||
import { ActionLogger, ItemLoader, LogFormat, isItemKey, type MessageDraft } 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 { TurnDaemonCommand, TurnDaemonCommandResult } from '../lifecycle/types.js';
|
||||
import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js';
|
||||
import type { TurnGeneral } from '../turn/types.js';
|
||||
import type { LogEntryDraft } from '@sammo-ts/logic';
|
||||
|
||||
export interface AuctionFinalizer {
|
||||
finalize(auctionId: number, db?: GamePrisma.TransactionClient): Promise<TurnDaemonCommandResult>;
|
||||
finalize(
|
||||
command: Extract<TurnDaemonCommand, { type: 'auctionFinalize' }>,
|
||||
db?: GamePrisma.TransactionClient
|
||||
): Promise<TurnDaemonCommandResult>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -31,6 +35,7 @@ interface AuctionRow {
|
||||
detail: unknown;
|
||||
status: AuctionStatus;
|
||||
closeAt: Date;
|
||||
closeTick: bigint | null;
|
||||
}
|
||||
|
||||
interface AuctionBidRow {
|
||||
@@ -59,6 +64,93 @@ const parseDetail = (detail: unknown): AuctionDetailResource => {
|
||||
return detail as AuctionDetailResource;
|
||||
};
|
||||
|
||||
const toFiniteNumber = (value: unknown): number => {
|
||||
const parsed = typeof value === 'string' ? Number(value) : value;
|
||||
return typeof parsed === 'number' && Number.isFinite(parsed) ? parsed : 0;
|
||||
};
|
||||
|
||||
const readRankTrackedAmount = (bid: AuctionBidRow): number => {
|
||||
const meta = asRecord(bid.meta);
|
||||
return Math.max(0, toFiniteNumber(meta.inheritSpentTrackedAmount));
|
||||
};
|
||||
|
||||
export const resolveAuctionResourceAmount = (detailAmount: unknown, targetCode: string | null): number | null => {
|
||||
const detailValue = toFiniteNumber(detailAmount);
|
||||
if (detailValue > 0) return detailValue;
|
||||
const targetValue = toFiniteNumber(targetCode);
|
||||
return targetValue > 0 ? targetValue : null;
|
||||
};
|
||||
|
||||
export const isUniqueAuctionSupplyExhausted = (configuredAmount: number, occupiedAmount: number): boolean =>
|
||||
configuredAmount <= occupiedAmount;
|
||||
|
||||
export const resolveUniqueSupplyRetryCloseAt = (now: Date, turnMinutes: number): Date =>
|
||||
new Date(now.getTime() + Math.max(1, turnMinutes) * 60_000);
|
||||
|
||||
export const isAuctionFinalizeGenerationCurrent = (
|
||||
auction: Pick<AuctionRow, 'closeAt' | 'closeTick'>,
|
||||
command: Pick<Extract<TurnDaemonCommand, { type: 'auctionFinalize' }>, 'expectedCloseAt' | 'expectedCloseTick'>
|
||||
): boolean => {
|
||||
if (command.expectedCloseTick !== undefined) {
|
||||
return auction.closeTick !== null && auction.closeTick === BigInt(command.expectedCloseTick);
|
||||
}
|
||||
if (command.expectedCloseAt !== undefined) {
|
||||
return auction.closeAt.getTime() === new Date(command.expectedCloseAt).getTime();
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
export const hasAuctionFinalizeDeadlineArrived = (
|
||||
auction: Pick<AuctionRow, 'closeAt' | 'closeTick'>,
|
||||
now: Date,
|
||||
nowTick: number
|
||||
): boolean =>
|
||||
auction.closeTick === null ? auction.closeAt.getTime() <= now.getTime() : auction.closeTick <= BigInt(nowTick);
|
||||
|
||||
export const buildAuctionBidderSystemMessage = (options: {
|
||||
bidder: TurnGeneral;
|
||||
nation?: { name: string; color: string } | null;
|
||||
time: Date;
|
||||
text: string;
|
||||
}): MessageDraft => ({
|
||||
msgType: 'private',
|
||||
src: {
|
||||
generalId: 0,
|
||||
generalName: '',
|
||||
nationId: 0,
|
||||
nationName: 'System',
|
||||
color: '#000000',
|
||||
icon: '',
|
||||
},
|
||||
dest: {
|
||||
generalId: options.bidder.id,
|
||||
generalName: options.bidder.name,
|
||||
nationId: options.bidder.nationId,
|
||||
nationName: options.nation?.name ?? '재야',
|
||||
color: options.nation?.color ?? '#000000',
|
||||
icon: options.bidder.picture ?? '',
|
||||
},
|
||||
text: options.text,
|
||||
time: new Date(options.time.getTime()),
|
||||
validUntil: new Date('9999-12-31T00:00:00.000Z'),
|
||||
option: {},
|
||||
sendDestOnly: true,
|
||||
});
|
||||
|
||||
export const buildAuctionCancellationMessage = (options: {
|
||||
auctionId: number;
|
||||
title?: string;
|
||||
bidder: TurnGeneral;
|
||||
nation?: { name: string; color: string } | null;
|
||||
time: Date;
|
||||
}): MessageDraft =>
|
||||
buildAuctionBidderSystemMessage({
|
||||
bidder: options.bidder,
|
||||
nation: options.nation,
|
||||
time: options.time,
|
||||
text: `${options.auctionId}번 ${options.title ?? '경매'}가 취소되었습니다.`,
|
||||
});
|
||||
|
||||
const toTurnMinutes = (tickSeconds: number): number => Math.max(1, Math.round(tickSeconds / 60));
|
||||
|
||||
type AuctionDb = GamePrisma.TransactionClient;
|
||||
@@ -76,12 +168,48 @@ const pushLogs = (world: InMemoryTurnWorld, logs: LogEntryDraft[]): void => {
|
||||
}
|
||||
};
|
||||
|
||||
export const buildUniqueAuctionInheritanceLogData = (options: {
|
||||
userId: string;
|
||||
year: number;
|
||||
month: number;
|
||||
itemName: string;
|
||||
amount: number;
|
||||
}) => ({
|
||||
userId: options.userId,
|
||||
year: options.year,
|
||||
month: options.month,
|
||||
logType: 'inheritPoint',
|
||||
text: `유니크 ${options.itemName} 경매로 ${options.amount} 포인트 사용`,
|
||||
});
|
||||
|
||||
export const buildUniqueAuctionAwardLogs = (options: {
|
||||
bidder: TurnGeneral;
|
||||
nationName: string;
|
||||
itemName: string;
|
||||
itemRawName: string;
|
||||
}): LogEntryDraft[] => {
|
||||
const logger = new ActionLogger({ generalId: options.bidder.id, nationId: options.bidder.nationId });
|
||||
const josaYi = JosaUtil.pick(options.bidder.name, '이');
|
||||
const josaUl = JosaUtil.pick(options.itemRawName, '을');
|
||||
logger.pushGeneralActionLog(`<C>${options.itemName}</>${josaUl} 습득했습니다!`);
|
||||
logger.pushGeneralHistoryLog(`<C>${options.itemName}</>${josaUl} 습득`);
|
||||
logger.pushGlobalActionLog(
|
||||
`<Y>${options.bidder.name}</>${josaYi} <C>${options.itemName}</>${josaUl} 습득했습니다!`
|
||||
);
|
||||
logger.pushGlobalHistoryLog(
|
||||
`<C><b>【보물수배】</b></><D><b>${options.nationName}</b></>의 <Y>${options.bidder.name}</>${josaYi} <C>${options.itemName}</>${josaUl} 습득했습니다!`
|
||||
);
|
||||
return logger.flush();
|
||||
};
|
||||
|
||||
const refundInheritancePoint = async (options: {
|
||||
prisma: AuctionDb;
|
||||
userId: string;
|
||||
generalId: number;
|
||||
amount: number;
|
||||
rankTrackedAmount: number;
|
||||
}): Promise<void> => {
|
||||
const { prisma, userId, amount } = options;
|
||||
const { prisma, userId, generalId, amount, rankTrackedAmount } = options;
|
||||
if (!userId || amount <= 0) {
|
||||
return;
|
||||
}
|
||||
@@ -95,6 +223,16 @@ const refundInheritancePoint = async (options: {
|
||||
updated_at = EXCLUDED.updated_at
|
||||
`
|
||||
);
|
||||
if (rankTrackedAmount > 0) {
|
||||
await prisma.$executeRaw(
|
||||
GamePrisma.sql`
|
||||
UPDATE rank_data
|
||||
SET value = GREATEST(0, value - ${rankTrackedAmount})
|
||||
WHERE general_id = ${generalId}
|
||||
AND type = 'inherit_spent_dyn'
|
||||
`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const createAuctionFinalizer = async (options: {
|
||||
@@ -103,7 +241,6 @@ export const createAuctionFinalizer = async (options: {
|
||||
}): Promise<AuctionFinalizer> => {
|
||||
const connector = createGamePostgresConnector({ url: options.databaseUrl });
|
||||
await connector.connect();
|
||||
const prisma = connector.prisma;
|
||||
const world = options.world;
|
||||
const itemLoader = new ItemLoader();
|
||||
|
||||
@@ -115,8 +252,17 @@ export const createAuctionFinalizer = async (options: {
|
||||
};
|
||||
|
||||
return {
|
||||
finalize: async (auctionId: number, commandDb): Promise<TurnDaemonCommandResult> => {
|
||||
const db = commandDb ?? prisma;
|
||||
finalize: async (command, commandDb): Promise<TurnDaemonCommandResult> => {
|
||||
const auctionId = command.auctionId;
|
||||
if (!commandDb) {
|
||||
return {
|
||||
type: 'auctionFinalize',
|
||||
ok: false,
|
||||
auctionId,
|
||||
reason: '경매 확정은 ENGINE mutation transaction에서만 실행할 수 있습니다.',
|
||||
};
|
||||
}
|
||||
const db = commandDb;
|
||||
const rows = await db.$queryRaw<AuctionRow[]>(
|
||||
GamePrisma.sql`
|
||||
SELECT id,
|
||||
@@ -126,9 +272,11 @@ export const createAuctionFinalizer = async (options: {
|
||||
host_name as "hostName",
|
||||
detail,
|
||||
status,
|
||||
close_at as "closeAt"
|
||||
close_at as "closeAt",
|
||||
close_tick as "closeTick"
|
||||
FROM auction
|
||||
WHERE id = ${auctionId}
|
||||
FOR UPDATE
|
||||
`
|
||||
);
|
||||
const auction = rows[0];
|
||||
@@ -145,7 +293,39 @@ export const createAuctionFinalizer = async (options: {
|
||||
return { type: 'auctionFinalize', ok: true, auctionId };
|
||||
}
|
||||
|
||||
if (auction.status !== 'FINALIZING') {
|
||||
const now = world.getGameNow(new Date());
|
||||
if (auction.status === 'OPEN') {
|
||||
if (!isAuctionFinalizeGenerationCurrent(auction, command)) {
|
||||
return {
|
||||
type: 'auctionFinalize',
|
||||
ok: false,
|
||||
auctionId,
|
||||
reason: '경매 마감 세대가 변경되었습니다.',
|
||||
};
|
||||
}
|
||||
const nowTick = world.dateToGameTick(now);
|
||||
if (!hasAuctionFinalizeDeadlineArrived(auction, now, nowTick)) {
|
||||
return {
|
||||
type: 'auctionFinalize',
|
||||
ok: false,
|
||||
auctionId,
|
||||
reason: '경매 마감 시각이 아직 지나지 않았습니다.',
|
||||
};
|
||||
}
|
||||
const transitioned = await db.$executeRaw(
|
||||
GamePrisma.sql`
|
||||
UPDATE auction
|
||||
SET status = 'FINALIZING',
|
||||
finalizing_at = ${now},
|
||||
updated_at = ${now}
|
||||
WHERE id = ${auctionId}
|
||||
AND status = 'OPEN'
|
||||
`
|
||||
);
|
||||
if (transitioned !== 1) {
|
||||
throw new Error(`경매 확정 상태 전이에 실패했습니다: ${auctionId}`);
|
||||
}
|
||||
} else if (auction.status !== 'FINALIZING') {
|
||||
return {
|
||||
type: 'auctionFinalize',
|
||||
ok: false,
|
||||
@@ -176,7 +356,6 @@ export const createAuctionFinalizer = async (options: {
|
||||
);
|
||||
const highestBid = bidRows[0] ?? null;
|
||||
|
||||
const now = world.getGameNow(new Date());
|
||||
const logs: LogEntryDraft[] = [];
|
||||
const globalLogger = new ActionLogger();
|
||||
|
||||
@@ -192,11 +371,76 @@ export const createAuctionFinalizer = async (options: {
|
||||
);
|
||||
};
|
||||
|
||||
const cancelWithEscrowRefund = async (options: {
|
||||
bid: AuctionBidRow;
|
||||
resourceAmount?: number | null;
|
||||
refundResourceHost?: boolean;
|
||||
}): Promise<boolean> => {
|
||||
const refundBidder = world.getGeneralById(options.bid.generalId);
|
||||
if (!refundBidder) return false;
|
||||
|
||||
const shouldRefundHost =
|
||||
auction.type !== 'UNIQUE_ITEM' && options.refundResourceHost !== false && auction.hostGeneralId > 0;
|
||||
const resourceAmount = options.resourceAmount ?? null;
|
||||
const resourceHost = shouldRefundHost ? world.getGeneralById(auction.hostGeneralId) : null;
|
||||
if (shouldRefundHost && (!resourceHost || resourceAmount === null || resourceAmount <= 0)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (auction.type === 'UNIQUE_ITEM') {
|
||||
const bidderUserId = await getGeneralUserId(db, refundBidder.id);
|
||||
if (!bidderUserId) return false;
|
||||
const rankTrackedAmount = readRankTrackedAmount(options.bid);
|
||||
await refundInheritancePoint({
|
||||
prisma: db,
|
||||
userId: bidderUserId,
|
||||
generalId: refundBidder.id,
|
||||
amount: options.bid.amount,
|
||||
rankTrackedAmount,
|
||||
});
|
||||
world.updateGeneral(refundBidder.id, {
|
||||
inheritancePoints: {
|
||||
...refundBidder.inheritancePoints,
|
||||
previous: toFiniteNumber(refundBidder.inheritancePoints?.previous) + options.bid.amount,
|
||||
},
|
||||
meta: {
|
||||
...refundBidder.meta,
|
||||
inherit_spent_dyn: Math.max(
|
||||
0,
|
||||
toFiniteNumber(refundBidder.meta.inherit_spent_dyn) - rankTrackedAmount
|
||||
),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const bidderResource = auction.type === 'BUY_RICE' ? 'gold' : 'rice';
|
||||
world.updateGeneral(refundBidder.id, {
|
||||
[bidderResource]: refundBidder[bidderResource] + options.bid.amount,
|
||||
});
|
||||
if (resourceHost && resourceAmount !== null) {
|
||||
const hostResource = auction.type === 'BUY_RICE' ? 'rice' : 'gold';
|
||||
world.updateGeneral(resourceHost.id, {
|
||||
[hostResource]: resourceHost[hostResource] + resourceAmount,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
world.queueMessage(
|
||||
buildAuctionCancellationMessage({
|
||||
auctionId: auction.id,
|
||||
title: detail.title,
|
||||
bidder: refundBidder,
|
||||
nation: world.getNationById(refundBidder.nationId),
|
||||
time: now,
|
||||
})
|
||||
);
|
||||
await finalizeStatus('CANCELED');
|
||||
return true;
|
||||
};
|
||||
|
||||
if (!highestBid) {
|
||||
if (auction.type === 'BUY_RICE' || auction.type === 'SELL_RICE') {
|
||||
const amount = detail.amount;
|
||||
if (!amount || amount <= 0) {
|
||||
await finalizeStatus('CANCELED');
|
||||
const amount = resolveAuctionResourceAmount(detail.amount, auction.targetCode);
|
||||
if (amount === null) {
|
||||
return {
|
||||
type: 'auctionFinalize',
|
||||
ok: false,
|
||||
@@ -207,7 +451,6 @@ export const createAuctionFinalizer = async (options: {
|
||||
if (auction.hostGeneralId > 0) {
|
||||
const host = world.getGeneralById(auction.hostGeneralId);
|
||||
if (!host) {
|
||||
await finalizeStatus('CANCELED');
|
||||
return {
|
||||
type: 'auctionFinalize',
|
||||
ok: false,
|
||||
@@ -226,7 +469,22 @@ export const createAuctionFinalizer = async (options: {
|
||||
);
|
||||
logs.push(...hostLogger.flush());
|
||||
globalLogger.pushGlobalActionLog(`경매 ${auctionId}번이 유찰되었습니다.`, LogFormat.PLAIN);
|
||||
world.queueMessage(
|
||||
buildAuctionBidderSystemMessage({
|
||||
bidder: host,
|
||||
nation: world.getNationById(host.nationId),
|
||||
time: now,
|
||||
text: `${auctionId}번 ${resourceKey === 'rice' ? '쌀' : '금'} 경매에 입찰이 없어 취소되었습니다.`,
|
||||
})
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
type: 'auctionFinalize',
|
||||
ok: false,
|
||||
auctionId,
|
||||
reason: '유니크 경매 입찰 정보가 없어 자동 환불할 수 없습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
logs.push(...globalLogger.flush());
|
||||
@@ -255,6 +513,7 @@ export const createAuctionFinalizer = async (options: {
|
||||
GamePrisma.sql`
|
||||
UPDATE auction
|
||||
SET status = 'OPEN',
|
||||
finalizing_at = NULL,
|
||||
detail = ${JSON.stringify(nextDetail)}::jsonb,
|
||||
close_at = ${nextCloseAt},
|
||||
close_tick = ${BigInt(world.dateToGameTick(nextCloseAt))},
|
||||
@@ -273,7 +532,6 @@ export const createAuctionFinalizer = async (options: {
|
||||
|
||||
const bidder = world.getGeneralById(highestBid.generalId);
|
||||
if (!bidder) {
|
||||
await finalizeStatus('CANCELED');
|
||||
return {
|
||||
type: 'auctionFinalize',
|
||||
ok: false,
|
||||
@@ -283,9 +541,8 @@ export const createAuctionFinalizer = async (options: {
|
||||
}
|
||||
|
||||
if (auction.type === 'BUY_RICE' || auction.type === 'SELL_RICE') {
|
||||
const amount = detail.amount;
|
||||
if (!amount || amount <= 0) {
|
||||
await finalizeStatus('CANCELED');
|
||||
const amount = resolveAuctionResourceAmount(detail.amount, auction.targetCode);
|
||||
if (amount === null) {
|
||||
return {
|
||||
type: 'auctionFinalize',
|
||||
ok: false,
|
||||
@@ -298,7 +555,11 @@ export const createAuctionFinalizer = async (options: {
|
||||
if (auction.hostGeneralId > 0) {
|
||||
const host = world.getGeneralById(auction.hostGeneralId);
|
||||
if (!host) {
|
||||
await finalizeStatus('CANCELED');
|
||||
await cancelWithEscrowRefund({
|
||||
bid: highestBid,
|
||||
resourceAmount: amount,
|
||||
refundResourceHost: false,
|
||||
});
|
||||
return {
|
||||
type: 'auctionFinalize',
|
||||
ok: false,
|
||||
@@ -332,7 +593,7 @@ export const createAuctionFinalizer = async (options: {
|
||||
} else if (auction.type === 'UNIQUE_ITEM') {
|
||||
const itemKey = auction.targetCode;
|
||||
if (!itemKey) {
|
||||
await finalizeStatus('CANCELED');
|
||||
await cancelWithEscrowRefund({ bid: highestBid });
|
||||
return {
|
||||
type: 'auctionFinalize',
|
||||
ok: false,
|
||||
@@ -341,7 +602,7 @@ export const createAuctionFinalizer = async (options: {
|
||||
};
|
||||
}
|
||||
if (!isItemKey(itemKey)) {
|
||||
await finalizeStatus('CANCELED');
|
||||
await cancelWithEscrowRefund({ bid: highestBid });
|
||||
return {
|
||||
type: 'auctionFinalize',
|
||||
ok: false,
|
||||
@@ -351,12 +612,7 @@ export const createAuctionFinalizer = async (options: {
|
||||
}
|
||||
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,
|
||||
});
|
||||
await cancelWithEscrowRefund({ bid: highestBid });
|
||||
return {
|
||||
type: 'auctionFinalize',
|
||||
ok: false,
|
||||
@@ -370,6 +626,42 @@ export const createAuctionFinalizer = async (options: {
|
||||
asRecord(world.getScenarioConfig().const),
|
||||
itemLoader
|
||||
);
|
||||
const configuredAmount = config.allItems[itemModule.slot]?.[itemKey] ?? 0;
|
||||
const occupiedAmount = world
|
||||
.listGenerals()
|
||||
.filter((candidate) => candidate.role.items[itemModule.slot] === itemKey).length;
|
||||
if (isUniqueAuctionSupplyExhausted(configuredAmount, occupiedAmount)) {
|
||||
// Ref keeps the already-escrowed highest bid and retries the expired
|
||||
// auction on later turn passes when every configured copy is held.
|
||||
// This differs from same-slot ownership, which extends the deadline.
|
||||
const turnMinutes = await resolveTurnMinutes(db);
|
||||
const nextCloseAt = resolveUniqueSupplyRetryCloseAt(now, turnMinutes);
|
||||
await db.$executeRaw(
|
||||
GamePrisma.sql`
|
||||
UPDATE auction
|
||||
SET status = 'OPEN',
|
||||
finalizing_at = NULL,
|
||||
close_at = ${nextCloseAt},
|
||||
close_tick = ${BigInt(world.dateToGameTick(nextCloseAt))},
|
||||
updated_at = ${now}
|
||||
WHERE id = ${auctionId}
|
||||
`
|
||||
);
|
||||
world.queueMessage(
|
||||
buildAuctionBidderSystemMessage({
|
||||
bidder,
|
||||
nation: world.getNationById(bidder.nationId),
|
||||
time: now,
|
||||
text: '그 유니크는 모두 점유되었습니다.',
|
||||
})
|
||||
);
|
||||
return {
|
||||
type: 'auctionFinalize',
|
||||
ok: false,
|
||||
auctionId,
|
||||
reason: '그 유니크는 모두 점유되었습니다.',
|
||||
};
|
||||
}
|
||||
const scenarioMeta = asRecord(state.meta.scenarioMeta);
|
||||
const startYear =
|
||||
typeof scenarioMeta.startYear === 'number' && Number.isFinite(scenarioMeta.startYear)
|
||||
@@ -417,6 +709,7 @@ export const createAuctionFinalizer = async (options: {
|
||||
GamePrisma.sql`
|
||||
UPDATE auction
|
||||
SET status = 'OPEN',
|
||||
finalizing_at = NULL,
|
||||
host_general_id = ${bidder.id === auction.hostGeneralId ? auction.hostGeneralId : 0},
|
||||
host_name = ${bidder.id === auction.hostGeneralId ? auction.hostName : '(상인)'},
|
||||
detail = ${JSON.stringify(nextDetail)}::jsonb,
|
||||
@@ -432,6 +725,14 @@ export const createAuctionFinalizer = async (options: {
|
||||
);
|
||||
logs.push(...globalLogger.flush());
|
||||
pushLogs(world, logs);
|
||||
world.queueMessage(
|
||||
buildAuctionBidderSystemMessage({
|
||||
bidder,
|
||||
nation: world.getNationById(bidder.nationId),
|
||||
time: now,
|
||||
text: '유니크 아이템 소유 제한 상태입니다. 종료 시간이 연장됩니다.',
|
||||
})
|
||||
);
|
||||
return {
|
||||
type: 'auctionFinalize',
|
||||
ok: false,
|
||||
@@ -463,6 +764,7 @@ export const createAuctionFinalizer = async (options: {
|
||||
GamePrisma.sql`
|
||||
UPDATE auction
|
||||
SET status = 'OPEN',
|
||||
finalizing_at = NULL,
|
||||
host_general_id = ${bidder.id === auction.hostGeneralId ? auction.hostGeneralId : 0},
|
||||
host_name = ${bidder.id === auction.hostGeneralId ? auction.hostName : '(상인)'},
|
||||
detail = ${JSON.stringify(nextDetail)}::jsonb,
|
||||
@@ -478,6 +780,17 @@ export const createAuctionFinalizer = async (options: {
|
||||
);
|
||||
logs.push(...globalLogger.flush());
|
||||
pushLogs(world, logs);
|
||||
world.queueMessage(
|
||||
buildAuctionBidderSystemMessage({
|
||||
bidder,
|
||||
nation: world.getNationById(bidder.nationId),
|
||||
time: now,
|
||||
text:
|
||||
currentItem === itemKey
|
||||
? '이미 그 유니크를 가지고 있습니다.'
|
||||
: '이미 다른 유니크를 가지고 있습니다.',
|
||||
})
|
||||
);
|
||||
return {
|
||||
type: 'auctionFinalize',
|
||||
ok: false,
|
||||
@@ -499,28 +812,28 @@ export const createAuctionFinalizer = async (options: {
|
||||
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(
|
||||
...buildUniqueAuctionAwardLogs({
|
||||
bidder,
|
||||
nationName: world.getNationById(bidder.nationId)?.name ?? '재야',
|
||||
itemName: itemModule.name,
|
||||
itemRawName: itemModule.rawName,
|
||||
})
|
||||
);
|
||||
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());
|
||||
}
|
||||
const state = world.getState();
|
||||
await db.inheritanceLog.create({
|
||||
data: buildUniqueAuctionInheritanceLogData({
|
||||
userId: bidderUserId,
|
||||
year: state.currentYear,
|
||||
month: state.currentMonth,
|
||||
itemName: itemModule.name,
|
||||
amount: highestBid.amount,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
globalLogger.pushGlobalActionLog(`유니크 경매 ${auctionId}번이 성사되었습니다.`, LogFormat.PLAIN);
|
||||
}
|
||||
|
||||
logs.push(...globalLogger.flush());
|
||||
|
||||
@@ -37,6 +37,12 @@ const fail = (reason: string): TurnDaemonCommandResult => ({
|
||||
reason,
|
||||
});
|
||||
|
||||
export const buildInitialUniqueAuctionBidMeta = (obfuscatedName: string, amount: number) => ({
|
||||
obfuscatedName,
|
||||
tryExtendCloseDate: false,
|
||||
inheritSpentTrackedAmount: amount,
|
||||
});
|
||||
|
||||
const openResourceAuction = async (
|
||||
command: AuctionOpenCommand,
|
||||
world: InMemoryTurnWorld,
|
||||
@@ -257,7 +263,7 @@ const openUniqueAuction = async (
|
||||
amount: command.amount,
|
||||
eventId,
|
||||
eventAt: now,
|
||||
meta: { obfuscatedName: alias, tryExtendCloseDate: false },
|
||||
meta: buildInitialUniqueAuctionBidMeta(alias, command.amount),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -266,6 +272,26 @@ const openUniqueAuction = async (
|
||||
where: { userId_key: { userId, key: 'previous' } },
|
||||
data: { value: currentPoint - command.amount },
|
||||
});
|
||||
await db.$executeRaw(
|
||||
GamePrisma.sql`
|
||||
INSERT INTO rank_data (nation_id, general_id, type, value)
|
||||
VALUES (${general.nationId}, ${general.id}, 'inherit_spent_dyn', ${command.amount})
|
||||
ON CONFLICT (general_id, type)
|
||||
DO UPDATE SET
|
||||
nation_id = EXCLUDED.nation_id,
|
||||
value = rank_data.value + EXCLUDED.value
|
||||
`
|
||||
);
|
||||
world.updateGeneral(general.id, {
|
||||
inheritancePoints: {
|
||||
...general.inheritancePoints,
|
||||
previous: currentPoint - command.amount,
|
||||
},
|
||||
meta: {
|
||||
...general.meta,
|
||||
inherit_spent_dyn: readNumber(asRecord(general.meta), 'inherit_spent_dyn', 0) + command.amount,
|
||||
},
|
||||
});
|
||||
|
||||
const logger = new ActionLogger();
|
||||
const rawNameJosa = JosaUtil.pick(item.rawName, '라');
|
||||
|
||||
Reference in New Issue
Block a user