From 4e1058ae8645c1f912624e40ce16257a9d6b362c Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 2 Aug 2026 05:10:09 +0000 Subject: [PATCH] fix(frontend): restore chief center command UI parity --- .../chiefCenter.live.playwright.config.mjs | 22 + app/game-frontend/e2e/chiefCenterLive.spec.ts | 205 ++++++++ .../e2e/commandArguments.spec.ts | 20 +- .../e2e/playwright.live.tsconfig.json | 4 +- .../components/chief/ChiefCommandEditor.vue | 445 ++++++++++++++++++ .../src/components/chief/ChiefTurnCard.vue | 54 ++- .../src/components/main/CommandSelectForm.vue | 14 +- .../src/views/ChiefCenterView.vue | 375 ++++++++++----- 8 files changed, 1009 insertions(+), 130 deletions(-) create mode 100644 app/game-frontend/e2e/chiefCenter.live.playwright.config.mjs create mode 100644 app/game-frontend/e2e/chiefCenterLive.spec.ts create mode 100644 app/game-frontend/src/components/chief/ChiefCommandEditor.vue diff --git a/app/game-frontend/e2e/chiefCenter.live.playwright.config.mjs b/app/game-frontend/e2e/chiefCenter.live.playwright.config.mjs new file mode 100644 index 0000000..da9bc88 --- /dev/null +++ b/app/game-frontend/e2e/chiefCenter.live.playwright.config.mjs @@ -0,0 +1,22 @@ +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineConfig } from '@playwright/test'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); +const frontendUrl = process.env.CHIEF_CENTER_LIVE_FRONTEND_URL ?? 'http://127.0.0.1:15160/hwe/'; + +export default defineConfig({ + testDir: '.', + testMatch: ['chiefCenterLive.spec.ts'], + fullyParallel: false, + workers: 1, + timeout: 90_000, + expect: { timeout: 15_000 }, + reporter: [['list']], + outputDir: resolve(repositoryRoot, 'test-results/chief-center-live'), + use: { + baseURL: frontendUrl, + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + }, +}); diff --git a/app/game-frontend/e2e/chiefCenterLive.spec.ts b/app/game-frontend/e2e/chiefCenterLive.spec.ts new file mode 100644 index 0000000..4756114 --- /dev/null +++ b/app/game-frontend/e2e/chiefCenterLive.spec.ts @@ -0,0 +1,205 @@ +import { randomUUID } from 'node:crypto'; + +import { expect, test, type Browser, type Page } from '@playwright/test'; +import { encryptGameSessionToken } from '../../../packages/common/dist/auth/gameToken.js'; +import { createGamePostgresConnector } from '../../../packages/infra/dist/index.js'; + +const databaseUrl = process.env.CHIEF_CENTER_LIVE_DATABASE_URL; +const gameTokenSecret = process.env.CHIEF_CENTER_LIVE_GAME_SECRET; +const profile = process.env.PLAYWRIGHT_GAME_PROFILE ?? 'hwe:1010'; +const hasLiveFixture = Boolean(databaseUrl && gameTokenSecret); +const gameSchema = profile.split(':', 1)[0] ?? ''; + +const resolveGameDatabaseUrl = (): string => { + const parsed = new URL(databaseUrl!); + const sourceSchema = parsed.searchParams.get('schema'); + if (!gameSchema || (sourceSchema !== 'public' && sourceSchema !== gameSchema)) { + throw new Error(`Refusing unexpected chief-center schema: ${sourceSchema ?? '(missing)'}`); + } + parsed.searchParams.set('schema', gameSchema); + return parsed.toString(); +}; + +const installSession = async (page: Page, userId: string, displayName: string): Promise => { + const now = new Date(); + const token = encryptGameSessionToken( + { + version: 1, + profile, + issuedAt: now.toISOString(), + expiresAt: new Date(now.getTime() + 3_600_000).toISOString(), + sessionId: `chief-center-live-${randomUUID()}`, + user: { + id: userId, + username: userId, + displayName, + roles: ['user'], + canUseGeneralPicture: false, + }, + sanctions: {}, + identity: { + kakaoVerified: true, + canCreateGeneral: true, + requiresKakaoVerification: false, + graceEndsAt: null, + }, + }, + gameTokenSecret! + ); + await page.addInitScript( + ({ gameToken, gameProfile }) => { + localStorage.setItem('sammo-game-token', gameToken); + localStorage.setItem('sammo-game-profile', gameProfile); + }, + { gameToken: token, gameProfile: profile } + ); +}; + +const newPage = async (browser: Browser, userId: string, displayName: string): Promise => { + const context = await browser.newContext({ + viewport: { width: 1365, height: 900 }, + deviceScaleFactor: 1, + locale: 'ko-KR', + timezoneId: 'Asia/Seoul', + colorScheme: 'dark', + }); + const page = await context.newPage(); + await installSession(page, userId, displayName); + return page; +}; + +test('persists one chief command and exposes it to a normal nation user and another chief', async ({ + browser, +}, testInfo) => { + test.skip(!hasLiveFixture, 'isolated chief-center PostgreSQL and token secret are required'); + test.setTimeout(90_000); + + const connector = createGamePostgresConnector({ url: resolveGameDatabaseUrl() }); + await connector.connect(); + const db = connector.prisma; + const editor = await db.general.findFirstOrThrow({ where: { name: 'GUI비교관리자' } }); + const candidates = await db.general.findMany({ + where: { nationId: editor.nationId, userId: null, id: { not: editor.id } }, + orderBy: { id: 'asc' }, + take: 2, + }); + if (candidates.length !== 2) throw new Error('Two isolated visibility candidates are required.'); + const [viewer, otherChief] = candidates; + const viewerUserId = `chief-center-viewer-${randomUUID()}`; + const otherChiefUserId = `chief-center-peer-${randomUUID()}`; + const originalTurns = await db.nationTurn.findMany({ + where: { nationId: editor.nationId, officerLevel: editor.officerLevel }, + orderBy: { turnIdx: 'asc' }, + }); + const originalRevision = await db.nationTurnRevision.findUnique({ + where: { + nationId_officerLevel: { nationId: editor.nationId, officerLevel: editor.officerLevel }, + }, + }); + let selectedTargetId: number | undefined; + + try { + await db.$transaction([ + db.general.update({ + where: { id: viewer.id }, + data: { + userId: viewerUserId, + officerLevel: 1, + npcState: 0, + meta: { ...(viewer.meta as Record), belong: 999 }, + penalty: {}, + }, + }), + db.general.update({ + where: { id: otherChief.id }, + data: { userId: otherChiefUserId, officerLevel: 10, npcState: 0, penalty: {} }, + }), + ]); + + const editorPage = await newPage(browser, editor.userId!, '사령부입력자'); + await editorPage.goto('chief-center'); + await expect(editorPage.getByRole('heading', { name: '사령부', exact: true })).toBeVisible(); + await editorPage.getByRole('button', { name: '1턴 명령 입력', exact: true }).click(); + const picker = editorPage.getByTestId('chief-command-picker'); + await expect(picker).toBeVisible(); + await picker.getByRole('button', { name: '인사', exact: true }).click(); + const reward = picker.getByRole('button', { name: /포상/ }); + await expect(reward).toBeEnabled(); + await reward.click(); + const argumentForm = picker.getByTestId('command-argument-form'); + await argumentForm.getByRole('button', { name: '쌀', exact: true }).click(); + await argumentForm.locator('input[type=number]').fill('1'); + const selectableGeneralIds = await argumentForm + .locator('select option') + .evaluateAll((options) => + options + .map((option) => Number((option as HTMLOptionElement).value)) + .filter((value) => Number.isInteger(value) && value > 0) + ); + selectedTargetId = selectableGeneralIds.find((generalId) => generalId !== editor.id); + if (!selectedTargetId) throw new Error('No reward target is available in the live command table.'); + await argumentForm.locator('select').selectOption(String(selectedTargetId)); + await picker.getByRole('button', { name: '입력', exact: true }).click(); + await expect( + editorPage.getByTestId('chief-command-editor').locator('.editor-turn-row strong').first() + ).toHaveText('포상'); + + const persisted = await db.nationTurn.findUniqueOrThrow({ + where: { + nationId_officerLevel_turnIdx: { + nationId: editor.nationId, + officerLevel: editor.officerLevel, + turnIdx: 0, + }, + }, + }); + expect(persisted.actionCode).toBe('che_포상'); + expect(persisted.arg).toEqual({ isGold: false, amount: 1, destGeneralId: selectedTargetId }); + await editorPage.screenshot({ path: testInfo.outputPath('chief-editor-command-entered.png'), fullPage: true }); + + const viewerPage = await newPage(browser, viewerUserId, '일반국가원'); + await viewerPage.goto('chief-center'); + await expect(viewerPage.getByRole('heading', { name: '사령부', exact: true })).toBeVisible(); + await expect(viewerPage.getByTestId('chief-command-editor')).toHaveCount(0); + await expect(viewerPage.locator('.chief-grid-row').first().getByText('포상', { exact: true })).toBeVisible(); + await viewerPage.screenshot({ path: testInfo.outputPath('chief-normal-user-visible.png'), fullPage: true }); + + const peerPage = await newPage(browser, otherChiefUserId, '다른수뇌'); + await peerPage.goto('chief-center'); + await expect(peerPage.getByTestId('chief-command-editor')).toBeVisible(); + await expect(peerPage.locator('.chief-grid-row').first().getByText('포상', { exact: true })).toBeVisible(); + await peerPage.screenshot({ path: testInfo.outputPath('chief-peer-visible.png'), fullPage: true }); + } finally { + await db.$transaction(async (transaction) => { + await transaction.nationTurn.deleteMany({ + where: { nationId: editor.nationId, officerLevel: editor.officerLevel }, + }); + if (originalTurns.length) await transaction.nationTurn.createMany({ data: originalTurns }); + await transaction.nationTurnRevision.deleteMany({ + where: { nationId: editor.nationId, officerLevel: editor.officerLevel }, + }); + if (originalRevision) await transaction.nationTurnRevision.create({ data: originalRevision }); + await transaction.general.update({ + where: { id: viewer.id }, + data: { + userId: viewer.userId, + officerLevel: viewer.officerLevel, + npcState: viewer.npcState, + meta: viewer.meta, + penalty: viewer.penalty, + }, + }); + await transaction.general.update({ + where: { id: otherChief.id }, + data: { + userId: otherChief.userId, + officerLevel: otherChief.officerLevel, + npcState: otherChief.npcState, + meta: otherChief.meta, + penalty: otherChief.penalty, + }, + }); + }); + await connector.disconnect(); + } +}); diff --git a/app/game-frontend/e2e/commandArguments.spec.ts b/app/game-frontend/e2e/commandArguments.spec.ts index 6768d9c..95a7af7 100644 --- a/app/game-frontend/e2e/commandArguments.spec.ts +++ b/app/game-frontend/e2e/commandArguments.spec.ts @@ -211,6 +211,7 @@ const install = async (page: Page, rejectGeneral = false) => { requests.push(body); return response({ ok: true, + revision: 1, turns: [{ index: 0, action: 'che_포상', args: { isGold: false, amount: 300, destGeneralId: 2 } }], }); } @@ -281,7 +282,7 @@ test('keeps the entered command visible and reports a server validation error', }); test('keeps the shared main and chief shell geometry and interaction states', async ({ page }) => { - await install(page); + const requests = await install(page); await page.setViewportSize({ width: 1000, height: 900 }); await page.goto('/'); await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible(); @@ -329,10 +330,23 @@ test('keeps the shared main and chief shell geometry and interaction states', as await page.locator('.main-nation-menu').first().locator('[data-navigation-id="chief-center"]').click(); await expect(page).toHaveURL(/\/che\/chief-center$/); await expect(page.getByRole('heading', { name: '사령부', exact: true })).toBeVisible(); + await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click(); + await expect(page.getByTestId('chief-command-picker')).toBeVisible(); + await page.getByTestId('chief-command-picker').getByRole('button', { name: /포상/ }).click(); + const chiefArgumentForm = page.getByTestId('chief-command-picker').getByTestId('command-argument-form'); + await chiefArgumentForm.getByRole('button', { name: '쌀' }).click(); + await chiefArgumentForm.locator('input[type=number]').fill('300'); + await chiefArgumentForm.locator('select').selectOption('2'); + await page.getByTestId('chief-command-picker').getByRole('button', { name: '입력', exact: true }).click(); + await expect(page.getByTestId('chief-command-editor').locator('.editor-turn-row strong').first()).toHaveText( + '포상' + ); + expect(JSON.stringify(requests)).toContain('"action":"che_포상"'); + expect(JSON.stringify(requests)).toContain('"destGeneralId":2'); const chiefDesktop = await page.locator('.chief-page').evaluate((element) => ({ width: element.getBoundingClientRect().width, padding: getComputedStyle(element).padding, - headerWidth: element.querySelector('.game-shell__header')!.getBoundingClientRect().width, + headerWidth: element.querySelector('.chief-top')!.getBoundingClientRect().width, })); expect(chiefDesktop).toEqual({ width: 1000, padding: '0px', headerWidth: 1000 }); @@ -340,7 +354,7 @@ test('keeps the shared main and chief shell geometry and interaction states', as const chiefMobile = await page.locator('.chief-page').evaluate((element) => ({ width: element.getBoundingClientRect().width, padding: getComputedStyle(element).padding, - headerWidth: element.querySelector('.game-shell__header')!.getBoundingClientRect().width, + headerWidth: element.querySelector('.chief-top')!.getBoundingClientRect().width, })); expect(chiefMobile).toEqual({ width: 500, padding: '0px', headerWidth: 500 }); }); diff --git a/app/game-frontend/e2e/playwright.live.tsconfig.json b/app/game-frontend/e2e/playwright.live.tsconfig.json index acbae34..8c3eddf 100644 --- a/app/game-frontend/e2e/playwright.live.tsconfig.json +++ b/app/game-frontend/e2e/playwright.live.tsconfig.json @@ -16,6 +16,8 @@ "./npcPossessionLive.spec.ts", "./npcPossession.live.playwright.config.mjs", "./dieOnPrestartLive.spec.ts", - "./dieOnPrestart.live.playwright.config.mjs" + "./dieOnPrestart.live.playwright.config.mjs", + "./chiefCenterLive.spec.ts", + "./chiefCenter.live.playwright.config.mjs" ] } diff --git a/app/game-frontend/src/components/chief/ChiefCommandEditor.vue b/app/game-frontend/src/components/chief/ChiefCommandEditor.vue new file mode 100644 index 0000000..a452d4f --- /dev/null +++ b/app/game-frontend/src/components/chief/ChiefCommandEditor.vue @@ -0,0 +1,445 @@ + + + + + diff --git a/app/game-frontend/src/components/chief/ChiefTurnCard.vue b/app/game-frontend/src/components/chief/ChiefTurnCard.vue index 1be9216..cf72796 100644 --- a/app/game-frontend/src/components/chief/ChiefTurnCard.vue +++ b/app/game-frontend/src/components/chief/ChiefTurnCard.vue @@ -18,6 +18,7 @@ const props = defineProps<{ compact?: boolean; isMe?: boolean; clickable?: boolean; + turnTimeLabel?: string; }>(); const emit = defineEmits<{ @@ -40,13 +41,26 @@ const handleClick = () => { @click="handleClick" >
-
- {{ props.officerLevelText }} - - {{ props.name ?? '-' }} - -
- ME + +
@@ -72,7 +86,9 @@ const handleClick = () => { .chief-card.clickable { cursor: pointer; - transition: border-color 0.2s ease, box-shadow 0.2s ease; + transition: + border-color 0.2s ease, + box-shadow 0.2s ease; } .chief-card.clickable:hover { @@ -163,6 +179,28 @@ const handleClick = () => { font-size: 0.65rem; } +.compact-name, +.compact-meta { + display: grid; + place-items: center; + min-width: 0; + overflow: hidden; + white-space: nowrap; +} +.compact-meta { + grid-template-columns: 1fr 1fr; +} +.chief-card.compact .chief-header { + height: 72px; + grid-template-rows: 36px 36px; + display: grid; + padding: 0; +} +.chief-card.compact .chief-row { + height: 46px; + line-height: 46px; +} + .chief-card.compact .chief-level, .chief-card.compact .chief-name { font-size: 0.6rem; diff --git a/app/game-frontend/src/components/main/CommandSelectForm.vue b/app/game-frontend/src/components/main/CommandSelectForm.vue index 9015cfa..3228315 100644 --- a/app/game-frontend/src/components/main/CommandSelectForm.vue +++ b/app/game-frontend/src/components/main/CommandSelectForm.vue @@ -25,6 +25,7 @@ const props = defineProps<{ commandTable: CommandTable | null; loading: boolean; activeCategory?: string; + scope?: 'all' | 'general' | 'nation'; }>(); const emit = defineEmits<{ @@ -48,6 +49,10 @@ const categories = computed(() => { category: group.category, groupType: 'nation' as const, })); + if (props.scope === 'general') return general; + if (props.scope === 'nation') { + return nation.map((entry) => ({ ...entry, label: entry.category === '국가' ? '기타' : entry.category })); + } return [...general, ...nation]; }); @@ -58,7 +63,10 @@ const selectedGroup = computed(() => { } const [scope, ...categoryParts] = selectedCategory.value.split(':'); const category = categoryParts.join(':'); - return props.commandTable[scope === 'nation' ? 'nation' : 'general'].find((group) => group.category === category) ?? null; + return ( + props.commandTable[scope === 'nation' ? 'nation' : 'general'].find((group) => group.category === category) ?? + null + ); }); watch( @@ -109,9 +117,7 @@ const statusLabel = (command: CommandAvailability) => {
-
- 명령 목록을 불러오지 못했습니다. -
+
명령 목록을 불러오지 못했습니다.