경매 관련 진행 준비
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
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`,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user