660 lines
23 KiB
TypeScript
660 lines
23 KiB
TypeScript
import { createTournamentRng, type TurnDaemonCommandResult } from '@sammo-ts/common';
|
|
import { resolveTournamentBattle } from '@sammo-ts/logic';
|
|
import {
|
|
createGamePostgresConnector,
|
|
createRedisConnector,
|
|
resolvePostgresConfigFromEnv,
|
|
resolveRedisConfigFromEnv,
|
|
type GamePrismaClient,
|
|
} from '@sammo-ts/infra';
|
|
|
|
import { resolveGameApiConfigFromEnv } from '../config.js';
|
|
import { DatabaseTurnDaemonTransport } from '../daemon/databaseTransport.js';
|
|
import type { TurnDaemonTransport } from '../daemon/transport.js';
|
|
import { buildTournamentKeys } from './keys.js';
|
|
import { TournamentStore } from './store.js';
|
|
import type { TournamentMatchEntry, TournamentState } from './types.js';
|
|
import {
|
|
applyGroupMatch,
|
|
assignGroupSlots,
|
|
buildBettingPayouts,
|
|
buildFinal16MatchesFromGroups,
|
|
buildInitialMatches,
|
|
buildNextMatches,
|
|
buildTournamentRewardPayload,
|
|
fillParticipants,
|
|
isBattleStage,
|
|
isPreBattleStage,
|
|
nextStage,
|
|
pickFinalists,
|
|
resolveBettingCloseAt,
|
|
resolveGroupPair,
|
|
resolveNextAt,
|
|
seedNpcBets,
|
|
sleepMs,
|
|
sortByRanking,
|
|
type TournamentMatchOutcome,
|
|
type TournamentPrismaClient,
|
|
} from './workerHelpers.js';
|
|
|
|
export const applyBattle = async (
|
|
store: TournamentStore,
|
|
state: TournamentState,
|
|
baseSeed: string,
|
|
daemonTransport?: TurnDaemonTransport
|
|
): Promise<TournamentState> => {
|
|
const matches = await store.getMatches();
|
|
const participants = await store.getParticipants();
|
|
|
|
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,
|
|
phase: 0,
|
|
nextAt: resolveNextAt(state),
|
|
};
|
|
await store.setState(nextState);
|
|
return nextState;
|
|
}
|
|
|
|
const target = pending[state.phase] ?? pending[0];
|
|
if (!target) {
|
|
throw new Error('토너먼트 매치가 없습니다.');
|
|
}
|
|
|
|
const attacker = participants.find((entry) => entry.id === target.attackerId);
|
|
const defender = participants.find((entry) => entry.id === target.defenderId);
|
|
if (!attacker || !defender) {
|
|
throw new Error('토너먼트 참가자 정보를 찾을 수 없습니다.');
|
|
}
|
|
|
|
const result = resolveTournamentBattle({
|
|
type: state.type,
|
|
battleType: 1,
|
|
attacker: {
|
|
id: attacker.id,
|
|
name: attacker.name,
|
|
stats: {
|
|
leadership: attacker.leadership,
|
|
strength: attacker.strength,
|
|
intel: attacker.intel,
|
|
},
|
|
level: attacker.level,
|
|
},
|
|
defender: {
|
|
id: defender.id,
|
|
name: defender.name,
|
|
stats: {
|
|
leadership: defender.leadership,
|
|
strength: defender.strength,
|
|
intel: defender.intel,
|
|
},
|
|
level: defender.level,
|
|
},
|
|
context: {
|
|
openYear: state.openYear,
|
|
openMonth: state.openMonth,
|
|
stage: state.stage,
|
|
phase: state.phase,
|
|
matchIndex: target.id,
|
|
},
|
|
baseSeed,
|
|
});
|
|
|
|
const lastLogEntry = result.logEntries[result.logEntries.length - 1] ?? null;
|
|
|
|
const updatedMatch: TournamentMatchEntry = {
|
|
...target,
|
|
winnerId: result.winnerId ?? undefined,
|
|
log: result.log,
|
|
logEntries: result.logEntries,
|
|
lastEnergy: lastLogEntry
|
|
? { attacker: lastLogEntry.attackerEnergy, defender: lastLogEntry.defenderEnergy }
|
|
: undefined,
|
|
};
|
|
|
|
const nextMatches = matches.map((entry) => (entry.id === target.id ? updatedMatch : entry));
|
|
await store.setMatches(nextMatches);
|
|
|
|
if (daemonTransport && attacker.id > 0 && defender.id > 0) {
|
|
await daemonTransport.sendCommand({
|
|
type: 'tournamentMatchResult',
|
|
tournamentType: state.type,
|
|
attackerId: attacker.id,
|
|
defenderId: defender.id,
|
|
result: result.draw ? 'draw' : result.winnerId === attacker.id ? 'attacker' : 'defender',
|
|
});
|
|
}
|
|
|
|
const nextPhase = state.phase + 1;
|
|
const nextState: TournamentState = {
|
|
...state,
|
|
phase: nextPhase,
|
|
nextAt: resolveNextAt(state),
|
|
};
|
|
await store.setState(nextState);
|
|
return nextState;
|
|
};
|
|
|
|
export const applyPreBattleStage = async (
|
|
store: TournamentStore,
|
|
prisma: TournamentPrismaClient,
|
|
state: TournamentState,
|
|
baseSeed: string,
|
|
daemonTransport: TurnDaemonTransport
|
|
): Promise<TournamentState> => {
|
|
const participants = await store.getParticipants();
|
|
|
|
if (state.stage === 1) {
|
|
let nextParticipants = participants;
|
|
if (participants.length < 64) {
|
|
nextParticipants = await fillParticipants({
|
|
prisma,
|
|
state,
|
|
baseSeed,
|
|
current: participants,
|
|
limit: 64,
|
|
});
|
|
}
|
|
const grouped = assignGroupSlots(nextParticipants, 8, 8, 0).map((entry) => ({
|
|
...entry,
|
|
win: 0,
|
|
draw: 0,
|
|
lose: 0,
|
|
gl: 0,
|
|
seedRank: 0,
|
|
finalRank: 0,
|
|
}));
|
|
await store.setParticipants(grouped);
|
|
const nextState: TournamentState = {
|
|
...state,
|
|
stage: 2,
|
|
phase: 0,
|
|
participantsLockedAt: new Date().toISOString(),
|
|
nextAt: resolveNextAt(state),
|
|
};
|
|
await store.setState(nextState);
|
|
return nextState;
|
|
}
|
|
|
|
if (state.stage === 2) {
|
|
const pair = resolveGroupPair(2, state.phase);
|
|
if (!pair) {
|
|
throw new Error('예선 매치 구성을 찾을 수 없습니다.');
|
|
}
|
|
let updated = participants.some((entry) => entry.groupId === undefined)
|
|
? assignGroupSlots(participants, 8, 8, 0)
|
|
: participants;
|
|
const outcomes: TournamentMatchOutcome[] = [];
|
|
for (let groupId = 0; groupId < 8; groupId += 1) {
|
|
const groupEntries = updated.filter((entry) => entry.groupId === groupId);
|
|
const attacker = groupEntries.find((entry) => entry.groupNo === pair[0]);
|
|
const defender = groupEntries.find((entry) => entry.groupNo === pair[1]);
|
|
if (!attacker || !defender) {
|
|
continue;
|
|
}
|
|
const result = applyGroupMatch(updated, attacker, defender, state, baseSeed, groupId);
|
|
updated = result.participants;
|
|
outcomes.push(result.outcome);
|
|
}
|
|
await store.setParticipants(updated);
|
|
|
|
if (outcomes.length > 0) {
|
|
await Promise.all(
|
|
outcomes
|
|
.filter((outcome) => outcome.attackerId > 0 && outcome.defenderId > 0)
|
|
.map((outcome) =>
|
|
daemonTransport.sendCommand({
|
|
type: 'tournamentMatchResult',
|
|
tournamentType: state.type,
|
|
attackerId: outcome.attackerId,
|
|
defenderId: outcome.defenderId,
|
|
result: outcome.result,
|
|
})
|
|
)
|
|
);
|
|
}
|
|
|
|
const maxPhase = 55;
|
|
const isComplete = state.phase >= maxPhase;
|
|
if (isComplete) {
|
|
const ranked = updated;
|
|
for (let groupId = 0; groupId < 8; groupId += 1) {
|
|
const groupEntries = ranked.filter((entry) => entry.groupId === groupId);
|
|
const ordered = sortByRanking(groupEntries);
|
|
ordered.slice(0, 4).forEach((entry, idx) => {
|
|
const target = ranked.find((item) => item.id === entry.id);
|
|
if (target) {
|
|
target.seedRank = idx + 1;
|
|
}
|
|
});
|
|
}
|
|
await store.setParticipants(ranked);
|
|
const nextState: TournamentState = {
|
|
...state,
|
|
stage: 3,
|
|
phase: 0,
|
|
nextAt: resolveNextAt(state),
|
|
};
|
|
await store.setState(nextState);
|
|
return nextState;
|
|
}
|
|
|
|
const nextState: TournamentState = {
|
|
...state,
|
|
phase: state.phase + 1,
|
|
nextAt: resolveNextAt(state),
|
|
};
|
|
await store.setState(nextState);
|
|
return nextState;
|
|
}
|
|
|
|
if (state.stage === 3) {
|
|
const phase = state.phase;
|
|
const groupId = 10 + (phase % 8);
|
|
const groupNo = Math.floor(phase / 8);
|
|
const seedTarget = phase < 8 ? 1 : phase < 16 ? 2 : 3;
|
|
|
|
const candidates = participants.filter((entry) => {
|
|
if (entry.groupId !== undefined && entry.groupId >= 10) {
|
|
return false;
|
|
}
|
|
if (seedTarget === 3) {
|
|
return (entry.seedRank ?? 0) > 2;
|
|
}
|
|
return (entry.seedRank ?? 0) === seedTarget;
|
|
});
|
|
|
|
if (candidates.length === 0) {
|
|
throw new Error('본선 추첨 후보가 없습니다.');
|
|
}
|
|
|
|
const rng = createTournamentRng(baseSeed, {
|
|
openYear: state.openYear,
|
|
openMonth: state.openMonth,
|
|
stage: 3,
|
|
phase,
|
|
matchIndex: groupId,
|
|
participantIndex: 0,
|
|
extraSeed: `selection:${seedTarget}:${candidates.map((c) => c.id).join('-')}`,
|
|
});
|
|
const picked = rng.choice(candidates);
|
|
|
|
const nextParticipants = participants.map((entry) => {
|
|
if (entry.id !== picked.id) {
|
|
return entry;
|
|
}
|
|
return {
|
|
...entry,
|
|
groupId,
|
|
groupNo,
|
|
win: 0,
|
|
draw: 0,
|
|
lose: 0,
|
|
gl: 0,
|
|
};
|
|
});
|
|
await store.setParticipants(nextParticipants);
|
|
|
|
if (phase >= 31) {
|
|
const nextState: TournamentState = {
|
|
...state,
|
|
stage: 4,
|
|
phase: 0,
|
|
nextAt: resolveNextAt(state),
|
|
};
|
|
await store.setState(nextState);
|
|
return nextState;
|
|
}
|
|
|
|
const nextState: TournamentState = {
|
|
...state,
|
|
phase: phase + 1,
|
|
nextAt: resolveNextAt(state),
|
|
};
|
|
await store.setState(nextState);
|
|
return nextState;
|
|
}
|
|
|
|
if (state.stage === 4) {
|
|
const pair = resolveGroupPair(4, state.phase);
|
|
if (!pair) {
|
|
throw new Error('본선 매치 구성을 찾을 수 없습니다.');
|
|
}
|
|
let updated = participants;
|
|
const outcomes: TournamentMatchOutcome[] = [];
|
|
for (let groupId = 10; groupId < 18; groupId += 1) {
|
|
const groupEntries = updated.filter((entry) => entry.groupId === groupId);
|
|
const attacker = groupEntries.find((entry) => entry.groupNo === pair[0]);
|
|
const defender = groupEntries.find((entry) => entry.groupNo === pair[1]);
|
|
if (!attacker || !defender) {
|
|
continue;
|
|
}
|
|
const result = applyGroupMatch(updated, attacker, defender, state, baseSeed, groupId);
|
|
updated = result.participants;
|
|
outcomes.push(result.outcome);
|
|
}
|
|
await store.setParticipants(updated);
|
|
|
|
if (outcomes.length > 0) {
|
|
await Promise.all(
|
|
outcomes
|
|
.filter((outcome) => outcome.attackerId > 0 && outcome.defenderId > 0)
|
|
.map((outcome) =>
|
|
daemonTransport.sendCommand({
|
|
type: 'tournamentMatchResult',
|
|
tournamentType: state.type,
|
|
attackerId: outcome.attackerId,
|
|
defenderId: outcome.defenderId,
|
|
result: outcome.result,
|
|
})
|
|
)
|
|
);
|
|
}
|
|
|
|
const maxPhase = 5;
|
|
if (state.phase >= maxPhase) {
|
|
for (let groupId = 10; groupId < 18; groupId += 1) {
|
|
const groupEntries = updated.filter((entry) => entry.groupId === groupId);
|
|
const ordered = sortByRanking(groupEntries);
|
|
ordered.slice(0, 2).forEach((entry, idx) => {
|
|
const target = updated.find((item) => item.id === entry.id);
|
|
if (target) {
|
|
target.finalRank = idx + 1;
|
|
}
|
|
});
|
|
}
|
|
await store.setParticipants(updated);
|
|
const nextState: TournamentState = {
|
|
...state,
|
|
stage: 5,
|
|
phase: 0,
|
|
nextAt: resolveNextAt(state),
|
|
};
|
|
await store.setState(nextState);
|
|
return nextState;
|
|
}
|
|
|
|
const nextState: TournamentState = {
|
|
...state,
|
|
phase: state.phase + 1,
|
|
nextAt: resolveNextAt(state),
|
|
};
|
|
await store.setState(nextState);
|
|
return nextState;
|
|
}
|
|
|
|
if (state.stage === 5) {
|
|
const matches = await store.getMatches();
|
|
if (matches.length === 0) {
|
|
const fixedMatches = buildFinal16MatchesFromGroups(participants);
|
|
const participantIds = fixedMatches
|
|
? fixedMatches.flatMap((entry) => [entry.attackerId, entry.defenderId])
|
|
: pickFinalists(state, participants);
|
|
const initialMatches = fixedMatches ?? buildInitialMatches(state, baseSeed, participantIds);
|
|
await store.setMatches(initialMatches);
|
|
}
|
|
const nextState: TournamentState = {
|
|
...state,
|
|
stage: 6,
|
|
phase: 0,
|
|
bettingId: state.bettingId ?? Date.now(),
|
|
bettingCloseAt: resolveBettingCloseAt(state),
|
|
nextAt: resolveNextAt(state),
|
|
};
|
|
await store.setState(nextState);
|
|
await seedNpcBets({ prisma, store, state: nextState, baseSeed, daemonTransport });
|
|
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 settleTournamentOutcome = async (options: {
|
|
store: TournamentStore;
|
|
daemonTransport: TurnDaemonTransport;
|
|
state: TournamentState;
|
|
}): Promise<TournamentState | null> => {
|
|
const { store, daemonTransport, state } = options;
|
|
if (state.stage !== 0 || !state.winnerId) {
|
|
return null;
|
|
}
|
|
|
|
let settledState = state;
|
|
let changed = false;
|
|
|
|
const requireSuccessfulResult = (
|
|
result: TurnDaemonCommandResult | null,
|
|
expectedType: TurnDaemonCommandResult['type']
|
|
): void => {
|
|
if (!result) {
|
|
throw new Error(`${expectedType} 명령 응답 시간이 초과되었습니다.`);
|
|
}
|
|
if (result.type !== expectedType) {
|
|
throw new Error(`${expectedType} 명령에 잘못된 응답(${result.type})을 받았습니다.`);
|
|
}
|
|
if (!result.ok) {
|
|
throw new Error(`${expectedType} 명령이 실패했습니다: ${result.reason}`);
|
|
}
|
|
};
|
|
|
|
if (!settledState.rewardSettled) {
|
|
const matches = await store.getMatches();
|
|
const rewardPayload = buildTournamentRewardPayload(matches);
|
|
const result = await daemonTransport.requestCommand({
|
|
type: 'tournamentReward',
|
|
requestId: `tournament:${state.bettingId ?? `${state.openYear}:${state.openMonth}:${state.type}`}:reward`,
|
|
tournamentType: state.type,
|
|
winnerId: rewardPayload.winnerId,
|
|
runnerUpId: rewardPayload.runnerUpId,
|
|
top16: rewardPayload.top16,
|
|
top8: rewardPayload.top8,
|
|
top4: rewardPayload.top4,
|
|
});
|
|
requireSuccessfulResult(result, 'tournamentReward');
|
|
settledState = {
|
|
...settledState,
|
|
rewardSettled: true,
|
|
};
|
|
changed = true;
|
|
await store.setState(settledState);
|
|
}
|
|
|
|
if (settledState.bettingId && !settledState.bettingSettled) {
|
|
const bettingEntries = await store.getBettingEntries();
|
|
if (bettingEntries.length > 0) {
|
|
const payoutInfo = buildBettingPayouts(settledState.winnerId!, bettingEntries);
|
|
if (payoutInfo.payouts.length > 0) {
|
|
if (payoutInfo.refundAll) {
|
|
const result = await daemonTransport.requestCommand({
|
|
type: 'tournamentRefund',
|
|
requestId: `tournament:${settledState.bettingId}:betting-refund`,
|
|
bettingId: settledState.bettingId,
|
|
refunds: payoutInfo.payouts,
|
|
reason: 'no_winner',
|
|
});
|
|
requireSuccessfulResult(result, 'tournamentRefund');
|
|
} else {
|
|
const result = await daemonTransport.requestCommand({
|
|
type: 'tournamentBettingPayout',
|
|
requestId: `tournament:${settledState.bettingId}:betting-payout`,
|
|
bettingId: settledState.bettingId,
|
|
payouts: payoutInfo.payouts,
|
|
reason: 'winner_payout',
|
|
});
|
|
requireSuccessfulResult(result, 'tournamentBettingPayout');
|
|
}
|
|
}
|
|
}
|
|
|
|
settledState = {
|
|
...settledState,
|
|
bettingSettled: true,
|
|
};
|
|
changed = true;
|
|
await store.setState(settledState);
|
|
}
|
|
|
|
return changed ? settledState : null;
|
|
};
|
|
|
|
const needsSettlement = (state: TournamentState): boolean =>
|
|
state.stage === 0 &&
|
|
Boolean(state.winnerId) &&
|
|
(!state.rewardSettled || (Boolean(state.bettingId) && !state.bettingSettled));
|
|
|
|
export const processTournamentTick = async (options: {
|
|
store: TournamentStore;
|
|
prisma: GamePrismaClient;
|
|
daemonTransport: TurnDaemonTransport;
|
|
now?: () => number;
|
|
}): Promise<TournamentState | null> => {
|
|
const { store, prisma, daemonTransport } = options;
|
|
const now = options.now ?? Date.now;
|
|
let processedState: TournamentState | null = null;
|
|
|
|
await store.withMutationLock(async () => {
|
|
const state = await store.getState();
|
|
if (!state || (!state.auto && !needsSettlement(state))) {
|
|
return;
|
|
}
|
|
const nextAt = new Date(state.nextAt).getTime();
|
|
if (state.auto && Number.isFinite(nextAt) && nextAt > now()) {
|
|
return;
|
|
}
|
|
|
|
if (needsSettlement(state)) {
|
|
processedState = (await settleTournamentOutcome({ store, daemonTransport, state })) ?? state;
|
|
return;
|
|
}
|
|
|
|
const worldState = await 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, prisma, state, String(baseSeed), daemonTransport);
|
|
}
|
|
processedState =
|
|
(await settleTournamentOutcome({
|
|
store,
|
|
daemonTransport,
|
|
state: nextState,
|
|
})) ?? nextState;
|
|
});
|
|
|
|
return processedState;
|
|
};
|
|
|
|
export const runTournamentWorker = async (): Promise<void> => {
|
|
const config = resolveGameApiConfigFromEnv();
|
|
const postgres = createGamePostgresConnector(resolvePostgresConfigFromEnv({ schema: config.profile }));
|
|
const redis = createRedisConnector(resolveRedisConfigFromEnv());
|
|
|
|
await postgres.connect();
|
|
await redis.connect();
|
|
|
|
const store = new TournamentStore(redis.client, buildTournamentKeys(config.profileName));
|
|
const daemonTransport = new DatabaseTurnDaemonTransport(postgres.prisma, config.daemonRequestTimeoutMs);
|
|
|
|
const handleExit = async () => {
|
|
await redis.disconnect();
|
|
await postgres.disconnect();
|
|
};
|
|
process.on('SIGINT', handleExit);
|
|
process.on('SIGTERM', handleExit);
|
|
|
|
while (true) {
|
|
const state = await store.getState();
|
|
if (!state || (!state.auto && !needsSettlement(state))) {
|
|
await sleepMs(config.tournamentPollMs);
|
|
continue;
|
|
}
|
|
|
|
const nextAt = new Date(state.nextAt).getTime();
|
|
const now = Date.now();
|
|
if (state.auto && Number.isFinite(nextAt) && nextAt > now) {
|
|
await sleepMs(Math.min(config.tournamentPollMs, nextAt - now));
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
await processTournamentTick({
|
|
store,
|
|
prisma: postgres.prisma,
|
|
daemonTransport,
|
|
});
|
|
} 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 currentState = (await store.getState()) ?? state;
|
|
const nextState: TournamentState = {
|
|
...currentState,
|
|
lastError: message,
|
|
lastErrorAt: now,
|
|
};
|
|
await store.setState(nextState);
|
|
}
|
|
|
|
await sleepMs(config.tournamentPollMs);
|
|
}
|
|
};
|