경매 관련 진행 준비
This commit is contained in:
@@ -16,6 +16,8 @@
|
|||||||
"dev": "tsdown -c ../../tsdown.config.ts -F @sammo-ts/game-api --watch",
|
"dev": "tsdown -c ../../tsdown.config.ts -F @sammo-ts/game-api --watch",
|
||||||
"worker": "GAME_API_ROLE=battle-sim-worker node dist/index.js",
|
"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: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": "eslint .",
|
||||||
"lint:fix": "eslint . --fix",
|
"lint:fix": "eslint . --fix",
|
||||||
"test": "vitest run --config vitest.config.ts",
|
"test": "vitest run --config vitest.config.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`,
|
||||||
|
});
|
||||||
@@ -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<number>;
|
||||||
|
zRem(key: string, values: string | string[]): Promise<number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuctionEventUpdate {
|
||||||
|
auctionId: number;
|
||||||
|
closeAt: Date;
|
||||||
|
eventId: string;
|
||||||
|
eventAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const seedAuctionTimers = async (
|
||||||
|
db: DatabaseClient,
|
||||||
|
redis: RedisSortedSetClient,
|
||||||
|
keys: AuctionTimerKeys
|
||||||
|
): Promise<number> => {
|
||||||
|
const rows = await db.$queryRaw<AuctionTimerRow[]>(
|
||||||
|
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<boolean> => {
|
||||||
|
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<void> => {
|
||||||
|
await redis.zRem(keys.timerKey, String(auctionId));
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<string[]>;
|
||||||
|
zRangeWithScores(key: string, start: number, stop: number): Promise<Array<{ value: string; score: number }>>;
|
||||||
|
zAdd(key: string, values: Array<{ score: number; value: string }>): Promise<number>;
|
||||||
|
zRem(key: string, values: string | string[]): Promise<number>;
|
||||||
|
zRemRangeByScore(key: string, min: number, max: number): Promise<number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sleepMs = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
const popDueAuctionIds = async (
|
||||||
|
redis: RedisTimerClient,
|
||||||
|
timerKey: string,
|
||||||
|
nowMs: number,
|
||||||
|
batchSize: number
|
||||||
|
): Promise<string[]> => {
|
||||||
|
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<number | null> => {
|
||||||
|
const next = await redis.zRangeWithScores(timerKey, 0, 0);
|
||||||
|
if (!next.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return next[0]?.score ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const runAuctionWorker = async (): Promise<void> => {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -11,6 +11,9 @@ export interface GameApiConfig {
|
|||||||
daemonRequestTimeoutMs: number;
|
daemonRequestTimeoutMs: number;
|
||||||
battleSimRequestTimeoutMs: number;
|
battleSimRequestTimeoutMs: number;
|
||||||
battleSimResultTtlSeconds: number;
|
battleSimResultTtlSeconds: number;
|
||||||
|
auctionTimerPollMs: number;
|
||||||
|
auctionTimerResyncMs: number;
|
||||||
|
auctionTimerRetentionSeconds: number;
|
||||||
gameTokenSecret: string;
|
gameTokenSecret: string;
|
||||||
flushChannel: string;
|
flushChannel: string;
|
||||||
}
|
}
|
||||||
@@ -44,6 +47,21 @@ export const resolveGameApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.env
|
|||||||
60,
|
60,
|
||||||
'BATTLE_SIM_RESULT_TTL_SECONDS'
|
'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,
|
gameTokenSecret: secret,
|
||||||
flushChannel: `${gatewayPrefix}:flush`,
|
flushChannel: `${gatewayPrefix}:flush`,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { fileURLToPath } from 'node:url';
|
|||||||
|
|
||||||
import { runGameApiServer } from './server.js';
|
import { runGameApiServer } from './server.js';
|
||||||
import { runBattleSimWorker } from './battleSim/worker.js';
|
import { runBattleSimWorker } from './battleSim/worker.js';
|
||||||
|
import { runAuctionWorker } from './auction/worker.js';
|
||||||
|
|
||||||
export * from './config.js';
|
export * from './config.js';
|
||||||
export * from './context.js';
|
export * from './context.js';
|
||||||
@@ -21,6 +22,10 @@ export * from './battleSim/redisTransport.js';
|
|||||||
export * from './battleSim/inMemoryTransport.js';
|
export * from './battleSim/inMemoryTransport.js';
|
||||||
export * from './battleSim/keys.js';
|
export * from './battleSim/keys.js';
|
||||||
export * from './battleSim/worker.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
|
// Types for TRPC consumer
|
||||||
export type { MessageView } from './messages/store.js';
|
export type { MessageView } from './messages/store.js';
|
||||||
@@ -40,7 +45,12 @@ const isMain = (): boolean => {
|
|||||||
|
|
||||||
if (isMain()) {
|
if (isMain()) {
|
||||||
const role = process.env.GAME_API_ROLE ?? 'server';
|
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) => {
|
run().catch((error) => {
|
||||||
console.error('[game-api] failed to start', error);
|
console.error('[game-api] failed to start', error);
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
|
|||||||
@@ -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<TurnDaemonCommandResult>;
|
||||||
|
close(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuctionRow {
|
||||||
|
id: number;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createAuctionFinalizer = async (databaseUrl: string): Promise<AuctionFinalizer> => {
|
||||||
|
const connector = createGamePostgresConnector({ url: databaseUrl });
|
||||||
|
await connector.connect();
|
||||||
|
const prisma = connector.prisma;
|
||||||
|
|
||||||
|
return {
|
||||||
|
finalize: async (auctionId: number): Promise<TurnDaemonCommandResult> => {
|
||||||
|
const rows = await prisma.$queryRaw<AuctionRow[]>(
|
||||||
|
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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -68,6 +68,16 @@ const normalizeCommand = (envelope: TurnDaemonCommandEnvelope): TurnDaemonComman
|
|||||||
requestId?: string;
|
requestId?: string;
|
||||||
};
|
};
|
||||||
switch (command.type) {
|
switch (command.type) {
|
||||||
|
case 'auctionFinalize': {
|
||||||
|
if (typeof command.auctionId !== 'number') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: 'auctionFinalize',
|
||||||
|
requestId: envelope.requestId,
|
||||||
|
auctionId: command.auctionId,
|
||||||
|
};
|
||||||
|
}
|
||||||
case 'troopJoin': {
|
case 'troopJoin': {
|
||||||
if (typeof command.generalId !== 'number' || typeof command.troopId !== 'number') {
|
if (typeof command.generalId !== 'number' || typeof command.troopId !== 'number') {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -249,6 +249,7 @@ export class TurnDaemonLifecycle {
|
|||||||
case 'vacation':
|
case 'vacation':
|
||||||
case 'setMySetting':
|
case 'setMySetting':
|
||||||
case 'dropItem':
|
case 'dropItem':
|
||||||
|
case 'auctionFinalize':
|
||||||
case 'changePermission':
|
case 'changePermission':
|
||||||
case 'kick':
|
case 'kick':
|
||||||
case 'appoint':
|
case 'appoint':
|
||||||
@@ -268,6 +269,7 @@ export class TurnDaemonLifecycle {
|
|||||||
| { type: 'vacation' }
|
| { type: 'vacation' }
|
||||||
| { type: 'setMySetting' }
|
| { type: 'setMySetting' }
|
||||||
| { type: 'dropItem' }
|
| { type: 'dropItem' }
|
||||||
|
| { type: 'auctionFinalize' }
|
||||||
| { type: 'changePermission' }
|
| { type: 'changePermission' }
|
||||||
| { type: 'kick' }
|
| { type: 'kick' }
|
||||||
| { type: 'appoint' }
|
| { type: 'appoint' }
|
||||||
@@ -277,23 +279,41 @@ export class TurnDaemonLifecycle {
|
|||||||
try {
|
try {
|
||||||
result = this.commandHandler ? await this.commandHandler.handle(command) : null;
|
result = this.commandHandler ? await this.commandHandler.handle(command) : null;
|
||||||
if (!result) {
|
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 = {
|
result = {
|
||||||
type: command.type,
|
type: command.type,
|
||||||
ok: false,
|
ok: false,
|
||||||
generalId: command.generalId,
|
generalId: command.generalId,
|
||||||
reason: '턴 데몬이 명령을 처리할 수 없습니다.',
|
reason,
|
||||||
...(command.type === 'troopJoin' ? { troopId: command.troopId } : {}),
|
...(command.type === 'troopJoin' ? { troopId: command.troopId } : {}),
|
||||||
} as TurnDaemonCommandResult;
|
} 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) {
|
if (this.commandResponder && command.requestId) {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { loadTurnCommandProfile } from './turnCommandProfile.js';
|
|||||||
import { loadTurnWorldFromDatabase } from './worldLoader.js';
|
import { loadTurnWorldFromDatabase } from './worldLoader.js';
|
||||||
import { shouldUseAi } from './ai/generalAi.js';
|
import { shouldUseAi } from './ai/generalAi.js';
|
||||||
import { createUnificationHandler } from './unificationHandler.js';
|
import { createUnificationHandler } from './unificationHandler.js';
|
||||||
|
import { createAuctionFinalizer } from '../auction/finalizer.js';
|
||||||
|
|
||||||
export interface TurnDaemonRuntimeOptions {
|
export interface TurnDaemonRuntimeOptions {
|
||||||
profile: string;
|
profile: string;
|
||||||
@@ -170,6 +171,7 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
|
|||||||
let hooks: TurnDaemonHooks | undefined;
|
let hooks: TurnDaemonHooks | undefined;
|
||||||
let publishRealtimeEvent: ((event: RealtimeEvent) => Promise<void>) | null = null;
|
let publishRealtimeEvent: ((event: RealtimeEvent) => Promise<void>) | null = null;
|
||||||
let close = async () => {};
|
let close = async () => {};
|
||||||
|
let auctionFinalizer: Awaited<ReturnType<typeof createAuctionFinalizer>> | null = null;
|
||||||
let redisCommandStream: RedisTurnDaemonCommandStream | null = null;
|
let redisCommandStream: RedisTurnDaemonCommandStream | null = null;
|
||||||
let redisConnector: ReturnType<typeof createRedisConnector> | null = null;
|
let redisConnector: ReturnType<typeof createRedisConnector> | null = null;
|
||||||
let pauseGate: (() => Promise<boolean>) | undefined;
|
let pauseGate: (() => Promise<boolean>) | undefined;
|
||||||
@@ -189,6 +191,7 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
|
|||||||
const dbHooks = await createDatabaseTurnHooks(options.databaseUrl, world, {
|
const dbHooks = await createDatabaseTurnHooks(options.databaseUrl, world, {
|
||||||
reservedTurns: reservedTurnStoreHandle?.store,
|
reservedTurns: reservedTurnStoreHandle?.store,
|
||||||
});
|
});
|
||||||
|
auctionFinalizer = await createAuctionFinalizer(options.databaseUrl);
|
||||||
hooks = {
|
hooks = {
|
||||||
...dbHooks.hooks,
|
...dbHooks.hooks,
|
||||||
onRunError: async (error) => {
|
onRunError: async (error) => {
|
||||||
@@ -197,6 +200,9 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
close = async () => {
|
close = async () => {
|
||||||
|
if (auctionFinalizer) {
|
||||||
|
await auctionFinalizer.close();
|
||||||
|
}
|
||||||
await dbHooks.close();
|
await dbHooks.close();
|
||||||
if (reservedTurnStoreHandle) {
|
if (reservedTurnStoreHandle) {
|
||||||
await reservedTurnStoreHandle.close();
|
await reservedTurnStoreHandle.close();
|
||||||
@@ -281,6 +287,7 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
|
|||||||
const commandHandler = createTurnDaemonCommandHandler({
|
const commandHandler = createTurnDaemonCommandHandler({
|
||||||
world,
|
world,
|
||||||
hooks,
|
hooks,
|
||||||
|
auctionFinalizer: auctionFinalizer ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const defaultBudget: TurnRunBudget = options.defaultBudget ?? {
|
const defaultBudget: TurnRunBudget = options.defaultBudget ?? {
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ const flushWorld = async (world: InMemoryTurnWorld, hooks?: TurnDaemonHooks): Pr
|
|||||||
interface CommandHandlerContext {
|
interface CommandHandlerContext {
|
||||||
world: InMemoryTurnWorld;
|
world: InMemoryTurnWorld;
|
||||||
hooks?: TurnDaemonHooks;
|
hooks?: TurnDaemonHooks;
|
||||||
|
auctionFinalizer?: AuctionFinalizer;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuctionFinalizer {
|
||||||
|
finalize(auctionId: number): Promise<TurnDaemonCommandResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleTroopJoin(
|
async function handleTroopJoin(
|
||||||
@@ -299,6 +304,21 @@ async function handleDropItem(
|
|||||||
return { type: 'dropItem', ok: true, generalId: command.generalId };
|
return { type: 'dropItem', ok: true, generalId: command.generalId };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleAuctionFinalize(
|
||||||
|
ctx: CommandHandlerContext,
|
||||||
|
command: Extract<TurnDaemonCommand, { type: 'auctionFinalize' }>
|
||||||
|
): Promise<TurnDaemonCommandResult> {
|
||||||
|
if (!ctx.auctionFinalizer) {
|
||||||
|
return {
|
||||||
|
type: 'auctionFinalize',
|
||||||
|
ok: false,
|
||||||
|
auctionId: command.auctionId,
|
||||||
|
reason: '경매 확정기가 준비되지 않았습니다.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return ctx.auctionFinalizer.finalize(command.auctionId);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleChangePermission(
|
async function handleChangePermission(
|
||||||
ctx: CommandHandlerContext,
|
ctx: CommandHandlerContext,
|
||||||
command: Extract<TurnDaemonCommand, { type: 'changePermission' }>
|
command: Extract<TurnDaemonCommand, { type: 'changePermission' }>
|
||||||
@@ -434,8 +454,9 @@ async function handleAppoint(
|
|||||||
export const createTurnDaemonCommandHandler = (options: {
|
export const createTurnDaemonCommandHandler = (options: {
|
||||||
world: InMemoryTurnWorld;
|
world: InMemoryTurnWorld;
|
||||||
hooks?: TurnDaemonHooks;
|
hooks?: TurnDaemonHooks;
|
||||||
|
auctionFinalizer?: AuctionFinalizer;
|
||||||
}): TurnDaemonCommandHandler => {
|
}): TurnDaemonCommandHandler => {
|
||||||
const ctx = { world: options.world, hooks: options.hooks };
|
const ctx = { world: options.world, hooks: options.hooks, auctionFinalizer: options.auctionFinalizer };
|
||||||
|
|
||||||
return {
|
return {
|
||||||
handle: async (command): Promise<TurnDaemonCommandResult | null> => {
|
handle: async (command): Promise<TurnDaemonCommandResult | null> => {
|
||||||
@@ -456,6 +477,8 @@ export const createTurnDaemonCommandHandler = (options: {
|
|||||||
return handleSetMySetting(ctx, command);
|
return handleSetMySetting(ctx, command);
|
||||||
case 'dropItem':
|
case 'dropItem':
|
||||||
return handleDropItem(ctx, command);
|
return handleDropItem(ctx, command);
|
||||||
|
case 'auctionFinalize':
|
||||||
|
return handleAuctionFinalize(ctx, command);
|
||||||
case 'changePermission':
|
case 'changePermission':
|
||||||
return handleChangePermission(ctx, command);
|
return handleChangePermission(ctx, command);
|
||||||
case 'kick':
|
case 'kick':
|
||||||
|
|||||||
@@ -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] Integrate war/battle pipeline into turn processing (troop movement/war resolution hooks, not just isolated sim jobs).
|
||||||
- [AI suggestion] Expand turn command catalog beyond the current subset (general/nation commands).
|
- [AI suggestion] Expand turn command catalog beyond the current subset (general/nation commands).
|
||||||
- [AI suggestion] Apply install settings (`join_mode`, `npcmode`, `show_img_level`, `tournament_trig`) to runtime rules/command constraints and UI behavior, beyond just storing them in world state.
|
- [AI suggestion] Apply install settings (`join_mode`, `npcmode`, `show_img_level`, `tournament_trig`) to runtime rules/command constraints and UI behavior, beyond just storing them in world state.
|
||||||
|
- [AI suggestion] Implement full auction finalization logic in the daemon (resource transfers, unique item grants, and log writes) once the auction schema is stabilized.
|
||||||
|
|
||||||
## Frontend
|
## Frontend
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ export type TurnDaemonCommand =
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
| { type: 'dropItem'; requestId?: string; generalId: number; itemType: string }
|
| { type: 'dropItem'; requestId?: string; generalId: number; itemType: string }
|
||||||
|
| { type: 'auctionFinalize'; requestId?: string; auctionId: number }
|
||||||
| {
|
| {
|
||||||
type: 'changePermission';
|
type: 'changePermission';
|
||||||
requestId?: string;
|
requestId?: string;
|
||||||
@@ -87,6 +88,17 @@ export type TurnDaemonCommand =
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type TurnDaemonCommandResult =
|
export type TurnDaemonCommandResult =
|
||||||
|
| {
|
||||||
|
type: 'auctionFinalize';
|
||||||
|
ok: true;
|
||||||
|
auctionId: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'auctionFinalize';
|
||||||
|
ok: false;
|
||||||
|
auctionId: number;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
type: 'troopJoin';
|
type: 'troopJoin';
|
||||||
ok: true;
|
ok: true;
|
||||||
|
|||||||
@@ -24,6 +24,19 @@ enum LogCategory {
|
|||||||
USER
|
USER
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum AuctionStatus {
|
||||||
|
OPEN
|
||||||
|
FINALIZING
|
||||||
|
FINISHED
|
||||||
|
CANCELED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AuctionType {
|
||||||
|
BUY_RICE
|
||||||
|
SELL_RICE
|
||||||
|
UNIQUE_ITEM
|
||||||
|
}
|
||||||
|
|
||||||
model WorldState {
|
model WorldState {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
scenarioCode String @map("scenario_code")
|
scenarioCode String @map("scenario_code")
|
||||||
@@ -246,6 +259,44 @@ model InheritanceResult {
|
|||||||
@@map("inheritance_result")
|
@@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 {
|
model InheritanceUserState {
|
||||||
userId String @id @map("user_id")
|
userId String @id @map("user_id")
|
||||||
meta Json @default(dbgenerated("'{}'::jsonb"))
|
meta Json @default(dbgenerated("'{}'::jsonb"))
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -3,6 +3,7 @@ import type { GamePrisma, GamePrismaClient } from './gamePrisma.js';
|
|||||||
export interface DatabaseClient {
|
export interface DatabaseClient {
|
||||||
$transaction?: GamePrismaClient['$transaction'];
|
$transaction?: GamePrismaClient['$transaction'];
|
||||||
$queryRaw: GamePrismaClient['$queryRaw'];
|
$queryRaw: GamePrismaClient['$queryRaw'];
|
||||||
|
$executeRaw: GamePrismaClient['$executeRaw'];
|
||||||
worldState: GamePrisma.WorldStateDelegate;
|
worldState: GamePrisma.WorldStateDelegate;
|
||||||
general: GamePrisma.GeneralDelegate;
|
general: GamePrisma.GeneralDelegate;
|
||||||
city: GamePrisma.CityDelegate;
|
city: GamePrisma.CityDelegate;
|
||||||
|
|||||||
Reference in New Issue
Block a user