Files
core2026/app/game-engine/src/tournament/finalizer.ts
T
Hide_D 18e0bed30a fix: Ref 유산 포인트 적립 조건을 전면 정합화한다
능동 행동 31개 호출 지점과 소유자·NPC·통일 경계를 고정하고 사용자 저장값을 함께 갱신한다.\n\n최대 내정·임관, 천통 기여, 토너먼트, 숙련·베팅·랭크 계산과 환생 정산을 공통 계산기로 통합한다.
2026-08-23 12:06:59 +00:00

294 lines
11 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>;
}
interface TournamentReward {
gold: number;
exp: number;
label: string;
inheritPoint: number;
}
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;
};
export const resolveTournamentDevelCost = (world: InMemoryTurnWorld): number => {
const constValues = asRecord(world.getScenarioConfig().const ?? {});
const configDevelCost = resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0);
// Ref reads the current game_env.develcost when setGift() runs. Core keeps
// that yearly value in world_state.meta; scenario const is only a fallback
// for older snapshots that predate the persisted field.
return resolveNumber(asRecord(world.getState().meta), ['develcost', 'develCost', 'develrate'], configDevelCost);
};
export const buildTournamentRewardMap = (
command: Pick<
Extract<TurnDaemonCommand, { type: 'tournamentReward' }>,
'top16' | 'top8' | 'top4' | 'winnerId' | 'runnerUpId'
>,
develCost: number
): Map<number, TournamentReward> => {
const rewardMap = new Map<number, TournamentReward>();
const applyTier = (ids: number[], tier: TournamentReward): 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,
});
}
};
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 });
// Ref's grp 50 final tier rewards both finalists before grp 60 adds
// the champion-only tier. The later champion tier replaces the label.
applyTier([command.runnerUpId, command.winnerId], {
gold: develCost * 6,
exp: 100,
label: '준우승',
inheritPoint: 50,
});
applyTier([command.winnerId], { gold: develCost * 8, exp: 200, label: '우승', inheritPoint: 100 });
return rewardMap;
};
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 develCost = resolveTournamentDevelCost(world);
const rewardMap = buildTournamentRewardMap(command, develCost);
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, npcState: true },
});
const userMap = new Map<number, string>();
for (const general of generals) {
nameMap.set(general.id, general.name);
if (general.userId && general.npcState < 2) {
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 },
});
const general = world.getGeneralById(entry.generalId);
if (general) {
world.updateGeneral(entry.generalId, {
inheritancePoints: {
...general.inheritancePoints,
tournament: Number(general.inheritancePoints?.tournament ?? 0) + entry.value,
},
});
}
}
return {
type: 'tournamentReward',
ok: true,
winnerId,
runnerUpId,
rewarded,
missing,
totalGold,
totalExp,
};
};
return {
finalize,
close: async () => {
await connector.disconnect();
},
};
};