From 3587befa2c40657abddba742d970a6ac9476ed79 Mon Sep 17 00:00:00 2001 From: hided62 Date: Mon, 17 Aug 2026 10:22:13 +0000 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20=EC=A0=84=ED=88=AC=20=EC=8B=9C?= =?UTF-8?q?=EB=AE=AC=EB=A0=88=EC=9D=B4=ED=84=B0=20=EA=B2=B0=EA=B3=BC=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=20=EC=95=88=EC=A0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-api/test/battleSimProcessor.test.ts | 3 ++ app/game-frontend/e2e/battleSimulator.spec.ts | 44 ++++++++++++++++++- .../src/views/BattleSimulatorView.vue | 25 +++++------ .../logic/src/battleSimulator/processor.ts | 1 + packages/logic/src/battleSimulator/types.ts | 2 + 5 files changed, 59 insertions(+), 16 deletions(-) diff --git a/app/game-api/test/battleSimProcessor.test.ts b/app/game-api/test/battleSimProcessor.test.ts index 233b3d51..d569422a 100644 --- a/app/game-api/test/battleSimProcessor.test.ts +++ b/app/game-api/test/battleSimProcessor.test.ts @@ -230,12 +230,14 @@ const buildPayload = (action: BattleSimJobPayload['action']): BattleSimJobPayloa describe('battle sim processor', () => { it('returns the fixed-seed battle summary instead of only a successful shape', () => { const payload = buildPayload('battle'); + payload.repeatCnt = 1000; const result = processBattleSimJob(payload); expect(result).toMatchObject({ result: true, reason: 'success', datetime: '2026-01-01 00:00:00', + repeatCnt: 1, avgWar: 1, phase: 2, killed: 626, @@ -277,6 +279,7 @@ describe('battle sim processor', () => { const second = processBattleSimJob(secondPayload); expect(first).toEqual(second); + expect(first.repeatCnt).toBe(2); expect(observedSeeds).toEqual(['server-repeat-0', 'server-repeat-1']); }); diff --git a/app/game-frontend/e2e/battleSimulator.spec.ts b/app/game-frontend/e2e/battleSimulator.spec.ts index ebd67c11..60b9d3ec 100644 --- a/app/game-frontend/e2e/battleSimulator.spec.ts +++ b/app/game-frontend/e2e/battleSimulator.spec.ts @@ -202,6 +202,7 @@ const importedGeneral = { type Fixture = { hasGeneral: boolean; failNextSimulation?: boolean; + prepareDelayMs?: number; requests: string[]; preparedPayloads: BattleSimJobPayload[]; serverResults: BattleSimResultPayload[]; @@ -256,6 +257,9 @@ const installApi = async (page: Page, fixture: Fixture) => { }, gameProfile); await page.route(gameTrpcRoute, async (route) => { const operations = operationNames(route); + if (fixture.prepareDelayMs && operations.includes('battle.prepareSimulation')) { + await new Promise((resolveDelay) => setTimeout(resolveDelay, fixture.prepareDelayMs)); + } const rawRequestBody: unknown = route.request().postData() ? route.request().postDataJSON() : {}; const requestBody = rawRequestBody && typeof rawRequestBody === 'object' ? (rawRequestBody as Record) : {}; @@ -446,8 +450,21 @@ test('keeps simulation available without a game general and preserves input afte await expect(page.getByText('시뮬레이터 입력 오류')).toBeVisible(); await expect(page.getByLabel('시드')).toHaveValue('keep-this-seed'); + fixture.prepareDelayMs = 1_500; await page.getByRole('button', { name: '전투', exact: true }).click(); - await expect(page.getByText('전투를 진행 중입니다.')).toHaveCount(0); + const progressToast = page.getByTestId('game-toast').filter({ hasText: '전투를 진행 중입니다.' }); + await expect(progressToast).toBeVisible(); + const progressToastRect = await progressToast.boundingBox(); + expect(progressToastRect?.x).toBeGreaterThanOrEqual(0); + expect((progressToastRect?.x ?? 0) + (progressToastRect?.width ?? 0)).toBeLessThanOrEqual(500); + if (artifactRoot) { + await page.screenshot({ + path: resolve(artifactRoot, 'battle-simulator-progress-mobile.png'), + fullPage: true, + animations: 'disabled', + }); + } + await expect(progressToast).toHaveCount(0); await expect(page.getByText('시뮬레이터 입력 오류')).toHaveCount(0); expect(await readBrowserWorkerResult(page, 0)).toEqual(fixture.serverResults[0]); @@ -468,6 +485,7 @@ test('runs 1000 battles in the Chromium worker and matches the Node processor ex test.setTimeout(60_000); const fixture: Fixture = { hasGeneral: false, + prepareDelayMs: 500, requests: [], preparedPayloads: [], serverResults: [], @@ -478,13 +496,35 @@ test('runs 1000 battles in the Chromium worker and matches the Node processor ex await page.getByLabel('반복 횟수').selectOption('1000'); await page.getByLabel('시드').fill(''); + const worldSettings = page.locator('[data-parity-id="world-settings"]'); + const worldSettingsTop = (await worldSettings.boundingBox())?.y; await page.getByRole('button', { name: '전투', exact: true }).click(); - await expect(page.getByText('전투를 진행 중입니다.')).toHaveCount(0, { timeout: 30_000 }); + + const progressToast = page.getByTestId('game-toast').filter({ hasText: '전투를 진행 중입니다.' }); + await expect(progressToast).toBeVisible(); + expect(await page.locator('.game-toast-viewport').evaluate((element) => getComputedStyle(element).position)).toBe( + 'fixed' + ); + expect((await worldSettings.boundingBox())?.y).toBe(worldSettingsTop); + if (artifactRoot) { + await page.screenshot({ + path: resolve(artifactRoot, 'battle-simulator-progress-desktop.png'), + fullPage: true, + animations: 'disabled', + }); + } + await expect(progressToast).toHaveCount(0, { timeout: 30_000 }); expect(fixture.preparedPayloads).toHaveLength(1); expect(fixture.preparedPayloads[0]?.seeds).toHaveLength(1000); expect(new Set(fixture.preparedPayloads[0]?.seeds).size).toBe(1000); expect(await readBrowserWorkerResult(page, 0)).toEqual(fixture.serverResults[0]); + const battleSummary = page.locator('[data-parity-id="battle-summary"]'); + await expect(battleSummary.locator('tr').filter({ hasText: '전투 횟수' }).locator('td')).toHaveText('1,000'); + await expect(battleSummary.locator('tr').filter({ hasText: '전투 일시' }).locator('td')).toHaveText( + /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/u + ); + expect(fixture.preparedPayloads[0]?.attackerGeneral.turntime).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/u); expect(fixture.requests).not.toContain('battle.simulate'); expect(fixture.requests).not.toContain('battle.getSimulation'); const workerUrls = await page.evaluate(() => { diff --git a/app/game-frontend/src/views/BattleSimulatorView.vue b/app/game-frontend/src/views/BattleSimulatorView.vue index 3f49c0cf..f42c6663 100644 --- a/app/game-frontend/src/views/BattleSimulatorView.vue +++ b/app/game-frontend/src/views/BattleSimulatorView.vue @@ -4,10 +4,12 @@ import type { BattleSimRequestPayload, BattleSimResultPayload } from '@sammo-ts/ import PanelCard from '../components/ui/PanelCard.vue'; import SkeletonLines from '../components/ui/SkeletonLines.vue'; import BattleGeneralCard from '../components/battle/BattleGeneralCard.vue'; +import { useGameFeedback } from '../composables/useGameFeedback'; import { trpc } from '../utils/trpc'; import { getNpcColor } from '../utils/npcColor'; import type { BattleSimOptions, GeneralDraft, InheritBuff } from '../utils/battleSimulatorTypes'; import { BattleSimulatorWorkerClient } from '../utils/battleSimulatorWorkerClient'; +import { formatSeoulDateTime } from '../utils/legacyDateTime'; type GeneralExport = Omit; @@ -67,8 +69,8 @@ const attackerGeneral = ref(null); const defenders = ref([]); const isSimulating = ref(false); -const statusMessage = ref(null); const simulationWorker = new BattleSimulatorWorkerClient(); +const { info: showInfoToast, dismissToast } = useGameFeedback(); onBeforeUnmount(() => { simulationWorker.dispose(); @@ -428,13 +430,6 @@ const normalizeGeneralExport = (raw: Record): GeneralExport => inheritBuff: normalizeInheritBuff(raw.inheritBuff), }); -const formatBattleTime = (date: Date): string => { - const pad = (value: number) => String(value).padStart(2, '0'); - return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad( - date.getMinutes() - )}`; -}; - const buildGeneralPayload = ( general: GeneralDraft, nationId: number, @@ -492,7 +487,7 @@ const buildBattlePayload = (action: BattleSimRequestPayload['action']): BattleSi if (!attackerGeneral.value) { throw new Error('attacker_general_missing'); } - const now = formatBattleTime(new Date()); + const now = formatSeoulDateTime(new Date()); const attackerNationPayload = { nation: 1, @@ -583,7 +578,10 @@ const runSimulation = async (action: BattleSimRequestPayload['action']) => { if (action === 'battle') { battleResult.value = null; } - statusMessage.value = action === 'battle' ? '전투를 진행 중입니다.' : '수비자 순서를 계산 중입니다.'; + const progressToastId = showInfoToast( + action === 'battle' ? '전투를 진행 중입니다.' : '수비자 순서를 계산 중입니다.', + 0 + ); try { const payload = buildBattlePayload(action); @@ -605,7 +603,7 @@ const runSimulation = async (action: BattleSimRequestPayload['action']) => { error.value = resolveErrorMessage(err); } finally { isSimulating.value = false; - statusMessage.value = null; + dismissToast(progressToastId); } }; @@ -952,8 +950,8 @@ const summaryRows = computed(() => { ]; } return [ - { label: '전투 일시', value: battleResult.value.datetime ?? '-' }, - { label: '전투 횟수', value: formatNumber(battleResult.value.avgWar) }, + { label: '전투 일시', value: formatSeoulDateTime(battleResult.value.datetime ?? '') || '-' }, + { label: '전투 횟수', value: formatNumber(battleResult.value.repeatCnt) }, { label: '전투 페이즈', value: formatNumber(battleResult.value.phase) }, { label: '준 피해', @@ -1001,7 +999,6 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
{{ error }}
-
{{ statusMessage }}
diff --git a/packages/logic/src/battleSimulator/processor.ts b/packages/logic/src/battleSimulator/processor.ts index 23e8d314..cdfa22e4 100644 --- a/packages/logic/src/battleSimulator/processor.ts +++ b/packages/logic/src/battleSimulator/processor.ts @@ -481,6 +481,7 @@ export const processBattleSimJob = ( result: true, reason: 'success', datetime: payload.attackerGeneral.turntime, + repeatCnt, lastWarLog: logBuckets, avgWar, phase: avgPhase, diff --git a/packages/logic/src/battleSimulator/types.ts b/packages/logic/src/battleSimulator/types.ts index deb11fac..3d45bc3e 100644 --- a/packages/logic/src/battleSimulator/types.ts +++ b/packages/logic/src/battleSimulator/types.ts @@ -121,6 +121,8 @@ export interface BattleSimResultPayload { result: boolean; reason: string; datetime?: string; + /** Number of battles actually evaluated. A fixed seed intentionally collapses a repeated request to one run. */ + repeatCnt?: number; lastWarLog?: BattleSimLogBuckets; avgWar?: number; phase?: number; From 23491877c1047752a966dfd22f20b5fca8045739 Mon Sep 17 00:00:00 2001 From: hided62 Date: Mon, 17 Aug 2026 10:28:36 +0000 Subject: [PATCH 2/3] =?UTF-8?q?fix(game-ui):=20=EA=B0=90=EC=B0=B0=EB=B6=80?= =?UTF-8?q?=20=EA=B8=B0=EB=A1=9D=20=EB=B0=B0=EA=B2=BD=EA=B3=BC=20=EB=AA=A8?= =?UTF-8?q?=EB=B0=94=EC=9D=BC=20=ED=91=9C=EC=8B=9C=EB=A5=BC=20=EB=B0=94?= =?UTF-8?q?=EB=A1=9C=EC=9E=A1=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 중복 하단 메타를 제거하고 최근 전투를 연도 없는 독립 행으로 넓힌다. 기록 본문에 walnut 질감을 적용하고 고정 높이 잘림을 없애 모바일 전투 결과까지 표시한다. production Chromium 검증이 실제 로그 유형과 배경·기하를 확인하도록 감찰부 fixture와 assertion도 보강한다. --- app/game-frontend/e2e/inGameMenus.spec.ts | 83 ++++++++++++++++--- .../src/views/BattleCenterView.vue | 47 +++++------ 2 files changed, 95 insertions(+), 35 deletions(-) diff --git a/app/game-frontend/e2e/inGameMenus.spec.ts b/app/game-frontend/e2e/inGameMenus.spec.ts index 05503f87..a7c616b8 100644 --- a/app/game-frontend/e2e/inGameMenus.spec.ts +++ b/app/game-frontend/e2e/inGameMenus.spec.ts @@ -327,9 +327,27 @@ const install = async (page: Page, state: FixtureState) => { const rawRequestBody: unknown = route.request().postData() ? route.request().postDataJSON() : {}; const requestBody = rawRequestBody && typeof rawRequestBody === 'object' ? (rawRequestBody as Record) : {}; + const queryInputText = new URL(route.request().url()).searchParams.get('input'); + let queryInput: Record = {}; + if (queryInputText) { + try { + const parsed: unknown = JSON.parse(queryInputText); + if (parsed && typeof parsed === 'object') { + queryInput = parsed as Record; + } + } catch { + queryInput = {}; + } + } const results = operations.map((operation, operationIndex) => { const rawPayload = - requestBody[String(operationIndex)] ?? (operations.length === 1 ? requestBody : undefined); + requestBody[String(operationIndex)] ?? + queryInput[String(operationIndex)] ?? + (operations.length === 1 + ? Object.keys(requestBody).length > 0 + ? requestBody + : queryInput + : undefined); const payload = rawPayload && typeof rawPayload === 'object' ? (rawPayload as TrpcRequestPayload) : undefined; const jsonInput = @@ -537,10 +555,12 @@ const install = async (page: Page, state: FixtureState) => { return response(battleCenter(state)); } if (operation === 'nation.getGeneralLog') { - const type = new URL(route.request().url()).searchParams.get('input')?.includes('generalAction') - ? 'generalAction' - : operation; - return response({ type, generalId: 7, logs: [{ id: 1, text: '감찰 기록' }] }); + const type = + typeof jsonInput.type === 'string' && + ['generalHistory', 'battleDetail', 'battleResult', 'generalAction'].includes(jsonInput.type) + ? jsonInput.type + : 'generalAction'; + return response({ type, generalId: 7, logs: [{ id: 1, text: `${type} 감찰 기록` }] }); } return response({ ok: true }); }); @@ -1310,7 +1330,9 @@ test('감찰부 keeps the selector interaction and shows the permission error pa await expect(page.locator('.selector-row select').nth(1)).toHaveValue('8'); await page.getByRole('button', { name: '다음 ▶' }).click(); await expect(page.locator('.selector-row select').nth(1)).toHaveValue('7'); - await expect(page.locator('.battle-general-name')).toContainText('검증장수 【 간의대부 | 건강 】'); + await expect(page.locator('.battle-general-name')).toContainText('검증장수'); + await expect(page.locator('.battle-general-name')).toContainText('간의대부'); + await expect(page.locator('.battle-general-name')).toContainText('건강'); await expect(page.locator('.battle-general-extra')).toContainText('계급29품관'); await expect(page.locator('.battle-general-card')).toContainText('병종보병'); await expect(page.locator('.battle-general-card')).not.toContainText('che_'); @@ -1321,6 +1343,11 @@ test('감찰부 keeps the selector interaction and shows the permission error pa expect(battleImages[1]?.backgroundImage).toContain('/game/crewtype1.png'); await expect(page.locator('.battle-general-card [role="progressbar"]')).toHaveCount(14); await expect(page.locator('.battle-general-card [aria-label*="1,275,975 (EX+)"]')).toHaveCount(5); + await expect(page.locator('.general-meta')).toHaveCount(0); + await expect(page.locator('.battle-general-extra__recent-value')).toHaveText('01-01 00:00'); + await expect(page.locator('.battle-general-extra__recent-value')).not.toContainText('2026'); + await expect(page.locator('.log-block')).toHaveCount(4); + await expect(page.locator('.log-block[data-log-type="battleResult"]')).toContainText('battleResult 감찰 기록'); expect( await page .locator('.battle-general-card [role="progressbar"]') @@ -1330,7 +1357,11 @@ test('감찰부 keeps the selector interaction and shows the permission error pa const geometry = await page.locator('.battle-page').evaluate((element) => { const selector = element.querySelector('.selector-row')!; const controls = [...selector.children].map((child) => (child as HTMLElement).getBoundingClientRect()); - const logBlock = element.querySelector('.log-block')!.getBoundingClientRect(); + const logBlocks = [...element.querySelectorAll('.log-block')]; + const logBlock = logBlocks[0]!.getBoundingClientRect(); + const recentLabel = element.querySelector('.battle-general-extra__recent-label')!; + const recentValue = element.querySelector('.battle-general-extra__recent-value')!; + const previousStat = recentLabel.previousElementSibling as HTMLElement; return { width: element.getBoundingClientRect().width, fontSize: getComputedStyle(element).fontSize, @@ -1341,6 +1372,11 @@ test('감찰부 keeps the selector interaction and shows the permission error pa backgroundImage: getComputedStyle(element).backgroundImage, generalBackgroundImage: getComputedStyle(element.querySelector('.battle-general-card')!) .backgroundImage, + logBackgroundImages: logBlocks.map((block) => getComputedStyle(block).backgroundImage), + recentLabelTop: recentLabel.getBoundingClientRect().top, + recentValueTop: recentValue.getBoundingClientRect().top, + recentValueWidth: recentValue.getBoundingClientRect().width, + previousStatTop: previousStat.getBoundingClientRect().top, }; }); expect(geometry.width).toBe(1000); @@ -1352,16 +1388,41 @@ test('감찰부 keeps the selector interaction and shows the permission error pa expect(geometry.logBlockWidth).toBeCloseTo(500, 0); expect(geometry.backgroundImage).toContain('back_walnut.jpg'); expect(geometry.generalBackgroundImage).toContain('back_blue.jpg'); + expect(geometry.logBackgroundImages).toHaveLength(4); + expect(geometry.logBackgroundImages.every((background) => background.includes('back_walnut.jpg'))).toBe(true); + expect(geometry.recentLabelTop).toBeCloseTo(geometry.recentValueTop, 0); + expect(geometry.recentLabelTop).toBeGreaterThan(geometry.previousStatTop); + expect(geometry.recentValueWidth).toBeGreaterThan(400); await persistParityArtifact(page, 'core-battle-center-desktop', geometry); await page.setViewportSize({ width: 500, height: 900 }); - const mobileGeometry = await page.locator('.selector-row').evaluate((element) => ({ - columns: getComputedStyle(element).gridTemplateColumns, - controlWidths: [...element.children].map((child) => (child as HTMLElement).getBoundingClientRect().width), - })); + const mobileGeometry = await page.locator('.battle-page').evaluate((element) => { + const selector = element.querySelector('.selector-row')!; + const battleResult = element.querySelector('.log-block[data-log-type="battleResult"]')!; + const footer = element.querySelector('.battle-footer')!; + const pageRect = element.getBoundingClientRect(); + const resultRect = battleResult.getBoundingClientRect(); + const footerRect = footer.getBoundingClientRect(); + return { + columns: getComputedStyle(selector).gridTemplateColumns, + controlWidths: [...selector.children].map((child) => (child as HTMLElement).getBoundingClientRect().width), + pageHeight: pageRect.height, + pageOverflow: getComputedStyle(element).overflow, + resultBottomWithinPage: resultRect.bottom <= pageRect.bottom, + footerAfterResult: footerRect.top >= resultRect.bottom, + resultBackgroundImage: getComputedStyle(battleResult).backgroundImage, + }; + }); expect(mobileGeometry.columns.split(' ')).toHaveLength(4); expect(mobileGeometry.controlWidths[0]).toBeCloseTo(83.33, 0); expect(mobileGeometry.controlWidths[1]).toBeCloseTo(125, 0); + expect(mobileGeometry.pageHeight).toBeGreaterThan(0); + expect(mobileGeometry.pageOverflow).toBe('visible'); + expect(mobileGeometry.resultBottomWithinPage).toBe(true); + expect(mobileGeometry.footerAfterResult).toBe(true); + expect(mobileGeometry.resultBackgroundImage).toContain('back_walnut.jpg'); + await page.locator('.log-block[data-log-type="battleResult"]').scrollIntoViewIfNeeded(); + await expect(page.locator('.log-block[data-log-type="battleResult"]')).toBeInViewport(); await persistParityArtifact(page, 'core-battle-center-mobile', mobileGeometry); await page.unrouteAll({ behavior: 'wait' }); diff --git a/app/game-frontend/src/views/BattleCenterView.vue b/app/game-frontend/src/views/BattleCenterView.vue index e2067bfa..759b5101 100644 --- a/app/game-frontend/src/views/BattleCenterView.vue +++ b/app/game-frontend/src/views/BattleCenterView.vue @@ -286,28 +286,26 @@ onMounted(() => { >{{ selectedGeneral.battleStats.killCrew.toLocaleString('ko-KR') }} 피살{{ selectedGeneral.battleStats.deathCrew.toLocaleString('ko-KR') }} - 최근 전투{{ selectedGeneral.recentWar || '-' }} + 최근 전투 + + {{ + formatServerDateTime(selectedGeneral.recentWar, { + format: 'monthDayTime', + fallback: '-', + }) + }} +
-
-
- 최근 턴: - {{ - formatServerDateTime(selectedGeneral.turnTime, { format: 'hourMinute', fallback: '-' }) - }} -
-
최근 전투: {{ selectedGeneral.recentWar || '-' }}
-
전투 횟수: {{ selectedGeneral.warnum }}
-
-
+
{{ logLabels[type] }}