feat: complete tournament API lifecycle

This commit is contained in:
2026-07-26 04:29:45 +00:00
parent b27c529a3d
commit eec2acc08d
19 changed files with 1436 additions and 543 deletions
+47 -3
View File
@@ -1,9 +1,19 @@
import { randomUUID } from 'node:crypto';
import type { TournamentKeys } from './keys.js';
import type { TournamentBetEntry, TournamentMatchEntry, TournamentParticipantEntry, TournamentState } from './types.js';
interface RedisClientLike {
get(key: string): Promise<string | null>;
set(key: string, value: string): Promise<unknown>;
set(
key: string,
value: string,
options?: {
NX?: boolean;
PX?: number;
}
): Promise<unknown>;
del?(key: string): Promise<unknown>;
}
const safeJsonParse = <T>(raw: string | null): T | null => {
@@ -18,7 +28,34 @@ const safeJsonParse = <T>(raw: string | null): T | null => {
};
export class TournamentStore {
constructor(private readonly redis: RedisClientLike, private readonly keys: TournamentKeys) {}
constructor(
private readonly redis: RedisClientLike,
private readonly keys: TournamentKeys
) {}
async withMutationLock<T>(operation: () => Promise<T>, timeoutMs = 2_000): Promise<T> {
if (!this.redis.del) {
return operation();
}
const lockKey = `${this.keys.stateKey}:mutation-lock`;
const token = randomUUID();
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const acquired = await this.redis.set(lockKey, token, { NX: true, PX: 30_000 });
if (acquired) {
try {
return await operation();
} finally {
if ((await this.redis.get(lockKey)) === token) {
await this.redis.del(lockKey);
}
}
}
await new Promise((resolve) => setTimeout(resolve, 10));
}
throw new Error('토너먼트 요청이 처리 중입니다. 잠시 후 다시 시도해주세요.');
}
async getState(): Promise<TournamentState | null> {
return safeJsonParse<TournamentState>(await this.redis.get(this.keys.stateKey));
@@ -54,7 +91,14 @@ export class TournamentStore {
async appendBettingEntry(entry: TournamentBetEntry): Promise<TournamentBetEntry[]> {
const entries = await this.getBettingEntries();
entries.push(entry);
const existing = entries.find(
(candidate) => candidate.generalId === entry.generalId && candidate.targetId === entry.targetId
);
if (existing) {
existing.amount += entry.amount;
} else {
entries.push(entry);
}
await this.setBettingEntries(entries);
return entries;
}
+29 -19
View File
@@ -518,7 +518,6 @@ export const settleTournamentOutcome = async (options: {
return settledState;
};
export const runTournamentWorker = async (): Promise<void> => {
const config = resolveGameApiConfigFromEnv();
const postgres = createGamePostgresConnector(resolvePostgresConfigFromEnv({ schema: config.profile }));
@@ -555,25 +554,36 @@ export const runTournamentWorker = async (): Promise<void> => {
}
try {
const worldState = await postgres.prisma.worldState.findFirst();
const baseSeed = (worldState?.meta as Record<string, unknown> | null)?.hiddenSeed ?? 'tournament';
let nextState = state;
if (isBattleStage(state.stage)) {
nextState = await applyBattle(store, state, String(baseSeed), daemonTransport);
} else if (isPreBattleStage(state.stage)) {
nextState = await applyPreBattleStage(
store,
postgres.prisma,
state,
String(baseSeed),
daemonTransport
);
}
await store.withMutationLock(async () => {
const lockedState = await store.getState();
if (!lockedState || !lockedState.auto) {
return;
}
const lockedNextAt = new Date(lockedState.nextAt).getTime();
if (Number.isFinite(lockedNextAt) && lockedNextAt > Date.now()) {
return;
}
await settleTournamentOutcome({
store,
daemonTransport,
state: nextState,
const worldState = await postgres.prisma.worldState.findFirst();
const baseSeed = (worldState?.meta as Record<string, unknown> | null)?.hiddenSeed ?? 'tournament';
let nextState = lockedState;
if (isBattleStage(lockedState.stage)) {
nextState = await applyBattle(store, lockedState, String(baseSeed), daemonTransport);
} else if (isPreBattleStage(lockedState.stage)) {
nextState = await applyPreBattleStage(
store,
postgres.prisma,
lockedState,
String(baseSeed),
daemonTransport
);
}
await settleTournamentOutcome({
store,
daemonTransport,
state: nextState,
});
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
+1 -1
View File
@@ -652,7 +652,7 @@ export const seedNpcBets = async (options: {
reason: 'tournamentNpcBet',
adjustments: npcBetList.map((npc) => ({
generalId: npc.id as number,
metaDelta: { rank_betgold: betGold },
metaDelta: { betgold: betGold },
})),
});
await store.setBettingEntries(entries);