diff --git a/app/game-frontend/e2e/tournamentBracket.spec.ts b/app/game-frontend/e2e/tournamentBracket.spec.ts index 1e8b79df..9221dd9f 100644 --- a/app/game-frontend/e2e/tournamentBracket.spec.ts +++ b/app/game-frontend/e2e/tournamentBracket.spec.ts @@ -194,6 +194,7 @@ const installFixture = async ( tournamentType?: number; tournamentStage?: number; joinedGroupId?: number; + emptyFinalGroups?: boolean; } = {} ) => { let joined = false; @@ -251,7 +252,12 @@ const installFixture = async ( finalRank: 0, }, ] - : participants, + : options.emptyFinalGroups + ? participants.map((participant) => ({ + ...participant, + groupId: participant.preliminaryGroupId, + })) + : participants, matches: matchesForStage(tournamentStage), betCount: 16, }); @@ -393,12 +399,28 @@ test('desktop bracket connects every real general slot to the next round', async expect(oddsContainment.oddsBottom).toBeLessThanOrEqual(oddsContainment.cardBottom); await expect(firstSlot.locator('.bracket-my-bet')).toHaveText('내 투자 금120'); - const preliminaryTables = page.locator('.preliminary-grid table'); - await expect(preliminaryTables).toHaveCount(8); + const preliminaryGroups = page.locator('.preliminary-grid .tournament-group-card'); + await expect(preliminaryGroups).toHaveCount(8); for (let groupIndex = 0; groupIndex < 8; groupIndex += 1) { - await expect(preliminaryTables.nth(groupIndex).locator('tbody tr')).toHaveCount(8); - await expect(preliminaryTables.nth(groupIndex).locator('.general-identity')).toHaveCount(8); + await expect(preliminaryGroups.nth(groupIndex).locator('.standing-row')).toHaveCount(8); + await expect(preliminaryGroups.nth(groupIndex).locator('.general-identity')).toHaveCount(8); } + await expect(page.locator('.group-grid th, .group-grid td')).toHaveCount(0); + + const longName = page + .locator('.tournament-group-card .general-identity-name', { hasText: longGeneralName }) + .first(); + await expect(longName).toHaveAttribute('title', longGeneralName); + const longNameGeometry = await longName.evaluate((element) => ({ + clientWidth: element.clientWidth, + scrollWidth: element.scrollWidth, + overflow: getComputedStyle(element).overflow, + textOverflow: getComputedStyle(element).textOverflow, + whiteSpace: getComputedStyle(element).whiteSpace, + })); + expect(longNameGeometry.clientWidth).toBeGreaterThan(100); + expect(longNameGeometry.scrollWidth).toBeGreaterThan(longNameGeometry.clientWidth); + expect(longNameGeometry).toMatchObject({ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }); await persistScreenshot(page, 'tournament-desktop', testInfo.outputPath('tournament-bracket-desktop.webp')); }); @@ -427,6 +449,16 @@ test('join refresh shows the assigned preliminary group immediately with accessi await expect(preliminaryTabs.getByRole('tab').nth(5)).toHaveAttribute('aria-selected', 'true'); const assignedGroup = page.locator('[data-preliminary-group="5"]'); await expect(assignedGroup.locator('.general-identity', { hasText: names[0] })).toBeVisible(); + await expect(assignedGroup.locator('.standing-row')).toHaveCount(8); + await expect(assignedGroup.locator('.standing-row[data-empty="true"]')).toHaveCount(7); + const emptySlotGeometry = await assignedGroup.locator('.standing-row').evaluateAll((rows) => + rows.map((row) => { + const icon = row.querySelector('.general-identity-icon')!.getBoundingClientRect(); + return { height: row.getBoundingClientRect().height, iconWidth: icon.width, iconHeight: icon.height }; + }) + ); + expect(new Set(emptySlotGeometry.map((row) => row.height)).size).toBe(1); + expect(emptySlotGeometry.every((row) => row.iconWidth === 64 && row.iconHeight === 64)).toBe(true); const assignedGroupBounds = await assignedGroup.boundingBox(); expect(assignedGroupBounds?.y).toBeLessThan(844); expect((assignedGroupBounds?.y ?? 0) + (assignedGroupBounds?.height ?? 0)).toBeGreaterThan(0); @@ -479,12 +511,19 @@ test('desktop join scrolls the assigned preliminary group into view without futu test('final group section appears before the later knockout section', async ({ page }, testInfo) => { await page.setViewportSize({ width: 390, height: 844 }); - await installFixture(page, { tournamentStage: 3 }); + await installFixture(page, { tournamentStage: 3, emptyFinalGroups: true }); await page.goto('tournament'); await expect(page.getByText('조별 예선 순위')).toBeVisible(); await expect(page.getByText('조별 본선 순위')).toBeVisible(); await expect(page.getByLabel('토너먼트 대진표')).toHaveCount(0); + const activeFinalGroup = page.locator('.final-grid .tournament-group-card.mobile-active'); + await expect(activeFinalGroup.locator('.standing-row')).toHaveCount(4); + await expect(activeFinalGroup.locator('.standing-row[data-empty="true"]')).toHaveCount(4); + const emptyFinalHeights = await activeFinalGroup + .locator('.standing-row') + .evaluateAll((rows) => rows.map((row) => row.getBoundingClientRect().height)); + expect(new Set(emptyFinalHeights).size).toBe(1); await persistScreenshot(page, 'tournament-final-stage-mobile', testInfo.outputPath('tournament-final-stage.webp')); }); diff --git a/app/game-frontend/src/components/tournament/TournamentGroupCard.vue b/app/game-frontend/src/components/tournament/TournamentGroupCard.vue new file mode 100644 index 00000000..8d479013 --- /dev/null +++ b/app/game-frontend/src/components/tournament/TournamentGroupCard.vue @@ -0,0 +1,161 @@ + + + + + diff --git a/app/game-frontend/src/components/ui/GeneralIdentity.vue b/app/game-frontend/src/components/ui/GeneralIdentity.vue index 0dedcbfd..c54f811e 100644 --- a/app/game-frontend/src/components/ui/GeneralIdentity.vue +++ b/app/game-frontend/src/components/ui/GeneralIdentity.vue @@ -8,11 +8,13 @@ const props = withDefaults( picture?: GeneralIconSource['picture']; imageServer?: GeneralIconSource['imageServer']; hideIcon?: boolean; + placeholder?: boolean; }>(), { picture: null, imageServer: 0, hideIcon: false, + placeholder: false, } ); @@ -25,16 +27,25 @@ const iconUrl = computed(() => @@ -56,10 +67,28 @@ const iconUrl = computed(() => background: #111; object-fit: cover; } +.general-identity-icon--placeholder { + background: + linear-gradient(135deg, transparent 47%, rgb(255 255 255 / 8%) 48%, rgb(255 255 255 / 8%) 52%, transparent 53%), + #17110f; +} +.general-identity-copy { + display: flex; + min-width: 0; + flex: 1 1 auto; + flex-direction: column; + justify-content: center; +} .general-identity-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.general-identity-details { + min-width: 0; +} +.general-identity--placeholder { + color: #91847e; +} diff --git a/app/game-frontend/src/views/TournamentView.vue b/app/game-frontend/src/views/TournamentView.vue index fad0a3ed..441af917 100644 --- a/app/game-frontend/src/views/TournamentView.vue +++ b/app/game-frontend/src/views/TournamentView.vue @@ -2,8 +2,8 @@ import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime'; import { computed, nextTick, onMounted, ref } from 'vue'; import TournamentBracket from '../components/tournament/TournamentBracket.vue'; +import TournamentGroupCard from '../components/tournament/TournamentGroupCard.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'; @@ -101,18 +101,6 @@ const preliminaryGroups = computed(() => ) ); const groupNames = ['一', '二', '三', '四', '五', '六', '七', '八']; -const statOf = (participant: Snapshot['participants'][number] | undefined): number | '' => { - if (!participant) return ''; - const type = snapshot.value?.state?.type ?? 0; - if (type === 0) return participant.leadership + participant.strength + participant.intel; - if (type === 1) return participant.leadership; - if (type === 2) return participant.strength; - return participant.intel; -}; -const gamesOf = (participant: Snapshot['participants'][number] | undefined): number | '' => - 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( @@ -274,51 +262,17 @@ const start = async () => { {{ groupName }}조 -
- + - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- {{ - groupNames[groupIndex] - }}조 -
장수{{ typeStatNames[snapshot?.state?.type ?? 0] }}
{{ rowIndex }} - - {{ statOf(group[rowIndex - 1]) }}{{ gamesOf(group[rowIndex - 1]) }}{{ group[rowIndex - 1]?.win ?? '' }}{{ group[rowIndex - 1]?.draw ?? '' }}{{ group[rowIndex - 1]?.lose ?? '' }}{{ pointsOf(group[rowIndex - 1]) }}{{ group[rowIndex - 1]?.gl ?? '' }}
+ />
{
- - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- {{ - groupNames[groupIndex] - }}조 -
장수{{ typeStatNames[snapshot?.state?.type ?? 0] }}
{{ rowIndex }} - - {{ statOf(group[rowIndex - 1]) }}{{ gamesOf(group[rowIndex - 1]) }}{{ group[rowIndex - 1]?.win ?? '' }}{{ group[rowIndex - 1]?.draw ?? '' }}{{ group[rowIndex - 1]?.lose ?? '' }}{{ pointsOf(group[rowIndex - 1]) }}{{ group[rowIndex - 1]?.gl ?? '' }}
+ />
.tournament-group-card { display: none; - min-width: 370px; } - .group-grid table.mobile-active { - display: table; + .group-grid > .tournament-group-card.mobile-active { + display: block; } .fight-log-grid { grid-template-columns: minmax(0, 1fr); padding: 6px 0; } - .group-grid th, - .group-grid td { - height: 31px; - padding: 1px; - font-size: 11px; - } - .group-grid th:first-child, - .group-grid td:first-child { - width: 22px; - } - .group-grid th:nth-child(2), - .group-grid td:nth-child(2) { - width: 138px; - } .tournament-guide { padding: 10px; font-size: 11px; diff --git a/docs/frontend-legacy-parity.md b/docs/frontend-legacy-parity.md index 21de3718..fa873ec1 100644 --- a/docs/frontend-legacy-parity.md +++ b/docs/frontend-legacy-parity.md @@ -18,7 +18,10 @@ tree instead of replacing images with layout-neutral placeholders. `public-gaps.spec.ts` adds bounded fixtures for nation betting and the public NPC list, including mutations and recoverable API failures. `tournament-betting.spec.ts` covers the separate tournament and tournament -betting routes, including a recoverable failed bet. +betting routes, including a recoverable failed bet. The production-bundle +`app/game-frontend/e2e/tournamentBracket.spec.ts` additionally measures the +responsive group cards, equal-height empty draw slots, 64px icon placeholders, +long-name ellipsis, and mobile group tabs. `reference-rankings.mjs` records the authenticated PHP 명장일람 and public 명예의 전당 computed DOM without embedding the reference password. `battleSimulator.spec.ts` covers the authenticated simulator with and without @@ -74,28 +77,28 @@ storage, route guards, and image loading. ## Enforced contracts -| Screen | Ref entry point | Current automated contract | -| -------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| gateway login/status | `index.php` | 450/700px desktop widths, mobile collapse, Pretendard title, real login mutation/session storage, actual seasonal map asset | -| gateway account | `i_entrance/user_info.php` | 550px × minimum 575px panel, 14px Pretendard, three legacy textures, success and API-error password flows | -| gateway OAuth join | `oauth_kakao/join.php` | 700px centered registration card, Kakao exchange/register success, retained-input API error, hover/focus | -| gateway Kakao OTP | `index.php#modalOTP` | 동일 문구·500px modal, desktop/mobile geometry와 색상·typography, password/OAuth 진입, autofocus·focus-visible·active·disabled·오류 재시도·session 저장 | -| game login hand-off | unauthenticated `hwe/index.php` redirect | `/che/login` delegates to `/gateway/` | -| troop | `hwe/v_troop.php` | existing `app/game-frontend/e2e/troop.spec.ts` desktop/mobile geometry and interaction suite | -| current city | `hwe/b_currentCity.php` | main-page Pretendard 14px, wrapping general-name summary, small reserved-turn lines but normal-size NPC labels, 1000px summary/1024px general tables, 400px selector, 64px icon, nation title color, force summary, actor/spy/admin redaction, and map-click query navigation | -| best general | `hwe/a_bestGeneral.php` | authenticated 500/1000px ranking and unique-item grids, user/NPC switch, 100/64px cell/image geometry, title/button computed styles, retained-data API error | -| hall of fame | `hwe/a_hallOfFame.php` | public 500/1000px container, 100px ranking cells, 64px natural image, title/button/select computed styles, scenario switch and retained-data API error | -| yearbook | `hwe/v_history.php` | 1000px 700+300 desktop grid, 500px stacked grid, month navigation, legacy textures, success and API-error flows | -| inheritance | `hwe/v_inheritPoint.php` | 1000px 3-column desktop and 500px stacked layout, walnut/green textures, Pretendard 14px, scenario unique selector, buff purchase success and retained-input API error, two 30-row history pages that expand the document and keep the last row/load-more button reachable by scrolling | -| nation betting | `hwe/v_nationBetting.php` | 1000px/6-column desktop and 500px/3-column mobile grids, picked card style, payout table, success and retained-form error | -| public NPC list | `hwe/a_npcList.php` | 1000px 12-column table with Chromium-expanded legacy widths, NPC color, eight sorts, retained table/sort after API error | -| survey | `hwe/v_vote.php`, `hwe/ts/PageVote.vue` | 1000/500px fixed container, blue title/green table textures, list/detail/results/comments, selection/focus/hover, submit and retained-selection API error | -| nation personnel | `hwe/b_myBossInfo.php` | fixed 1000px document at both viewports, chief icon columns, officer/permission/city/kick controls, role redaction | -| nation finance | `hwe/v_nationStratFinan.php` | 1000/500px at the legacy 940px breakpoint, exact diplomacy grid, policy controls, role gating and failed-mutation rollback | -| battle simulator | `hwe/battle_simulator.php` | centered 1000px desktop document, 500px responsive stacking, independent/current presets, owned-general import gating, browser Web Worker calculation including 1000 repeats, fixed-seed result/logs, retained input after API error | -| NPC policy | `hwe/v_NPCControl.php` | 1000/500px form and priority-list geometry, walnut/green textures, dynamic zero hints, drag/focus/tooltip, successful save and permission failures | -| tournament | `hwe/b_tournament.php` | fixed 2000px Ref canvas and eight 250px group tables; Core responsive bracket plus all eight latest preliminary/final-group fight logs, latest knockout/final log, safe Ref marker colors, desktop/mobile containment | -| tournament betting | `hwe/b_betting.php` | fixed 1120px canvas, 16×70px candidates, four 280px rank tables, exact title/button geometry, retained selection on error | +| Screen | Ref entry point | Current automated contract | +| -------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| gateway login/status | `index.php` | 450/700px desktop widths, mobile collapse, Pretendard title, real login mutation/session storage, actual seasonal map asset | +| gateway account | `i_entrance/user_info.php` | 550px × minimum 575px panel, 14px Pretendard, three legacy textures, success and API-error password flows | +| gateway OAuth join | `oauth_kakao/join.php` | 700px centered registration card, Kakao exchange/register success, retained-input API error, hover/focus | +| gateway Kakao OTP | `index.php#modalOTP` | 동일 문구·500px modal, desktop/mobile geometry와 색상·typography, password/OAuth 진입, autofocus·focus-visible·active·disabled·오류 재시도·session 저장 | +| game login hand-off | unauthenticated `hwe/index.php` redirect | `/che/login` delegates to `/gateway/` | +| troop | `hwe/v_troop.php` | existing `app/game-frontend/e2e/troop.spec.ts` desktop/mobile geometry and interaction suite | +| current city | `hwe/b_currentCity.php` | main-page Pretendard 14px, wrapping general-name summary, small reserved-turn lines but normal-size NPC labels, 1000px summary/1024px general tables, 400px selector, 64px icon, nation title color, force summary, actor/spy/admin redaction, and map-click query navigation | +| best general | `hwe/a_bestGeneral.php` | authenticated 500/1000px ranking and unique-item grids, user/NPC switch, 100/64px cell/image geometry, title/button computed styles, retained-data API error | +| hall of fame | `hwe/a_hallOfFame.php` | public 500/1000px container, 100px ranking cells, 64px natural image, title/button/select computed styles, scenario switch and retained-data API error | +| yearbook | `hwe/v_history.php` | 1000px 700+300 desktop grid, 500px stacked grid, month navigation, legacy textures, success and API-error flows | +| inheritance | `hwe/v_inheritPoint.php` | 1000px 3-column desktop and 500px stacked layout, walnut/green textures, Pretendard 14px, scenario unique selector, buff purchase success and retained-input API error, two 30-row history pages that expand the document and keep the last row/load-more button reachable by scrolling | +| nation betting | `hwe/v_nationBetting.php` | 1000px/6-column desktop and 500px/3-column mobile grids, picked card style, payout table, success and retained-form error | +| public NPC list | `hwe/a_npcList.php` | 1000px 12-column table with Chromium-expanded legacy widths, NPC color, eight sorts, retained table/sort after API error | +| survey | `hwe/v_vote.php`, `hwe/ts/PageVote.vue` | 1000/500px fixed container, blue title/green table textures, list/detail/results/comments, selection/focus/hover, submit and retained-selection API error | +| nation personnel | `hwe/b_myBossInfo.php` | fixed 1000px document at both viewports, chief icon columns, officer/permission/city/kick controls, role redaction | +| nation finance | `hwe/v_nationStratFinan.php` | 1000/500px at the legacy 940px breakpoint, exact diplomacy grid, policy controls, role gating and failed-mutation rollback | +| battle simulator | `hwe/battle_simulator.php` | centered 1000px desktop document, 500px responsive stacking, independent/current presets, owned-general import gating, browser Web Worker calculation including 1000 repeats, fixed-seed result/logs, retained input after API error | +| NPC policy | `hwe/v_NPCControl.php` | 1000/500px form and priority-list geometry, walnut/green textures, dynamic zero hints, drag/focus/tooltip, successful save and permission failures | +| tournament | `hwe/b_tournament.php` | fixed 2000px Ref canvas and eight 250px group tables; Core responsive bracket, four-column/one-tab group cards with equal-height 64px empty slots and grouped stat/record/score summaries, long-name ellipsis/title, all eight latest preliminary/final-group fight logs, latest knockout/final log, safe Ref marker colors, desktop/mobile containment | +| tournament betting | `hwe/b_betting.php` | fixed 1120px canvas, 16×70px candidates, four 280px rank tables, exact title/button geometry, retained selection on error | The global game baseline is black, white, Pretendard 14px. Legacy texture helpers intentionally follow `common.orig.css`: `bg0` is walnut, `bg1` is