feat: 토너먼트 상태 취소 기능 추가 및 관리자 메시지 수정

This commit is contained in:
2026-01-23 18:19:48 +00:00
parent fbf8cd876f
commit 144b8d5705
5 changed files with 725 additions and 30 deletions
@@ -3,6 +3,7 @@ import { z } from 'zod';
import { asRecord } from '@sammo-ts/common';
import type { TournamentType } from '@sammo-ts/logic';
import type { TournamentState } from '../../tournament/types.js';
import { TournamentStore } from '../../tournament/store.js';
import { buildTournamentKeys } from '../../tournament/keys.js';
@@ -15,6 +16,16 @@ const hasAdminRole = (roles: string[], profileName: string): boolean => {
return roles.some((role) => role === 'admin.tournament' || role === `admin.tournament:${profileName}`);
};
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 adminProcedure = authedProcedure.use(({ ctx, next }) => {
const roles = ctx.auth?.user.roles ?? [];
if (!hasAdminRole(roles, ctx.profile.name)) {
@@ -49,6 +60,14 @@ const zParticipant = z.object({
strength: z.number().int().min(0),
intel: z.number().int().min(0),
level: z.number().int().min(0),
groupId: z.number().int().optional(),
groupNo: z.number().int().optional(),
win: z.number().int().optional(),
draw: z.number().int().optional(),
lose: z.number().int().optional(),
gl: z.number().int().optional(),
seedRank: z.number().int().optional(),
finalRank: z.number().int().optional(),
});
const zMatch = z.object({
@@ -246,6 +265,12 @@ export const tournamentRouter = router({
throw new TRPCError({ code: 'NOT_FOUND', message: '장수 정보를 찾을 수 없습니다.' });
}
const nextMeta = { ...asRecord(general.meta), tnmt: 1 };
await ctx.db.general.update({
where: { id: general.id },
data: { meta: nextMeta },
});
const already = participants.find((entry) => entry.id === general.id);
if (already) {
return { ok: true, count: participants.length };
@@ -269,6 +294,68 @@ export const tournamentRouter = router({
await store.setParticipants(next);
return { ok: true, count: next.length };
}),
cancel: adminProcedure.mutation(async ({ ctx }) => {
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
const state = await store.getState();
if (!state) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Tournament state not found.' });
}
const [participants, bets] = await Promise.all([
store.getParticipants(),
store.getBettingEntries(),
]);
const worldState = await ctx.db.worldState.findFirst();
const config = asRecord(worldState?.config ?? {});
const constValues = asRecord(config.const ?? config);
const develCost = resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0);
const refundMap = new Map<number, number>();
for (const participant of participants) {
if (participant.id <= 0) {
continue;
}
if (participant.groupId !== undefined && participant.groupId >= 0 && participant.groupId < 8) {
refundMap.set(participant.id, (refundMap.get(participant.id) ?? 0) + develCost);
}
}
for (const bet of bets) {
refundMap.set(bet.generalId, (refundMap.get(bet.generalId) ?? 0) + bet.amount);
}
if (refundMap.size > 0) {
await ctx.turnDaemon.sendCommand({
type: 'tournamentRefund',
refunds: Array.from(refundMap.entries()).map(([generalId, amount]) => ({
generalId,
amount,
})),
reason: 'cancel',
});
}
await Promise.all([
store.setParticipants([]),
store.setMatches([]),
store.setBettingEntries([]),
]);
const nextState: TournamentState = {
...state,
stage: 0,
phase: 0,
auto: false,
winnerId: undefined,
bettingSettled: true,
rewardSettled: false,
bettingCloseAt: undefined,
participantsLockedAt: undefined,
nextAt: new Date().toISOString(),
};
await store.setState(nextState);
return { ok: true };
}),
placeBet: authedProcedure
.input(
z.object({
+8
View File
@@ -26,6 +26,14 @@ export interface TournamentParticipantEntry {
strength: number;
intel: number;
level: number;
groupId?: number;
groupNo?: number;
win?: number;
draw?: number;
lose?: number;
gl?: number;
seedRank?: number;
finalRank?: number;
}
export interface TournamentMatchEntry {
+583 -22
View File
@@ -1,4 +1,4 @@
import { createTournamentRng } from '@sammo-ts/common';
import { asRecord, createTournamentRng } from '@sammo-ts/common';
import { resolveTournamentBattle, TournamentType } from '@sammo-ts/logic';
import {
createGamePostgresConnector,
@@ -12,7 +12,7 @@ import { RedisTurnDaemonTransport } from '../daemon/redisTransport.js';
import { buildTurnDaemonStreamKeys } from '../daemon/streamKeys.js';
import { buildTournamentKeys } from './keys.js';
import { TournamentStore } from './store.js';
import type { TournamentBetEntry, TournamentMatchEntry, TournamentState } from './types.js';
import type { TournamentBetEntry, TournamentMatchEntry, TournamentParticipantEntry, TournamentState } from './types.js';
const sleepMs = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
@@ -54,6 +54,340 @@ const resolveStatValue = (type: TournamentType, entry: { leadership: number; str
}
};
const resolveGroupPair = (stage: number, phase: number): [number, number] | null => {
if (stage === 2) {
const pairMap: Array<[number, number]> = [
[0, 1], [2, 3], [4, 5], [6, 7],
[0, 2], [1, 3], [4, 6], [5, 7],
[0, 3], [1, 6], [2, 5], [4, 7],
[0, 4], [1, 5], [2, 6], [3, 7],
[0, 5], [1, 4], [2, 7], [3, 6],
[0, 6], [1, 7], [2, 4], [3, 5],
[0, 7], [1, 2], [3, 4], [5, 6],
];
const basePair = pairMap[phase % 28];
if (!basePair) {
return null;
}
return phase >= 28 ? [basePair[1], basePair[0]] : basePair;
}
if (stage === 4) {
const pairMap: Array<[number, number]> = [
[0, 1], [2, 3],
[0, 2], [1, 3],
[0, 3], [1, 2],
];
return pairMap[phase % 6] ?? null;
}
return null;
};
const assignGroupSlots = (
participants: Array<TournamentParticipantEntry & { groupId?: number; groupNo?: number }>,
groupCount: number,
groupSize: number,
groupStart: number
): TournamentParticipantEntry[] => {
const groupCounts = Array.from({ length: groupCount }, () => 0);
for (const entry of participants) {
if (entry.groupId !== undefined && entry.groupId >= groupStart && entry.groupId < groupStart + groupCount) {
const idx = entry.groupId - groupStart;
if (idx >= 0 && idx < groupCount) {
groupCounts[idx] += 1;
}
}
}
return participants.map((entry) => {
if (entry.groupId !== undefined && entry.groupNo !== undefined) {
return entry;
}
const minCount = Math.min(...groupCounts);
const groupIdx = groupCounts.findIndex((count) => count === minCount);
const groupNo = groupCounts[groupIdx] ?? 0;
groupCounts[groupIdx] = groupNo + 1;
return {
...entry,
groupId: groupStart + groupIdx,
groupNo: groupNo < groupSize ? groupNo : groupNo % groupSize,
win: 0,
draw: 0,
lose: 0,
gl: 0,
};
});
};
const selectWeighted = <T>(rng: ReturnType<typeof createTournamentRng>, pool: Array<{ item: T; weight: number }>): T => {
return rng.choiceUsingWeightPair(pool.map((entry) => [entry.item, entry.weight]));
};
const fillParticipants = async (options: {
prisma: ReturnType<typeof createGamePostgresConnector>['prisma'];
state: TournamentState;
baseSeed: string;
current: TournamentParticipantEntry[];
limit: number;
}): Promise<TournamentParticipantEntry[]> => {
const { prisma, state, baseSeed, limit } = options;
const takenIds = new Set(options.current.map((entry) => entry.id));
const result = [...options.current];
if (result.length >= limit) {
return result;
}
const applicants = await prisma.general.findMany({
where: {
meta: { path: ['tnmt'], equals: 1 },
},
select: {
id: true,
name: true,
leadership: true,
strength: true,
intel: true,
meta: true,
npcState: true,
},
});
const applicantPool = applicants
.filter((entry) => !takenIds.has(entry.id))
.map((entry) => {
const score = resolveStatValue(state.type, entry);
const meta = asRecord(entry.meta);
const level = typeof meta.explevel === 'number' ? meta.explevel : 0;
return {
item: {
id: entry.id,
name: entry.name,
leadership: entry.leadership,
strength: entry.strength,
intel: entry.intel,
level,
},
weight: Math.max(1, score ** 1.5),
};
});
const applicantRng = createTournamentRng(baseSeed, {
openYear: state.openYear,
openMonth: state.openMonth,
stage: 1,
phase: state.phase,
matchIndex: 0,
participantIndex: 0,
extraSeed: 'fill:applicants',
});
while (result.length < limit && applicantPool.length > 0) {
const picked = selectWeighted(applicantRng, applicantPool);
applicantPool.splice(applicantPool.findIndex((entry) => entry.item.id === picked.id), 1);
takenIds.add(picked.id);
result.push(picked);
}
if (result.length >= limit) {
return result;
}
const npcRows = await prisma.general.findMany({
where: {
npcState: { gte: 2 },
},
select: {
id: true,
name: true,
leadership: true,
strength: true,
intel: true,
meta: true,
npcState: true,
},
});
const npcPool = npcRows
.filter((entry) => !takenIds.has(entry.id))
.map((entry) => {
const score = resolveStatValue(state.type, entry);
const meta = asRecord(entry.meta);
const level = typeof meta.explevel === 'number' ? meta.explevel : 0;
return {
item: {
id: entry.id,
name: entry.name,
leadership: entry.leadership,
strength: entry.strength,
intel: entry.intel,
level,
},
weight: Math.max(1, score ** 1.5),
};
});
const npcRng = createTournamentRng(baseSeed, {
openYear: state.openYear,
openMonth: state.openMonth,
stage: 1,
phase: state.phase,
matchIndex: 0,
participantIndex: 1,
extraSeed: 'fill:npc',
});
while (result.length < limit && npcPool.length > 0) {
const picked = selectWeighted(npcRng, npcPool);
npcPool.splice(npcPool.findIndex((entry) => entry.item.id === picked.id), 1);
takenIds.add(picked.id);
result.push(picked);
}
let dummyId = -1;
while (result.length < limit) {
while (takenIds.has(dummyId)) {
dummyId -= 1;
}
takenIds.add(dummyId);
result.push({
id: dummyId,
name: '무명장수',
leadership: 10,
strength: 10,
intel: 10,
level: 10,
});
dummyId -= 1;
}
return result;
};
const applyGroupMatch = (
participants: TournamentParticipantEntry[],
attacker: TournamentParticipantEntry,
defender: TournamentParticipantEntry,
state: TournamentState,
baseSeed: string,
matchIndex: number
): TournamentParticipantEntry[] => {
const result = resolveTournamentBattle({
type: state.type,
battleType: 0,
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,
},
baseSeed,
});
const glDelta = Math.round((result.totalDamage.defender - result.totalDamage.attacker) / 50);
return participants.map((entry) => {
if (entry.id !== attacker.id && entry.id !== defender.id) {
return entry;
}
const next = {
...entry,
win: entry.win ?? 0,
draw: entry.draw ?? 0,
lose: entry.lose ?? 0,
gl: entry.gl ?? 0,
};
if (result.draw) {
next.draw += 1;
return next;
}
if (result.winnerId === entry.id) {
next.win += 1;
next.gl += glDelta;
return next;
}
next.lose += 1;
next.gl -= glDelta;
return next;
});
};
const sortByRanking = (entries: TournamentParticipantEntry[]): TournamentParticipantEntry[] => {
return [...entries].sort((lhs, rhs) => {
const lhsPoints = (lhs.win ?? 0) * 3 + (lhs.draw ?? 0);
const rhsPoints = (rhs.win ?? 0) * 3 + (rhs.draw ?? 0);
if (lhsPoints !== rhsPoints) {
return rhsPoints - lhsPoints;
}
const lhsGl = lhs.gl ?? 0;
const rhsGl = rhs.gl ?? 0;
if (lhsGl !== rhsGl) {
return rhsGl - lhsGl;
}
return lhs.id - rhs.id;
});
};
const buildFinal16MatchesFromGroups = (
state: TournamentState,
participants: TournamentParticipantEntry[]
): TournamentMatchEntry[] | null => {
const groupOrder = [10, 14, 11, 15, 12, 16, 13, 17, 14, 10, 15, 11, 16, 12, 17, 13];
const rankOrder = [1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2];
const selected: number[] = [];
for (let i = 0; i < groupOrder.length; i += 1) {
const groupId = groupOrder[i]!;
const rank = rankOrder[i]!;
const entry = participants.find((p) => p.groupId === groupId && p.finalRank === rank);
if (!entry) {
return null;
}
selected.push(entry.id);
}
return selected.reduce<TournamentMatchEntry[]>((acc, _id, idx) => {
if (idx % 2 !== 0) {
return acc;
}
const attackerId = selected[idx];
const defenderId = selected[idx + 1];
if (attackerId === undefined || defenderId === undefined) {
return acc;
}
acc.push({
id: acc.length + 1,
stage: 7,
roundIndex: acc.length,
attackerId,
defenderId,
});
return acc;
}, []);
};
const pickFinalists = (state: TournamentState, participants: Array<{ id: number; leadership: number; strength: number; intel: number }>): number[] =>
participants
.slice()
@@ -310,22 +644,114 @@ const buildTournamentRewardPayload = (
};
};
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 seedNpcBets = async (options: {
prisma: ReturnType<typeof createGamePostgresConnector>['prisma'];
store: TournamentStore;
state: TournamentState;
baseSeed: string;
}): Promise<void> => {
const { prisma, store, state, baseSeed } = options;
const existing = await store.getBettingEntries();
if (existing.length > 0) {
return;
}
const matches = await store.getMatches();
const candidateIds = Array.from(
new Set(
matches
.filter((match) => match.stage === 7)
.flatMap((match) => [match.attackerId, match.defenderId])
)
);
if (candidateIds.length === 0) {
return;
}
const worldState = await prisma.worldState.findFirst();
const config = asRecord(worldState?.config ?? {});
const constValues = asRecord(config.const ?? config);
const startYear = resolveNumber(constValues, ['startYear', 'startyear'], state.openYear);
const currentYear = worldState?.currentYear ?? state.openYear;
const betGold = Math.max(10, Math.floor((3 + currentYear - startYear) * 0.334) * 10);
const npcList = await prisma.general.findMany({
where: {
npcState: { gte: 2 },
gold: { gte: 500 + betGold },
},
select: { id: true, gold: true },
});
if (npcList.length === 0) {
return;
}
const rng = createTournamentRng(baseSeed, {
openYear: state.openYear,
openMonth: state.openMonth,
stage: 6,
phase: 0,
matchIndex: 0,
participantIndex: 0,
extraSeed: `OpenBettingTournament:${state.bettingId ?? 'none'}`,
});
const entries = [...existing];
for (const npc of npcList) {
const targetId = rng.choice(candidateIds);
entries.push({ generalId: npc.id, targetId, amount: betGold });
}
await prisma.$transaction(
npcList.map((npc) =>
prisma.general.update({
where: { id: npc.id },
data: { gold: npc.gold - betGold },
})
)
);
await store.setBettingEntries(entries);
};
const applyPreBattleStage = async (
store: TournamentStore,
prisma: ReturnType<typeof createGamePostgresConnector>['prisma'],
state: TournamentState,
baseSeed: string
): Promise<TournamentState> => {
const participants = await store.getParticipants();
if (state.stage === 1) {
let nextParticipants = participants;
if (participants.length < 64) {
const waitingState: TournamentState = {
...state,
nextAt: resolveNextAt(state),
};
await store.setState(waitingState);
return waitingState;
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,
@@ -338,13 +764,52 @@ const applyPreBattleStage = async (
}
if (state.stage === 2) {
const maxPhase = 27;
const nextPhase = Math.min(maxPhase, state.phase + 1);
const isComplete = nextPhase >= maxPhase;
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;
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;
}
updated = applyGroupMatch(updated, attacker, defender, state, baseSeed, groupId);
}
await store.setParticipants(updated);
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,
stage: isComplete ? 3 : state.stage,
phase: isComplete ? 0 : nextPhase,
phase: state.phase + 1,
nextAt: resolveNextAt(state),
};
await store.setState(nextState);
@@ -352,10 +817,66 @@ const applyPreBattleStage = async (
}
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,
stage: 4,
phase: 0,
phase: phase + 1,
nextAt: resolveNextAt(state),
};
await store.setState(nextState);
@@ -363,13 +884,48 @@ const applyPreBattleStage = async (
}
if (state.stage === 4) {
const pair = resolveGroupPair(4, state.phase);
if (!pair) {
throw new Error('본선 매치 구성을 찾을 수 없습니다.');
}
let updated = participants;
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;
}
updated = applyGroupMatch(updated, attacker, defender, state, baseSeed, groupId);
}
await store.setParticipants(updated);
const maxPhase = 5;
const nextPhase = Math.min(maxPhase, state.phase + 1);
const isComplete = nextPhase >= maxPhase;
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,
stage: isComplete ? 5 : state.stage,
phase: isComplete ? 0 : nextPhase,
phase: state.phase + 1,
nextAt: resolveNextAt(state),
};
await store.setState(nextState);
@@ -379,18 +935,23 @@ const applyPreBattleStage = async (
if (state.stage === 5) {
const matches = await store.getMatches();
if (matches.length === 0) {
const participantIds = pickFinalists(state, participants);
const initialMatches = buildInitialMatches(state, baseSeed, participantIds);
const fixedMatches = buildFinal16MatchesFromGroups(state, 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 });
return nextState;
}
@@ -463,7 +1024,7 @@ export const runTournamentWorker = async (): Promise<void> => {
if (isBattleStage(state.stage)) {
nextState = await applyBattle(store, state, String(baseSeed));
} else if (isPreBattleStage(state.stage)) {
nextState = await applyPreBattleStage(store, state, String(baseSeed));
nextState = await applyPreBattleStage(store, postgres.prisma, state, String(baseSeed));
}
if (nextState.stage === 0 && nextState.winnerId) {
+45 -2
View File
@@ -1,6 +1,6 @@
import { JosaUtil, asRecord } from '@sammo-ts/common';
import { createGamePostgresConnector } from '@sammo-ts/infra';
import { ActionLogger, LogFormat, type TournamentType } from '@sammo-ts/logic';
import { ActionLogger, LogFormat, type TournamentType, type TriggerValue } from '@sammo-ts/logic';
import type { TurnDaemonCommand, TurnDaemonCommandResult, TurnDaemonHooks } from '../lifecycle/types.js';
import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js';
@@ -24,6 +24,20 @@ const resolveTournamentLabel = (type: TournamentType): string => {
}
};
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];
@@ -124,7 +138,9 @@ export const createTournamentRewardFinalizer = async (options: {
}
}
const tournamentLabel = resolveTournamentLabel(command.tournamentType as TournamentType);
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;
@@ -137,9 +153,36 @@ export const createTournamentRewardFinalizer = async (options: {
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;
@@ -285,13 +285,9 @@ const progressSummary = computed(() => {
const adminMessage = ref<string | null>(null);
const adminStopTournament = async () => {
if (!snapshot.value?.state) {
adminMessage.value = '토너먼트 상태를 찾을 수 없습니다.';
return;
}
try {
await trpc.tournament.patchState.mutate({ auto: false });
adminMessage.value = '자동 진행이 중지되었습니다.';
await trpc.tournament.cancel.mutate();
adminMessage.value = '토너먼트가 취소되었습니다.';
await loadTournament();
} catch (err) {
adminMessage.value = resolveErrorMessage(err);