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:
2026-08-23 16:26:14 +00:00
parent bf6b7be7b0
commit 85591c68ad
114 changed files with 13327 additions and 901 deletions
+94 -8
View File
@@ -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,
+357 -44
View File
@@ -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());
+27 -1
View File
@@ -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, '라');