경매 관련 진행 준비

This commit is contained in:
2026-01-22 17:25:39 +00:00
parent de914a5d16
commit b5b761984e
17 changed files with 481 additions and 12 deletions
+2
View File
@@ -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",
+9
View File
@@ -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`,
});
+73
View File
@@ -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));
};
+11
View File
@@ -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;
}
+126
View File
@@ -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);
}
};
+18
View File
@@ -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`,
};
+11 -1
View File
@@ -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;