Merge origin/main into frontend parity worktree
This commit is contained in:
@@ -0,0 +1,44 @@
|
|||||||
|
import { TRPCError } from '@trpc/server';
|
||||||
|
|
||||||
|
import type { GameApiContext } from '../context.js';
|
||||||
|
import { buildAuctionTimerKeys } from './keys.js';
|
||||||
|
|
||||||
|
export type OpenAuctionInput =
|
||||||
|
| {
|
||||||
|
auctionType: 'BUY_RICE' | 'SELL_RICE';
|
||||||
|
amount: number;
|
||||||
|
closeTurnCnt: number;
|
||||||
|
startBidAmount: number;
|
||||||
|
finishBidAmount: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
auctionType: 'UNIQUE_ITEM';
|
||||||
|
amount: number;
|
||||||
|
itemKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const openAuctionWithDaemon = async (
|
||||||
|
ctx: GameApiContext,
|
||||||
|
generalId: number,
|
||||||
|
input: OpenAuctionInput
|
||||||
|
): Promise<{ auctionId: number; closeAt: string }> => {
|
||||||
|
const result = await ctx.turnDaemon.requestCommand({
|
||||||
|
type: 'auctionOpen',
|
||||||
|
generalId,
|
||||||
|
...input,
|
||||||
|
});
|
||||||
|
if (!result || result.type !== 'auctionOpen') {
|
||||||
|
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
||||||
|
}
|
||||||
|
if (!result.ok) {
|
||||||
|
throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason });
|
||||||
|
}
|
||||||
|
|
||||||
|
const timerKeys = buildAuctionTimerKeys(ctx.profile.name);
|
||||||
|
const closeAt = new Date(result.closeAt);
|
||||||
|
await ctx.redis.zAdd(timerKeys.timerKey, [{ score: closeAt.getTime(), value: String(result.auctionId) }]);
|
||||||
|
return {
|
||||||
|
auctionId: result.auctionId,
|
||||||
|
closeAt: result.closeAt,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -6,6 +6,9 @@ import type { DatabaseClient, GameApiContext, GeneralRow } from '../../context.j
|
|||||||
import { buildAuctionTimerKeys } from '../../auction/keys.js';
|
import { buildAuctionTimerKeys } from '../../auction/keys.js';
|
||||||
import { GamePrisma } from '@sammo-ts/infra';
|
import { GamePrisma } from '@sammo-ts/infra';
|
||||||
import { ItemLoader, isItemKey } from '@sammo-ts/logic';
|
import { ItemLoader, isItemKey } from '@sammo-ts/logic';
|
||||||
|
import { asRecord } from '@sammo-ts/common';
|
||||||
|
import { buildAuctionAlias } from '@sammo-ts/logic';
|
||||||
|
import { openAuctionWithDaemon } from '../../auction/open.js';
|
||||||
|
|
||||||
|
|
||||||
const zBidInput = z.object({
|
const zBidInput = z.object({
|
||||||
@@ -17,6 +20,18 @@ const zUniqueBidInput = zBidInput.extend({
|
|||||||
tryExtendCloseDate: z.boolean().optional(),
|
tryExtendCloseDate: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const zOpenResourceInput = z.object({
|
||||||
|
amount: z.number().int().min(100).max(10_000),
|
||||||
|
closeTurnCnt: z.number().int().min(1).max(24),
|
||||||
|
startBidAmount: z.number().int().positive(),
|
||||||
|
finishBidAmount: z.number().int().positive(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const zOpenUniqueInput = z.object({
|
||||||
|
itemKey: z.string(),
|
||||||
|
amount: z.number().int().positive(),
|
||||||
|
});
|
||||||
|
|
||||||
type AuctionType = 'BUY_RICE' | 'SELL_RICE' | 'UNIQUE_ITEM';
|
type AuctionType = 'BUY_RICE' | 'SELL_RICE' | 'UNIQUE_ITEM';
|
||||||
|
|
||||||
interface AuctionRow {
|
interface AuctionRow {
|
||||||
@@ -29,7 +44,7 @@ interface AuctionRow {
|
|||||||
closeAt: Date;
|
closeAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuctionDetail {
|
export interface AuctionDetail {
|
||||||
title?: string;
|
title?: string;
|
||||||
amount?: number;
|
amount?: number;
|
||||||
isReverse?: boolean;
|
isReverse?: boolean;
|
||||||
@@ -161,6 +176,169 @@ const shouldUsePrevBid = (highestBid: AuctionBidRow | null, myPrevBid: AuctionBi
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const auctionRouter = router({
|
export const auctionRouter = router({
|
||||||
|
getOverview: authedProcedure.query(async ({ ctx }) => {
|
||||||
|
const auth = requireAuth(ctx);
|
||||||
|
const general = await ensureGeneral(ctx.db, auth.user.id);
|
||||||
|
const [auctions, worldState, point, recentLogs] = await Promise.all([
|
||||||
|
ctx.db.auction.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ type: { in: ['BUY_RICE', 'SELL_RICE'] }, status: 'OPEN' },
|
||||||
|
{ type: 'UNIQUE_ITEM' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
orderBy: [{ status: 'asc' }, { id: 'desc' }],
|
||||||
|
take: 120,
|
||||||
|
include: {
|
||||||
|
bids: {
|
||||||
|
orderBy: [{ amount: 'desc' }, { id: 'asc' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
ctx.db.worldState.findFirst(),
|
||||||
|
ctx.db.inheritancePoint.findUnique({
|
||||||
|
where: { userId_key: { userId: auth.user.id, key: 'previous' } },
|
||||||
|
}),
|
||||||
|
ctx.db.logEntry.findMany({
|
||||||
|
where: { text: { contains: '경매' } },
|
||||||
|
orderBy: { id: 'desc' },
|
||||||
|
take: 20,
|
||||||
|
select: { id: true, text: true, createdAt: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const generalIds = new Set<number>();
|
||||||
|
for (const auction of auctions) {
|
||||||
|
if (auction.type !== 'UNIQUE_ITEM' && auction.hostGeneralId > 0) {
|
||||||
|
generalIds.add(auction.hostGeneralId);
|
||||||
|
}
|
||||||
|
for (const bid of auction.bids) {
|
||||||
|
if (auction.type !== 'UNIQUE_ITEM') {
|
||||||
|
generalIds.add(bid.generalId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const names = new Map(
|
||||||
|
(
|
||||||
|
await ctx.db.general.findMany({
|
||||||
|
where: { id: { in: [...generalIds] } },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
})
|
||||||
|
).map((row) => [row.id, row.name])
|
||||||
|
);
|
||||||
|
const worldMeta = asRecord(worldState?.meta);
|
||||||
|
const configConst = asRecord(asRecord(worldState?.config).const);
|
||||||
|
const hiddenSeed =
|
||||||
|
typeof worldMeta.hiddenSeed === 'string' || typeof worldMeta.hiddenSeed === 'number'
|
||||||
|
? worldMeta.hiddenSeed
|
||||||
|
: worldState?.id ?? 0;
|
||||||
|
const callerAlias = buildAuctionAlias(general.id, hiddenSeed, configConst);
|
||||||
|
|
||||||
|
const mapped = auctions.map((auction) => {
|
||||||
|
const detail = parseDetail(auction.detail);
|
||||||
|
const highestBid = auction.bids[0] ?? null;
|
||||||
|
const isUnique = auction.type === 'UNIQUE_ITEM';
|
||||||
|
return {
|
||||||
|
id: auction.id,
|
||||||
|
type: auction.type,
|
||||||
|
targetCode: auction.targetCode,
|
||||||
|
status: auction.status,
|
||||||
|
hostGeneralId: isUnique ? null : auction.hostGeneralId,
|
||||||
|
hostName: isUnique
|
||||||
|
? auction.hostName ?? buildAuctionAlias(auction.hostGeneralId, hiddenSeed, configConst)
|
||||||
|
: auction.hostName ?? names.get(auction.hostGeneralId) ?? '상인',
|
||||||
|
isCallerHost: auction.hostGeneralId === general.id,
|
||||||
|
closeAt: auction.closeAt.toISOString(),
|
||||||
|
detail,
|
||||||
|
highestBid: highestBid
|
||||||
|
? {
|
||||||
|
amount: highestBid.amount,
|
||||||
|
bidderName: isUnique
|
||||||
|
? buildAuctionAlias(highestBid.generalId, hiddenSeed, configConst)
|
||||||
|
: names.get(highestBid.generalId) ?? '상인',
|
||||||
|
isCaller: highestBid.generalId === general.id,
|
||||||
|
eventAt: highestBid.eventAt.toISOString(),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
resourceAuctions: mapped.filter((auction) => auction.type !== 'UNIQUE_ITEM'),
|
||||||
|
uniqueAuctions: mapped.filter((auction) => auction.type === 'UNIQUE_ITEM'),
|
||||||
|
callerAlias,
|
||||||
|
remainPoint: point?.value ?? 0,
|
||||||
|
recentLogs: recentLogs.map((log) => ({
|
||||||
|
id: log.id,
|
||||||
|
text: log.text,
|
||||||
|
createdAt: log.createdAt.toISOString(),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
getUniqueDetail: authedProcedure
|
||||||
|
.input(z.object({ auctionId: z.number().int().positive() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const auth = requireAuth(ctx);
|
||||||
|
const general = await ensureGeneral(ctx.db, auth.user.id);
|
||||||
|
const [auction, worldState, point] = await Promise.all([
|
||||||
|
ctx.db.auction.findFirst({
|
||||||
|
where: { id: input.auctionId, type: 'UNIQUE_ITEM' },
|
||||||
|
include: { bids: { orderBy: [{ amount: 'desc' }, { id: 'asc' }] } },
|
||||||
|
}),
|
||||||
|
ctx.db.worldState.findFirst(),
|
||||||
|
ctx.db.inheritancePoint.findUnique({
|
||||||
|
where: { userId_key: { userId: auth.user.id, key: 'previous' } },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
if (!auction) {
|
||||||
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Auction not found.' });
|
||||||
|
}
|
||||||
|
const worldMeta = asRecord(worldState?.meta);
|
||||||
|
const configConst = asRecord(asRecord(worldState?.config).const);
|
||||||
|
const hiddenSeed =
|
||||||
|
typeof worldMeta.hiddenSeed === 'string' || typeof worldMeta.hiddenSeed === 'number'
|
||||||
|
? worldMeta.hiddenSeed
|
||||||
|
: worldState?.id ?? 0;
|
||||||
|
return {
|
||||||
|
auction: {
|
||||||
|
id: auction.id,
|
||||||
|
targetCode: auction.targetCode,
|
||||||
|
status: auction.status,
|
||||||
|
hostName:
|
||||||
|
auction.hostName ?? buildAuctionAlias(auction.hostGeneralId, hiddenSeed, configConst),
|
||||||
|
isCallerHost: auction.hostGeneralId === general.id,
|
||||||
|
closeAt: auction.closeAt.toISOString(),
|
||||||
|
detail: parseDetail(auction.detail),
|
||||||
|
},
|
||||||
|
bids: auction.bids.map((bid) => ({
|
||||||
|
id: bid.id,
|
||||||
|
amount: bid.amount,
|
||||||
|
bidderName: buildAuctionAlias(bid.generalId, hiddenSeed, configConst),
|
||||||
|
isCaller: bid.generalId === general.id,
|
||||||
|
eventAt: bid.eventAt.toISOString(),
|
||||||
|
})),
|
||||||
|
callerAlias: buildAuctionAlias(general.id, hiddenSeed, configConst),
|
||||||
|
remainPoint: point?.value ?? 0,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
openBuyRice: authedProcedure.input(zOpenResourceInput).mutation(async ({ ctx, input }) => {
|
||||||
|
const auth = requireAuth(ctx);
|
||||||
|
const general = await ensureGeneral(ctx.db, auth.user.id);
|
||||||
|
return openAuctionWithDaemon(ctx, general.id, { auctionType: 'BUY_RICE', ...input });
|
||||||
|
}),
|
||||||
|
openSellRice: authedProcedure.input(zOpenResourceInput).mutation(async ({ ctx, input }) => {
|
||||||
|
const auth = requireAuth(ctx);
|
||||||
|
const general = await ensureGeneral(ctx.db, auth.user.id);
|
||||||
|
return openAuctionWithDaemon(ctx, general.id, { auctionType: 'SELL_RICE', ...input });
|
||||||
|
}),
|
||||||
|
openUnique: authedProcedure.input(zOpenUniqueInput).mutation(async ({ ctx, input }) => {
|
||||||
|
const auth = requireAuth(ctx);
|
||||||
|
const general = await ensureGeneral(ctx.db, auth.user.id);
|
||||||
|
return openAuctionWithDaemon(ctx, general.id, {
|
||||||
|
auctionType: 'UNIQUE_ITEM',
|
||||||
|
itemKey: input.itemKey,
|
||||||
|
amount: input.amount,
|
||||||
|
});
|
||||||
|
}),
|
||||||
bidBuyRice: authedProcedure.input(zBidInput).mutation(async ({ ctx, input }) => {
|
bidBuyRice: authedProcedure.input(zBidInput).mutation(async ({ ctx, input }) => {
|
||||||
const auth = requireAuth(ctx);
|
const auth = requireAuth(ctx);
|
||||||
const general = await ensureGeneral(ctx.db, auth.user.id);
|
const general = await ensureGeneral(ctx.db, auth.user.id);
|
||||||
@@ -176,6 +354,9 @@ export const auctionRouter = router({
|
|||||||
if (auction.closeAt <= now) {
|
if (auction.closeAt <= now) {
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '경매가 종료되었습니다.' });
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '경매가 종료되었습니다.' });
|
||||||
}
|
}
|
||||||
|
if (auction.hostGeneralId === general.id) {
|
||||||
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '자신이 연 경매에 입찰할 수 없습니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
const detail = parseDetail(auction.detail);
|
const detail = parseDetail(auction.detail);
|
||||||
const amount = detail.amount ?? 0;
|
const amount = detail.amount ?? 0;
|
||||||
@@ -183,6 +364,9 @@ export const auctionRouter = router({
|
|||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '거래량 정보가 없습니다.' });
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '거래량 정보가 없습니다.' });
|
||||||
}
|
}
|
||||||
const isReverse = detail.isReverse === true;
|
const isReverse = detail.isReverse === true;
|
||||||
|
if (!isReverse && detail.finishBidAmount != null && input.amount > detail.finishBidAmount) {
|
||||||
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '즉시판매가보다 높을 수 없습니다.' });
|
||||||
|
}
|
||||||
const highestBid = await loadHighestBid(ctx.db, auction.id, isReverse);
|
const highestBid = await loadHighestBid(ctx.db, auction.id, isReverse);
|
||||||
const myPrevBidRaw = await loadMyPrevBid(ctx.db, auction.id, general.id, isReverse);
|
const myPrevBidRaw = await loadMyPrevBid(ctx.db, auction.id, general.id, isReverse);
|
||||||
const myPrevBid = shouldUsePrevBid(highestBid, myPrevBidRaw);
|
const myPrevBid = shouldUsePrevBid(highestBid, myPrevBidRaw);
|
||||||
@@ -241,6 +425,9 @@ export const auctionRouter = router({
|
|||||||
if (auction.closeAt <= now) {
|
if (auction.closeAt <= now) {
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '경매가 종료되었습니다.' });
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '경매가 종료되었습니다.' });
|
||||||
}
|
}
|
||||||
|
if (auction.hostGeneralId === general.id) {
|
||||||
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '자신이 연 경매에 입찰할 수 없습니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
const detail = parseDetail(auction.detail);
|
const detail = parseDetail(auction.detail);
|
||||||
const amount = detail.amount ?? 0;
|
const amount = detail.amount ?? 0;
|
||||||
@@ -248,6 +435,9 @@ export const auctionRouter = router({
|
|||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '거래량 정보가 없습니다.' });
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '거래량 정보가 없습니다.' });
|
||||||
}
|
}
|
||||||
const isReverse = detail.isReverse === true;
|
const isReverse = detail.isReverse === true;
|
||||||
|
if (!isReverse && detail.finishBidAmount != null && input.amount > detail.finishBidAmount) {
|
||||||
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '즉시판매가보다 높을 수 없습니다.' });
|
||||||
|
}
|
||||||
const highestBid = await loadHighestBid(ctx.db, auction.id, isReverse);
|
const highestBid = await loadHighestBid(ctx.db, auction.id, isReverse);
|
||||||
const myPrevBidRaw = await loadMyPrevBid(ctx.db, auction.id, general.id, isReverse);
|
const myPrevBidRaw = await loadMyPrevBid(ctx.db, auction.id, general.id, isReverse);
|
||||||
const myPrevBid = shouldUsePrevBid(highestBid, myPrevBidRaw);
|
const myPrevBid = shouldUsePrevBid(highestBid, myPrevBidRaw);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
writeUserStateMeta,
|
writeUserStateMeta,
|
||||||
} from '../../services/inheritance.js';
|
} from '../../services/inheritance.js';
|
||||||
import type { GameApiContext, WorldStateRow } from '../../context.js';
|
import type { GameApiContext, WorldStateRow } from '../../context.js';
|
||||||
|
import { openAuctionWithDaemon } from '../../auction/open.js';
|
||||||
|
|
||||||
const BUFF_KEYS: InheritBuffType[] = [
|
const BUFF_KEYS: InheritBuffType[] = [
|
||||||
'warAvoidRatio',
|
'warAvoidRatio',
|
||||||
@@ -749,43 +750,19 @@ export const inheritRouter = router({
|
|||||||
if (input.amount < inheritConst.inheritItemUniqueMinPoint) {
|
if (input.amount < inheritConst.inheritItemUniqueMinPoint) {
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '입찰 포인트가 부족합니다.' });
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '입찰 포인트가 부족합니다.' });
|
||||||
}
|
}
|
||||||
const currentPoint = await readInheritancePoint(ctx.db, userId, 'previous');
|
|
||||||
if (currentPoint < input.amount) {
|
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '유산 포인트가 부족합니다.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const general = await ctx.db.general.findFirst({
|
const general = await ctx.db.general.findFirst({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
select: { id: true, meta: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
if (!general) {
|
if (!general) {
|
||||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '장수가 존재하지 않습니다.' });
|
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '장수가 존재하지 않습니다.' });
|
||||||
}
|
}
|
||||||
const meta = asRecord(general.meta);
|
const result = await openAuctionWithDaemon(ctx, general.id, {
|
||||||
if (meta.inheritSpecificUnique) {
|
auctionType: 'UNIQUE_ITEM',
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 유니크 경매 신청이 있습니다.' });
|
itemKey: input.itemId,
|
||||||
}
|
amount: input.amount,
|
||||||
|
|
||||||
await patchGeneral(ctx, general.id, {
|
|
||||||
meta: {
|
|
||||||
...meta,
|
|
||||||
inheritSpecificUnique: JSON.stringify({
|
|
||||||
itemId: input.itemId,
|
|
||||||
amount: input.amount,
|
|
||||||
requestedAt: new Date().toISOString(),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
return { ok: true, ...result };
|
||||||
await setInheritancePoint(ctx.db, userId, 'previous', currentPoint - input.amount);
|
|
||||||
await appendInheritanceLog(
|
|
||||||
ctx.db,
|
|
||||||
userId,
|
|
||||||
worldState.currentYear,
|
|
||||||
worldState.currentMonth,
|
|
||||||
`${input.amount} 포인트로 유니크 경매 신청`
|
|
||||||
);
|
|
||||||
return { ok: true };
|
|
||||||
}),
|
}),
|
||||||
checkOwner: authedProcedure
|
checkOwner: authedProcedure
|
||||||
.input(
|
.input(
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { TRPCError } from '@trpc/server';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { randomBytes } from 'node:crypto';
|
import { randomBytes } from 'node:crypto';
|
||||||
|
|
||||||
import type { WorldStateRow } from '../../context.js';
|
import type { DatabaseClient, WorldStateRow } from '../../context.js';
|
||||||
import { authedProcedure, router } from '../../trpc.js';
|
import { authedProcedure, router } from '../../trpc.js';
|
||||||
import { asNumber, asRecord, asStringArray, LiteHashDRBG } from '@sammo-ts/common';
|
import { asNumber, asRecord, asStringArray, LiteHashDRBG } from '@sammo-ts/common';
|
||||||
import {
|
import {
|
||||||
@@ -361,7 +361,7 @@ export const joinRouter = router({
|
|||||||
? input.character
|
? input.character
|
||||||
: 'None';
|
: 'None';
|
||||||
|
|
||||||
return ctx.db.$transaction!(async (db) => {
|
const createGeneral = async (db: DatabaseClient) => {
|
||||||
const existing = await db.general.findFirst({ where: { userId } });
|
const existing = await db.general.findFirst({ where: { userId } });
|
||||||
if (existing) {
|
if (existing) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
@@ -514,6 +514,7 @@ export const joinRouter = router({
|
|||||||
meta: {
|
meta: {
|
||||||
createdBy: 'join',
|
createdBy: 'join',
|
||||||
ownerName: ctx.auth?.user.displayName ?? '',
|
ownerName: ctx.auth?.user.displayName ?? '',
|
||||||
|
killturn: 24,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -538,7 +539,9 @@ export const joinRouter = router({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { ok: true, generalId: general.id };
|
return { ok: true, generalId: general.id };
|
||||||
});
|
};
|
||||||
|
|
||||||
|
return ctx.db.$transaction ? ctx.db.$transaction(createGeneral) : createGeneral(ctx.db);
|
||||||
}),
|
}),
|
||||||
listPossessCandidates: authedProcedure
|
listPossessCandidates: authedProcedure
|
||||||
.input(
|
.input(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { asNumber, asRecord } from '@sammo-ts/common';
|
import { asNumber, asRecord } from '@sammo-ts/common';
|
||||||
|
import { GamePrisma } from '@sammo-ts/infra';
|
||||||
import type { DatabaseClient, WorldStateRow, InputJsonValue } from '../context.js';
|
import type { DatabaseClient, WorldStateRow, InputJsonValue } from '../context.js';
|
||||||
|
|
||||||
export type InheritPointKey =
|
export type InheritPointKey =
|
||||||
@@ -116,18 +117,15 @@ export const readInheritancePoint = async (
|
|||||||
userId: string,
|
userId: string,
|
||||||
key: InheritPointKey
|
key: InheritPointKey
|
||||||
): Promise<number> => {
|
): Promise<number> => {
|
||||||
const row = await db.inheritancePoint.findUnique({
|
const rows = await db.$queryRaw<Array<{ value: number }>>(
|
||||||
where: {
|
GamePrisma.sql`
|
||||||
userId_key: {
|
SELECT value
|
||||||
userId,
|
FROM inheritance_point
|
||||||
key,
|
WHERE user_id = ${userId} AND key = ${key}
|
||||||
},
|
FOR UPDATE
|
||||||
},
|
`
|
||||||
select: {
|
);
|
||||||
value: true,
|
return rows[0]?.value ?? 0;
|
||||||
},
|
|
||||||
});
|
|
||||||
return row?.value ?? 0;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const setInheritancePoint = async (
|
export const setInheritancePoint = async (
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const MIN_EXTENSION_MINUTES_PER_BID = 1;
|
|||||||
interface AuctionRow {
|
interface AuctionRow {
|
||||||
id: number;
|
id: number;
|
||||||
type: AuctionType;
|
type: AuctionType;
|
||||||
|
hostGeneralId: number;
|
||||||
detail: unknown;
|
detail: unknown;
|
||||||
status: AuctionStatus;
|
status: AuctionStatus;
|
||||||
closeAt: Date;
|
closeAt: Date;
|
||||||
@@ -37,6 +38,7 @@ interface AuctionBidRow {
|
|||||||
interface AuctionDetail {
|
interface AuctionDetail {
|
||||||
isReverse?: boolean;
|
isReverse?: boolean;
|
||||||
startBidAmount?: number;
|
startBidAmount?: number;
|
||||||
|
finishBidAmount?: number | null;
|
||||||
availableLatestBidCloseDate?: string | null;
|
availableLatestBidCloseDate?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,11 +87,13 @@ const loadAuction = async (prisma: QueryClient, auctionId: number): Promise<Auct
|
|||||||
GamePrisma.sql`
|
GamePrisma.sql`
|
||||||
SELECT id,
|
SELECT id,
|
||||||
type,
|
type,
|
||||||
|
host_general_id as "hostGeneralId",
|
||||||
detail,
|
detail,
|
||||||
status,
|
status,
|
||||||
close_at as "closeAt"
|
close_at as "closeAt"
|
||||||
FROM auction
|
FROM auction
|
||||||
WHERE id = ${auctionId}
|
WHERE id = ${auctionId}
|
||||||
|
FOR UPDATE
|
||||||
`
|
`
|
||||||
);
|
);
|
||||||
return rows[0] ?? null;
|
return rows[0] ?? null;
|
||||||
@@ -218,6 +222,22 @@ export const createAuctionBidder = async (options: {
|
|||||||
reason: '시작가보다 낮습니다.',
|
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 (auction.type === 'UNIQUE_ITEM' && highestBid) {
|
||||||
if (command.amount < highestBid.amount * 1.01) {
|
if (command.amount < highestBid.amount * 1.01) {
|
||||||
@@ -257,6 +277,14 @@ export const createAuctionBidder = async (options: {
|
|||||||
reason: '장수 정보를 찾을 수 없습니다.',
|
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) {
|
if (auction.type === 'BUY_RICE' && general.gold < morePoint) {
|
||||||
return {
|
return {
|
||||||
@@ -296,12 +324,19 @@ export const createAuctionBidder = async (options: {
|
|||||||
const availableLatestBidCloseDate = detail.availableLatestBidCloseDate
|
const availableLatestBidCloseDate = detail.availableLatestBidCloseDate
|
||||||
? new Date(detail.availableLatestBidCloseDate)
|
? new Date(detail.availableLatestBidCloseDate)
|
||||||
: null;
|
: null;
|
||||||
const nextCloseAt = extendCloseDate({
|
let nextCloseAt = extendCloseDate({
|
||||||
now,
|
now,
|
||||||
closeAt: auction.closeAt,
|
closeAt: auction.closeAt,
|
||||||
turnMinutes,
|
turnMinutes,
|
||||||
availableLatestBidCloseDate,
|
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 eventId = randomUUID();
|
||||||
const eventAt = now;
|
const eventAt = now;
|
||||||
@@ -347,51 +382,34 @@ export const createAuctionBidder = async (options: {
|
|||||||
if (!userId) {
|
if (!userId) {
|
||||||
throw new Error('USER_NOT_FOUND');
|
throw new Error('USER_NOT_FOUND');
|
||||||
}
|
}
|
||||||
const current = await tx.inheritancePoint.findUnique({
|
const deductedRows = await tx.$queryRaw<Array<{ value: number }>>(
|
||||||
where: {
|
GamePrisma.sql`
|
||||||
userId_key: {
|
UPDATE inheritance_point
|
||||||
userId,
|
SET value = value - ${morePoint},
|
||||||
key: 'previous',
|
updated_at = ${eventAt}
|
||||||
},
|
WHERE user_id = ${userId}
|
||||||
},
|
AND key = 'previous'
|
||||||
});
|
AND value >= ${morePoint}
|
||||||
const prevValue = current?.value ?? 0;
|
RETURNING value
|
||||||
if (prevValue < morePoint) {
|
`
|
||||||
|
);
|
||||||
|
if (deductedRows.length === 0) {
|
||||||
throw new Error('INSUFFICIENT_POINT');
|
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) {
|
if (highestBid && highestBid.generalId !== command.generalId && !myPrevBid) {
|
||||||
const prevUserId = await resolveUserId(tx, highestBid.generalId);
|
const prevUserId = await resolveUserId(tx, highestBid.generalId);
|
||||||
if (prevUserId) {
|
if (prevUserId) {
|
||||||
const prevPoint = await tx.inheritancePoint.findUnique({
|
await tx.$executeRaw(
|
||||||
where: {
|
GamePrisma.sql`
|
||||||
userId_key: {
|
INSERT INTO inheritance_point (user_id, key, value, updated_at)
|
||||||
userId: prevUserId,
|
VALUES (${prevUserId}, 'previous', ${highestBid.amount}, ${eventAt})
|
||||||
key: 'previous',
|
ON CONFLICT (user_id, key)
|
||||||
},
|
DO UPDATE SET
|
||||||
},
|
value = inheritance_point.value + EXCLUDED.value,
|
||||||
});
|
updated_at = EXCLUDED.updated_at
|
||||||
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 },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import { createGamePostgresConnector, GamePrisma } from '@sammo-ts/infra';
|
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 { 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 { TurnDaemonCommandResult } from '../lifecycle/types.js';
|
||||||
import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.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 COEFF_EXTENSION_MINUTES_PER_BID = 1 / 6;
|
||||||
const MIN_EXTENSION_MINUTES_PER_BID = 1;
|
const MIN_EXTENSION_MINUTES_PER_BID = 1;
|
||||||
|
const MIN_EXTENSION_MINUTES_LIMIT_BY_BID = 5;
|
||||||
|
|
||||||
interface AuctionRow {
|
interface AuctionRow {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -33,12 +41,15 @@ interface AuctionBidRow {
|
|||||||
id: number;
|
id: number;
|
||||||
generalId: number;
|
generalId: number;
|
||||||
amount: number;
|
amount: number;
|
||||||
|
meta: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuctionDetailBase {
|
interface AuctionDetailBase {
|
||||||
title?: string;
|
title?: string;
|
||||||
isReverse?: boolean;
|
isReverse?: boolean;
|
||||||
|
tryExtendCloseDate?: boolean;
|
||||||
availableLatestBidCloseDate?: string | null;
|
availableLatestBidCloseDate?: string | null;
|
||||||
|
remainCloseDateExtensionCnt?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuctionDetailResource extends AuctionDetailBase {
|
interface AuctionDetailResource extends AuctionDetailBase {
|
||||||
@@ -63,24 +74,6 @@ const resolveTurnMinutes = async (prisma: AuctionDb): Promise<number> => {
|
|||||||
return toTurnMinutes(rows[0]?.tickSeconds ?? 60);
|
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 => {
|
const pushLogs = (world: InMemoryTurnWorld, logs: LogEntryDraft[]): void => {
|
||||||
for (const log of logs) {
|
for (const log of logs) {
|
||||||
world.pushLog(log);
|
world.pushLog(log);
|
||||||
@@ -96,31 +89,16 @@ const refundInheritancePoint = async (options: {
|
|||||||
if (!userId || amount <= 0) {
|
if (!userId || amount <= 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const current = await prisma.inheritancePoint.findUnique({
|
await prisma.$executeRaw(
|
||||||
where: {
|
GamePrisma.sql`
|
||||||
userId_key: {
|
INSERT INTO inheritance_point (user_id, key, value, updated_at)
|
||||||
userId,
|
VALUES (${userId}, 'previous', ${amount}, ${new Date()})
|
||||||
key: 'previous',
|
ON CONFLICT (user_id, key)
|
||||||
},
|
DO UPDATE SET
|
||||||
},
|
value = inheritance_point.value + EXCLUDED.value,
|
||||||
});
|
updated_at = EXCLUDED.updated_at
|
||||||
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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createAuctionFinalizer = async (options: {
|
export const createAuctionFinalizer = async (options: {
|
||||||
@@ -186,14 +164,14 @@ export const createAuctionFinalizer = async (options: {
|
|||||||
const bidRows = await db.$queryRaw<AuctionBidRow[]>(
|
const bidRows = await db.$queryRaw<AuctionBidRow[]>(
|
||||||
isReverse
|
isReverse
|
||||||
? GamePrisma.sql`
|
? GamePrisma.sql`
|
||||||
SELECT id, general_id as "generalId", amount
|
SELECT id, general_id as "generalId", amount, meta
|
||||||
FROM auction_bid
|
FROM auction_bid
|
||||||
WHERE auction_id = ${auctionId}
|
WHERE auction_id = ${auctionId}
|
||||||
ORDER BY amount ASC, id ASC
|
ORDER BY amount ASC, id ASC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`
|
`
|
||||||
: GamePrisma.sql`
|
: GamePrisma.sql`
|
||||||
SELECT id, general_id as "generalId", amount
|
SELECT id, general_id as "generalId", amount, meta
|
||||||
FROM auction_bid
|
FROM auction_bid
|
||||||
WHERE auction_id = ${auctionId}
|
WHERE auction_id = ${auctionId}
|
||||||
ORDER BY amount DESC, id ASC
|
ORDER BY amount DESC, id ASC
|
||||||
@@ -261,6 +239,43 @@ export const createAuctionFinalizer = async (options: {
|
|||||||
return { type: 'auctionFinalize', ok: true, auctionId };
|
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);
|
const bidder = world.getGeneralById(highestBid.generalId);
|
||||||
if (!bidder) {
|
if (!bidder) {
|
||||||
await finalizeStatus('CANCELED');
|
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 slot = itemModule.slot;
|
||||||
const currentItem = bidder.role.items?.[slot] ?? null;
|
const currentItem = bidder.role.items?.[slot] ?? null;
|
||||||
if (currentItem && currentItem !== 'None' && isItemKey(currentItem)) {
|
if (currentItem && currentItem !== 'None' && isItemKey(currentItem)) {
|
||||||
const currentModule = await itemLoader.load(currentItem).catch(() => null);
|
const currentModule = await itemLoader.load(currentItem).catch(() => null);
|
||||||
if (currentModule && !currentModule.buyable) {
|
if (currentModule && !currentModule.buyable) {
|
||||||
const turnMinutes = await resolveTurnMinutes(db);
|
const turnMinutes = await resolveTurnMinutes(db);
|
||||||
const availableLatestBidCloseDate = detail.availableLatestBidCloseDate
|
const nextCloseAt = new Date(
|
||||||
? new Date(detail.availableLatestBidCloseDate)
|
auction.closeAt.getTime() +
|
||||||
: null;
|
Math.max(MIN_EXTENSION_MINUTES_LIMIT_BY_BID, turnMinutes * 0.5) * 60_000
|
||||||
const nextCloseAt = extendCloseDate({
|
);
|
||||||
now,
|
const nextLatestBidCloseAt = new Date(
|
||||||
closeAt: auction.closeAt,
|
nextCloseAt.getTime() +
|
||||||
turnMinutes,
|
Math.max(
|
||||||
availableLatestBidCloseDate,
|
MIN_EXTENSION_MINUTES_PER_BID,
|
||||||
});
|
turnMinutes * COEFF_EXTENSION_MINUTES_PER_BID
|
||||||
|
) *
|
||||||
|
60_000
|
||||||
|
);
|
||||||
|
const nextDetail = {
|
||||||
|
...detail,
|
||||||
|
availableLatestBidCloseDate: nextLatestBidCloseAt.toISOString(),
|
||||||
|
};
|
||||||
await db.$executeRaw(
|
await db.$executeRaw(
|
||||||
GamePrisma.sql`
|
GamePrisma.sql`
|
||||||
UPDATE auction
|
UPDATE auction
|
||||||
SET status = 'OPEN',
|
SET status = 'OPEN',
|
||||||
|
host_general_id = ${bidder.id === auction.hostGeneralId ? auction.hostGeneralId : 0},
|
||||||
|
host_name = ${bidder.id === auction.hostGeneralId ? auction.hostName : '(상인)'},
|
||||||
|
detail = ${JSON.stringify(nextDetail)}::jsonb,
|
||||||
close_at = ${nextCloseAt},
|
close_at = ${nextCloseAt},
|
||||||
updated_at = ${now}
|
updated_at = ${now}
|
||||||
WHERE id = ${auctionId}
|
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 undefined;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
beforeMonthChanged: (context) => {
|
beforeMonthChanged: async (context) => {
|
||||||
for (const handler of resolved) {
|
for (const handler of resolved) {
|
||||||
handler.beforeMonthChanged?.(context);
|
await handler.beforeMonthChanged?.(context);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onMonthChanged: (context) => {
|
onMonthChanged: async (context) => {
|
||||||
for (const handler of resolved) {
|
for (const handler of resolved) {
|
||||||
handler.onMonthChanged?.(context);
|
await handler.onMonthChanged?.(context);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onYearChanged: (context) => {
|
onYearChanged: async (context) => {
|
||||||
for (const handler of resolved) {
|
for (const handler of resolved) {
|
||||||
handler.onYearChanged?.(context);
|
await handler.onYearChanged?.(context);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -36,6 +36,17 @@ const zAuctionFinalize = z.object({
|
|||||||
auctionId: zFiniteNumber,
|
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({
|
const zAuctionBid = z.object({
|
||||||
type: z.literal('auctionBid'),
|
type: z.literal('auctionBid'),
|
||||||
auctionId: zFiniteNumber,
|
auctionId: zFiniteNumber,
|
||||||
@@ -266,6 +277,14 @@ const normalizeAuctionFinalize: CommandNormalizer<'auctionFinalize'> = (envelope
|
|||||||
return { ...command, requestId: envelope.requestId };
|
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 normalizeAuctionBid: CommandNormalizer<'auctionBid'> = (envelope) => {
|
||||||
const command = parseWith(zAuctionBid, envelope.command);
|
const command = parseWith(zAuctionBid, envelope.command);
|
||||||
if (!command) {
|
if (!command) {
|
||||||
@@ -488,6 +507,7 @@ const normalizeShutdown: CommandNormalizer<'shutdown'> = (envelope) => {
|
|||||||
|
|
||||||
const normalizers: CommandNormalizerMap = {
|
const normalizers: CommandNormalizerMap = {
|
||||||
auctionFinalize: normalizeAuctionFinalize,
|
auctionFinalize: normalizeAuctionFinalize,
|
||||||
|
auctionOpen: normalizeAuctionOpen,
|
||||||
auctionBid: normalizeAuctionBid,
|
auctionBid: normalizeAuctionBid,
|
||||||
troopCreate: normalizeTroopCreate,
|
troopCreate: normalizeTroopCreate,
|
||||||
troopJoin: normalizeTroopJoin,
|
troopJoin: normalizeTroopJoin,
|
||||||
|
|||||||
@@ -344,6 +344,7 @@ export const createDatabaseTurnHooks = async (
|
|||||||
createdDiplomacy,
|
createdDiplomacy,
|
||||||
deletedEvents,
|
deletedEvents,
|
||||||
lifecycleEvents,
|
lifecycleEvents,
|
||||||
|
pendingNeutralAuctions,
|
||||||
} = changes;
|
} = changes;
|
||||||
const reservedTurnChanges = options?.reservedTurns?.peekDirtyState();
|
const reservedTurnChanges = options?.reservedTurns?.peekDirtyState();
|
||||||
|
|
||||||
@@ -354,6 +355,28 @@ export const createDatabaseTurnHooks = async (
|
|||||||
meta: asJson(state.meta),
|
meta: asJson(state.meta),
|
||||||
};
|
};
|
||||||
const persist = async (prisma: GamePrisma.TransactionClient): Promise<void> => {
|
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({
|
await prisma.worldState.update({
|
||||||
where: { id: state.id },
|
where: { id: state.id },
|
||||||
data: worldStateUpdate,
|
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 createdIds = new Set(createdGenerals.map((general) => general.id));
|
||||||
const createdNationIds = new Set(createdNations.map((nation) => nation.id));
|
const createdNationIds = new Set(createdNations.map((nation) => nation.id));
|
||||||
const createdTroopIds = new Set(createdTroops.map((troop) => troop.id));
|
const createdTroopIds = new Set(createdTroops.map((troop) => troop.id));
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export class InMemoryTurnProcessor implements TurnProcessor {
|
|||||||
partial = true;
|
partial = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
this.world.advanceMonth(nextTickTime);
|
await this.world.advanceMonth(nextTickTime);
|
||||||
processedTurns += 1;
|
processedTurns += 1;
|
||||||
nextTickTime = getNextTickTime(this.world.getState().lastTurnTime, this.tickMinutes);
|
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 { getNextTurnAt } from '@sammo-ts/logic';
|
||||||
|
|
||||||
import type { TurnCheckpoint } from '../lifecycle/types.js';
|
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 {
|
import {
|
||||||
applyDiplomacyPatch as applyDiplomacyPatchToEntry,
|
applyDiplomacyPatch as applyDiplomacyPatchToEntry,
|
||||||
buildDefaultDiplomacy,
|
buildDefaultDiplomacy,
|
||||||
@@ -73,9 +80,9 @@ export interface TurnCalendarContext {
|
|||||||
|
|
||||||
export interface TurnCalendarHandler {
|
export interface TurnCalendarHandler {
|
||||||
// 레거시 PRE_MONTH는 날짜 변경 전, MONTH는 날짜 변경 후에 실행된다.
|
// 레거시 PRE_MONTH는 날짜 변경 전, MONTH는 날짜 변경 후에 실행된다.
|
||||||
beforeMonthChanged?(context: TurnCalendarContext): void;
|
beforeMonthChanged?(context: TurnCalendarContext): void | Promise<void>;
|
||||||
onMonthChanged?(context: TurnCalendarContext): void;
|
onMonthChanged?(context: TurnCalendarContext): void | Promise<void>;
|
||||||
onYearChanged?(context: TurnCalendarContext): void;
|
onYearChanged?(context: TurnCalendarContext): void | Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InMemoryTurnWorldOptions {
|
export interface InMemoryTurnWorldOptions {
|
||||||
@@ -102,6 +109,7 @@ export interface TurnWorldChanges {
|
|||||||
createdDiplomacy: TurnDiplomacy[];
|
createdDiplomacy: TurnDiplomacy[];
|
||||||
deletedEvents: number[];
|
deletedEvents: number[];
|
||||||
lifecycleEvents: GeneralLifecycleEvent[];
|
lifecycleEvents: GeneralLifecycleEvent[];
|
||||||
|
pendingNeutralAuctions: PendingNeutralAuction[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const compareTurnOrder = (left: TurnGeneral, right: TurnGeneral): number => {
|
const compareTurnOrder = (left: TurnGeneral, right: TurnGeneral): number => {
|
||||||
@@ -270,6 +278,7 @@ export class InMemoryTurnWorld {
|
|||||||
private readonly logs: LogEntryDraft[] = [];
|
private readonly logs: LogEntryDraft[] = [];
|
||||||
private readonly messages: MessageDraft[] = [];
|
private readonly messages: MessageDraft[] = [];
|
||||||
private readonly lifecycleEvents: GeneralLifecycleEvent[] = [];
|
private readonly lifecycleEvents: GeneralLifecycleEvent[] = [];
|
||||||
|
private readonly pendingNeutralAuctions: PendingNeutralAuction[] = [];
|
||||||
private readonly scenarioConfig: ScenarioConfig;
|
private readonly scenarioConfig: ScenarioConfig;
|
||||||
private checkpoint?: TurnCheckpoint;
|
private checkpoint?: TurnCheckpoint;
|
||||||
private state: TurnWorldState;
|
private state: TurnWorldState;
|
||||||
@@ -331,6 +340,14 @@ export class InMemoryTurnWorld {
|
|||||||
this.logs.push(entry);
|
this.logs.push(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
queueNeutralAuction(auction: PendingNeutralAuction): void {
|
||||||
|
this.pendingNeutralAuctions.push({
|
||||||
|
...auction,
|
||||||
|
detail: { ...auction.detail },
|
||||||
|
closeAt: new Date(auction.closeAt.getTime()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
getScenarioConfig(): ScenarioConfig {
|
getScenarioConfig(): ScenarioConfig {
|
||||||
return this.scenarioConfig;
|
return this.scenarioConfig;
|
||||||
}
|
}
|
||||||
@@ -744,7 +761,7 @@ export class InMemoryTurnWorld {
|
|||||||
return nextTurnAt;
|
return nextTurnAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
advanceMonth(turnTime: Date): void {
|
async advanceMonth(turnTime: Date): Promise<void> {
|
||||||
const previousYear = this.state.currentYear;
|
const previousYear = this.state.currentYear;
|
||||||
const previousMonth = this.state.currentMonth;
|
const previousMonth = this.state.currentMonth;
|
||||||
let nextYear = previousYear;
|
let nextYear = previousYear;
|
||||||
@@ -761,7 +778,7 @@ export class InMemoryTurnWorld {
|
|||||||
currentMonth: nextMonth,
|
currentMonth: nextMonth,
|
||||||
turnTime,
|
turnTime,
|
||||||
};
|
};
|
||||||
this.calendarHandler?.beforeMonthChanged?.(context);
|
await this.calendarHandler?.beforeMonthChanged?.(context);
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
...this.state.meta,
|
...this.state.meta,
|
||||||
@@ -776,9 +793,9 @@ export class InMemoryTurnWorld {
|
|||||||
};
|
};
|
||||||
|
|
||||||
this.advanceDiplomacyMonth();
|
this.advanceDiplomacyMonth();
|
||||||
this.calendarHandler?.onMonthChanged?.(context);
|
await this.calendarHandler?.onMonthChanged?.(context);
|
||||||
if (nextYear !== previousYear) {
|
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 logs = this.logs.slice();
|
||||||
const messages = this.messages.slice();
|
const messages = this.messages.slice();
|
||||||
const lifecycleEvents = this.lifecycleEvents.slice();
|
const lifecycleEvents = this.lifecycleEvents.slice();
|
||||||
|
const pendingNeutralAuctions = this.pendingNeutralAuctions.map((auction) => ({
|
||||||
|
...auction,
|
||||||
|
detail: { ...auction.detail },
|
||||||
|
closeAt: new Date(auction.closeAt.getTime()),
|
||||||
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
generals,
|
generals,
|
||||||
@@ -837,6 +859,7 @@ export class InMemoryTurnWorld {
|
|||||||
createdDiplomacy,
|
createdDiplomacy,
|
||||||
deletedEvents,
|
deletedEvents,
|
||||||
lifecycleEvents,
|
lifecycleEvents,
|
||||||
|
pendingNeutralAuctions,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -862,6 +885,7 @@ export class InMemoryTurnWorld {
|
|||||||
this.logs.splice(0, changes.logs.length);
|
this.logs.splice(0, changes.logs.length);
|
||||||
this.messages.splice(0, changes.messages.length);
|
this.messages.splice(0, changes.messages.length);
|
||||||
this.lifecycleEvents.splice(0, changes.lifecycleEvents.length);
|
this.lifecycleEvents.splice(0, changes.lifecycleEvents.length);
|
||||||
|
this.pendingNeutralAuctions.splice(0, changes.pendingNeutralAuctions.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
consumeDirtyState(): TurnWorldChanges {
|
consumeDirtyState(): TurnWorldChanges {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { shouldUseAi } from './ai/generalAi.js';
|
|||||||
import { createUnificationHandler } from './unificationHandler.js';
|
import { createUnificationHandler } from './unificationHandler.js';
|
||||||
import { createAuctionFinalizer } from '../auction/finalizer.js';
|
import { createAuctionFinalizer } from '../auction/finalizer.js';
|
||||||
import { createAuctionBidder } from '../auction/bidder.js';
|
import { createAuctionBidder } from '../auction/bidder.js';
|
||||||
|
import { createNeutralAuctionRegistrar } from '../auction/neutralRegistrar.js';
|
||||||
import { createTournamentRewardFinalizer } from '../tournament/finalizer.js';
|
import { createTournamentRewardFinalizer } from '../tournament/finalizer.js';
|
||||||
import { createTournamentAutoStartHandler } from './tournamentAutoStart.js';
|
import { createTournamentAutoStartHandler } from './tournamentAutoStart.js';
|
||||||
import { createYearbookHandler } from './yearbookHandler.js';
|
import { createYearbookHandler } from './yearbookHandler.js';
|
||||||
@@ -198,6 +199,13 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
|
|||||||
getWorld: () => worldRef,
|
getWorld: () => worldRef,
|
||||||
map: snapshot.map ?? null,
|
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({
|
const tournamentAutoStartHandler = createTournamentAutoStartHandler({
|
||||||
profileName: options.profileName ?? options.profile,
|
profileName: options.profileName ?? options.profile,
|
||||||
getRedisClient: () => redisConnector?.client,
|
getRedisClient: () => redisConnector?.client,
|
||||||
@@ -215,6 +223,7 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
|
|||||||
nationTurnMonthlyHandler,
|
nationTurnMonthlyHandler,
|
||||||
hasEventAction('ProcessIncome') ? null : incomeHandler,
|
hasEventAction('ProcessIncome') ? null : incomeHandler,
|
||||||
frontStateHandler,
|
frontStateHandler,
|
||||||
|
neutralAuctionRegistrar.handler,
|
||||||
tournamentAutoStartHandler,
|
tournamentAutoStartHandler,
|
||||||
yearbookHandler.handler
|
yearbookHandler.handler
|
||||||
);
|
);
|
||||||
@@ -394,6 +403,7 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
|
|||||||
const baseClose = close;
|
const baseClose = close;
|
||||||
close = async () => {
|
close = async () => {
|
||||||
await baseClose();
|
await baseClose();
|
||||||
|
await neutralAuctionRegistrar.close();
|
||||||
if (unification) {
|
if (unification) {
|
||||||
await unification.close();
|
await unification.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,16 @@ export interface TurnEvent {
|
|||||||
meta: Record<string, unknown>;
|
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<
|
export interface TurnWorldSnapshot extends Omit<
|
||||||
WorldSnapshot,
|
WorldSnapshot,
|
||||||
'generals' | 'cities' | 'nations' | 'troops' | 'diplomacy'
|
'generals' | 'cities' | 'nations' | 'troops' | 'diplomacy'
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
} from '@sammo-ts/logic/items/index.js';
|
} from '@sammo-ts/logic/items/index.js';
|
||||||
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
|
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
|
||||||
import type { TurnGeneral } from './types.js';
|
import type { TurnGeneral } from './types.js';
|
||||||
|
import { openAuction } from '../auction/opener.js';
|
||||||
|
|
||||||
let itemRegistryPromise: Promise<Map<string, ItemModule>> | null = null;
|
let itemRegistryPromise: Promise<Map<string, ItemModule>> | null = null;
|
||||||
|
|
||||||
@@ -830,6 +831,13 @@ async function handleAuctionFinalize(
|
|||||||
return ctx.auctionFinalizer.finalize(command.auctionId, ctx.commandDb);
|
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(
|
async function handleAuctionBid(
|
||||||
ctx: CommandHandlerContext,
|
ctx: CommandHandlerContext,
|
||||||
command: Extract<TurnDaemonCommand, { type: 'auctionBid' }>
|
command: Extract<TurnDaemonCommand, { type: 'auctionBid' }>
|
||||||
@@ -1340,6 +1348,8 @@ export const createTurnDaemonCommandHandler = (options: {
|
|||||||
dropItem: (command) => handleDropItem(ctx, command as Extract<TurnDaemonCommand, { type: 'dropItem' }>),
|
dropItem: (command) => handleDropItem(ctx, command as Extract<TurnDaemonCommand, { type: 'dropItem' }>),
|
||||||
auctionFinalize: (command) =>
|
auctionFinalize: (command) =>
|
||||||
handleAuctionFinalize(ctx, command as Extract<TurnDaemonCommand, { type: 'auctionFinalize' }>),
|
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' }>),
|
auctionBid: (command) => handleAuctionBid(ctx, command as Extract<TurnDaemonCommand, { type: 'auctionBid' }>),
|
||||||
changePermission: (command) =>
|
changePermission: (command) =>
|
||||||
handleChangePermission(ctx, command as Extract<TurnDaemonCommand, { type: 'changePermission' }>),
|
handleChangePermission(ctx, command as Extract<TurnDaemonCommand, { type: 'changePermission' }>),
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ const buildWorld = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('monthly event pipeline', () => {
|
describe('monthly event pipeline', () => {
|
||||||
it('runs PRE_MONTH before the date change and MONTH after it in priority/id order', () => {
|
it('runs PRE_MONTH before the date change and MONTH after it in priority/id order', async () => {
|
||||||
const trace: string[] = [];
|
const trace: string[] = [];
|
||||||
const actions = new Map<string, MonthlyEventActionHandler>([
|
const actions = new Map<string, MonthlyEventActionHandler>([
|
||||||
[
|
[
|
||||||
@@ -103,12 +103,12 @@ describe('monthly event pipeline', () => {
|
|||||||
actions
|
actions
|
||||||
);
|
);
|
||||||
|
|
||||||
world.advanceMonth(new Date('0190-01-01T00:00:00.000Z'));
|
await world.advanceMonth(new Date('0190-01-01T00:00:00.000Z'));
|
||||||
|
|
||||||
expect(trace).toEqual(['pre:189-12', 'month-high:190-1', 'month-low:190-1']);
|
expect(trace).toEqual(['pre:189-12', 'month-high:190-1', 'month-low:190-1']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('supports logic conditions and persists DeleteEvent through dirty state', () => {
|
it('supports logic conditions and persists DeleteEvent through dirty state', async () => {
|
||||||
const world = buildWorld(
|
const world = buildWorld(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
@@ -123,7 +123,7 @@ describe('monthly event pipeline', () => {
|
|||||||
new Map()
|
new Map()
|
||||||
);
|
);
|
||||||
|
|
||||||
world.advanceMonth(new Date('0190-01-01T00:00:00.000Z'));
|
await world.advanceMonth(new Date('0190-01-01T00:00:00.000Z'));
|
||||||
|
|
||||||
expect(world.listEvents('month')).toEqual([]);
|
expect(world.listEvents('month')).toEqual([]);
|
||||||
expect(world.peekDirtyState().deletedEvents).toEqual([7]);
|
expect(world.peekDirtyState().deletedEvents).toEqual([7]);
|
||||||
@@ -131,7 +131,7 @@ describe('monthly event pipeline', () => {
|
|||||||
expect(world.peekDirtyState().deletedEvents).toEqual([]);
|
expect(world.peekDirtyState().deletedEvents).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fails explicitly when a scenario action has not been migrated', () => {
|
it('fails explicitly when a scenario action has not been migrated', async () => {
|
||||||
const world = buildWorld(
|
const world = buildWorld(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
@@ -146,7 +146,7 @@ describe('monthly event pipeline', () => {
|
|||||||
new Map()
|
new Map()
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(() => world.advanceMonth(new Date('0190-01-01T00:00:00.000Z'))).toThrow(
|
await expect(world.advanceMonth(new Date('0190-01-01T00:00:00.000Z'))).rejects.toThrow(
|
||||||
'Unsupported monthly event action: RaiseInvader (eventId=9)'
|
'Unsupported monthly event action: RaiseInvader (eventId=9)'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { createGamePostgresConnector, GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
|
||||||
|
|
||||||
|
import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js';
|
||||||
|
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||||
|
import type { TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||||
|
|
||||||
|
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
||||||
|
const integration = describe.skipIf(!databaseUrl);
|
||||||
|
const registrationKey = 'integration-neutral-auction-180-02';
|
||||||
|
|
||||||
|
integration('neutral auction database persistence', () => {
|
||||||
|
let db: GamePrismaClient;
|
||||||
|
let closeDb: (() => Promise<void>) | undefined;
|
||||||
|
|
||||||
|
const deleteFixtureAuctions = async (): Promise<void> => {
|
||||||
|
await db.$executeRaw(
|
||||||
|
GamePrisma.sql`
|
||||||
|
DELETE FROM auction
|
||||||
|
WHERE detail->>'neutralRegistrationKey' = ${registrationKey}
|
||||||
|
`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const connector = createGamePostgresConnector({ url: databaseUrl! });
|
||||||
|
await connector.connect();
|
||||||
|
db = connector.prisma;
|
||||||
|
closeDb = () => connector.disconnect();
|
||||||
|
await deleteFixtureAuctions();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await deleteFixtureAuctions();
|
||||||
|
await closeDb?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('commits the auction with the month state and skips a duplicate registration key', async () => {
|
||||||
|
const row = await db.worldState.create({
|
||||||
|
data: {
|
||||||
|
scenarioCode: 'neutral-auction-integration',
|
||||||
|
currentYear: 180,
|
||||||
|
currentMonth: 2,
|
||||||
|
tickSeconds: 600,
|
||||||
|
config: {},
|
||||||
|
meta: { killturn: 24, neutralAuctionRegistrationKey: registrationKey },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const state: TurnWorldState = {
|
||||||
|
id: row.id,
|
||||||
|
currentYear: 180,
|
||||||
|
currentMonth: 2,
|
||||||
|
tickSeconds: 600,
|
||||||
|
lastTurnTime: new Date('2026-07-25T00:10:00.000Z'),
|
||||||
|
meta: { killturn: 24, neutralAuctionRegistrationKey: registrationKey },
|
||||||
|
};
|
||||||
|
const snapshot: TurnWorldSnapshot = {
|
||||||
|
generals: [],
|
||||||
|
cities: [],
|
||||||
|
nations: [],
|
||||||
|
troops: [],
|
||||||
|
diplomacy: [],
|
||||||
|
events: [],
|
||||||
|
initialEvents: [],
|
||||||
|
map: {
|
||||||
|
id: 'test',
|
||||||
|
name: 'test',
|
||||||
|
cities: [],
|
||||||
|
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
|
||||||
|
},
|
||||||
|
scenarioConfig: {
|
||||||
|
stat: {
|
||||||
|
total: 300,
|
||||||
|
min: 10,
|
||||||
|
max: 100,
|
||||||
|
npcTotal: 150,
|
||||||
|
npcMax: 50,
|
||||||
|
npcMin: 10,
|
||||||
|
chiefMin: 70,
|
||||||
|
},
|
||||||
|
iconPath: '',
|
||||||
|
map: {},
|
||||||
|
const: {},
|
||||||
|
environment: { mapName: 'test', unitSet: 'default' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const world = new InMemoryTurnWorld(state, snapshot, {
|
||||||
|
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
|
||||||
|
});
|
||||||
|
const pending = {
|
||||||
|
registrationKey,
|
||||||
|
type: 'BUY_RICE' as const,
|
||||||
|
targetCode: '1150',
|
||||||
|
hostGeneralId: 0 as const,
|
||||||
|
hostName: '상인' as const,
|
||||||
|
detail: {
|
||||||
|
title: '쌀 1150 경매',
|
||||||
|
hostName: '상인',
|
||||||
|
amount: 1150,
|
||||||
|
isReverse: false,
|
||||||
|
startBidAmount: 920,
|
||||||
|
finishBidAmount: 2300,
|
||||||
|
neutralRegistrationKey: registrationKey,
|
||||||
|
},
|
||||||
|
closeAt: new Date('2026-07-25T00:50:00.000Z'),
|
||||||
|
};
|
||||||
|
const dbHooks = await createDatabaseTurnHooks(databaseUrl!, world);
|
||||||
|
try {
|
||||||
|
// DB marker는 아직 없도록 되돌려 첫 flush가 실제 생성을 담당하게 한다.
|
||||||
|
await db.worldState.update({ where: { id: row.id }, data: { meta: { killturn: 24 } } });
|
||||||
|
world.queueNeutralAuction(pending);
|
||||||
|
await dbHooks.hooks.flushChanges?.({
|
||||||
|
lastTurnTime: state.lastTurnTime.toISOString(),
|
||||||
|
processedGenerals: 0,
|
||||||
|
processedTurns: 1,
|
||||||
|
durationMs: 0,
|
||||||
|
partial: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await db.auction.count({
|
||||||
|
where: {
|
||||||
|
hostGeneralId: 0,
|
||||||
|
detail: { path: ['neutralRegistrationKey'], equals: registrationKey },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).toBe(1);
|
||||||
|
|
||||||
|
world.queueNeutralAuction(pending);
|
||||||
|
await dbHooks.hooks.flushChanges?.({
|
||||||
|
lastTurnTime: state.lastTurnTime.toISOString(),
|
||||||
|
processedGenerals: 0,
|
||||||
|
processedTurns: 1,
|
||||||
|
durationMs: 0,
|
||||||
|
partial: false,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
await db.auction.count({
|
||||||
|
where: {
|
||||||
|
hostGeneralId: 0,
|
||||||
|
detail: { path: ['neutralRegistrationKey'], equals: registrationKey },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).toBe(1);
|
||||||
|
} finally {
|
||||||
|
await dbHooks.close();
|
||||||
|
await db.worldState.delete({ where: { id: row.id } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { createNeutralAuctionRegistrar } from '../src/auction/neutralRegistrar.js';
|
||||||
|
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||||
|
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||||
|
|
||||||
|
const buildGeneral = (id: number, npcState: number, gold: number, rice: number): TurnGeneral => ({
|
||||||
|
id,
|
||||||
|
name: `General_${id}`,
|
||||||
|
nationId: 1,
|
||||||
|
cityId: 0,
|
||||||
|
troopId: 0,
|
||||||
|
stats: { leadership: 50, strength: 50, intelligence: 50 },
|
||||||
|
turnTime: new Date('0180-01-01T00:00:00Z'),
|
||||||
|
role: {
|
||||||
|
items: { horse: null, weapon: null, book: null, item: null },
|
||||||
|
personality: null,
|
||||||
|
specialDomestic: null,
|
||||||
|
specialWar: null,
|
||||||
|
},
|
||||||
|
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||||
|
meta: { killturn: 24 },
|
||||||
|
officerLevel: 1,
|
||||||
|
experience: 0,
|
||||||
|
dedication: 0,
|
||||||
|
injury: 0,
|
||||||
|
gold,
|
||||||
|
rice,
|
||||||
|
crew: 0,
|
||||||
|
crewTypeId: 0,
|
||||||
|
train: 0,
|
||||||
|
atmos: 0,
|
||||||
|
age: 30,
|
||||||
|
npcState,
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildSnapshot = (): TurnWorldSnapshot => ({
|
||||||
|
generals: [
|
||||||
|
buildGeneral(1, 0, 5_432, 7_654),
|
||||||
|
// ref의 WHERE npc < 2와 같이 평균에서 제외되어야 한다.
|
||||||
|
buildGeneral(2, 2, 99_999, 99_999),
|
||||||
|
],
|
||||||
|
cities: [],
|
||||||
|
nations: [1, 2, 3].map((id) => ({
|
||||||
|
id,
|
||||||
|
name: `Nation_${id}`,
|
||||||
|
color: '#000000',
|
||||||
|
capitalCityId: null,
|
||||||
|
chiefGeneralId: id === 1 ? 1 : 0,
|
||||||
|
gold: 0,
|
||||||
|
rice: 0,
|
||||||
|
power: 0,
|
||||||
|
level: 1,
|
||||||
|
typeCode: 'che_def',
|
||||||
|
meta: {},
|
||||||
|
})),
|
||||||
|
troops: [],
|
||||||
|
diplomacy: [],
|
||||||
|
events: [],
|
||||||
|
initialEvents: [],
|
||||||
|
map: {
|
||||||
|
id: 'test',
|
||||||
|
name: 'test',
|
||||||
|
cities: [],
|
||||||
|
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
|
||||||
|
},
|
||||||
|
scenarioConfig: {
|
||||||
|
stat: {
|
||||||
|
total: 300,
|
||||||
|
min: 10,
|
||||||
|
max: 100,
|
||||||
|
npcTotal: 150,
|
||||||
|
npcMax: 50,
|
||||||
|
npcMin: 10,
|
||||||
|
chiefMin: 70,
|
||||||
|
},
|
||||||
|
iconPath: '',
|
||||||
|
map: {},
|
||||||
|
const: {},
|
||||||
|
environment: { mapName: 'test', unitSet: 'default' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('neutral auction monthly registrar', () => {
|
||||||
|
it('uses the previous month seed and queues the legacy amount at the new month boundary', async () => {
|
||||||
|
const worldRef: { current: InMemoryTurnWorld | null } = { current: null };
|
||||||
|
const now = new Date('2026-07-25T12:00:00.000Z');
|
||||||
|
const registrar = await createNeutralAuctionRegistrar({
|
||||||
|
databaseUrl: 'unused://test',
|
||||||
|
profileName: 'test',
|
||||||
|
getWorld: () => worldRef.current,
|
||||||
|
getRedisClient: () => null,
|
||||||
|
getWorldConfig: () => ({ tournamentTrig: false }),
|
||||||
|
now: () => now,
|
||||||
|
loadNeutralAuctionCounts: async () => [],
|
||||||
|
});
|
||||||
|
const state: TurnWorldState = {
|
||||||
|
id: 1,
|
||||||
|
currentYear: 180,
|
||||||
|
currentMonth: 1,
|
||||||
|
tickSeconds: 600,
|
||||||
|
lastTurnTime: new Date('2026-07-25T00:00:00.000Z'),
|
||||||
|
meta: { hiddenSeed: 'merchant-11', killturn: 24 },
|
||||||
|
};
|
||||||
|
const world = new InMemoryTurnWorld(state, buildSnapshot(), {
|
||||||
|
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
|
||||||
|
calendarHandler: registrar.handler,
|
||||||
|
});
|
||||||
|
worldRef.current = world;
|
||||||
|
|
||||||
|
await world.advanceMonth(new Date('2026-07-25T00:10:00.000Z'));
|
||||||
|
|
||||||
|
expect(world.getState()).toMatchObject({
|
||||||
|
currentYear: 180,
|
||||||
|
currentMonth: 2,
|
||||||
|
meta: { neutralAuctionRegistrationKey: '180-02' },
|
||||||
|
});
|
||||||
|
expect(world.peekDirtyState().pendingNeutralAuctions).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
registrationKey: '180-02',
|
||||||
|
type: 'BUY_RICE',
|
||||||
|
targetCode: '1150',
|
||||||
|
hostGeneralId: 0,
|
||||||
|
hostName: '상인',
|
||||||
|
closeAt: new Date(now.getTime() + 4 * 10 * 60_000),
|
||||||
|
detail: expect.objectContaining({
|
||||||
|
amount: 1_150,
|
||||||
|
startBidAmount: 920,
|
||||||
|
finishBidAmount: 2_300,
|
||||||
|
seedYear: 180,
|
||||||
|
seedMonth: 1,
|
||||||
|
closeTurnCnt: 4,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
await registrar.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ import PublicView from '../views/PublicView.vue';
|
|||||||
import LoginView from '../views/LoginView.vue';
|
import LoginView from '../views/LoginView.vue';
|
||||||
import JoinView from '../views/JoinView.vue';
|
import JoinView from '../views/JoinView.vue';
|
||||||
import InheritView from '../views/InheritView.vue';
|
import InheritView from '../views/InheritView.vue';
|
||||||
|
import AuctionView from '../views/AuctionView.vue';
|
||||||
import NationCitiesView from '../views/NationCitiesView.vue';
|
import NationCitiesView from '../views/NationCitiesView.vue';
|
||||||
import NationGeneralsView from '../views/NationGeneralsView.vue';
|
import NationGeneralsView from '../views/NationGeneralsView.vue';
|
||||||
import NationPersonnelView from '../views/NationPersonnelView.vue';
|
import NationPersonnelView from '../views/NationPersonnelView.vue';
|
||||||
@@ -70,6 +71,15 @@ const routes = [
|
|||||||
requiresGeneral: true,
|
requiresGeneral: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/auction',
|
||||||
|
name: 'auction',
|
||||||
|
component: AuctionView,
|
||||||
|
meta: {
|
||||||
|
requiresAuth: true,
|
||||||
|
requiresGeneral: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/nation/cities',
|
path: '/nation/cities',
|
||||||
name: 'nation-cities',
|
name: 'nation-cities',
|
||||||
|
|||||||
@@ -0,0 +1,352 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue';
|
||||||
|
|
||||||
|
import PanelCard from '../components/ui/PanelCard.vue';
|
||||||
|
import SkeletonLines from '../components/ui/SkeletonLines.vue';
|
||||||
|
import { formatLog } from '../utils/formatLog';
|
||||||
|
import { trpc } from '../utils/trpc';
|
||||||
|
|
||||||
|
type AuctionOverview = Awaited<ReturnType<typeof trpc.auction.getOverview.query>>;
|
||||||
|
type ResourceAuction = AuctionOverview['resourceAuctions'][number];
|
||||||
|
type UniqueAuction = AuctionOverview['uniqueAuctions'][number];
|
||||||
|
type UniqueDetail = Awaited<ReturnType<typeof trpc.auction.getUniqueDetail.query>>;
|
||||||
|
|
||||||
|
const activeTab = ref<'resource' | 'unique'>('resource');
|
||||||
|
const loading = ref(false);
|
||||||
|
const actionBusy = ref(false);
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
const message = ref<string | null>(null);
|
||||||
|
const overview = ref<AuctionOverview | null>(null);
|
||||||
|
const selectedResource = ref<ResourceAuction | null>(null);
|
||||||
|
const selectedUnique = ref<UniqueAuction | null>(null);
|
||||||
|
const uniqueDetail = ref<UniqueDetail | null>(null);
|
||||||
|
const bidAmount = ref(0);
|
||||||
|
|
||||||
|
const openForm = reactive({
|
||||||
|
type: 'BUY_RICE' as 'BUY_RICE' | 'SELL_RICE',
|
||||||
|
amount: 1000,
|
||||||
|
closeTurnCnt: 24,
|
||||||
|
startBidAmount: 500,
|
||||||
|
finishBidAmount: 2000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolveErrorMessage = (value: unknown): string => {
|
||||||
|
if (value instanceof Error) {
|
||||||
|
return value.message;
|
||||||
|
}
|
||||||
|
return typeof value === 'string' ? value : 'unknown_error';
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatNumber = (value: number | null | undefined): string => (value ?? 0).toLocaleString();
|
||||||
|
const formatDate = (value: string): string =>
|
||||||
|
new Intl.DateTimeFormat('ko-KR', {
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
}).format(new Date(value));
|
||||||
|
|
||||||
|
const resourceTitle = (auction: ResourceAuction): string =>
|
||||||
|
auction.type === 'BUY_RICE' ? '쌀 구매' : '쌀 판매';
|
||||||
|
const hostResource = (auction: ResourceAuction): string => (auction.type === 'BUY_RICE' ? '쌀' : '금');
|
||||||
|
const bidResource = (auction: ResourceAuction): string => (auction.type === 'BUY_RICE' ? '금' : '쌀');
|
||||||
|
|
||||||
|
const resourceAuctions = computed(() => overview.value?.resourceAuctions ?? []);
|
||||||
|
const uniqueAuctions = computed(() => overview.value?.uniqueAuctions ?? []);
|
||||||
|
|
||||||
|
const loadOverview = async (): Promise<void> => {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
overview.value = await trpc.auction.getOverview.query();
|
||||||
|
if (selectedResource.value) {
|
||||||
|
selectedResource.value =
|
||||||
|
overview.value.resourceAuctions.find((auction) => auction.id === selectedResource.value?.id) ?? null;
|
||||||
|
}
|
||||||
|
if (selectedUnique.value) {
|
||||||
|
selectedUnique.value =
|
||||||
|
overview.value.uniqueAuctions.find((auction) => auction.id === selectedUnique.value?.id) ?? null;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error.value = resolveErrorMessage(err);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectResource = (auction: ResourceAuction): void => {
|
||||||
|
selectedResource.value = auction;
|
||||||
|
bidAmount.value = auction.highestBid?.amount ?? auction.detail.startBidAmount ?? 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectUnique = async (auction: UniqueAuction): Promise<void> => {
|
||||||
|
selectedUnique.value = auction;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
uniqueDetail.value = await trpc.auction.getUniqueDetail.query({ auctionId: auction.id });
|
||||||
|
const highest = uniqueDetail.value.bids[0]?.amount ?? auction.detail.startBidAmount ?? 0;
|
||||||
|
bidAmount.value = Math.max(Math.ceil(highest * 1.01), highest + 10);
|
||||||
|
} catch (err) {
|
||||||
|
error.value = resolveErrorMessage(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const runAction = async (action: () => Promise<void>): Promise<void> => {
|
||||||
|
if (actionBusy.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
actionBusy.value = true;
|
||||||
|
error.value = null;
|
||||||
|
message.value = null;
|
||||||
|
try {
|
||||||
|
await action();
|
||||||
|
await loadOverview();
|
||||||
|
} catch (err) {
|
||||||
|
error.value = resolveErrorMessage(err);
|
||||||
|
} finally {
|
||||||
|
actionBusy.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openResourceAuction = (): Promise<void> =>
|
||||||
|
runAction(async () => {
|
||||||
|
const input = {
|
||||||
|
amount: openForm.amount,
|
||||||
|
closeTurnCnt: openForm.closeTurnCnt,
|
||||||
|
startBidAmount: openForm.startBidAmount,
|
||||||
|
finishBidAmount: openForm.finishBidAmount,
|
||||||
|
};
|
||||||
|
const result =
|
||||||
|
openForm.type === 'BUY_RICE'
|
||||||
|
? await trpc.auction.openBuyRice.mutate(input)
|
||||||
|
: await trpc.auction.openSellRice.mutate(input);
|
||||||
|
message.value = `${result.auctionId}번 경매로 등록되었습니다.`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const bidResourceAuction = (): Promise<void> =>
|
||||||
|
runAction(async () => {
|
||||||
|
const auction = selectedResource.value;
|
||||||
|
if (!auction) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (auction.isCallerHost) {
|
||||||
|
throw new Error('자신이 연 경매에 입찰할 수 없습니다.');
|
||||||
|
}
|
||||||
|
if (auction.type === 'BUY_RICE') {
|
||||||
|
await trpc.auction.bidBuyRice.mutate({ auctionId: auction.id, amount: bidAmount.value });
|
||||||
|
} else {
|
||||||
|
await trpc.auction.bidSellRice.mutate({ auctionId: auction.id, amount: bidAmount.value });
|
||||||
|
}
|
||||||
|
message.value = `${auction.id}번 경매에 입찰했습니다.`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const bidUniqueAuction = (): Promise<void> =>
|
||||||
|
runAction(async () => {
|
||||||
|
const auction = selectedUnique.value;
|
||||||
|
if (!auction) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!window.confirm(`${auction.detail.title ?? auction.targetCode ?? '유니크'}에 ${bidAmount.value} 포인트를 입찰하시겠습니까?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await trpc.auction.bidUnique.mutate({
|
||||||
|
auctionId: auction.id,
|
||||||
|
amount: bidAmount.value,
|
||||||
|
tryExtendCloseDate: true,
|
||||||
|
});
|
||||||
|
message.value = `${auction.id}번 유니크 경매에 입찰했습니다.`;
|
||||||
|
await selectUnique(auction);
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void loadOverview();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="auction-page">
|
||||||
|
<header class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1>거래장</h1>
|
||||||
|
<p>금·쌀 거래와 유니크 아이템 경매를 확인합니다.</p>
|
||||||
|
</div>
|
||||||
|
<button class="ghost" :disabled="loading" @click="loadOverview">새로고침</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav class="tabs" aria-label="경매 종류">
|
||||||
|
<button :class="{ active: activeTab === 'resource' }" @click="activeTab = 'resource'">금·쌀 경매</button>
|
||||||
|
<button :class="{ active: activeTab === 'unique' }" @click="activeTab = 'unique'">유니크 경매</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<p v-if="error" class="notice error">{{ error }}</p>
|
||||||
|
<p v-if="message" class="notice success">{{ message }}</p>
|
||||||
|
<SkeletonLines v-if="loading && !overview" :lines="8" />
|
||||||
|
|
||||||
|
<template v-else-if="activeTab === 'resource'">
|
||||||
|
<PanelCard title="진행 중인 금·쌀 경매" subtitle="행을 선택하면 아래에서 입찰할 수 있습니다.">
|
||||||
|
<div class="auction-table resource-table">
|
||||||
|
<div class="table-head">
|
||||||
|
<span>번호</span><span>종류</span><span>판매자</span><span>수량</span><span>입찰자</span>
|
||||||
|
<span>현재가</span><span>마감가</span><span>종료</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-for="auction in resourceAuctions"
|
||||||
|
:key="auction.id"
|
||||||
|
class="table-row"
|
||||||
|
:class="{ selected: selectedResource?.id === auction.id }"
|
||||||
|
@click="selectResource(auction)"
|
||||||
|
>
|
||||||
|
<span>{{ auction.id }}</span>
|
||||||
|
<span>{{ resourceTitle(auction) }}</span>
|
||||||
|
<span>{{ auction.hostName }}</span>
|
||||||
|
<span>{{ hostResource(auction) }} {{ formatNumber(auction.detail.amount) }}</span>
|
||||||
|
<span>{{ auction.highestBid?.bidderName ?? '-' }}</span>
|
||||||
|
<span>{{ bidResource(auction) }} {{ formatNumber(auction.highestBid?.amount ?? auction.detail.startBidAmount) }}</span>
|
||||||
|
<span>{{ bidResource(auction) }} {{ formatNumber(auction.detail.finishBidAmount) }}</span>
|
||||||
|
<span>{{ formatDate(auction.closeAt) }}</span>
|
||||||
|
</button>
|
||||||
|
<p v-if="resourceAuctions.length === 0" class="empty">진행 중인 경매가 없습니다.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form v-if="selectedResource" class="bid-form" @submit.prevent="bidResourceAuction">
|
||||||
|
<strong>{{ selectedResource.id }}번 {{ resourceTitle(selectedResource) }}</strong>
|
||||||
|
<label>
|
||||||
|
<span>입찰가 ({{ bidResource(selectedResource) }})</span>
|
||||||
|
<input v-model.number="bidAmount" type="number" min="1" step="10" required />
|
||||||
|
</label>
|
||||||
|
<button :disabled="actionBusy || selectedResource.isCallerHost">입찰</button>
|
||||||
|
</form>
|
||||||
|
</PanelCard>
|
||||||
|
|
||||||
|
<PanelCard title="경매 등록" subtitle="레거시와 동일하게 한 장수는 자원 경매를 한 건만 진행할 수 있습니다.">
|
||||||
|
<form class="open-form" @submit.prevent="openResourceAuction">
|
||||||
|
<label>
|
||||||
|
<span>매물</span>
|
||||||
|
<select v-model="openForm.type">
|
||||||
|
<option value="BUY_RICE">쌀</option>
|
||||||
|
<option value="SELL_RICE">금</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label><span>수량</span><input v-model.number="openForm.amount" type="number" min="100" max="10000" step="10" /></label>
|
||||||
|
<label><span>기간(턴)</span><input v-model.number="openForm.closeTurnCnt" type="number" min="1" max="24" /></label>
|
||||||
|
<label><span>시작가</span><input v-model.number="openForm.startBidAmount" type="number" min="1" step="10" /></label>
|
||||||
|
<label><span>마감가</span><input v-model.number="openForm.finishBidAmount" type="number" min="1" step="10" /></label>
|
||||||
|
<button :disabled="actionBusy">등록</button>
|
||||||
|
</form>
|
||||||
|
</PanelCard>
|
||||||
|
|
||||||
|
<PanelCard title="이전 경매" subtitle="최근 경매 기록 20건">
|
||||||
|
<ol class="log-list">
|
||||||
|
<!-- eslint-disable vue/no-v-html -->
|
||||||
|
<li v-for="log in overview?.recentLogs ?? []" :key="log.id" v-html="formatLog(log.text)" />
|
||||||
|
<!-- eslint-enable vue/no-v-html -->
|
||||||
|
<li v-if="(overview?.recentLogs.length ?? 0) === 0" class="empty">경매 기록이 없습니다.</li>
|
||||||
|
</ol>
|
||||||
|
</PanelCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<PanelCard title="유니크 경매" :subtitle="`내 가명: ${overview?.callerAlias ?? '-'}`">
|
||||||
|
<div class="auction-table unique-table">
|
||||||
|
<div class="table-head">
|
||||||
|
<span>번호</span><span>경매명</span><span>주최자</span><span>종료</span><span>1순위</span><span>포인트</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-for="auction in uniqueAuctions"
|
||||||
|
:key="auction.id"
|
||||||
|
class="table-row"
|
||||||
|
:class="{ selected: selectedUnique?.id === auction.id }"
|
||||||
|
@click="selectUnique(auction)"
|
||||||
|
>
|
||||||
|
<span>{{ auction.id }}</span>
|
||||||
|
<span>{{ auction.detail.title ?? auction.targetCode }}</span>
|
||||||
|
<span :class="{ me: auction.isCallerHost }">{{ auction.hostName }}</span>
|
||||||
|
<span>{{ formatDate(auction.closeAt) }}</span>
|
||||||
|
<span :class="{ me: auction.highestBid?.isCaller }">{{ auction.highestBid?.bidderName ?? '-' }}</span>
|
||||||
|
<span>{{ formatNumber(auction.highestBid?.amount ?? auction.detail.startBidAmount) }}</span>
|
||||||
|
</button>
|
||||||
|
<p v-if="uniqueAuctions.length === 0" class="empty">유니크 경매가 없습니다.</p>
|
||||||
|
</div>
|
||||||
|
</PanelCard>
|
||||||
|
|
||||||
|
<PanelCard v-if="uniqueDetail" title="유니크 경매 상세">
|
||||||
|
<dl class="detail-grid">
|
||||||
|
<dt>경매명</dt><dd>{{ uniqueDetail.auction.detail.title ?? uniqueDetail.auction.targetCode }}</dd>
|
||||||
|
<dt>주최자(익명)</dt><dd :class="{ me: uniqueDetail.auction.isCallerHost }">{{ uniqueDetail.auction.hostName }}</dd>
|
||||||
|
<dt>종료일시</dt><dd>{{ formatDate(uniqueDetail.auction.closeAt) }}</dd>
|
||||||
|
<dt>잔여 포인트</dt><dd>{{ formatNumber(uniqueDetail.remainPoint) }}</dd>
|
||||||
|
</dl>
|
||||||
|
<div class="bid-history">
|
||||||
|
<div v-for="bid in uniqueDetail.bids" :key="bid.id" class="bid-entry">
|
||||||
|
<span :class="{ me: bid.isCaller }">{{ bid.bidderName }}</span>
|
||||||
|
<strong>{{ formatNumber(bid.amount) }}</strong>
|
||||||
|
<time>{{ formatDate(bid.eventAt) }}</time>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form v-if="uniqueDetail.auction.status === 'OPEN'" class="bid-form" @submit.prevent="bidUniqueAuction">
|
||||||
|
<label><span>유산 포인트</span><input v-model.number="bidAmount" type="number" min="1" required /></label>
|
||||||
|
<button :disabled="actionBusy">입찰</button>
|
||||||
|
</form>
|
||||||
|
</PanelCard>
|
||||||
|
</template>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.auction-page {
|
||||||
|
min-height: 100%;
|
||||||
|
padding: 18px;
|
||||||
|
color: #e8ddc4;
|
||||||
|
background: radial-gradient(circle at top, rgba(93, 57, 26, 0.25), transparent 42%), #080807;
|
||||||
|
}
|
||||||
|
.page-header, .tabs, .bid-form, .open-form, .detail-grid, .bid-entry {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.page-header { justify-content: space-between; gap: 16px; margin-bottom: 12px; }
|
||||||
|
.page-header h1 { margin: 0; font-size: 1.45rem; }
|
||||||
|
.page-header p { margin: 4px 0 0; color: rgba(232, 221, 196, 0.7); }
|
||||||
|
.tabs { gap: 6px; margin-bottom: 12px; }
|
||||||
|
button, input, select {
|
||||||
|
border: 1px solid rgba(201, 164, 90, 0.55);
|
||||||
|
background: rgba(20, 17, 12, 0.95);
|
||||||
|
color: #e8ddc4;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
button { cursor: pointer; }
|
||||||
|
button:hover, button:focus-visible, button.active { background: rgba(201, 164, 90, 0.22); }
|
||||||
|
button:disabled { cursor: not-allowed; opacity: 0.45; }
|
||||||
|
.ghost { background: transparent; }
|
||||||
|
.notice { padding: 9px 12px; border: 1px solid; }
|
||||||
|
.notice.error { color: #ffb3a9; border-color: rgba(255, 90, 70, 0.45); }
|
||||||
|
.notice.success { color: #b9e6af; border-color: rgba(94, 177, 75, 0.45); }
|
||||||
|
.auction-page :deep(.panel-card) { margin-bottom: 12px; }
|
||||||
|
.auction-table { overflow-x: auto; }
|
||||||
|
.table-head, .table-row { display: grid; min-width: 820px; align-items: center; text-align: center; }
|
||||||
|
.resource-table .table-head, .resource-table .table-row { grid-template-columns: 52px 84px 1fr 1fr 1fr 1fr 1fr 150px; }
|
||||||
|
.unique-table .table-head, .unique-table .table-row { grid-template-columns: 52px 2fr 1fr 150px 1fr 110px; }
|
||||||
|
.table-head { border-bottom: 1px solid rgba(232, 221, 196, 0.4); padding: 7px; color: rgba(232, 221, 196, 0.7); }
|
||||||
|
.table-row { width: 100%; border: 0; border-bottom: 1px solid rgba(232, 221, 196, 0.12); background: transparent; }
|
||||||
|
.table-row.selected { background: rgba(201, 164, 90, 0.18); }
|
||||||
|
.table-row > span { padding: 8px 5px; }
|
||||||
|
.bid-form { justify-content: center; gap: 12px; margin-top: 14px; flex-wrap: wrap; }
|
||||||
|
.bid-form label, .open-form label { display: grid; gap: 5px; }
|
||||||
|
.open-form { align-items: end; gap: 10px; flex-wrap: wrap; }
|
||||||
|
.open-form label { min-width: 110px; flex: 1; }
|
||||||
|
.empty { padding: 14px; text-align: center; color: rgba(232, 221, 196, 0.6); }
|
||||||
|
.log-list { margin: 0; padding-left: 24px; }
|
||||||
|
.log-list li { padding: 4px 0; }
|
||||||
|
.detail-grid { display: grid; grid-template-columns: 130px 1fr 130px 1fr; gap: 1px; background: rgba(232, 221, 196, 0.18); }
|
||||||
|
.detail-grid dt, .detail-grid dd { margin: 0; padding: 9px; background: #11100d; }
|
||||||
|
.detail-grid dt { color: rgba(232, 221, 196, 0.65); }
|
||||||
|
.bid-history { margin-top: 12px; }
|
||||||
|
.bid-entry { justify-content: space-between; gap: 12px; padding: 7px 10px; border-bottom: 1px solid rgba(232, 221, 196, 0.14); }
|
||||||
|
.bid-entry time { color: rgba(232, 221, 196, 0.65); }
|
||||||
|
.me { color: aquamarine; font-weight: 700; }
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.auction-page { padding: 10px; }
|
||||||
|
.page-header { align-items: flex-start; }
|
||||||
|
.detail-grid { grid-template-columns: 110px 1fr; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -106,6 +106,7 @@ watch(
|
|||||||
<RouterLink class="ghost" to="/battle-simulator">전투 시뮬레이터</RouterLink>
|
<RouterLink class="ghost" to="/battle-simulator">전투 시뮬레이터</RouterLink>
|
||||||
<RouterLink class="ghost" to="/my-page">내 정보</RouterLink>
|
<RouterLink class="ghost" to="/my-page">내 정보</RouterLink>
|
||||||
<RouterLink class="ghost" to="/tournament">토너먼트</RouterLink>
|
<RouterLink class="ghost" to="/tournament">토너먼트</RouterLink>
|
||||||
|
<RouterLink class="ghost" to="/auction">거래장</RouterLink>
|
||||||
<RouterLink class="ghost" to="/survey">설문조사</RouterLink>
|
<RouterLink class="ghost" to="/survey">설문조사</RouterLink>
|
||||||
<RouterLink class="ghost" to="/npc-control">NPC 정책</RouterLink>
|
<RouterLink class="ghost" to="/npc-control">NPC 정책</RouterLink>
|
||||||
<RouterLink class="ghost" to="/inherit">유산 강화</RouterLink>
|
<RouterLink class="ghost" to="/inherit">유산 강화</RouterLink>
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ const ensureAdminGeneral = async (databaseUrl: string, adminUser: AdminSeedUser)
|
|||||||
turnTime,
|
turnTime,
|
||||||
meta: {
|
meta: {
|
||||||
createdBy: 'admin-seed',
|
createdBy: 'admin-seed',
|
||||||
|
killturn: 24,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -71,8 +71,36 @@ Event actions controlling lifecycle:
|
|||||||
|
|
||||||
## Open Questions / Follow-ups
|
## Open Questions / Follow-ups
|
||||||
|
|
||||||
- The exact schedule for `registerAuction()` is outside this file; it is
|
- `registerAuction()` is called from legacy `func_gamerule.php` after the
|
||||||
likely called from monthly or timed maintenance.
|
logical month changes. Its RNG is seeded before that change with the previous
|
||||||
- Unique-item auction close-date extension limits depend on
|
year/month, then consumes one power roll per nation and the conditional
|
||||||
`AuctionUniqueItem` constants and `turnterm`; verify scenarios that override
|
tournament roll before auction generation.
|
||||||
auction timing.
|
- Scenario-specific overrides of unique auction timing have not been found.
|
||||||
|
Core2026 currently applies the legacy constants against `tickSeconds`.
|
||||||
|
|
||||||
|
## core2026 compatibility implementation (2026-07-25)
|
||||||
|
|
||||||
|
| Contract | core2026 path | Status |
|
||||||
|
| -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
|
||||||
|
| Resource auction list/open/bid | `game-frontend/AuctionView.vue` → `game-api/router/auction` → `auctionOpen`/`auctionBid` engine commands | Confirmed by integration test |
|
||||||
|
| Resource reservation/refund/settlement | opener reserves host gold/rice; bidder reserves and refunds the previous top bid; finalizer transfers or returns resources | Confirmed by integration test |
|
||||||
|
| Unique list/detail privacy | API replaces host and bidder identity with deterministic aliases and never returns unique-auction general IDs | Confirmed by API implementation and typecheck |
|
||||||
|
| Unique open | engine validates configured quantity, slot ownership, one active host/item auction, and atomically inserts the host's first bid while deducting `inheritance_point.previous` | Confirmed by integration test |
|
||||||
|
| Unique bid | row-locked auction, minimum `max(1%, 10)`, conditional point deduction, previous-top-bid refund, slot/top-bid conflict checks | Confirmed by integration test |
|
||||||
|
| Unique finalization | requested extension, per-slot and total ownership-limit extension, host neutralization, item inventory update | Confirmed by integration test |
|
||||||
|
| Timer/worker | API updates the Redis timer after open/bid; existing scheduler claims due rows and sends durable `auctionFinalize` commands | Confirmed by integration test for timer seed and durable command finalization |
|
||||||
|
| Neutral merchant registration | `neutralRegistrar.ts` runs at the month boundary, reproduces the previous-month seed and preceding RNG consumption, and queues host `0` auctions in the same DB transaction as the month state | Confirmed by PHP differential, engine boundary, PostgreSQL idempotency, and full integration tests |
|
||||||
|
|
||||||
|
Unique auction mutations deliberately run in the turn-engine database
|
||||||
|
transaction. Point reads use row locks and deductions use conditional updates,
|
||||||
|
so simultaneous requests cannot spend the same previous-season inheritance
|
||||||
|
points twice. A failed open command creates neither an auction nor an orphaned
|
||||||
|
inheritance request marker.
|
||||||
|
|
||||||
|
Neutral registration intentionally counts every historical host-`0` resource
|
||||||
|
auction, including finished rows, because the legacy probability denominator
|
||||||
|
does not filter on `finished`. Average gold/rice includes only generals with
|
||||||
|
`npcState < 2` and is clamped to 1,000..20,000. The month marker and generated
|
||||||
|
auction rows are persisted together under a PostgreSQL advisory transaction
|
||||||
|
lock, preventing the same month from producing duplicate merchant auctions
|
||||||
|
during daemon retry.
|
||||||
|
|||||||
@@ -80,6 +80,17 @@ export type TurnDaemonCommand =
|
|||||||
}
|
}
|
||||||
| { type: 'dropItem'; requestId?: string; generalId: number; itemType: string }
|
| { type: 'dropItem'; requestId?: string; generalId: number; itemType: string }
|
||||||
| { type: 'auctionFinalize'; requestId?: string; auctionId: number }
|
| { type: 'auctionFinalize'; requestId?: string; auctionId: number }
|
||||||
|
| {
|
||||||
|
type: 'auctionOpen';
|
||||||
|
requestId?: string;
|
||||||
|
generalId: number;
|
||||||
|
auctionType: 'BUY_RICE' | 'SELL_RICE' | 'UNIQUE_ITEM';
|
||||||
|
amount: number;
|
||||||
|
closeTurnCnt?: number;
|
||||||
|
startBidAmount?: number;
|
||||||
|
finishBidAmount?: number;
|
||||||
|
itemKey?: string;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
type: 'changePermission';
|
type: 'changePermission';
|
||||||
requestId?: string;
|
requestId?: string;
|
||||||
@@ -226,6 +237,17 @@ export type TurnDaemonCommandResult =
|
|||||||
generalId: number;
|
generalId: number;
|
||||||
reason: string;
|
reason: string;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
type: 'auctionOpen';
|
||||||
|
ok: true;
|
||||||
|
auctionId: number;
|
||||||
|
closeAt: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'auctionOpen';
|
||||||
|
ok: false;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
type: 'troopJoin';
|
type: 'troopJoin';
|
||||||
ok: true;
|
ok: true;
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export interface DatabaseClient {
|
|||||||
nationTurn: GamePrisma.NationTurnDelegate;
|
nationTurn: GamePrisma.NationTurnDelegate;
|
||||||
troop: GamePrisma.TroopDelegate;
|
troop: GamePrisma.TroopDelegate;
|
||||||
logEntry: GamePrisma.LogEntryDelegate;
|
logEntry: GamePrisma.LogEntryDelegate;
|
||||||
|
auction: GamePrisma.AuctionDelegate;
|
||||||
|
auctionBid: GamePrisma.AuctionBidDelegate;
|
||||||
inheritancePoint: GamePrisma.InheritancePointDelegate;
|
inheritancePoint: GamePrisma.InheritancePointDelegate;
|
||||||
inheritanceLog: GamePrisma.InheritanceLogDelegate;
|
inheritanceLog: GamePrisma.InheritanceLogDelegate;
|
||||||
inheritanceResult: GamePrisma.InheritanceResultDelegate;
|
inheritanceResult: GamePrisma.InheritanceResultDelegate;
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
||||||
|
|
||||||
|
import { simpleSerialize } from '../war/utils.js';
|
||||||
|
|
||||||
|
const DEFAULT_FIRST_NAMES = [
|
||||||
|
'가', '간', '감', '강', '고', '공', '공손', '곽', '관', '괴', '교', '금', '노', '뇌', '능', '도', '동',
|
||||||
|
'두', '등', '마', '맹', '문', '미', '반', '방', '부', '비', '사', '사마', '서', '설', '성', '소', '손',
|
||||||
|
'송', '순', '신', '심', '악', '안', '양', '엄', '여', '염', '오', '왕', '요', '우', '원', '위', '유',
|
||||||
|
'육', '윤', '이', '장', '저', '전', '정', '제갈', '조', '종', '주', '진', '채', '태사', '하', '하후',
|
||||||
|
'학', '한', '향', '허', '호', '화', '황', '공손', '손', '왕', '유', '장', '조',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const DEFAULT_LAST_NAMES = [
|
||||||
|
'가', '간', '강', '거', '건', '검', '견', '경', '공', '광', '권', '규', '녕', '단', '대', '도', '등',
|
||||||
|
'람', '량', '례', '로', '료', '모', '민', '박', '범', '보', '비', '사', '상', '색', '서', '소', '속',
|
||||||
|
'송', '수', '순', '습', '승', '양', '연', '영', '온', '옹', '완', '우', '웅', '월', '위', '유', '윤',
|
||||||
|
'융', '이', '익', '임', '정', '제', '조', '주', '준', '지', '찬', '책', '충', '탁', '택', '통', '패',
|
||||||
|
'평', '포', '합', '해', '혁', '현', '화', '환', '회', '횡', '후', '훈', '휴', '흠', '흥',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const readNameParts = (value: unknown, fallback: readonly string[]): string[] => {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return [...fallback];
|
||||||
|
}
|
||||||
|
const result = value.filter((entry): entry is string => typeof entry === 'string');
|
||||||
|
return result.length > 0 ? result : [...fallback];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildAuctionAlias = (
|
||||||
|
generalId: number,
|
||||||
|
hiddenSeed: string | number,
|
||||||
|
configConst: Record<string, unknown> = {}
|
||||||
|
): string => {
|
||||||
|
const firstNames = readNameParts(configConst.randGenFirstName, DEFAULT_FIRST_NAMES);
|
||||||
|
const middleNames = readNameParts(configConst.randGenMiddleName, ['']);
|
||||||
|
const lastNames = readNameParts(configConst.randGenLastName, DEFAULT_LAST_NAMES);
|
||||||
|
const pool: string[] = [];
|
||||||
|
for (const first of firstNames) {
|
||||||
|
for (const middle of middleNames) {
|
||||||
|
for (const last of lastNames) {
|
||||||
|
pool.push(`${first}${middle}${last}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const shuffled = new RandUtil(
|
||||||
|
new LiteHashDRBG(simpleSerialize(hiddenSeed, 'obfuscatedNamePool'))
|
||||||
|
).shuffle(pool);
|
||||||
|
const normalizedId = Math.max(0, Math.floor(generalId));
|
||||||
|
const duplicateIndex = Math.floor(normalizedId / shuffled.length);
|
||||||
|
const name = shuffled[normalizedId % shuffled.length] ?? `익명${normalizedId}`;
|
||||||
|
return duplicateIndex === 0 ? name : `${name}${duplicateIndex}`;
|
||||||
|
};
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
||||||
|
|
||||||
|
import { simpleSerialize } from '../war/utils.js';
|
||||||
|
|
||||||
|
export type NeutralResourceAuctionType = 'BUY_RICE' | 'SELL_RICE';
|
||||||
|
|
||||||
|
export interface NeutralAuctionPlanInput {
|
||||||
|
hiddenSeed: string | number;
|
||||||
|
seedYear: number;
|
||||||
|
seedMonth: number;
|
||||||
|
nationCount: number;
|
||||||
|
consumeTournamentRoll: boolean;
|
||||||
|
averageGold: number;
|
||||||
|
averageRice: number;
|
||||||
|
buyRiceAuctionCount: number;
|
||||||
|
sellRiceAuctionCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NeutralResourceAuctionPlan {
|
||||||
|
auctionType: NeutralResourceAuctionType;
|
||||||
|
amount: number;
|
||||||
|
startBidAmount: number;
|
||||||
|
finishBidAmount: number;
|
||||||
|
closeTurnCnt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clamp = (value: number, min: number, max: number): number => {
|
||||||
|
if (max < min) {
|
||||||
|
return min;
|
||||||
|
}
|
||||||
|
return Math.min(max, Math.max(min, value));
|
||||||
|
};
|
||||||
|
|
||||||
|
const roundToTens = (value: number): number => Math.round(value / 10) * 10;
|
||||||
|
|
||||||
|
const normalizeCount = (value: number): number => (Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0);
|
||||||
|
|
||||||
|
const normalizeAverage = (value: number): number => clamp(Number.isFinite(value) ? value : 0, 1_000, 20_000);
|
||||||
|
|
||||||
|
const canOpenResourceAuction = (plan: NeutralResourceAuctionPlan): boolean =>
|
||||||
|
plan.closeTurnCnt >= 1 &&
|
||||||
|
plan.closeTurnCnt <= 24 &&
|
||||||
|
plan.amount >= 100 &&
|
||||||
|
plan.amount <= 10_000 &&
|
||||||
|
plan.startBidAmount >= plan.amount * 0.5 &&
|
||||||
|
plan.startBidAmount <= plan.amount * 2 &&
|
||||||
|
plan.finishBidAmount >= plan.amount * 1.1 &&
|
||||||
|
plan.finishBidAmount <= plan.amount * 2 &&
|
||||||
|
plan.finishBidAmount >= plan.startBidAmount * 1.1;
|
||||||
|
|
||||||
|
export const buildNeutralResourceAuctionPlan = (input: NeutralAuctionPlanInput): NeutralResourceAuctionPlan[] => {
|
||||||
|
// ref TurnExecutionHelper는 날짜를 넘기기 전에 이전 연월로 monthly RNG를 만든다.
|
||||||
|
const rng = new RandUtil(
|
||||||
|
new LiteHashDRBG(simpleSerialize(input.hiddenSeed, 'monthly', input.seedYear, input.seedMonth))
|
||||||
|
);
|
||||||
|
|
||||||
|
// ref postUpdateMonthly()의 국가 국력 보정이 registerAuction()보다 먼저 RNG를 소비한다.
|
||||||
|
for (let nationIdx = 0; nationIdx < normalizeCount(input.nationCount); nationIdx += 1) {
|
||||||
|
rng.nextRange(0.95, 1.05);
|
||||||
|
}
|
||||||
|
// 토너먼트가 없고 자동 개시가 켜진 경우 성공 여부와 무관하게 한 번 소비한다.
|
||||||
|
if (input.consumeTournamentRoll) {
|
||||||
|
rng.nextBool(0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
const averageGold = normalizeAverage(input.averageGold);
|
||||||
|
const averageRice = normalizeAverage(input.averageRice);
|
||||||
|
const result: NeutralResourceAuctionPlan[] = [];
|
||||||
|
|
||||||
|
const buyRiceAuctionCount = normalizeCount(input.buyRiceAuctionCount);
|
||||||
|
if (rng.nextBool(1 / (buyRiceAuctionCount + 5))) {
|
||||||
|
const multiplier = rng.nextRangeInt(1, 5);
|
||||||
|
const rawAmount = (averageRice / 20) * multiplier;
|
||||||
|
const rawStartBid = clamp((averageGold / 20) * 0.9 * multiplier, rawAmount * 0.8, rawAmount * 1.2);
|
||||||
|
const plan: NeutralResourceAuctionPlan = {
|
||||||
|
auctionType: 'BUY_RICE',
|
||||||
|
amount: roundToTens(rawAmount),
|
||||||
|
startBidAmount: roundToTens(rawStartBid),
|
||||||
|
finishBidAmount: roundToTens(rawAmount * 2),
|
||||||
|
closeTurnCnt: rng.nextRangeInt(3, 12),
|
||||||
|
};
|
||||||
|
if (canOpenResourceAuction(plan)) {
|
||||||
|
result.push(plan);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sellRiceAuctionCount = normalizeCount(input.sellRiceAuctionCount);
|
||||||
|
if (rng.nextBool(1 / (sellRiceAuctionCount + 5))) {
|
||||||
|
const multiplier = rng.nextRangeInt(1, 5);
|
||||||
|
const rawAmount = (averageGold / 20) * multiplier;
|
||||||
|
const rawStartBid = clamp((averageRice / 20) * 1.1 * multiplier, rawAmount * 0.8, rawAmount * 1.2);
|
||||||
|
const plan: NeutralResourceAuctionPlan = {
|
||||||
|
auctionType: 'SELL_RICE',
|
||||||
|
amount: roundToTens(rawAmount),
|
||||||
|
startBidAmount: roundToTens(rawStartBid),
|
||||||
|
finishBidAmount: roundToTens(rawAmount * 2),
|
||||||
|
closeTurnCnt: rng.nextRangeInt(3, 12),
|
||||||
|
};
|
||||||
|
if (canOpenResourceAuction(plan)) {
|
||||||
|
result.push(plan);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
export * from './domain/entities.js';
|
export * from './domain/entities.js';
|
||||||
export type { RandomGenerator } from '@sammo-ts/common';
|
export type { RandomGenerator } from '@sammo-ts/common';
|
||||||
export * from './actions/index.js';
|
export * from './actions/index.js';
|
||||||
|
export * from './auction/alias.js';
|
||||||
|
export * from './auction/neutral.js';
|
||||||
export * from './constraints/index.js';
|
export * from './constraints/index.js';
|
||||||
export * from './crewType/index.js';
|
export * from './crewType/index.js';
|
||||||
export * from './diplomacy/index.js';
|
export * from './diplomacy/index.js';
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { buildAuctionAlias } from '../src/auction/alias.js';
|
||||||
|
|
||||||
|
describe('buildAuctionAlias', () => {
|
||||||
|
it('returns a stable alias for the same world seed and general id', () => {
|
||||||
|
const first = buildAuctionAlias(17, 'legacy-compatible-seed');
|
||||||
|
const second = buildAuctionAlias(17, 'legacy-compatible-seed');
|
||||||
|
|
||||||
|
expect(second).toBe(first);
|
||||||
|
expect(first.length).toBeGreaterThan(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses scenario-specific name pools without exposing a general name', () => {
|
||||||
|
const config = {
|
||||||
|
randGenFirstName: ['청'],
|
||||||
|
randGenMiddleName: ['운'],
|
||||||
|
randGenLastName: ['객', '상'],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(buildAuctionAlias(0, 'seed', config)).toMatch(/^청운(객|상)$/);
|
||||||
|
expect(buildAuctionAlias(1, 'seed', config)).toMatch(/^청운(객|상)$/);
|
||||||
|
expect(buildAuctionAlias(2, 'seed', config)).toMatch(/^청운(객|상)1$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildNeutralResourceAuctionPlan,
|
||||||
|
type NeutralAuctionPlanInput,
|
||||||
|
type NeutralResourceAuctionPlan,
|
||||||
|
} from '../src/auction/neutral.js';
|
||||||
|
|
||||||
|
const refRoot = process.env.SAMMO_REF_ROOT;
|
||||||
|
const oraclePath = fileURLToPath(new URL('../../../tools/legacy-oracles/neutral-auction.php', import.meta.url));
|
||||||
|
|
||||||
|
const runLegacyOracle = (input: NeutralAuctionPlanInput): NeutralResourceAuctionPlan[] => {
|
||||||
|
if (!refRoot) {
|
||||||
|
throw new Error('SAMMO_REF_ROOT is required');
|
||||||
|
}
|
||||||
|
const result = spawnSync('php', [oraclePath, refRoot, JSON.stringify(input)], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
});
|
||||||
|
if (result.status !== 0) {
|
||||||
|
throw new Error(result.stderr || `legacy oracle exited with ${result.status}`);
|
||||||
|
}
|
||||||
|
return JSON.parse(result.stdout) as NeutralResourceAuctionPlan[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const fixtures: Array<{ input: NeutralAuctionPlanInput; expectedTypes: string[] }> = [
|
||||||
|
{
|
||||||
|
expectedTypes: ['BUY_RICE'],
|
||||||
|
input: {
|
||||||
|
hiddenSeed: 'merchant-11',
|
||||||
|
seedYear: 180,
|
||||||
|
seedMonth: 1,
|
||||||
|
nationCount: 3,
|
||||||
|
consumeTournamentRoll: false,
|
||||||
|
averageGold: 5_432,
|
||||||
|
averageRice: 7_654,
|
||||||
|
buyRiceAuctionCount: 0,
|
||||||
|
sellRiceAuctionCount: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expectedTypes: ['BUY_RICE', 'SELL_RICE'],
|
||||||
|
input: {
|
||||||
|
hiddenSeed: 'tournament-35',
|
||||||
|
seedYear: 191,
|
||||||
|
seedMonth: 12,
|
||||||
|
nationCount: 8,
|
||||||
|
consumeTournamentRoll: true,
|
||||||
|
averageGold: 25_000,
|
||||||
|
averageRice: 500,
|
||||||
|
buyRiceAuctionCount: 2,
|
||||||
|
sellRiceAuctionCount: 4,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expectedTypes: [],
|
||||||
|
input: {
|
||||||
|
hiddenSeed: 'merchant-32',
|
||||||
|
seedYear: 203,
|
||||||
|
seedMonth: 7,
|
||||||
|
nationCount: 3,
|
||||||
|
consumeTournamentRoll: false,
|
||||||
|
averageGold: 5_432,
|
||||||
|
averageRice: 7_654,
|
||||||
|
buyRiceAuctionCount: 0,
|
||||||
|
sellRiceAuctionCount: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe.skipIf(!refRoot)('neutral auction legacy PHP differential', () => {
|
||||||
|
for (const [index, fixture] of fixtures.entries()) {
|
||||||
|
it(`matches legacy RNG timing and amounts for fixture ${index + 1}`, () => {
|
||||||
|
const actual = buildNeutralResourceAuctionPlan(fixture.input);
|
||||||
|
expect(actual).toEqual(runLegacyOracle(fixture.input));
|
||||||
|
expect(actual.map((plan) => plan.auctionType)).toEqual(fixture.expectedTypes);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('matches a seed, month, count, tournament, and average-resource matrix', () => {
|
||||||
|
let generatedAuctions = 0;
|
||||||
|
const generatedTypes = new Set<string>();
|
||||||
|
for (let index = 0; index < 96; index += 1) {
|
||||||
|
const input: NeutralAuctionPlanInput = {
|
||||||
|
hiddenSeed: `neutral-matrix-${index}`,
|
||||||
|
seedYear: 180 + (index % 17),
|
||||||
|
seedMonth: (index % 12) + 1,
|
||||||
|
nationCount: index % 11,
|
||||||
|
consumeTournamentRoll: index % 2 === 0,
|
||||||
|
averageGold: 500 + ((index * 1_337) % 25_000),
|
||||||
|
averageRice: 500 + ((index * 2_111) % 25_000),
|
||||||
|
buyRiceAuctionCount: index % 9,
|
||||||
|
sellRiceAuctionCount: index % 13,
|
||||||
|
};
|
||||||
|
const actual = buildNeutralResourceAuctionPlan(input);
|
||||||
|
expect(actual, `matrix fixture ${index}`).toEqual(runLegacyOracle(input));
|
||||||
|
generatedAuctions += actual.length;
|
||||||
|
for (const plan of actual) {
|
||||||
|
generatedTypes.add(plan.auctionType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(generatedAuctions).toBeGreaterThan(0);
|
||||||
|
expect(generatedTypes).toEqual(new Set(['BUY_RICE', 'SELL_RICE']));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,8 +13,7 @@ import {
|
|||||||
createGameApiServer,
|
createGameApiServer,
|
||||||
buildAuctionTimerKeys,
|
buildAuctionTimerKeys,
|
||||||
seedAuctionTimers,
|
seedAuctionTimers,
|
||||||
buildTurnDaemonStreamKeys,
|
DatabaseTurnDaemonTransport,
|
||||||
RedisTurnDaemonTransport,
|
|
||||||
} from '@sammo-ts/game-api';
|
} from '@sammo-ts/game-api';
|
||||||
import { createTurnDaemonRuntime } from '@sammo-ts/game-engine';
|
import { createTurnDaemonRuntime } from '@sammo-ts/game-engine';
|
||||||
import {
|
import {
|
||||||
@@ -25,7 +24,7 @@ import {
|
|||||||
resolveRedisConfigFromEnv,
|
resolveRedisConfigFromEnv,
|
||||||
GamePrisma,
|
GamePrisma,
|
||||||
} from '@sammo-ts/infra';
|
} from '@sammo-ts/infra';
|
||||||
import { ItemLoader, ITEM_KEYS } from '@sammo-ts/logic';
|
import { buildNeutralResourceAuctionPlan, ItemLoader, ITEM_KEYS } from '@sammo-ts/logic';
|
||||||
|
|
||||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
const workspaceRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
|
const workspaceRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||||
@@ -221,6 +220,7 @@ describe('auction integration flow', () => {
|
|||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await loadEnv();
|
await loadEnv();
|
||||||
|
process.env.SCENARIO = '908';
|
||||||
process.chdir(workspaceRoot);
|
process.chdir(workspaceRoot);
|
||||||
await resetDatabase();
|
await resetDatabase();
|
||||||
await resetRedis();
|
await resetRedis();
|
||||||
@@ -271,15 +271,15 @@ describe('auction integration flow', () => {
|
|||||||
|
|
||||||
await gatewayClient.admin.profiles.upsert.mutate({
|
await gatewayClient.admin.profiles.upsert.mutate({
|
||||||
profile: 'che',
|
profile: 'che',
|
||||||
scenario: '2',
|
scenario: '908',
|
||||||
apiPort: Number(process.env.GAME_API_PORT ?? 14000),
|
apiPort: Number(process.env.GAME_API_PORT ?? 14000),
|
||||||
status: 'RUNNING',
|
status: 'RUNNING',
|
||||||
});
|
});
|
||||||
|
|
||||||
await gatewayClient.admin.profiles.installNow.mutate({
|
await gatewayClient.admin.profiles.installNow.mutate({
|
||||||
profileName: 'che:2',
|
profileName: 'che:908',
|
||||||
install: {
|
install: {
|
||||||
scenarioId: 2,
|
scenarioId: 908,
|
||||||
turnTermMinutes: 1,
|
turnTermMinutes: 1,
|
||||||
sync: false,
|
sync: false,
|
||||||
fiction: 0,
|
fiction: 0,
|
||||||
@@ -309,7 +309,7 @@ describe('auction integration flow', () => {
|
|||||||
});
|
});
|
||||||
const gatewayToken = await gatewayClient.auth.issueGameSession.mutate({
|
const gatewayToken = await gatewayClient.auth.issueGameSession.mutate({
|
||||||
sessionToken: login.sessionToken,
|
sessionToken: login.sessionToken,
|
||||||
profile: 'che:2',
|
profile: 'che:908',
|
||||||
});
|
});
|
||||||
const access = await gameClient.auth.exchangeGatewayToken.mutate({
|
const access = await gameClient.auth.exchangeGatewayToken.mutate({
|
||||||
gatewayToken: gatewayToken.gameToken,
|
gatewayToken: gatewayToken.gameToken,
|
||||||
@@ -335,7 +335,7 @@ describe('auction integration flow', () => {
|
|||||||
const gatewayDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'public' }).url;
|
const gatewayDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'public' }).url;
|
||||||
turnDaemon = await createTurnDaemonRuntime({
|
turnDaemon = await createTurnDaemonRuntime({
|
||||||
profile: 'che',
|
profile: 'che',
|
||||||
profileName: 'che:2',
|
profileName: 'che:908',
|
||||||
databaseUrl: gameDatabaseUrl,
|
databaseUrl: gameDatabaseUrl,
|
||||||
gatewayDatabaseUrl,
|
gatewayDatabaseUrl,
|
||||||
});
|
});
|
||||||
@@ -414,6 +414,9 @@ describe('auction integration flow', () => {
|
|||||||
where: { id: poorBidder.generalId },
|
where: { id: poorBidder.generalId },
|
||||||
data: { gold: 50, rice: 1000 },
|
data: { gold: 50, rice: 1000 },
|
||||||
});
|
});
|
||||||
|
await prisma.worldState.updateMany({
|
||||||
|
data: { currentMonth: 4 },
|
||||||
|
});
|
||||||
|
|
||||||
if (turnDaemon) {
|
if (turnDaemon) {
|
||||||
await turnDaemon.lifecycle.stop('integration-test');
|
await turnDaemon.lifecycle.stop('integration-test');
|
||||||
@@ -424,31 +427,24 @@ describe('auction integration flow', () => {
|
|||||||
const gatewayDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'public' }).url;
|
const gatewayDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'public' }).url;
|
||||||
turnDaemon = await createTurnDaemonRuntime({
|
turnDaemon = await createTurnDaemonRuntime({
|
||||||
profile: 'che',
|
profile: 'che',
|
||||||
profileName: 'che:2',
|
profileName: 'che:908',
|
||||||
databaseUrl: gameDatabaseUrl,
|
databaseUrl: gameDatabaseUrl,
|
||||||
gatewayDatabaseUrl,
|
gatewayDatabaseUrl,
|
||||||
});
|
});
|
||||||
turnDaemonLoop = turnDaemon.lifecycle.start();
|
turnDaemonLoop = turnDaemon.lifecycle.start();
|
||||||
await sleep(500);
|
await sleep(500);
|
||||||
|
|
||||||
const now = new Date();
|
const hostClient = createGameClient(gameUrl, gameServer.config.trpcPath, {
|
||||||
const initialCloseAt = new Date(now.getTime() + 10_000);
|
value: bidder3.accessToken,
|
||||||
const auction = await prisma.auction.create({
|
|
||||||
data: {
|
|
||||||
type: 'BUY_RICE',
|
|
||||||
targetCode: null,
|
|
||||||
hostGeneralId: 0,
|
|
||||||
hostName: '시스템',
|
|
||||||
detail: {
|
|
||||||
amount: 500,
|
|
||||||
startBidAmount: 100,
|
|
||||||
isReverse: false,
|
|
||||||
availableLatestBidCloseDate: new Date(now.getTime() + 5 * 60_000).toISOString(),
|
|
||||||
},
|
|
||||||
status: 'OPEN',
|
|
||||||
closeAt: initialCloseAt,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
const opened = await hostClient.auction.openBuyRice.mutate({
|
||||||
|
amount: 500,
|
||||||
|
closeTurnCnt: 1,
|
||||||
|
startBidAmount: 250,
|
||||||
|
finishBidAmount: 1000,
|
||||||
|
});
|
||||||
|
const auction = await prisma.auction.findUniqueOrThrow({ where: { id: opened.auctionId } });
|
||||||
|
const initialCloseAt = auction.closeAt;
|
||||||
|
|
||||||
const keys = buildAuctionTimerKeys(gameServer.config.profileName);
|
const keys = buildAuctionTimerKeys(gameServer.config.profileName);
|
||||||
await seedAuctionTimers(prisma, redis, keys);
|
await seedAuctionTimers(prisma, redis, keys);
|
||||||
@@ -458,7 +454,7 @@ describe('auction integration flow', () => {
|
|||||||
const bidder1Client = createGameClient(gameUrl, gameServer.config.trpcPath, {
|
const bidder1Client = createGameClient(gameUrl, gameServer.config.trpcPath, {
|
||||||
value: bidder1.accessToken,
|
value: bidder1.accessToken,
|
||||||
});
|
});
|
||||||
await bidder1Client.auction.bidBuyRice.mutate({ auctionId: auction.id, amount: 200 });
|
await bidder1Client.auction.bidBuyRice.mutate({ auctionId: auction.id, amount: 300 });
|
||||||
|
|
||||||
const bidRows = await prisma.auctionBid.findMany({ where: { auctionId: auction.id } });
|
const bidRows = await prisma.auctionBid.findMany({ where: { auctionId: auction.id } });
|
||||||
expect(bidRows).toHaveLength(1);
|
expect(bidRows).toHaveLength(1);
|
||||||
@@ -466,9 +462,9 @@ describe('auction integration flow', () => {
|
|||||||
const poorClient = createGameClient(gameUrl, gameServer.config.trpcPath, {
|
const poorClient = createGameClient(gameUrl, gameServer.config.trpcPath, {
|
||||||
value: poorBidder.accessToken,
|
value: poorBidder.accessToken,
|
||||||
});
|
});
|
||||||
await expect(
|
await expect(poorClient.auction.bidBuyRice.mutate({ auctionId: auction.id, amount: 500 })).rejects.toThrow(
|
||||||
poorClient.auction.bidBuyRice.mutate({ auctionId: auction.id, amount: 500 })
|
'금이 부족합니다.'
|
||||||
).rejects.toThrow('금이 부족합니다.');
|
);
|
||||||
|
|
||||||
const updatedAuction = await prisma.auction.findUnique({
|
const updatedAuction = await prisma.auction.findUnique({
|
||||||
where: { id: auction.id },
|
where: { id: auction.id },
|
||||||
@@ -487,10 +483,7 @@ describe('auction integration flow', () => {
|
|||||||
});
|
});
|
||||||
await redis.zAdd(keys.timerKey, [{ score: finalizeAt.getTime(), value: String(auction.id) }]);
|
await redis.zAdd(keys.timerKey, [{ score: finalizeAt.getTime(), value: String(auction.id) }]);
|
||||||
|
|
||||||
const transport = new RedisTurnDaemonTransport(redis, {
|
const transport = new DatabaseTurnDaemonTransport(prisma, 30_000);
|
||||||
keys: buildTurnDaemonStreamKeys(gameServer.config.profileName),
|
|
||||||
requestTimeoutMs: 10_000,
|
|
||||||
});
|
|
||||||
await prisma.$executeRaw(
|
await prisma.$executeRaw(
|
||||||
GamePrisma.sql`
|
GamePrisma.sql`
|
||||||
UPDATE auction
|
UPDATE auction
|
||||||
@@ -500,7 +493,7 @@ describe('auction integration flow', () => {
|
|||||||
WHERE id = ${auction.id}
|
WHERE id = ${auction.id}
|
||||||
`
|
`
|
||||||
);
|
);
|
||||||
const result = await transport.requestCommand({ type: 'auctionFinalize', auctionId: auction.id }, 10_000);
|
const result = await transport.requestCommand({ type: 'auctionFinalize', auctionId: auction.id }, 30_000);
|
||||||
expect(result?.ok).toBe(true);
|
expect(result?.ok).toBe(true);
|
||||||
|
|
||||||
const finished = await prisma.auction.findUnique({
|
const finished = await prisma.auction.findUnique({
|
||||||
@@ -592,9 +585,9 @@ describe('auction integration flow', () => {
|
|||||||
const ownerClient = createGameClient(gameUrl, gameServer.config.trpcPath, {
|
const ownerClient = createGameClient(gameUrl, gameServer.config.trpcPath, {
|
||||||
value: ownerBidder.accessToken,
|
value: ownerBidder.accessToken,
|
||||||
});
|
});
|
||||||
await expect(
|
await expect(ownerClient.auction.bidUnique.mutate({ auctionId: auction.id, amount: 300 })).rejects.toThrow(
|
||||||
ownerClient.auction.bidUnique.mutate({ auctionId: auction.id, amount: 300 })
|
'이미 다른 유니크를 가지고 있습니다.'
|
||||||
).rejects.toThrow('이미 다른 유니크를 가지고 있습니다.');
|
);
|
||||||
|
|
||||||
const validClient = createGameClient(gameUrl, gameServer.config.trpcPath, {
|
const validClient = createGameClient(gameUrl, gameServer.config.trpcPath, {
|
||||||
value: validBidder.accessToken,
|
value: validBidder.accessToken,
|
||||||
@@ -622,7 +615,7 @@ describe('auction integration flow', () => {
|
|||||||
const gatewayDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'public' }).url;
|
const gatewayDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'public' }).url;
|
||||||
turnDaemon = await createTurnDaemonRuntime({
|
turnDaemon = await createTurnDaemonRuntime({
|
||||||
profile: 'che',
|
profile: 'che',
|
||||||
profileName: 'che:2',
|
profileName: 'che:908',
|
||||||
databaseUrl: gameDatabaseUrl,
|
databaseUrl: gameDatabaseUrl,
|
||||||
gatewayDatabaseUrl,
|
gatewayDatabaseUrl,
|
||||||
});
|
});
|
||||||
@@ -636,10 +629,7 @@ describe('auction integration flow', () => {
|
|||||||
});
|
});
|
||||||
await redis.zAdd(keys.timerKey, [{ score: finalizeAt.getTime(), value: String(auction.id) }]);
|
await redis.zAdd(keys.timerKey, [{ score: finalizeAt.getTime(), value: String(auction.id) }]);
|
||||||
|
|
||||||
const transport = new RedisTurnDaemonTransport(redis, {
|
const transport = new DatabaseTurnDaemonTransport(prisma, 30_000);
|
||||||
keys: buildTurnDaemonStreamKeys(gameServer.config.profileName),
|
|
||||||
requestTimeoutMs: 10_000,
|
|
||||||
});
|
|
||||||
await prisma.$executeRaw(
|
await prisma.$executeRaw(
|
||||||
GamePrisma.sql`
|
GamePrisma.sql`
|
||||||
UPDATE auction
|
UPDATE auction
|
||||||
@@ -649,7 +639,7 @@ describe('auction integration flow', () => {
|
|||||||
WHERE id = ${auction.id}
|
WHERE id = ${auction.id}
|
||||||
`
|
`
|
||||||
);
|
);
|
||||||
const result = await transport.requestCommand({ type: 'auctionFinalize', auctionId: auction.id }, 10_000);
|
const result = await transport.requestCommand({ type: 'auctionFinalize', auctionId: auction.id }, 30_000);
|
||||||
expect(result?.ok).toBe(false);
|
expect(result?.ok).toBe(false);
|
||||||
|
|
||||||
const reopened = await prisma.auction.findUnique({
|
const reopened = await prisma.auction.findUnique({
|
||||||
@@ -686,16 +676,20 @@ describe('auction integration flow', () => {
|
|||||||
where: { id: bidderB.generalId },
|
where: { id: bidderB.generalId },
|
||||||
data: { weaponCode: 'None', bookCode: 'None', horseCode: 'None', itemCode: 'None' },
|
data: { weaponCode: 'None', bookCode: 'None', horseCode: 'None', itemCode: 'None' },
|
||||||
});
|
});
|
||||||
|
await prisma.general.updateMany({
|
||||||
|
where: { [slotField]: uniquePair.keyA } as GamePrisma.GeneralWhereInput,
|
||||||
|
data: { [slotField]: 'None' } as GamePrisma.GeneralUpdateManyMutationInput,
|
||||||
|
});
|
||||||
|
|
||||||
await prisma.inheritancePoint.upsert({
|
await prisma.inheritancePoint.upsert({
|
||||||
where: { userId_key: { userId: bidderA.userId, key: 'previous' } },
|
where: { userId_key: { userId: bidderA.userId, key: 'previous' } },
|
||||||
update: { value: 5000 },
|
update: { value: 100_000 },
|
||||||
create: { userId: bidderA.userId, key: 'previous', value: 5000 },
|
create: { userId: bidderA.userId, key: 'previous', value: 100_000 },
|
||||||
});
|
});
|
||||||
await prisma.inheritancePoint.upsert({
|
await prisma.inheritancePoint.upsert({
|
||||||
where: { userId_key: { userId: bidderB.userId, key: 'previous' } },
|
where: { userId_key: { userId: bidderB.userId, key: 'previous' } },
|
||||||
update: { value: 5000 },
|
update: { value: 100_000 },
|
||||||
create: { userId: bidderB.userId, key: 'previous', value: 5000 },
|
create: { userId: bidderB.userId, key: 'previous', value: 100_000 },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (turnDaemon) {
|
if (turnDaemon) {
|
||||||
@@ -707,7 +701,7 @@ describe('auction integration flow', () => {
|
|||||||
const gatewayDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'public' }).url;
|
const gatewayDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'public' }).url;
|
||||||
turnDaemon = await createTurnDaemonRuntime({
|
turnDaemon = await createTurnDaemonRuntime({
|
||||||
profile: 'che',
|
profile: 'che',
|
||||||
profileName: 'che:2',
|
profileName: 'che:908',
|
||||||
databaseUrl: gameDatabaseUrl,
|
databaseUrl: gameDatabaseUrl,
|
||||||
gatewayDatabaseUrl,
|
gatewayDatabaseUrl,
|
||||||
});
|
});
|
||||||
@@ -729,32 +723,39 @@ describe('auction integration flow', () => {
|
|||||||
const keys = buildAuctionTimerKeys(gameServer.config.profileName);
|
const keys = buildAuctionTimerKeys(gameServer.config.profileName);
|
||||||
await redis.del(keys.timerKey);
|
await redis.del(keys.timerKey);
|
||||||
|
|
||||||
const limitCloseAt = new Date(now.getTime() + 60_000);
|
const bidderAClient = createGameClient(gameUrl, gameServer.config.trpcPath, { value: bidderA.accessToken });
|
||||||
const auction = await prisma.auction.create({
|
const bidderBClient = createGameClient(gameUrl, gameServer.config.trpcPath, { value: bidderB.accessToken });
|
||||||
data: {
|
const opened = await bidderAClient.auction.openUnique.mutate({
|
||||||
type: 'UNIQUE_ITEM',
|
itemKey: uniquePair.keyA,
|
||||||
targetCode: uniquePair.keyA,
|
amount: 5000,
|
||||||
hostGeneralId: 0,
|
|
||||||
hostName: '시스템',
|
|
||||||
detail: {
|
|
||||||
startBidAmount: 200,
|
|
||||||
isReverse: false,
|
|
||||||
availableLatestBidCloseDate: limitCloseAt.toISOString(),
|
|
||||||
},
|
|
||||||
status: 'OPEN',
|
|
||||||
closeAt: new Date(now.getTime() + 2000),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
const limitCloseAt = new Date(now.getTime() + 60_000);
|
||||||
|
await prisma.$executeRaw(
|
||||||
|
GamePrisma.sql`
|
||||||
|
UPDATE auction
|
||||||
|
SET close_at = ${new Date(now.getTime() + 2000)},
|
||||||
|
detail = jsonb_set(
|
||||||
|
jsonb_set(
|
||||||
|
detail,
|
||||||
|
'{availableLatestBidCloseDate}',
|
||||||
|
to_jsonb(${limitCloseAt.toISOString()}::text)
|
||||||
|
),
|
||||||
|
'{remainCloseDateExtensionCnt}',
|
||||||
|
'0'::jsonb
|
||||||
|
),
|
||||||
|
updated_at = ${now}
|
||||||
|
WHERE id = ${opened.auctionId}
|
||||||
|
`
|
||||||
|
);
|
||||||
|
const auction = await prisma.auction.findUniqueOrThrow({ where: { id: opened.auctionId } });
|
||||||
|
|
||||||
await seedAuctionTimers(prisma, redis, keys);
|
await seedAuctionTimers(prisma, redis, keys);
|
||||||
|
|
||||||
const bidderAClient = createGameClient(gameUrl, gameServer.config.trpcPath, { value: bidderA.accessToken });
|
await bidderBClient.auction.bidUnique.mutate({ auctionId: auction.id, amount: 5100, tryExtendCloseDate: true });
|
||||||
const bidderBClient = createGameClient(gameUrl, gameServer.config.trpcPath, { value: bidderB.accessToken });
|
await bidderAClient.auction.bidUnique.mutate({ auctionId: auction.id, amount: 5200, tryExtendCloseDate: true });
|
||||||
|
await bidderBClient.auction.bidUnique.mutate({ auctionId: auction.id, amount: 5300, tryExtendCloseDate: true });
|
||||||
await bidderAClient.auction.bidUnique.mutate({ auctionId: auction.id, amount: 300, tryExtendCloseDate: true });
|
await bidderAClient.auction.bidUnique.mutate({ auctionId: auction.id, amount: 5400, tryExtendCloseDate: true });
|
||||||
await bidderBClient.auction.bidUnique.mutate({ auctionId: auction.id, amount: 350, tryExtendCloseDate: true });
|
await bidderBClient.auction.bidUnique.mutate({ auctionId: auction.id, amount: 5500, tryExtendCloseDate: true });
|
||||||
await bidderAClient.auction.bidUnique.mutate({ auctionId: auction.id, amount: 420, tryExtendCloseDate: true });
|
|
||||||
await bidderBClient.auction.bidUnique.mutate({ auctionId: auction.id, amount: 500, tryExtendCloseDate: true });
|
|
||||||
|
|
||||||
const afterBids = await prisma.auction.findUnique({
|
const afterBids = await prisma.auction.findUnique({
|
||||||
where: { id: auction.id },
|
where: { id: auction.id },
|
||||||
@@ -770,10 +771,7 @@ describe('auction integration flow', () => {
|
|||||||
});
|
});
|
||||||
await redis.zAdd(keys.timerKey, [{ score: finalizeAt.getTime(), value: String(auction.id) }]);
|
await redis.zAdd(keys.timerKey, [{ score: finalizeAt.getTime(), value: String(auction.id) }]);
|
||||||
|
|
||||||
const transport = new RedisTurnDaemonTransport(redis, {
|
const transport = new DatabaseTurnDaemonTransport(prisma, 30_000);
|
||||||
keys: buildTurnDaemonStreamKeys(gameServer.config.profileName),
|
|
||||||
requestTimeoutMs: 10_000,
|
|
||||||
});
|
|
||||||
await prisma.$executeRaw(
|
await prisma.$executeRaw(
|
||||||
GamePrisma.sql`
|
GamePrisma.sql`
|
||||||
UPDATE auction
|
UPDATE auction
|
||||||
@@ -783,7 +781,7 @@ describe('auction integration flow', () => {
|
|||||||
WHERE id = ${auction.id}
|
WHERE id = ${auction.id}
|
||||||
`
|
`
|
||||||
);
|
);
|
||||||
const result = await transport.requestCommand({ type: 'auctionFinalize', auctionId: auction.id }, 10_000);
|
const result = await transport.requestCommand({ type: 'auctionFinalize', auctionId: auction.id }, 30_000);
|
||||||
expect(result?.ok).toBe(true);
|
expect(result?.ok).toBe(true);
|
||||||
|
|
||||||
const winner = await prisma.general.findUnique({
|
const winner = await prisma.general.findUnique({
|
||||||
@@ -793,4 +791,138 @@ describe('auction integration flow', () => {
|
|||||||
expect(winner).not.toBeNull();
|
expect(winner).not.toBeNull();
|
||||||
expect(Object.values(winner!)).toContain(uniquePair.keyA);
|
expect(Object.values(winner!)).toContain(uniquePair.keyA);
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
|
|
||||||
|
it('opens the same neutral merchant auctions on the legacy monthly boundary', async () => {
|
||||||
|
if (!gameConnector) {
|
||||||
|
throw new Error('runtime not ready');
|
||||||
|
}
|
||||||
|
const prisma = gameConnector.prisma;
|
||||||
|
if (turnDaemon) {
|
||||||
|
await turnDaemon.lifecycle.stop('integration-test');
|
||||||
|
await turnDaemon.close();
|
||||||
|
await turnDaemonLoop;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.auction.deleteMany({
|
||||||
|
where: {
|
||||||
|
hostGeneralId: 0,
|
||||||
|
type: { in: ['BUY_RICE', 'SELL_RICE'] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const futureTurn = new Date(Date.now() + 60 * 60_000);
|
||||||
|
await prisma.general.updateMany({
|
||||||
|
where: { npcState: { lt: 2 } },
|
||||||
|
data: {
|
||||||
|
gold: 5_432,
|
||||||
|
rice: 7_654,
|
||||||
|
turnTime: futureTurn,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const nationCount = await prisma.nation.count();
|
||||||
|
let hiddenSeed = '';
|
||||||
|
let expected = [] as ReturnType<typeof buildNeutralResourceAuctionPlan>;
|
||||||
|
for (let index = 0; index < 1_000; index += 1) {
|
||||||
|
hiddenSeed = `integration-neutral-${index}`;
|
||||||
|
expected = buildNeutralResourceAuctionPlan({
|
||||||
|
hiddenSeed,
|
||||||
|
seedYear: 180,
|
||||||
|
seedMonth: 1,
|
||||||
|
nationCount,
|
||||||
|
consumeTournamentRoll: false,
|
||||||
|
averageGold: 5_432,
|
||||||
|
averageRice: 7_654,
|
||||||
|
buyRiceAuctionCount: 0,
|
||||||
|
sellRiceAuctionCount: 0,
|
||||||
|
});
|
||||||
|
if (expected.length > 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(expected.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const worldState = await prisma.worldState.findFirstOrThrow();
|
||||||
|
const worldMeta =
|
||||||
|
worldState.meta && typeof worldState.meta === 'object' && !Array.isArray(worldState.meta)
|
||||||
|
? worldState.meta
|
||||||
|
: {};
|
||||||
|
await prisma.worldState.update({
|
||||||
|
where: { id: worldState.id },
|
||||||
|
data: {
|
||||||
|
currentYear: 180,
|
||||||
|
currentMonth: 1,
|
||||||
|
tickSeconds: 60,
|
||||||
|
meta: {
|
||||||
|
...worldMeta,
|
||||||
|
hiddenSeed,
|
||||||
|
lastTurnTime: new Date(Date.now() - 61_000).toISOString(),
|
||||||
|
neutralAuctionRegistrationKey: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const gameDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'che' }).url;
|
||||||
|
const gatewayDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'public' }).url;
|
||||||
|
turnDaemon = await createTurnDaemonRuntime({
|
||||||
|
profile: 'che',
|
||||||
|
profileName: 'che:908',
|
||||||
|
databaseUrl: gameDatabaseUrl,
|
||||||
|
gatewayDatabaseUrl,
|
||||||
|
});
|
||||||
|
turnDaemonLoop = turnDaemon.lifecycle.start();
|
||||||
|
|
||||||
|
const deadline = Date.now() + 10_000;
|
||||||
|
let rows = await prisma.auction.findMany({
|
||||||
|
where: {
|
||||||
|
hostGeneralId: 0,
|
||||||
|
type: { in: ['BUY_RICE', 'SELL_RICE'] },
|
||||||
|
},
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
});
|
||||||
|
while (rows.length < expected.length && Date.now() < deadline) {
|
||||||
|
await sleep(100);
|
||||||
|
rows = await prisma.auction.findMany({
|
||||||
|
where: {
|
||||||
|
hostGeneralId: 0,
|
||||||
|
type: { in: ['BUY_RICE', 'SELL_RICE'] },
|
||||||
|
},
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(rows).toHaveLength(expected.length);
|
||||||
|
for (const [index, plan] of expected.entries()) {
|
||||||
|
const row = rows[index]!;
|
||||||
|
const detail = row.detail as Record<string, unknown>;
|
||||||
|
expect(row).toMatchObject({
|
||||||
|
type: plan.auctionType,
|
||||||
|
targetCode: String(plan.amount),
|
||||||
|
hostGeneralId: 0,
|
||||||
|
hostName: '상인',
|
||||||
|
status: 'OPEN',
|
||||||
|
});
|
||||||
|
expect(detail).toMatchObject({
|
||||||
|
amount: plan.amount,
|
||||||
|
startBidAmount: plan.startBidAmount,
|
||||||
|
finishBidAmount: plan.finishBidAmount,
|
||||||
|
closeTurnCnt: plan.closeTurnCnt,
|
||||||
|
seedYear: 180,
|
||||||
|
seedMonth: 1,
|
||||||
|
neutralRegistrationKey: '180-02',
|
||||||
|
});
|
||||||
|
expect(row.closeAt.getTime() - row.createdAt.getTime()).toBeGreaterThanOrEqual(
|
||||||
|
plan.closeTurnCnt * 60_000 - 2_000
|
||||||
|
);
|
||||||
|
expect(row.closeAt.getTime() - row.createdAt.getTime()).toBeLessThanOrEqual(
|
||||||
|
plan.closeTurnCnt * 60_000 + 2_000
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const persistedState = await prisma.worldState.findUniqueOrThrow({
|
||||||
|
where: { id: worldState.id },
|
||||||
|
});
|
||||||
|
expect(persistedState).toMatchObject({
|
||||||
|
currentYear: 180,
|
||||||
|
currentMonth: 2,
|
||||||
|
meta: expect.objectContaining({ neutralAuctionRegistrationKey: '180-02' }),
|
||||||
|
});
|
||||||
|
}, 60_000);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use sammo\LiteHashDRBG;
|
||||||
|
use sammo\RandUtil;
|
||||||
|
use sammo\Util;
|
||||||
|
|
||||||
|
if ($argc !== 3) {
|
||||||
|
fwrite(STDERR, "usage: php neutral-auction.php <ref-root> <json-input>\n");
|
||||||
|
exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
$refRoot = rtrim($argv[1], '/');
|
||||||
|
require $refRoot . '/vendor/autoload.php';
|
||||||
|
|
||||||
|
$input = json_decode($argv[2], true, flags: JSON_THROW_ON_ERROR);
|
||||||
|
$rng = new RandUtil(new LiteHashDRBG(Util::simpleSerialize(
|
||||||
|
$input['hiddenSeed'],
|
||||||
|
'monthly',
|
||||||
|
$input['seedYear'],
|
||||||
|
$input['seedMonth'],
|
||||||
|
)));
|
||||||
|
|
||||||
|
for ($idx = 0; $idx < max(0, (int)$input['nationCount']); $idx++) {
|
||||||
|
$rng->nextRange(0.95, 1.05);
|
||||||
|
}
|
||||||
|
if ($input['consumeTournamentRoll']) {
|
||||||
|
$rng->nextBool(0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
$avgGold = Util::valueFit($input['averageGold'], 1000, 20000);
|
||||||
|
$avgRice = Util::valueFit($input['averageRice'], 1000, 20000);
|
||||||
|
$result = [];
|
||||||
|
$appendIfOpenable = static function (array $plan) use (&$result): void {
|
||||||
|
if ($plan['closeTurnCnt'] < 1 || $plan['closeTurnCnt'] > 24) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($plan['amount'] < 100 || $plan['amount'] > 10000) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($plan['startBidAmount'] < $plan['amount'] * 0.5 || $plan['amount'] * 2 < $plan['startBidAmount']) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($plan['finishBidAmount'] < $plan['amount'] * 1.1 || $plan['amount'] * 2 < $plan['finishBidAmount']) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($plan['finishBidAmount'] < $plan['startBidAmount'] * 1.1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$result[] = $plan;
|
||||||
|
};
|
||||||
|
|
||||||
|
$buyRiceCount = max(0, (int)$input['buyRiceAuctionCount']);
|
||||||
|
if ($rng->nextBool(1 / ($buyRiceCount + 5))) {
|
||||||
|
$mul = $rng->nextRangeInt(1, 5);
|
||||||
|
$amount = $avgRice / 20 * $mul;
|
||||||
|
$cost = $avgGold / 20 * 0.9 * $mul;
|
||||||
|
$topv = $amount * 2;
|
||||||
|
$cost = Util::valueFit($cost, $amount * 0.8, $amount * 1.2);
|
||||||
|
$appendIfOpenable([
|
||||||
|
'auctionType' => 'BUY_RICE',
|
||||||
|
'amount' => Util::round($amount, -1),
|
||||||
|
'startBidAmount' => Util::round($cost, -1),
|
||||||
|
'finishBidAmount' => Util::round($topv, -1),
|
||||||
|
'closeTurnCnt' => $rng->nextRangeInt(3, 12),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$sellRiceCount = max(0, (int)$input['sellRiceAuctionCount']);
|
||||||
|
if ($rng->nextBool(1 / ($sellRiceCount + 5))) {
|
||||||
|
$mul = $rng->nextRangeInt(1, 5);
|
||||||
|
$amount = $avgGold / 20 * $mul;
|
||||||
|
$cost = $avgRice / 20 * 1.1 * $mul;
|
||||||
|
$topv = $amount * 2;
|
||||||
|
$cost = Util::valueFit($cost, $amount * 0.8, $amount * 1.2);
|
||||||
|
$appendIfOpenable([
|
||||||
|
'auctionType' => 'SELL_RICE',
|
||||||
|
'amount' => Util::round($amount, -1),
|
||||||
|
'startBidAmount' => Util::round($cost, -1),
|
||||||
|
'finishBidAmount' => Util::round($topv, -1),
|
||||||
|
'closeTurnCnt' => $rng->nextRangeInt(3, 12),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode($result, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), PHP_EOL;
|
||||||
Reference in New Issue
Block a user