From e7fed61fb35407ed788d1aa352baff7ec9aa0512 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sat, 22 Aug 2026 04:51:46 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=86=A0=EB=84=88=EB=A8=BC=ED=8A=B8=20?= =?UTF-8?q?=EC=A1=B0=EB=B3=84=20=EC=A0=84=ED=88=AC=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EB=A5=BC=20=EB=B3=B5=EC=9B=90=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 예선과 본선 조별 경기의 최신 전투 로그를 저장하고 화면에 여덟 조 모두 표시한다. 결선은 최근 완료 경기와 종료 후 결승 로그를 유지하며 기존 대진 난수 순서를 보존한다. --- app/game-api/src/router/tournament/index.ts | 1 + app/game-api/src/tournament/types.ts | 2 + app/game-api/src/tournament/worker.ts | 35 ++++- app/game-api/src/tournament/workerHelpers.ts | 18 ++- app/game-api/test/tournamentRouter.test.ts | 47 +++++++ app/game-api/test/tournamentWorker.test.ts | 13 ++ .../e2e/tournamentBracket.spec.ts | 127 +++++++++++++++++- .../src/views/TournamentView.vue | 90 ++++++++++++- docs/frontend-legacy-parity.md | 42 +++--- 9 files changed, 345 insertions(+), 30 deletions(-) diff --git a/app/game-api/src/router/tournament/index.ts b/app/game-api/src/router/tournament/index.ts index 9aa986e9..2912a1bf 100644 --- a/app/game-api/src/router/tournament/index.ts +++ b/app/game-api/src/router/tournament/index.ts @@ -84,6 +84,7 @@ const zMatch = z.object({ id: z.number().int().positive(), stage: z.number().int().min(0), roundIndex: z.number().int().min(0), + groupId: z.number().int().min(0).max(17).optional(), attackerId: z.number().int().positive(), defenderId: z.number().int().positive(), winnerId: z.number().int().positive().optional(), diff --git a/app/game-api/src/tournament/types.ts b/app/game-api/src/tournament/types.ts index c0a4ab9c..3efde50b 100644 --- a/app/game-api/src/tournament/types.ts +++ b/app/game-api/src/tournament/types.ts @@ -47,6 +47,8 @@ export interface TournamentMatchEntry { id: number; stage: number; roundIndex: number; + /** Ref fight{group}.txt와 같이 조별전의 최신 로그를 식별합니다. */ + groupId?: number; attackerId: number; defenderId: number; winnerId?: number; diff --git a/app/game-api/src/tournament/worker.ts b/app/game-api/src/tournament/worker.ts index 5ee85766..04286cac 100644 --- a/app/game-api/src/tournament/worker.ts +++ b/app/game-api/src/tournament/worker.ts @@ -39,6 +39,35 @@ import { type TournamentPrismaClient, } from './workerHelpers.js'; +const persistLatestGroupFightLogs = async ( + store: TournamentStore, + stage: 2 | 4, + outcomes: TournamentMatchOutcome[] +): Promise => { + if (outcomes.length === 0) { + return; + } + + const matches = await store.getMatches(); + const replacedGroupIds = new Set(outcomes.map((outcome) => outcome.groupId)); + const retained = matches.filter( + (match) => match.stage !== stage || match.groupId === undefined || !replacedGroupIds.has(match.groupId) + ); + const latest = outcomes.map((outcome): TournamentMatchEntry => ({ + id: stage * 100 + outcome.groupId + 1, + stage, + roundIndex: outcome.groupId, + groupId: outcome.groupId, + attackerId: outcome.attackerId, + defenderId: outcome.defenderId, + winnerId: outcome.winnerId, + log: outcome.log, + logEntries: outcome.logEntries, + lastEnergy: outcome.lastEnergy, + })); + await store.setMatches(retained.concat(latest)); +}; + export const applyBattle = async ( store: TournamentStore, state: TournamentState, @@ -222,6 +251,7 @@ export const applyPreBattleStage = async ( outcomes.push(result.outcome); } await store.setParticipants(updated); + await persistLatestGroupFightLogs(store, 2, outcomes); if (outcomes.length > 0) { await Promise.all( @@ -366,6 +396,7 @@ export const applyPreBattleStage = async ( outcomes.push(result.outcome); } await store.setParticipants(updated); + await persistLatestGroupFightLogs(store, 4, outcomes); if (outcomes.length > 0) { await Promise.all( @@ -417,13 +448,13 @@ export const applyPreBattleStage = async ( if (state.stage === 5) { const matches = await store.getMatches(); - if (matches.length === 0) { + if (!matches.some((match) => match.stage >= 7)) { const fixedMatches = buildFinal16MatchesFromGroups(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); + await store.setMatches(matches.concat(initialMatches)); } const nextState: TournamentState = { ...state, diff --git a/app/game-api/src/tournament/workerHelpers.ts b/app/game-api/src/tournament/workerHelpers.ts index 7d71272a..deabd118 100644 --- a/app/game-api/src/tournament/workerHelpers.ts +++ b/app/game-api/src/tournament/workerHelpers.ts @@ -371,9 +371,14 @@ export const fillParticipants = async (options: { }; export type TournamentMatchOutcome = { + groupId: number; attackerId: number; defenderId: number; result: 'attacker' | 'defender' | 'draw'; + winnerId?: number; + log: string[]; + logEntries: NonNullable; + lastEnergy?: NonNullable; }; export const applyGroupMatch = ( @@ -419,10 +424,18 @@ export const applyGroupMatch = ( const glDelta = Math.round((result.totalDamage.defender - result.totalDamage.attacker) / 50); + const lastLogEntry = result.logEntries.at(-1); const outcome: TournamentMatchOutcome = { + groupId: matchIndex, attackerId: attacker.id, defenderId: defender.id, result: result.draw ? 'draw' : result.winnerId === attacker.id ? 'attacker' : 'defender', + winnerId: result.winnerId ?? undefined, + log: result.log, + logEntries: result.logEntries, + lastEnergy: lastLogEntry + ? { attacker: lastLogEntry.attackerEnergy, defender: lastLogEntry.defenderEnergy } + : undefined, }; return { @@ -572,7 +585,10 @@ export const buildNextMatches = (stage: number, matches: TournamentMatchEntry[]) throw new Error('다음 라운드를 만들 수 없습니다.'); } - const nextIdBase = matches.reduce((max, entry) => Math.max(max, entry.id), 0) + 1; + // 조별 최신 로그도 matches projection에 함께 보존하지만 결선 match ID는 + // 전투 RNG seed의 일부이므로 기존 결선 경기만으로 연속 번호를 계산합니다. + const nextIdBase = + matches.filter((entry) => entry.stage >= 7).reduce((max, entry) => Math.max(max, entry.id), 0) + 1; const nextStageValue = nextStage(stage); const result: TournamentMatchEntry[] = []; diff --git a/app/game-api/test/tournamentRouter.test.ts b/app/game-api/test/tournamentRouter.test.ts index b3bd1b4b..c822f77d 100644 --- a/app/game-api/test/tournamentRouter.test.ts +++ b/app/game-api/test/tournamentRouter.test.ts @@ -179,6 +179,53 @@ const setTournamentFixture = async (redis: MemoryRedis, state: Record { + it('returns persisted group fight logs to an authenticated tournament viewer', async () => { + const redis = new MemoryRedis(); + const transport = new TournamentTransport(); + const general = buildGeneral(1, 'user-1'); + await setTournamentFixture(redis, { + stage: 2, + phase: 1, + type: 0, + auto: true, + openYear: 193, + openMonth: 1, + termSeconds: 60, + nextAt: '2026-07-26T01:00:00.000Z', + }); + await redis.set( + 'sammo:che:default:tournament:matches', + JSON.stringify([ + { + id: 201, + stage: 2, + roundIndex: 0, + groupId: 0, + attackerId: 11, + defenderId: 12, + winnerId: 11, + log: ['후보11 승리!'], + }, + ]) + ); + const caller = appRouter.createCaller( + buildContext({ redis, transport, generals: [general, buildGeneral(11, 'user-11')], userId: 'user-1' }) + ); + + const snapshot = await caller.tournament.getSnapshot(); + + expect(snapshot.matches).toEqual([ + expect.objectContaining({ + stage: 2, + groupId: 0, + attackerId: 11, + defenderId: 12, + winnerId: 11, + log: ['후보11 승리!'], + }), + ]); + }); + it('charges the authenticated general once when joining', async () => { const redis = new MemoryRedis(); const transport = new TournamentTransport(); diff --git a/app/game-api/test/tournamentWorker.test.ts b/app/game-api/test/tournamentWorker.test.ts index 1b779111..53cff127 100644 --- a/app/game-api/test/tournamentWorker.test.ts +++ b/app/game-api/test/tournamentWorker.test.ts @@ -359,10 +359,23 @@ describe('tournament worker (in-memory)', () => { const prisma = createPrismaMock({ baseSeed: 'seed' }); const finalState = await runTournamentToCompletion({ store, prisma, baseSeed: 'seed' }); const matches = await store.getMatches(); + const preliminaryLogs = matches.filter((match) => match.stage === 2); + const finalGroupLogs = matches.filter((match) => match.stage === 4); + const knockoutMatches = matches.filter((match) => match.stage >= 7); const finalMatches = matches.filter((match) => match.stage === 10); expect(finalState.stage).toBe(0); expect(finalState.winnerId).toBe(15); + expect(preliminaryLogs).toHaveLength(8); + expect(finalGroupLogs).toHaveLength(8); + expect(knockoutMatches).toHaveLength(15); + expect(preliminaryLogs.map((match) => match.groupId).sort((a, b) => Number(a) - Number(b))).toEqual([ + 0, 1, 2, 3, 4, 5, 6, 7, + ]); + expect(finalGroupLogs.map((match) => match.groupId).sort((a, b) => Number(a) - Number(b))).toEqual([ + 10, 11, 12, 13, 14, 15, 16, 17, + ]); + expect([...preliminaryLogs, ...finalGroupLogs].every((match) => (match.log?.length ?? 0) >= 3)).toBe(true); expect(finalMatches).toHaveLength(1); expect(finalMatches[0]).toMatchObject({ attackerId: 15, diff --git a/app/game-frontend/e2e/tournamentBracket.spec.ts b/app/game-frontend/e2e/tournamentBracket.spec.ts index 6f7f0940..d2eff7e0 100644 --- a/app/game-frontend/e2e/tournamentBracket.spec.ts +++ b/app/game-frontend/e2e/tournamentBracket.spec.ts @@ -86,8 +86,55 @@ const matches = [ defenderId: index * 8 + 5, winnerId: index * 8 + 1, })), - { id: 15, stage: 10, roundIndex: 0, attackerId: 1, defenderId: 9, winnerId: 1 }, + { + id: 15, + stage: 10, + roundIndex: 0, + attackerId: 1, + defenderId: 9, + winnerId: 1, + log: ['관우 (800) vs (790) 여포', '관우 우승!'], + }, ]; +const buildGroupFightMatches = (stage: 2 | 4) => { + const groupStart = stage === 2 ? 0 : 10; + return Array.from({ length: 8 }, (_, index) => ({ + id: stage * 100 + groupStart + index + 1, + stage, + roundIndex: groupStart + index, + groupId: groupStart + index, + attackerId: index * 2 + 1, + defenderId: index * 2 + 2, + winnerId: index * 2 + 1, + log: [ + `${names[index * 2]} (800) vs (790) ${names[index * 2 + 1]}`, + '● 01合 : 720(-080) vs (-090)700', + `${names[index * 2]} 승리!`, + ], + })); +}; +const matchesForStage = (stage: number) => { + if (stage === 2 || stage === 3) { + return [...buildGroupFightMatches(2), ...matches]; + } + if (stage === 4 || stage === 5) { + return [...buildGroupFightMatches(2), ...buildGroupFightMatches(4), ...matches]; + } + if (stage === 7) { + return matches.map((match, index) => + match.stage === 7 && index === 0 + ? { + ...match, + log: [ + '관우 (800) vs (790) 장료', + '관우 승리!', + ], + } + : match + ); + } + return matches; +}; const response = (data: unknown) => ({ result: { data } }); const asRecord = (value: unknown): Record | null => @@ -205,7 +252,7 @@ const installFixture = async ( }, ] : participants, - matches, + matches: matchesForStage(tournamentStage), betCount: 16, }); } @@ -441,6 +488,82 @@ test('final group section appears before the later knockout section', async ({ p await persistScreenshot(page, 'tournament-final-stage-mobile', testInfo.outputPath('tournament-final-stage.webp')); }); +test('preliminary stage renders the latest fight log for all eight groups', async ({ page }, testInfo) => { + await page.setViewportSize({ width: 1365, height: 900 }); + await installFixture(page, { tournamentStage: 2 }); + await page.goto('tournament'); + + const region = page.getByRole('region', { name: '예선 조별 전투 로그' }); + const logs = region.locator('.fight-log'); + await expect(logs).toHaveCount(8); + await expect(logs).toHaveText([ + /一조 전투 로그.*관우.*장료.*승리/s, + /二조 전투 로그.*조운.*하후돈.*승리/s, + /三조 전투 로그/s, + /四조 전투 로그/s, + /五조 전투 로그/s, + /六조 전투 로그/s, + /七조 전투 로그/s, + /八조 전투 로그/s, + ]); + const geometry = await logs.evaluateAll((elements) => + elements.map((element) => { + const bounds = element.getBoundingClientRect(); + return { top: bounds.top, left: bounds.left, right: bounds.right, width: bounds.width }; + }) + ); + expect(new Set(geometry.slice(0, 4).map((item) => item.top)).size).toBe(1); + expect(geometry[4]!.top).toBeGreaterThan(geometry[0]!.top); + expect(geometry.every((item) => item.left >= 0 && item.right <= 1365 && item.width > 0)).toBe(true); + await expect(logs.first().locator('p').first().locator('span').first()).toHaveCSS('color', 'rgb(135, 206, 235)'); + await persistScreenshot(page, 'tournament-preliminary-fight-logs', testInfo.outputPath('preliminary-logs.webp')); +}); + +test('final group stage keeps all eight fight logs visible on mobile without overflow', async ({ page }, testInfo) => { + await page.setViewportSize({ width: 390, height: 844 }); + await installFixture(page, { tournamentStage: 4 }); + await page.goto('tournament'); + + const region = page.getByRole('region', { name: '본선 조별 전투 로그' }); + const logs = region.locator('.fight-log'); + await expect(logs).toHaveCount(8); + for (let index = 0; index < 8; index += 1) { + await expect(logs.nth(index)).toBeVisible(); + } + const geometry = await logs.evaluateAll((elements) => + elements.map((element) => { + const bounds = element.getBoundingClientRect(); + return { top: bounds.top, left: bounds.left, right: bounds.right }; + }) + ); + expect(geometry.every((item, index) => index === 0 || item.top > geometry[index - 1]!.top)).toBe(true); + expect(geometry.every((item) => item.left >= 0 && item.right <= 390)).toBe(true); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390); + await persistScreenshot(page, 'tournament-final-fight-logs-mobile', testInfo.outputPath('final-logs-mobile.webp')); +}); + +test('knockout stage shows the latest completed match instead of the next empty match', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await installFixture(page, { tournamentStage: 7 }); + await page.goto('tournament'); + + const region = page.getByRole('region', { name: '현재 토너먼트 전투 로그' }); + await expect(region).toContainText('관우 vs 장료'); + await expect(region).toContainText('관우 승리!'); + await expect(region).not.toContainText(''); + await expect(region.locator('p').first().locator('span').first()).toHaveCSS('color', 'rgb(135, 206, 235)'); +}); + +test('completed tournament retains the final fight log like Ref', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await installFixture(page); + await page.goto('tournament'); + + const region = page.getByRole('region', { name: '현재 토너먼트 전투 로그' }); + await expect(region).toContainText('관우 vs 여포'); + await expect(region).toContainText('관우 우승!'); +}); + test('mobile bracket exposes every round through tabs with standard horizontal identities', async ({ page, }, testInfo) => { diff --git a/app/game-frontend/src/views/TournamentView.vue b/app/game-frontend/src/views/TournamentView.vue index a6eb4a05..fad0a3ed 100644 --- a/app/game-frontend/src/views/TournamentView.vue +++ b/app/game-frontend/src/views/TournamentView.vue @@ -5,6 +5,7 @@ import TournamentBracket from '../components/tournament/TournamentBracket.vue'; import TournamentPageHeader from '../components/tournament/TournamentPageHeader.vue'; import GeneralIdentity from '../components/ui/GeneralIdentity.vue'; import { useGameFeedback } from '../composables/useGameFeedback'; +import { formatLog } from '../utils/formatLog'; import { trpc } from '../utils/trpc'; import { resolveTournamentSectionVisibility, resolveTournamentStageName } from '../utils/tournamentStatus'; @@ -112,10 +113,30 @@ const gamesOf = (participant: Snapshot['participants'][number] | undefined): num participant ? (participant.win ?? 0) + (participant.draw ?? 0) + (participant.lose ?? 0) : ''; const pointsOf = (participant: Snapshot['participants'][number] | undefined): number | '' => participant ? (participant.win ?? 0) * 3 + (participant.draw ?? 0) : ''; +const groupFightLogsAt = (stage: 2 | 4, groupStart: 0 | 10) => + Array.from({ length: 8 }, (_, index) => + (snapshot.value?.matches ?? []).find( + (match) => match.stage === stage && match.groupId === groupStart + index && (match.log?.length ?? 0) > 0 + ) + ); +const preliminaryFightLogs = computed(() => groupFightLogsAt(2, 0)); +const finalFightLogs = computed(() => groupFightLogsAt(4, 10)); +const showPreliminaryFightLogs = computed( + () => [2, 3].includes(snapshot.value?.state?.stage ?? -1) && preliminaryFightLogs.value.some(Boolean) +); +const showFinalFightLogs = computed( + () => [4, 5].includes(snapshot.value?.state?.stage ?? -1) && finalFightLogs.value.some(Boolean) +); const currentMatch = computed(() => { const state = snapshot.value?.state; - if (!state || state.stage < 7 || state.stage > 10) return null; - return matchesAt(state.stage).find((match) => !match.winnerId) ?? matchesAt(state.stage)[state.phase] ?? null; + if (!state) return null; + const logStage = state.stage === 0 && state.winnerId ? 10 : state.stage; + if (logStage < 7 || logStage > 10) return null; + return ( + matchesAt(logStage) + .filter((match) => (match.log?.length ?? 0) > 0) + .at(-1) ?? null + ); }); const revealMyPreliminaryGroup = async (): Promise => { @@ -230,9 +251,11 @@ const start = async () => { :tournament-type="snapshot?.state?.type ?? 0" /> -
+

{{ nameOf(currentMatch.attackerId) }} vs {{ nameOf(currentMatch.defenderId) }}

-

{{ line }}

+ + +

@@ -297,6 +320,21 @@ const start = async () => {
+
+
+

{{ groupNames[groupIndex] }}조 전투 로그

+ +
+