diff --git a/app/game-api/src/router/tournament/index.ts b/app/game-api/src/router/tournament/index.ts index 54976a6..1268ba1 100644 --- a/app/game-api/src/router/tournament/index.ts +++ b/app/game-api/src/router/tournament/index.ts @@ -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, 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(); + 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({ diff --git a/app/game-api/src/tournament/types.ts b/app/game-api/src/tournament/types.ts index a071ad9..e05e478 100644 --- a/app/game-api/src/tournament/types.ts +++ b/app/game-api/src/tournament/types.ts @@ -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 { diff --git a/app/game-api/src/tournament/worker.ts b/app/game-api/src/tournament/worker.ts index 7226311..ed8b5c9 100644 --- a/app/game-api/src/tournament/worker.ts +++ b/app/game-api/src/tournament/worker.ts @@ -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 => 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, + 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 = (rng: ReturnType, pool: Array<{ item: T; weight: number }>): T => { + return rng.choiceUsingWeightPair(pool.map((entry) => [entry.item, entry.weight])); +}; + +const fillParticipants = async (options: { + prisma: ReturnType['prisma']; + state: TournamentState; + baseSeed: string; + current: TournamentParticipantEntry[]; + limit: number; +}): Promise => { + 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((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, 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['prisma']; + store: TournamentStore; + state: TournamentState; + baseSeed: string; +}): Promise => { + 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['prisma'], state: TournamentState, baseSeed: string ): Promise => { 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 => { 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) { diff --git a/app/game-engine/src/tournament/finalizer.ts b/app/game-engine/src/tournament/finalizer.ts index 7485358..e4b0060 100644 --- a/app/game-engine/src/tournament/finalizer.ts +++ b/app/game-engine/src/tournament/finalizer.ts @@ -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, 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 = []; 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 = { + [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; diff --git a/app/game-frontend/src/views/TournamentView.vue b/app/game-frontend/src/views/TournamentView.vue index 2edddb8..e685078 100644 --- a/app/game-frontend/src/views/TournamentView.vue +++ b/app/game-frontend/src/views/TournamentView.vue @@ -285,13 +285,9 @@ const progressSummary = computed(() => { const adminMessage = ref(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);