Files
core2026/app/game-engine/src/tournament/finalizer.ts
T
Hide_D 5040691d7c feat: implement input event system with durable command handling
- Introduced InputEvent model with status tracking (PENDING, PROCESSING, SUCCEEDED, FAILED) and unique request IDs.
- Added DatabaseTurnDaemonTransport for sending commands and handling idempotency.
- Implemented executeInputEvent function to manage input event lifecycle and error handling.
- Created DatabaseTurnDaemonCommandQueue for managing command processing and lease recovery.
- Enhanced turn daemon lifecycle to support atomic command execution and error recovery.
- Added tests for input event atomicity, command queuing, and error handling scenarios.
2026-07-25 05:36:34 +00:00

257 lines
9.8 KiB
TypeScript

import { JosaUtil, asRecord } from '@sammo-ts/common';
import { createGamePostgresConnector, type GamePrisma } from '@sammo-ts/infra';
import { ActionLogger, LogFormat, type TournamentType, type TriggerValue } from '@sammo-ts/logic';
import type { TurnDaemonCommand, TurnDaemonCommandResult } from '../lifecycle/types.js';
import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js';
export interface TournamentRewardFinalizer {
finalize(
command: Extract<TurnDaemonCommand, { type: 'tournamentReward' }>,
db?: GamePrisma.TransactionClient
): Promise<TurnDaemonCommandResult>;
close(): Promise<void>;
}
const resolveTournamentLabel = (type: TournamentType): string => {
switch (type) {
case 1:
return '통솔전';
case 2:
return '일기토';
case 3:
return '설전';
case 0:
default:
return '전력전';
}
};
const resolveTournamentRankPrefix = (type: TournamentType): string => {
switch (type) {
case 1:
return 'tl';
case 2:
return 'ts';
case 3:
return 'ti';
case 0:
default:
return 'tt';
}
};
const resolveNumber = (source: Record<string, unknown>, keys: string[], fallback: number): number => {
for (const key of keys) {
const value = source[key];
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
}
return fallback;
};
const pushLogs = (world: InMemoryTurnWorld, logs: ReturnType<ActionLogger['flush']>): void => {
if (logs.length === 0) {
return;
}
for (const entry of logs) {
world.pushLog(entry);
}
};
export const createTournamentRewardFinalizer = async (options: {
databaseUrl: string;
world: InMemoryTurnWorld;
}): Promise<TournamentRewardFinalizer> => {
const connector = createGamePostgresConnector({ url: options.databaseUrl });
await connector.connect();
const prisma = connector.prisma;
const finalize = async (
command: Extract<TurnDaemonCommand, { type: 'tournamentReward' }>,
commandDb?: GamePrisma.TransactionClient
): Promise<TurnDaemonCommandResult> => {
const { world } = options;
const db = commandDb ?? prisma;
const { winnerId, runnerUpId } = command;
const rewardMap = new Map<number, { gold: number; exp: number; label: string; inheritPoint: number }>();
const applyTier = (
ids: number[],
tier: { gold: number; exp: number; label: string; inheritPoint: number }
): void => {
for (const id of new Set(ids)) {
const current = rewardMap.get(id) ?? { gold: 0, exp: 0, label: tier.label, inheritPoint: 0 };
rewardMap.set(id, {
gold: current.gold + tier.gold,
exp: current.exp + tier.exp,
label: tier.label,
inheritPoint: tier.inheritPoint > 0 ? tier.inheritPoint : current.inheritPoint,
});
}
};
const constValues = asRecord(world.getScenarioConfig().const ?? {});
const develCost = resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0);
applyTier(command.top16, { gold: develCost, exp: 25, label: '16강 진출', inheritPoint: 10 });
applyTier(command.top8, { gold: develCost * 2, exp: 50, label: '8강 진출', inheritPoint: 0 });
applyTier(command.top4, { gold: develCost * 3, exp: 50, label: '4강 진출', inheritPoint: 10 });
applyTier([runnerUpId], { gold: develCost * 6, exp: 100, label: '준우승', inheritPoint: 50 });
applyTier([winnerId], { gold: develCost * 8, exp: 200, label: '우승', inheritPoint: 100 });
if (rewardMap.size === 0) {
return {
type: 'tournamentReward',
ok: false,
winnerId,
runnerUpId,
reason: '보상 대상이 없습니다.',
};
}
const nameMap = new Map<number, string>();
const generals = await db.general.findMany({
where: { id: { in: Array.from(rewardMap.keys()) } },
select: { id: true, userId: true, name: true },
});
const userMap = new Map<number, string>();
for (const general of generals) {
nameMap.set(general.id, general.name);
if (general.userId) {
userMap.set(general.id, general.userId);
}
}
const tournamentType = command.tournamentType as TournamentType;
const tournamentLabel = resolveTournamentLabel(tournamentType);
const rankPrefix = resolveTournamentRankPrefix(tournamentType);
const logs: ReturnType<ActionLogger['flush']> = [];
let rewarded = 0;
let missing = 0;
let totalGold = 0;
let totalExp = 0;
for (const [generalId, reward] of rewardMap) {
const general = world.getGeneralById(generalId);
if (!general) {
missing += 1;
continue;
}
const rankKey = `${rankPrefix}g`;
const currentRankValue = typeof general.meta[rankKey] === 'number' ? Number(general.meta[rankKey]) : 0;
let rankDelta = 0;
if (reward.label === '16강 진출') {
rankDelta = 1;
} else if (reward.label === '8강 진출') {
rankDelta = 1;
} else if (reward.label === '4강 진출') {
rankDelta = 2;
} else if (reward.label === '준우승') {
rankDelta = 2;
} else if (reward.label === '우승') {
rankDelta = 2;
}
const rankMetaNext: Record<string, TriggerValue> = {
[rankKey]: currentRankValue + rankDelta,
};
if (reward.label === '우승') {
const pointKey = `${rankPrefix}p`;
const currentPoint = typeof general.meta[pointKey] === 'number' ? Number(general.meta[pointKey]) : 0;
rankMetaNext[pointKey] = currentPoint + 1;
}
world.updateGeneral(generalId, {
gold: general.gold + reward.gold,
experience: general.experience + reward.exp,
meta: {
...general.meta,
...rankMetaNext,
},
});
totalGold += reward.gold;
totalExp += reward.exp;
rewarded += 1;
const rewardText = reward.gold.toLocaleString('ko-KR');
const logger = new ActionLogger({ generalId, nationId: general.nationId });
logger.pushGeneralActionLog(
`<C>${tournamentLabel}</> 대회의 ${reward.label}로 <C>${rewardText}</>의 <S>상금</>, 약간의 <S>명성</> 획득!`,
LogFormat.PLAIN
);
logs.push(...logger.flush());
}
const winnerName = nameMap.get(winnerId);
const runnerUpName = nameMap.get(runnerUpId);
const winnerReward = rewardMap.get(winnerId)?.gold ?? 0;
const runnerUpReward = rewardMap.get(runnerUpId)?.gold ?? 0;
if (winnerName) {
const winnerLogger = new ActionLogger({ generalId: winnerId });
winnerLogger.pushGeneralHistoryLog(`<C>${tournamentLabel}</> 대회에서 우승`);
logs.push(...winnerLogger.flush());
}
if (runnerUpName) {
const runnerLogger = new ActionLogger({ generalId: runnerUpId });
runnerLogger.pushGeneralHistoryLog(`<C>${tournamentLabel}</> 대회에서 준우승`);
logs.push(...runnerLogger.flush());
}
if (winnerName && runnerUpName) {
const globalLogger = new ActionLogger();
const josaWinner = JosaUtil.pick(winnerName, '이');
const josaRunner = JosaUtil.pick(runnerUpName, '이');
const winnerRewardText = winnerReward.toLocaleString('ko-KR');
const runnerRewardText = runnerUpReward.toLocaleString('ko-KR');
globalLogger.pushGlobalHistoryLog(
`<B><b>【대회】</b></><C>${tournamentLabel}</> 대회에서 <Y>${winnerName}</>${josaWinner} <C>우승</>, <Y>${runnerUpName}</>${josaRunner} <C>준우승</>을 차지하여 천하에 이름을 떨칩니다!`,
LogFormat.YEAR_MONTH
);
globalLogger.pushGlobalHistoryLog(
`<B><b>【대회】</b></><C>${tournamentLabel}</> 대회의 <S>우승자</>에게는 <C>${winnerRewardText}</>, <S>준우승자</>에겐 <C>${runnerRewardText}</>의 <S>상금</>과 약간의 <S>명성</>이 주어집니다!`,
LogFormat.YEAR_MONTH
);
logs.push(...globalLogger.flush());
}
pushLogs(world, logs);
const pointUpdates = Array.from(rewardMap.entries())
.filter(([, reward]) => reward.inheritPoint > 0)
.map(([generalId, reward]) => ({
generalId,
userId: userMap.get(generalId),
value: reward.inheritPoint,
}))
.filter((entry) => !!entry.userId);
for (const entry of pointUpdates) {
await db.inheritancePoint.upsert({
where: {
userId_key: { userId: entry.userId!, key: 'tournament' },
},
update: { value: { increment: entry.value } },
create: { userId: entry.userId!, key: 'tournament', value: entry.value },
});
}
return {
type: 'tournamentReward',
ok: true,
winnerId,
runnerUpId,
rewarded,
missing,
totalGold,
totalExp,
};
};
return {
finalize,
close: async () => {
await connector.disconnect();
},
};
};