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/inGameMenus.spec.ts b/app/game-frontend/e2e/inGameMenus.spec.ts index 2f2f0c37..4b7294d0 100644 --- a/app/game-frontend/e2e/inGameMenus.spec.ts +++ b/app/game-frontend/e2e/inGameMenus.spec.ts @@ -22,6 +22,13 @@ const persistParityArtifact = async (page: Page, name: string, geometry: unknown ]); }; +const waitForVisualAssets = async (page: Page): Promise => { + await page.waitForLoadState('networkidle'); + await page.evaluate(async () => { + await document.fonts.ready; + }); +}; + const readGeneralPanelImages = async (panel: Locator) => panel.evaluate((element) => [...element.querySelectorAll('.general-image')].map((image) => { @@ -1313,6 +1320,9 @@ test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-ide await expect(page.locator('#container')).not.toContainText('che_'); await expect(page.locator('.title-row')).toContainText('내 정 보'); await expect(page.locator('#set_my_setting')).toBeVisible(); + await expect(page.getByRole('radiogroup', { name: '화면 폭 모드' })).toHaveCount(0); + await expect(page.getByRole('button', { name: '순서 바꾸기', exact: true })).toHaveCount(0); + await expect(page.locator('#custom_css')).toHaveCount(0); await expect(page.locator('.general-column [role="progressbar"]')).toHaveCount(14); await expect(page.locator('.general-column [aria-label*="1,275,975 (EX+)"]')).toHaveCount(5); await expect.poll(() => state.generalMeQueries).toBeGreaterThan(0); @@ -1333,7 +1343,6 @@ test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-ide const settings = element.querySelector('.settings-column')!.getBoundingClientRect(); const saveButton = element.querySelector('#set_my_setting')!; const save = saveButton.getBoundingClientRect(); - const customCss = element.querySelector('#custom_css')!.getBoundingClientRect(); const columns = getComputedStyle(element.querySelector('.top-grid')!).gridTemplateColumns; return { width: rect.width, @@ -1345,8 +1354,6 @@ test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-ide saveWidth: save.width, saveHeight: save.height, saveBackground: getComputedStyle(saveButton).backgroundColor, - customCssWidth: customCss.width, - customCssHeight: customCss.height, backgroundImage: getComputedStyle(element).backgroundImage, sectionBackgroundImage: getComputedStyle(element.querySelector('.section-title')!).backgroundImage, }; @@ -1360,8 +1367,6 @@ test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-ide expect(desktop.saveWidth).toBe(160); expect(desktop.saveHeight).toBe(30); expect(desktop.saveBackground).toBe('rgb(34, 85, 0)'); - expect(desktop.customCssWidth).toBe(420); - expect(desktop.customCssHeight).toBe(150); expect(desktop.backgroundImage).toContain('back_walnut.jpg'); expect(desktop.sectionBackgroundImage).toContain('back_green.jpg'); await expectLumenButtonStates(page, page.locator('#set_my_setting'), 'rgb(34, 85, 0)'); @@ -1626,11 +1631,68 @@ test('내 정보&설정의 지난 플레이는 기본 탐색과 분리되어 오 } }); -test('내 정보&설정에서 모바일 메인 패널을 드래그하거나 버튼으로 재정렬하고 기본 순서로 복원한다', async ({ page }) => { +test('화면 설정에서 화면 폭과 개인 CSS를 저장하고 게임 설정 API는 호출하지 않는다', async ({ page }, testInfo) => { + const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] }; + await install(page, state); + await page.setViewportSize({ width: 1000, height: 900 }); + await page.goto('my-settings'); + await waitForVisualAssets(page); + + await expect(page.locator('.title-row')).toContainText('화 면 설 정'); + await expect( + page.getByText('이 설정은 이 기기의 화면 표시만 바꾸며 게임 상태에는 영향을 주지 않습니다.') + ).toHaveCount(0); + await expect(page.locator('#set_my_setting')).toHaveCount(0); + await expect(page.locator('#custom_css')).toBeVisible(); + expect(state.settingMutations).toHaveLength(0); + + const desktop = await page.locator('#interface-settings').evaluate((element) => { + const rect = element.getBoundingClientRect(); + const css = element.querySelector('#custom_css')!.getBoundingClientRect(); + return { + width: rect.width, + columns: getComputedStyle(element.querySelector('.settings-grid')!).gridTemplateColumns, + cssWidth: css.width, + cssHeight: css.height, + backgroundImage: getComputedStyle(element).backgroundImage, + sectionBackgroundImage: getComputedStyle(element.querySelector('.section-title')!).backgroundImage, + }; + }); + expect(desktop.width).toBe(1000); + expect(desktop.columns.split(' ')).toHaveLength(2); + expect(desktop.cssWidth).toBe(420); + expect(desktop.cssHeight).toBe(150); + expect(desktop.backgroundImage).toContain('back_walnut.jpg'); + expect(desktop.sectionBackgroundImage).toContain('back_green.jpg'); + await page.screenshot({ path: testInfo.outputPath('interface-settings-desktop.png'), fullPage: true }); + + await page.getByRole('radio', { name: '500px' }).check(); + await expect.poll(() => page.evaluate(() => localStorage.getItem('sam.screenMode'))).toBe('500px'); + await expect(page.locator('meta[name="viewport"]')).toHaveAttribute('content', 'width=500'); + + const cssText = '#interface-settings { --ui-settings-e2e: 23px; }'; + await page.getByLabel('개인용 CSS').fill(cssText); + await expect(page.locator('.custom-css span')).toHaveText('(저장 중)'); + await expect.poll(() => page.evaluate(() => localStorage.getItem('sam_customCSS'))).toBe(cssText); + await expect.poll(() => page.locator('#sammo-custom-css').textContent()).toBe(cssText); + await page.reload(); + await expect.poll(() => page.locator('#sammo-custom-css').textContent()).toBe(cssText); + expect(state.settingMutations).toHaveLength(0); + + await page.getByLabel('개인용 CSS').fill(''); + await expect.poll(() => page.evaluate(() => localStorage.getItem('sam_customCSS'))).toBe(''); + await page.getByRole('radio', { name: '자동' }).check(); + await persistParityArtifact(page, 'core-interface-settings-desktop', desktop); +}); + +test('화면 설정에서 모바일 메인 패널을 드래그하거나 버튼으로 재정렬하고 기본 순서로 복원한다', async ({ + page, +}, testInfo) => { const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] }; await install(page, state); await page.setViewportSize({ width: 390, height: 844 }); - await page.goto('my-page'); + await page.goto('my-settings'); + await waitForVisualAssets(page); await page.getByRole('button', { name: '순서 바꾸기', exact: true }).click(); const dialog = page.getByRole('dialog', { name: '모바일 레이아웃 순서 바꾸기' }); @@ -1680,7 +1742,8 @@ test('내 정보&설정에서 모바일 메인 패널을 드래그하거나 버 expect(dialogGeometry.firstItem?.height).toBeGreaterThanOrEqual(44); expect(dialogGeometry.moveButton?.width).toBeGreaterThanOrEqual(36); expect(dialogGeometry.documentWidth).toBe(390); - await persistParityArtifact(page, 'core-my-page-mobile-layout-order-dialog', dialogGeometry); + await dialog.screenshot({ path: testInfo.outputPath('interface-settings-mobile-layout-dialog.png') }); + await persistParityArtifact(page, 'core-interface-settings-mobile-layout-order-dialog', dialogGeometry); await dialog.getByRole('button', { name: '적용', exact: true }).click(); await expect(dialog).toBeHidden(); @@ -1697,7 +1760,7 @@ test('내 정보&설정에서 모바일 메인 패널을 드래그하거나 버 .toEqual(defaultOrder); }); -test('실제 모바일 터치로 메인 패널 순서를 재정렬한다', async ({ browser }, testInfo) => { +test('화면 설정에서 실제 모바일 터치로 메인 패널 순서를 재정렬한다', async ({ browser }, testInfo) => { const configuredBaseUrl = testInfo.project.use.baseURL; if (typeof configuredBaseUrl !== 'string') { throw new Error('Playwright baseURL is required for the mobile touch contract'); @@ -1715,7 +1778,8 @@ test('실제 모바일 터치로 메인 패널 순서를 재정렬한다', async try { const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] }; await install(mobilePage, state); - await mobilePage.goto('my-page'); + await mobilePage.goto('my-settings'); + await waitForVisualAssets(mobilePage); await mobilePage.getByRole('button', { name: '순서 바꾸기', exact: true }).click(); const dialog = mobilePage.getByRole('dialog', { name: '모바일 레이아웃 순서 바꾸기' }); diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index 9f78ed3f..8ae3476a 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -29,6 +29,8 @@ type NavigationFixture = { permission: number; nationLevel: number; stage: number; + tournamentType?: 0 | 1 | 2 | 3; + tournamentWinnerId?: number; npcMode: number; generalMeCalls: number; operations: string[]; @@ -758,7 +760,16 @@ const installFixture = async (page: Page, state: NavigationFixture) => { canSecret: state.permission >= 2, }); } - if (operation === 'tournament.getState') return response({ stage: state.stage }); + if (operation === 'tournament.getState') { + if (state.stage === 0 && state.tournamentType === undefined && state.tournamentWinnerId === undefined) { + return response(null); + } + return response({ + stage: state.stage, + type: state.tournamentType ?? 0, + winnerId: state.tournamentWinnerId, + }); + } return response({ ok: true }); }); operations.forEach((operation, index) => { @@ -1260,7 +1271,27 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro await expect(global.locator('[data-navigation-id="board-community"]')).toHaveAttribute('href', '/xe/community'); await expect(global.locator('[data-navigation-id="official-chat"]')).toHaveAttribute('target', '_blank'); await expect(global.locator('[data-navigation-id="survey"]')).toHaveClass(/highlight/); - await expect(page.locator('.main-nation-menu [data-navigation-id="tournament"]')).toHaveClass(/highlight/); + const nationMenu = page.locator('.main-nation-menu:visible'); + await expect(nationMenu.locator(':scope > *')).toHaveCount(20); + const tournamentMain = nationMenu.locator('[data-navigation-id="tournament"]'); + const tournamentToggle = nationMenu.locator('[data-menu-id="tournament-betting"]'); + await expect(tournamentMain).toHaveClass(/highlight/); + await expect(tournamentMain).toHaveAttribute('href', `${basePath}/tournament`); + await expect(tournamentToggle).toHaveAttribute('aria-expanded', 'false'); + await expect(nationMenu.locator('[data-navigation-id="my-settings"]')).toHaveAttribute( + 'href', + `${basePath}/my-settings` + ); + await tournamentToggle.click(); + await expect(tournamentToggle).toHaveAttribute('aria-expanded', 'true'); + await expect( + nationMenu.locator('#nation-menu-tournament-betting [data-navigation-id="tournament-menu"]') + ).toHaveText('토너먼트'); + await expect(nationMenu.locator('#nation-menu-tournament-betting [data-navigation-id="betting"]')).toHaveAttribute( + 'href', + `${basePath}/betting` + ); + await page.keyboard.press('Escape'); const gameInfoButton = global.locator('[data-menu-id="game-info"]'); const bettingButton = global.locator('[data-navigation-id="nation-betting"]'); @@ -1417,6 +1448,95 @@ test('shows the persisted official game index beside the scenario title without } }); +test('tournament split main action follows recruitment, betting, finals, and tournament type on desktop and mobile', async ({ + page, +}, testInfo) => { + const state: NavigationFixture = { + officerLevel: 5, + permission: 2, + nationLevel: 3, + stage: 1, + tournamentType: 3, + npcMode: 1, + scenarioTitle: '토너먼트 동적 메뉴 검증 시나리오', + generalMeCalls: 0, + operations: [], + }; + await installFixture(page, state); + + const cases = [ + { stage: 1, type: 3 as const, label: '설 전', route: '/tournament' }, + { stage: 5, type: 2 as const, label: '일 기 토', route: '/tournament' }, + { stage: 6, type: 2 as const, label: '베 팅 장', route: '/betting' }, + { stage: 7, type: 2 as const, label: '일 기 토', route: '/tournament' }, + { stage: 10, type: 3 as const, label: '설 전', route: '/tournament' }, + { stage: 0, type: 3 as const, label: '설 전', route: '/tournament', winnerId: 17 }, + ]; + + for (const viewport of [ + { width: 1200, height: 900 }, + { width: 500, height: 900 }, + ]) { + await page.setViewportSize(viewport); + for (const lifecycle of cases) { + state.stage = lifecycle.stage; + state.tournamentType = lifecycle.type; + state.tournamentWinnerId = lifecycle.winnerId; + if (page.url() === 'about:blank') { + await waitForMain(page); + } else { + await page.reload(); + await waitForMain(page); + } + + const nationMenu = page.locator('.main-nation-menu:visible'); + const main = nationMenu.locator('[data-navigation-id="tournament"]'); + await expect(main).toHaveText(lifecycle.label); + await expect(main).toHaveAttribute('href', `${basePath}${lifecycle.route}`); + await expect(main).toHaveClass(/highlight/); + + const toggle = nationMenu.locator('[data-menu-id="tournament-betting"]'); + await toggle.click(); + const tournamentItem = nationMenu.locator( + '#nation-menu-tournament-betting [data-navigation-id="tournament-menu"]' + ); + const bettingItem = nationMenu.locator('#nation-menu-tournament-betting [data-navigation-id="betting"]'); + if (lifecycle.stage === 6) { + await expect(tournamentItem).not.toHaveClass(/highlight/); + await expect(bettingItem).toHaveClass(/highlight/); + } else { + await expect(tournamentItem).toHaveClass(/highlight/); + await expect(bettingItem).not.toHaveClass(/highlight/); + } + await page.keyboard.press('Escape'); + + if (viewport.width === 500) { + const nationTrigger = page.locator('[data-bottom-menu="nation"]'); + await nationTrigger.click(); + const mobileTournament = page.locator('#mobile-nation-menu [data-navigation-id="tournament-menu"]'); + const mobileBetting = page.locator('#mobile-nation-menu [data-navigation-id="betting"]'); + if (lifecycle.stage === 6) { + await expect(mobileTournament).not.toHaveClass(/highlight/); + await expect(mobileBetting).toHaveClass(/highlight/); + } else { + await expect(mobileTournament).toHaveClass(/highlight/); + await expect(mobileBetting).not.toHaveClass(/highlight/); + } + await page.keyboard.press('Escape'); + } + } + + await page + .locator('.main-nation-menu:visible [data-menu-id="tournament-betting"]') + .locator('..') + .screenshot({ + path: artifactRoot + ? resolve(artifactRoot, `tournament-dynamic-main-${viewport.width}.png`) + : testInfo.outputPath(`tournament-dynamic-main-${viewport.width}.png`), + }); + } +}); + test('shows game index zero for a profile whose first game starts at zero', async ({ page }) => { const state: NavigationFixture = { officerLevel: 5, @@ -1455,7 +1575,7 @@ test('nation split buttons keep square inner corners and a single divider in eve officerLevel: 5, permission: 2, nationLevel: 3, - stage: 1, + stage: 6, npcMode: 1, scenarioTitle: '분할 버튼 이음새 검증 시나리오', generalMeCalls: 0, @@ -1515,14 +1635,21 @@ test('nation split buttons keep square inner corners and a single divider in eve for (const width of [1200, 500]) { await page.setViewportSize({ width, height: 900 }); await waitForMain(page); - const nationSplit = page.locator('.main-nation-menu:visible .nation-menu-split').first(); const pairs: Array<[string, Locator, Locator]> = [ [ - 'nation', - nationSplit.locator('[data-navigation-id="auction-resource"]'), - nationSplit.locator('[data-menu-id="auction"]'), + 'tournament', + page.locator('.main-nation-menu:visible [data-navigation-id="tournament"]'), + page.locator('.main-nation-menu:visible [data-menu-id="tournament-betting"]'), + ], + [ + 'auction', + page.locator('.main-nation-menu:visible [data-navigation-id="auction-resource"]'), + page.locator('.main-nation-menu:visible [data-menu-id="auction"]'), ], ]; + await expect(page.locator('.main-nation-menu:visible [data-navigation-id="tournament"]')).toHaveClass( + /highlight/ + ); for (const [label, main, toggle] of pairs) { await expect(main).toBeVisible(); @@ -3959,9 +4086,7 @@ for (const viewport of [ await picker.getByLabel('장비 종류', { exact: true }).selectOption('weapon'); const equipment = picker.getByLabel('장비', { exact: true }); await equipment.selectOption('청룡언월도'); - const optionLabelBeforeRefresh = await equipment - .locator('option[value="청룡언월도"]') - .textContent(); + const optionLabelBeforeRefresh = await equipment.locator('option[value="청룡언월도"]').textContent(); await equipment.evaluate((element) => { const select = element as HTMLSelectElement; const valueDescriptor = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value'); @@ -4091,8 +4216,8 @@ test('keeps an Android Chromium native command select untouched while a turn sig await waitForMain(mobilePage); await expect .poll(() => - mobilePage.evaluate( - () => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime() + mobilePage.evaluate(() => + (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime() ) ) .toBe(true); diff --git a/app/game-frontend/e2e/tournamentBracket.spec.ts b/app/game-frontend/e2e/tournamentBracket.spec.ts index 6f7f0940..1e8b79df 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) => { @@ -570,6 +693,30 @@ test('tournament and betting pages expose same-row navigation tabs beside close' expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390); }); +test('tournament and betting close only their script-opened popup window', async ({ page }, testInfo) => { + const baseURL = testInfo.project.use.baseURL; + expect(typeof baseURL).toBe('string'); + await page.goto('about:blank'); + + for (const route of ['tournament', 'betting'] as const) { + const popupPromise = page.waitForEvent('popup'); + await page.evaluate(() => window.open('about:blank', '_blank', 'noopener')); + const popup = await popupPromise; + await installFixture(popup, { tournamentStage: route === 'betting' ? 6 : 1 }); + await popup.goto(new URL(route, baseURL as string).href); + + await expect(popup.getByRole('button', { name: '창 닫기' }).first()).toBeVisible(); + expect(await popup.evaluate(() => window.opener)).toBeNull(); + + const closed = popup.waitForEvent('close'); + await popup.getByRole('button', { name: '창 닫기' }).first().click(); + await closed; + + expect(page.isClosed()).toBe(false); + expect(page.url()).toBe('about:blank'); + } +}); + test('mobile betting rankings use tabs and keep dedicated icons beside general names', async ({ page }, testInfo) => { await page.setViewportSize({ width: 390, height: 844 }); const { placedBets } = await installFixture(page, { tournamentStage: 6 }); diff --git a/app/game-frontend/src/assets/main.css b/app/game-frontend/src/assets/main.css index 65148076..4912c428 100644 --- a/app/game-frontend/src/assets/main.css +++ b/app/game-frontend/src/assets/main.css @@ -42,6 +42,7 @@ body { /* These redesigned screens own a true handheld layout. */ #app:has(.responsive-settings-page), +#app:has(.interface-settings-page), #app:has(#tournament-container), #app:has(#tournament-betting-container), #app:has(#personnel-container) { diff --git a/app/game-frontend/src/components/main/MainMobileBottomBar.vue b/app/game-frontend/src/components/main/MainMobileBottomBar.vue index dc492b03..21559738 100644 --- a/app/game-frontend/src/components/main/MainMobileBottomBar.vue +++ b/app/game-frontend/src/components/main/MainMobileBottomBar.vue @@ -4,9 +4,9 @@ import { legacyNationTextColor } from '../../utils/legacyNationColor'; import MainNavigationLink from './MainNavigationLink.vue'; import { buildGlobalNavigation, + buildNationNavigation, isNationNavigationEnabled, isNavigationConfigured, - nationNavigation, quickNavigation, type MainNavigationLink as MainNavigationLinkItem, type MainNavigationEntry, @@ -18,6 +18,7 @@ import { useMenuPopup } from './useMenuPopup'; const props = defineProps<{ access: NationNavigationAccess; tournamentStage: number; + tournamentType: number | null; nationColor: string; npcMode: number; realtimeEnabled: boolean; @@ -35,9 +36,12 @@ const emit = defineEmits<{ const { setRoot, openId, close, toggle } = useMenuPopup(); const globalEntries = computed(() => buildGlobalNavigation(props.npcMode, props.entries)); +const nationEntries = computed(() => buildNationNavigation(props.tournamentStage, props.tournamentType)); const nationMenuColor = computed(() => props.nationColor || '#000000'); const nationMenuTextColor = computed(() => legacyNationTextColor(nationMenuColor.value)); -const isActive = (link: MainNavigationLinkItem) => link.highlightStage === props.tournamentStage; +const isActive = (link: MainNavigationLinkItem) => + link.highlightStage === props.tournamentStage || + link.highlightStages?.some((stage) => stage === props.tournamentStage) === true; const onQuick = (item: QuickNavigationItem) => { close(); @@ -148,7 +152,7 @@ const onAction = (action: NonNullable) => {