feat: implement auction bidding and resource adjustment commands
- Added `adjustGeneralResources` command to handle resource adjustments for generals. - Introduced `patchGeneral` command for updating general attributes. - Created `auctionBid` command to facilitate bidding in auctions with validation. - Refactored database interactions to use transactions for auction bids and resource adjustments. - Enhanced error handling for auction and resource operations. - Updated command handling in the turn daemon to support new commands. - Introduced `AuctionBidder` interface and implementation for managing auction bids. - Improved overall structure and readability of auction-related code.
This commit is contained in:
@@ -1,5 +1,4 @@
|
|||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
import { randomUUID } from 'node:crypto';
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { authedProcedure, router } from '../../trpc.js';
|
import { authedProcedure, router } from '../../trpc.js';
|
||||||
@@ -8,8 +7,6 @@ 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';
|
||||||
|
|
||||||
const COEFF_EXTENSION_MINUTES_PER_BID = 1 / 6;
|
|
||||||
const MIN_EXTENSION_MINUTES_PER_BID = 1;
|
|
||||||
|
|
||||||
const zBidInput = z.object({
|
const zBidInput = z.object({
|
||||||
auctionId: z.number().int().positive(),
|
auctionId: z.number().int().positive(),
|
||||||
@@ -56,15 +53,6 @@ const parseDetail = (detail: unknown): AuctionDetail => {
|
|||||||
return detail as AuctionDetail;
|
return detail as AuctionDetail;
|
||||||
};
|
};
|
||||||
|
|
||||||
const toTurnMinutes = (tickSeconds: number): number => Math.max(1, Math.round(tickSeconds / 60));
|
|
||||||
|
|
||||||
const resolveTurnMinutes = async (db: DatabaseClient) => {
|
|
||||||
const rows = (await db.$queryRaw(
|
|
||||||
GamePrisma.sql`SELECT tick_seconds as "tickSeconds" FROM world_state ORDER BY id LIMIT 1`
|
|
||||||
)) as Array<{ tickSeconds: number }>;
|
|
||||||
return toTurnMinutes(rows[0]?.tickSeconds ?? 60);
|
|
||||||
};
|
|
||||||
|
|
||||||
const requireAuth = (ctx: GameApiContext): NonNullable<GameApiContext['auth']> => {
|
const requireAuth = (ctx: GameApiContext): NonNullable<GameApiContext['auth']> => {
|
||||||
if (!ctx.auth) {
|
if (!ctx.auth) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
@@ -159,42 +147,6 @@ const loadMyPrevBid = async (
|
|||||||
return rows[0] ?? null;
|
return rows[0] ?? null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const cancelAuction = async (
|
|
||||||
db: DatabaseClient,
|
|
||||||
auctionId: number,
|
|
||||||
reason: string
|
|
||||||
) => {
|
|
||||||
const now = new Date();
|
|
||||||
await db.$executeRaw(
|
|
||||||
GamePrisma.sql`
|
|
||||||
UPDATE auction
|
|
||||||
SET status = 'CANCELED',
|
|
||||||
finished_at = ${now},
|
|
||||||
updated_at = ${now},
|
|
||||||
detail = jsonb_set(detail, '{cancelReason}', to_jsonb(${reason}), true)
|
|
||||||
WHERE id = ${auctionId}
|
|
||||||
`
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const extendCloseDate = (options: {
|
|
||||||
now: Date;
|
|
||||||
closeAt: Date;
|
|
||||||
turnMinutes: number;
|
|
||||||
availableLatestBidCloseDate?: Date | null;
|
|
||||||
}) => {
|
|
||||||
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 shouldUsePrevBid = (highestBid: AuctionBidRow | null, myPrevBid: AuctionBidRow | null): AuctionBidRow | null => {
|
const shouldUsePrevBid = (highestBid: AuctionBidRow | null, myPrevBid: AuctionBidRow | null): AuctionBidRow | null => {
|
||||||
if (!myPrevBid) {
|
if (!myPrevBid) {
|
||||||
return null;
|
return null;
|
||||||
@@ -208,13 +160,6 @@ const shouldUsePrevBid = (highestBid: AuctionBidRow | null, myPrevBid: AuctionBi
|
|||||||
return myPrevBid;
|
return myPrevBid;
|
||||||
};
|
};
|
||||||
|
|
||||||
const runInTransaction = async <T>(db: DatabaseClient, fn: (tx: DatabaseClient) => Promise<T>): Promise<T> => {
|
|
||||||
if (db.$transaction) {
|
|
||||||
return db.$transaction(fn);
|
|
||||||
}
|
|
||||||
return fn(db);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const auctionRouter = router({
|
export const auctionRouter = router({
|
||||||
bidBuyRice: authedProcedure.input(zBidInput).mutation(async ({ ctx, input }) => {
|
bidBuyRice: authedProcedure.input(zBidInput).mutation(async ({ ctx, input }) => {
|
||||||
const auth = requireAuth(ctx);
|
const auth = requireAuth(ctx);
|
||||||
@@ -260,71 +205,23 @@ export const auctionRouter = router({
|
|||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '금이 부족합니다.' });
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '금이 부족합니다.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventId = randomUUID();
|
const result = await ctx.turnDaemon.requestCommand({
|
||||||
const eventAt = now;
|
type: 'auctionBid',
|
||||||
const turnMinutes = await resolveTurnMinutes(ctx.db);
|
auctionId: auction.id,
|
||||||
const availableLatestBidCloseDate = detail.availableLatestBidCloseDate
|
generalId: general.id,
|
||||||
? new Date(detail.availableLatestBidCloseDate)
|
amount: input.amount,
|
||||||
: null;
|
tryExtendCloseDate: true,
|
||||||
const nextCloseAt = extendCloseDate({
|
|
||||||
now,
|
|
||||||
closeAt: auction.closeAt,
|
|
||||||
turnMinutes,
|
|
||||||
availableLatestBidCloseDate,
|
|
||||||
});
|
|
||||||
|
|
||||||
await runInTransaction(ctx.db, async (tx) => {
|
|
||||||
await tx.$executeRaw(
|
|
||||||
GamePrisma.sql`
|
|
||||||
INSERT INTO auction_bid (auction_id, general_id, amount, event_id, event_at, meta)
|
|
||||||
VALUES (${auction.id}, ${general.id}, ${input.amount}, ${eventId}, ${eventAt}, ${JSON.stringify({ tryExtendCloseDate: true })}::jsonb)
|
|
||||||
`
|
|
||||||
);
|
|
||||||
|
|
||||||
await tx.general.update({
|
|
||||||
where: { id: general.id },
|
|
||||||
data: { gold: general.gold - morePoint },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (highestBid && highestBid.generalId !== general.id && !myPrevBid) {
|
|
||||||
const prev = await tx.general.findUnique({ where: { id: highestBid.generalId } });
|
|
||||||
if (!prev) {
|
|
||||||
await cancelAuction(tx, auction.id, '중복 입찰 등 문제가 발생하여 취소');
|
|
||||||
throw new TRPCError({ code: 'CONFLICT', message: '경매가 취소되었습니다.' });
|
|
||||||
}
|
|
||||||
await tx.general.update({
|
|
||||||
where: { id: prev.id },
|
|
||||||
data: { gold: prev.gold + highestBid.amount },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await tx.$executeRaw(
|
|
||||||
GamePrisma.sql`
|
|
||||||
UPDATE auction
|
|
||||||
SET close_at = ${nextCloseAt},
|
|
||||||
latest_event_id = ${eventId},
|
|
||||||
latest_event_at = ${eventAt},
|
|
||||||
updated_at = ${eventAt}
|
|
||||||
WHERE id = ${auction.id}
|
|
||||||
AND status = 'OPEN'
|
|
||||||
AND (
|
|
||||||
latest_event_at < ${eventAt}
|
|
||||||
OR (latest_event_at = ${eventAt} AND latest_event_id < ${eventId})
|
|
||||||
)
|
|
||||||
`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (updated === 0) {
|
|
||||||
await cancelAuction(tx, auction.id, '중복 입찰 등 문제가 발생하여 취소');
|
|
||||||
await tx.general.update({
|
|
||||||
where: { id: general.id },
|
|
||||||
data: { gold: general.gold },
|
|
||||||
});
|
|
||||||
throw new TRPCError({ code: 'CONFLICT', message: '경매가 취소되었습니다.' });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
if (!result || result.type !== 'auctionBid') {
|
||||||
|
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
||||||
|
}
|
||||||
|
if (!result.ok) {
|
||||||
|
const code = result.reason.includes('취소') ? 'CONFLICT' : 'BAD_REQUEST';
|
||||||
|
throw new TRPCError({ code, message: result.reason });
|
||||||
|
}
|
||||||
|
|
||||||
const timerKeys = buildAuctionTimerKeys(ctx.profile.name);
|
const timerKeys = buildAuctionTimerKeys(ctx.profile.name);
|
||||||
|
const nextCloseAt = new Date(result.closeAt);
|
||||||
await ctx.redis.zAdd(timerKeys.timerKey, [{ score: nextCloseAt.getTime(), value: String(auction.id) }]);
|
await ctx.redis.zAdd(timerKeys.timerKey, [{ score: nextCloseAt.getTime(), value: String(auction.id) }]);
|
||||||
|
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
@@ -373,71 +270,23 @@ export const auctionRouter = router({
|
|||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '쌀이 부족합니다.' });
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '쌀이 부족합니다.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventId = randomUUID();
|
const result = await ctx.turnDaemon.requestCommand({
|
||||||
const eventAt = now;
|
type: 'auctionBid',
|
||||||
const turnMinutes = await resolveTurnMinutes(ctx.db);
|
auctionId: auction.id,
|
||||||
const availableLatestBidCloseDate = detail.availableLatestBidCloseDate
|
generalId: general.id,
|
||||||
? new Date(detail.availableLatestBidCloseDate)
|
amount: input.amount,
|
||||||
: null;
|
tryExtendCloseDate: true,
|
||||||
const nextCloseAt = extendCloseDate({
|
|
||||||
now,
|
|
||||||
closeAt: auction.closeAt,
|
|
||||||
turnMinutes,
|
|
||||||
availableLatestBidCloseDate,
|
|
||||||
});
|
|
||||||
|
|
||||||
await runInTransaction(ctx.db, async (tx) => {
|
|
||||||
await tx.$executeRaw(
|
|
||||||
GamePrisma.sql`
|
|
||||||
INSERT INTO auction_bid (auction_id, general_id, amount, event_id, event_at, meta)
|
|
||||||
VALUES (${auction.id}, ${general.id}, ${input.amount}, ${eventId}, ${eventAt}, ${JSON.stringify({ tryExtendCloseDate: true })}::jsonb)
|
|
||||||
`
|
|
||||||
);
|
|
||||||
|
|
||||||
await tx.general.update({
|
|
||||||
where: { id: general.id },
|
|
||||||
data: { rice: general.rice - morePoint },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (highestBid && highestBid.generalId !== general.id && !myPrevBid) {
|
|
||||||
const prev = await tx.general.findUnique({ where: { id: highestBid.generalId } });
|
|
||||||
if (!prev) {
|
|
||||||
await cancelAuction(tx, auction.id, '중복 입찰 등 문제가 발생하여 취소');
|
|
||||||
throw new TRPCError({ code: 'CONFLICT', message: '경매가 취소되었습니다.' });
|
|
||||||
}
|
|
||||||
await tx.general.update({
|
|
||||||
where: { id: prev.id },
|
|
||||||
data: { rice: prev.rice + highestBid.amount },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await tx.$executeRaw(
|
|
||||||
GamePrisma.sql`
|
|
||||||
UPDATE auction
|
|
||||||
SET close_at = ${nextCloseAt},
|
|
||||||
latest_event_id = ${eventId},
|
|
||||||
latest_event_at = ${eventAt},
|
|
||||||
updated_at = ${eventAt}
|
|
||||||
WHERE id = ${auction.id}
|
|
||||||
AND status = 'OPEN'
|
|
||||||
AND (
|
|
||||||
latest_event_at < ${eventAt}
|
|
||||||
OR (latest_event_at = ${eventAt} AND latest_event_id < ${eventId})
|
|
||||||
)
|
|
||||||
`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (updated === 0) {
|
|
||||||
await cancelAuction(tx, auction.id, '중복 입찰 등 문제가 발생하여 취소');
|
|
||||||
await tx.general.update({
|
|
||||||
where: { id: general.id },
|
|
||||||
data: { rice: general.rice },
|
|
||||||
});
|
|
||||||
throw new TRPCError({ code: 'CONFLICT', message: '경매가 취소되었습니다.' });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
if (!result || result.type !== 'auctionBid') {
|
||||||
|
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
||||||
|
}
|
||||||
|
if (!result.ok) {
|
||||||
|
const code = result.reason.includes('취소') ? 'CONFLICT' : 'BAD_REQUEST';
|
||||||
|
throw new TRPCError({ code, message: result.reason });
|
||||||
|
}
|
||||||
|
|
||||||
const timerKeys = buildAuctionTimerKeys(ctx.profile.name);
|
const timerKeys = buildAuctionTimerKeys(ctx.profile.name);
|
||||||
|
const nextCloseAt = new Date(result.closeAt);
|
||||||
await ctx.redis.zAdd(timerKeys.timerKey, [{ score: nextCloseAt.getTime(), value: String(auction.id) }]);
|
await ctx.redis.zAdd(timerKeys.timerKey, [{ score: nextCloseAt.getTime(), value: String(auction.id) }]);
|
||||||
|
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
@@ -550,84 +399,23 @@ export const auctionRouter = router({
|
|||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '유산포인트가 부족합니다.' });
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '유산포인트가 부족합니다.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventId = randomUUID();
|
const result = await ctx.turnDaemon.requestCommand({
|
||||||
const eventAt = now;
|
type: 'auctionBid',
|
||||||
const turnMinutes = await resolveTurnMinutes(ctx.db);
|
auctionId: auction.id,
|
||||||
const availableLatestBidCloseDate = detail.availableLatestBidCloseDate
|
generalId: general.id,
|
||||||
? new Date(detail.availableLatestBidCloseDate)
|
amount: input.amount,
|
||||||
: null;
|
tryExtendCloseDate: input.tryExtendCloseDate ?? true,
|
||||||
const nextCloseAt = extendCloseDate({
|
|
||||||
now,
|
|
||||||
closeAt: auction.closeAt,
|
|
||||||
turnMinutes,
|
|
||||||
availableLatestBidCloseDate,
|
|
||||||
});
|
|
||||||
|
|
||||||
await runInTransaction(ctx.db, async (tx) => {
|
|
||||||
await tx.$executeRaw(
|
|
||||||
GamePrisma.sql`
|
|
||||||
INSERT INTO auction_bid (auction_id, general_id, amount, event_id, event_at, meta)
|
|
||||||
VALUES (
|
|
||||||
${auction.id},
|
|
||||||
${general.id},
|
|
||||||
${input.amount},
|
|
||||||
${eventId},
|
|
||||||
${eventAt},
|
|
||||||
${JSON.stringify({ tryExtendCloseDate: input.tryExtendCloseDate ?? true })}::jsonb
|
|
||||||
)
|
|
||||||
`
|
|
||||||
);
|
|
||||||
|
|
||||||
const prevValue = inheritPoint?.value ?? 0;
|
|
||||||
await tx.inheritancePoint.upsert({
|
|
||||||
where: { userId_key: { userId: auth.user.id, key: 'previous' } },
|
|
||||||
update: { value: prevValue - morePoint },
|
|
||||||
create: { userId: auth.user.id, key: 'previous', value: prevValue - morePoint },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (highestBid && highestBid.generalId !== general.id && !myPrevBid) {
|
|
||||||
const prev = await tx.general.findUnique({ where: { id: highestBid.generalId } });
|
|
||||||
if (!prev) {
|
|
||||||
await cancelAuction(tx, auction.id, '중복 입찰 등 문제가 발생하여 취소');
|
|
||||||
throw new TRPCError({ code: 'CONFLICT', message: '경매가 취소되었습니다.' });
|
|
||||||
}
|
|
||||||
const prevUserId = prev.userId ?? '';
|
|
||||||
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 },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await tx.$executeRaw(
|
|
||||||
GamePrisma.sql`
|
|
||||||
UPDATE auction
|
|
||||||
SET close_at = ${nextCloseAt},
|
|
||||||
latest_event_id = ${eventId},
|
|
||||||
latest_event_at = ${eventAt},
|
|
||||||
updated_at = ${eventAt}
|
|
||||||
WHERE id = ${auction.id}
|
|
||||||
AND status = 'OPEN'
|
|
||||||
AND (
|
|
||||||
latest_event_at < ${eventAt}
|
|
||||||
OR (latest_event_at = ${eventAt} AND latest_event_id < ${eventId})
|
|
||||||
)
|
|
||||||
`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (updated === 0) {
|
|
||||||
await cancelAuction(tx, auction.id, '중복 입찰 등 문제가 발생하여 취소');
|
|
||||||
throw new TRPCError({ code: 'CONFLICT', message: '경매가 취소되었습니다.' });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
if (!result || result.type !== 'auctionBid') {
|
||||||
|
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
||||||
|
}
|
||||||
|
if (!result.ok) {
|
||||||
|
const code = result.reason.includes('취소') ? 'CONFLICT' : 'BAD_REQUEST';
|
||||||
|
throw new TRPCError({ code, message: result.reason });
|
||||||
|
}
|
||||||
|
|
||||||
const timerKeys = buildAuctionTimerKeys(ctx.profile.name);
|
const timerKeys = buildAuctionTimerKeys(ctx.profile.name);
|
||||||
|
const nextCloseAt = new Date(result.closeAt);
|
||||||
await ctx.redis.zAdd(timerKeys.timerKey, [{ score: nextCloseAt.getTime(), value: String(auction.id) }]);
|
await ctx.redis.zAdd(timerKeys.timerKey, [{ score: nextCloseAt.getTime(), value: String(auction.id) }]);
|
||||||
|
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
sumInheritanceItems,
|
sumInheritanceItems,
|
||||||
writeUserStateMeta,
|
writeUserStateMeta,
|
||||||
} from '../../services/inheritance.js';
|
} from '../../services/inheritance.js';
|
||||||
import type { WorldStateRow } from '../../context.js';
|
import type { GameApiContext, WorldStateRow } from '../../context.js';
|
||||||
|
|
||||||
const BUFF_KEYS: InheritBuffType[] = [
|
const BUFF_KEYS: InheritBuffType[] = [
|
||||||
'warAvoidRatio',
|
'warAvoidRatio',
|
||||||
@@ -74,6 +74,33 @@ const resolveWorld = async (ctx: { db: { worldState: { findFirst: () => Promise<
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const patchGeneral = async (
|
||||||
|
ctx: Pick<GameApiContext, 'turnDaemon'>,
|
||||||
|
generalId: number,
|
||||||
|
patch: {
|
||||||
|
meta?: Record<string, unknown>;
|
||||||
|
turnTime?: string;
|
||||||
|
stats?: {
|
||||||
|
leadership?: number;
|
||||||
|
strength?: number;
|
||||||
|
intelligence?: number;
|
||||||
|
};
|
||||||
|
specialWar?: string;
|
||||||
|
}
|
||||||
|
): Promise<void> => {
|
||||||
|
const result = await ctx.turnDaemon.requestCommand({
|
||||||
|
type: 'patchGeneral',
|
||||||
|
generalId,
|
||||||
|
patch,
|
||||||
|
});
|
||||||
|
if (!result || result.type !== 'patchGeneral') {
|
||||||
|
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
||||||
|
}
|
||||||
|
if (!result.ok) {
|
||||||
|
throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const buildTurnTimeZoneList = (tickMinutes: number): string[] => {
|
const buildTurnTimeZoneList = (tickMinutes: number): string[] => {
|
||||||
const zones: string[] = [];
|
const zones: string[] = [];
|
||||||
for (let i = 0; i < 60; i += 1) {
|
for (let i = 0; i < 60; i += 1) {
|
||||||
@@ -306,13 +333,10 @@ export const inheritRouter = router({
|
|||||||
const buffText = BUFF_LABELS[input.type];
|
const buffText = BUFF_LABELS[input.type];
|
||||||
const moreText = prevLevel > 0 ? '추가' : '';
|
const moreText = prevLevel > 0 ? '추가' : '';
|
||||||
buff[input.type] = input.level;
|
buff[input.type] = input.level;
|
||||||
await ctx.db.general.update({
|
await patchGeneral(ctx, general.id, {
|
||||||
where: { id: general.id },
|
meta: {
|
||||||
data: {
|
...asRecord(general.meta),
|
||||||
meta: {
|
inheritBuff: serializeBuffRecord(buff),
|
||||||
...asRecord(general.meta),
|
|
||||||
inheritBuff: serializeBuffRecord(buff),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -385,13 +409,10 @@ export const inheritRouter = router({
|
|||||||
const [warModule] = await loadWarTraitModules([input.specialKey], new WarTraitLoader());
|
const [warModule] = await loadWarTraitModules([input.specialKey], new WarTraitLoader());
|
||||||
const warName = warModule?.name ?? input.specialKey;
|
const warName = warModule?.name ?? input.specialKey;
|
||||||
|
|
||||||
await ctx.db.general.update({
|
await patchGeneral(ctx, general.id, {
|
||||||
where: { id: general.id },
|
meta: {
|
||||||
data: {
|
...meta,
|
||||||
meta: {
|
inheritSpecificSpecialWar: input.specialKey,
|
||||||
...meta,
|
|
||||||
inheritSpecificSpecialWar: input.specialKey,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -441,15 +462,12 @@ export const inheritRouter = router({
|
|||||||
const prevList = parseJson<string[]>(typeof meta.prev_types_special2 === 'string' ? meta.prev_types_special2 : null) ?? [];
|
const prevList = parseJson<string[]>(typeof meta.prev_types_special2 === 'string' ? meta.prev_types_special2 : null) ?? [];
|
||||||
prevList.push(general.special2Code);
|
prevList.push(general.special2Code);
|
||||||
|
|
||||||
await ctx.db.general.update({
|
await patchGeneral(ctx, general.id, {
|
||||||
where: { id: general.id },
|
specialWar: 'None',
|
||||||
data: {
|
meta: {
|
||||||
special2Code: 'None',
|
...meta,
|
||||||
meta: {
|
inheritResetSpecialWar: nextLevel,
|
||||||
...meta,
|
prev_types_special2: JSON.stringify(prevList),
|
||||||
inheritResetSpecialWar: nextLevel,
|
|
||||||
prev_types_special2: JSON.stringify(prevList),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -496,14 +514,11 @@ export const inheritRouter = router({
|
|||||||
nextTurnTime = new Date(nextTurnTime.getTime() + tickMinutes * 60000);
|
nextTurnTime = new Date(nextTurnTime.getTime() + tickMinutes * 60000);
|
||||||
}
|
}
|
||||||
|
|
||||||
await ctx.db.general.update({
|
await patchGeneral(ctx, general.id, {
|
||||||
where: { id: general.id },
|
turnTime: nextTurnTime.toISOString(),
|
||||||
data: {
|
meta: {
|
||||||
turnTime: nextTurnTime,
|
...asRecord(general.meta),
|
||||||
meta: {
|
inheritResetTurnTime: nextLevel,
|
||||||
...asRecord(general.meta),
|
|
||||||
inheritResetTurnTime: nextLevel,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -620,12 +635,11 @@ export const inheritRouter = router({
|
|||||||
intel: input.intel + finalBonus[2],
|
intel: input.intel + finalBonus[2],
|
||||||
};
|
};
|
||||||
|
|
||||||
await ctx.db.general.update({
|
await patchGeneral(ctx, general.id, {
|
||||||
where: { id: general.id },
|
stats: {
|
||||||
data: {
|
|
||||||
leadership: nextStats.leadership,
|
leadership: nextStats.leadership,
|
||||||
strength: nextStats.strength,
|
strength: nextStats.strength,
|
||||||
intel: nextStats.intel,
|
intelligence: nextStats.intel,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -697,13 +711,10 @@ export const inheritRouter = router({
|
|||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 구입 명령을 내렸습니다. 다음 턴까지 기다려주세요.' });
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 구입 명령을 내렸습니다. 다음 턴까지 기다려주세요.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
await ctx.db.general.update({
|
await patchGeneral(ctx, general.id, {
|
||||||
where: { id: general.id },
|
meta: {
|
||||||
data: {
|
...meta,
|
||||||
meta: {
|
inheritRandomUnique: 1,
|
||||||
...meta,
|
|
||||||
inheritRandomUnique: 1,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -755,17 +766,14 @@ export const inheritRouter = router({
|
|||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 유니크 경매 신청이 있습니다.' });
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 유니크 경매 신청이 있습니다.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
await ctx.db.general.update({
|
await patchGeneral(ctx, general.id, {
|
||||||
where: { id: general.id },
|
meta: {
|
||||||
data: {
|
...meta,
|
||||||
meta: {
|
inheritSpecificUnique: JSON.stringify({
|
||||||
...meta,
|
itemId: input.itemId,
|
||||||
inheritSpecificUnique: JSON.stringify({
|
amount: input.amount,
|
||||||
itemId: input.itemId,
|
requestedAt: new Date().toISOString(),
|
||||||
amount: input.amount,
|
}),
|
||||||
requestedAt: new Date().toISOString(),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -265,11 +265,17 @@ export const tournamentRouter = router({
|
|||||||
throw new TRPCError({ code: 'NOT_FOUND', message: '장수 정보를 찾을 수 없습니다.' });
|
throw new TRPCError({ code: 'NOT_FOUND', message: '장수 정보를 찾을 수 없습니다.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextMeta = { ...asRecord(general.meta), tnmt: 1 };
|
const result = await ctx.turnDaemon.requestCommand({
|
||||||
await ctx.db.general.update({
|
type: 'setMySetting',
|
||||||
where: { id: general.id },
|
generalId: general.id,
|
||||||
data: { meta: nextMeta },
|
settings: { tnmt: 1 },
|
||||||
});
|
});
|
||||||
|
if (!result || result.type !== 'setMySetting') {
|
||||||
|
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
||||||
|
}
|
||||||
|
if (!result.ok) {
|
||||||
|
throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason ?? '요청에 실패했습니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
const already = participants.find((entry) => entry.id === general.id);
|
const already = participants.find((entry) => entry.id === general.id);
|
||||||
if (already) {
|
if (already) {
|
||||||
@@ -403,10 +409,17 @@ export const tournamentRouter = router({
|
|||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '소지금이 부족합니다.' });
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '소지금이 부족합니다.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
await ctx.db.general.update({
|
const adjustResult = await ctx.turnDaemon.requestCommand({
|
||||||
where: { id: general.id },
|
type: 'adjustGeneralResources',
|
||||||
data: { gold: general.gold - input.amount },
|
reason: 'tournamentBet',
|
||||||
|
adjustments: [{ generalId: general.id, goldDelta: -input.amount }],
|
||||||
});
|
});
|
||||||
|
if (!adjustResult || adjustResult.type !== 'adjustGeneralResources') {
|
||||||
|
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
||||||
|
}
|
||||||
|
if (!adjustResult.ok) {
|
||||||
|
throw new TRPCError({ code: 'BAD_REQUEST', message: adjustResult.reason });
|
||||||
|
}
|
||||||
|
|
||||||
await store.appendBettingEntry({
|
await store.appendBettingEntry({
|
||||||
generalId: general.id,
|
generalId: general.id,
|
||||||
|
|||||||
@@ -681,8 +681,9 @@ const seedNpcBets = async (options: {
|
|||||||
store: TournamentStore;
|
store: TournamentStore;
|
||||||
state: TournamentState;
|
state: TournamentState;
|
||||||
baseSeed: string;
|
baseSeed: string;
|
||||||
|
daemonTransport: TurnDaemonTransport;
|
||||||
}): Promise<void> => {
|
}): Promise<void> => {
|
||||||
const { prisma, store, state, baseSeed } = options;
|
const { prisma, store, state, baseSeed, daemonTransport } = options;
|
||||||
const existing = await store.getBettingEntries();
|
const existing = await store.getBettingEntries();
|
||||||
if (existing.length > 0) {
|
if (existing.length > 0) {
|
||||||
return;
|
return;
|
||||||
@@ -737,14 +738,14 @@ const seedNpcBets = async (options: {
|
|||||||
entries.push({ generalId: npc.id as number, targetId, amount: betGold });
|
entries.push({ generalId: npc.id as number, targetId, amount: betGold });
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.$transaction(
|
await daemonTransport.sendCommand({
|
||||||
npcBetList.map((npc) =>
|
type: 'adjustGeneralResources',
|
||||||
prisma.general.update({
|
reason: 'tournamentNpcBet',
|
||||||
where: { id: npc.id as number },
|
adjustments: npcBetList.map((npc) => ({
|
||||||
data: { gold: (npc.gold as number) - betGold },
|
generalId: npc.id as number,
|
||||||
})
|
goldDelta: -betGold,
|
||||||
)
|
})),
|
||||||
);
|
});
|
||||||
await store.setBettingEntries(entries);
|
await store.setBettingEntries(entries);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -752,7 +753,8 @@ export const applyPreBattleStage = async (
|
|||||||
store: TournamentStore,
|
store: TournamentStore,
|
||||||
prisma: TournamentPrismaClient,
|
prisma: TournamentPrismaClient,
|
||||||
state: TournamentState,
|
state: TournamentState,
|
||||||
baseSeed: string
|
baseSeed: string,
|
||||||
|
daemonTransport: TurnDaemonTransport
|
||||||
): Promise<TournamentState> => {
|
): Promise<TournamentState> => {
|
||||||
const participants = await store.getParticipants();
|
const participants = await store.getParticipants();
|
||||||
|
|
||||||
@@ -976,7 +978,7 @@ export const applyPreBattleStage = async (
|
|||||||
nextAt: resolveNextAt(state),
|
nextAt: resolveNextAt(state),
|
||||||
};
|
};
|
||||||
await store.setState(nextState);
|
await store.setState(nextState);
|
||||||
await seedNpcBets({ prisma, store, state: nextState, baseSeed });
|
await seedNpcBets({ prisma, store, state: nextState, baseSeed, daemonTransport });
|
||||||
return nextState;
|
return nextState;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1115,7 +1117,13 @@ export const runTournamentWorker = async (): Promise<void> => {
|
|||||||
if (isBattleStage(state.stage)) {
|
if (isBattleStage(state.stage)) {
|
||||||
nextState = await applyBattle(store, state, String(baseSeed));
|
nextState = await applyBattle(store, state, String(baseSeed));
|
||||||
} else if (isPreBattleStage(state.stage)) {
|
} else if (isPreBattleStage(state.stage)) {
|
||||||
nextState = await applyPreBattleStage(store, postgres.prisma, state, String(baseSeed));
|
nextState = await applyPreBattleStage(
|
||||||
|
store,
|
||||||
|
postgres.prisma,
|
||||||
|
state,
|
||||||
|
String(baseSeed),
|
||||||
|
daemonTransport
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await settleTournamentOutcome({
|
await settleTournamentOutcome({
|
||||||
|
|||||||
@@ -0,0 +1,470 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
import { createGamePostgresConnector, GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
|
||||||
|
|
||||||
|
import type { TurnDaemonCommand, TurnDaemonCommandResult, TurnDaemonHooks } from '../lifecycle/types.js';
|
||||||
|
import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js';
|
||||||
|
|
||||||
|
export interface AuctionBidder {
|
||||||
|
bid(command: Extract<TurnDaemonCommand, { type: 'auctionBid' }>): Promise<TurnDaemonCommandResult>;
|
||||||
|
close(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuctionType = 'BUY_RICE' | 'SELL_RICE' | 'UNIQUE_ITEM';
|
||||||
|
|
||||||
|
type AuctionStatus = 'OPEN' | 'FINALIZING' | 'FINISHED' | 'CANCELED';
|
||||||
|
|
||||||
|
const COEFF_EXTENSION_MINUTES_PER_BID = 1 / 6;
|
||||||
|
const MIN_EXTENSION_MINUTES_PER_BID = 1;
|
||||||
|
|
||||||
|
interface AuctionRow {
|
||||||
|
id: number;
|
||||||
|
type: AuctionType;
|
||||||
|
detail: unknown;
|
||||||
|
status: AuctionStatus;
|
||||||
|
closeAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuctionBidRow {
|
||||||
|
id: number;
|
||||||
|
generalId: number;
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuctionDetail {
|
||||||
|
isReverse?: boolean;
|
||||||
|
startBidAmount?: number;
|
||||||
|
availableLatestBidCloseDate?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildFlushResult = (world: InMemoryTurnWorld) => {
|
||||||
|
const state = world.getState();
|
||||||
|
return {
|
||||||
|
lastTurnTime: state.lastTurnTime.toISOString(),
|
||||||
|
processedGenerals: 0,
|
||||||
|
processedTurns: 0,
|
||||||
|
durationMs: 0,
|
||||||
|
partial: false,
|
||||||
|
checkpoint: world.getCheckpoint(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const flushWorld = async (world: InMemoryTurnWorld, hooks?: TurnDaemonHooks): Promise<void> => {
|
||||||
|
if (!hooks?.flushChanges) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await hooks.flushChanges(buildFlushResult(world));
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseDetail = (detail: unknown): AuctionDetail => {
|
||||||
|
if (!detail || typeof detail !== 'object') {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return detail as AuctionDetail;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 shouldUsePrevBid = (highestBid: AuctionBidRow | null, myPrevBid: AuctionBidRow | null): AuctionBidRow | null => {
|
||||||
|
if (!myPrevBid) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!highestBid) {
|
||||||
|
return myPrevBid;
|
||||||
|
}
|
||||||
|
if (highestBid.id !== myPrevBid.id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return myPrevBid;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadAuction = async (prisma: GamePrismaClient, auctionId: number): Promise<AuctionRow | null> => {
|
||||||
|
const rows = await prisma.$queryRaw<AuctionRow[]>(
|
||||||
|
GamePrisma.sql`
|
||||||
|
SELECT id,
|
||||||
|
type,
|
||||||
|
detail,
|
||||||
|
status,
|
||||||
|
close_at as "closeAt"
|
||||||
|
FROM auction
|
||||||
|
WHERE id = ${auctionId}
|
||||||
|
`
|
||||||
|
);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadHighestBid = async (
|
||||||
|
prisma: GamePrismaClient,
|
||||||
|
auctionId: number,
|
||||||
|
isReverse: boolean
|
||||||
|
): Promise<AuctionBidRow | null> => {
|
||||||
|
const rows = await prisma.$queryRaw<AuctionBidRow[]>(
|
||||||
|
isReverse
|
||||||
|
? GamePrisma.sql`
|
||||||
|
SELECT id, general_id as "generalId", amount
|
||||||
|
FROM auction_bid
|
||||||
|
WHERE auction_id = ${auctionId}
|
||||||
|
ORDER BY amount ASC, id ASC
|
||||||
|
LIMIT 1
|
||||||
|
`
|
||||||
|
: GamePrisma.sql`
|
||||||
|
SELECT id, general_id as "generalId", amount
|
||||||
|
FROM auction_bid
|
||||||
|
WHERE auction_id = ${auctionId}
|
||||||
|
ORDER BY amount DESC, id ASC
|
||||||
|
LIMIT 1
|
||||||
|
`
|
||||||
|
);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadMyPrevBid = async (
|
||||||
|
prisma: GamePrismaClient,
|
||||||
|
auctionId: number,
|
||||||
|
generalId: number,
|
||||||
|
isReverse: boolean
|
||||||
|
): Promise<AuctionBidRow | null> => {
|
||||||
|
const rows = await prisma.$queryRaw<AuctionBidRow[]>(
|
||||||
|
isReverse
|
||||||
|
? GamePrisma.sql`
|
||||||
|
SELECT id, general_id as "generalId", amount
|
||||||
|
FROM auction_bid
|
||||||
|
WHERE auction_id = ${auctionId} AND general_id = ${generalId}
|
||||||
|
ORDER BY amount ASC, id ASC
|
||||||
|
LIMIT 1
|
||||||
|
`
|
||||||
|
: GamePrisma.sql`
|
||||||
|
SELECT id, general_id as "generalId", amount
|
||||||
|
FROM auction_bid
|
||||||
|
WHERE auction_id = ${auctionId} AND general_id = ${generalId}
|
||||||
|
ORDER BY amount DESC, id ASC
|
||||||
|
LIMIT 1
|
||||||
|
`
|
||||||
|
);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type QueryClient = Pick<GamePrismaClient, '$queryRaw'>;
|
||||||
|
|
||||||
|
const resolveUserId = async (prisma: QueryClient, generalId: number): Promise<string | null> => {
|
||||||
|
const rows = await prisma.$queryRaw<{ userId: string | null }[]>(
|
||||||
|
GamePrisma.sql`SELECT user_id as "userId" FROM general WHERE id = ${generalId}`
|
||||||
|
);
|
||||||
|
return rows[0]?.userId ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createAuctionBidder = async (options: {
|
||||||
|
databaseUrl: string;
|
||||||
|
world: InMemoryTurnWorld;
|
||||||
|
hooks?: TurnDaemonHooks;
|
||||||
|
}): Promise<AuctionBidder> => {
|
||||||
|
const connector = createGamePostgresConnector({ url: options.databaseUrl });
|
||||||
|
await connector.connect();
|
||||||
|
const prisma = connector.prisma;
|
||||||
|
const world = options.world;
|
||||||
|
const hooks = options.hooks;
|
||||||
|
|
||||||
|
return {
|
||||||
|
bid: async (command): Promise<TurnDaemonCommandResult> => {
|
||||||
|
const auction = await loadAuction(prisma, command.auctionId);
|
||||||
|
if (!auction) {
|
||||||
|
return { type: 'auctionBid', ok: false, auctionId: command.auctionId, reason: '경매가 없습니다.' };
|
||||||
|
}
|
||||||
|
if (auction.status !== 'OPEN') {
|
||||||
|
return {
|
||||||
|
type: 'auctionBid',
|
||||||
|
ok: false,
|
||||||
|
auctionId: command.auctionId,
|
||||||
|
reason: '경매가 종료되었습니다.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const now = new Date();
|
||||||
|
if (auction.closeAt.getTime() <= now.getTime()) {
|
||||||
|
return {
|
||||||
|
type: 'auctionBid',
|
||||||
|
ok: false,
|
||||||
|
auctionId: command.auctionId,
|
||||||
|
reason: '경매가 종료되었습니다.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const detail = parseDetail(auction.detail);
|
||||||
|
const isReverse = detail.isReverse === true;
|
||||||
|
const highestBid = await loadHighestBid(prisma, command.auctionId, isReverse);
|
||||||
|
const myPrevBidRaw = await loadMyPrevBid(prisma, command.auctionId, command.generalId, isReverse);
|
||||||
|
const myPrevBid = shouldUsePrevBid(highestBid, myPrevBidRaw);
|
||||||
|
|
||||||
|
if (highestBid) {
|
||||||
|
if (!isReverse && command.amount <= highestBid.amount) {
|
||||||
|
return {
|
||||||
|
type: 'auctionBid',
|
||||||
|
ok: false,
|
||||||
|
auctionId: command.auctionId,
|
||||||
|
reason: '현재입찰가보다 높게 입찰해야 합니다.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (isReverse && command.amount >= highestBid.amount) {
|
||||||
|
return {
|
||||||
|
type: 'auctionBid',
|
||||||
|
ok: false,
|
||||||
|
auctionId: command.auctionId,
|
||||||
|
reason: '현재입찰가보다 낮게 입찰해야 합니다.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else if (detail.startBidAmount && command.amount < detail.startBidAmount) {
|
||||||
|
return {
|
||||||
|
type: 'auctionBid',
|
||||||
|
ok: false,
|
||||||
|
auctionId: command.auctionId,
|
||||||
|
reason: '시작가보다 낮습니다.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (auction.type === 'UNIQUE_ITEM' && highestBid) {
|
||||||
|
if (command.amount < highestBid.amount * 1.01) {
|
||||||
|
return {
|
||||||
|
type: 'auctionBid',
|
||||||
|
ok: false,
|
||||||
|
auctionId: command.auctionId,
|
||||||
|
reason: '현재입찰가보다 1% 높게 입찰해야 합니다.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (command.amount < highestBid.amount + 10) {
|
||||||
|
return {
|
||||||
|
type: 'auctionBid',
|
||||||
|
ok: false,
|
||||||
|
auctionId: command.auctionId,
|
||||||
|
reason: '현재입찰가보다 10 포인트 높게 입찰해야 합니다.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const morePoint = command.amount - (myPrevBid?.amount ?? 0);
|
||||||
|
if (morePoint <= 0) {
|
||||||
|
return {
|
||||||
|
type: 'auctionBid',
|
||||||
|
ok: false,
|
||||||
|
auctionId: command.auctionId,
|
||||||
|
reason: '입찰가가 유효하지 않습니다.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const general = world.getGeneralById(command.generalId);
|
||||||
|
if (!general) {
|
||||||
|
return {
|
||||||
|
type: 'auctionBid',
|
||||||
|
ok: false,
|
||||||
|
auctionId: command.auctionId,
|
||||||
|
reason: '장수 정보를 찾을 수 없습니다.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (auction.type === 'BUY_RICE' && general.gold < morePoint) {
|
||||||
|
return {
|
||||||
|
type: 'auctionBid',
|
||||||
|
ok: false,
|
||||||
|
auctionId: command.auctionId,
|
||||||
|
reason: '금이 부족합니다.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (auction.type === 'SELL_RICE' && general.rice < morePoint) {
|
||||||
|
return {
|
||||||
|
type: 'auctionBid',
|
||||||
|
ok: false,
|
||||||
|
auctionId: command.auctionId,
|
||||||
|
reason: '쌀이 부족합니다.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (auction.type !== 'UNIQUE_ITEM' && highestBid && highestBid.generalId !== command.generalId && !myPrevBid) {
|
||||||
|
const prev = world.getGeneralById(highestBid.generalId);
|
||||||
|
if (!prev) {
|
||||||
|
return {
|
||||||
|
type: 'auctionBid',
|
||||||
|
ok: false,
|
||||||
|
auctionId: command.auctionId,
|
||||||
|
reason: '환불 대상을 찾을 수 없습니다.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const turnMinutes = Math.max(1, Math.round(world.getState().tickSeconds / 60));
|
||||||
|
const availableLatestBidCloseDate = detail.availableLatestBidCloseDate
|
||||||
|
? new Date(detail.availableLatestBidCloseDate)
|
||||||
|
: null;
|
||||||
|
const nextCloseAt = extendCloseDate({
|
||||||
|
now,
|
||||||
|
closeAt: auction.closeAt,
|
||||||
|
turnMinutes,
|
||||||
|
availableLatestBidCloseDate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const eventId = randomUUID();
|
||||||
|
const eventAt = now;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.$executeRaw(
|
||||||
|
GamePrisma.sql`
|
||||||
|
INSERT INTO auction_bid (auction_id, general_id, amount, event_id, event_at, meta)
|
||||||
|
VALUES (
|
||||||
|
${command.auctionId},
|
||||||
|
${command.generalId},
|
||||||
|
${command.amount},
|
||||||
|
${eventId},
|
||||||
|
${eventAt},
|
||||||
|
${JSON.stringify({ tryExtendCloseDate: command.tryExtendCloseDate ?? true })}::jsonb
|
||||||
|
)
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
const updated = await tx.$executeRaw(
|
||||||
|
GamePrisma.sql`
|
||||||
|
UPDATE auction
|
||||||
|
SET close_at = ${nextCloseAt},
|
||||||
|
latest_event_id = ${eventId},
|
||||||
|
latest_event_at = ${eventAt},
|
||||||
|
updated_at = ${eventAt}
|
||||||
|
WHERE id = ${command.auctionId}
|
||||||
|
AND status = 'OPEN'
|
||||||
|
AND (
|
||||||
|
latest_event_at < ${eventAt}
|
||||||
|
OR (latest_event_at = ${eventAt} AND latest_event_id < ${eventId})
|
||||||
|
)
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (updated === 0) {
|
||||||
|
throw new Error('CONFLICT');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (auction.type === 'UNIQUE_ITEM') {
|
||||||
|
const userId = await resolveUserId(tx, command.generalId);
|
||||||
|
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) {
|
||||||
|
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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const reason = error instanceof Error ? error.message : 'CONFLICT';
|
||||||
|
if (reason === 'INSUFFICIENT_POINT') {
|
||||||
|
return {
|
||||||
|
type: 'auctionBid',
|
||||||
|
ok: false,
|
||||||
|
auctionId: command.auctionId,
|
||||||
|
reason: '유산포인트가 부족합니다.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (reason === 'USER_NOT_FOUND') {
|
||||||
|
return {
|
||||||
|
type: 'auctionBid',
|
||||||
|
ok: false,
|
||||||
|
auctionId: command.auctionId,
|
||||||
|
reason: '장수 정보를 찾을 수 없습니다.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: 'auctionBid',
|
||||||
|
ok: false,
|
||||||
|
auctionId: command.auctionId,
|
||||||
|
reason: '경매가 취소되었습니다.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (auction.type !== 'UNIQUE_ITEM') {
|
||||||
|
const resourceType = auction.type === 'BUY_RICE' ? 'gold' : 'rice';
|
||||||
|
world.updateGeneral(command.generalId, {
|
||||||
|
gold: resourceType === 'gold' ? general.gold - morePoint : general.gold,
|
||||||
|
rice: resourceType === 'rice' ? general.rice - morePoint : general.rice,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (highestBid && highestBid.generalId !== command.generalId && !myPrevBid) {
|
||||||
|
const prev = world.getGeneralById(highestBid.generalId);
|
||||||
|
if (prev) {
|
||||||
|
world.updateGeneral(highestBid.generalId, {
|
||||||
|
gold:
|
||||||
|
resourceType === 'gold' ? prev.gold + highestBid.amount : prev.gold,
|
||||||
|
rice:
|
||||||
|
resourceType === 'rice' ? prev.rice + highestBid.amount : prev.rice,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await flushWorld(world, hooks);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'auctionBid',
|
||||||
|
ok: true,
|
||||||
|
auctionId: command.auctionId,
|
||||||
|
closeAt: nextCloseAt.toISOString(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
close: async (): Promise<void> => {
|
||||||
|
await connector.disconnect();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -42,6 +42,9 @@ type TurnDaemonEventEnvelope = {
|
|||||||
event: TurnDaemonEvent;
|
event: TurnDaemonEvent;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
|
Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
||||||
|
|
||||||
const parseCommandEnvelope = (raw: string): TurnDaemonCommandEnvelope | null => {
|
const parseCommandEnvelope = (raw: string): TurnDaemonCommandEnvelope | null => {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(raw) as Partial<TurnDaemonCommandEnvelope>;
|
const parsed = JSON.parse(raw) as Partial<TurnDaemonCommandEnvelope>;
|
||||||
@@ -294,6 +297,77 @@ const normalizeCommand = (envelope: TurnDaemonCommandEnvelope): TurnDaemonComman
|
|||||||
expectedUpdatedAt: typeof command.expectedUpdatedAt === 'string' ? command.expectedUpdatedAt : undefined,
|
expectedUpdatedAt: typeof command.expectedUpdatedAt === 'string' ? command.expectedUpdatedAt : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
case 'adjustGeneralResources': {
|
||||||
|
if (!Array.isArray(command.adjustments)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const adjustments = command.adjustments
|
||||||
|
.filter((entry) => entry && typeof entry.generalId === 'number')
|
||||||
|
.map((entry) => ({
|
||||||
|
generalId: entry.generalId,
|
||||||
|
goldDelta: typeof entry.goldDelta === 'number' ? entry.goldDelta : undefined,
|
||||||
|
riceDelta: typeof entry.riceDelta === 'number' ? entry.riceDelta : undefined,
|
||||||
|
}))
|
||||||
|
.filter((entry) => entry.goldDelta !== undefined || entry.riceDelta !== undefined);
|
||||||
|
if (adjustments.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: 'adjustGeneralResources',
|
||||||
|
requestId: envelope.requestId,
|
||||||
|
reason: typeof command.reason === 'string' ? command.reason : undefined,
|
||||||
|
adjustments,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'patchGeneral': {
|
||||||
|
if (typeof command.generalId !== 'number' || !command.patch || typeof command.patch !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: 'patchGeneral',
|
||||||
|
requestId: envelope.requestId,
|
||||||
|
generalId: command.generalId,
|
||||||
|
patch: {
|
||||||
|
meta: isRecord(command.patch.meta) ? command.patch.meta : undefined,
|
||||||
|
turnTime: typeof command.patch.turnTime === 'string' ? command.patch.turnTime : undefined,
|
||||||
|
stats: isRecord(command.patch.stats)
|
||||||
|
? {
|
||||||
|
leadership:
|
||||||
|
typeof command.patch.stats.leadership === 'number'
|
||||||
|
? command.patch.stats.leadership
|
||||||
|
: undefined,
|
||||||
|
strength:
|
||||||
|
typeof command.patch.stats.strength === 'number'
|
||||||
|
? command.patch.stats.strength
|
||||||
|
: undefined,
|
||||||
|
intelligence:
|
||||||
|
typeof command.patch.stats.intelligence === 'number'
|
||||||
|
? command.patch.stats.intelligence
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
specialWar: typeof command.patch.specialWar === 'string' ? command.patch.specialWar : undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'auctionBid': {
|
||||||
|
if (
|
||||||
|
typeof command.auctionId !== 'number' ||
|
||||||
|
typeof command.generalId !== 'number' ||
|
||||||
|
typeof command.amount !== 'number'
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: 'auctionBid',
|
||||||
|
requestId: envelope.requestId,
|
||||||
|
auctionId: command.auctionId,
|
||||||
|
generalId: command.generalId,
|
||||||
|
amount: command.amount,
|
||||||
|
tryExtendCloseDate:
|
||||||
|
typeof command.tryExtendCloseDate === 'boolean' ? command.tryExtendCloseDate : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
case 'getStatus': {
|
case 'getStatus': {
|
||||||
const requestId = typeof command.requestId === 'string' ? command.requestId : envelope.requestId;
|
const requestId = typeof command.requestId === 'string' ? command.requestId : envelope.requestId;
|
||||||
return { type: 'getStatus', requestId };
|
return { type: 'getStatus', requestId };
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { loadTurnWorldFromDatabase } from './worldLoader.js';
|
|||||||
import { shouldUseAi } from './ai/generalAi.js';
|
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 { createTournamentRewardFinalizer } from '../tournament/finalizer.js';
|
import { createTournamentRewardFinalizer } from '../tournament/finalizer.js';
|
||||||
import { createTournamentAutoStartHandler } from './tournamentAutoStart.js';
|
import { createTournamentAutoStartHandler } from './tournamentAutoStart.js';
|
||||||
|
|
||||||
@@ -185,6 +186,7 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
|
|||||||
let publishRealtimeEvent: ((event: RealtimeEvent) => Promise<void>) | null = null;
|
let publishRealtimeEvent: ((event: RealtimeEvent) => Promise<void>) | null = null;
|
||||||
let close = async () => {};
|
let close = async () => {};
|
||||||
let auctionFinalizer: Awaited<ReturnType<typeof createAuctionFinalizer>> | null = null;
|
let auctionFinalizer: Awaited<ReturnType<typeof createAuctionFinalizer>> | null = null;
|
||||||
|
let auctionBidder: Awaited<ReturnType<typeof createAuctionBidder>> | null = null;
|
||||||
let tournamentRewardFinalizer: Awaited<ReturnType<typeof createTournamentRewardFinalizer>> | null = null;
|
let tournamentRewardFinalizer: Awaited<ReturnType<typeof createTournamentRewardFinalizer>> | null = null;
|
||||||
let redisCommandStream: RedisTurnDaemonCommandStream | null = null;
|
let redisCommandStream: RedisTurnDaemonCommandStream | null = null;
|
||||||
let pauseGate: (() => Promise<boolean>) | undefined;
|
let pauseGate: (() => Promise<boolean>) | undefined;
|
||||||
@@ -204,6 +206,11 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
|
|||||||
const dbHooks = await createDatabaseTurnHooks(options.databaseUrl, world, {
|
const dbHooks = await createDatabaseTurnHooks(options.databaseUrl, world, {
|
||||||
reservedTurns: reservedTurnStoreHandle?.store,
|
reservedTurns: reservedTurnStoreHandle?.store,
|
||||||
});
|
});
|
||||||
|
auctionBidder = await createAuctionBidder({
|
||||||
|
databaseUrl: options.databaseUrl,
|
||||||
|
world,
|
||||||
|
hooks: dbHooks.hooks,
|
||||||
|
});
|
||||||
auctionFinalizer = await createAuctionFinalizer({
|
auctionFinalizer = await createAuctionFinalizer({
|
||||||
databaseUrl: options.databaseUrl,
|
databaseUrl: options.databaseUrl,
|
||||||
world,
|
world,
|
||||||
@@ -222,6 +229,9 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
close = async () => {
|
close = async () => {
|
||||||
|
if (auctionBidder) {
|
||||||
|
await auctionBidder.close();
|
||||||
|
}
|
||||||
if (auctionFinalizer) {
|
if (auctionFinalizer) {
|
||||||
await auctionFinalizer.close();
|
await auctionFinalizer.close();
|
||||||
}
|
}
|
||||||
@@ -313,6 +323,7 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
|
|||||||
world,
|
world,
|
||||||
hooks,
|
hooks,
|
||||||
auctionFinalizer: auctionFinalizer ?? undefined,
|
auctionFinalizer: auctionFinalizer ?? undefined,
|
||||||
|
auctionBidder: auctionBidder ?? undefined,
|
||||||
tournamentRewardFinalizer: tournamentRewardFinalizer ?? undefined,
|
tournamentRewardFinalizer: tournamentRewardFinalizer ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
TurnRunResult,
|
TurnRunResult,
|
||||||
} from '../lifecycle/types.js';
|
} from '../lifecycle/types.js';
|
||||||
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
|
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
|
||||||
|
import type { TurnGeneral } from './types.js';
|
||||||
|
|
||||||
const buildFlushResult = (world: InMemoryTurnWorld): TurnRunResult => {
|
const buildFlushResult = (world: InMemoryTurnWorld): TurnRunResult => {
|
||||||
const state = world.getState();
|
const state = world.getState();
|
||||||
@@ -30,6 +31,7 @@ interface CommandHandlerContext {
|
|||||||
world: InMemoryTurnWorld;
|
world: InMemoryTurnWorld;
|
||||||
hooks?: TurnDaemonHooks;
|
hooks?: TurnDaemonHooks;
|
||||||
auctionFinalizer?: AuctionFinalizer;
|
auctionFinalizer?: AuctionFinalizer;
|
||||||
|
auctionBidder?: AuctionBidder;
|
||||||
tournamentRewardFinalizer?: TournamentRewardFinalizer;
|
tournamentRewardFinalizer?: TournamentRewardFinalizer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,6 +39,10 @@ interface AuctionFinalizer {
|
|||||||
finalize(auctionId: number): Promise<TurnDaemonCommandResult>;
|
finalize(auctionId: number): Promise<TurnDaemonCommandResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AuctionBidder {
|
||||||
|
bid(command: Extract<TurnDaemonCommand, { type: 'auctionBid' }>): Promise<TurnDaemonCommandResult>;
|
||||||
|
}
|
||||||
|
|
||||||
interface TournamentRewardFinalizer {
|
interface TournamentRewardFinalizer {
|
||||||
finalize(command: Extract<TurnDaemonCommand, { type: 'tournamentReward' }>): Promise<TurnDaemonCommandResult>;
|
finalize(command: Extract<TurnDaemonCommand, { type: 'tournamentReward' }>): Promise<TurnDaemonCommandResult>;
|
||||||
}
|
}
|
||||||
@@ -87,6 +93,109 @@ async function handleSetNationMeta(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleAdjustGeneralResources(
|
||||||
|
ctx: CommandHandlerContext,
|
||||||
|
command: Extract<TurnDaemonCommand, { type: 'adjustGeneralResources' }>
|
||||||
|
): Promise<TurnDaemonCommandResult> {
|
||||||
|
const { world, hooks } = ctx;
|
||||||
|
if (!command.adjustments || command.adjustments.length === 0) {
|
||||||
|
return { type: 'adjustGeneralResources', ok: false, reason: '조정 대상이 없습니다.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const targets = command.adjustments.map((adjustment) => {
|
||||||
|
const general = world.getGeneralById(adjustment.generalId);
|
||||||
|
return { adjustment, general };
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const { adjustment, general } of targets) {
|
||||||
|
if (!general) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const nextGold = general.gold + (adjustment.goldDelta ?? 0);
|
||||||
|
const nextRice = general.rice + (adjustment.riceDelta ?? 0);
|
||||||
|
if (nextGold < 0 || nextRice < 0) {
|
||||||
|
return { type: 'adjustGeneralResources', ok: false, reason: '자원이 부족합니다.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let processed = 0;
|
||||||
|
let missing = 0;
|
||||||
|
let totalGoldDelta = 0;
|
||||||
|
let totalRiceDelta = 0;
|
||||||
|
|
||||||
|
for (const { adjustment, general } of targets) {
|
||||||
|
if (!general) {
|
||||||
|
missing += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const goldDelta = adjustment.goldDelta ?? 0;
|
||||||
|
const riceDelta = adjustment.riceDelta ?? 0;
|
||||||
|
world.updateGeneral(adjustment.generalId, {
|
||||||
|
gold: general.gold + goldDelta,
|
||||||
|
rice: general.rice + riceDelta,
|
||||||
|
});
|
||||||
|
processed += 1;
|
||||||
|
totalGoldDelta += goldDelta;
|
||||||
|
totalRiceDelta += riceDelta;
|
||||||
|
}
|
||||||
|
|
||||||
|
await flushWorld(world, hooks);
|
||||||
|
return {
|
||||||
|
type: 'adjustGeneralResources',
|
||||||
|
ok: true,
|
||||||
|
processed,
|
||||||
|
missing,
|
||||||
|
totalGoldDelta,
|
||||||
|
totalRiceDelta,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePatchGeneral(
|
||||||
|
ctx: CommandHandlerContext,
|
||||||
|
command: Extract<TurnDaemonCommand, { type: 'patchGeneral' }>
|
||||||
|
): Promise<TurnDaemonCommandResult> {
|
||||||
|
const { world, hooks } = ctx;
|
||||||
|
const general = world.getGeneralById(command.generalId);
|
||||||
|
if (!general) {
|
||||||
|
return {
|
||||||
|
type: 'patchGeneral',
|
||||||
|
ok: false,
|
||||||
|
generalId: command.generalId,
|
||||||
|
reason: '장수 정보를 찾을 수 없습니다.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const patch: Partial<typeof general> = {};
|
||||||
|
if (command.patch.meta) {
|
||||||
|
patch.meta = {
|
||||||
|
...general.meta,
|
||||||
|
...command.patch.meta,
|
||||||
|
} as TurnGeneral['meta'];
|
||||||
|
}
|
||||||
|
if (command.patch.turnTime) {
|
||||||
|
const nextTurnTime = new Date(command.patch.turnTime);
|
||||||
|
if (!Number.isNaN(nextTurnTime.getTime())) {
|
||||||
|
patch.turnTime = nextTurnTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (command.patch.stats) {
|
||||||
|
patch.stats = {
|
||||||
|
...general.stats,
|
||||||
|
...command.patch.stats,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (typeof command.patch.specialWar === 'string') {
|
||||||
|
patch.role = {
|
||||||
|
...general.role,
|
||||||
|
specialWar: command.patch.specialWar,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
world.updateGeneral(command.generalId, patch);
|
||||||
|
await flushWorld(world, hooks);
|
||||||
|
return { type: 'patchGeneral', ok: true, generalId: command.generalId };
|
||||||
|
}
|
||||||
|
|
||||||
async function handleTroopJoin(
|
async function handleTroopJoin(
|
||||||
ctx: CommandHandlerContext,
|
ctx: CommandHandlerContext,
|
||||||
command: Extract<TurnDaemonCommand, { type: 'troopJoin' }>
|
command: Extract<TurnDaemonCommand, { type: 'troopJoin' }>
|
||||||
@@ -370,6 +479,21 @@ async function handleAuctionFinalize(
|
|||||||
return ctx.auctionFinalizer.finalize(command.auctionId);
|
return ctx.auctionFinalizer.finalize(command.auctionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleAuctionBid(
|
||||||
|
ctx: CommandHandlerContext,
|
||||||
|
command: Extract<TurnDaemonCommand, { type: 'auctionBid' }>
|
||||||
|
): Promise<TurnDaemonCommandResult> {
|
||||||
|
if (!ctx.auctionBidder) {
|
||||||
|
return {
|
||||||
|
type: 'auctionBid',
|
||||||
|
ok: false,
|
||||||
|
auctionId: command.auctionId,
|
||||||
|
reason: '경매 입찰기가 준비되지 않았습니다.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return ctx.auctionBidder.bid(command);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleChangePermission(
|
async function handleChangePermission(
|
||||||
ctx: CommandHandlerContext,
|
ctx: CommandHandlerContext,
|
||||||
command: Extract<TurnDaemonCommand, { type: 'changePermission' }>
|
command: Extract<TurnDaemonCommand, { type: 'changePermission' }>
|
||||||
@@ -618,12 +742,14 @@ export const createTurnDaemonCommandHandler = (options: {
|
|||||||
world: InMemoryTurnWorld;
|
world: InMemoryTurnWorld;
|
||||||
hooks?: TurnDaemonHooks;
|
hooks?: TurnDaemonHooks;
|
||||||
auctionFinalizer?: AuctionFinalizer;
|
auctionFinalizer?: AuctionFinalizer;
|
||||||
|
auctionBidder?: AuctionBidder;
|
||||||
tournamentRewardFinalizer?: TournamentRewardFinalizer;
|
tournamentRewardFinalizer?: TournamentRewardFinalizer;
|
||||||
}): TurnDaemonCommandHandler => {
|
}): TurnDaemonCommandHandler => {
|
||||||
const ctx = {
|
const ctx = {
|
||||||
world: options.world,
|
world: options.world,
|
||||||
hooks: options.hooks,
|
hooks: options.hooks,
|
||||||
auctionFinalizer: options.auctionFinalizer,
|
auctionFinalizer: options.auctionFinalizer,
|
||||||
|
auctionBidder: options.auctionBidder,
|
||||||
tournamentRewardFinalizer: options.tournamentRewardFinalizer,
|
tournamentRewardFinalizer: options.tournamentRewardFinalizer,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -648,6 +774,8 @@ export const createTurnDaemonCommandHandler = (options: {
|
|||||||
return handleDropItem(ctx, command);
|
return handleDropItem(ctx, command);
|
||||||
case 'auctionFinalize':
|
case 'auctionFinalize':
|
||||||
return handleAuctionFinalize(ctx, command);
|
return handleAuctionFinalize(ctx, command);
|
||||||
|
case 'auctionBid':
|
||||||
|
return handleAuctionBid(ctx, command);
|
||||||
case 'changePermission':
|
case 'changePermission':
|
||||||
return handleChangePermission(ctx, command);
|
return handleChangePermission(ctx, command);
|
||||||
case 'kick':
|
case 'kick':
|
||||||
@@ -662,6 +790,10 @@ export const createTurnDaemonCommandHandler = (options: {
|
|||||||
return handleTournamentReward(ctx, command);
|
return handleTournamentReward(ctx, command);
|
||||||
case 'setNationMeta':
|
case 'setNationMeta':
|
||||||
return handleSetNationMeta(ctx, command);
|
return handleSetNationMeta(ctx, command);
|
||||||
|
case 'adjustGeneralResources':
|
||||||
|
return handleAdjustGeneralResources(ctx, command);
|
||||||
|
case 'patchGeneral':
|
||||||
|
return handlePatchGeneral(ctx, command);
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ Move items into the main docs once they are finalized.
|
|||||||
- [AI suggestion] Implement diplomacy/state transitions and monthly/command-based updates beyond read-only maps.
|
- [AI suggestion] Implement diplomacy/state transitions and monthly/command-based updates beyond read-only maps.
|
||||||
- [AI suggestion] Integrate war/battle pipeline into turn processing (troop movement/war resolution hooks, not just isolated sim jobs).
|
- [AI suggestion] Integrate war/battle pipeline into turn processing (troop movement/war resolution hooks, not just isolated sim jobs).
|
||||||
- [AI suggestion] Expand turn command catalog beyond the current subset (general/nation commands).
|
- [AI suggestion] Expand turn command catalog beyond the current subset (general/nation commands).
|
||||||
|
- [AI suggestion] Replace growing switch-based command routing/serialization with a registry (command schema + handler map + validator) to reduce manual case additions.
|
||||||
- [AI suggestion] Apply install settings (`join_mode`, `npcmode`, `show_img_level`, `tournament_trig`) to runtime rules/command constraints and UI behavior, beyond just storing them in world state.
|
- [AI suggestion] Apply install settings (`join_mode`, `npcmode`, `show_img_level`, `tournament_trig`) to runtime rules/command constraints and UI behavior, beyond just storing them in world state.
|
||||||
- [AI suggestion] Implement full auction finalization logic in the daemon (resource transfers, unique item grants, and log writes) once the auction schema is stabilized.
|
- [AI suggestion] Implement full auction finalization logic in the daemon (resource transfers, unique item grants, and log writes) once the auction schema is stabilized.
|
||||||
|
|
||||||
|
|||||||
@@ -122,7 +122,40 @@ export type TurnDaemonCommand =
|
|||||||
nationId: number;
|
nationId: number;
|
||||||
updates: Record<string, unknown>;
|
updates: Record<string, unknown>;
|
||||||
expectedUpdatedAt?: string;
|
expectedUpdatedAt?: string;
|
||||||
};
|
}
|
||||||
|
| {
|
||||||
|
type: 'adjustGeneralResources';
|
||||||
|
requestId?: string;
|
||||||
|
reason?: string;
|
||||||
|
adjustments: Array<{
|
||||||
|
generalId: number;
|
||||||
|
goldDelta?: number;
|
||||||
|
riceDelta?: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'patchGeneral';
|
||||||
|
requestId?: string;
|
||||||
|
generalId: number;
|
||||||
|
patch: {
|
||||||
|
meta?: Record<string, unknown>;
|
||||||
|
turnTime?: string;
|
||||||
|
stats?: {
|
||||||
|
leadership?: number;
|
||||||
|
strength?: number;
|
||||||
|
intelligence?: number;
|
||||||
|
};
|
||||||
|
specialWar?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'auctionBid';
|
||||||
|
requestId?: string;
|
||||||
|
auctionId: number;
|
||||||
|
generalId: number;
|
||||||
|
amount: number;
|
||||||
|
tryExtendCloseDate?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type TurnDaemonCommandResult =
|
export type TurnDaemonCommandResult =
|
||||||
| {
|
| {
|
||||||
@@ -227,7 +260,43 @@ export type TurnDaemonCommandResult =
|
|||||||
nationId: number;
|
nationId: number;
|
||||||
reason: string;
|
reason: string;
|
||||||
currentUpdatedAt?: string;
|
currentUpdatedAt?: string;
|
||||||
};
|
}
|
||||||
|
| {
|
||||||
|
type: 'adjustGeneralResources';
|
||||||
|
ok: true;
|
||||||
|
processed: number;
|
||||||
|
missing: number;
|
||||||
|
totalGoldDelta: number;
|
||||||
|
totalRiceDelta: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'adjustGeneralResources';
|
||||||
|
ok: false;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'patchGeneral';
|
||||||
|
ok: true;
|
||||||
|
generalId: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'patchGeneral';
|
||||||
|
ok: false;
|
||||||
|
generalId: number;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'auctionBid';
|
||||||
|
ok: true;
|
||||||
|
auctionId: number;
|
||||||
|
closeAt: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'auctionBid';
|
||||||
|
ok: false;
|
||||||
|
auctionId: number;
|
||||||
|
reason: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type TurnDaemonEvent =
|
export type TurnDaemonEvent =
|
||||||
| { type: 'status'; requestId?: string; status: TurnDaemonStatus }
|
| { type: 'status'; requestId?: string; status: TurnDaemonStatus }
|
||||||
|
|||||||
Reference in New Issue
Block a user