From eec2acc08d05b82f956ed24075b29d38c8087efa Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 04:29:45 +0000 Subject: [PATCH 1/2] feat: complete tournament API lifecycle --- app/game-api/src/router/tournament/index.ts | 585 +++++++++++------- app/game-api/src/tournament/store.ts | 50 +- app/game-api/src/tournament/worker.ts | 48 +- app/game-api/src/tournament/workerHelpers.ts | 2 +- app/game-api/test/tournamentRouter.test.ts | 336 ++++++++++ app/game-api/test/tournamentWorker.test.ts | 27 + app/game-engine/src/turn/commandRegistry.ts | 1 + .../src/turn/worldCommandHandler.ts | 9 +- .../test/tournamentCommands.test.ts | 139 +++++ app/gateway-api/src/adminRouter.ts | 114 ++-- .../src/lobby/profileStatusService.ts | 4 +- .../src/orchestrator/gatewayOrchestrator.ts | 66 +- .../test/orchestratorOperations.test.ts | 32 +- app/gateway-api/test/orchestratorPlan.test.ts | 11 + .../e2e/server-operations.spec.ts | 33 +- app/gateway-frontend/src/views/AdminView.vue | 117 ++-- .../src/views/ServerOperationsView.vue | 165 +++-- packages/common/src/turnDaemon/types.ts | 223 +++---- .../test/orchestrator.e2e.test.ts | 17 +- 19 files changed, 1436 insertions(+), 543 deletions(-) create mode 100644 app/game-api/test/tournamentRouter.test.ts create mode 100644 app/game-engine/test/tournamentCommands.test.ts diff --git a/app/game-api/src/router/tournament/index.ts b/app/game-api/src/router/tournament/index.ts index 9c34702..650b026 100644 --- a/app/game-api/src/router/tournament/index.ts +++ b/app/game-api/src/router/tournament/index.ts @@ -7,7 +7,8 @@ import type { TournamentState } from '../../tournament/types.js'; import { TournamentStore } from '../../tournament/store.js'; import { buildTournamentKeys } from '../../tournament/keys.js'; -import { authedProcedure, procedure, router } from '../../trpc.js'; +import { authedProcedure, router } from '../../trpc.js'; +import { getMyGeneral } from '../shared/general.js'; const hasAdminRole = (roles: string[], profileName: string): boolean => { if (roles.includes('superuser') || roles.includes('admin') || roles.includes('admin.superuser')) { @@ -110,13 +111,24 @@ const zSeedParticipants = z.object({ includeNpc: z.boolean().optional(), }); +const tournamentRankTypes = ['tt', 'tl', 'ts', 'ti'] as const; + +const tournamentRankInfo = { + tt: { title: '전 력 전', statLabel: '종합' }, + tl: { title: '통 솔 전', statLabel: '통솔' }, + ts: { title: '일 기 토', statLabel: '무력' }, + ti: { title: '설 전', statLabel: '지력' }, +} as const; + export const tournamentRouter = router({ - getState: procedure.query(async ({ ctx }) => { + getState: authedProcedure.query(async ({ ctx }) => { + await getMyGeneral(ctx); const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name)); return store.getState(); }), getAdminStatus: adminProcedure.query(async () => ({ ok: true })), - getSnapshot: procedure.query(async ({ ctx }) => { + getSnapshot: authedProcedure.query(async ({ ctx }) => { + await getMyGeneral(ctx); const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name)); const [state, participants, matches, bets] = await Promise.all([ store.getState(), @@ -124,66 +136,156 @@ export const tournamentRouter = router({ store.getMatches(), store.getBettingEntries(), ]); - return { state, participants, matches, bets }; + return { state, participants, matches, betCount: bets.length }; + }), + getRankings: authedProcedure.query(async ({ ctx }) => { + await getMyGeneral(ctx); + const rankTypeNames = tournamentRankTypes.flatMap((prefix) => [ + `${prefix}p`, + `${prefix}g`, + `${prefix}w`, + `${prefix}d`, + `${prefix}l`, + ]); + const candidateRows = await Promise.all( + tournamentRankTypes.map((prefix) => + ctx.db.rankData.findMany({ + where: { type: `${prefix}g` }, + orderBy: { value: 'desc' }, + take: 40, + select: { generalId: true }, + }) + ) + ); + const candidateIdsByPrefix = new Map( + tournamentRankTypes.map((prefix, index) => [ + prefix, + new Set((candidateRows[index] ?? []).map((row) => row.generalId)), + ]) + ); + const candidateIds = new Set(candidateRows.flatMap((rows) => rows.map((row) => row.generalId))); + const scoreRows = await ctx.db.rankData.findMany({ + where: { generalId: { in: [...candidateIds] }, type: { in: rankTypeNames } }, + select: { generalId: true, type: true, value: true }, + }); + const rankMap = new Map>(); + for (const row of scoreRows) { + const ranks = rankMap.get(row.generalId) ?? {}; + ranks[row.type] = row.value; + rankMap.set(row.generalId, ranks); + } + const generals = await ctx.db.general.findMany({ + where: { id: { in: [...rankMap.keys()] } }, + select: { id: true, name: true, npcState: true, leadership: true, strength: true, intel: true }, + }); + + return tournamentRankTypes.map((prefix) => { + const entries = generals + .filter((general) => candidateIdsByPrefix.get(prefix)?.has(general.id)) + .map((general) => { + const ranks = rankMap.get(general.id) ?? {}; + const win = ranks[`${prefix}w`] ?? 0; + const draw = ranks[`${prefix}d`] ?? 0; + const lose = ranks[`${prefix}l`] ?? 0; + const score = ranks[`${prefix}g`] ?? 0; + const stat = + prefix === 'tt' + ? general.leadership + general.strength + general.intel + : prefix === 'tl' + ? general.leadership + : prefix === 'ts' + ? general.strength + : general.intel; + return { + generalId: general.id, + name: general.name, + npcState: general.npcState, + stat, + games: win + draw + lose, + win, + draw, + lose, + score, + prizes: ranks[`${prefix}p`] ?? 0, + }; + }) + .sort( + (lhs, rhs) => + rhs.score - lhs.score || + rhs.games - lhs.games || + rhs.win - lhs.win || + rhs.draw - lhs.draw || + lhs.lose - rhs.lose + ) + .slice(0, 30) + .map((entry, index) => ({ rank: index + 1, ...entry })); + return { prefix, ...tournamentRankInfo[prefix], entries }; + }); }), setState: adminProcedure.input(zTournamentState).mutation(async ({ ctx, input }) => { const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name)); - await store.setState({ - ...input, - type: input.type as TournamentType, + return store.withMutationLock(async () => { + await store.setState({ + ...input, + type: input.type as TournamentType, + }); + return { ok: true }; }); - return { ok: true }; }), patchState: adminProcedure.input(zTournamentState.partial()).mutation(async ({ ctx, input }) => { const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name)); - const current = await store.getState(); - if (!current) { - throw new TRPCError({ code: 'NOT_FOUND', message: 'Tournament state not found.' }); - } - const next = { - ...current, - ...input, - type: (input.type !== undefined ? input.type : current.type) as TournamentType, - }; - await store.setState(next); - return { ok: true }; + return store.withMutationLock(async () => { + const current = await store.getState(); + if (!current) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Tournament state not found.' }); + } + const next = { + ...current, + ...input, + type: (input.type !== undefined ? input.type : current.type) as TournamentType, + }; + await store.setState(next); + return { ok: true }; + }); }), setParticipants: adminProcedure.input(z.array(zParticipant)).mutation(async ({ ctx, input }) => { const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name)); - await store.setParticipants(input); - return { ok: true, count: input.length }; + return store.withMutationLock(async () => { + await store.setParticipants(input); + return { ok: true, count: input.length }; + }); }), setMatches: adminProcedure.input(z.array(zMatch)).mutation(async ({ ctx, input }) => { const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name)); - await store.setMatches(input); - return { ok: true, count: input.length }; + return store.withMutationLock(async () => { + await store.setMatches(input); + return { ok: true, count: input.length }; + }); }), setBettingEntries: adminProcedure.input(z.array(zBetEntry)).mutation(async ({ ctx, input }) => { const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name)); - await store.setBettingEntries(input); - return { ok: true, count: input.length }; + return store.withMutationLock(async () => { + await store.setBettingEntries(input); + return { ok: true, count: input.length }; + }); }), seedParticipants: adminProcedure.input(zSeedParticipants).mutation(async ({ ctx, input }) => { const limit = input.limit ?? 64; const includeNpc = input.includeNpc ?? true; const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name)); - const generals = input.generalIds && input.generalIds.length > 0 - ? await ctx.db.general.findMany({ - where: { id: { in: input.generalIds } }, - select: { id: true, name: true, leadership: true, strength: true, intel: true, meta: true }, - }) - : await ctx.db.general.findMany({ - where: includeNpc ? {} : { npcState: 0 }, - orderBy: [ - { leadership: 'desc' }, - { strength: 'desc' }, - { intel: 'desc' }, - { id: 'asc' }, - ], - take: limit, - select: { id: true, name: true, leadership: true, strength: true, intel: true, meta: true }, - }); + const generals = + input.generalIds && input.generalIds.length > 0 + ? await ctx.db.general.findMany({ + where: { id: { in: input.generalIds } }, + select: { id: true, name: true, leadership: true, strength: true, intel: true, meta: true }, + }) + : await ctx.db.general.findMany({ + where: includeNpc ? {} : { npcState: 0 }, + orderBy: [{ leadership: 'desc' }, { strength: 'desc' }, { intel: 'desc' }, { id: 'asc' }], + take: limit, + select: { id: true, name: true, leadership: true, strength: true, intel: true, meta: true }, + }); const participants = generals.map((general) => { const meta = asRecord(general.meta); @@ -198,10 +300,13 @@ export const tournamentRouter = router({ }; }); - await store.setParticipants(participants); - return { ok: true, count: participants.length }; + return store.withMutationLock(async () => { + await store.setParticipants(participants); + return { ok: true, count: participants.length }; + }); }), getBettingSummary: authedProcedure.query(async ({ ctx }) => { + const general = await getMyGeneral(ctx); const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name)); const [state, entries, matches] = await Promise.all([ store.getState(), @@ -225,18 +330,13 @@ export const tournamentRouter = router({ const myTotals: Record = {}; let totalAmount = 0; let myAmount = 0; - const userId = ctx.auth?.user.id; - const general = userId - ? await ctx.db.general.findFirst({ where: { userId }, select: { id: true } }) - : null; - for (const entry of entries) { if (!candidateIds.has(entry.targetId)) { continue; } totals[entry.targetId] = (totals[entry.targetId] ?? 0) + entry.amount; totalAmount += entry.amount; - if (general && entry.generalId === general.id) { + if (entry.generalId === general.id) { myTotals[entry.targetId] = (myTotals[entry.targetId] ?? 0) + entry.amount; myAmount += entry.amount; } @@ -245,199 +345,254 @@ export const tournamentRouter = router({ return { state, totals, myTotals, totalAmount, myAmount }; }), join: authedProcedure.mutation(async ({ ctx }) => { - const userId = ctx.auth?.user.id; - if (!userId) { - throw new TRPCError({ code: 'UNAUTHORIZED' }); - } + const general = await getMyGeneral(ctx); const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name)); - const state = await store.getState(); - if (!state || state.stage !== 1 || state.participantsLockedAt) { - throw new TRPCError({ code: 'BAD_REQUEST', message: '참가 신청 기간이 아닙니다.' }); - } + return store.withMutationLock(async () => { + const state = await store.getState(); + if (!state || state.stage !== 1 || state.participantsLockedAt) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '참가 신청 기간이 아닙니다.' }); + } - const participants = await store.getParticipants(); + const [participants, worldState] = await Promise.all([ + store.getParticipants(), + ctx.db.worldState.findFirst(), + ]); + if (participants.some((entry) => entry.id === general.id)) { + return { ok: true, count: participants.length }; + } + if (participants.length >= 64) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '참가 인원이 가득 찼습니다.' }); + } - const general = await ctx.db.general.findFirst({ - where: { userId }, - select: { id: true, name: true, leadership: true, strength: true, intel: true, meta: true }, + const config = asRecord(worldState?.config ?? {}); + const constValues = asRecord(config.const ?? config); + const develCost = resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0); + const feeResult = await ctx.turnDaemon.requestCommand({ + type: 'adjustGeneralResources', + reason: 'tournamentJoin', + adjustments: [{ generalId: general.id, goldDelta: -develCost, minGoldAfter: 0 }], + }); + if (!feeResult || feeResult.type !== 'adjustGeneralResources') { + throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' }); + } + if (!feeResult.ok || feeResult.processed !== 1) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: feeResult.ok ? '금이 부족합니다.' : feeResult.reason, + }); + } + + const settingResult = await ctx.turnDaemon.requestCommand({ + type: 'setMySetting', + generalId: general.id, + settings: { tnmt: 1 }, + }); + if (!settingResult || settingResult.type !== 'setMySetting' || !settingResult.ok) { + await ctx.turnDaemon.requestCommand({ + type: 'adjustGeneralResources', + reason: 'tournamentJoinRollback', + adjustments: [{ generalId: general.id, goldDelta: develCost }], + }); + throw new TRPCError({ + code: 'BAD_REQUEST', + message: + settingResult && settingResult.type === 'setMySetting' + ? (settingResult.reason ?? '요청에 실패했습니다.') + : 'Unexpected response', + }); + } + + const meta = asRecord(general.meta); + const level = typeof meta.explevel === 'number' ? meta.explevel : 0; + const next = participants.concat({ + id: general.id, + name: general.name, + leadership: general.leadership, + strength: general.strength, + intel: general.intel, + level, + }); + + try { + await store.setParticipants(next); + } catch (error) { + await Promise.all([ + ctx.turnDaemon.requestCommand({ + type: 'adjustGeneralResources', + reason: 'tournamentJoinRollback', + adjustments: [{ generalId: general.id, goldDelta: develCost }], + }), + ctx.turnDaemon.requestCommand({ + type: 'setMySetting', + generalId: general.id, + settings: { tnmt: 0 }, + }), + ]); + throw error; + } + return { ok: true, count: next.length }; }); - if (!general) { - throw new TRPCError({ code: 'NOT_FOUND', message: '장수 정보를 찾을 수 없습니다.' }); - } - - const result = await ctx.turnDaemon.requestCommand({ - type: 'setMySetting', - generalId: general.id, - settings: { tnmt: 1 }, - }); - if (!result || result.type !== 'setMySetting') { - throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' }); - } - if (!result.ok) { - throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason ?? '요청에 실패했습니다.' }); - } - - const already = participants.find((entry) => entry.id === general.id); - if (already) { - return { ok: true, count: participants.length }; - } - - if (participants.length >= 64) { - throw new TRPCError({ code: 'BAD_REQUEST', message: '참가 인원이 가득 찼습니다.' }); - } - - const meta = asRecord(general.meta); - const level = typeof meta.explevel === 'number' ? meta.explevel : 0; - const next = participants.concat({ - id: general.id, - name: general.name, - leadership: general.leadership, - strength: general.strength, - intel: general.intel, - level, - }); - - 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; + return store.withMutationLock(async () => { + const state = await store.getState(); + if (!state) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Tournament state not found.' }); } - if (participant.groupId !== undefined && participant.groupId >= 0 && participant.groupId < 8) { - refundMap.set(participant.id, (refundMap.get(participant.id) ?? 0) + develCost); + + 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); } - } - 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', - }); - } + 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([]), - ]); + 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 }; + 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({ targetId: z.number().int().positive(), - amount: z.number().int().positive(), + amount: z.number().int().min(10), }) ) .mutation(async ({ ctx, input }) => { - const userId = ctx.auth?.user.id; - if (!userId) { - throw new TRPCError({ code: 'UNAUTHORIZED' }); - } + const general = await getMyGeneral(ctx); const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name)); - const state = await store.getState(); - if (!state || state.stage !== 6) { - throw new TRPCError({ code: 'BAD_REQUEST', message: '베팅 기간이 아닙니다.' }); - } - const closeAt = state.bettingCloseAt ? new Date(state.bettingCloseAt).getTime() : 0; - if (closeAt && closeAt <= Date.now()) { - throw new TRPCError({ code: 'BAD_REQUEST', message: '베팅이 마감되었습니다.' }); - } - - const matches = await store.getMatches(); - const candidateIds = new Set(); - for (const match of matches) { - if (match.stage === 7) { - candidateIds.add(match.attackerId); - candidateIds.add(match.defenderId); + return store.withMutationLock(async () => { + const state = await store.getState(); + if (!state || state.stage !== 6) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '베팅 기간이 아닙니다.' }); + } + const closeAt = state.bettingCloseAt ? new Date(state.bettingCloseAt).getTime() : 0; + if (closeAt && closeAt <= Date.now()) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '베팅이 마감되었습니다.' }); } - } - if (!candidateIds.has(input.targetId)) { - throw new TRPCError({ code: 'BAD_REQUEST', message: '올바르지 않은 베팅 대상입니다.' }); - } - const general = await ctx.db.general.findFirst({ - where: { userId }, - select: { id: true, gold: true }, - }); - if (!general) { - throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '장수가 존재하지 않습니다.' }); - } + const [matches, entries] = await Promise.all([store.getMatches(), store.getBettingEntries()]); + const candidateIds = new Set(); + for (const match of matches) { + if (match.stage === 7) { + candidateIds.add(match.attackerId); + candidateIds.add(match.defenderId); + } + } + if (!candidateIds.has(input.targetId)) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '올바르지 않은 베팅 대상입니다.' }); + } - const minRemainGold = 500; - if (general.gold - input.amount < minRemainGold) { - throw new TRPCError({ code: 'BAD_REQUEST', message: '소지금이 부족합니다.' }); - } + const previousBetAmount = entries + .filter((entry) => entry.generalId === general.id) + .reduce((sum, entry) => sum + entry.amount, 0); + if (previousBetAmount + input.amount > 1_000) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `${1_000 - previousBetAmount}금까지만 베팅 가능합니다.`, + }); + } - const adjustResult = await ctx.turnDaemon.requestCommand({ - type: 'adjustGeneralResources', - reason: 'tournamentBet', - adjustments: [{ generalId: general.id, goldDelta: -input.amount }], - }); - if (!adjustResult || adjustResult.type !== 'adjustGeneralResources') { - throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' }); - } - if (!adjustResult.ok) { - throw new TRPCError({ code: 'BAD_REQUEST', message: adjustResult.reason }); - } + const adjustResult = await ctx.turnDaemon.requestCommand({ + type: 'adjustGeneralResources', + reason: 'tournamentBet', + adjustments: [{ generalId: general.id, goldDelta: -input.amount, minGoldAfter: 500 }], + }); + if (!adjustResult || adjustResult.type !== 'adjustGeneralResources') { + throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' }); + } + if (!adjustResult.ok || adjustResult.processed !== 1) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: adjustResult.ok ? '금이 부족합니다.' : adjustResult.reason, + }); + } - await ctx.turnDaemon.sendCommand({ - type: 'adjustGeneralMeta', - reason: 'tournamentBet', - adjustments: [ - { + const rankResult = await ctx.turnDaemon.requestCommand({ + type: 'adjustGeneralMeta', + reason: 'tournamentBet', + adjustments: [ + { + generalId: general.id, + metaDelta: { betgold: input.amount }, + }, + ], + }); + if (!rankResult || rankResult.type !== 'adjustGeneralMeta' || !rankResult.ok) { + await ctx.turnDaemon.requestCommand({ + type: 'adjustGeneralResources', + reason: 'tournamentBetRollback', + adjustments: [{ generalId: general.id, goldDelta: input.amount }], + }); + throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: '베팅 기록을 저장하지 못했습니다.' }); + } + + try { + await store.appendBettingEntry({ generalId: general.id, - metaDelta: { rank_betgold: input.amount }, - }, - ], + targetId: input.targetId, + amount: input.amount, + }); + } catch (error) { + await Promise.all([ + ctx.turnDaemon.requestCommand({ + type: 'adjustGeneralResources', + reason: 'tournamentBetRollback', + adjustments: [{ generalId: general.id, goldDelta: input.amount }], + }), + ctx.turnDaemon.requestCommand({ + type: 'adjustGeneralMeta', + reason: 'tournamentBetRollback', + adjustments: [ + { + generalId: general.id, + metaDelta: { betgold: -input.amount }, + }, + ], + }), + ]); + throw error; + } + return { ok: true }; }); - - await store.appendBettingEntry({ - generalId: general.id, - targetId: input.targetId, - amount: input.amount, - }); - - return { ok: true }; }), }); diff --git a/app/game-api/src/tournament/store.ts b/app/game-api/src/tournament/store.ts index d8a357d..2da037c 100644 --- a/app/game-api/src/tournament/store.ts +++ b/app/game-api/src/tournament/store.ts @@ -1,9 +1,19 @@ +import { randomUUID } from 'node:crypto'; + import type { TournamentKeys } from './keys.js'; import type { TournamentBetEntry, TournamentMatchEntry, TournamentParticipantEntry, TournamentState } from './types.js'; interface RedisClientLike { get(key: string): Promise; - set(key: string, value: string): Promise; + set( + key: string, + value: string, + options?: { + NX?: boolean; + PX?: number; + } + ): Promise; + del?(key: string): Promise; } const safeJsonParse = (raw: string | null): T | null => { @@ -18,7 +28,34 @@ const safeJsonParse = (raw: string | null): T | null => { }; export class TournamentStore { - constructor(private readonly redis: RedisClientLike, private readonly keys: TournamentKeys) {} + constructor( + private readonly redis: RedisClientLike, + private readonly keys: TournamentKeys + ) {} + + async withMutationLock(operation: () => Promise, timeoutMs = 2_000): Promise { + if (!this.redis.del) { + return operation(); + } + + const lockKey = `${this.keys.stateKey}:mutation-lock`; + const token = randomUUID(); + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const acquired = await this.redis.set(lockKey, token, { NX: true, PX: 30_000 }); + if (acquired) { + try { + return await operation(); + } finally { + if ((await this.redis.get(lockKey)) === token) { + await this.redis.del(lockKey); + } + } + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error('토너먼트 요청이 처리 중입니다. 잠시 후 다시 시도해주세요.'); + } async getState(): Promise { return safeJsonParse(await this.redis.get(this.keys.stateKey)); @@ -54,7 +91,14 @@ export class TournamentStore { async appendBettingEntry(entry: TournamentBetEntry): Promise { const entries = await this.getBettingEntries(); - entries.push(entry); + const existing = entries.find( + (candidate) => candidate.generalId === entry.generalId && candidate.targetId === entry.targetId + ); + if (existing) { + existing.amount += entry.amount; + } else { + entries.push(entry); + } await this.setBettingEntries(entries); return entries; } diff --git a/app/game-api/src/tournament/worker.ts b/app/game-api/src/tournament/worker.ts index 82646e6..af79bfb 100644 --- a/app/game-api/src/tournament/worker.ts +++ b/app/game-api/src/tournament/worker.ts @@ -518,7 +518,6 @@ export const settleTournamentOutcome = async (options: { return settledState; }; - export const runTournamentWorker = async (): Promise => { const config = resolveGameApiConfigFromEnv(); const postgres = createGamePostgresConnector(resolvePostgresConfigFromEnv({ schema: config.profile })); @@ -555,25 +554,36 @@ export const runTournamentWorker = async (): Promise => { } try { - const worldState = await postgres.prisma.worldState.findFirst(); - const baseSeed = (worldState?.meta as Record | 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, - postgres.prisma, - state, - String(baseSeed), - daemonTransport - ); - } + 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; + } - await settleTournamentOutcome({ - store, - daemonTransport, - state: nextState, + const worldState = await postgres.prisma.worldState.findFirst(); + const baseSeed = (worldState?.meta as Record | 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, + }); }); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; diff --git a/app/game-api/src/tournament/workerHelpers.ts b/app/game-api/src/tournament/workerHelpers.ts index 4bfe3cf..2519d24 100644 --- a/app/game-api/src/tournament/workerHelpers.ts +++ b/app/game-api/src/tournament/workerHelpers.ts @@ -652,7 +652,7 @@ export const seedNpcBets = async (options: { reason: 'tournamentNpcBet', adjustments: npcBetList.map((npc) => ({ generalId: npc.id as number, - metaDelta: { rank_betgold: betGold }, + metaDelta: { betgold: betGold }, })), }); await store.setBettingEntries(entries); diff --git a/app/game-api/test/tournamentRouter.test.ts b/app/game-api/test/tournamentRouter.test.ts new file mode 100644 index 0000000..d2f59ac --- /dev/null +++ b/app/game-api/test/tournamentRouter.test.ts @@ -0,0 +1,336 @@ +import { describe, expect, it } from 'vitest'; + +import type { TurnDaemonCommand, TurnDaemonCommandResult } from '@sammo-ts/common'; +import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; +import type { RedisConnector } from '@sammo-ts/infra'; + +import { InMemoryFlushStore } from '../src/auth/flushStore.js'; +import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js'; +import type { DatabaseClient, GameApiContext, GeneralRow } from '../src/context.js'; +import type { TurnDaemonTransport } from '../src/daemon/transport.js'; +import { appRouter } from '../src/router.js'; + +class MemoryRedis { + private readonly values = new Map(); + + async get(key: string): Promise { + return this.values.get(key) ?? null; + } + + async set(key: string, value: string, options?: { NX?: boolean }): Promise { + if (options?.NX && this.values.has(key)) { + return null; + } + this.values.set(key, value); + return 'OK'; + } + + async del(key: string): Promise { + return this.values.delete(key) ? 1 : 0; + } +} + +class TournamentTransport implements TurnDaemonTransport { + readonly commands: TurnDaemonCommand[] = []; + readonly gold = new Map(); + failNextRankUpdate = false; + + async sendCommand(command: TurnDaemonCommand): Promise { + this.commands.push(command); + return `command-${this.commands.length}`; + } + + async requestCommand(command: TurnDaemonCommand): Promise { + this.commands.push(command); + if (command.type === 'adjustGeneralResources') { + const adjustment = command.adjustments[0]; + if (!adjustment) { + return { type: 'adjustGeneralResources', ok: false, reason: '조정 대상이 없습니다.' }; + } + const nextGold = (this.gold.get(adjustment.generalId) ?? 0) + (adjustment.goldDelta ?? 0); + if (nextGold < (adjustment.minGoldAfter ?? 0)) { + return { type: 'adjustGeneralResources', ok: false, reason: '자원이 부족합니다.' }; + } + this.gold.set(adjustment.generalId, nextGold); + return { + type: 'adjustGeneralResources', + ok: true, + processed: 1, + missing: 0, + totalGoldDelta: adjustment.goldDelta ?? 0, + totalRiceDelta: 0, + }; + } + if (command.type === 'setMySetting') { + return { type: 'setMySetting', ok: true, generalId: command.generalId }; + } + if (command.type === 'adjustGeneralMeta') { + if (this.failNextRankUpdate) { + this.failNextRankUpdate = false; + return { type: 'adjustGeneralMeta', ok: false, reason: 'rank update failed' }; + } + return { type: 'adjustGeneralMeta', ok: true, processed: 1, missing: 0 }; + } + return null; + } + + async requestStatus() { + return null; + } +} + +const buildGeneral = (id: number, userId: string, gold = 2_000): GeneralRow => + ({ + id, + userId, + name: `장수${id}`, + leadership: 70 + id, + strength: 60 + id, + intel: 50 + id, + gold, + meta: { explevel: 5 }, + }) as unknown as GeneralRow; + +const buildAuth = (userId: string, roles: string[] = []): GameSessionTokenPayload => ({ + version: 1, + profile: 'che:default', + issuedAt: '2026-07-26T00:00:00.000Z', + expiresAt: '2026-07-27T00:00:00.000Z', + sessionId: `session-${userId}`, + user: { + id: userId, + username: userId, + displayName: userId, + roles, + }, + sanctions: {}, +}); + +const buildContext = (options: { + redis: MemoryRedis; + transport: TournamentTransport; + generals: GeneralRow[]; + userId: string; + roles?: string[]; + develCost?: number; + rankRows?: Array<{ generalId: number; type: string; value: number }>; +}): GameApiContext => { + const db = { + general: { + findFirst: async ({ where }: { where: { userId: string } }) => + options.generals.find((general) => general.userId === where.userId) ?? null, + findMany: async ({ where }: { where: { id: { in: number[] } } }) => + options.generals.filter((general) => where.id.in.includes(general.id)), + }, + rankData: { + findMany: async () => options.rankRows ?? [], + }, + worldState: { + findFirst: async () => ({ config: { const: { develCost: options.develCost ?? 200 } } }), + }, + } as unknown as DatabaseClient; + return { + db, + redis: options.redis as unknown as RedisConnector['client'], + turnDaemon: options.transport, + battleSim: {} as GameApiContext['battleSim'], + profile: { id: 'che', scenario: 'default', name: 'che:default' }, + uploadDir: 'uploads', + uploadPath: '/uploads', + uploadPublicUrl: null, + auth: buildAuth(options.userId, options.roles), + accessTokenStore: new RedisAccessTokenStore(options.redis, 'che:default'), + flushStore: new InMemoryFlushStore(), + gameTokenSecret: 'test-secret', + }; +}; + +const setTournamentFixture = async (redis: MemoryRedis, state: Record) => { + const prefix = 'sammo:che:default:tournament'; + await redis.set(`${prefix}:state`, JSON.stringify(state)); + await redis.set( + `${prefix}:participants`, + JSON.stringify([ + { id: 11, name: '후보11', leadership: 80, strength: 70, intel: 60, level: 5 }, + { id: 12, name: '후보12', leadership: 70, strength: 80, intel: 60, level: 5 }, + ]) + ); + await redis.set( + `${prefix}:matches`, + JSON.stringify([{ id: 1, stage: 7, roundIndex: 0, attackerId: 11, defenderId: 12 }]) + ); + await redis.set(`${prefix}:betting`, '[]'); +}; + +describe('tournament router permissions and mutations', () => { + it('charges the authenticated general once when joining', async () => { + const redis = new MemoryRedis(); + const transport = new TournamentTransport(); + const general = buildGeneral(1, 'user-1'); + transport.gold.set(general.id, general.gold); + await setTournamentFixture(redis, { + stage: 1, + phase: 0, + type: 0, + auto: true, + openYear: 193, + openMonth: 1, + termSeconds: 60, + nextAt: '2026-07-26T01:00:00.000Z', + }); + await redis.set('sammo:che:default:tournament:participants', '[]'); + const caller = appRouter.createCaller( + buildContext({ redis, transport, generals: [general], userId: 'user-1', develCost: 200 }) + ); + + await expect(caller.tournament.join()).resolves.toEqual({ ok: true, count: 1 }); + await expect(caller.tournament.join()).resolves.toEqual({ ok: true, count: 1 }); + + expect(transport.gold.get(general.id)).toBe(1_800); + expect(transport.commands.filter((command) => command.type === 'adjustGeneralResources')).toHaveLength(1); + }); + + it('serializes concurrent bets and enforces the legacy per-user 1000 limit', async () => { + const redis = new MemoryRedis(); + const transport = new TournamentTransport(); + const general = buildGeneral(1, 'user-1', 3_000); + transport.gold.set(general.id, general.gold); + await setTournamentFixture(redis, { + stage: 6, + phase: 0, + type: 0, + auto: true, + openYear: 193, + openMonth: 1, + termSeconds: 60, + nextAt: '2026-07-26T01:00:00.000Z', + bettingCloseAt: '2099-01-01T00:00:00.000Z', + }); + const caller = appRouter.createCaller( + buildContext({ redis, transport, generals: [general], userId: 'user-1' }) + ); + + const results = await Promise.allSettled([ + caller.tournament.placeBet({ targetId: 11, amount: 600 }), + caller.tournament.placeBet({ targetId: 12, amount: 600 }), + ]); + + expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1); + expect(results.filter((result) => result.status === 'rejected')).toHaveLength(1); + const summary = await caller.tournament.getBettingSummary(); + expect(summary.myAmount).toBe(600); + expect(summary.totalAmount).toBe(600); + expect(transport.gold.get(general.id)).toBe(2_400); + }); + + it('keeps another user from reading my bet identity and requires that user to own a general', async () => { + const redis = new MemoryRedis(); + const transport = new TournamentTransport(); + const owner = buildGeneral(1, 'user-1'); + const other = buildGeneral(2, 'user-2'); + await setTournamentFixture(redis, { + stage: 6, + phase: 0, + type: 0, + auto: true, + openYear: 193, + openMonth: 1, + termSeconds: 60, + nextAt: '2026-07-26T01:00:00.000Z', + }); + await redis.set( + 'sammo:che:default:tournament:betting', + JSON.stringify([{ generalId: owner.id, targetId: 11, amount: 300 }]) + ); + + const otherCaller = appRouter.createCaller( + buildContext({ redis, transport, generals: [owner, other], userId: 'user-2' }) + ); + const snapshot = await otherCaller.tournament.getSnapshot(); + expect(snapshot).toMatchObject({ betCount: 1 }); + expect(snapshot).not.toHaveProperty('bets'); + expect((await otherCaller.tournament.getBettingSummary()).myAmount).toBe(0); + + const generalLessCaller = appRouter.createCaller( + buildContext({ redis, transport, generals: [owner], userId: 'user-3' }) + ); + await expect(generalLessCaller.tournament.getSnapshot()).rejects.toMatchObject({ code: 'NOT_FOUND' }); + await expect(generalLessCaller.tournament.join()).rejects.toMatchObject({ code: 'NOT_FOUND' }); + }); + + it('limits tournament administration to global or profile-scoped roles', async () => { + const redis = new MemoryRedis(); + const transport = new TournamentTransport(); + const general = buildGeneral(1, 'user-1'); + const userCaller = appRouter.createCaller( + buildContext({ redis, transport, generals: [general], userId: 'user-1', roles: ['user'] }) + ); + await expect(userCaller.tournament.getAdminStatus()).rejects.toMatchObject({ code: 'FORBIDDEN' }); + + const adminCaller = appRouter.createCaller( + buildContext({ + redis, + transport, + generals: [general], + userId: 'user-1', + roles: ['admin.tournament:che:default'], + }) + ); + await expect(adminCaller.tournament.getAdminStatus()).resolves.toEqual({ ok: true }); + }); + + it('returns the legacy tournament rank ordering only to a user who owns a general', async () => { + const redis = new MemoryRedis(); + const transport = new TournamentTransport(); + const first = buildGeneral(1, 'user-1'); + const second = buildGeneral(2, 'user-2'); + const rankRows = [ + { generalId: first.id, type: 'ttg', value: 20 }, + { generalId: first.id, type: 'ttw', value: 3 }, + { generalId: second.id, type: 'ttg', value: 20 }, + { generalId: second.id, type: 'ttw', value: 4 }, + ]; + const ownerCaller = appRouter.createCaller( + buildContext({ redis, transport, generals: [first, second], userId: 'user-1', rankRows }) + ); + const sections = await ownerCaller.tournament.getRankings(); + expect(sections).toHaveLength(4); + expect(sections[0]?.entries.map((entry) => entry.generalId)).toEqual([second.id, first.id]); + + const generalLessCaller = appRouter.createCaller( + buildContext({ redis, transport, generals: [first, second], userId: 'user-3', rankRows }) + ); + await expect(generalLessCaller.tournament.getRankings()).rejects.toMatchObject({ code: 'NOT_FOUND' }); + }); + + it('refunds gold when the tournament bet rank update fails', async () => { + const redis = new MemoryRedis(); + const transport = new TournamentTransport(); + const general = buildGeneral(1, 'user-1', 2_000); + transport.gold.set(general.id, general.gold); + transport.failNextRankUpdate = true; + await setTournamentFixture(redis, { + stage: 6, + phase: 0, + type: 0, + auto: true, + openYear: 193, + openMonth: 1, + termSeconds: 60, + nextAt: '2026-07-26T01:00:00.000Z', + bettingCloseAt: '2099-01-01T00:00:00.000Z', + }); + const caller = appRouter.createCaller( + buildContext({ redis, transport, generals: [general], userId: 'user-1' }) + ); + + await expect(caller.tournament.placeBet({ targetId: 11, amount: 100 })).rejects.toMatchObject({ + code: 'INTERNAL_SERVER_ERROR', + }); + expect(transport.gold.get(general.id)).toBe(2_000); + await expect(caller.tournament.getBettingSummary()).resolves.toMatchObject({ + totalAmount: 0, + myAmount: 0, + }); + }); +}); diff --git a/app/game-api/test/tournamentWorker.test.ts b/app/game-api/test/tournamentWorker.test.ts index 11b8f62..4a0ca2a 100644 --- a/app/game-api/test/tournamentWorker.test.ts +++ b/app/game-api/test/tournamentWorker.test.ts @@ -209,6 +209,33 @@ const runTournamentToCompletion = async (options: { const delayTick = async (): Promise => new Promise((resolve) => setTimeout(resolve, 0)); describe('tournament worker (in-memory)', () => { + it('locks 64 applicants into eight groups of eight', async () => { + const redis = new MemoryRedis(); + const store = new TournamentStore(redis, buildTournamentKeys('test-groups')); + const state = createTournamentState({ stage: 1 }); + await store.setParticipants(createParticipants(16, 16, 32)); + await store.setState(state); + + const next = await applyPreBattleStage( + store, + createPrismaMock({ baseSeed: 'seed' }), + state, + 'seed', + createNoopDaemonTransport() + ); + const grouped = await store.getParticipants(); + + expect(next).toMatchObject({ stage: 2, phase: 0 }); + expect(next.participantsLockedAt).toBeTruthy(); + for (let groupId = 0; groupId < 8; groupId += 1) { + const entries = grouped.filter((entry) => entry.groupId === groupId); + expect(entries).toHaveLength(8); + expect(entries.map((entry) => entry.groupNo).sort((a, b) => Number(a) - Number(b))).toEqual([ + 0, 1, 2, 3, 4, 5, 6, 7, + ]); + } + }); + it('64명 고정 seed 대진에서 15번 참가자가 결승을 이긴다', async () => { const redis = new MemoryRedis(); const store = new TournamentStore(redis, buildTournamentKeys('test')); diff --git a/app/game-engine/src/turn/commandRegistry.ts b/app/game-engine/src/turn/commandRegistry.ts index 9798d6b..3bfea14 100644 --- a/app/game-engine/src/turn/commandRegistry.ts +++ b/app/game-engine/src/turn/commandRegistry.ts @@ -198,6 +198,7 @@ const zAdjustGeneralResources = z.object({ generalId: zFiniteNumber, goldDelta: zFiniteNumber.optional(), riceDelta: zFiniteNumber.optional(), + minGoldAfter: zFiniteNumber.optional(), }) .refine((value) => value.goldDelta !== undefined || value.riceDelta !== undefined) ) diff --git a/app/game-engine/src/turn/worldCommandHandler.ts b/app/game-engine/src/turn/worldCommandHandler.ts index 3be3c7c..ea4594f 100644 --- a/app/game-engine/src/turn/worldCommandHandler.ts +++ b/app/game-engine/src/turn/worldCommandHandler.ts @@ -147,7 +147,8 @@ async function handleAdjustGeneralResources( } const nextGold = general.gold + (adjustment.goldDelta ?? 0); const nextRice = general.rice + (adjustment.riceDelta ?? 0); - if (nextGold < 0 || nextRice < 0) { + const minGoldAfter = adjustment.minGoldAfter ?? 0; + if (nextGold < minGoldAfter || nextRice < 0) { return { type: 'adjustGeneralResources', ok: false, reason: '자원이 부족합니다.' }; } } @@ -260,7 +261,7 @@ async function handleTournamentMatchResult( }; } - const rankKey = (suffix: string): string => `rank_${prefix}${suffix}`; + const rankKey = (suffix: string): string => `${prefix}${suffix}`; const getRankNumber = (general: TurnGeneral | null, key: string): number => general && typeof general.meta[key] === 'number' ? Number(general.meta[key]) : 0; @@ -1074,8 +1075,8 @@ async function handleTournamentBettingPayout( continue; } const nextMeta = { ...general.meta } as TurnGeneral['meta']; - const betwinKey = 'rank_betwin'; - const betwingoldKey = 'rank_betwingold'; + const betwinKey = 'betwin'; + const betwingoldKey = 'betwingold'; const currentBetwin = typeof nextMeta[betwinKey] === 'number' ? Number(nextMeta[betwinKey]) : 0; const currentBetwingold = typeof nextMeta[betwingoldKey] === 'number' ? Number(nextMeta[betwingoldKey]) : 0; nextMeta[betwinKey] = currentBetwin + delta.betwin; diff --git a/app/game-engine/test/tournamentCommands.test.ts b/app/game-engine/test/tournamentCommands.test.ts new file mode 100644 index 0000000..6e1db68 --- /dev/null +++ b/app/game-engine/test/tournamentCommands.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from 'vitest'; + +import type { TurnSchedule } from '@sammo-ts/logic'; + +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; +import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js'; + +const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] }; + +const buildGeneral = (id: number, meta: Record = {}): TurnGeneral => ({ + id, + name: `장수${id}`, + nationId: 1, + cityId: 1, + troopId: 0, + stats: { leadership: 50, strength: 50, intelligence: 50 }, + turnTime: new Date('0180-01-01T00:00:00Z'), + recentWarTime: null, + role: { + items: { horse: null, weapon: null, book: null, item: null }, + personality: null, + specialDomestic: null, + specialWar: null, + }, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: { killturn: 24, ...meta }, + penalty: {}, + officerLevel: 1, + experience: 0, + dedication: 0, + injury: 0, + gold: 1_000, + rice: 1_000, + crew: 100, + crewTypeId: 0, + train: 0, + atmos: 0, + age: 30, + npcState: 0, +}); + +const buildWorld = (generals: TurnGeneral[]): InMemoryTurnWorld => { + const state: TurnWorldState = { + id: 1, + currentYear: 180, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: new Date('0180-01-01T00:00:00Z'), + meta: {}, + }; + const snapshot: TurnWorldSnapshot = { + generals, + cities: [], + nations: [], + troops: [], + diplomacy: [], + events: [], + initialEvents: [], + scenarioConfig: { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 }, + iconPath: '', + map: {}, + const: {}, + environment: { mapName: 'test', unitSet: 'test' }, + }, + map: { + id: 'test', + name: 'test', + cities: [], + defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 }, + }, + }; + return new InMemoryTurnWorld(state, snapshot, { schedule }); +}; + +describe('tournament world commands', () => { + it('updates the persisted tt rank keys for a tournament match', async () => { + const world = buildWorld([ + buildGeneral(1, { ttg: 10, ttw: 2 }), + buildGeneral(2, { ttg: 5, ttl: 1 }), + ]); + const handler = createTurnDaemonCommandHandler({ world }); + + await expect( + handler.handle({ + type: 'tournamentMatchResult', + tournamentType: 0, + attackerId: 1, + defenderId: 2, + result: 'attacker', + }) + ).resolves.toMatchObject({ ok: true }); + + expect(world.getGeneralById(1)?.meta).toMatchObject({ ttg: 11, ttw: 3 }); + expect(world.getGeneralById(2)?.meta).toMatchObject({ ttg: 5, ttl: 2 }); + expect(world.getGeneralById(1)?.meta).not.toHaveProperty('rank_ttg'); + }); + + it('updates persisted betting ranks when paying a winner', async () => { + const world = buildWorld([buildGeneral(1, { betwin: 2, betwingold: 100 })]); + const handler = createTurnDaemonCommandHandler({ world }); + + await expect( + handler.handle({ + type: 'tournamentBettingPayout', + bettingId: 1, + payouts: [{ generalId: 1, amount: 500 }], + }) + ).resolves.toMatchObject({ ok: true, totalPayout: 500 }); + + expect(world.getGeneralById(1)).toMatchObject({ + gold: 1_500, + meta: { betwin: 3, betwingold: 600 }, + }); + expect(world.getGeneralById(1)?.meta).not.toHaveProperty('rank_betwin'); + }); + + it('enforces a command-specific minimum remaining gold atomically', async () => { + const world = buildWorld([buildGeneral(1)]); + const handler = createTurnDaemonCommandHandler({ world }); + + await expect( + handler.handle({ + type: 'adjustGeneralResources', + adjustments: [{ generalId: 1, goldDelta: -501, minGoldAfter: 500 }], + }) + ).resolves.toMatchObject({ ok: false }); + expect(world.getGeneralById(1)?.gold).toBe(1_000); + + await expect( + handler.handle({ + type: 'adjustGeneralResources', + adjustments: [{ generalId: 1, goldDelta: -500, minGoldAfter: 500 }], + }) + ).resolves.toMatchObject({ ok: true }); + expect(world.getGeneralById(1)?.gold).toBe(500); + }); +}); diff --git a/app/gateway-api/src/adminRouter.ts b/app/gateway-api/src/adminRouter.ts index 471a922..e469052 100644 --- a/app/gateway-api/src/adminRouter.ts +++ b/app/gateway-api/src/adminRouter.ts @@ -4,18 +4,10 @@ import path from 'node:path'; import { TRPCError } from '@trpc/server'; import { z } from 'zod'; -import { - createGamePostgresConnector, - resolvePostgresConfigFromEnv, - type GatewayPrisma, -} from '@sammo-ts/infra'; +import { createGamePostgresConnector, resolvePostgresConfigFromEnv, type GatewayPrisma } from '@sammo-ts/infra'; import { procedure, router } from './trpc.js'; -import { - listScenarioPreviews, - resolveGitBranchCommitSha, - resolveGitCommitSha, -} from './scenario/scenarioCatalog.js'; +import { listScenarioPreviews, resolveGitBranchCommitSha, resolveGitCommitSha } from './scenario/scenarioCatalog.js'; import type { UserSanctions, UserServerRestriction } from './auth/userRepository.js'; import { toPublicUser } from './auth/userRepository.js'; import type { AdminAuthContext } from './adminAuth.js'; @@ -40,15 +32,7 @@ const zServerAction = z.enum([ ]); const TURN_TERM_MINUTES = [1, 2, 5, 10, 20, 30, 60, 120] as const; -const AUTORUN_USER_OPTIONS = [ - 'develop', - 'warp', - 'recruit', - 'recruit_high', - 'train', - 'battle', - 'chief', -] as const; +const AUTORUN_USER_OPTIONS = ['develop', 'warp', 'recruit', 'recruit_high', 'train', 'battle', 'chief'] as const; const ADMIN_ROLE_PREFIX = 'admin.'; const ADMIN_ROLE_SUPERUSER = 'admin.superuser'; @@ -254,9 +238,12 @@ const isUniqueConstraintError = (error: unknown): boolean => const zInstallOptions = z.object({ scenarioId: z.number().int().min(0), - turnTermMinutes: z.number().int().refine((value) => isAllowedTurnTerm(value), { - message: 'turnTermMinutes must divide 120.', - }), + turnTermMinutes: z + .number() + .int() + .refine((value) => isAllowedTurnTerm(value), { + message: 'turnTermMinutes must divide 120.', + }), sync: z.boolean(), fiction: z.number().int().min(0).max(1), extend: z.boolean(), @@ -771,55 +758,51 @@ export const adminRouter = router({ }); } }), - cancel: adminProcedure - .input(z.object({ id: z.string().uuid() })) - .mutation(async ({ ctx, input }) => { - const adminAuth = requireAdminAuth(ctx); - const previous = await ctx.profiles.getOperation(input.id); - if (!previous) { - throw new TRPCError({ code: 'NOT_FOUND', message: 'Operation not found.' }); - } - assertPermission(adminAuth, ROLE_ADMIN_PROFILES, previous.profileName); - const cancelled = await ctx.profiles.cancelOperation(input.id); - if (!cancelled) { + cancel: adminProcedure.input(z.object({ id: z.string().uuid() })).mutation(async ({ ctx, input }) => { + const adminAuth = requireAdminAuth(ctx); + const previous = await ctx.profiles.getOperation(input.id); + if (!previous) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Operation not found.' }); + } + assertPermission(adminAuth, ROLE_ADMIN_PROFILES, previous.profileName); + const cancelled = await ctx.profiles.cancelOperation(input.id); + if (!cancelled) { + throw new TRPCError({ + code: 'CONFLICT', + message: 'Only queued operations can be cancelled.', + }); + } + return { ok: true }; + }), + retry: adminProcedure.input(z.object({ id: z.string().uuid() })).mutation(async ({ ctx, input }) => { + const adminAuth = requireAdminAuth(ctx); + const previous = await ctx.profiles.getOperation(input.id); + if (!previous) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Operation not found.' }); + } + assertPermission(adminAuth, ROLE_ADMIN_PROFILES, previous.profileName); + try { + const operation = await ctx.profiles.retryOperation(input.id, adminAuth.user.id); + if (!operation) { throw new TRPCError({ code: 'CONFLICT', - message: 'Only queued operations can be cancelled.', + message: 'Only failed or cancelled operations can be retried.', }); } - return { ok: true }; - }), - retry: adminProcedure - .input(z.object({ id: z.string().uuid() })) - .mutation(async ({ ctx, input }) => { - const adminAuth = requireAdminAuth(ctx); - const previous = await ctx.profiles.getOperation(input.id); - if (!previous) { - throw new TRPCError({ code: 'NOT_FOUND', message: 'Operation not found.' }); + return operation; + } catch (error) { + if (error instanceof TRPCError) { + throw error; } - assertPermission(adminAuth, ROLE_ADMIN_PROFILES, previous.profileName); - try { - const operation = await ctx.profiles.retryOperation(input.id, adminAuth.user.id); - if (!operation) { - throw new TRPCError({ - code: 'CONFLICT', - message: 'Only failed or cancelled operations can be retried.', - }); - } - return operation; - } catch (error) { - if (error instanceof TRPCError) { - throw error; - } - if (!isUniqueConstraintError(error)) { - throw error; - } - throw new TRPCError({ - code: 'CONFLICT', - message: 'This profile already has a queued or running operation.', - }); + if (!isUniqueConstraintError(error)) { + throw error; } - }), + throw new TRPCError({ + code: 'CONFLICT', + message: 'This profile already has a queued or running operation.', + }); + } + }), }), profiles: router({ list: adminProcedure.query(async ({ ctx }) => { @@ -834,6 +817,7 @@ export const adminRouter = router({ profileName: profile.profileName, apiRunning: false, daemonRunning: false, + tournamentRunning: false, }, })); }), diff --git a/app/gateway-api/src/lobby/profileStatusService.ts b/app/gateway-api/src/lobby/profileStatusService.ts index 05e7e32..b65c2f5 100644 --- a/app/gateway-api/src/lobby/profileStatusService.ts +++ b/app/gateway-api/src/lobby/profileStatusService.ts @@ -26,6 +26,7 @@ export type LobbyProfileStatus = { runtime: { apiRunning: boolean; daemonRunning: boolean; + tournamentRunning: boolean; }; korName: string; color: string; @@ -68,7 +69,7 @@ export class RepositoryProfileStatusService implements GatewayProfileStatusServi private mapProfile( row: GatewayProfileRecord, - runtimeMap: Map + runtimeMap: Map ): LobbyProfileStatus { const meta = row.meta; return { @@ -80,6 +81,7 @@ export class RepositoryProfileStatusService implements GatewayProfileStatusServi runtime: runtimeMap.get(row.profileName) ?? { apiRunning: false, daemonRunning: false, + tournamentRunning: false, }, korName: (meta.korName as string | undefined) ?? row.profile, color: (meta.color as string | undefined) ?? '#ffffff', diff --git a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts index d3c8262..273c083 100644 --- a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts +++ b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts @@ -39,6 +39,7 @@ export interface GatewayOrchestratorOptions { export interface ProfileRuntimeState { apiRunning: boolean; daemonRunning: boolean; + tournamentRunning: boolean; } export interface ProfileRuntimeSnapshot extends ProfileRuntimeState { @@ -65,13 +66,13 @@ export const planProfileReconcile = ( ): { shouldStart: boolean; shouldStop: boolean } => { if (status === 'RUNNING' || status === 'PREOPEN' || status === 'PAUSED' || status === 'COMPLETED') { return { - shouldStart: !(runtime.apiRunning && runtime.daemonRunning), + shouldStart: !(runtime.apiRunning && runtime.daemonRunning && runtime.tournamentRunning), shouldStop: false, }; } return { shouldStart: false, - shouldStop: runtime.apiRunning || runtime.daemonRunning, + shouldStop: runtime.apiRunning || runtime.daemonRunning || runtime.tournamentRunning, }; }; @@ -197,14 +198,18 @@ const parseInstallOptions = ( : undefined; const sync = typeof install.sync === 'boolean' ? install.sync : undefined; const fiction = - typeof install.fiction === 'number' && Number.isFinite(install.fiction) ? Math.floor(install.fiction) : undefined; + typeof install.fiction === 'number' && Number.isFinite(install.fiction) + ? Math.floor(install.fiction) + : undefined; const extend = typeof install.extend === 'boolean' ? install.extend : undefined; const blockGeneralCreate = typeof install.blockGeneralCreate === 'number' && Number.isFinite(install.blockGeneralCreate) ? Math.floor(install.blockGeneralCreate) : undefined; const npcMode = - typeof install.npcMode === 'number' && Number.isFinite(install.npcMode) ? Math.floor(install.npcMode) : undefined; + typeof install.npcMode === 'number' && Number.isFinite(install.npcMode) + ? Math.floor(install.npcMode) + : undefined; const showImgLevel = typeof install.showImgLevel === 'number' && Number.isFinite(install.showImgLevel) ? Math.floor(install.showImgLevel) @@ -241,9 +246,7 @@ const parseInstallOptions = ( ? install.adminUser.username : install.adminUser.id, displayName: - typeof install.adminUser.displayName === 'string' - ? install.adminUser.displayName - : undefined, + typeof install.adminUser.displayName === 'string' ? install.adminUser.displayName : undefined, } : null; @@ -270,8 +273,8 @@ const parseInstallOptions = ( }; }; -const buildProcessName = (profileName: string, role: 'api' | 'daemon'): string => - `sammo:${profileName}:${role === 'api' ? 'game-api' : 'turn-daemon'}`; +const buildProcessName = (profileName: string, role: 'api' | 'daemon' | 'tournament'): string => + `sammo:${profileName}:${role === 'api' ? 'game-api' : role === 'daemon' ? 'turn-daemon' : 'tournament-worker'}`; const isMissingProcessError = (error: unknown): boolean => error instanceof Error && /process or namespace not found/i.test(error.message); @@ -282,10 +285,12 @@ export const buildProcessDefinitions = ( ): { api: { name: string; script: string; cwd: string; env: Record }; daemon: { name: string; script: string; cwd: string; env: Record }; + tournament: { name: string; script: string; cwd: string; env: Record }; } => { const baseEnv = { ...(config.baseEnv ?? {}) }; const apiName = buildProcessName(profile.profileName, 'api'); const daemonName = buildProcessName(profile.profileName, 'daemon'); + const tournamentName = buildProcessName(profile.profileName, 'tournament'); const runtimeWorkspace = profile.buildWorkspace ?? config.workspaceRoot; const apiCwd = path.join(runtimeWorkspace, 'app', 'game-api'); const daemonCwd = path.join(runtimeWorkspace, 'app', 'game-engine'); @@ -322,6 +327,15 @@ export const buildProcessDefinitions = ( cwd: daemonCwd, env: daemonEnv, }, + tournament: { + name: tournamentName, + script: apiScript, + cwd: apiCwd, + env: { + ...apiEnv, + GAME_API_ROLE: 'tournament-worker', + }, + }, }; }; @@ -362,10 +376,12 @@ const mapRuntimeStates = (profileNames: string[], processNames: Map { const apiName = buildProcessName(profileName, 'api'); const daemonName = buildProcessName(profileName, 'daemon'); + const tournamentName = buildProcessName(profileName, 'tournament'); return { profileName, apiRunning: processNames.get(apiName) ?? false, daemonRunning: processNames.get(daemonName) ?? false, + tournamentRunning: processNames.get(tournamentName) ?? false, }; }); @@ -756,8 +772,13 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { this.buildInFlight = true; this.resetInFlight.add(profile.profileName); try { - const { installOptions, scenarioId: installScenarioId, adminUser, openAt, preopenAt } = - parseInstallOptions(action); + const { + installOptions, + scenarioId: installScenarioId, + adminUser, + openAt, + preopenAt, + } = parseInstallOptions(action); const tickOverride = installOptions?.turnTermMinutes !== undefined ? installOptions.turnTermMinutes * 60 : undefined; const seedInfo = await this.resolveResetSeedInfo(profile, { @@ -850,7 +871,12 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { private async resolveResetSeedInfo( profile: GatewayProfileRecord, overrides?: { scenarioId?: number | null; tickSeconds?: number } - ): Promise<{ databaseUrl: string; scenarioId: number | null; tickSeconds?: number; meta: Record } > { + ): Promise<{ + databaseUrl: string; + scenarioId: number | null; + tickSeconds?: number; + meta: Record; + }> { const databaseUrl = resolvePostgresConfigFromEnv({ env: this.processConfig.baseEnv ?? process.env, schema: profile.profile, @@ -871,7 +897,11 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { scenarioId = resolvedScenario; } } - if (tickSeconds === undefined && typeof row.tickSeconds === 'number' && Number.isFinite(row.tickSeconds)) { + if ( + tickSeconds === undefined && + typeof row.tickSeconds === 'number' && + Number.isFinite(row.tickSeconds) + ) { tickSeconds = row.tickSeconds; } meta = normalizeMeta(row.meta); @@ -889,11 +919,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { const workspace = await this.workspaceManager.prepare(commitSha); const lastUsedAt = this.now().toISOString(); await this.repository.updateWorkspaceUsage(profileName, workspace.root, lastUsedAt); - const commands = buildWorkspaceCommands( - workspace.root, - workspace.needsInstall, - this.processConfig.baseEnv - ); + const commands = buildWorkspaceCommands(workspace.root, workspace.needsInstall, this.processConfig.baseEnv); return this.buildRunner.run(commands); } @@ -955,6 +981,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { try { await this.processManager.start(definitions.api); await this.processManager.start(definitions.daemon); + await this.processManager.start(definitions.tournament); await this.repository.updateLastError(profile.profileName, null); return true; } catch (error) { @@ -969,9 +996,10 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { private async stopProfile(profile: GatewayProfileRecord): Promise { const apiName = buildProcessName(profile.profileName, 'api'); const daemonName = buildProcessName(profile.profileName, 'daemon'); + const tournamentName = buildProcessName(profile.profileName, 'tournament'); const existingNames = new Set((await this.processManager.list()).map((process) => process.name)); const failures: string[] = []; - for (const name of [apiName, daemonName]) { + for (const name of [apiName, daemonName, tournamentName]) { if (!existingNames.has(name)) { continue; } diff --git a/app/gateway-api/test/orchestratorOperations.test.ts b/app/gateway-api/test/orchestratorOperations.test.ts index 48184a2..7a47e10 100644 --- a/app/gateway-api/test/orchestratorOperations.test.ts +++ b/app/gateway-api/test/orchestratorOperations.test.ts @@ -88,6 +88,7 @@ const createHarness = ( ? [ { name: 'sammo:che:2:game-api', status: 'online' }, { name: 'sammo:che:2:turn-daemon', status: 'online' }, + { name: 'sammo:che:2:tournament-worker', status: 'online' }, ] : [], start: async (definition) => { @@ -146,6 +147,7 @@ describe('GatewayOrchestrator first-class operations', () => { expect(harness.started.map((definition) => definition.name)).toEqual([ 'sammo:che:2:game-api', 'sammo:che:2:turn-daemon', + 'sammo:che:2:tournament-worker', ]); expect(harness.completions).toEqual(['SUCCEEDED']); }); @@ -156,8 +158,16 @@ describe('GatewayOrchestrator first-class operations', () => { await harness.orchestrator.runOperationsNow(); expect(harness.statuses).toEqual(['STOPPED']); - expect(harness.stopped).toEqual(['sammo:che:2:game-api', 'sammo:che:2:turn-daemon']); - expect(harness.deleted).toEqual(['sammo:che:2:game-api', 'sammo:che:2:turn-daemon']); + expect(harness.stopped).toEqual([ + 'sammo:che:2:game-api', + 'sammo:che:2:turn-daemon', + 'sammo:che:2:tournament-worker', + ]); + expect(harness.deleted).toEqual([ + 'sammo:che:2:game-api', + 'sammo:che:2:turn-daemon', + 'sammo:che:2:tournament-worker', + ]); expect(harness.completions).toEqual(['SUCCEEDED']); }); @@ -177,7 +187,11 @@ describe('GatewayOrchestrator first-class operations', () => { await harness.orchestrator.runOperationsNow(); - expect(harness.deleted).toEqual(['sammo:che:2:game-api', 'sammo:che:2:turn-daemon']); + expect(harness.deleted).toEqual([ + 'sammo:che:2:game-api', + 'sammo:che:2:turn-daemon', + 'sammo:che:2:tournament-worker', + ]); expect(harness.completions).toEqual(['SUCCEEDED']); }); @@ -194,8 +208,16 @@ describe('GatewayOrchestrator first-class operations', () => { await harness.orchestrator.runOperationsNow(); - expect(harness.stopped).toEqual(['sammo:che:2:game-api', 'sammo:che:2:turn-daemon']); - expect(harness.deleted).toEqual(['sammo:che:2:game-api', 'sammo:che:2:turn-daemon']); + expect(harness.stopped).toEqual([ + 'sammo:che:2:game-api', + 'sammo:che:2:turn-daemon', + 'sammo:che:2:tournament-worker', + ]); + expect(harness.deleted).toEqual([ + 'sammo:che:2:game-api', + 'sammo:che:2:turn-daemon', + 'sammo:che:2:tournament-worker', + ]); expect(harness.completions).toEqual(['FAILED']); }); }); diff --git a/app/gateway-api/test/orchestratorPlan.test.ts b/app/gateway-api/test/orchestratorPlan.test.ts index 9f6f8bc..ae92d15 100644 --- a/app/gateway-api/test/orchestratorPlan.test.ts +++ b/app/gateway-api/test/orchestratorPlan.test.ts @@ -29,6 +29,7 @@ describe('planProfileReconcile', () => { planProfileReconcile('RUNNING', { apiRunning: true, daemonRunning: false, + tournamentRunning: true, }) ).toEqual({ shouldStart: true, shouldStop: false }); }); @@ -38,6 +39,7 @@ describe('planProfileReconcile', () => { planProfileReconcile('PREOPEN', { apiRunning: false, daemonRunning: false, + tournamentRunning: false, }) ).toEqual({ shouldStart: true, shouldStop: false }); }); @@ -47,6 +49,7 @@ describe('planProfileReconcile', () => { planProfileReconcile('RUNNING', { apiRunning: true, daemonRunning: true, + tournamentRunning: true, }) ).toEqual({ shouldStart: false, shouldStop: false }); }); @@ -56,6 +59,7 @@ describe('planProfileReconcile', () => { planProfileReconcile('STOPPED', { apiRunning: false, daemonRunning: true, + tournamentRunning: false, }) ).toEqual({ shouldStart: false, shouldStop: true }); }); @@ -65,6 +69,7 @@ describe('planProfileReconcile', () => { planProfileReconcile('RESERVED', { apiRunning: false, daemonRunning: false, + tournamentRunning: false, }) ).toEqual({ shouldStart: false, shouldStop: false }); }); @@ -90,6 +95,11 @@ describe('buildProcessDefinitions', () => { }); expect(definitions.daemon.cwd).toBe(path.join(buildWorkspace, 'app', 'game-engine')); expect(definitions.daemon.script).toBe(path.join(buildWorkspace, 'app', 'game-engine', 'dist', 'index.js')); + expect(definitions.tournament).toMatchObject({ + cwd: path.join(buildWorkspace, 'app', 'game-api'), + script: path.join(buildWorkspace, 'app', 'game-api', 'dist', 'index.js'), + env: { GAME_API_ROLE: 'tournament-worker' }, + }); }); it('keeps main as the runtime for profiles without a commit worktree', () => { @@ -97,6 +107,7 @@ describe('buildProcessDefinitions', () => { expect(definitions.api.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api')); expect(definitions.daemon.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-engine')); + expect(definitions.tournament.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api')); }); }); diff --git a/app/gateway-frontend/e2e/server-operations.spec.ts b/app/gateway-frontend/e2e/server-operations.spec.ts index 4c9744b..ba61af7 100644 --- a/app/gateway-frontend/e2e/server-operations.spec.ts +++ b/app/gateway-frontend/e2e/server-operations.spec.ts @@ -38,6 +38,7 @@ const profile = (runtimeRunning: boolean) => ({ profileName: 'che:2', apiRunning: runtimeRunning, daemonRunning: runtimeRunning, + tournamentRunning: runtimeRunning, }, }); @@ -147,13 +148,17 @@ test('separates branch and commit semantics and submits a reset from the dedicat await expect(page.getByTestId('source-help')).toContainText('실제로 시작될 때'); await expect(page.getByTestId('scenario-select')).toHaveValue('2'); - const desktopGeometry = await page.getByTestId('server-operations-page').locator('section').first().evaluate((section) => { - const children = Array.from(section.children).map((child) => { - const rect = child.getBoundingClientRect(); - return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; + const desktopGeometry = await page + .getByTestId('server-operations-page') + .locator('section') + .first() + .evaluate((section) => { + const children = Array.from(section.children).map((child) => { + const rect = child.getBoundingClientRect(); + return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; + }); + return children; }); - return children; - }); expect(desktopGeometry).toHaveLength(2); expect(desktopGeometry[1]!.x).toBeGreaterThan(desktopGeometry[0]!.x); const sourceInput = page.getByTestId('source-ref'); @@ -191,13 +196,17 @@ test('separates branch and commit semantics and submits a reset from the dedicat expect(JSON.stringify(resetRequest?.body)).toContain('"scenarioId":5'); await page.setViewportSize({ width: 390, height: 844 }); - const mobileGeometry = await page.getByTestId('server-operations-page').locator('section').first().evaluate((section) => { - const children = Array.from(section.children).map((child) => { - const rect = child.getBoundingClientRect(); - return { x: rect.x, y: rect.y, width: rect.width }; + const mobileGeometry = await page + .getByTestId('server-operations-page') + .locator('section') + .first() + .evaluate((section) => { + const children = Array.from(section.children).map((child) => { + const rect = child.getBoundingClientRect(); + return { x: rect.x, y: rect.y, width: rect.width }; + }); + return children; }); - return children; - }); expect(mobileGeometry[1]!.y).toBeGreaterThan(mobileGeometry[0]!.y); expect(mobileGeometry[0]!.width).toBeLessThanOrEqual(390); await page.screenshot({ path: testInfo.outputPath('mobile-operations.png'), fullPage: true }); diff --git a/app/gateway-frontend/src/views/AdminView.vue b/app/gateway-frontend/src/views/AdminView.vue index 0403de4..565317c 100644 --- a/app/gateway-frontend/src/views/AdminView.vue +++ b/app/gateway-frontend/src/views/AdminView.vue @@ -70,6 +70,7 @@ type AdminProfile = { runtime: { apiRunning: boolean; daemonRunning: boolean; + tournamentRunning: boolean; }; buildCommitSha?: string; meta: Record; @@ -121,15 +122,7 @@ type InstallFormState = { }; type AdminAction = - | 'RESUME' - | 'PAUSE' - | 'STOP' - | 'ACCELERATE' - | 'DELAY' - | 'RESET_NOW' - | 'RESET_SCHEDULED' - | 'OPEN_SURVEY' - | 'SHUTDOWN'; + 'RESUME' | 'PAUSE' | 'STOP' | 'ACCELERATE' | 'DELAY' | 'RESET_NOW' | 'RESET_SCHEDULED' | 'OPEN_SURVEY' | 'SHUTDOWN'; type AdminClient = { system: { @@ -436,8 +429,7 @@ const toLocalInputValue = (value: unknown): string => { const readNumber = (value: unknown, fallback: number): number => typeof value === 'number' && Number.isFinite(value) ? value : fallback; -const readBoolean = (value: unknown, fallback: boolean): boolean => - typeof value === 'boolean' ? value : fallback; +const readBoolean = (value: unknown, fallback: boolean): boolean => (typeof value === 'boolean' ? value : fallback); const readString = (value: unknown, fallback: string): string => (typeof value === 'string' ? value : fallback); @@ -1090,21 +1082,16 @@ onMounted(() => {
제재 상태
{{ JSON.stringify(userResult.sanctions, null, 2) }}
-              
+

로컬 계정 생성

- - ENV {{ localAccountEnabled ? 'ON' : 'OFF' }} - -
-
- 카카오 OAuth 없이 로그인 가능한 계정을 생성합니다. + ENV {{ localAccountEnabled ? 'ON' : 'OFF' }}
+
카카오 OAuth 없이 로그인 가능한 계정을 생성합니다.
{
상태: {{ profile.status }} / API: {{ profile.runtime.apiRunning ? 'ON' : 'OFF' }} / - DAEMON: {{ profile.runtime.daemonRunning ? 'ON' : 'OFF' }} + DAEMON: {{ profile.runtime.daemonRunning ? 'ON' : 'OFF' }} / TOURNAMENT: + {{ profile.runtime.tournamentRunning ? 'ON' : 'OFF' }}
@@ -1504,7 +1492,9 @@ onMounted(() => { >

설치/리셋

- {{ profileInstallStatus[profile.profileName] }} + {{ + profileInstallStatus[profile.profileName] + }}
@@ -1525,7 +1515,9 @@ onMounted(() => { 불러오기
-
비워두면 현재 저장소 기준으로 불러옵니다.
+
+ 비워두면 현재 저장소 기준으로 불러옵니다. +
@@ -1535,16 +1527,28 @@ onMounted(() => { class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white" :disabled="getScenarioLoading(profile.profileName)" > - -