Files
core2026/app/game-api/src/tournament/worker.ts
T

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);
}
};