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,