From 52be3b40efef59e1ed2a6ef330a131d2ee48abef Mon Sep 17 00:00:00 2001 From: Hide_D Date: Sat, 24 Jan 2026 04:25:04 +0000 Subject: [PATCH] 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. --- app/game-api/src/router/auction/index.ts | 296 ++--------- app/game-api/src/router/inherit/index.ts | 116 +++-- app/game-api/src/router/tournament/index.ts | 27 +- app/game-api/src/tournament/worker.ts | 32 +- app/game-engine/src/auction/bidder.ts | 470 ++++++++++++++++++ .../src/lifecycle/redisCommandStream.ts | 74 +++ app/game-engine/src/turn/turnDaemon.ts | 11 + .../src/turn/worldCommandHandler.ts | 132 +++++ docs/architecture/todo.md | 1 + packages/common/src/turnDaemon/types.ts | 73 ++- 10 files changed, 903 insertions(+), 329 deletions(-) create mode 100644 app/game-engine/src/auction/bidder.ts diff --git a/app/game-api/src/router/auction/index.ts b/app/game-api/src/router/auction/index.ts index c10b170..d1fe2c0 100644 --- a/app/game-api/src/router/auction/index.ts +++ b/app/game-api/src/router/auction/index.ts @@ -1,5 +1,4 @@ import { TRPCError } from '@trpc/server'; -import { randomUUID } from 'node:crypto'; import { z } from 'zod'; import { authedProcedure, router } from '../../trpc.js'; @@ -8,8 +7,6 @@ import { buildAuctionTimerKeys } from '../../auction/keys.js'; import { GamePrisma } from '@sammo-ts/infra'; 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({ auctionId: z.number().int().positive(), @@ -56,15 +53,6 @@ const parseDetail = (detail: unknown): 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 => { if (!ctx.auth) { throw new TRPCError({ @@ -159,42 +147,6 @@ const loadMyPrevBid = async ( 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 => { if (!myPrevBid) { return null; @@ -208,13 +160,6 @@ const shouldUsePrevBid = (highestBid: AuctionBidRow | null, myPrevBid: AuctionBi return myPrevBid; }; -const runInTransaction = async (db: DatabaseClient, fn: (tx: DatabaseClient) => Promise): Promise => { - if (db.$transaction) { - return db.$transaction(fn); - } - return fn(db); -}; - export const auctionRouter = router({ bidBuyRice: authedProcedure.input(zBidInput).mutation(async ({ ctx, input }) => { const auth = requireAuth(ctx); @@ -260,71 +205,23 @@ export const auctionRouter = router({ throw new TRPCError({ code: 'BAD_REQUEST', message: '금이 부족합니다.' }); } - const eventId = randomUUID(); - const eventAt = now; - const turnMinutes = await resolveTurnMinutes(ctx.db); - const availableLatestBidCloseDate = detail.availableLatestBidCloseDate - ? new Date(detail.availableLatestBidCloseDate) - : null; - 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: '경매가 취소되었습니다.' }); - } + const result = await ctx.turnDaemon.requestCommand({ + type: 'auctionBid', + auctionId: auction.id, + generalId: general.id, + amount: input.amount, + tryExtendCloseDate: true, }); + 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 nextCloseAt = new Date(result.closeAt); await ctx.redis.zAdd(timerKeys.timerKey, [{ score: nextCloseAt.getTime(), value: String(auction.id) }]); return { ok: true }; @@ -373,71 +270,23 @@ export const auctionRouter = router({ throw new TRPCError({ code: 'BAD_REQUEST', message: '쌀이 부족합니다.' }); } - const eventId = randomUUID(); - const eventAt = now; - const turnMinutes = await resolveTurnMinutes(ctx.db); - const availableLatestBidCloseDate = detail.availableLatestBidCloseDate - ? new Date(detail.availableLatestBidCloseDate) - : null; - 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: '경매가 취소되었습니다.' }); - } + const result = await ctx.turnDaemon.requestCommand({ + type: 'auctionBid', + auctionId: auction.id, + generalId: general.id, + amount: input.amount, + tryExtendCloseDate: true, }); + 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 nextCloseAt = new Date(result.closeAt); await ctx.redis.zAdd(timerKeys.timerKey, [{ score: nextCloseAt.getTime(), value: String(auction.id) }]); return { ok: true }; @@ -550,84 +399,23 @@ export const auctionRouter = router({ throw new TRPCError({ code: 'BAD_REQUEST', message: '유산포인트가 부족합니다.' }); } - const eventId = randomUUID(); - const eventAt = now; - const turnMinutes = await resolveTurnMinutes(ctx.db); - const availableLatestBidCloseDate = detail.availableLatestBidCloseDate - ? new Date(detail.availableLatestBidCloseDate) - : null; - 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: '경매가 취소되었습니다.' }); - } + const result = await ctx.turnDaemon.requestCommand({ + type: 'auctionBid', + auctionId: auction.id, + generalId: general.id, + amount: input.amount, + tryExtendCloseDate: input.tryExtendCloseDate ?? true, }); + 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 nextCloseAt = new Date(result.closeAt); await ctx.redis.zAdd(timerKeys.timerKey, [{ score: nextCloseAt.getTime(), value: String(auction.id) }]); return { ok: true }; diff --git a/app/game-api/src/router/inherit/index.ts b/app/game-api/src/router/inherit/index.ts index e9ecfeb..fbdab4d 100644 --- a/app/game-api/src/router/inherit/index.ts +++ b/app/game-api/src/router/inherit/index.ts @@ -16,7 +16,7 @@ import { sumInheritanceItems, writeUserStateMeta, } from '../../services/inheritance.js'; -import type { WorldStateRow } from '../../context.js'; +import type { GameApiContext, WorldStateRow } from '../../context.js'; const BUFF_KEYS: InheritBuffType[] = [ 'warAvoidRatio', @@ -74,6 +74,33 @@ const resolveWorld = async (ctx: { db: { worldState: { findFirst: () => Promise< }; }; +const patchGeneral = async ( + ctx: Pick, + generalId: number, + patch: { + meta?: Record; + turnTime?: string; + stats?: { + leadership?: number; + strength?: number; + intelligence?: number; + }; + specialWar?: string; + } +): Promise => { + 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 zones: string[] = []; for (let i = 0; i < 60; i += 1) { @@ -306,13 +333,10 @@ export const inheritRouter = router({ const buffText = BUFF_LABELS[input.type]; const moreText = prevLevel > 0 ? '추가' : ''; buff[input.type] = input.level; - await ctx.db.general.update({ - where: { id: general.id }, - data: { - meta: { - ...asRecord(general.meta), - inheritBuff: serializeBuffRecord(buff), - }, + await patchGeneral(ctx, general.id, { + meta: { + ...asRecord(general.meta), + inheritBuff: serializeBuffRecord(buff), }, }); @@ -385,13 +409,10 @@ export const inheritRouter = router({ const [warModule] = await loadWarTraitModules([input.specialKey], new WarTraitLoader()); const warName = warModule?.name ?? input.specialKey; - await ctx.db.general.update({ - where: { id: general.id }, - data: { - meta: { - ...meta, - inheritSpecificSpecialWar: input.specialKey, - }, + await patchGeneral(ctx, general.id, { + meta: { + ...meta, + inheritSpecificSpecialWar: input.specialKey, }, }); @@ -441,15 +462,12 @@ export const inheritRouter = router({ const prevList = parseJson(typeof meta.prev_types_special2 === 'string' ? meta.prev_types_special2 : null) ?? []; prevList.push(general.special2Code); - await ctx.db.general.update({ - where: { id: general.id }, - data: { - special2Code: 'None', - meta: { - ...meta, - inheritResetSpecialWar: nextLevel, - prev_types_special2: JSON.stringify(prevList), - }, + await patchGeneral(ctx, general.id, { + specialWar: 'None', + meta: { + ...meta, + inheritResetSpecialWar: nextLevel, + prev_types_special2: JSON.stringify(prevList), }, }); @@ -496,14 +514,11 @@ export const inheritRouter = router({ nextTurnTime = new Date(nextTurnTime.getTime() + tickMinutes * 60000); } - await ctx.db.general.update({ - where: { id: general.id }, - data: { - turnTime: nextTurnTime, - meta: { - ...asRecord(general.meta), - inheritResetTurnTime: nextLevel, - }, + await patchGeneral(ctx, general.id, { + turnTime: nextTurnTime.toISOString(), + meta: { + ...asRecord(general.meta), + inheritResetTurnTime: nextLevel, }, }); @@ -620,12 +635,11 @@ export const inheritRouter = router({ intel: input.intel + finalBonus[2], }; - await ctx.db.general.update({ - where: { id: general.id }, - data: { + await patchGeneral(ctx, general.id, { + stats: { leadership: nextStats.leadership, strength: nextStats.strength, - intel: nextStats.intel, + intelligence: nextStats.intel, }, }); @@ -697,13 +711,10 @@ export const inheritRouter = router({ throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 구입 명령을 내렸습니다. 다음 턴까지 기다려주세요.' }); } - await ctx.db.general.update({ - where: { id: general.id }, - data: { - meta: { - ...meta, - inheritRandomUnique: 1, - }, + await patchGeneral(ctx, general.id, { + meta: { + ...meta, + inheritRandomUnique: 1, }, }); @@ -755,17 +766,14 @@ export const inheritRouter = router({ throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 유니크 경매 신청이 있습니다.' }); } - await ctx.db.general.update({ - where: { id: general.id }, - data: { - meta: { - ...meta, - inheritSpecificUnique: JSON.stringify({ - itemId: input.itemId, - amount: input.amount, - requestedAt: new Date().toISOString(), - }), - }, + await patchGeneral(ctx, general.id, { + meta: { + ...meta, + inheritSpecificUnique: JSON.stringify({ + itemId: input.itemId, + amount: input.amount, + requestedAt: new Date().toISOString(), + }), }, }); diff --git a/app/game-api/src/router/tournament/index.ts b/app/game-api/src/router/tournament/index.ts index 1268ba1..5b9c981 100644 --- a/app/game-api/src/router/tournament/index.ts +++ b/app/game-api/src/router/tournament/index.ts @@ -265,11 +265,17 @@ export const tournamentRouter = router({ throw new TRPCError({ code: 'NOT_FOUND', message: '장수 정보를 찾을 수 없습니다.' }); } - const nextMeta = { ...asRecord(general.meta), tnmt: 1 }; - await ctx.db.general.update({ - where: { id: general.id }, - data: { meta: nextMeta }, + const result = await ctx.turnDaemon.requestCommand({ + type: 'setMySetting', + generalId: general.id, + 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); if (already) { @@ -403,10 +409,17 @@ export const tournamentRouter = router({ throw new TRPCError({ code: 'BAD_REQUEST', message: '소지금이 부족합니다.' }); } - await ctx.db.general.update({ - where: { id: general.id }, - data: { gold: general.gold - input.amount }, + const adjustResult = await ctx.turnDaemon.requestCommand({ + type: 'adjustGeneralResources', + 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({ generalId: general.id, diff --git a/app/game-api/src/tournament/worker.ts b/app/game-api/src/tournament/worker.ts index 3b9c686..8fcbd04 100644 --- a/app/game-api/src/tournament/worker.ts +++ b/app/game-api/src/tournament/worker.ts @@ -681,8 +681,9 @@ const seedNpcBets = async (options: { store: TournamentStore; state: TournamentState; baseSeed: string; + daemonTransport: TurnDaemonTransport; }): Promise => { - const { prisma, store, state, baseSeed } = options; + const { prisma, store, state, baseSeed, daemonTransport } = options; const existing = await store.getBettingEntries(); if (existing.length > 0) { return; @@ -737,14 +738,14 @@ const seedNpcBets = async (options: { entries.push({ generalId: npc.id as number, targetId, amount: betGold }); } - await prisma.$transaction( - npcBetList.map((npc) => - prisma.general.update({ - where: { id: npc.id as number }, - data: { gold: (npc.gold as number) - betGold }, - }) - ) - ); + await daemonTransport.sendCommand({ + type: 'adjustGeneralResources', + reason: 'tournamentNpcBet', + adjustments: npcBetList.map((npc) => ({ + generalId: npc.id as number, + goldDelta: -betGold, + })), + }); await store.setBettingEntries(entries); }; @@ -752,7 +753,8 @@ export const applyPreBattleStage = async ( store: TournamentStore, prisma: TournamentPrismaClient, state: TournamentState, - baseSeed: string + baseSeed: string, + daemonTransport: TurnDaemonTransport ): Promise => { const participants = await store.getParticipants(); @@ -976,7 +978,7 @@ export const applyPreBattleStage = async ( nextAt: resolveNextAt(state), }; await store.setState(nextState); - await seedNpcBets({ prisma, store, state: nextState, baseSeed }); + await seedNpcBets({ prisma, store, state: nextState, baseSeed, daemonTransport }); return nextState; } @@ -1115,7 +1117,13 @@ export const runTournamentWorker = async (): Promise => { if (isBattleStage(state.stage)) { nextState = await applyBattle(store, state, String(baseSeed)); } 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({ diff --git a/app/game-engine/src/auction/bidder.ts b/app/game-engine/src/auction/bidder.ts new file mode 100644 index 0000000..1804c54 --- /dev/null +++ b/app/game-engine/src/auction/bidder.ts @@ -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): Promise; + close(): Promise; +} + +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 => { + 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 => { + const rows = await prisma.$queryRaw( + 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 => { + const rows = await prisma.$queryRaw( + 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 => { + const rows = await prisma.$queryRaw( + 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; + +const resolveUserId = async (prisma: QueryClient, generalId: number): Promise => { + 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 => { + 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 => { + 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 => { + await connector.disconnect(); + }, + }; +}; diff --git a/app/game-engine/src/lifecycle/redisCommandStream.ts b/app/game-engine/src/lifecycle/redisCommandStream.ts index be19639..5bdc070 100644 --- a/app/game-engine/src/lifecycle/redisCommandStream.ts +++ b/app/game-engine/src/lifecycle/redisCommandStream.ts @@ -42,6 +42,9 @@ type TurnDaemonEventEnvelope = { event: TurnDaemonEvent; }; +const isRecord = (value: unknown): value is Record => + Boolean(value && typeof value === 'object' && !Array.isArray(value)); + const parseCommandEnvelope = (raw: string): TurnDaemonCommandEnvelope | null => { try { const parsed = JSON.parse(raw) as Partial; @@ -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 }; diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index 1718e44..b93c20d 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -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) | null = null; let close = async () => {}; let auctionFinalizer: Awaited> | null = null; + let auctionBidder: Awaited> | null = null; let tournamentRewardFinalizer: Awaited> | null = null; let redisCommandStream: RedisTurnDaemonCommandStream | null = null; let pauseGate: (() => Promise) | 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, }); diff --git a/app/game-engine/src/turn/worldCommandHandler.ts b/app/game-engine/src/turn/worldCommandHandler.ts index 4255d92..3c10ae4 100644 --- a/app/game-engine/src/turn/worldCommandHandler.ts +++ b/app/game-engine/src/turn/worldCommandHandler.ts @@ -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; } +interface AuctionBidder { + bid(command: Extract): Promise; +} + interface TournamentRewardFinalizer { finalize(command: Extract): Promise; } @@ -87,6 +93,109 @@ async function handleSetNationMeta( }; } +async function handleAdjustGeneralResources( + ctx: CommandHandlerContext, + command: Extract +): Promise { + 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 +): Promise { + const { world, hooks } = ctx; + const general = world.getGeneralById(command.generalId); + if (!general) { + return { + type: 'patchGeneral', + ok: false, + generalId: command.generalId, + reason: '장수 정보를 찾을 수 없습니다.', + }; + } + + const patch: Partial = {}; + 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 @@ -370,6 +479,21 @@ async function handleAuctionFinalize( return ctx.auctionFinalizer.finalize(command.auctionId); } +async function handleAuctionBid( + ctx: CommandHandlerContext, + command: Extract +): Promise { + if (!ctx.auctionBidder) { + return { + type: 'auctionBid', + ok: false, + auctionId: command.auctionId, + reason: '경매 입찰기가 준비되지 않았습니다.', + }; + } + return ctx.auctionBidder.bid(command); +} + async function handleChangePermission( ctx: CommandHandlerContext, command: Extract @@ -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; } diff --git a/docs/architecture/todo.md b/docs/architecture/todo.md index 9f91ae6..cd34e45 100644 --- a/docs/architecture/todo.md +++ b/docs/architecture/todo.md @@ -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] 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] 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] Implement full auction finalization logic in the daemon (resource transfers, unique item grants, and log writes) once the auction schema is stabilized. diff --git a/packages/common/src/turnDaemon/types.ts b/packages/common/src/turnDaemon/types.ts index 908a723..f795ab7 100644 --- a/packages/common/src/turnDaemon/types.ts +++ b/packages/common/src/turnDaemon/types.ts @@ -122,7 +122,40 @@ export type TurnDaemonCommand = nationId: number; updates: Record; 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; + 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 = | { @@ -227,7 +260,43 @@ export type TurnDaemonCommandResult = nationId: number; reason: 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 = | { type: 'status'; requestId?: string; status: TurnDaemonStatus }