fix tournament lifecycle settlement

This commit is contained in:
2026-07-26 05:09:31 +00:00
parent 8fac491c9d
commit b92774bc45
5 changed files with 683 additions and 61 deletions
+3
View File
@@ -30,6 +30,9 @@ export * from './auction/types.js';
export * from './auction/keys.js';
export * from './auction/scheduler.js';
export * from './auction/worker.js';
export * from './tournament/keys.js';
export * from './tournament/store.js';
export * from './tournament/types.js';
export * from './tournament/worker.js';
// Types for TRPC consumer
+98 -56
View File
@@ -1,15 +1,15 @@
import { createTournamentRng } from '@sammo-ts/common';
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 { RedisTurnDaemonTransport } from '../daemon/redisTransport.js';
import { buildTurnDaemonStreamKeys } from '../daemon/streamKeys.js';
import { DatabaseTurnDaemonTransport } from '../daemon/databaseTransport.js';
import type { TurnDaemonTransport } from '../daemon/transport.js';
import { buildTournamentKeys } from './keys.js';
import { TournamentStore } from './store.js';
@@ -462,13 +462,30 @@ export const settleTournamentOutcome = async (options: {
return null;
}
let settledState: TournamentState | null = null;
let settledState = state;
let changed = false;
if (!state.rewardSettled) {
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);
await daemonTransport.sendCommand({
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,
@@ -476,46 +493,100 @@ export const settleTournamentOutcome = async (options: {
top8: rewardPayload.top8,
top4: rewardPayload.top4,
});
requireSuccessfulResult(result, 'tournamentReward');
settledState = {
...(settledState ?? state),
...settledState,
rewardSettled: true,
};
changed = true;
await store.setState(settledState);
}
if (state.bettingId && !state.bettingSettled) {
if (settledState.bettingId && !settledState.bettingSettled) {
const bettingEntries = await store.getBettingEntries();
if (bettingEntries.length > 0) {
const payoutInfo = buildBettingPayouts(state.winnerId, bettingEntries);
const payoutInfo = buildBettingPayouts(settledState.winnerId!, bettingEntries);
if (payoutInfo.payouts.length > 0) {
if (payoutInfo.refundAll) {
await daemonTransport.sendCommand({
const result = await daemonTransport.requestCommand({
type: 'tournamentRefund',
bettingId: state.bettingId,
requestId: `tournament:${settledState.bettingId}:betting-refund`,
bettingId: settledState.bettingId,
refunds: payoutInfo.payouts,
reason: 'no_winner',
});
requireSuccessfulResult(result, 'tournamentRefund');
} else {
await daemonTransport.sendCommand({
const result = await daemonTransport.requestCommand({
type: 'tournamentBettingPayout',
bettingId: state.bettingId,
requestId: `tournament:${settledState.bettingId}:betting-payout`,
bettingId: settledState.bettingId,
payouts: payoutInfo.payouts,
reason: 'winner_payout',
});
requireSuccessfulResult(result, 'tournamentBettingPayout');
}
}
}
settledState = {
...(settledState ?? state),
...settledState,
bettingSettled: true,
};
}
if (settledState) {
changed = true;
await store.setState(settledState);
}
return 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> => {
@@ -527,10 +598,7 @@ export const runTournamentWorker = async (): Promise<void> => {
await redis.connect();
const store = new TournamentStore(redis.client, buildTournamentKeys(config.profileName));
const daemonTransport = new RedisTurnDaemonTransport(redis.client, {
keys: buildTurnDaemonStreamKeys(config.profileName),
requestTimeoutMs: config.daemonRequestTimeoutMs,
});
const daemonTransport = new DatabaseTurnDaemonTransport(postgres.prisma, config.daemonRequestTimeoutMs);
const handleExit = async () => {
await redis.disconnect();
@@ -541,49 +609,23 @@ export const runTournamentWorker = async (): Promise<void> => {
while (true) {
const state = await store.getState();
if (!state || !state.auto) {
if (!state || (!state.auto && !needsSettlement(state))) {
await sleepMs(config.tournamentPollMs);
continue;
}
const nextAt = new Date(state.nextAt).getTime();
const now = Date.now();
if (Number.isFinite(nextAt) && nextAt > now) {
if (state.auto && Number.isFinite(nextAt) && nextAt > now) {
await sleepMs(Math.min(config.tournamentPollMs, nextAt - now));
continue;
}
try {
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;
}
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,
});
await processTournamentTick({
store,
prisma: postgres.prisma,
daemonTransport,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
@@ -603,9 +645,9 @@ export const runTournamentWorker = async (): Promise<void> => {
},
},
});
const currentState = (await store.getState()) ?? state;
const nextState: TournamentState = {
...state,
auto: false,
...currentState,
lastError: message,
lastErrorAt: now,
};
+3 -2
View File
@@ -520,8 +520,9 @@ export const buildBettingPayouts = (
const winners = entries.filter((entry) => entry.targetId === winnerId);
const winnersTotal = winners.reduce((sum, entry) => sum + entry.amount, 0);
if (winnersTotal <= 0) {
const refunds = entries.map((entry) => ({ generalId: entry.generalId, amount: entry.amount }));
return { payouts: refunds, total, refundAll: true };
// Legacy Betting::_calcRewardExclusive() builds a refund candidate list
// but returns no rewards when nobody selected the winner.
return { payouts: [], total, refundAll: false };
}
const ratio = total / winnersTotal;
const payouts = winners.map((entry) => ({
+111 -3
View File
@@ -12,6 +12,7 @@ import type {
TournamentState,
} from '../src/tournament/types.js';
import { applyBattle, applyPreBattleStage, settleTournamentOutcome } from '../src/tournament/worker.js';
import { buildBettingPayouts } from '../src/tournament/workerHelpers.js';
import type { TurnDaemonTransport } from '../src/daemon/transport.js';
class MemoryRedis {
@@ -209,6 +210,15 @@ const runTournamentToCompletion = async (options: {
const delayTick = async (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0));
describe('tournament worker (in-memory)', () => {
it('당첨자가 없으면 레거시와 같이 베팅금을 지급하거나 환불하지 않는다', () => {
expect(
buildBettingPayouts(10, [
{ generalId: 1, targetId: 11, amount: 100 },
{ generalId: 2, targetId: 12, amount: 200 },
])
).toEqual({ payouts: [], total: 300, refundAll: false });
});
it('locks 64 applicants into eight groups of eight', async () => {
const redis = new MemoryRedis();
const store = new TournamentStore(redis, buildTournamentKeys('test-groups'));
@@ -284,11 +294,33 @@ describe('tournament worker (in-memory)', () => {
const sent: TurnDaemonCommand[] = [];
const transport: TurnDaemonTransport = {
sendCommand: async (command) => {
sendCommand: async () => 'unused',
requestCommand: async (command) => {
sent.push(command);
return 'ok';
if (command.type === 'tournamentReward') {
return {
type: 'tournamentReward',
ok: true,
winnerId: command.winnerId,
runnerUpId: command.runnerUpId,
rewarded: 2,
missing: 0,
totalGold: 100,
totalExp: 10,
};
}
if (command.type === 'tournamentBettingPayout') {
return {
type: 'tournamentBettingPayout',
ok: true,
bettingId: command.bettingId,
processed: command.payouts.length,
missing: 0,
totalPayout: command.payouts.reduce((sum, payout) => sum + payout.amount, 0),
};
}
return null;
},
requestCommand: async () => null,
requestStatus: async () => null,
};
@@ -302,6 +334,82 @@ describe('tournament worker (in-memory)', () => {
if (bettingCommand && bettingCommand.type === 'tournamentBettingPayout') {
expect(bettingCommand.payouts).toEqual([{ generalId: 1, amount: 300 }]);
}
expect(await store.getState()).toMatchObject({
rewardSettled: true,
bettingSettled: true,
});
});
it('정산 응답 실패 시 완료 표시를 남기지 않고 성공한 보상만 재시도에서 제외한다', async () => {
const redis = new MemoryRedis();
const store = new TournamentStore(redis, buildTournamentKeys('test-bet-retry'));
const state = createTournamentState({
stage: 0,
auto: false,
winnerId: 10,
bettingId: 124,
rewardSettled: false,
bettingSettled: false,
});
await store.setMatches([{ id: 1, stage: 10, roundIndex: 0, attackerId: 10, defenderId: 11, winnerId: 10 }]);
await store.setBettingEntries([{ generalId: 1, targetId: 10, amount: 100 }]);
await store.setState(state);
let payoutAttempts = 0;
const commands: TurnDaemonCommand[] = [];
const transport: TurnDaemonTransport = {
sendCommand: async () => 'unused',
requestCommand: async (command) => {
commands.push(command);
if (command.type === 'tournamentReward') {
return {
type: 'tournamentReward',
ok: true,
winnerId: command.winnerId,
runnerUpId: command.runnerUpId,
rewarded: 2,
missing: 0,
totalGold: 100,
totalExp: 10,
};
}
if (command.type === 'tournamentBettingPayout') {
payoutAttempts += 1;
if (payoutAttempts === 1) {
return {
type: 'tournamentBettingPayout',
ok: false,
bettingId: command.bettingId,
reason: '일시적 실패',
};
}
return {
type: 'tournamentBettingPayout',
ok: true,
bettingId: command.bettingId,
processed: 1,
missing: 0,
totalPayout: 100,
};
}
return null;
},
requestStatus: async () => null,
};
await expect(settleTournamentOutcome({ store, daemonTransport: transport, state })).rejects.toThrow(
'일시적 실패'
);
const afterFailure = await store.getState();
expect(afterFailure).toMatchObject({ rewardSettled: true, bettingSettled: false });
await settleTournamentOutcome({ store, daemonTransport: transport, state: afterFailure! });
expect(await store.getState()).toMatchObject({ rewardSettled: true, bettingSettled: true });
expect(commands.filter((command) => command.type === 'tournamentReward')).toHaveLength(1);
expect(commands.filter((command) => command.type === 'tournamentBettingPayout')).toHaveLength(2);
expect(
commands.filter((command) => command.type === 'tournamentBettingPayout').map((command) => command.requestId)
).toEqual(['tournament:124:betting-payout', 'tournament:124:betting-payout']);
});
it('자동 오픈 후 참가자 보충(NPC/더미 포함)하고 결승까지 진행된다', async () => {