diff --git a/app/game-api/package.json b/app/game-api/package.json index 1799435e..1e018e83 100644 --- a/app/game-api/package.json +++ b/app/game-api/package.json @@ -16,6 +16,8 @@ "dev": "tsdown -c ../../tsdown.config.ts -F @sammo-ts/game-api --watch", "worker": "GAME_API_ROLE=battle-sim-worker node dist/index.js", "worker:dev": "GAME_API_ROLE=battle-sim-worker tsdown -c ../../tsdown.config.ts -F @sammo-ts/game-api --watch", + "worker:auction": "GAME_API_ROLE=auction-worker node dist/index.js", + "worker:auction:dev": "GAME_API_ROLE=auction-worker tsdown -c ../../tsdown.config.ts -F @sammo-ts/game-api --watch", "lint": "eslint .", "lint:fix": "eslint . --fix", "test": "vitest run --config vitest.config.ts", diff --git a/app/game-api/src/auction/keys.ts b/app/game-api/src/auction/keys.ts new file mode 100644 index 00000000..2a59fa31 --- /dev/null +++ b/app/game-api/src/auction/keys.ts @@ -0,0 +1,9 @@ +export interface AuctionTimerKeys { + timerKey: string; + historyKey: string; +} + +export const buildAuctionTimerKeys = (profileName: string): AuctionTimerKeys => ({ + timerKey: `sammo:${profileName}:auction:timer`, + historyKey: `sammo:${profileName}:auction:timer:history`, +}); diff --git a/app/game-api/src/auction/scheduler.ts b/app/game-api/src/auction/scheduler.ts new file mode 100644 index 00000000..958dadc9 --- /dev/null +++ b/app/game-api/src/auction/scheduler.ts @@ -0,0 +1,73 @@ +import { GamePrisma } from '@sammo-ts/infra'; + +import type { DatabaseClient } from '../context.js'; +import type { AuctionTimerRow } from './types.js'; +import type { AuctionTimerKeys } from './keys.js'; + +interface RedisSortedSetClient { + zAdd(key: string, values: Array<{ score: number; value: string }>): Promise; + zRem(key: string, values: string | string[]): Promise; +} + +export interface AuctionEventUpdate { + auctionId: number; + closeAt: Date; + eventId: string; + eventAt: Date; +} + +export const seedAuctionTimers = async ( + db: DatabaseClient, + redis: RedisSortedSetClient, + keys: AuctionTimerKeys +): Promise => { + const rows = await db.$queryRaw( + GamePrisma.sql`SELECT id, close_at as "closeAt", status FROM auction WHERE status = 'OPEN'` + ); + if (!rows.length) { + return 0; + } + + const payload = rows.map((row) => ({ score: row.closeAt.getTime(), value: String(row.id) })); + await redis.zAdd(keys.timerKey, payload); + return payload.length; +}; + +export const applyAuctionEvent = async ( + db: DatabaseClient, + redis: RedisSortedSetClient, + keys: AuctionTimerKeys, + event: AuctionEventUpdate +): Promise => { + const now = new Date(); + const updated = await db.$executeRaw( + GamePrisma.sql` + UPDATE auction + SET close_at = ${event.closeAt}, + latest_event_id = ${event.eventId}, + latest_event_at = ${event.eventAt}, + updated_at = ${now} + WHERE id = ${event.auctionId} + AND status = 'OPEN' + AND ( + latest_event_at < ${event.eventAt} + OR (latest_event_at = ${event.eventAt} AND latest_event_id < ${event.eventId}) + ) + ` + ); + + if (updated > 0) { + await redis.zAdd(keys.timerKey, [{ score: event.closeAt.getTime(), value: String(event.auctionId) }]); + return true; + } + + return false; +}; + +export const removeAuctionTimer = async ( + redis: RedisSortedSetClient, + keys: AuctionTimerKeys, + auctionId: number +): Promise => { + await redis.zRem(keys.timerKey, String(auctionId)); +}; diff --git a/app/game-api/src/auction/types.ts b/app/game-api/src/auction/types.ts new file mode 100644 index 00000000..117d7fd9 --- /dev/null +++ b/app/game-api/src/auction/types.ts @@ -0,0 +1,11 @@ +export type AuctionStatus = 'OPEN' | 'FINALIZING' | 'FINISHED' | 'CANCELED'; + +export interface AuctionTimerRow { + id: number; + closeAt: Date; + status: AuctionStatus; +} + +export interface AuctionFinalizeRequest { + auctionId: number; +} diff --git a/app/game-api/src/auction/worker.ts b/app/game-api/src/auction/worker.ts new file mode 100644 index 00000000..f2b41918 --- /dev/null +++ b/app/game-api/src/auction/worker.ts @@ -0,0 +1,126 @@ +import { + createGamePostgresConnector, + createRedisConnector, + GamePrisma, + resolvePostgresConfigFromEnv, + resolveRedisConfigFromEnv, +} from '@sammo-ts/infra'; + +import { resolveGameApiConfigFromEnv } from '../config.js'; +import { RedisTurnDaemonTransport } from '../daemon/redisTransport.js'; +import { buildTurnDaemonStreamKeys } from '../daemon/streamKeys.js'; +import { buildAuctionTimerKeys } from './keys.js'; +import { seedAuctionTimers } from './scheduler.js'; + +interface RedisTimerClient { + zRangeByScore( + key: string, + min: number, + max: number, + options?: { LIMIT?: { offset: number; count: number } } + ): Promise; + zRangeWithScores(key: string, start: number, stop: number): Promise>; + zAdd(key: string, values: Array<{ score: number; value: string }>): Promise; + zRem(key: string, values: string | string[]): Promise; + zRemRangeByScore(key: string, min: number, max: number): Promise; +} + +const sleepMs = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +const popDueAuctionIds = async ( + redis: RedisTimerClient, + timerKey: string, + nowMs: number, + batchSize: number +): Promise => { + const ids = await redis.zRangeByScore(timerKey, 0, nowMs, { LIMIT: { offset: 0, count: batchSize } }); + if (ids.length > 0) { + await redis.zRem(timerKey, ids); + } + return ids; +}; + +const getNextDueMs = async (redis: RedisTimerClient, timerKey: string): Promise => { + const next = await redis.zRangeWithScores(timerKey, 0, 0); + if (!next.length) { + return null; + } + return next[0]?.score ?? null; +}; + +export const runAuctionWorker = async (): Promise => { + const config = resolveGameApiConfigFromEnv(); + const postgres = createGamePostgresConnector(resolvePostgresConfigFromEnv({ schema: config.profile })); + const redis = createRedisConnector(resolveRedisConfigFromEnv()); + + await postgres.connect(); + await redis.connect(); + + const keys = buildAuctionTimerKeys(config.profileName); + const daemonTransport = new RedisTurnDaemonTransport(redis.client, { + keys: buildTurnDaemonStreamKeys(config.profileName), + requestTimeoutMs: config.daemonRequestTimeoutMs, + }); + + const handleExit = async () => { + await redis.disconnect(); + await postgres.disconnect(); + }; + process.on('SIGINT', handleExit); + process.on('SIGTERM', handleExit); + + let nextResyncAt = Date.now(); + + while (true) { + const nowMs = Date.now(); + const historyTrimBefore = nowMs - config.auctionTimerRetentionSeconds * 1000; + if (historyTrimBefore > 0) { + await redis.client.zRemRangeByScore(keys.historyKey, 0, historyTrimBefore); + } + if (nowMs >= nextResyncAt) { + await seedAuctionTimers(postgres.prisma, redis.client, keys); + nextResyncAt = nowMs + config.auctionTimerResyncMs; + } + + const dueIds = await popDueAuctionIds(redis.client, keys.timerKey, nowMs, 100); + if (dueIds.length > 0) { + const now = new Date(nowMs); + await redis.client.zAdd( + keys.historyKey, + dueIds.map((id) => ({ score: nowMs, value: id })) + ); + for (const id of dueIds) { + const auctionId = Number(id); + if (!Number.isFinite(auctionId)) { + continue; + } + + const updated = await postgres.prisma.$executeRaw( + GamePrisma.sql` + UPDATE auction + SET status = 'FINALIZING', + finalizing_at = ${now}, + updated_at = ${now} + WHERE id = ${auctionId} + AND status = 'OPEN' + AND close_at <= ${now} + ` + ); + + if (updated > 0) { + await daemonTransport.sendCommand({ type: 'auctionFinalize', auctionId }); + } + } + continue; + } + + const nextDueMs = await getNextDueMs(redis.client, keys.timerKey); + if (nextDueMs === null) { + await sleepMs(config.auctionTimerPollMs); + continue; + } + + const waitMs = Math.max(0, Math.min(config.auctionTimerPollMs, nextDueMs - Date.now())); + await sleepMs(waitMs); + } +}; diff --git a/app/game-api/src/config.ts b/app/game-api/src/config.ts index 7e9c92be..6ba5c094 100644 --- a/app/game-api/src/config.ts +++ b/app/game-api/src/config.ts @@ -11,6 +11,9 @@ export interface GameApiConfig { daemonRequestTimeoutMs: number; battleSimRequestTimeoutMs: number; battleSimResultTtlSeconds: number; + auctionTimerPollMs: number; + auctionTimerResyncMs: number; + auctionTimerRetentionSeconds: number; gameTokenSecret: string; flushChannel: string; } @@ -44,6 +47,21 @@ export const resolveGameApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.env 60, 'BATTLE_SIM_RESULT_TTL_SECONDS' ), + auctionTimerPollMs: parseNumberWithFallback( + env.AUCTION_TIMER_POLL_MS, + 1000, + 'AUCTION_TIMER_POLL_MS' + ), + auctionTimerResyncMs: parseNumberWithFallback( + env.AUCTION_TIMER_RESYNC_MS, + 300000, + 'AUCTION_TIMER_RESYNC_MS' + ), + auctionTimerRetentionSeconds: parseNumberWithFallback( + env.AUCTION_TIMER_RETENTION_SECONDS, + 21600, + 'AUCTION_TIMER_RETENTION_SECONDS' + ), gameTokenSecret: secret, flushChannel: `${gatewayPrefix}:flush`, }; diff --git a/app/game-api/src/index.ts b/app/game-api/src/index.ts index 4605565c..3e9da272 100644 --- a/app/game-api/src/index.ts +++ b/app/game-api/src/index.ts @@ -3,6 +3,7 @@ import { fileURLToPath } from 'node:url'; import { runGameApiServer } from './server.js'; import { runBattleSimWorker } from './battleSim/worker.js'; +import { runAuctionWorker } from './auction/worker.js'; export * from './config.js'; export * from './context.js'; @@ -21,6 +22,10 @@ export * from './battleSim/redisTransport.js'; export * from './battleSim/inMemoryTransport.js'; export * from './battleSim/keys.js'; export * from './battleSim/worker.js'; +export * from './auction/types.js'; +export * from './auction/keys.js'; +export * from './auction/scheduler.js'; +export * from './auction/worker.js'; // Types for TRPC consumer export type { MessageView } from './messages/store.js'; @@ -40,7 +45,12 @@ const isMain = (): boolean => { if (isMain()) { const role = process.env.GAME_API_ROLE ?? 'server'; - const run = role === 'battle-sim-worker' ? runBattleSimWorker : runGameApiServer; + const run = + role === 'battle-sim-worker' + ? runBattleSimWorker + : role === 'auction-worker' + ? runAuctionWorker + : runGameApiServer; run().catch((error) => { console.error('[game-api] failed to start', error); process.exitCode = 1; diff --git a/app/game-engine/src/auction/finalizer.ts b/app/game-engine/src/auction/finalizer.ts new file mode 100644 index 00000000..00edd1a4 --- /dev/null +++ b/app/game-engine/src/auction/finalizer.ts @@ -0,0 +1,61 @@ +import { createGamePostgresConnector, GamePrisma } from '@sammo-ts/infra'; + +import type { TurnDaemonCommandResult } from '../lifecycle/types.js'; + +export interface AuctionFinalizer { + finalize(auctionId: number): Promise; + close(): Promise; +} + +interface AuctionRow { + id: number; + status: string; +} + +export const createAuctionFinalizer = async (databaseUrl: string): Promise => { + const connector = createGamePostgresConnector({ url: databaseUrl }); + await connector.connect(); + const prisma = connector.prisma; + + return { + finalize: async (auctionId: number): Promise => { + const rows = await prisma.$queryRaw( + GamePrisma.sql`SELECT id, status FROM auction WHERE id = ${auctionId}` + ); + const auction = rows[0]; + if (!auction) { + return { + type: 'auctionFinalize', + ok: false, + auctionId, + reason: '경매 정보를 찾을 수 없습니다.', + }; + } + + if (auction.status === 'FINISHED') { + return { type: 'auctionFinalize', ok: true, auctionId }; + } + + if (auction.status !== 'FINALIZING') { + return { + type: 'auctionFinalize', + ok: false, + auctionId, + reason: '경매가 확정 대기 상태가 아닙니다.', + }; + } + + const now = new Date(); + await prisma.$executeRaw( + GamePrisma.sql`UPDATE auction SET status = 'FINISHED', finished_at = ${now}, updated_at = ${now} WHERE id = ${auctionId}` + ); + + // TODO: 경매 정산(자원 이동, 로그 기록, 유니크 지급)을 월드 상태와 함께 확정해야 한다. + + return { type: 'auctionFinalize', ok: true, auctionId }; + }, + close: async () => { + await connector.disconnect(); + }, + }; +}; diff --git a/app/game-engine/src/lifecycle/redisCommandStream.ts b/app/game-engine/src/lifecycle/redisCommandStream.ts index ee010eb2..7886766b 100644 --- a/app/game-engine/src/lifecycle/redisCommandStream.ts +++ b/app/game-engine/src/lifecycle/redisCommandStream.ts @@ -68,6 +68,16 @@ const normalizeCommand = (envelope: TurnDaemonCommandEnvelope): TurnDaemonComman requestId?: string; }; switch (command.type) { + case 'auctionFinalize': { + if (typeof command.auctionId !== 'number') { + return null; + } + return { + type: 'auctionFinalize', + requestId: envelope.requestId, + auctionId: command.auctionId, + }; + } case 'troopJoin': { if (typeof command.generalId !== 'number' || typeof command.troopId !== 'number') { return null; diff --git a/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts b/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts index 80d4662e..d7d2536c 100644 --- a/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts +++ b/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts @@ -249,6 +249,7 @@ export class TurnDaemonLifecycle { case 'vacation': case 'setMySetting': case 'dropItem': + case 'auctionFinalize': case 'changePermission': case 'kick': case 'appoint': @@ -268,6 +269,7 @@ export class TurnDaemonLifecycle { | { type: 'vacation' } | { type: 'setMySetting' } | { type: 'dropItem' } + | { type: 'auctionFinalize' } | { type: 'changePermission' } | { type: 'kick' } | { type: 'appoint' } @@ -277,23 +279,41 @@ export class TurnDaemonLifecycle { try { result = this.commandHandler ? await this.commandHandler.handle(command) : null; if (!result) { + if (command.type === 'auctionFinalize') { + result = { + type: 'auctionFinalize', + ok: false, + auctionId: command.auctionId, + reason: '턴 데몬이 경매 확정을 처리할 수 없습니다.', + }; + } else { + result = { + type: command.type, + ok: false, + generalId: command.generalId, + reason: '턴 데몬이 명령을 처리할 수 없습니다.', + ...(command.type === 'troopJoin' ? { troopId: command.troopId } : {}), + } as TurnDaemonCommandResult; + } + } + } catch (error) { + const reason = error instanceof Error ? error.message : 'Unknown command error.'; + if (command.type === 'auctionFinalize') { + result = { + type: 'auctionFinalize', + ok: false, + auctionId: command.auctionId, + reason, + }; + } else { result = { type: command.type, ok: false, generalId: command.generalId, - reason: '턴 데몬이 명령을 처리할 수 없습니다.', + reason, ...(command.type === 'troopJoin' ? { troopId: command.troopId } : {}), } as TurnDaemonCommandResult; } - } catch (error) { - const reason = error instanceof Error ? error.message : 'Unknown command error.'; - result = { - type: command.type, - ok: false, - generalId: command.generalId, - reason, - ...(command.type === 'troopJoin' ? { troopId: command.troopId } : {}), - } as TurnDaemonCommandResult; } if (this.commandResponder && command.requestId) { diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index 53f14a38..d5dabfad 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -26,6 +26,7 @@ import { loadTurnCommandProfile } from './turnCommandProfile.js'; import { loadTurnWorldFromDatabase } from './worldLoader.js'; import { shouldUseAi } from './ai/generalAi.js'; import { createUnificationHandler } from './unificationHandler.js'; +import { createAuctionFinalizer } from '../auction/finalizer.js'; export interface TurnDaemonRuntimeOptions { profile: string; @@ -170,6 +171,7 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions) let hooks: TurnDaemonHooks | undefined; let publishRealtimeEvent: ((event: RealtimeEvent) => Promise) | null = null; let close = async () => {}; + let auctionFinalizer: Awaited> | null = null; let redisCommandStream: RedisTurnDaemonCommandStream | null = null; let redisConnector: ReturnType | null = null; let pauseGate: (() => Promise) | undefined; @@ -189,6 +191,7 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions) const dbHooks = await createDatabaseTurnHooks(options.databaseUrl, world, { reservedTurns: reservedTurnStoreHandle?.store, }); + auctionFinalizer = await createAuctionFinalizer(options.databaseUrl); hooks = { ...dbHooks.hooks, onRunError: async (error) => { @@ -197,6 +200,9 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions) }, }; close = async () => { + if (auctionFinalizer) { + await auctionFinalizer.close(); + } await dbHooks.close(); if (reservedTurnStoreHandle) { await reservedTurnStoreHandle.close(); @@ -281,6 +287,7 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions) const commandHandler = createTurnDaemonCommandHandler({ world, hooks, + auctionFinalizer: auctionFinalizer ?? undefined, }); const defaultBudget: TurnRunBudget = options.defaultBudget ?? { diff --git a/app/game-engine/src/turn/worldCommandHandler.ts b/app/game-engine/src/turn/worldCommandHandler.ts index c6bd62b4..86c74633 100644 --- a/app/game-engine/src/turn/worldCommandHandler.ts +++ b/app/game-engine/src/turn/worldCommandHandler.ts @@ -29,6 +29,11 @@ const flushWorld = async (world: InMemoryTurnWorld, hooks?: TurnDaemonHooks): Pr interface CommandHandlerContext { world: InMemoryTurnWorld; hooks?: TurnDaemonHooks; + auctionFinalizer?: AuctionFinalizer; +} + +interface AuctionFinalizer { + finalize(auctionId: number): Promise; } async function handleTroopJoin( @@ -299,6 +304,21 @@ async function handleDropItem( return { type: 'dropItem', ok: true, generalId: command.generalId }; } +async function handleAuctionFinalize( + ctx: CommandHandlerContext, + command: Extract +): Promise { + if (!ctx.auctionFinalizer) { + return { + type: 'auctionFinalize', + ok: false, + auctionId: command.auctionId, + reason: '경매 확정기가 준비되지 않았습니다.', + }; + } + return ctx.auctionFinalizer.finalize(command.auctionId); +} + async function handleChangePermission( ctx: CommandHandlerContext, command: Extract @@ -434,8 +454,9 @@ async function handleAppoint( export const createTurnDaemonCommandHandler = (options: { world: InMemoryTurnWorld; hooks?: TurnDaemonHooks; + auctionFinalizer?: AuctionFinalizer; }): TurnDaemonCommandHandler => { - const ctx = { world: options.world, hooks: options.hooks }; + const ctx = { world: options.world, hooks: options.hooks, auctionFinalizer: options.auctionFinalizer }; return { handle: async (command): Promise => { @@ -456,6 +477,8 @@ export const createTurnDaemonCommandHandler = (options: { return handleSetMySetting(ctx, command); case 'dropItem': return handleDropItem(ctx, command); + case 'auctionFinalize': + return handleAuctionFinalize(ctx, command); case 'changePermission': return handleChangePermission(ctx, command); case 'kick': diff --git a/docs/architecture/todo.md b/docs/architecture/todo.md index 5387c3d3..9f91ae68 100644 --- a/docs/architecture/todo.md +++ b/docs/architecture/todo.md @@ -15,6 +15,7 @@ Move items into the main docs once they are finalized. - [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] 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. ## Frontend diff --git a/packages/common/src/turnDaemon/types.ts b/packages/common/src/turnDaemon/types.ts index 535d221c..f662a225 100644 --- a/packages/common/src/turnDaemon/types.ts +++ b/packages/common/src/turnDaemon/types.ts @@ -69,6 +69,7 @@ export type TurnDaemonCommand = }; } | { type: 'dropItem'; requestId?: string; generalId: number; itemType: string } + | { type: 'auctionFinalize'; requestId?: string; auctionId: number } | { type: 'changePermission'; requestId?: string; @@ -87,6 +88,17 @@ export type TurnDaemonCommand = }; export type TurnDaemonCommandResult = + | { + type: 'auctionFinalize'; + ok: true; + auctionId: number; + } + | { + type: 'auctionFinalize'; + ok: false; + auctionId: number; + reason: string; + } | { type: 'troopJoin'; ok: true; diff --git a/packages/infra/prisma/game.prisma b/packages/infra/prisma/game.prisma index 6d3b6ae3..28c7e342 100644 --- a/packages/infra/prisma/game.prisma +++ b/packages/infra/prisma/game.prisma @@ -24,6 +24,19 @@ enum LogCategory { USER } +enum AuctionStatus { + OPEN + FINALIZING + FINISHED + CANCELED +} + +enum AuctionType { + BUY_RICE + SELL_RICE + UNIQUE_ITEM +} + model WorldState { id Int @id @default(autoincrement()) scenarioCode String @map("scenario_code") @@ -246,6 +259,44 @@ model InheritanceResult { @@map("inheritance_result") } +model Auction { + id Int @id @default(autoincrement()) + type AuctionType + targetCode String? @map("target_code") + hostGeneralId Int @map("host_general_id") + hostName String? @map("host_name") + detail Json @default(dbgenerated("'{}'::jsonb")) + status AuctionStatus @default(OPEN) + closeAt DateTime @map("close_at") + latestEventId String @default("") @map("latest_event_id") + latestEventAt DateTime @default(now()) @map("latest_event_at") + finalizingAt DateTime? @map("finalizing_at") + finishedAt DateTime? @map("finished_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + bids AuctionBid[] + + @@index([status, closeAt]) + @@map("auction") +} + +model AuctionBid { + id Int @id @default(autoincrement()) + auctionId Int @map("auction_id") + generalId Int @map("general_id") + amount Int + eventId String @map("event_id") + eventAt DateTime @map("event_at") + createdAt DateTime @default(now()) @map("created_at") + + auction Auction @relation(fields: [auctionId], references: [id], onDelete: Cascade) + + @@index([auctionId, amount]) + @@index([auctionId, eventAt]) + @@map("auction_bid") +} + model InheritanceUserState { userId String @id @map("user_id") meta Json @default(dbgenerated("'{}'::jsonb")) diff --git a/packages/infra/prisma/migrations/20260122000000_add_auction_tables/migration.sql b/packages/infra/prisma/migrations/20260122000000_add_auction_tables/migration.sql new file mode 100644 index 00000000..62d32640 --- /dev/null +++ b/packages/infra/prisma/migrations/20260122000000_add_auction_tables/migration.sql @@ -0,0 +1,34 @@ +CREATE TYPE "auction_status" AS ENUM ('OPEN', 'FINALIZING', 'FINISHED', 'CANCELED'); +CREATE TYPE "auction_type" AS ENUM ('BUY_RICE', 'SELL_RICE', 'UNIQUE_ITEM'); + +CREATE TABLE "auction" ( + "id" SERIAL PRIMARY KEY, + "type" "auction_type" NOT NULL, + "target_code" TEXT, + "host_general_id" INTEGER NOT NULL, + "host_name" TEXT, + "detail" JSONB NOT NULL DEFAULT '{}'::jsonb, + "status" "auction_status" NOT NULL DEFAULT 'OPEN', + "close_at" TIMESTAMP(3) NOT NULL, + "latest_event_id" TEXT NOT NULL DEFAULT '', + "latest_event_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "finalizing_at" TIMESTAMP(3), + "finished_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX "auction_status_close_at_idx" ON "auction"("status", "close_at"); + +CREATE TABLE "auction_bid" ( + "id" SERIAL PRIMARY KEY, + "auction_id" INTEGER NOT NULL REFERENCES "auction"("id") ON DELETE CASCADE, + "general_id" INTEGER NOT NULL, + "amount" INTEGER NOT NULL, + "event_id" TEXT NOT NULL, + "event_at" TIMESTAMP(3) NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX "auction_bid_auction_amount_idx" ON "auction_bid"("auction_id", "amount"); +CREATE INDEX "auction_bid_auction_event_at_idx" ON "auction_bid"("auction_id", "event_at"); diff --git a/packages/infra/src/db.ts b/packages/infra/src/db.ts index 7b5fc848..d7a21821 100644 --- a/packages/infra/src/db.ts +++ b/packages/infra/src/db.ts @@ -3,6 +3,7 @@ import type { GamePrisma, GamePrismaClient } from './gamePrisma.js'; export interface DatabaseClient { $transaction?: GamePrismaClient['$transaction']; $queryRaw: GamePrismaClient['$queryRaw']; + $executeRaw: GamePrismaClient['$executeRaw']; worldState: GamePrisma.WorldStateDelegate; general: GamePrisma.GeneralDelegate; city: GamePrisma.CityDelegate;