diff --git a/app/game-frontend/e2e/joinLayout.spec.ts b/app/game-frontend/e2e/joinLayout.spec.ts new file mode 100644 index 00000000..d050ba78 --- /dev/null +++ b/app/game-frontend/e2e/joinLayout.spec.ts @@ -0,0 +1,237 @@ +import { expect, test, type Page, type Route } from '@playwright/test'; +import { gameBasePath, gameProfile } from './gameTestPaths.js'; + +const response = (data: unknown) => ({ result: { data } }); +const operationNames = (route: Route) => + decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(','); + +type FixtureState = { + mapRequests: number; + generalRequests: number; +}; + +const installFixture = async (page: Page, state: FixtureState): Promise => { + await page.addInitScript((profile) => { + localStorage.setItem('sammo-game-token', 'ga_join_layout'); + localStorage.setItem('sammo-game-profile', profile); + }, gameProfile); + await page.route('**/events**', async (route) => route.abort()); + await page.route('**/image/**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'image/svg+xml', + body: '', + }); + }); + await page.route(`**${gameBasePath}/api/trpc/**`, async (route) => { + const operations = operationNames(route); + const results = operations.map((operation) => { + if (operation === 'auth.status') return response({ ok: true }); + if (operation === 'lobby.info') { + return response({ myGeneral: null, year: 180, month: 4, turnTerm: 5 }); + } + if (operation === 'join.getConfig') { + return response({ + rules: { + stat: { total: 165, min: 15, max: 80, bonusMin: 3, bonusMax: 5 }, + allowCustomName: true, + }, + user: { + id: 'join-layout-user', + displayName: '생성장수', + canCreateGeneral: true, + icons: [], + preferredPicture: 'default.jpg', + }, + personalities: [ + { key: 'Random', name: '???', info: '무작위 성격을 선택합니다.' }, + { key: 'che_대담', name: '대담', info: '과감한 행동을 선호합니다.' }, + ], + warSpecials: [{ key: 'che_무쌍', name: '무쌍', info: '전투 특기' }], + nations: [ + { id: 1, name: '촉', color: '#66aa44', scoutMessage: '함께 천하를 도모합시다.' }, + { id: 2, name: '위', color: '#5577bb', scoutMessage: '능력 있는 장수를 기다립니다.' }, + ], + serverInfo: { + currentYear: 180, + currentMonth: 4, + tickMinutes: 5, + maxGeneral: 500, + userGeneralCount: 2, + npcGeneralCount: 1, + }, + inherit: { + totalPoint: 30, + costs: { + inheritBornSpecialPoint: 10, + inheritBornTurntimePoint: 5, + inheritBornCityPoint: 5, + inheritBornStatPoint: 10, + }, + availableCities: [{ id: 1, name: '성도', level: 4, region: 5 }], + turnTimeZones: ['00분', '05분'], + availableSpecialWar: [{ key: 'che_무쌍', name: '무쌍', info: '전투 특기' }], + }, + selectionPool: { enabled: false, hasGeneral: false }, + npcPossession: { enabled: true }, + }); + } + if (operation === 'public.getCachedMap') { + state.mapRequests += 1; + return response({ + year: 180, + month: 4, + startYear: 180, + cityList: [[1, 4, 0, 1, 5, 1]], + nationList: [[1, '촉', '#66aa44', 1]], + myCity: null, + myNation: null, + history: [], + }); + } + if (operation === 'public.getMapLayout') { + state.mapRequests += 1; + return response({ + mapName: 'che', + cityList: [{ id: 1, name: '성도', level: 4, region: 5, x: 100, y: 100, path: [] }], + regionMap: { 5: '익주' }, + levelMap: { 4: '대도시' }, + }); + } + if (operation === 'public.getGeneralList') { + state.generalRequests += 1; + return response([ + { + id: 1, + name: '유비', + npcState: 0, + nationId: 1, + nationName: '촉', + leadership: 72, + strength: 67, + intelligence: 76, + }, + { + id: 2, + name: '조조', + npcState: 0, + nationId: 2, + nationName: '위', + leadership: 78, + strength: 62, + intelligence: 80, + }, + { + id: 3, + name: '황건장수', + npcState: 2, + nationId: 0, + nationName: '무주', + leadership: 55, + strength: 65, + intelligence: 45, + }, + ]); + } + return response({}); + }); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(operations.length === 1 ? results[0] : results), + }); + }); +}; + +test('prioritizes core general fields and keeps context and inheritance progressive', async ({ page }, testInfo) => { + const state: FixtureState = { mapRequests: 0, generalRequests: 0 }; + await installFixture(page, state); + await page.setViewportSize({ width: 1200, height: 900 }); + await page.goto('join'); + + const flow = page.locator('.join-flow'); + const basicPanel = flow.locator('.panel-card').first(); + const advanced = flow.locator('.advanced-options'); + const contextPanel = flow.locator('.panel-card').nth(1); + await expect(page.getByRole('heading', { name: '장수 기본 정보' })).toBeVisible(); + await expect(page.getByLabel('장수명')).toHaveValue('생성장수'); + await expect(page.getByLabel('성격')).toBeVisible(); + await expect(page.locator('.create-form').getByLabel('통솔')).toHaveValue('55'); + await expect(advanced).not.toHaveAttribute('open'); + await expect(page.getByText('전투 특기 선택')).toBeHidden(); + expect(state.mapRequests).toBe(0); + expect(state.generalRequests).toBe(0); + + const geometry = await flow.evaluate((element) => { + const basic = element.querySelector('.panel-card'); + const advancedOptions = element.querySelector('.advanced-options'); + const context = element.querySelectorAll('.panel-card')[1]; + const rect = element.getBoundingClientRect(); + return { + width: rect.width, + left: rect.left, + basicTop: basic?.getBoundingClientRect().top, + advancedTop: advancedOptions?.getBoundingClientRect().top, + contextTop: context?.getBoundingClientRect().top, + }; + }); + expect(geometry.width).toBe(1000); + expect(geometry.left).toBe(100); + expect(geometry.basicTop).toBeLessThan(geometry.advancedTop ?? 0); + expect(geometry.advancedTop).toBeLessThan(geometry.contextTop ?? 0); + await expect(basicPanel).toBeVisible(); + await expect(contextPanel).toBeVisible(); + + await advanced.locator('summary').click(); + await expect(advanced).toHaveAttribute('open'); + await expect(page.getByText('전투 특기 선택')).toBeVisible(); + await page.getByLabel('전투 특기 선택').selectOption('che_무쌍'); + await expect(advanced.locator('.advanced-point-summary')).toContainText('사용 10'); + + const mapTab = page.getByRole('tab', { name: '현재 지도' }); + await mapTab.click(); + await expect(mapTab).toHaveAttribute('aria-selected', 'true'); + await expect(page.locator('#context-panel-map .map-area')).toBeVisible(); + expect(state.mapRequests).toBe(2); + + const generalTab = page.getByRole('tab', { name: '장수 목록' }); + await generalTab.click(); + await expect(page.locator('.context-general-table tbody tr')).toHaveCount(3); + expect(state.generalRequests).toBe(1); + await page.getByPlaceholder('장수명 또는 국가 검색').fill('촉'); + await expect(page.locator('.context-general-table tbody tr')).toHaveCount(1); + await expect(page.locator('.context-general-table')).toContainText('유비'); + await generalTab.focus(); + await expect(generalTab).toBeFocused(); + + await page.screenshot({ path: testInfo.outputPath('join-layout-desktop.png'), fullPage: true }); +}); + +test('keeps the primary creation flow readable without horizontal overflow on mobile', async ({ page }, testInfo) => { + const state: FixtureState = { mapRequests: 0, generalRequests: 0 }; + await installFixture(page, state); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto('join'); + + await expect(page.getByRole('heading', { name: '장수 기본 정보' })).toBeVisible(); + const mobileGeometry = await page.evaluate(() => { + const flow = document.querySelector('.join-flow'); + const tabs = document.querySelector('.context-tabs'); + return { + viewportWidth: window.innerWidth, + documentWidth: document.documentElement.scrollWidth, + flowWidth: flow?.getBoundingClientRect().width, + tabsWidth: tabs?.getBoundingClientRect().width, + }; + }); + expect(mobileGeometry).toEqual({ + viewportWidth: 390, + documentWidth: 390, + flowWidth: 366, + tabsWidth: 352, + }); + await expect(page.locator('.advanced-options')).not.toHaveAttribute('open'); + await page.getByRole('tab', { name: '임관 권유' }).focus(); + await expect(page.getByRole('tab', { name: '임관 권유' })).toBeFocused(); + await page.screenshot({ path: testInfo.outputPath('join-layout-mobile.png'), fullPage: true }); +}); diff --git a/app/game-frontend/e2e/playwright.config.mjs b/app/game-frontend/e2e/playwright.config.mjs index 2f852a07..52eb8542 100644 --- a/app/game-frontend/e2e/playwright.config.mjs +++ b/app/game-frontend/e2e/playwright.config.mjs @@ -33,6 +33,7 @@ export default defineConfig({ 'mainNavigation.spec.ts', 'session-auth.spec.ts', 'npcPossession.spec.ts', + 'joinLayout.spec.ts', ], fullyParallel: false, workers: 1, diff --git a/app/game-frontend/package.json b/app/game-frontend/package.json index fb4a9907..faef7ed8 100644 --- a/app/game-frontend/package.json +++ b/app/game-frontend/package.json @@ -19,6 +19,7 @@ "test:e2e:battle-simulator": "playwright test battleSimulator.spec.ts --config e2e/playwright.config.mjs", "test:e2e:join-live": "playwright test --config e2e/joinGeneral.live.playwright.config.mjs --tsconfig e2e/playwright.live.tsconfig.json", "test:e2e:npc-possession": "playwright test npcPossession.spec.ts --config e2e/playwright.config.mjs", + "test:e2e:join-layout": "playwright test joinLayout.spec.ts --config e2e/playwright.config.mjs", "test:e2e:npc-possession-live": "pnpm --filter @sammo-ts/infra prisma:generate && pnpm --filter @sammo-ts/common build && pnpm --filter @sammo-ts/logic build && pnpm --filter @sammo-ts/infra build && pnpm --filter @sammo-ts/game-engine build && pnpm --filter @sammo-ts/game-api build && playwright test --config e2e/npcPossession.live.playwright.config.mjs --tsconfig e2e/playwright.live.tsconfig.json", "test:e2e:die-on-prestart-live": "pnpm --filter @sammo-ts/infra prisma:generate && pnpm --filter @sammo-ts/common build && pnpm --filter @sammo-ts/logic build && pnpm --filter @sammo-ts/infra build && pnpm --filter @sammo-ts/game-engine build && pnpm --filter @sammo-ts/game-api build && playwright test --config e2e/dieOnPrestart.live.playwright.config.mjs --tsconfig e2e/playwright.live.tsconfig.json", "lint": "eslint .", diff --git a/app/game-frontend/src/views/JoinView.vue b/app/game-frontend/src/views/JoinView.vue index 38010c31..fae3bf93 100644 --- a/app/game-frontend/src/views/JoinView.vue +++ b/app/game-frontend/src/views/JoinView.vue @@ -3,6 +3,7 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue'; import { RouterLink, useRoute, useRouter } from 'vue-router'; import PanelCard from '../components/ui/PanelCard.vue'; import SkeletonLines from '../components/ui/SkeletonLines.vue'; +import MapViewer from '../components/main/MapViewer.vue'; import { trpc } from '../utils/trpc'; import { useSessionStore } from '../stores/session'; import { cityLevelMap, formatOfficerLevelText, regionMap } from '../utils/nationFormat'; @@ -15,6 +16,9 @@ type JoinInput = Parameters[0]; type PossessReservation = Awaited>; type PossessCandidate = PossessReservation['candidates'][number]; type NpcGeneralList = Awaited>; +type PublicMap = Awaited>; +type MapLayout = Awaited>; +type PublicGeneral = Awaited>[number]; type NpcGeneralRow = NpcGeneralList['generals'][number] & { reservationState: 0 | 1 | 2; keepCount: number | null; @@ -45,6 +49,8 @@ const submitting = ref(false); const joinConfig = ref(null); const accountIcons = computed(() => joinConfig.value?.user.icons ?? []); const activeTab = ref<'create' | 'possess'>('create'); +const contextTab = ref<'invitation' | 'map' | 'generals'>('invitation'); +const inheritOpen = ref(false); const pendingJoinStorageKey = 'sammo-join-create-pending-action'; const pendingPossessStorageKey = 'sammo-npc-possess-pending-action'; @@ -178,6 +184,16 @@ const npcGeneralList = ref(null); const npcGeneralListLoading = ref(false); const npcGeneralListError = ref(''); const npcGeneralListVisibleCount = ref(50); +const publicMap = ref(null); +const mapLayout = ref(null); +const mapLoaded = ref(false); +const mapLoading = ref(false); +const mapError = ref(''); +const publicGenerals = ref([]); +const publicGeneralsLoaded = ref(false); +const publicGeneralsLoading = ref(false); +const publicGeneralsError = ref(''); +const publicGeneralFilter = ref(''); let npcTimer: number | null = null; const npcCandidates = computed(() => npcReservation.value?.candidates ?? []); @@ -225,6 +241,17 @@ const npcGeneralRows = computed(() => { ); }); const visibleNpcGeneralRows = computed(() => npcGeneralRows.value.slice(0, npcGeneralListVisibleCount.value)); +const filteredPublicGenerals = computed(() => { + const keyword = publicGeneralFilter.value.trim().toLocaleLowerCase('ko-KR'); + if (!keyword) { + return publicGenerals.value; + } + return publicGenerals.value.filter( + (general) => + general.name.toLocaleLowerCase('ko-KR').includes(keyword) || + general.nationName.toLocaleLowerCase('ko-KR').includes(keyword) + ); +}); const npcValidColor = computed(() => { const remaining = npcValidUntilMs.value - nowMs.value; if (remaining > 30_000) return '#ffffff'; @@ -542,12 +569,58 @@ const loadNpcGeneralList = async () => { } }; +const loadPublicMap = async () => { + if (mapLoading.value || mapLoaded.value) { + return; + } + mapLoading.value = true; + mapError.value = ''; + try { + const [map, layout] = await Promise.all([trpc.public.getCachedMap.query(), trpc.public.getMapLayout.query()]); + publicMap.value = map; + mapLayout.value = layout; + mapLoaded.value = true; + } catch (err) { + mapError.value = err instanceof Error ? err.message : 'public_map_failed'; + } finally { + mapLoading.value = false; + } +}; + +const loadPublicGenerals = async () => { + if (publicGeneralsLoading.value || publicGeneralsLoaded.value) { + return; + } + publicGeneralsLoading.value = true; + publicGeneralsError.value = ''; + try { + publicGenerals.value = await trpc.public.getGeneralList.query(); + publicGeneralsLoaded.value = true; + } catch (err) { + publicGeneralsError.value = err instanceof Error ? err.message : 'public_general_list_failed'; + } finally { + publicGeneralsLoading.value = false; + } +}; + +const onInheritanceToggle = (event: Event) => { + inheritOpen.value = (event.currentTarget as HTMLDetailsElement).open; +}; + watch(activeTab, (value) => { if (value === 'possess' && !npcReservation.value) { void loadNpcCandidates(false); } }); +watch(contextTab, (value) => { + if (value === 'map') { + void loadPublicMap(); + } else if (value === 'generals') { + void loadPublicGenerals(); + } +}); + onMounted(() => { npcTimer = window.setInterval(() => { nowMs.value = Date.now(); @@ -596,98 +669,105 @@ onUnmounted(() => { -
- -
국가 정보가 아직 준비되지 않았습니다.
-
-
-
{{ nation.name }}
-
- {{ nation.scoutMessage ?? '권유문 없음' }} -
-
-
-
- - -
- - -
- -
- - - -
- -
-
전용 아이콘 선택
- -
-
-
- - - - - -
- -
-
능력치 합계: {{ statTotal }} / {{ statRules?.total ?? '-' }}
-
-
{{ item }}
+
+ + +
-
-
- - -
+
+ + + + + +
+ +
+
전용 아이콘 선택
+ +
+ +
+
+ +
+
+ 능력치 합계: {{ statTotal }} / {{ statRules?.total ?? '-' }} +
+
+
{{ item }}
+
+
+ +
+ + +
+ - -
유산 포인트 정보를 불러오지 못했습니다.
-
+
+ + + 고급 옵션 · 유산 포인트 + 시작 특기·도시·턴 시간과 보너스 능력치를 지정합니다. + + + 보유 {{ inheritTotalPoint }} · 사용 {{ inheritRequiredPoint }} + + +
유산 포인트 정보를 불러오지 못했습니다.
+
보유 포인트: {{ inheritTotalPoint }}
필요 포인트: {{ inheritRequiredPoint }}
@@ -785,6 +865,122 @@ onUnmounted(() => {
{{ item }}
+
+ + +
+ + + +
+ +
+
국가 정보가 아직 준비되지 않았습니다.
+
+
+

{{ nation.name }}

+

{{ nation.scoutMessage ?? '권유문 없음' }}

+
+
+
+ +
+ + +
+ +
+
+ + 총 {{ filteredPublicGenerals.length }}명 +
+ + +
+ + + + + + + + + + + + + + + + + + + +
장수명국가통솔무력지력
+ NPC + {{ general.name }} + {{ general.nationName }}{{ general.leadership }}{{ general.strength }}{{ general.intelligence }}
+
표시할 장수가 없습니다.
+
+
@@ -1028,6 +1224,10 @@ onUnmounted(() => { gap: 16px; } +:global(#app:has(.join-page)) { + min-width: 320px; +} + .join-header { display: flex; flex-wrap: wrap; @@ -1076,10 +1276,12 @@ onUnmounted(() => { color: rgba(240, 150, 150, 0.9); } -.join-grid { - display: grid; +.join-flow { + width: min(100%, 1000px); + align-self: center; + display: flex; + flex-direction: column; gap: 16px; - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); } .nation-list { @@ -1090,28 +1292,41 @@ onUnmounted(() => { .nation-card { border: 1px solid rgba(201, 164, 90, 0.25); - padding: 8px; display: grid; grid-template-columns: 120px 1fr; - gap: 8px; + align-items: stretch; } .nation-name { + display: flex; + align-items: center; + justify-content: center; + margin: 0; font-weight: 600; - padding: 4px 6px; + padding: 8px 6px; color: #101010; text-align: center; } .nation-message { + margin: 0; + padding: 8px; font-size: 0.75rem; color: rgba(232, 221, 196, 0.7); } -.form-grid { +.create-form { + display: flex; + flex-direction: column; +} + +.identity-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 12px; + gap: 16px; + padding: 10px; + border: 1px solid rgba(201, 164, 90, 0.2); + background: rgba(0, 0, 0, 0.16); } .stat-grid { @@ -1128,6 +1343,12 @@ onUnmounted(() => { font-size: 0.75rem; } +.primary-field > span:first-child { + color: #f0d99e; + font-size: 0.85rem; + font-weight: 700; +} + .form-input { border: 1px solid rgba(201, 164, 90, 0.4); background: rgba(10, 10, 10, 0.8); @@ -1135,6 +1356,11 @@ onUnmounted(() => { color: inherit; } +.primary-field > .form-input { + min-height: 36px; + font-size: 1rem; +} + .stat-actions { display: flex; flex-wrap: wrap; @@ -1171,6 +1397,186 @@ onUnmounted(() => { font-size: 0.8rem; } +.form-actions .primary-action { + min-width: 150px; + border-color: rgba(226, 190, 112, 0.8); + background: rgba(116, 81, 29, 0.75); + color: #fff6dc; + font-weight: 700; +} + +.advanced-options { + border: 1px solid gray; + background-color: #302016; + background-image: var(--sammo-texture-walnut); +} + +.advanced-options > summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 8px 10px; + cursor: pointer; + list-style: none; + background-color: #14241b; + background-image: var(--sammo-texture-green); +} + +.advanced-options > summary::-webkit-details-marker { + display: none; +} + +.advanced-options > summary::before { + content: '+'; + flex: 0 0 auto; + color: #dcbf7a; + font-weight: 700; +} + +.advanced-options[open] > summary::before { + content: '-'; +} + +.advanced-title { + display: flex; + flex: 1; + flex-direction: column; + gap: 2px; +} + +.advanced-title small { + color: #ccc; + font-size: 0.7rem; + font-weight: 400; +} + +.advanced-point-summary { + color: #ead8ac; + font-size: 0.75rem; + white-space: nowrap; +} + +.advanced-body { + padding: 12px; + border-top: 1px solid gray; +} + +.context-tabs { + display: grid; + grid-template-columns: repeat(3, 1fr); + border-bottom: 1px solid rgba(201, 164, 90, 0.4); +} + +.context-tabs button { + min-height: 38px; + border: 0; + border-right: 1px solid rgba(201, 164, 90, 0.25); + color: rgba(232, 221, 196, 0.68); + font-size: 0.8rem; +} + +.context-tabs button:last-child { + border-right: 0; +} + +.context-tabs button.active { + background: rgba(201, 164, 90, 0.18); + color: #fff2cf; + box-shadow: inset 0 -2px #d3ad60; + font-weight: 700; +} + +.context-tabs button:focus-visible, +.advanced-options > summary:focus-visible, +.form-actions button:focus-visible { + outline: 2px solid #f0d58f; + outline-offset: -2px; +} + +.context-panel { + padding-top: 10px; +} + +.map-context { + overflow-x: auto; +} + +.general-list-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 8px; + color: rgba(232, 221, 196, 0.75); + font-size: 0.75rem; +} + +.general-list-head label { + flex: 1; +} + +.general-list-head input { + width: min(100%, 320px); +} + +.general-list-scroll { + max-height: 420px; + overflow: auto; +} + +.context-general-table { + width: 100%; + border-collapse: collapse; + font-size: 0.75rem; +} + +.context-general-table th, +.context-general-table td { + border: 1px solid rgba(201, 164, 90, 0.28); + padding: 6px 8px; + text-align: center; +} + +.context-general-table th { + position: sticky; + z-index: 1; + top: 0; + background: #14241b; +} + +.context-general-table td:first-child, +.context-general-table td:nth-child(2) { + text-align: left; +} + +.npc-badge { + margin-right: 4px; + padding: 1px 3px; + background: rgba(111, 74, 141, 0.8); + color: #fff; + font-size: 0.6rem; +} + +.context-error, +.empty-list { + padding: 12px; + color: rgba(240, 150, 150, 0.9); + text-align: center; + font-size: 0.75rem; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + .inherit-panel { display: flex; flex-direction: column; @@ -1414,4 +1820,59 @@ onUnmounted(() => { align-items: center; gap: 3px; } + +@media (max-width: 700px) { + .join-page { + padding: 12px; + } + + .join-header, + .join-tabs { + width: 100%; + } + + .join-tabs { + flex-wrap: wrap; + } + + .join-tabs > * { + flex: 1 1 auto; + text-align: center; + } + + .identity-grid { + grid-template-columns: 1fr; + } + + .stat-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + } + + .form-actions { + flex-direction: column; + } + + .form-actions button { + min-height: 40px; + } + + .advanced-options > summary { + align-items: flex-start; + gap: 8px; + } + + .advanced-point-summary { + white-space: normal; + text-align: right; + } + + .nation-card { + grid-template-columns: 90px 1fr; + } + + .context-general-table { + min-width: 520px; + } +}