fix tournament lifecycle settlement
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user