Merge origin/main into frontend parity worktree
This commit is contained in:
@@ -23,6 +23,7 @@ const MIN_EXTENSION_MINUTES_PER_BID = 1;
|
||||
interface AuctionRow {
|
||||
id: number;
|
||||
type: AuctionType;
|
||||
hostGeneralId: number;
|
||||
detail: unknown;
|
||||
status: AuctionStatus;
|
||||
closeAt: Date;
|
||||
@@ -37,6 +38,7 @@ interface AuctionBidRow {
|
||||
interface AuctionDetail {
|
||||
isReverse?: boolean;
|
||||
startBidAmount?: number;
|
||||
finishBidAmount?: number | null;
|
||||
availableLatestBidCloseDate?: string | null;
|
||||
}
|
||||
|
||||
@@ -85,11 +87,13 @@ const loadAuction = async (prisma: QueryClient, auctionId: number): Promise<Auct
|
||||
GamePrisma.sql`
|
||||
SELECT id,
|
||||
type,
|
||||
host_general_id as "hostGeneralId",
|
||||
detail,
|
||||
status,
|
||||
close_at as "closeAt"
|
||||
FROM auction
|
||||
WHERE id = ${auctionId}
|
||||
FOR UPDATE
|
||||
`
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
@@ -218,6 +222,22 @@ export const createAuctionBidder = async (options: {
|
||||
reason: '시작가보다 낮습니다.',
|
||||
};
|
||||
}
|
||||
if (!isReverse && detail.finishBidAmount != null && command.amount > detail.finishBidAmount) {
|
||||
return {
|
||||
type: 'auctionBid',
|
||||
ok: false,
|
||||
auctionId: command.auctionId,
|
||||
reason: '즉시판매가보다 높을 수 없습니다.',
|
||||
};
|
||||
}
|
||||
if (isReverse && detail.finishBidAmount != null && command.amount < detail.finishBidAmount) {
|
||||
return {
|
||||
type: 'auctionBid',
|
||||
ok: false,
|
||||
auctionId: command.auctionId,
|
||||
reason: '즉시판매가보다 낮을 수 없습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
if (auction.type === 'UNIQUE_ITEM' && highestBid) {
|
||||
if (command.amount < highestBid.amount * 1.01) {
|
||||
@@ -257,6 +277,14 @@ export const createAuctionBidder = async (options: {
|
||||
reason: '장수 정보를 찾을 수 없습니다.',
|
||||
};
|
||||
}
|
||||
if (auction.type !== 'UNIQUE_ITEM' && auction.hostGeneralId === general.id) {
|
||||
return {
|
||||
type: 'auctionBid',
|
||||
ok: false,
|
||||
auctionId: command.auctionId,
|
||||
reason: '자신이 연 경매에 입찰할 수 없습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
if (auction.type === 'BUY_RICE' && general.gold < morePoint) {
|
||||
return {
|
||||
@@ -296,12 +324,19 @@ export const createAuctionBidder = async (options: {
|
||||
const availableLatestBidCloseDate = detail.availableLatestBidCloseDate
|
||||
? new Date(detail.availableLatestBidCloseDate)
|
||||
: null;
|
||||
const nextCloseAt = extendCloseDate({
|
||||
let nextCloseAt = extendCloseDate({
|
||||
now,
|
||||
closeAt: auction.closeAt,
|
||||
turnMinutes,
|
||||
availableLatestBidCloseDate,
|
||||
});
|
||||
if (
|
||||
auction.type !== 'UNIQUE_ITEM' &&
|
||||
detail.finishBidAmount != null &&
|
||||
command.amount === detail.finishBidAmount
|
||||
) {
|
||||
nextCloseAt = new Date(now.getTime() + turnMinutes * 60_000);
|
||||
}
|
||||
|
||||
const eventId = randomUUID();
|
||||
const eventAt = now;
|
||||
@@ -347,51 +382,34 @@ export const createAuctionBidder = async (options: {
|
||||
if (!userId) {
|
||||
throw new Error('USER_NOT_FOUND');
|
||||
}
|
||||
const current = await tx.inheritancePoint.findUnique({
|
||||
where: {
|
||||
userId_key: {
|
||||
userId,
|
||||
key: 'previous',
|
||||
},
|
||||
},
|
||||
});
|
||||
const prevValue = current?.value ?? 0;
|
||||
if (prevValue < morePoint) {
|
||||
const deductedRows = await tx.$queryRaw<Array<{ value: number }>>(
|
||||
GamePrisma.sql`
|
||||
UPDATE inheritance_point
|
||||
SET value = value - ${morePoint},
|
||||
updated_at = ${eventAt}
|
||||
WHERE user_id = ${userId}
|
||||
AND key = 'previous'
|
||||
AND value >= ${morePoint}
|
||||
RETURNING value
|
||||
`
|
||||
);
|
||||
if (deductedRows.length === 0) {
|
||||
throw new Error('INSUFFICIENT_POINT');
|
||||
}
|
||||
await tx.inheritancePoint.upsert({
|
||||
where: {
|
||||
userId_key: {
|
||||
userId,
|
||||
key: 'previous',
|
||||
},
|
||||
},
|
||||
update: { value: prevValue - morePoint },
|
||||
create: { userId, key: 'previous', value: prevValue - morePoint },
|
||||
});
|
||||
|
||||
if (highestBid && highestBid.generalId !== command.generalId && !myPrevBid) {
|
||||
const prevUserId = await resolveUserId(tx, highestBid.generalId);
|
||||
if (prevUserId) {
|
||||
const prevPoint = await tx.inheritancePoint.findUnique({
|
||||
where: {
|
||||
userId_key: {
|
||||
userId: prevUserId,
|
||||
key: 'previous',
|
||||
},
|
||||
},
|
||||
});
|
||||
const nextValue = (prevPoint?.value ?? 0) + highestBid.amount;
|
||||
await tx.inheritancePoint.upsert({
|
||||
where: {
|
||||
userId_key: {
|
||||
userId: prevUserId,
|
||||
key: 'previous',
|
||||
},
|
||||
},
|
||||
update: { value: nextValue },
|
||||
create: { userId: prevUserId, key: 'previous', value: nextValue },
|
||||
});
|
||||
await tx.$executeRaw(
|
||||
GamePrisma.sql`
|
||||
INSERT INTO inheritance_point (user_id, key, value, updated_at)
|
||||
VALUES (${prevUserId}, 'previous', ${highestBid.amount}, ${eventAt})
|
||||
ON CONFLICT (user_id, key)
|
||||
DO UPDATE SET
|
||||
value = inheritance_point.value + EXCLUDED.value,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { createGamePostgresConnector, GamePrisma } from '@sammo-ts/infra';
|
||||
import { ActionLogger, ItemLoader, LogFormat, UserLogger, isItemKey } from '@sammo-ts/logic';
|
||||
import {
|
||||
ActionLogger,
|
||||
ItemLoader,
|
||||
LogFormat,
|
||||
UserLogger,
|
||||
isItemKey,
|
||||
resolveUniqueConfig,
|
||||
} from '@sammo-ts/logic';
|
||||
import { cloneItemInventory, ensureItemInventory, equipNewItem } from '@sammo-ts/logic/items/index.js';
|
||||
import { JosaUtil } from '@sammo-ts/common';
|
||||
import { asRecord, JosaUtil } from '@sammo-ts/common';
|
||||
|
||||
import type { TurnDaemonCommandResult } from '../lifecycle/types.js';
|
||||
import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js';
|
||||
@@ -17,6 +24,7 @@ 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;
|
||||
|
||||
interface AuctionRow {
|
||||
id: number;
|
||||
@@ -33,12 +41,15 @@ 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 {
|
||||
@@ -63,24 +74,6 @@ const resolveTurnMinutes = async (prisma: AuctionDb): Promise<number> => {
|
||||
return toTurnMinutes(rows[0]?.tickSeconds ?? 60);
|
||||
};
|
||||
|
||||
const extendCloseDate = (options: {
|
||||
now: Date;
|
||||
closeAt: Date;
|
||||
turnMinutes: number;
|
||||
availableLatestBidCloseDate?: Date | null;
|
||||
}): Date => {
|
||||
const { now, closeAt, turnMinutes, availableLatestBidCloseDate } = options;
|
||||
const extendMinutes = Math.max(MIN_EXTENSION_MINUTES_PER_BID, turnMinutes * COEFF_EXTENSION_MINUTES_PER_BID);
|
||||
const extended = new Date(now.getTime() + extendMinutes * 60 * 1000);
|
||||
if (extended.getTime() <= closeAt.getTime()) {
|
||||
return closeAt;
|
||||
}
|
||||
if (availableLatestBidCloseDate && extended.getTime() > availableLatestBidCloseDate.getTime()) {
|
||||
return availableLatestBidCloseDate;
|
||||
}
|
||||
return extended;
|
||||
};
|
||||
|
||||
const pushLogs = (world: InMemoryTurnWorld, logs: LogEntryDraft[]): void => {
|
||||
for (const log of logs) {
|
||||
world.pushLog(log);
|
||||
@@ -96,31 +89,16 @@ const refundInheritancePoint = async (options: {
|
||||
if (!userId || amount <= 0) {
|
||||
return;
|
||||
}
|
||||
const current = await prisma.inheritancePoint.findUnique({
|
||||
where: {
|
||||
userId_key: {
|
||||
userId,
|
||||
key: 'previous',
|
||||
},
|
||||
},
|
||||
});
|
||||
const nextValue = (current?.value ?? 0) + amount;
|
||||
await prisma.inheritancePoint.upsert({
|
||||
where: {
|
||||
userId_key: {
|
||||
userId,
|
||||
key: 'previous',
|
||||
},
|
||||
},
|
||||
update: {
|
||||
value: nextValue,
|
||||
},
|
||||
create: {
|
||||
userId,
|
||||
key: 'previous',
|
||||
value: nextValue,
|
||||
},
|
||||
});
|
||||
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: {
|
||||
@@ -186,14 +164,14 @@ export const createAuctionFinalizer = async (options: {
|
||||
const bidRows = await db.$queryRaw<AuctionBidRow[]>(
|
||||
isReverse
|
||||
? GamePrisma.sql`
|
||||
SELECT id, general_id as "generalId", amount
|
||||
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
|
||||
SELECT id, general_id as "generalId", amount, meta
|
||||
FROM auction_bid
|
||||
WHERE auction_id = ${auctionId}
|
||||
ORDER BY amount DESC, id ASC
|
||||
@@ -261,6 +239,43 @@ export const createAuctionFinalizer = async (options: {
|
||||
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},
|
||||
updated_at = ${now}
|
||||
WHERE id = ${auctionId}
|
||||
`
|
||||
);
|
||||
return {
|
||||
type: 'auctionFinalize',
|
||||
ok: false,
|
||||
auctionId,
|
||||
reason: '입찰자의 요청으로 경매 종료가 연장되었습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const bidder = world.getGeneralById(highestBid.generalId);
|
||||
if (!bidder) {
|
||||
await finalizeStatus('CANCELED');
|
||||
@@ -355,25 +370,102 @@ export const createAuctionFinalizer = async (options: {
|
||||
};
|
||||
}
|
||||
|
||||
const state = world.getState();
|
||||
const config = resolveUniqueConfig(asRecord(world.getScenarioConfig().const));
|
||||
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_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},
|
||||
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 availableLatestBidCloseDate = detail.availableLatestBidCloseDate
|
||||
? new Date(detail.availableLatestBidCloseDate)
|
||||
: null;
|
||||
const nextCloseAt = extendCloseDate({
|
||||
now,
|
||||
closeAt: auction.closeAt,
|
||||
turnMinutes,
|
||||
availableLatestBidCloseDate,
|
||||
});
|
||||
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},
|
||||
updated_at = ${now}
|
||||
WHERE id = ${auctionId}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import { createGamePostgresConnector, GamePrisma, type RedisConnector } from '@sammo-ts/infra';
|
||||
import { buildNeutralResourceAuctionPlan } from '@sammo-ts/logic';
|
||||
|
||||
import type { TurnCalendarHandler } from '../turn/inMemoryWorld.js';
|
||||
import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js';
|
||||
|
||||
interface NeutralAuctionCountRow {
|
||||
type: 'BUY_RICE' | 'SELL_RICE';
|
||||
count: bigint | number;
|
||||
}
|
||||
|
||||
interface TournamentState {
|
||||
stage?: unknown;
|
||||
}
|
||||
|
||||
const readFiniteNumber = (value: unknown, fallback: number): number => {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const average = (values: number[]): number => {
|
||||
if (values.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
return values.reduce((sum, value) => sum + value, 0) / values.length;
|
||||
};
|
||||
|
||||
const parseTournamentState = (raw: string | null): TournamentState | null => {
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
return asRecord(parsed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const isTournamentActive = async (
|
||||
profileName: string,
|
||||
redis: RedisConnector['client'] | null | undefined
|
||||
): Promise<boolean> => {
|
||||
if (!redis) {
|
||||
return false;
|
||||
}
|
||||
const state = parseTournamentState(await redis.get(`sammo:${profileName}:tournament:state`));
|
||||
return readFiniteNumber(state?.stage, 0) > 0;
|
||||
};
|
||||
|
||||
export interface NeutralAuctionRegistrar {
|
||||
handler: TurnCalendarHandler;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export const createNeutralAuctionRegistrar = async (options: {
|
||||
databaseUrl: string;
|
||||
profileName: string;
|
||||
getWorld: () => InMemoryTurnWorld | null;
|
||||
getRedisClient: () => RedisConnector['client'] | null | undefined;
|
||||
getWorldConfig: () => Record<string, unknown> | null | undefined;
|
||||
now?: () => Date;
|
||||
loadNeutralAuctionCounts?: () => Promise<NeutralAuctionCountRow[]>;
|
||||
loadTournamentActive?: () => Promise<boolean>;
|
||||
}): Promise<NeutralAuctionRegistrar> => {
|
||||
const connector = options.loadNeutralAuctionCounts
|
||||
? null
|
||||
: createGamePostgresConnector({ url: options.databaseUrl });
|
||||
await connector?.connect();
|
||||
const loadNeutralAuctionCounts =
|
||||
options.loadNeutralAuctionCounts ??
|
||||
(() =>
|
||||
connector!.prisma.$queryRaw<NeutralAuctionCountRow[]>(
|
||||
GamePrisma.sql`
|
||||
SELECT type, count(*) AS count
|
||||
FROM auction
|
||||
WHERE host_general_id = 0
|
||||
AND type IN ('BUY_RICE'::"AuctionType", 'SELL_RICE'::"AuctionType")
|
||||
GROUP BY type
|
||||
`
|
||||
));
|
||||
|
||||
const handler: TurnCalendarHandler = {
|
||||
onMonthChanged: async (context) => {
|
||||
const world = options.getWorld();
|
||||
if (!world) {
|
||||
return;
|
||||
}
|
||||
const state = world.getState();
|
||||
const hiddenSeed =
|
||||
typeof state.meta.hiddenSeed === 'string' || typeof state.meta.hiddenSeed === 'number'
|
||||
? state.meta.hiddenSeed
|
||||
: state.id;
|
||||
const eligibleGenerals = world.listGenerals().filter((general) => general.npcState < 2);
|
||||
const counts = await loadNeutralAuctionCounts();
|
||||
const countByType = new Map(counts.map((row) => [row.type, Number(row.count)]));
|
||||
for (const pending of world.peekDirtyState().pendingNeutralAuctions) {
|
||||
countByType.set(pending.type, (countByType.get(pending.type) ?? 0) + 1);
|
||||
}
|
||||
const worldConfig = asRecord(options.getWorldConfig() ?? {});
|
||||
const consumeTournamentRoll =
|
||||
worldConfig.tournamentTrig === true &&
|
||||
!(await (options.loadTournamentActive
|
||||
? options.loadTournamentActive()
|
||||
: isTournamentActive(options.profileName, options.getRedisClient())));
|
||||
const plans = buildNeutralResourceAuctionPlan({
|
||||
hiddenSeed,
|
||||
seedYear: context.previousYear,
|
||||
seedMonth: context.previousMonth,
|
||||
nationCount: world.listNations().length,
|
||||
consumeTournamentRoll,
|
||||
averageGold: average(eligibleGenerals.map((general) => general.gold)),
|
||||
averageRice: average(eligibleGenerals.map((general) => general.rice)),
|
||||
buyRiceAuctionCount: countByType.get('BUY_RICE') ?? 0,
|
||||
sellRiceAuctionCount: countByType.get('SELL_RICE') ?? 0,
|
||||
});
|
||||
|
||||
const registrationKey = `${context.currentYear}-${String(context.currentMonth).padStart(2, '0')}`;
|
||||
world.updateWorldMeta({ neutralAuctionRegistrationKey: registrationKey });
|
||||
const turnMinutes = Math.max(1, Math.round(state.tickSeconds / 60));
|
||||
for (const plan of plans) {
|
||||
const openedAt = options.now?.() ?? new Date();
|
||||
const hostResourceName = plan.auctionType === 'BUY_RICE' ? '쌀' : '금';
|
||||
world.queueNeutralAuction({
|
||||
registrationKey,
|
||||
type: plan.auctionType,
|
||||
targetCode: String(plan.amount),
|
||||
hostGeneralId: 0,
|
||||
hostName: '상인',
|
||||
detail: {
|
||||
title: `${hostResourceName} ${plan.amount} 경매`,
|
||||
hostName: '상인',
|
||||
amount: plan.amount,
|
||||
isReverse: false,
|
||||
startBidAmount: plan.startBidAmount,
|
||||
finishBidAmount: plan.finishBidAmount,
|
||||
neutralRegistrationKey: registrationKey,
|
||||
seedYear: context.previousYear,
|
||||
seedMonth: context.previousMonth,
|
||||
closeTurnCnt: plan.closeTurnCnt,
|
||||
},
|
||||
closeAt: new Date(openedAt.getTime() + plan.closeTurnCnt * turnMinutes * 60_000),
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
handler,
|
||||
close: async () => {
|
||||
await connector?.disconnect();
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,304 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { asRecord, JosaUtil } from '@sammo-ts/common';
|
||||
import { GamePrisma } from '@sammo-ts/infra';
|
||||
import {
|
||||
ActionLogger,
|
||||
ItemLoader,
|
||||
LogFormat,
|
||||
buildAuctionAlias,
|
||||
isItemKey,
|
||||
resolveUniqueConfig,
|
||||
} from '@sammo-ts/logic';
|
||||
|
||||
import type { TurnDaemonCommand, TurnDaemonCommandResult } from '../lifecycle/types.js';
|
||||
import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js';
|
||||
|
||||
type AuctionOpenCommand = Extract<TurnDaemonCommand, { type: 'auctionOpen' }>;
|
||||
|
||||
const MIN_AUCTION_AMOUNT = 100;
|
||||
const MAX_AUCTION_AMOUNT = 10_000;
|
||||
const MIN_AUCTION_CLOSE_MINUTES = 30;
|
||||
const COEFF_AUCTION_CLOSE_MINUTES = 24;
|
||||
const MIN_EXTENSION_MINUTES_LIMIT_BY_BID = 5;
|
||||
const COEFF_EXTENSION_MINUTES_LIMIT_BY_BID = 0.5;
|
||||
|
||||
const readNumber = (record: Record<string, unknown>, key: string, fallback: number): number => {
|
||||
const value = record[key];
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||
};
|
||||
|
||||
const getRelativeMonth = (world: InMemoryTurnWorld): number => {
|
||||
const state = world.getState();
|
||||
const meta = state.meta;
|
||||
const scenarioMeta = asRecord(meta.scenarioMeta);
|
||||
const initYear = readNumber(meta, 'initYear', readNumber(scenarioMeta, 'startYear', state.currentYear));
|
||||
const initMonth = readNumber(meta, 'initMonth', 1);
|
||||
return state.currentYear * 12 + state.currentMonth - (initYear * 12 + initMonth);
|
||||
};
|
||||
|
||||
const fail = (reason: string): TurnDaemonCommandResult => ({
|
||||
type: 'auctionOpen',
|
||||
ok: false,
|
||||
reason,
|
||||
});
|
||||
|
||||
const openResourceAuction = async (
|
||||
command: AuctionOpenCommand,
|
||||
world: InMemoryTurnWorld,
|
||||
db: GamePrisma.TransactionClient
|
||||
): Promise<TurnDaemonCommandResult> => {
|
||||
const general = world.getGeneralById(command.generalId);
|
||||
if (!general) {
|
||||
return fail('장수 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
const closeTurnCnt = command.closeTurnCnt ?? 0;
|
||||
const startBidAmount = command.startBidAmount ?? 0;
|
||||
const finishBidAmount = command.finishBidAmount ?? 0;
|
||||
if (closeTurnCnt < 1 || closeTurnCnt > 24) {
|
||||
return fail('종료기한은 1 ~ 24 턴 이어야 합니다.');
|
||||
}
|
||||
if (command.amount < MIN_AUCTION_AMOUNT || command.amount > MAX_AUCTION_AMOUNT) {
|
||||
return fail(`거래량은 ${MIN_AUCTION_AMOUNT} ~ ${MAX_AUCTION_AMOUNT} 이어야 합니다.`);
|
||||
}
|
||||
if (startBidAmount < command.amount * 0.5 || command.amount * 2 < startBidAmount) {
|
||||
return fail('시작거래가는 50% ~ 200% 이어야 합니다.');
|
||||
}
|
||||
if (finishBidAmount < command.amount * 1.1 || command.amount * 2 < finishBidAmount) {
|
||||
return fail('즉시거래가는 110% ~ 200% 이어야 합니다.');
|
||||
}
|
||||
if (finishBidAmount < startBidAmount * 1.1) {
|
||||
return fail('즉시거래가는 시작판매가의 110% 이상이어야 합니다.');
|
||||
}
|
||||
|
||||
await db.$executeRaw(GamePrisma.sql`SELECT pg_advisory_xact_lock(${command.generalId}, 41001)`);
|
||||
const previous = await db.auction.findFirst({
|
||||
where: {
|
||||
hostGeneralId: command.generalId,
|
||||
status: { in: ['OPEN', 'FINALIZING'] },
|
||||
type: { in: ['BUY_RICE', 'SELL_RICE'] },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (previous) {
|
||||
return fail('아직 경매가 끝나지 않았습니다.');
|
||||
}
|
||||
|
||||
const configConst = asRecord(world.getScenarioConfig().const);
|
||||
const hostResource = command.auctionType === 'BUY_RICE' ? 'rice' : 'gold';
|
||||
const minimumResource =
|
||||
hostResource === 'rice'
|
||||
? readNumber(configConst, 'generalMinimumRice', 500)
|
||||
: readNumber(configConst, 'generalMinimumGold', 0);
|
||||
if (general[hostResource] < command.amount + minimumResource) {
|
||||
return fail(`기본 ${hostResource === 'rice' ? '쌀' : '금'} ${minimumResource}은 거래할 수 없습니다.`);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const turnMinutes = Math.max(1, Math.round(world.getState().tickSeconds / 60));
|
||||
const closeAt = new Date(now.getTime() + closeTurnCnt * turnMinutes * 60_000);
|
||||
const auction = await db.auction.create({
|
||||
data: {
|
||||
type: command.auctionType,
|
||||
targetCode: String(command.amount),
|
||||
hostGeneralId: command.generalId,
|
||||
hostName: general.name,
|
||||
detail: {
|
||||
title: `${hostResource === 'rice' ? '쌀' : '금'} ${command.amount} 경매`,
|
||||
hostName: general.name,
|
||||
amount: command.amount,
|
||||
isReverse: false,
|
||||
startBidAmount,
|
||||
finishBidAmount,
|
||||
},
|
||||
status: 'OPEN',
|
||||
closeAt,
|
||||
},
|
||||
});
|
||||
world.updateGeneral(general.id, {
|
||||
[hostResource]: general[hostResource] - command.amount,
|
||||
});
|
||||
return {
|
||||
type: 'auctionOpen',
|
||||
ok: true,
|
||||
auctionId: auction.id,
|
||||
closeAt: closeAt.toISOString(),
|
||||
};
|
||||
};
|
||||
|
||||
const openUniqueAuction = async (
|
||||
command: AuctionOpenCommand,
|
||||
world: InMemoryTurnWorld,
|
||||
db: GamePrisma.TransactionClient
|
||||
): Promise<TurnDaemonCommandResult> => {
|
||||
const general = world.getGeneralById(command.generalId);
|
||||
if (!general) {
|
||||
return fail('장수 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
const itemKey = command.itemKey;
|
||||
if (!itemKey || !isItemKey(itemKey)) {
|
||||
return fail('아이템이 올바르지 않습니다.');
|
||||
}
|
||||
const configConst = asRecord(world.getScenarioConfig().const);
|
||||
const minimumPoint = readNumber(configConst, 'inheritItemUniqueMinPoint', 5000);
|
||||
if (command.amount < minimumPoint) {
|
||||
return fail(`최소 경매 금액은 ${minimumPoint}입니다.`);
|
||||
}
|
||||
|
||||
const item = await new ItemLoader().load(itemKey).catch(() => null);
|
||||
if (!item) {
|
||||
return fail('아이템 정보를 불러올 수 없습니다.');
|
||||
}
|
||||
if (item.buyable) {
|
||||
return fail('구매할 수 있는 아이템입니다.');
|
||||
}
|
||||
const currentSlotItem = general.role.items[item.slot];
|
||||
if (currentSlotItem && currentSlotItem !== 'None' && isItemKey(currentSlotItem)) {
|
||||
const currentItem = await new ItemLoader().load(currentSlotItem).catch(() => null);
|
||||
if (currentItem && !currentItem.buyable) {
|
||||
return fail('이미 가진 아이템이 있습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
await db.$executeRaw(GamePrisma.sql`SELECT pg_advisory_xact_lock(hashtext(${`auction:unique:item:${itemKey}`}))`);
|
||||
await db.$executeRaw(GamePrisma.sql`SELECT pg_advisory_xact_lock(${command.generalId}, 41002)`);
|
||||
const [sameItemAuction, previousHostAuction] = await Promise.all([
|
||||
db.auction.findFirst({
|
||||
where: {
|
||||
type: 'UNIQUE_ITEM',
|
||||
targetCode: itemKey,
|
||||
status: { in: ['OPEN', 'FINALIZING'] },
|
||||
},
|
||||
select: { id: true },
|
||||
}),
|
||||
db.auction.findFirst({
|
||||
where: {
|
||||
type: 'UNIQUE_ITEM',
|
||||
hostGeneralId: command.generalId,
|
||||
status: { in: ['OPEN', 'FINALIZING'] },
|
||||
},
|
||||
select: { id: true },
|
||||
}),
|
||||
]);
|
||||
if (sameItemAuction) {
|
||||
return fail('이미 경매가 진행중입니다.');
|
||||
}
|
||||
if (previousHostAuction) {
|
||||
return fail('아직 경매가 끝나지 않았습니다.');
|
||||
}
|
||||
|
||||
const uniqueConfig = resolveUniqueConfig(configConst);
|
||||
const configuredAmount = uniqueConfig.allItems[item.slot]?.[itemKey] ?? 0;
|
||||
const occupiedAmount = world
|
||||
.listGenerals()
|
||||
.filter((candidate) => candidate.role.items[item.slot] === itemKey).length;
|
||||
if (configuredAmount <= occupiedAmount) {
|
||||
return fail('그 유니크를 더 얻을 수 없습니다.');
|
||||
}
|
||||
|
||||
const generalRows = await db.$queryRaw<Array<{ userId: string | null }>>(
|
||||
GamePrisma.sql`SELECT user_id as "userId" FROM general WHERE id = ${command.generalId}`
|
||||
);
|
||||
const userId = generalRows[0]?.userId;
|
||||
if (!userId) {
|
||||
return fail('장수 소유자 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
const pointRows = await db.$queryRaw<Array<{ value: number }>>(
|
||||
GamePrisma.sql`
|
||||
SELECT value
|
||||
FROM inheritance_point
|
||||
WHERE user_id = ${userId} AND key = 'previous'
|
||||
FOR UPDATE
|
||||
`
|
||||
);
|
||||
const currentPoint = pointRows[0]?.value ?? 0;
|
||||
if (currentPoint < command.amount) {
|
||||
return fail('경매를 시작할 포인트가 부족합니다.');
|
||||
}
|
||||
|
||||
const state = world.getState();
|
||||
const turnMinutes = Math.max(1, Math.round(state.tickSeconds / 60));
|
||||
const now = new Date();
|
||||
const closeMinutes = Math.max(MIN_AUCTION_CLOSE_MINUTES, turnMinutes * COEFF_AUCTION_CLOSE_MINUTES);
|
||||
const closeAt = new Date(now.getTime() + closeMinutes * 60_000);
|
||||
const extensionLimitMinutes = Math.max(
|
||||
MIN_EXTENSION_MINUTES_LIMIT_BY_BID,
|
||||
turnMinutes * COEFF_EXTENSION_MINUTES_LIMIT_BY_BID
|
||||
);
|
||||
const availableLatestBidCloseDate = new Date(closeAt.getTime() + extensionLimitMinutes * 60_000);
|
||||
const hiddenSeed =
|
||||
typeof state.meta.hiddenSeed === 'string' || typeof state.meta.hiddenSeed === 'number'
|
||||
? state.meta.hiddenSeed
|
||||
: state.id;
|
||||
const alias = buildAuctionAlias(command.generalId, hiddenSeed, configConst);
|
||||
const eventId = randomUUID();
|
||||
const auction = await db.auction.create({
|
||||
data: {
|
||||
type: 'UNIQUE_ITEM',
|
||||
targetCode: itemKey,
|
||||
hostGeneralId: command.generalId,
|
||||
hostName: alias,
|
||||
detail: {
|
||||
title: `${item.name} 경매`,
|
||||
hostName: alias,
|
||||
amount: 1,
|
||||
isReverse: false,
|
||||
startBidAmount: command.amount,
|
||||
finishBidAmount: null,
|
||||
remainCloseDateExtensionCnt: 1,
|
||||
availableLatestBidCloseDate: availableLatestBidCloseDate.toISOString(),
|
||||
},
|
||||
status: 'OPEN',
|
||||
closeAt,
|
||||
latestEventId: eventId,
|
||||
latestEventAt: now,
|
||||
bids: {
|
||||
create: {
|
||||
generalId: command.generalId,
|
||||
amount: command.amount,
|
||||
eventId,
|
||||
eventAt: now,
|
||||
meta: { obfuscatedName: alias, tryExtendCloseDate: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await db.inheritancePoint.update({
|
||||
where: { userId_key: { userId, key: 'previous' } },
|
||||
data: { value: currentPoint - command.amount },
|
||||
});
|
||||
|
||||
const logger = new ActionLogger();
|
||||
const rawNameJosa = JosaUtil.pick(item.rawName, '라');
|
||||
logger.pushGlobalHistoryLog(
|
||||
`<C><b>【보물수배】</b></>누군가가 <C>${item.name}</>${rawNameJosa}는 보물을 구한다는 소문이 들려옵니다.`,
|
||||
LogFormat.PLAIN
|
||||
);
|
||||
for (const log of logger.flush()) {
|
||||
world.pushLog(log);
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'auctionOpen',
|
||||
ok: true,
|
||||
auctionId: auction.id,
|
||||
closeAt: closeAt.toISOString(),
|
||||
};
|
||||
};
|
||||
|
||||
export const openAuction = async (
|
||||
command: AuctionOpenCommand,
|
||||
world: InMemoryTurnWorld,
|
||||
db?: GamePrisma.TransactionClient
|
||||
): Promise<TurnDaemonCommandResult> => {
|
||||
if (!db) {
|
||||
return fail('경매 등록 트랜잭션이 준비되지 않았습니다.');
|
||||
}
|
||||
if (getRelativeMonth(world) < 3) {
|
||||
return fail('시작 후 3개월이 지나야 경매를 열 수 있습니다.');
|
||||
}
|
||||
if (command.auctionType === 'UNIQUE_ITEM') {
|
||||
return openUniqueAuction(command, world, db);
|
||||
}
|
||||
return openResourceAuction(command, world, db);
|
||||
};
|
||||
@@ -8,19 +8,19 @@ export const composeCalendarHandlers = (
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
beforeMonthChanged: (context) => {
|
||||
beforeMonthChanged: async (context) => {
|
||||
for (const handler of resolved) {
|
||||
handler.beforeMonthChanged?.(context);
|
||||
await handler.beforeMonthChanged?.(context);
|
||||
}
|
||||
},
|
||||
onMonthChanged: (context) => {
|
||||
onMonthChanged: async (context) => {
|
||||
for (const handler of resolved) {
|
||||
handler.onMonthChanged?.(context);
|
||||
await handler.onMonthChanged?.(context);
|
||||
}
|
||||
},
|
||||
onYearChanged: (context) => {
|
||||
onYearChanged: async (context) => {
|
||||
for (const handler of resolved) {
|
||||
handler.onYearChanged?.(context);
|
||||
await handler.onYearChanged?.(context);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -36,6 +36,17 @@ const zAuctionFinalize = z.object({
|
||||
auctionId: zFiniteNumber,
|
||||
});
|
||||
|
||||
const zAuctionOpen = z.object({
|
||||
type: z.literal('auctionOpen'),
|
||||
generalId: zFiniteNumber,
|
||||
auctionType: z.enum(['BUY_RICE', 'SELL_RICE', 'UNIQUE_ITEM']),
|
||||
amount: zFiniteNumber,
|
||||
closeTurnCnt: zFiniteNumber.optional(),
|
||||
startBidAmount: zFiniteNumber.optional(),
|
||||
finishBidAmount: zFiniteNumber.optional(),
|
||||
itemKey: z.string().optional(),
|
||||
});
|
||||
|
||||
const zAuctionBid = z.object({
|
||||
type: z.literal('auctionBid'),
|
||||
auctionId: zFiniteNumber,
|
||||
@@ -266,6 +277,14 @@ const normalizeAuctionFinalize: CommandNormalizer<'auctionFinalize'> = (envelope
|
||||
return { ...command, requestId: envelope.requestId };
|
||||
};
|
||||
|
||||
const normalizeAuctionOpen: CommandNormalizer<'auctionOpen'> = (envelope) => {
|
||||
const command = parseWith(zAuctionOpen, envelope.command);
|
||||
if (!command) {
|
||||
return null;
|
||||
}
|
||||
return { ...command, requestId: envelope.requestId };
|
||||
};
|
||||
|
||||
const normalizeAuctionBid: CommandNormalizer<'auctionBid'> = (envelope) => {
|
||||
const command = parseWith(zAuctionBid, envelope.command);
|
||||
if (!command) {
|
||||
@@ -488,6 +507,7 @@ const normalizeShutdown: CommandNormalizer<'shutdown'> = (envelope) => {
|
||||
|
||||
const normalizers: CommandNormalizerMap = {
|
||||
auctionFinalize: normalizeAuctionFinalize,
|
||||
auctionOpen: normalizeAuctionOpen,
|
||||
auctionBid: normalizeAuctionBid,
|
||||
troopCreate: normalizeTroopCreate,
|
||||
troopJoin: normalizeTroopJoin,
|
||||
|
||||
@@ -344,6 +344,7 @@ export const createDatabaseTurnHooks = async (
|
||||
createdDiplomacy,
|
||||
deletedEvents,
|
||||
lifecycleEvents,
|
||||
pendingNeutralAuctions,
|
||||
} = changes;
|
||||
const reservedTurnChanges = options?.reservedTurns?.peekDirtyState();
|
||||
|
||||
@@ -354,6 +355,28 @@ export const createDatabaseTurnHooks = async (
|
||||
meta: asJson(state.meta),
|
||||
};
|
||||
const persist = async (prisma: GamePrisma.TransactionClient): Promise<void> => {
|
||||
let neutralAuctionsToCreate = pendingNeutralAuctions;
|
||||
if (pendingNeutralAuctions.length > 0) {
|
||||
const latestRegistrationKey =
|
||||
pendingNeutralAuctions[pendingNeutralAuctions.length - 1]!.registrationKey;
|
||||
await prisma.$executeRaw`
|
||||
SELECT pg_advisory_xact_lock(
|
||||
hashtext(${'neutral-auction-registration'}),
|
||||
${state.id}
|
||||
)
|
||||
`;
|
||||
const persistedRows = await prisma.$queryRaw<Array<{ meta: unknown }>>`
|
||||
SELECT meta
|
||||
FROM world_state
|
||||
WHERE id = ${state.id}
|
||||
FOR UPDATE
|
||||
`;
|
||||
const persistedMeta = asRecord(persistedRows[0]?.meta);
|
||||
if (persistedMeta.neutralAuctionRegistrationKey === latestRegistrationKey) {
|
||||
neutralAuctionsToCreate = [];
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.worldState.update({
|
||||
where: { id: state.id },
|
||||
data: worldStateUpdate,
|
||||
@@ -436,6 +459,20 @@ export const createDatabaseTurnHooks = async (
|
||||
);
|
||||
}
|
||||
|
||||
if (neutralAuctionsToCreate.length > 0) {
|
||||
await prisma.auction.createMany({
|
||||
data: neutralAuctionsToCreate.map((auction) => ({
|
||||
type: auction.type,
|
||||
targetCode: auction.targetCode,
|
||||
hostGeneralId: auction.hostGeneralId,
|
||||
hostName: auction.hostName,
|
||||
detail: asJson(auction.detail),
|
||||
status: 'OPEN',
|
||||
closeAt: auction.closeAt,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
const createdIds = new Set(createdGenerals.map((general) => general.id));
|
||||
const createdNationIds = new Set(createdNations.map((nation) => nation.id));
|
||||
const createdTroopIds = new Set(createdTroops.map((troop) => troop.id));
|
||||
|
||||
@@ -96,7 +96,7 @@ export class InMemoryTurnProcessor implements TurnProcessor {
|
||||
partial = true;
|
||||
break;
|
||||
}
|
||||
this.world.advanceMonth(nextTickTime);
|
||||
await this.world.advanceMonth(nextTickTime);
|
||||
processedTurns += 1;
|
||||
nextTickTime = getNextTickTime(this.world.getState().lastTurnTime, this.tickMinutes);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,14 @@ import type { City, LogEntryDraft, MessageDraft, Nation, ScenarioConfig, Troop,
|
||||
import { getNextTurnAt } from '@sammo-ts/logic';
|
||||
|
||||
import type { TurnCheckpoint } from '../lifecycle/types.js';
|
||||
import type { TurnDiplomacy, TurnEvent, TurnGeneral, TurnWorldSnapshot, TurnWorldState } from './types.js';
|
||||
import type {
|
||||
PendingNeutralAuction,
|
||||
TurnDiplomacy,
|
||||
TurnEvent,
|
||||
TurnGeneral,
|
||||
TurnWorldSnapshot,
|
||||
TurnWorldState,
|
||||
} from './types.js';
|
||||
import {
|
||||
applyDiplomacyPatch as applyDiplomacyPatchToEntry,
|
||||
buildDefaultDiplomacy,
|
||||
@@ -73,9 +80,9 @@ export interface TurnCalendarContext {
|
||||
|
||||
export interface TurnCalendarHandler {
|
||||
// 레거시 PRE_MONTH는 날짜 변경 전, MONTH는 날짜 변경 후에 실행된다.
|
||||
beforeMonthChanged?(context: TurnCalendarContext): void;
|
||||
onMonthChanged?(context: TurnCalendarContext): void;
|
||||
onYearChanged?(context: TurnCalendarContext): void;
|
||||
beforeMonthChanged?(context: TurnCalendarContext): void | Promise<void>;
|
||||
onMonthChanged?(context: TurnCalendarContext): void | Promise<void>;
|
||||
onYearChanged?(context: TurnCalendarContext): void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface InMemoryTurnWorldOptions {
|
||||
@@ -102,6 +109,7 @@ export interface TurnWorldChanges {
|
||||
createdDiplomacy: TurnDiplomacy[];
|
||||
deletedEvents: number[];
|
||||
lifecycleEvents: GeneralLifecycleEvent[];
|
||||
pendingNeutralAuctions: PendingNeutralAuction[];
|
||||
}
|
||||
|
||||
const compareTurnOrder = (left: TurnGeneral, right: TurnGeneral): number => {
|
||||
@@ -270,6 +278,7 @@ export class InMemoryTurnWorld {
|
||||
private readonly logs: LogEntryDraft[] = [];
|
||||
private readonly messages: MessageDraft[] = [];
|
||||
private readonly lifecycleEvents: GeneralLifecycleEvent[] = [];
|
||||
private readonly pendingNeutralAuctions: PendingNeutralAuction[] = [];
|
||||
private readonly scenarioConfig: ScenarioConfig;
|
||||
private checkpoint?: TurnCheckpoint;
|
||||
private state: TurnWorldState;
|
||||
@@ -331,6 +340,14 @@ export class InMemoryTurnWorld {
|
||||
this.logs.push(entry);
|
||||
}
|
||||
|
||||
queueNeutralAuction(auction: PendingNeutralAuction): void {
|
||||
this.pendingNeutralAuctions.push({
|
||||
...auction,
|
||||
detail: { ...auction.detail },
|
||||
closeAt: new Date(auction.closeAt.getTime()),
|
||||
});
|
||||
}
|
||||
|
||||
getScenarioConfig(): ScenarioConfig {
|
||||
return this.scenarioConfig;
|
||||
}
|
||||
@@ -744,7 +761,7 @@ export class InMemoryTurnWorld {
|
||||
return nextTurnAt;
|
||||
}
|
||||
|
||||
advanceMonth(turnTime: Date): void {
|
||||
async advanceMonth(turnTime: Date): Promise<void> {
|
||||
const previousYear = this.state.currentYear;
|
||||
const previousMonth = this.state.currentMonth;
|
||||
let nextYear = previousYear;
|
||||
@@ -761,7 +778,7 @@ export class InMemoryTurnWorld {
|
||||
currentMonth: nextMonth,
|
||||
turnTime,
|
||||
};
|
||||
this.calendarHandler?.beforeMonthChanged?.(context);
|
||||
await this.calendarHandler?.beforeMonthChanged?.(context);
|
||||
|
||||
const meta = {
|
||||
...this.state.meta,
|
||||
@@ -776,9 +793,9 @@ export class InMemoryTurnWorld {
|
||||
};
|
||||
|
||||
this.advanceDiplomacyMonth();
|
||||
this.calendarHandler?.onMonthChanged?.(context);
|
||||
await this.calendarHandler?.onMonthChanged?.(context);
|
||||
if (nextYear !== previousYear) {
|
||||
this.calendarHandler?.onYearChanged?.(context);
|
||||
await this.calendarHandler?.onYearChanged?.(context);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -818,6 +835,11 @@ export class InMemoryTurnWorld {
|
||||
const logs = this.logs.slice();
|
||||
const messages = this.messages.slice();
|
||||
const lifecycleEvents = this.lifecycleEvents.slice();
|
||||
const pendingNeutralAuctions = this.pendingNeutralAuctions.map((auction) => ({
|
||||
...auction,
|
||||
detail: { ...auction.detail },
|
||||
closeAt: new Date(auction.closeAt.getTime()),
|
||||
}));
|
||||
|
||||
return {
|
||||
generals,
|
||||
@@ -837,6 +859,7 @@ export class InMemoryTurnWorld {
|
||||
createdDiplomacy,
|
||||
deletedEvents,
|
||||
lifecycleEvents,
|
||||
pendingNeutralAuctions,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -862,6 +885,7 @@ export class InMemoryTurnWorld {
|
||||
this.logs.splice(0, changes.logs.length);
|
||||
this.messages.splice(0, changes.messages.length);
|
||||
this.lifecycleEvents.splice(0, changes.lifecycleEvents.length);
|
||||
this.pendingNeutralAuctions.splice(0, changes.pendingNeutralAuctions.length);
|
||||
}
|
||||
|
||||
consumeDirtyState(): TurnWorldChanges {
|
||||
|
||||
@@ -30,6 +30,7 @@ import { shouldUseAi } from './ai/generalAi.js';
|
||||
import { createUnificationHandler } from './unificationHandler.js';
|
||||
import { createAuctionFinalizer } from '../auction/finalizer.js';
|
||||
import { createAuctionBidder } from '../auction/bidder.js';
|
||||
import { createNeutralAuctionRegistrar } from '../auction/neutralRegistrar.js';
|
||||
import { createTournamentRewardFinalizer } from '../tournament/finalizer.js';
|
||||
import { createTournamentAutoStartHandler } from './tournamentAutoStart.js';
|
||||
import { createYearbookHandler } from './yearbookHandler.js';
|
||||
@@ -198,6 +199,13 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
|
||||
getWorld: () => worldRef,
|
||||
map: snapshot.map ?? null,
|
||||
});
|
||||
const neutralAuctionRegistrar = await createNeutralAuctionRegistrar({
|
||||
databaseUrl: options.databaseUrl,
|
||||
profileName: options.profileName ?? options.profile,
|
||||
getWorld: () => worldRef,
|
||||
getRedisClient: () => redisConnector?.client,
|
||||
getWorldConfig: () => snapshot.worldConfig ?? null,
|
||||
});
|
||||
const tournamentAutoStartHandler = createTournamentAutoStartHandler({
|
||||
profileName: options.profileName ?? options.profile,
|
||||
getRedisClient: () => redisConnector?.client,
|
||||
@@ -215,6 +223,7 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
|
||||
nationTurnMonthlyHandler,
|
||||
hasEventAction('ProcessIncome') ? null : incomeHandler,
|
||||
frontStateHandler,
|
||||
neutralAuctionRegistrar.handler,
|
||||
tournamentAutoStartHandler,
|
||||
yearbookHandler.handler
|
||||
);
|
||||
@@ -394,6 +403,7 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
|
||||
const baseClose = close;
|
||||
close = async () => {
|
||||
await baseClose();
|
||||
await neutralAuctionRegistrar.close();
|
||||
if (unification) {
|
||||
await unification.close();
|
||||
}
|
||||
|
||||
@@ -49,6 +49,16 @@ export interface TurnEvent {
|
||||
meta: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface PendingNeutralAuction {
|
||||
registrationKey: string;
|
||||
type: 'BUY_RICE' | 'SELL_RICE';
|
||||
targetCode: string;
|
||||
hostGeneralId: 0;
|
||||
hostName: '상인';
|
||||
detail: Record<string, unknown>;
|
||||
closeAt: Date;
|
||||
}
|
||||
|
||||
export interface TurnWorldSnapshot extends Omit<
|
||||
WorldSnapshot,
|
||||
'generals' | 'cities' | 'nations' | 'troops' | 'diplomacy'
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from '@sammo-ts/logic/items/index.js';
|
||||
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
|
||||
import type { TurnGeneral } from './types.js';
|
||||
import { openAuction } from '../auction/opener.js';
|
||||
|
||||
let itemRegistryPromise: Promise<Map<string, ItemModule>> | null = null;
|
||||
|
||||
@@ -830,6 +831,13 @@ async function handleAuctionFinalize(
|
||||
return ctx.auctionFinalizer.finalize(command.auctionId, ctx.commandDb);
|
||||
}
|
||||
|
||||
async function handleAuctionOpen(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'auctionOpen' }>
|
||||
): Promise<TurnDaemonCommandResult> {
|
||||
return openAuction(command, ctx.world, ctx.commandDb);
|
||||
}
|
||||
|
||||
async function handleAuctionBid(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'auctionBid' }>
|
||||
@@ -1340,6 +1348,8 @@ export const createTurnDaemonCommandHandler = (options: {
|
||||
dropItem: (command) => handleDropItem(ctx, command as Extract<TurnDaemonCommand, { type: 'dropItem' }>),
|
||||
auctionFinalize: (command) =>
|
||||
handleAuctionFinalize(ctx, command as Extract<TurnDaemonCommand, { type: 'auctionFinalize' }>),
|
||||
auctionOpen: (command) =>
|
||||
handleAuctionOpen(ctx, command as Extract<TurnDaemonCommand, { type: 'auctionOpen' }>),
|
||||
auctionBid: (command) => handleAuctionBid(ctx, command as Extract<TurnDaemonCommand, { type: 'auctionBid' }>),
|
||||
changePermission: (command) =>
|
||||
handleChangePermission(ctx, command as Extract<TurnDaemonCommand, { type: 'changePermission' }>),
|
||||
|
||||
Reference in New Issue
Block a user