feat: complete legacy-compatible auction system

This commit is contained in:
2026-07-25 10:43:49 +00:00
parent 26c3d5ce16
commit 93ae4df519
21 changed files with 1355 additions and 216 deletions
+44
View File
@@ -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,
};
};
+191 -1
View File
@@ -6,6 +6,9 @@ import type { DatabaseClient, GameApiContext, GeneralRow } from '../../context.j
import { buildAuctionTimerKeys } from '../../auction/keys.js';
import { GamePrisma } from '@sammo-ts/infra';
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({
@@ -17,6 +20,18 @@ const zUniqueBidInput = zBidInput.extend({
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';
interface AuctionRow {
@@ -29,7 +44,7 @@ interface AuctionRow {
closeAt: Date;
}
interface AuctionDetail {
export interface AuctionDetail {
title?: string;
amount?: number;
isReverse?: boolean;
@@ -161,6 +176,169 @@ const shouldUsePrevBid = (highestBid: AuctionBidRow | null, myPrevBid: AuctionBi
};
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 }) => {
const auth = requireAuth(ctx);
const general = await ensureGeneral(ctx.db, auth.user.id);
@@ -176,6 +354,9 @@ export const auctionRouter = router({
if (auction.closeAt <= now) {
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 amount = detail.amount ?? 0;
@@ -183,6 +364,9 @@ export const auctionRouter = router({
throw new TRPCError({ code: 'BAD_REQUEST', message: '거래량 정보가 없습니다.' });
}
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 myPrevBidRaw = await loadMyPrevBid(ctx.db, auction.id, general.id, isReverse);
const myPrevBid = shouldUsePrevBid(highestBid, myPrevBidRaw);
@@ -241,6 +425,9 @@ export const auctionRouter = router({
if (auction.closeAt <= now) {
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 amount = detail.amount ?? 0;
@@ -248,6 +435,9 @@ export const auctionRouter = router({
throw new TRPCError({ code: 'BAD_REQUEST', message: '거래량 정보가 없습니다.' });
}
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 myPrevBidRaw = await loadMyPrevBid(ctx.db, auction.id, general.id, isReverse);
const myPrevBid = shouldUsePrevBid(highestBid, myPrevBidRaw);
+7 -30
View File
@@ -17,6 +17,7 @@ import {
writeUserStateMeta,
} from '../../services/inheritance.js';
import type { GameApiContext, WorldStateRow } from '../../context.js';
import { openAuctionWithDaemon } from '../../auction/open.js';
const BUFF_KEYS: InheritBuffType[] = [
'warAvoidRatio',
@@ -749,43 +750,19 @@ export const inheritRouter = router({
if (input.amount < inheritConst.inheritItemUniqueMinPoint) {
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({
where: { userId },
select: { id: true, meta: true },
select: { id: true },
});
if (!general) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '장수가 존재하지 않습니다.' });
}
const meta = asRecord(general.meta);
if (meta.inheritSpecificUnique) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 유니크 경매 신청이 있습니다.' });
}
await patchGeneral(ctx, general.id, {
meta: {
...meta,
inheritSpecificUnique: JSON.stringify({
itemId: input.itemId,
amount: input.amount,
requestedAt: new Date().toISOString(),
}),
},
const result = await openAuctionWithDaemon(ctx, general.id, {
auctionType: 'UNIQUE_ITEM',
itemKey: input.itemId,
amount: input.amount,
});
await setInheritancePoint(ctx.db, userId, 'previous', currentPoint - input.amount);
await appendInheritanceLog(
ctx.db,
userId,
worldState.currentYear,
worldState.currentMonth,
`${input.amount} 포인트로 유니크 경매 신청`
);
return { ok: true };
return { ok: true, ...result };
}),
checkOwner: authedProcedure
.input(
+6 -3
View File
@@ -2,7 +2,7 @@ import { TRPCError } from '@trpc/server';
import { z } from 'zod';
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 { asNumber, asRecord, asStringArray, LiteHashDRBG } from '@sammo-ts/common';
import {
@@ -361,7 +361,7 @@ export const joinRouter = router({
? input.character
: 'None';
return ctx.db.$transaction!(async (db) => {
const createGeneral = async (db: DatabaseClient) => {
const existing = await db.general.findFirst({ where: { userId } });
if (existing) {
throw new TRPCError({
@@ -514,6 +514,7 @@ export const joinRouter = router({
meta: {
createdBy: 'join',
ownerName: ctx.auth?.user.displayName ?? '',
killturn: 24,
},
},
});
@@ -526,7 +527,9 @@ export const joinRouter = router({
}
return { ok: true, generalId: general.id };
});
};
return ctx.db.$transaction ? ctx.db.$transaction(createGeneral) : createGeneral(ctx.db);
}),
listPossessCandidates: authedProcedure
.input(
+10 -12
View File
@@ -1,4 +1,5 @@
import { asNumber, asRecord } from '@sammo-ts/common';
import { GamePrisma } from '@sammo-ts/infra';
import type { DatabaseClient, WorldStateRow, InputJsonValue } from '../context.js';
export type InheritPointKey =
@@ -116,18 +117,15 @@ export const readInheritancePoint = async (
userId: string,
key: InheritPointKey
): Promise<number> => {
const row = await db.inheritancePoint.findUnique({
where: {
userId_key: {
userId,
key,
},
},
select: {
value: true,
},
});
return row?.value ?? 0;
const rows = await db.$queryRaw<Array<{ value: number }>>(
GamePrisma.sql`
SELECT value
FROM inheritance_point
WHERE user_id = ${userId} AND key = ${key}
FOR UPDATE
`
);
return rows[0]?.value ?? 0;
};
export const setInheritancePoint = async (
+58 -40
View File
@@ -23,6 +23,7 @@ const MIN_EXTENSION_MINUTES_PER_BID = 1;
interface AuctionRow {
id: number;
type: AuctionType;
hostGeneralId: number;
detail: unknown;
status: AuctionStatus;
closeAt: Date;
@@ -37,6 +38,7 @@ interface AuctionBidRow {
interface AuctionDetail {
isReverse?: boolean;
startBidAmount?: number;
finishBidAmount?: number | null;
availableLatestBidCloseDate?: string | null;
}
@@ -85,11 +87,13 @@ const loadAuction = async (prisma: QueryClient, auctionId: number): Promise<Auct
GamePrisma.sql`
SELECT id,
type,
host_general_id as "hostGeneralId",
detail,
status,
close_at as "closeAt"
FROM auction
WHERE id = ${auctionId}
FOR UPDATE
`
);
return rows[0] ?? null;
@@ -218,6 +222,22 @@ export const createAuctionBidder = async (options: {
reason: '시작가보다 낮습니다.',
};
}
if (!isReverse && detail.finishBidAmount != null && command.amount > detail.finishBidAmount) {
return {
type: 'auctionBid',
ok: false,
auctionId: command.auctionId,
reason: '즉시판매가보다 높을 수 없습니다.',
};
}
if (isReverse && detail.finishBidAmount != null && command.amount < detail.finishBidAmount) {
return {
type: 'auctionBid',
ok: false,
auctionId: command.auctionId,
reason: '즉시판매가보다 낮을 수 없습니다.',
};
}
if (auction.type === 'UNIQUE_ITEM' && highestBid) {
if (command.amount < highestBid.amount * 1.01) {
@@ -257,6 +277,14 @@ export const createAuctionBidder = async (options: {
reason: '장수 정보를 찾을 수 없습니다.',
};
}
if (auction.type !== 'UNIQUE_ITEM' && auction.hostGeneralId === general.id) {
return {
type: 'auctionBid',
ok: false,
auctionId: command.auctionId,
reason: '자신이 연 경매에 입찰할 수 없습니다.',
};
}
if (auction.type === 'BUY_RICE' && general.gold < morePoint) {
return {
@@ -296,12 +324,19 @@ export const createAuctionBidder = async (options: {
const availableLatestBidCloseDate = detail.availableLatestBidCloseDate
? new Date(detail.availableLatestBidCloseDate)
: null;
const nextCloseAt = extendCloseDate({
let nextCloseAt = extendCloseDate({
now,
closeAt: auction.closeAt,
turnMinutes,
availableLatestBidCloseDate,
});
if (
auction.type !== 'UNIQUE_ITEM' &&
detail.finishBidAmount != null &&
command.amount === detail.finishBidAmount
) {
nextCloseAt = new Date(now.getTime() + turnMinutes * 60_000);
}
const eventId = randomUUID();
const eventAt = now;
@@ -347,51 +382,34 @@ export const createAuctionBidder = async (options: {
if (!userId) {
throw new Error('USER_NOT_FOUND');
}
const current = await tx.inheritancePoint.findUnique({
where: {
userId_key: {
userId,
key: 'previous',
},
},
});
const prevValue = current?.value ?? 0;
if (prevValue < morePoint) {
const deductedRows = await tx.$queryRaw<Array<{ value: number }>>(
GamePrisma.sql`
UPDATE inheritance_point
SET value = value - ${morePoint},
updated_at = ${eventAt}
WHERE user_id = ${userId}
AND key = 'previous'
AND value >= ${morePoint}
RETURNING value
`
);
if (deductedRows.length === 0) {
throw new Error('INSUFFICIENT_POINT');
}
await tx.inheritancePoint.upsert({
where: {
userId_key: {
userId,
key: 'previous',
},
},
update: { value: prevValue - morePoint },
create: { userId, key: 'previous', value: prevValue - morePoint },
});
if (highestBid && highestBid.generalId !== command.generalId && !myPrevBid) {
const prevUserId = await resolveUserId(tx, highestBid.generalId);
if (prevUserId) {
const prevPoint = await tx.inheritancePoint.findUnique({
where: {
userId_key: {
userId: prevUserId,
key: 'previous',
},
},
});
const nextValue = (prevPoint?.value ?? 0) + highestBid.amount;
await tx.inheritancePoint.upsert({
where: {
userId_key: {
userId: prevUserId,
key: 'previous',
},
},
update: { value: nextValue },
create: { userId: prevUserId, key: 'previous', value: nextValue },
});
await tx.$executeRaw(
GamePrisma.sql`
INSERT INTO inheritance_point (user_id, key, value, updated_at)
VALUES (${prevUserId}, 'previous', ${highestBid.amount}, ${eventAt})
ON CONFLICT (user_id, key)
DO UPDATE SET
value = inheritance_point.value + EXCLUDED.value,
updated_at = EXCLUDED.updated_at
`
);
}
}
}
+148 -56
View File
@@ -1,7 +1,14 @@
import { createGamePostgresConnector, GamePrisma } from '@sammo-ts/infra';
import { ActionLogger, ItemLoader, LogFormat, UserLogger, isItemKey } from '@sammo-ts/logic';
import {
ActionLogger,
ItemLoader,
LogFormat,
UserLogger,
isItemKey,
resolveUniqueConfig,
} from '@sammo-ts/logic';
import { cloneItemInventory, ensureItemInventory, equipNewItem } from '@sammo-ts/logic/items/index.js';
import { JosaUtil } from '@sammo-ts/common';
import { asRecord, JosaUtil } from '@sammo-ts/common';
import type { TurnDaemonCommandResult } from '../lifecycle/types.js';
import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js';
@@ -17,6 +24,7 @@ type AuctionStatus = 'OPEN' | 'FINALIZING' | 'FINISHED' | 'CANCELED';
const COEFF_EXTENSION_MINUTES_PER_BID = 1 / 6;
const MIN_EXTENSION_MINUTES_PER_BID = 1;
const MIN_EXTENSION_MINUTES_LIMIT_BY_BID = 5;
interface AuctionRow {
id: number;
@@ -33,12 +41,15 @@ interface AuctionBidRow {
id: number;
generalId: number;
amount: number;
meta: unknown;
}
interface AuctionDetailBase {
title?: string;
isReverse?: boolean;
tryExtendCloseDate?: boolean;
availableLatestBidCloseDate?: string | null;
remainCloseDateExtensionCnt?: number | null;
}
interface AuctionDetailResource extends AuctionDetailBase {
@@ -63,24 +74,6 @@ const resolveTurnMinutes = async (prisma: AuctionDb): Promise<number> => {
return toTurnMinutes(rows[0]?.tickSeconds ?? 60);
};
const extendCloseDate = (options: {
now: Date;
closeAt: Date;
turnMinutes: number;
availableLatestBidCloseDate?: Date | null;
}): Date => {
const { now, closeAt, turnMinutes, availableLatestBidCloseDate } = options;
const extendMinutes = Math.max(MIN_EXTENSION_MINUTES_PER_BID, turnMinutes * COEFF_EXTENSION_MINUTES_PER_BID);
const extended = new Date(now.getTime() + extendMinutes * 60 * 1000);
if (extended.getTime() <= closeAt.getTime()) {
return closeAt;
}
if (availableLatestBidCloseDate && extended.getTime() > availableLatestBidCloseDate.getTime()) {
return availableLatestBidCloseDate;
}
return extended;
};
const pushLogs = (world: InMemoryTurnWorld, logs: LogEntryDraft[]): void => {
for (const log of logs) {
world.pushLog(log);
@@ -96,31 +89,16 @@ const refundInheritancePoint = async (options: {
if (!userId || amount <= 0) {
return;
}
const current = await prisma.inheritancePoint.findUnique({
where: {
userId_key: {
userId,
key: 'previous',
},
},
});
const nextValue = (current?.value ?? 0) + amount;
await prisma.inheritancePoint.upsert({
where: {
userId_key: {
userId,
key: 'previous',
},
},
update: {
value: nextValue,
},
create: {
userId,
key: 'previous',
value: nextValue,
},
});
await prisma.$executeRaw(
GamePrisma.sql`
INSERT INTO inheritance_point (user_id, key, value, updated_at)
VALUES (${userId}, 'previous', ${amount}, ${new Date()})
ON CONFLICT (user_id, key)
DO UPDATE SET
value = inheritance_point.value + EXCLUDED.value,
updated_at = EXCLUDED.updated_at
`
);
};
export const createAuctionFinalizer = async (options: {
@@ -186,14 +164,14 @@ export const createAuctionFinalizer = async (options: {
const bidRows = await db.$queryRaw<AuctionBidRow[]>(
isReverse
? GamePrisma.sql`
SELECT id, general_id as "generalId", amount
SELECT id, general_id as "generalId", amount, meta
FROM auction_bid
WHERE auction_id = ${auctionId}
ORDER BY amount ASC, id ASC
LIMIT 1
`
: GamePrisma.sql`
SELECT id, general_id as "generalId", amount
SELECT id, general_id as "generalId", amount, meta
FROM auction_bid
WHERE auction_id = ${auctionId}
ORDER BY amount DESC, id ASC
@@ -261,6 +239,43 @@ export const createAuctionFinalizer = async (options: {
return { type: 'auctionFinalize', ok: true, auctionId };
}
if (auction.type === 'UNIQUE_ITEM') {
const bidMeta = parseDetail(highestBid.meta);
const remainExtension = detail.remainCloseDateExtensionCnt ?? 0;
if (bidMeta.tryExtendCloseDate === true && remainExtension > 0) {
const turnMinutes = await resolveTurnMinutes(db);
const nextCloseAt = new Date(
auction.closeAt.getTime() + Math.max(5, turnMinutes) * 60_000
);
const nextLatestBidCloseAt = new Date(
nextCloseAt.getTime() +
Math.max(MIN_EXTENSION_MINUTES_PER_BID, turnMinutes * COEFF_EXTENSION_MINUTES_PER_BID) *
60_000
);
const nextDetail = {
...detail,
remainCloseDateExtensionCnt: remainExtension - 1,
availableLatestBidCloseDate: nextLatestBidCloseAt.toISOString(),
};
await db.$executeRaw(
GamePrisma.sql`
UPDATE auction
SET status = 'OPEN',
detail = ${JSON.stringify(nextDetail)}::jsonb,
close_at = ${nextCloseAt},
updated_at = ${now}
WHERE id = ${auctionId}
`
);
return {
type: 'auctionFinalize',
ok: false,
auctionId,
reason: '입찰자의 요청으로 경매 종료가 연장되었습니다.',
};
}
}
const bidder = world.getGeneralById(highestBid.generalId);
if (!bidder) {
await finalizeStatus('CANCELED');
@@ -355,25 +370,102 @@ export const createAuctionFinalizer = async (options: {
};
}
const state = world.getState();
const config = resolveUniqueConfig(asRecord(world.getScenarioConfig().const));
const scenarioMeta = asRecord(state.meta.scenarioMeta);
const startYear =
typeof scenarioMeta.startYear === 'number' && Number.isFinite(scenarioMeta.startYear)
? scenarioMeta.startYear
: state.currentYear;
const relativeYear = state.currentYear - startYear;
let uniqueLimit = 1;
for (const [targetYear, targetLimit] of config.maxUniqueItemLimit) {
if (relativeYear < targetYear) {
break;
}
uniqueLimit = targetLimit;
}
uniqueLimit = Math.min(uniqueLimit, Object.keys(config.allItems).length);
let equippedUniqueCount = 0;
for (const equippedKey of Object.values(bidder.role.items)) {
if (!equippedKey || equippedKey === 'None' || !isItemKey(equippedKey)) {
continue;
}
const equippedModule = await itemLoader.load(equippedKey).catch(() => null);
if (equippedModule && !equippedModule.buyable) {
equippedUniqueCount += 1;
}
}
if (equippedUniqueCount >= uniqueLimit) {
const turnMinutes = await resolveTurnMinutes(db);
const nextCloseAt = new Date(
auction.closeAt.getTime() +
Math.max(MIN_EXTENSION_MINUTES_LIMIT_BY_BID, turnMinutes * 0.5) * 60_000
);
const nextLatestBidCloseAt = new Date(
nextCloseAt.getTime() +
Math.max(MIN_EXTENSION_MINUTES_PER_BID, turnMinutes * COEFF_EXTENSION_MINUTES_PER_BID) *
60_000
);
const nextDetail = {
...detail,
availableLatestBidCloseDate: nextLatestBidCloseAt.toISOString(),
};
await db.$executeRaw(
GamePrisma.sql`
UPDATE auction
SET status = 'OPEN',
host_general_id = ${bidder.id === auction.hostGeneralId ? auction.hostGeneralId : 0},
host_name = ${bidder.id === auction.hostGeneralId ? auction.hostName : '(상인)'},
detail = ${JSON.stringify(nextDetail)}::jsonb,
close_at = ${nextCloseAt},
updated_at = ${now}
WHERE id = ${auctionId}
`
);
globalLogger.pushGlobalActionLog(
`유니크 경매 ${auctionId}번이 전체 보유 제한으로 연장되었습니다.`,
LogFormat.PLAIN
);
logs.push(...globalLogger.flush());
pushLogs(world, logs);
return {
type: 'auctionFinalize',
ok: false,
auctionId,
reason: '유니크 아이템 소유 제한 상태입니다. 종료 시간이 연장됩니다.',
};
}
const slot = itemModule.slot;
const currentItem = bidder.role.items?.[slot] ?? null;
if (currentItem && currentItem !== 'None' && isItemKey(currentItem)) {
const currentModule = await itemLoader.load(currentItem).catch(() => null);
if (currentModule && !currentModule.buyable) {
const turnMinutes = await resolveTurnMinutes(db);
const availableLatestBidCloseDate = detail.availableLatestBidCloseDate
? new Date(detail.availableLatestBidCloseDate)
: null;
const nextCloseAt = extendCloseDate({
now,
closeAt: auction.closeAt,
turnMinutes,
availableLatestBidCloseDate,
});
const nextCloseAt = new Date(
auction.closeAt.getTime() +
Math.max(MIN_EXTENSION_MINUTES_LIMIT_BY_BID, turnMinutes * 0.5) * 60_000
);
const nextLatestBidCloseAt = new Date(
nextCloseAt.getTime() +
Math.max(
MIN_EXTENSION_MINUTES_PER_BID,
turnMinutes * COEFF_EXTENSION_MINUTES_PER_BID
) *
60_000
);
const nextDetail = {
...detail,
availableLatestBidCloseDate: nextLatestBidCloseAt.toISOString(),
};
await db.$executeRaw(
GamePrisma.sql`
UPDATE auction
SET status = 'OPEN',
host_general_id = ${bidder.id === auction.hostGeneralId ? auction.hostGeneralId : 0},
host_name = ${bidder.id === auction.hostGeneralId ? auction.hostName : '(상인)'},
detail = ${JSON.stringify(nextDetail)}::jsonb,
close_at = ${nextCloseAt},
updated_at = ${now}
WHERE id = ${auctionId}
+304
View File
@@ -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);
};
@@ -36,6 +36,17 @@ const zAuctionFinalize = z.object({
auctionId: zFiniteNumber,
});
const zAuctionOpen = z.object({
type: z.literal('auctionOpen'),
generalId: zFiniteNumber,
auctionType: z.enum(['BUY_RICE', 'SELL_RICE', 'UNIQUE_ITEM']),
amount: zFiniteNumber,
closeTurnCnt: zFiniteNumber.optional(),
startBidAmount: zFiniteNumber.optional(),
finishBidAmount: zFiniteNumber.optional(),
itemKey: z.string().optional(),
});
const zAuctionBid = z.object({
type: z.literal('auctionBid'),
auctionId: zFiniteNumber,
@@ -246,6 +257,14 @@ const normalizeAuctionFinalize: CommandNormalizer<'auctionFinalize'> = (envelope
return { ...command, requestId: envelope.requestId };
};
const normalizeAuctionOpen: CommandNormalizer<'auctionOpen'> = (envelope) => {
const command = parseWith(zAuctionOpen, envelope.command);
if (!command) {
return null;
}
return { ...command, requestId: envelope.requestId };
};
const normalizeAuctionBid: CommandNormalizer<'auctionBid'> = (envelope) => {
const command = parseWith(zAuctionBid, envelope.command);
if (!command) {
@@ -444,6 +463,7 @@ const normalizeShutdown: CommandNormalizer<'shutdown'> = (envelope) => {
const normalizers: CommandNormalizerMap = {
auctionFinalize: normalizeAuctionFinalize,
auctionOpen: normalizeAuctionOpen,
auctionBid: normalizeAuctionBid,
troopJoin: normalizeTroopJoin,
troopExit: normalizeTroopExit,
@@ -28,6 +28,7 @@ import {
} from '@sammo-ts/logic/items/index.js';
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
import type { TurnGeneral } from './types.js';
import { openAuction } from '../auction/opener.js';
let itemRegistryPromise: Promise<Map<string, ItemModule>> | null = null;
@@ -663,6 +664,13 @@ async function handleAuctionFinalize(
return ctx.auctionFinalizer.finalize(command.auctionId, ctx.commandDb);
}
async function handleAuctionOpen(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'auctionOpen' }>
): Promise<TurnDaemonCommandResult> {
return openAuction(command, ctx.world, ctx.commandDb);
}
async function handleAuctionBid(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'auctionBid' }>
@@ -1168,6 +1176,8 @@ export const createTurnDaemonCommandHandler = (options: {
dropItem: (command) => handleDropItem(ctx, command as Extract<TurnDaemonCommand, { type: 'dropItem' }>),
auctionFinalize: (command) =>
handleAuctionFinalize(ctx, command as Extract<TurnDaemonCommand, { type: 'auctionFinalize' }>),
auctionOpen: (command) =>
handleAuctionOpen(ctx, command as Extract<TurnDaemonCommand, { type: 'auctionOpen' }>),
auctionBid: (command) => handleAuctionBid(ctx, command as Extract<TurnDaemonCommand, { type: 'auctionBid' }>),
changePermission: (command) =>
handleChangePermission(ctx, command as Extract<TurnDaemonCommand, { type: 'changePermission' }>),
+10
View File
@@ -4,6 +4,7 @@ import PublicView from '../views/PublicView.vue';
import LoginView from '../views/LoginView.vue';
import JoinView from '../views/JoinView.vue';
import InheritView from '../views/InheritView.vue';
import AuctionView from '../views/AuctionView.vue';
import NationCitiesView from '../views/NationCitiesView.vue';
import NationGeneralsView from '../views/NationGeneralsView.vue';
import NationPersonnelView from '../views/NationPersonnelView.vue';
@@ -60,6 +61,15 @@ const routes = [
requiresGeneral: true,
},
},
{
path: '/auction',
name: 'auction',
component: AuctionView,
meta: {
requiresAuth: true,
requiresGeneral: true,
},
},
{
path: '/nation/cities',
name: 'nation-cities',
+352
View File
@@ -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>
+1
View File
@@ -105,6 +105,7 @@ watch(
<RouterLink class="ghost" to="/battle-simulator">전투 시뮬레이터</RouterLink>
<RouterLink class="ghost" to="/my-page"> 정보</RouterLink>
<RouterLink class="ghost" to="/tournament">토너먼트</RouterLink>
<RouterLink class="ghost" to="/auction">거래장</RouterLink>
<RouterLink class="ghost" to="/survey">설문조사</RouterLink>
<RouterLink class="ghost" to="/npc-control">NPC 정책</RouterLink>
<RouterLink class="ghost" to="/inherit">유산 강화</RouterLink>
@@ -134,6 +134,7 @@ const ensureAdminGeneral = async (databaseUrl: string, adminUser: AdminSeedUser)
turnTime,
meta: {
createdBy: 'admin-seed',
killturn: 24,
},
},
});
+24 -5
View File
@@ -71,8 +71,27 @@ Event actions controlling lifecycle:
## Open Questions / Follow-ups
- The exact schedule for `registerAuction()` is outside this file; it is
likely called from monthly or timed maintenance.
- Unique-item auction close-date extension limits depend on
`AuctionUniqueItem` constants and `turnterm`; verify scenarios that override
auction timing.
- `registerAuction()` is called from legacy `func_gamerule.php` during the
monthly game-rule pass. Player-hosted auctions are implemented in core2026;
neutral merchant auction generation remains a separate compatibility item.
- 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 | legacy monthly probabilistic `registerAuction()` | Not implemented |
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.
+22
View File
@@ -71,6 +71,17 @@ export type TurnDaemonCommand =
}
| { type: 'dropItem'; requestId?: string; generalId: number; itemType: string }
| { 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';
requestId?: string;
@@ -204,6 +215,17 @@ export type TurnDaemonCommandResult =
auctionId: number;
reason: string;
}
| {
type: 'auctionOpen';
ok: true;
auctionId: number;
closeAt: string;
}
| {
type: 'auctionOpen';
ok: false;
reason: string;
}
| {
type: 'troopJoin';
ok: true;
+2
View File
@@ -21,6 +21,8 @@ export interface DatabaseClient {
nationTurn: GamePrisma.NationTurnDelegate;
troop: GamePrisma.TroopDelegate;
logEntry: GamePrisma.LogEntryDelegate;
auction: GamePrisma.AuctionDelegate;
auctionBid: GamePrisma.AuctionBidDelegate;
inheritancePoint: GamePrisma.InheritancePointDelegate;
inheritanceLog: GamePrisma.InheritanceLogDelegate;
inheritanceResult: GamePrisma.InheritanceResultDelegate;
+52
View File
@@ -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}`;
};
+1
View File
@@ -1,6 +1,7 @@
export * from './domain/entities.js';
export type { RandomGenerator } from '@sammo-ts/common';
export * from './actions/index.js';
export * from './auction/alias.js';
export * from './constraints/index.js';
export * from './crewType/index.js';
export * from './diplomacy/index.js';
+25
View File
@@ -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$/);
});
});
@@ -13,8 +13,7 @@ import {
createGameApiServer,
buildAuctionTimerKeys,
seedAuctionTimers,
buildTurnDaemonStreamKeys,
RedisTurnDaemonTransport,
DatabaseTurnDaemonTransport,
} from '@sammo-ts/game-api';
import { createTurnDaemonRuntime } from '@sammo-ts/game-engine';
import {
@@ -221,6 +220,7 @@ describe('auction integration flow', () => {
beforeAll(async () => {
await loadEnv();
process.env.SCENARIO = '908';
process.chdir(workspaceRoot);
await resetDatabase();
await resetRedis();
@@ -271,15 +271,15 @@ describe('auction integration flow', () => {
await gatewayClient.admin.profiles.upsert.mutate({
profile: 'che',
scenario: '2',
scenario: '908',
apiPort: Number(process.env.GAME_API_PORT ?? 14000),
status: 'RUNNING',
});
await gatewayClient.admin.profiles.installNow.mutate({
profileName: 'che:2',
profileName: 'che:908',
install: {
scenarioId: 2,
scenarioId: 908,
turnTermMinutes: 1,
sync: false,
fiction: 0,
@@ -300,7 +300,7 @@ describe('auction integration flow', () => {
});
const gatewayToken = await gatewayClient.auth.issueGameSession.mutate({
sessionToken: login.sessionToken,
profile: 'che:2',
profile: 'che:908',
});
const access = await gameClient.auth.exchangeGatewayToken.mutate({
gatewayToken: gatewayToken.gameToken,
@@ -326,7 +326,7 @@ describe('auction integration flow', () => {
const gatewayDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'public' }).url;
turnDaemon = await createTurnDaemonRuntime({
profile: 'che',
profileName: 'che:2',
profileName: 'che:908',
databaseUrl: gameDatabaseUrl,
gatewayDatabaseUrl,
});
@@ -405,6 +405,9 @@ describe('auction integration flow', () => {
where: { id: poorBidder.generalId },
data: { gold: 50, rice: 1000 },
});
await prisma.worldState.updateMany({
data: { currentMonth: 4 },
});
if (turnDaemon) {
await turnDaemon.lifecycle.stop('integration-test');
@@ -415,31 +418,24 @@ describe('auction integration flow', () => {
const gatewayDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'public' }).url;
turnDaemon = await createTurnDaemonRuntime({
profile: 'che',
profileName: 'che:2',
profileName: 'che:908',
databaseUrl: gameDatabaseUrl,
gatewayDatabaseUrl,
});
turnDaemonLoop = turnDaemon.lifecycle.start();
await sleep(500);
const now = new Date();
const initialCloseAt = new Date(now.getTime() + 10_000);
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 hostClient = createGameClient(gameUrl, gameServer.config.trpcPath, {
value: bidder3.accessToken,
});
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);
await seedAuctionTimers(prisma, redis, keys);
@@ -449,7 +445,7 @@ describe('auction integration flow', () => {
const bidder1Client = createGameClient(gameUrl, gameServer.config.trpcPath, {
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 } });
expect(bidRows).toHaveLength(1);
@@ -478,10 +474,7 @@ describe('auction integration flow', () => {
});
await redis.zAdd(keys.timerKey, [{ score: finalizeAt.getTime(), value: String(auction.id) }]);
const transport = new RedisTurnDaemonTransport(redis, {
keys: buildTurnDaemonStreamKeys(gameServer.config.profileName),
requestTimeoutMs: 10_000,
});
const transport = new DatabaseTurnDaemonTransport(prisma, 30_000);
await prisma.$executeRaw(
GamePrisma.sql`
UPDATE auction
@@ -491,7 +484,7 @@ describe('auction integration flow', () => {
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);
const finished = await prisma.auction.findUnique({
@@ -613,7 +606,7 @@ describe('auction integration flow', () => {
const gatewayDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'public' }).url;
turnDaemon = await createTurnDaemonRuntime({
profile: 'che',
profileName: 'che:2',
profileName: 'che:908',
databaseUrl: gameDatabaseUrl,
gatewayDatabaseUrl,
});
@@ -627,10 +620,7 @@ describe('auction integration flow', () => {
});
await redis.zAdd(keys.timerKey, [{ score: finalizeAt.getTime(), value: String(auction.id) }]);
const transport = new RedisTurnDaemonTransport(redis, {
keys: buildTurnDaemonStreamKeys(gameServer.config.profileName),
requestTimeoutMs: 10_000,
});
const transport = new DatabaseTurnDaemonTransport(prisma, 30_000);
await prisma.$executeRaw(
GamePrisma.sql`
UPDATE auction
@@ -640,7 +630,7 @@ describe('auction integration flow', () => {
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);
const reopened = await prisma.auction.findUnique({
@@ -677,16 +667,20 @@ describe('auction integration flow', () => {
where: { id: bidderB.generalId },
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({
where: { userId_key: { userId: bidderA.userId, key: 'previous' } },
update: { value: 5000 },
create: { userId: bidderA.userId, key: 'previous', value: 5000 },
update: { value: 100_000 },
create: { userId: bidderA.userId, key: 'previous', value: 100_000 },
});
await prisma.inheritancePoint.upsert({
where: { userId_key: { userId: bidderB.userId, key: 'previous' } },
update: { value: 5000 },
create: { userId: bidderB.userId, key: 'previous', value: 5000 },
update: { value: 100_000 },
create: { userId: bidderB.userId, key: 'previous', value: 100_000 },
});
if (turnDaemon) {
@@ -698,7 +692,7 @@ describe('auction integration flow', () => {
const gatewayDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'public' }).url;
turnDaemon = await createTurnDaemonRuntime({
profile: 'che',
profileName: 'che:2',
profileName: 'che:908',
databaseUrl: gameDatabaseUrl,
gatewayDatabaseUrl,
});
@@ -720,32 +714,39 @@ describe('auction integration flow', () => {
const keys = buildAuctionTimerKeys(gameServer.config.profileName);
await redis.del(keys.timerKey);
const limitCloseAt = new Date(now.getTime() + 60_000);
const auction = await prisma.auction.create({
data: {
type: 'UNIQUE_ITEM',
targetCode: uniquePair.keyA,
hostGeneralId: 0,
hostName: '시스템',
detail: {
startBidAmount: 200,
isReverse: false,
availableLatestBidCloseDate: limitCloseAt.toISOString(),
},
status: 'OPEN',
closeAt: new Date(now.getTime() + 2000),
},
const bidderAClient = createGameClient(gameUrl, gameServer.config.trpcPath, { value: bidderA.accessToken });
const bidderBClient = createGameClient(gameUrl, gameServer.config.trpcPath, { value: bidderB.accessToken });
const opened = await bidderAClient.auction.openUnique.mutate({
itemKey: uniquePair.keyA,
amount: 5000,
});
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);
const bidderAClient = createGameClient(gameUrl, gameServer.config.trpcPath, { value: bidderA.accessToken });
const bidderBClient = createGameClient(gameUrl, gameServer.config.trpcPath, { value: bidderB.accessToken });
await bidderAClient.auction.bidUnique.mutate({ auctionId: auction.id, amount: 300, tryExtendCloseDate: true });
await bidderBClient.auction.bidUnique.mutate({ auctionId: auction.id, amount: 350, 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 });
await bidderBClient.auction.bidUnique.mutate({ auctionId: auction.id, amount: 5100, tryExtendCloseDate: true });
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: 5400, tryExtendCloseDate: true });
await bidderBClient.auction.bidUnique.mutate({ auctionId: auction.id, amount: 5500, tryExtendCloseDate: true });
const afterBids = await prisma.auction.findUnique({
where: { id: auction.id },
@@ -761,10 +762,7 @@ describe('auction integration flow', () => {
});
await redis.zAdd(keys.timerKey, [{ score: finalizeAt.getTime(), value: String(auction.id) }]);
const transport = new RedisTurnDaemonTransport(redis, {
keys: buildTurnDaemonStreamKeys(gameServer.config.profileName),
requestTimeoutMs: 10_000,
});
const transport = new DatabaseTurnDaemonTransport(prisma, 30_000);
await prisma.$executeRaw(
GamePrisma.sql`
UPDATE auction
@@ -774,7 +772,7 @@ describe('auction integration flow', () => {
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);
const winner = await prisma.general.findUnique({