From e33478a7e0b5d46df2bf5776d7886864ad6a6409 Mon Sep 17 00:00:00 2001
From: hided62
Date: Thu, 20 Aug 2026 05:16:19 +0000
Subject: [PATCH] =?UTF-8?q?feat:=20=EC=8B=9C=EB=82=98=EB=A6=AC=EC=98=A4=20?=
=?UTF-8?q?=EC=B4=88=EA=B8=B0=ED=99=94=20=EC=9A=A9=EC=96=B4=EC=99=80=20?=
=?UTF-8?q?=EB=8F=84=EC=9B=80=EB=A7=90=20=EC=A0=95=ED=95=A9=ED=99=94?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Ref의 NPC 상성, 확장 NPC, NPC 빙의와 자율행동 표현으로 Gateway 초기화 옵션을 통일한다. 접힌 고급 옵션에 접근 가능한 소형 도움말을 추가하고 desktop/mobile Chromium 회귀를 보강한다.
---
.../e2e/server-operations.spec.ts | 99 +++++
.../src/components/CompactHelp.vue | 45 +++
.../src/utils/resetDefaults.ts | 57 +++
app/gateway-frontend/src/views/AdminView.vue | 76 ++--
.../src/views/ServerOperationsView.vue | 346 +++++++++++++-----
5 files changed, 483 insertions(+), 140 deletions(-)
create mode 100644 app/gateway-frontend/src/components/CompactHelp.vue
diff --git a/app/gateway-frontend/e2e/server-operations.spec.ts b/app/gateway-frontend/e2e/server-operations.spec.ts
index fcdd3e87..86369af4 100644
--- a/app/gateway-frontend/e2e/server-operations.spec.ts
+++ b/app/gateway-frontend/e2e/server-operations.spec.ts
@@ -1010,6 +1010,105 @@ test('loads server metadata defaults into the reset form and submits them', asyn
expect(request).toContain('"options":["develop","recruit_high","chief"]');
});
+test('uses ref reset terms with compact hover, focus, and mobile help', async ({ page }, testInfo) => {
+ const state: FixtureState = {
+ operations: [],
+ gatewayOperations: [],
+ runtimeRunning: true,
+ requestBodies: [],
+ };
+ await installFixture(page, state);
+
+ await page.goto('admin/servers/che%3Adefault/scenario');
+ await page.getByText('고급 시나리오 옵션').click();
+
+ await expect(page.getByText('NPC 상성', { exact: true })).toBeVisible();
+ await expect(page.getByText('확장 NPC', { exact: true })).toBeVisible();
+ await expect(page.getByText('장수 임의 생성', { exact: true })).toBeVisible();
+ await expect(page.getByText('NPC 빙의', { exact: true })).toBeVisible();
+ await expect(page.getByText('자율행동', { exact: true })).toBeVisible();
+ await expect(page.getByText('임관 모드', { exact: true })).toBeVisible();
+ await expect(page.getByText('이미지 표기', { exact: true })).toBeVisible();
+ await expect(page.getByText('토너먼트 자동 시작', { exact: true })).toBeVisible();
+ await expect(page.getByTestId('reset-fiction').locator('option')).toHaveText(['연의', '가상']);
+ await expect(page.getByTestId('reset-extend').locator('option')).toHaveText(['포함', '미포함']);
+ await expect(page.getByTestId('reset-block-general-create').locator('option')).toHaveText([
+ '가능',
+ '장수명 무작위',
+ '불가',
+ ]);
+ await expect(page.getByTestId('reset-npc-mode').locator('option')).toHaveText(['불가', '가능', '선택 생성 가능']);
+ await expect(page.getByTestId('reset-show-img-level').locator('option')).toHaveText([
+ '안함',
+ '전콘',
+ '전콘, 병종',
+ '전콘, 병종, NPC',
+ ]);
+
+ const helpButtons = page.getByRole('button', { name: /도움말$/ });
+ await expect(helpButtons).toHaveCount(10);
+ const fictionHelp = page.getByTestId('reset-help-fiction');
+ const fictionTooltip = page.getByTestId('reset-help-fiction-tooltip');
+ await expect(fictionTooltip).toBeHidden();
+ await fictionHelp.hover();
+ await expect(fictionTooltip).toBeVisible();
+ await expect(fictionTooltip).toContainText('연의는 서버 정보에서 사실 모드로도 표시');
+ await expect.poll(() => fictionTooltip.evaluate((element) => getComputedStyle(element).opacity)).toBe('1');
+ const hoverMetrics = await fictionHelp.evaluate((element) => {
+ const buttonRect = element.getBoundingClientRect();
+ const tooltip = document.querySelector('[data-testid="reset-help-fiction-tooltip"]');
+ const tooltipRect = tooltip?.getBoundingClientRect();
+ const buttonStyle = getComputedStyle(element);
+ const tooltipStyle = tooltip ? getComputedStyle(tooltip) : null;
+ return {
+ button: { width: buttonRect.width, height: buttonRect.height },
+ tooltip: tooltipRect
+ ? { x: tooltipRect.x, y: tooltipRect.y, width: tooltipRect.width, height: tooltipRect.height }
+ : null,
+ buttonBorderRadius: buttonStyle.borderRadius,
+ tooltipVisibility: tooltipStyle?.visibility,
+ tooltipOpacity: tooltipStyle?.opacity,
+ };
+ });
+ expect(hoverMetrics.button).toEqual({ width: 18, height: 18 });
+ expect(hoverMetrics.tooltipVisibility).toBe('visible');
+ expect(hoverMetrics.tooltipOpacity).toBe('1');
+
+ await page.mouse.move(0, 0);
+ const npcHelp = page.getByTestId('reset-help-npc-mode');
+ await npcHelp.focus();
+ await expect(page.getByTestId('reset-help-npc-mode-tooltip')).toBeVisible();
+ await expect(npcHelp).toBeFocused();
+
+ await page.getByTestId('reset-autorun-enabled').selectOption({ label: '사용' });
+ await expect(page.getByText('자율행동 종류', { exact: true })).toBeVisible();
+ for (const action of ['내정', '순간이동', '징병', '모병', '훈사', '출병', '기본 사령턴']) {
+ await expect(page.getByText(action, { exact: true })).toBeVisible();
+ }
+
+ await page.setViewportSize({ width: 390, height: 844 });
+ const mobileHelp = page.getByTestId('reset-help-npc-mode');
+ await mobileHelp.focus();
+ const mobileMetrics = await page.getByTestId('reset-help-npc-mode-tooltip').evaluate((element) => {
+ const rect = element.getBoundingClientRect();
+ return {
+ x: rect.x,
+ right: rect.right,
+ width: rect.width,
+ viewportWidth: document.documentElement.clientWidth,
+ documentScrollWidth: document.documentElement.scrollWidth,
+ };
+ });
+ expect(mobileMetrics.x).toBeGreaterThanOrEqual(0);
+ expect(mobileMetrics.right).toBeLessThanOrEqual(mobileMetrics.viewportWidth);
+ expect(mobileMetrics.documentScrollWidth).toBeLessThanOrEqual(mobileMetrics.viewportWidth);
+ await page.screenshot({ path: testInfo.outputPath('mobile-reset-option-help.png'), fullPage: true });
+ await writeFile(
+ testInfo.outputPath('reset-option-help-metrics.json'),
+ JSON.stringify({ hoverMetrics, mobileMetrics }, null, 2)
+ );
+});
+
test('edits server reset defaults through profile metadata settings', async ({ page }, testInfo) => {
const state: FixtureState = { operations: [], gatewayOperations: [], runtimeRunning: true, requestBodies: [] };
await installFixture(page, state);
diff --git a/app/gateway-frontend/src/components/CompactHelp.vue b/app/gateway-frontend/src/components/CompactHelp.vue
new file mode 100644
index 00000000..e89e4040
--- /dev/null
+++ b/app/gateway-frontend/src/components/CompactHelp.vue
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+ {{ props.text }}
+
+
+
+
+
diff --git a/app/gateway-frontend/src/utils/resetDefaults.ts b/app/gateway-frontend/src/utils/resetDefaults.ts
index 1d855431..bd39095f 100644
--- a/app/gateway-frontend/src/utils/resetDefaults.ts
+++ b/app/gateway-frontend/src/utils/resetDefaults.ts
@@ -11,6 +11,63 @@ export const RESET_AUTORUN_OPTIONS = [
export type ResetAutorunOption = (typeof RESET_AUTORUN_OPTIONS)[number];
+export const RESET_AUTORUN_LABELS: ReadonlyArray<{ value: ResetAutorunOption; label: string }> = [
+ { value: 'develop', label: '내정' },
+ { value: 'warp', label: '순간이동' },
+ { value: 'recruit', label: '징병' },
+ { value: 'recruit_high', label: '모병' },
+ { value: 'train', label: '훈사' },
+ { value: 'battle', label: '출병' },
+ { value: 'chief', label: '기본 사령턴' },
+];
+
+export const RESET_OPTION_COPY = {
+ turnTerm: {
+ label: '턴 시간(분)',
+ help: '한 턴이 진행되는 실제 시간입니다. ref 초기화 화면과 같은 분 단위 값입니다.',
+ },
+ sync: {
+ label: '시간 동기화',
+ help: '실제 시각의 시간 단위에 게임의 년·월 경계를 맞춥니다. ref 기준 120분은 오전 1시, 60분은 오전·오후 1시에 1월이 시작됩니다.',
+ },
+ fiction: {
+ label: 'NPC 상성',
+ help: 'ref의 연의/가상 모드입니다. 연의는 서버 정보에서 사실 모드로도 표시됩니다. 가상 장수 생성 허용 여부를 뜻하는 옵션은 아닙니다.',
+ },
+ extend: {
+ label: '확장 NPC',
+ help: '시나리오에 별도로 정의된 확장 장수 목록을 초기 배치에 포함할지 정합니다. 게임 기간을 연장하는 옵션은 아닙니다.',
+ },
+ blockGeneralCreate: {
+ label: '장수 임의 생성',
+ help: '가능은 이름을 직접 정해 생성, 장수명 무작위는 생성은 허용하되 이름을 무작위로 지정, 불가는 일반 장수 생성을 막습니다.',
+ },
+ npcMode: {
+ label: 'NPC 빙의',
+ help: '불가는 빙의를 막고, 가능은 배치된 NPC에 빙의할 수 있게 합니다. 선택 생성 가능은 장수 풀에서 대상을 골라 생성하는 방식입니다.',
+ },
+ autorun: {
+ label: '자율행동',
+ help: 'ref 초기화 화면의 휴식 턴 시 장수 턴입니다. 설정한 유효 시간 동안 휴식 턴에 선택한 행동을 대신 수행합니다. 훈사는 훈련/사기진작을 뜻합니다.',
+ },
+ autorunLimit: {
+ label: '유효 시간(분)',
+ help: '자율행동을 사용할 수 있는 기간입니다. 43200분은 ref의 항상 유효에 해당합니다.',
+ },
+ joinMode: {
+ label: '임관 모드',
+ help: '일반은 지원되는 임관 방식을 허용하고, 랜덤 임관은 무작위 국가 임관만 허용합니다.',
+ },
+ showImgLevel: {
+ label: '이미지 표기',
+ help: '전콘은 장수 개인 아이콘입니다. 단계에 따라 전콘, 병종 이미지, NPC 이미지를 차례로 표시합니다.',
+ },
+ tournamentTrig: {
+ label: '토너먼트 자동 시작',
+ help: '자동은 기수 진행 중 토너먼트를 자동으로 시작하고, 수동은 자동 시작을 끕니다.',
+ },
+} as const;
+
export type ProfileResetDefaults = {
turnTermMinutes: number;
sync: boolean;
diff --git a/app/gateway-frontend/src/views/AdminView.vue b/app/gateway-frontend/src/views/AdminView.vue
index a78367ef..f1277e1f 100644
--- a/app/gateway-frontend/src/views/AdminView.vue
+++ b/app/gateway-frontend/src/views/AdminView.vue
@@ -13,6 +13,8 @@ import { useToast } from '../composables/useToast';
import {
normalizeProfileResetDefaults,
PROFILE_TURN_TERM_MINUTES,
+ RESET_AUTORUN_LABELS,
+ RESET_OPTION_COPY,
type ProfileResetDefaults,
type ResetAutorunOption,
} from '../utils/resetDefaults';
@@ -431,15 +433,6 @@ const profileActions = ref<
}
>
>({});
-const resetAutorunLabels: Array<{ value: ResetAutorunOption; label: string }> = [
- { value: 'develop', label: '내정' },
- { value: 'warp', label: '이동' },
- { value: 'recruit', label: '징병' },
- { value: 'recruit_high', label: '고급 징병' },
- { value: 'train', label: '훈련' },
- { value: 'battle', label: '전투' },
- { value: 'chief', label: '참모' },
-];
const profileActionStatus = ref>({});
const profileActionSubmitting = ref>({});
const visibleProfiles = computed(() =>
@@ -688,7 +681,7 @@ const ensureProfileBuffers = (profile: AdminProfile) => {
blockGeneralCreate: settings.blockGeneralCreate,
autorunEnabled: settings.autorunUser !== null,
autorunUserMinutes: String(settings.autorunUser?.limitMinutes ?? 1440),
- autorunOptions: settings.autorunUser?.options.slice() ?? resetAutorunLabels.map(({ value }) => value),
+ autorunOptions: settings.autorunUser?.options.slice() ?? RESET_AUTORUN_LABELS.map(({ value }) => value),
};
}
};
@@ -701,7 +694,7 @@ const syncRuntimeSettingsBuffer = (profile: AdminProfile): void => {
buffer.blockGeneralCreate = settings.blockGeneralCreate;
buffer.autorunEnabled = settings.autorunUser !== null;
buffer.autorunUserMinutes = String(settings.autorunUser?.limitMinutes ?? 1440);
- buffer.autorunOptions = settings.autorunUser?.options.slice() ?? resetAutorunLabels.map(({ value }) => value);
+ buffer.autorunOptions = settings.autorunUser?.options.slice() ?? RESET_AUTORUN_LABELS.map(({ value }) => value);
};
const toLocalInputValue = (value: string): string => toServerDateTimeInputValue(value);
@@ -809,7 +802,7 @@ const updateProfileMeta = async (profileName: string) => {
) {
profileActionStatus.value = {
...profileActionStatus.value,
- [profileName]: '유저 자동턴은 제한 시간과 한 개 이상의 동작을 선택해야 합니다.',
+ [profileName]: '자율행동은 유효 시간과 한 개 이상의 행동을 선택해야 합니다.',
};
return;
}
@@ -855,7 +848,7 @@ const ensureResetAutorun = (profileName: string) => {
if (!edit?.resetAutorunEnabled || edit.resetDefaults.autorunUser) return;
edit.resetDefaults.autorunUser = {
limitMinutes: 1440,
- options: resetAutorunLabels.map(({ value }) => value),
+ options: RESET_AUTORUN_LABELS.map(({ value }) => value),
};
};
@@ -896,7 +889,7 @@ const requestProfileAction = async (profileName: string, action: AdminAction) =>
[profileName]:
!reason || reason.length < 3
? '현재 기수 설정 변경 사유를 입력하세요.'
- : '턴 간격과 유저 자동턴 값을 확인하세요.',
+ : '턴 시간과 자율행동 값을 확인하세요.',
};
profileActionSubmitting.value = { ...profileActionSubmitting.value, [profileName]: false };
return;
@@ -2247,7 +2240,7 @@ onMounted(() => {