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:
2026-01-24 04:25:04 +00:00
parent cbee0d4c20
commit 52be3b40ef
10 changed files with 903 additions and 329 deletions
+470
View File
@@ -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;
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value && typeof value === 'object' && !Array.isArray(value));
const parseCommandEnvelope = (raw: string): TurnDaemonCommandEnvelope | null => {
try {
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,
};
}
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': {
const requestId = typeof command.requestId === 'string' ? command.requestId : envelope.requestId;
return { type: 'getStatus', requestId };
+11
View File
@@ -27,6 +27,7 @@ import { loadTurnWorldFromDatabase } from './worldLoader.js';
import { shouldUseAi } from './ai/generalAi.js';
import { createUnificationHandler } from './unificationHandler.js';
import { createAuctionFinalizer } from '../auction/finalizer.js';
import { createAuctionBidder } from '../auction/bidder.js';
import { createTournamentRewardFinalizer } from '../tournament/finalizer.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 close = async () => {};
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 redisCommandStream: RedisTurnDaemonCommandStream | null = null;
let pauseGate: (() => Promise<boolean>) | undefined;
@@ -204,6 +206,11 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
const dbHooks = await createDatabaseTurnHooks(options.databaseUrl, world, {
reservedTurns: reservedTurnStoreHandle?.store,
});
auctionBidder = await createAuctionBidder({
databaseUrl: options.databaseUrl,
world,
hooks: dbHooks.hooks,
});
auctionFinalizer = await createAuctionFinalizer({
databaseUrl: options.databaseUrl,
world,
@@ -222,6 +229,9 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
},
};
close = async () => {
if (auctionBidder) {
await auctionBidder.close();
}
if (auctionFinalizer) {
await auctionFinalizer.close();
}
@@ -313,6 +323,7 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
world,
hooks,
auctionFinalizer: auctionFinalizer ?? undefined,
auctionBidder: auctionBidder ?? undefined,
tournamentRewardFinalizer: tournamentRewardFinalizer ?? undefined,
});
@@ -6,6 +6,7 @@ import type {
TurnRunResult,
} from '../lifecycle/types.js';
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
import type { TurnGeneral } from './types.js';
const buildFlushResult = (world: InMemoryTurnWorld): TurnRunResult => {
const state = world.getState();
@@ -30,6 +31,7 @@ interface CommandHandlerContext {
world: InMemoryTurnWorld;
hooks?: TurnDaemonHooks;
auctionFinalizer?: AuctionFinalizer;
auctionBidder?: AuctionBidder;
tournamentRewardFinalizer?: TournamentRewardFinalizer;
}
@@ -37,6 +39,10 @@ interface AuctionFinalizer {
finalize(auctionId: number): Promise<TurnDaemonCommandResult>;
}
interface AuctionBidder {
bid(command: Extract<TurnDaemonCommand, { type: 'auctionBid' }>): Promise<TurnDaemonCommandResult>;
}
interface TournamentRewardFinalizer {
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(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'troopJoin' }>
@@ -370,6 +479,21 @@ async function handleAuctionFinalize(
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(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'changePermission' }>
@@ -618,12 +742,14 @@ export const createTurnDaemonCommandHandler = (options: {
world: InMemoryTurnWorld;
hooks?: TurnDaemonHooks;
auctionFinalizer?: AuctionFinalizer;
auctionBidder?: AuctionBidder;
tournamentRewardFinalizer?: TournamentRewardFinalizer;
}): TurnDaemonCommandHandler => {
const ctx = {
world: options.world,
hooks: options.hooks,
auctionFinalizer: options.auctionFinalizer,
auctionBidder: options.auctionBidder,
tournamentRewardFinalizer: options.tournamentRewardFinalizer,
};
@@ -648,6 +774,8 @@ export const createTurnDaemonCommandHandler = (options: {
return handleDropItem(ctx, command);
case 'auctionFinalize':
return handleAuctionFinalize(ctx, command);
case 'auctionBid':
return handleAuctionBid(ctx, command);
case 'changePermission':
return handleChangePermission(ctx, command);
case 'kick':
@@ -662,6 +790,10 @@ export const createTurnDaemonCommandHandler = (options: {
return handleTournamentReward(ctx, command);
case 'setNationMeta':
return handleSetNationMeta(ctx, command);
case 'adjustGeneralResources':
return handleAdjustGeneralResources(ctx, command);
case 'patchGeneral':
return handlePatchGeneral(ctx, command);
default:
return null;
}