merge: 최신 main을 감찰부 기록 화면 작업에 통합

This commit is contained in:
2026-08-17 10:30:10 +00:00
5 changed files with 59 additions and 16 deletions
@@ -230,12 +230,14 @@ const buildPayload = (action: BattleSimJobPayload['action']): BattleSimJobPayloa
describe('battle sim processor', () => { describe('battle sim processor', () => {
it('returns the fixed-seed battle summary instead of only a successful shape', () => { it('returns the fixed-seed battle summary instead of only a successful shape', () => {
const payload = buildPayload('battle'); const payload = buildPayload('battle');
payload.repeatCnt = 1000;
const result = processBattleSimJob(payload); const result = processBattleSimJob(payload);
expect(result).toMatchObject({ expect(result).toMatchObject({
result: true, result: true,
reason: 'success', reason: 'success',
datetime: '2026-01-01 00:00:00', datetime: '2026-01-01 00:00:00',
repeatCnt: 1,
avgWar: 1, avgWar: 1,
phase: 2, phase: 2,
killed: 626, killed: 626,
@@ -277,6 +279,7 @@ describe('battle sim processor', () => {
const second = processBattleSimJob(secondPayload); const second = processBattleSimJob(secondPayload);
expect(first).toEqual(second); expect(first).toEqual(second);
expect(first.repeatCnt).toBe(2);
expect(observedSeeds).toEqual(['server-repeat-0', 'server-repeat-1']); expect(observedSeeds).toEqual(['server-repeat-0', 'server-repeat-1']);
}); });
+42 -2
View File
@@ -202,6 +202,7 @@ const importedGeneral = {
type Fixture = { type Fixture = {
hasGeneral: boolean; hasGeneral: boolean;
failNextSimulation?: boolean; failNextSimulation?: boolean;
prepareDelayMs?: number;
requests: string[]; requests: string[];
preparedPayloads: BattleSimJobPayload[]; preparedPayloads: BattleSimJobPayload[];
serverResults: BattleSimResultPayload[]; serverResults: BattleSimResultPayload[];
@@ -256,6 +257,9 @@ const installApi = async (page: Page, fixture: Fixture) => {
}, gameProfile); }, gameProfile);
await page.route(gameTrpcRoute, async (route) => { await page.route(gameTrpcRoute, async (route) => {
const operations = operationNames(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 rawRequestBody: unknown = route.request().postData() ? route.request().postDataJSON() : {};
const requestBody = const requestBody =
rawRequestBody && typeof rawRequestBody === 'object' ? (rawRequestBody as Record<string, unknown>) : {}; rawRequestBody && typeof rawRequestBody === 'object' ? (rawRequestBody as Record<string, unknown>) : {};
@@ -446,8 +450,21 @@ test('keeps simulation available without a game general and preserves input afte
await expect(page.getByText('시뮬레이터 입력 오류')).toBeVisible(); await expect(page.getByText('시뮬레이터 입력 오류')).toBeVisible();
await expect(page.getByLabel('시드')).toHaveValue('keep-this-seed'); await expect(page.getByLabel('시드')).toHaveValue('keep-this-seed');
fixture.prepareDelayMs = 1_500;
await page.getByRole('button', { name: '전투', exact: true }).click(); 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); await expect(page.getByText('시뮬레이터 입력 오류')).toHaveCount(0);
expect(await readBrowserWorkerResult(page, 0)).toEqual(fixture.serverResults[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); test.setTimeout(60_000);
const fixture: Fixture = { const fixture: Fixture = {
hasGeneral: false, hasGeneral: false,
prepareDelayMs: 500,
requests: [], requests: [],
preparedPayloads: [], preparedPayloads: [],
serverResults: [], 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('반복 횟수').selectOption('1000');
await page.getByLabel('시드').fill(''); 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 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).toHaveLength(1);
expect(fixture.preparedPayloads[0]?.seeds).toHaveLength(1000); expect(fixture.preparedPayloads[0]?.seeds).toHaveLength(1000);
expect(new Set(fixture.preparedPayloads[0]?.seeds).size).toBe(1000); expect(new Set(fixture.preparedPayloads[0]?.seeds).size).toBe(1000);
expect(await readBrowserWorkerResult(page, 0)).toEqual(fixture.serverResults[0]); 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.simulate');
expect(fixture.requests).not.toContain('battle.getSimulation'); expect(fixture.requests).not.toContain('battle.getSimulation');
const workerUrls = await page.evaluate(() => { const workerUrls = await page.evaluate(() => {
@@ -4,10 +4,12 @@ import type { BattleSimRequestPayload, BattleSimResultPayload } from '@sammo-ts/
import PanelCard from '../components/ui/PanelCard.vue'; import PanelCard from '../components/ui/PanelCard.vue';
import SkeletonLines from '../components/ui/SkeletonLines.vue'; import SkeletonLines from '../components/ui/SkeletonLines.vue';
import BattleGeneralCard from '../components/battle/BattleGeneralCard.vue'; import BattleGeneralCard from '../components/battle/BattleGeneralCard.vue';
import { useGameFeedback } from '../composables/useGameFeedback';
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
import { getNpcColor } from '../utils/npcColor'; import { getNpcColor } from '../utils/npcColor';
import type { BattleSimOptions, GeneralDraft, InheritBuff } from '../utils/battleSimulatorTypes'; import type { BattleSimOptions, GeneralDraft, InheritBuff } from '../utils/battleSimulatorTypes';
import { BattleSimulatorWorkerClient } from '../utils/battleSimulatorWorkerClient'; import { BattleSimulatorWorkerClient } from '../utils/battleSimulatorWorkerClient';
import { formatSeoulDateTime } from '../utils/legacyDateTime';
type GeneralExport = Omit<GeneralDraft, 'id'>; type GeneralExport = Omit<GeneralDraft, 'id'>;
@@ -67,8 +69,8 @@ const attackerGeneral = ref<GeneralDraft | null>(null);
const defenders = ref<GeneralDraft[]>([]); const defenders = ref<GeneralDraft[]>([]);
const isSimulating = ref(false); const isSimulating = ref(false);
const statusMessage = ref<string | null>(null);
const simulationWorker = new BattleSimulatorWorkerClient(); const simulationWorker = new BattleSimulatorWorkerClient();
const { info: showInfoToast, dismissToast } = useGameFeedback();
onBeforeUnmount(() => { onBeforeUnmount(() => {
simulationWorker.dispose(); simulationWorker.dispose();
@@ -428,13 +430,6 @@ const normalizeGeneralExport = (raw: Record<string, unknown>): GeneralExport =>
inheritBuff: normalizeInheritBuff(raw.inheritBuff), 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 = ( const buildGeneralPayload = (
general: GeneralDraft, general: GeneralDraft,
nationId: number, nationId: number,
@@ -492,7 +487,7 @@ const buildBattlePayload = (action: BattleSimRequestPayload['action']): BattleSi
if (!attackerGeneral.value) { if (!attackerGeneral.value) {
throw new Error('attacker_general_missing'); throw new Error('attacker_general_missing');
} }
const now = formatBattleTime(new Date()); const now = formatSeoulDateTime(new Date());
const attackerNationPayload = { const attackerNationPayload = {
nation: 1, nation: 1,
@@ -583,7 +578,10 @@ const runSimulation = async (action: BattleSimRequestPayload['action']) => {
if (action === 'battle') { if (action === 'battle') {
battleResult.value = null; battleResult.value = null;
} }
statusMessage.value = action === 'battle' ? '전투를 진행 중입니다.' : '수비자 순서를 계산 중입니다.'; const progressToastId = showInfoToast(
action === 'battle' ? '전투를 진행 중입니다.' : '수비자 순서를 계산 중입니다.',
0
);
try { try {
const payload = buildBattlePayload(action); const payload = buildBattlePayload(action);
@@ -605,7 +603,7 @@ const runSimulation = async (action: BattleSimRequestPayload['action']) => {
error.value = resolveErrorMessage(err); error.value = resolveErrorMessage(err);
} finally { } finally {
isSimulating.value = false; isSimulating.value = false;
statusMessage.value = null; dismissToast(progressToastId);
} }
}; };
@@ -952,8 +950,8 @@ const summaryRows = computed(() => {
]; ];
} }
return [ return [
{ label: '전투 일시', value: battleResult.value.datetime ?? '-' }, { label: '전투 일시', value: formatSeoulDateTime(battleResult.value.datetime ?? '') || '-' },
{ label: '전투 횟수', value: formatNumber(battleResult.value.avgWar) }, { label: '전투 횟수', value: formatNumber(battleResult.value.repeatCnt) },
{ label: '전투 페이즈', value: formatNumber(battleResult.value.phase) }, { label: '전투 페이즈', value: formatNumber(battleResult.value.phase) },
{ {
label: '준 피해', label: '준 피해',
@@ -1001,7 +999,6 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
<button v-for="index in 5" :key="`legacy-button-${index}`" type="button"></button> <button v-for="index in 5" :key="`legacy-button-${index}`" type="button"></button>
</div> </div>
<div v-if="error" class="error">{{ error }}</div> <div v-if="error" class="error">{{ error }}</div>
<div v-if="statusMessage" class="status">{{ statusMessage }}</div>
<PanelCard data-parity-id="world-settings" title="전역 설정"> <PanelCard data-parity-id="world-settings" title="전역 설정">
<div v-if="loading"> <div v-if="loading">
@@ -481,6 +481,7 @@ export const processBattleSimJob = (
result: true, result: true,
reason: 'success', reason: 'success',
datetime: payload.attackerGeneral.turntime, datetime: payload.attackerGeneral.turntime,
repeatCnt,
lastWarLog: logBuckets, lastWarLog: logBuckets,
avgWar, avgWar,
phase: avgPhase, phase: avgPhase,
@@ -121,6 +121,8 @@ export interface BattleSimResultPayload {
result: boolean; result: boolean;
reason: string; reason: string;
datetime?: string; datetime?: string;
/** Number of battles actually evaluated. A fixed seed intentionally collapses a repeated request to one run. */
repeatCnt?: number;
lastWarLog?: BattleSimLogBuckets; lastWarLog?: BattleSimLogBuckets;
avgWar?: number; avgWar?: number;
phase?: number; phase?: number;