토너먼트 진행
This commit is contained in:
@@ -10,6 +10,8 @@ export interface TournamentState {
|
||||
termSeconds: number;
|
||||
nextAt: string;
|
||||
bettingId?: number;
|
||||
bettingCloseAt?: string;
|
||||
winnerId?: number;
|
||||
lastError?: string;
|
||||
lastErrorAt?: string;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { resolveTournamentBattle } from '@sammo-ts/logic';
|
||||
import { createTournamentRng } from '@sammo-ts/common';
|
||||
import { resolveTournamentBattle, TournamentType } from '@sammo-ts/logic';
|
||||
import {
|
||||
createGamePostgresConnector,
|
||||
createRedisConnector,
|
||||
@@ -14,6 +15,7 @@ import type { TournamentMatchEntry, TournamentState } from './types.js';
|
||||
const sleepMs = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const isBattleStage = (stage: number): boolean => stage >= 7 && stage <= 10;
|
||||
const isPreBattleStage = (stage: number): boolean => stage >= 1 && stage <= 6;
|
||||
|
||||
const nextStage = (stage: number): number => {
|
||||
switch (stage) {
|
||||
@@ -31,6 +33,116 @@ const nextStage = (stage: number): number => {
|
||||
const resolveNextAt = (state: TournamentState): string =>
|
||||
new Date(Date.now() + Math.max(1, state.termSeconds) * 1000).toISOString();
|
||||
|
||||
const resolveBettingCloseAt = (state: TournamentState): string => {
|
||||
const bettingTermMs = Math.min(state.termSeconds * 60, 3600) * 1000;
|
||||
return new Date(Date.now() + Math.max(1000, bettingTermMs)).toISOString();
|
||||
};
|
||||
|
||||
const resolveStatValue = (type: TournamentType, entry: { leadership: number; strength: number; intel: number }): number => {
|
||||
switch (type) {
|
||||
case TournamentType.LEADERSHIP:
|
||||
return entry.leadership;
|
||||
case TournamentType.STRENGTH:
|
||||
return entry.strength;
|
||||
case TournamentType.INTEL:
|
||||
return entry.intel;
|
||||
case TournamentType.TOTAL:
|
||||
default:
|
||||
return entry.leadership + entry.strength + entry.intel;
|
||||
}
|
||||
};
|
||||
|
||||
const pickFinalists = (state: TournamentState, participants: Array<{ id: number; leadership: number; strength: number; intel: number }>): number[] =>
|
||||
participants
|
||||
.slice()
|
||||
.sort((lhs, rhs) => {
|
||||
const lVal = resolveStatValue(state.type, lhs);
|
||||
const rVal = resolveStatValue(state.type, rhs);
|
||||
if (lVal !== rVal) {
|
||||
return rVal - lVal;
|
||||
}
|
||||
return lhs.id - rhs.id;
|
||||
})
|
||||
.slice(0, 16)
|
||||
.map((entry) => entry.id);
|
||||
|
||||
const buildInitialMatches = (
|
||||
state: TournamentState,
|
||||
baseSeed: string,
|
||||
participantIds: number[]
|
||||
): TournamentMatchEntry[] => {
|
||||
const rng = createTournamentRng(baseSeed, {
|
||||
openYear: state.openYear,
|
||||
openMonth: state.openMonth,
|
||||
stage: 5,
|
||||
phase: state.phase,
|
||||
matchIndex: 0,
|
||||
participantIndex: 0,
|
||||
extraSeed: 'round16',
|
||||
});
|
||||
const shuffled = rng.shuffle(participantIds);
|
||||
const pairs = shuffled.slice(0, 16);
|
||||
if (pairs.length < 2 || pairs.length % 2 !== 0) {
|
||||
throw new Error('대진표를 구성할 참가자가 부족합니다.');
|
||||
}
|
||||
return pairs.reduce<TournamentMatchEntry[]>((acc, _id, idx) => {
|
||||
if (idx % 2 !== 0) {
|
||||
return acc;
|
||||
}
|
||||
const attackerId = pairs[idx];
|
||||
const defenderId = pairs[idx + 1];
|
||||
if (attackerId === undefined || defenderId === undefined) {
|
||||
return acc;
|
||||
}
|
||||
acc.push({
|
||||
id: acc.length + 1,
|
||||
stage: 7,
|
||||
roundIndex: acc.length,
|
||||
attackerId,
|
||||
defenderId,
|
||||
});
|
||||
return acc;
|
||||
}, []);
|
||||
};
|
||||
|
||||
const buildNextMatches = (
|
||||
stage: number,
|
||||
matches: TournamentMatchEntry[]
|
||||
): TournamentMatchEntry[] => {
|
||||
const stageMatches = matches.filter((match) => match.stage === stage);
|
||||
const winners = stageMatches
|
||||
.map((match) => match.winnerId)
|
||||
.filter((winner): winner is number => typeof winner === 'number');
|
||||
|
||||
if (winners.length === 0 || winners.length % 2 !== 0) {
|
||||
throw new Error('다음 라운드를 만들 수 없습니다.');
|
||||
}
|
||||
|
||||
const nextIdBase = matches.reduce((max, entry) => Math.max(max, entry.id), 0) + 1;
|
||||
const nextStageValue = nextStage(stage);
|
||||
const result: TournamentMatchEntry[] = [];
|
||||
|
||||
for (let i = 0; i < winners.length; i += 2) {
|
||||
const attackerId = winners[i];
|
||||
const defenderId = winners[i + 1];
|
||||
if (attackerId === undefined || defenderId === undefined) {
|
||||
continue;
|
||||
}
|
||||
result.push({
|
||||
id: nextIdBase + result.length,
|
||||
stage: nextStageValue,
|
||||
roundIndex: i / 2,
|
||||
attackerId,
|
||||
defenderId,
|
||||
});
|
||||
}
|
||||
|
||||
if (result.length === 0) {
|
||||
throw new Error('다음 라운드 대진 생성에 실패했습니다.');
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const applyBattle = async (
|
||||
store: TournamentStore,
|
||||
state: TournamentState,
|
||||
@@ -42,6 +154,23 @@ const applyBattle = async (
|
||||
const pending = matches.filter((match) => match.stage === state.stage && !match.winnerId);
|
||||
if (pending.length === 0) {
|
||||
const next = nextStage(state.stage);
|
||||
if (next === 0) {
|
||||
const finalMatch = matches.find((match) => match.stage === state.stage && match.winnerId);
|
||||
const finished: TournamentState = {
|
||||
...state,
|
||||
stage: 0,
|
||||
phase: 0,
|
||||
auto: false,
|
||||
winnerId: finalMatch?.winnerId,
|
||||
nextAt: resolveNextAt(state),
|
||||
};
|
||||
await store.setState(finished);
|
||||
return finished;
|
||||
}
|
||||
|
||||
const nextMatches = buildNextMatches(state.stage, matches);
|
||||
await store.setMatches(matches.concat(nextMatches));
|
||||
|
||||
const nextState: TournamentState = {
|
||||
...state,
|
||||
stage: next,
|
||||
@@ -115,6 +244,115 @@ const applyBattle = async (
|
||||
return nextState;
|
||||
};
|
||||
|
||||
const applyPreBattleStage = async (
|
||||
store: TournamentStore,
|
||||
state: TournamentState,
|
||||
baseSeed: string
|
||||
): Promise<TournamentState> => {
|
||||
const participants = await store.getParticipants();
|
||||
|
||||
if (state.stage === 1) {
|
||||
if (participants.length < 64) {
|
||||
const waitingState: TournamentState = {
|
||||
...state,
|
||||
nextAt: resolveNextAt(state),
|
||||
};
|
||||
await store.setState(waitingState);
|
||||
return waitingState;
|
||||
}
|
||||
const nextState: TournamentState = {
|
||||
...state,
|
||||
stage: 2,
|
||||
phase: 0,
|
||||
nextAt: resolveNextAt(state),
|
||||
};
|
||||
await store.setState(nextState);
|
||||
return nextState;
|
||||
}
|
||||
|
||||
if (state.stage === 2) {
|
||||
const maxPhase = 27;
|
||||
const nextPhase = Math.min(maxPhase, state.phase + 1);
|
||||
const isComplete = nextPhase >= maxPhase;
|
||||
const nextState: TournamentState = {
|
||||
...state,
|
||||
stage: isComplete ? 3 : state.stage,
|
||||
phase: isComplete ? 0 : nextPhase,
|
||||
nextAt: resolveNextAt(state),
|
||||
};
|
||||
await store.setState(nextState);
|
||||
return nextState;
|
||||
}
|
||||
|
||||
if (state.stage === 3) {
|
||||
const nextState: TournamentState = {
|
||||
...state,
|
||||
stage: 4,
|
||||
phase: 0,
|
||||
nextAt: resolveNextAt(state),
|
||||
};
|
||||
await store.setState(nextState);
|
||||
return nextState;
|
||||
}
|
||||
|
||||
if (state.stage === 4) {
|
||||
const maxPhase = 5;
|
||||
const nextPhase = Math.min(maxPhase, state.phase + 1);
|
||||
const isComplete = nextPhase >= maxPhase;
|
||||
const nextState: TournamentState = {
|
||||
...state,
|
||||
stage: isComplete ? 5 : state.stage,
|
||||
phase: isComplete ? 0 : nextPhase,
|
||||
nextAt: resolveNextAt(state),
|
||||
};
|
||||
await store.setState(nextState);
|
||||
return nextState;
|
||||
}
|
||||
|
||||
if (state.stage === 5) {
|
||||
const matches = await store.getMatches();
|
||||
if (matches.length === 0) {
|
||||
const participantIds = pickFinalists(state, participants);
|
||||
const initialMatches = buildInitialMatches(state, baseSeed, participantIds);
|
||||
await store.setMatches(initialMatches);
|
||||
}
|
||||
const nextState: TournamentState = {
|
||||
...state,
|
||||
stage: 6,
|
||||
phase: 0,
|
||||
bettingCloseAt: resolveBettingCloseAt(state),
|
||||
nextAt: resolveNextAt(state),
|
||||
};
|
||||
await store.setState(nextState);
|
||||
return nextState;
|
||||
}
|
||||
|
||||
if (state.stage === 6) {
|
||||
const bettingCloseAt = state.bettingCloseAt ?? resolveBettingCloseAt(state);
|
||||
const bettingCloseMs = new Date(bettingCloseAt).getTime();
|
||||
if (Number.isFinite(bettingCloseMs) && bettingCloseMs > Date.now()) {
|
||||
const waitingState: TournamentState = {
|
||||
...state,
|
||||
bettingCloseAt,
|
||||
nextAt: bettingCloseAt,
|
||||
};
|
||||
await store.setState(waitingState);
|
||||
return waitingState;
|
||||
}
|
||||
const nextState: TournamentState = {
|
||||
...state,
|
||||
stage: 7,
|
||||
phase: 0,
|
||||
bettingCloseAt,
|
||||
nextAt: resolveNextAt(state),
|
||||
};
|
||||
await store.setState(nextState);
|
||||
return nextState;
|
||||
}
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
export const runTournamentWorker = async (): Promise<void> => {
|
||||
const config = resolveGameApiConfigFromEnv();
|
||||
const postgres = createGamePostgresConnector(resolvePostgresConfigFromEnv({ schema: config.profile }));
|
||||
@@ -139,11 +377,6 @@ export const runTournamentWorker = async (): Promise<void> => {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isBattleStage(state.stage)) {
|
||||
await sleepMs(config.tournamentPollMs);
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextAt = new Date(state.nextAt).getTime();
|
||||
const now = Date.now();
|
||||
if (Number.isFinite(nextAt) && nextAt > now) {
|
||||
@@ -154,14 +387,34 @@ 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';
|
||||
await applyBattle(store, state, String(baseSeed));
|
||||
if (isBattleStage(state.stage)) {
|
||||
await applyBattle(store, state, String(baseSeed));
|
||||
} else if (isPreBattleStage(state.stage)) {
|
||||
await applyPreBattleStage(store, state, String(baseSeed));
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
const trace = error instanceof Error ? error.stack : undefined;
|
||||
const now = new Date().toISOString();
|
||||
await postgres.prisma.errorLog.create({
|
||||
data: {
|
||||
category: 'TOURNAMENT',
|
||||
source: 'tournament-worker',
|
||||
message,
|
||||
trace,
|
||||
context: {
|
||||
stage: state.stage,
|
||||
phase: state.phase,
|
||||
bettingId: state.bettingId ?? null,
|
||||
nextAt: state.nextAt,
|
||||
},
|
||||
},
|
||||
});
|
||||
const nextState: TournamentState = {
|
||||
...state,
|
||||
auto: false,
|
||||
lastError: message,
|
||||
lastErrorAt: new Date().toISOString(),
|
||||
lastErrorAt: now,
|
||||
};
|
||||
await store.setState(nextState);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user