feat: 시나리오 초기화 용어와 도움말 정합화

Ref의 NPC 상성, 확장 NPC, NPC 빙의와 자율행동 표현으로 Gateway 초기화 옵션을 통일한다. 접힌 고급 옵션에 접근 가능한 소형 도움말을 추가하고 desktop/mobile Chromium 회귀를 보강한다.
This commit is contained in:
2026-08-20 05:16:19 +00:00
parent fafe4909dc
commit e33478a7e0
5 changed files with 483 additions and 140 deletions
@@ -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"]'); 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<HTMLElement>('[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) => { test('edits server reset defaults through profile metadata settings', async ({ page }, testInfo) => {
const state: FixtureState = { operations: [], gatewayOperations: [], runtimeRunning: true, requestBodies: [] }; const state: FixtureState = { operations: [], gatewayOperations: [], runtimeRunning: true, requestBodies: [] };
await installFixture(page, state); await installFixture(page, state);
@@ -0,0 +1,45 @@
<script setup lang="ts">
import { useId } from 'vue';
const props = defineProps<{
label: string;
text: string;
testId?: string;
}>();
const tooltipId = useId();
</script>
<template>
<span class="group relative inline-flex shrink-0 align-middle">
<button
type="button"
class="inline-flex size-[18px] items-center justify-center rounded-full border border-zinc-600 bg-zinc-900 text-[11px] font-bold leading-none text-zinc-300 transition-colors duration-100 hover:border-sky-400 hover:text-sky-200 focus-visible:border-sky-400 focus-visible:text-sky-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-400"
:aria-label="`${props.label} 도움말`"
:aria-describedby="tooltipId"
:data-testid="props.testId"
>
?
</button>
<span
:id="tooltipId"
role="tooltip"
class="compact-help-tooltip pointer-events-none invisible absolute left-0 top-[calc(100%+0.4rem)] z-40 w-72 max-w-[calc(100vw-2rem)] rounded border border-zinc-600 bg-zinc-950 px-3 py-2 text-left text-xs font-normal leading-5 text-zinc-100 opacity-0 shadow-xl transition-opacity duration-100 group-hover:visible group-hover:opacity-100 group-focus-within:visible group-focus-within:opacity-100"
:data-testid="props.testId ? `${props.testId}-tooltip` : undefined"
>
{{ props.text }}
</span>
</span>
</template>
<style scoped>
@media (max-width: 639px) {
.compact-help-tooltip {
position: fixed;
inset: auto 1rem 1rem;
width: auto;
max-width: none;
z-index: 60;
}
}
</style>
@@ -11,6 +11,63 @@ export const RESET_AUTORUN_OPTIONS = [
export type ResetAutorunOption = (typeof RESET_AUTORUN_OPTIONS)[number]; 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 = { export type ProfileResetDefaults = {
turnTermMinutes: number; turnTermMinutes: number;
sync: boolean; sync: boolean;
+35 -41
View File
@@ -13,6 +13,8 @@ import { useToast } from '../composables/useToast';
import { import {
normalizeProfileResetDefaults, normalizeProfileResetDefaults,
PROFILE_TURN_TERM_MINUTES, PROFILE_TURN_TERM_MINUTES,
RESET_AUTORUN_LABELS,
RESET_OPTION_COPY,
type ProfileResetDefaults, type ProfileResetDefaults,
type ResetAutorunOption, type ResetAutorunOption,
} from '../utils/resetDefaults'; } 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<Record<string, string>>({}); const profileActionStatus = ref<Record<string, string>>({});
const profileActionSubmitting = ref<Record<string, boolean>>({}); const profileActionSubmitting = ref<Record<string, boolean>>({});
const visibleProfiles = computed(() => const visibleProfiles = computed(() =>
@@ -688,7 +681,7 @@ const ensureProfileBuffers = (profile: AdminProfile) => {
blockGeneralCreate: settings.blockGeneralCreate, blockGeneralCreate: settings.blockGeneralCreate,
autorunEnabled: settings.autorunUser !== null, autorunEnabled: settings.autorunUser !== null,
autorunUserMinutes: String(settings.autorunUser?.limitMinutes ?? 1440), 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.blockGeneralCreate = settings.blockGeneralCreate;
buffer.autorunEnabled = settings.autorunUser !== null; buffer.autorunEnabled = settings.autorunUser !== null;
buffer.autorunUserMinutes = String(settings.autorunUser?.limitMinutes ?? 1440); 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); const toLocalInputValue = (value: string): string => toServerDateTimeInputValue(value);
@@ -809,7 +802,7 @@ const updateProfileMeta = async (profileName: string) => {
) { ) {
profileActionStatus.value = { profileActionStatus.value = {
...profileActionStatus.value, ...profileActionStatus.value,
[profileName]: '유저 자동턴은 제한 시간과 한 개 이상의 동을 선택해야 합니다.', [profileName]: '자율행동은 유효 시간과 한 개 이상의 동을 선택해야 합니다.',
}; };
return; return;
} }
@@ -855,7 +848,7 @@ const ensureResetAutorun = (profileName: string) => {
if (!edit?.resetAutorunEnabled || edit.resetDefaults.autorunUser) return; if (!edit?.resetAutorunEnabled || edit.resetDefaults.autorunUser) return;
edit.resetDefaults.autorunUser = { edit.resetDefaults.autorunUser = {
limitMinutes: 1440, 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]: [profileName]:
!reason || reason.length < 3 !reason || reason.length < 3
? '현재 기수 설정 변경 사유를 입력하세요.' ? '현재 기수 설정 변경 사유를 입력하세요.'
: '턴 간격과 유저 자동턴 값을 확인하세요.', : '턴 시간과 자율행동 값을 확인하세요.',
}; };
profileActionSubmitting.value = { ...profileActionSubmitting.value, [profileName]: false }; profileActionSubmitting.value = { ...profileActionSubmitting.value, [profileName]: false };
return; return;
@@ -2247,7 +2240,7 @@ onMounted(() => {
</p> </p>
<div class="mt-3 grid gap-3 text-xs sm:grid-cols-2"> <div class="mt-3 grid gap-3 text-xs sm:grid-cols-2">
<label> <label>
턴 간격 {{ RESET_OPTION_COPY.turnTerm.label }}
<select <select
v-model.number=" v-model.number="
profileEdits[profile.profileName].resetDefaults.turnTermMinutes profileEdits[profile.profileName].resetDefaults.turnTermMinutes
@@ -2265,29 +2258,29 @@ onMounted(() => {
</select> </select>
</label> </label>
<label> <label>
가입 방식 {{ RESET_OPTION_COPY.joinMode.label }}
<select <select
v-model="profileEdits[profile.profileName].resetDefaults.joinMode" v-model="profileEdits[profile.profileName].resetDefaults.joinMode"
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-900 px-2 py-2" class="mt-1 w-full rounded border border-zinc-700 bg-zinc-900 px-2 py-2"
> >
<option value="full">전체</option> <option value="full">일반</option>
<option value="onlyRandom">랜덤</option> <option value="onlyRandom">랜덤 임관</option>
</select> </select>
</label> </label>
<label> <label>
가상 장수 {{ RESET_OPTION_COPY.fiction.label }}
<select <select
v-model.number=" v-model.number="
profileEdits[profile.profileName].resetDefaults.fiction profileEdits[profile.profileName].resetDefaults.fiction
" "
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-900 px-2 py-2" class="mt-1 w-full rounded border border-zinc-700 bg-zinc-900 px-2 py-2"
> >
<option :value="1">허용</option> <option :value="0">연의</option>
<option :value="0">금지</option> <option :value="1">가상</option>
</select> </select>
</label> </label>
<label> <label>
장수 생성 제한 {{ RESET_OPTION_COPY.blockGeneralCreate.label }}
<select <select
v-model.number=" v-model.number="
profileEdits[profile.profileName].resetDefaults profileEdits[profile.profileName].resetDefaults
@@ -2301,7 +2294,7 @@ onMounted(() => {
</select> </select>
</label> </label>
<label> <label>
NPC 모드 {{ RESET_OPTION_COPY.npcMode.label }}
<select <select
v-model.number=" v-model.number="
profileEdits[profile.profileName].resetDefaults.npcMode profileEdits[profile.profileName].resetDefaults.npcMode
@@ -2309,22 +2302,23 @@ onMounted(() => {
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-900 px-2 py-2" class="mt-1 w-full rounded border border-zinc-700 bg-zinc-900 px-2 py-2"
data-testid="meta-reset-npc-mode" data-testid="meta-reset-npc-mode"
> >
<option :value="0">기본</option> <option :value="0">불가</option>
<option :value="1">확장</option> <option :value="1">가능</option>
<option :value="2">전체</option> <option :value="2">선택 생성 가능</option>
</select> </select>
</label> </label>
<label> <label>
이미지 표시 {{ RESET_OPTION_COPY.showImgLevel.label }}
<select <select
v-model.number=" v-model.number="
profileEdits[profile.profileName].resetDefaults.showImgLevel profileEdits[profile.profileName].resetDefaults.showImgLevel
" "
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-900 px-2 py-2" class="mt-1 w-full rounded border border-zinc-700 bg-zinc-900 px-2 py-2"
> >
<option v-for="level in [0, 1, 2, 3]" :key="level" :value="level"> <option :value="0">안함</option>
{{ level }} <option :value="1">전콘</option>
</option> <option :value="2">전콘, 병종</option>
<option :value="3">전콘, 병종, NPC</option>
</select> </select>
</label> </label>
<label class="flex items-center gap-2"> <label class="flex items-center gap-2">
@@ -2332,14 +2326,14 @@ onMounted(() => {
v-model="profileEdits[profile.profileName].resetDefaults.sync" v-model="profileEdits[profile.profileName].resetDefaults.sync"
type="checkbox" type="checkbox"
/> />
동기화 사용 {{ RESET_OPTION_COPY.sync.label }} 사용
</label> </label>
<label class="flex items-center gap-2"> <label class="flex items-center gap-2">
<input <input
v-model="profileEdits[profile.profileName].resetDefaults.extend" v-model="profileEdits[profile.profileName].resetDefaults.extend"
type="checkbox" type="checkbox"
/> />
연장 사용 {{ RESET_OPTION_COPY.extend.label }} 포함
</label> </label>
<label class="flex items-center gap-2"> <label class="flex items-center gap-2">
<input <input
@@ -2348,7 +2342,7 @@ onMounted(() => {
" "
type="checkbox" type="checkbox"
/> />
토너먼트 사용 {{ RESET_OPTION_COPY.tournamentTrig.label }}
</label> </label>
<label class="flex items-center gap-2"> <label class="flex items-center gap-2">
<input <input
@@ -2356,7 +2350,7 @@ onMounted(() => {
type="checkbox" type="checkbox"
@change="ensureResetAutorun(profile.profileName)" @change="ensureResetAutorun(profile.profileName)"
/> />
유저 자동턴 {{ RESET_OPTION_COPY.autorun.label }}
</label> </label>
<template <template
v-if=" v-if="
@@ -2365,7 +2359,7 @@ onMounted(() => {
" "
> >
<label> <label>
자동턴 제한 분 {{ RESET_OPTION_COPY.autorunLimit.label }}
<input <input
v-model.number=" v-model.number="
profileEdits[profile.profileName].resetDefaults.autorunUser! profileEdits[profile.profileName].resetDefaults.autorunUser!
@@ -2379,7 +2373,7 @@ onMounted(() => {
</label> </label>
<div class="flex flex-wrap items-center gap-3 sm:col-span-2"> <div class="flex flex-wrap items-center gap-3 sm:col-span-2">
<label <label
v-for="option in resetAutorunLabels" v-for="option in RESET_AUTORUN_LABELS"
:key="option.value" :key="option.value"
class="flex items-center gap-1" class="flex items-center gap-1"
> >
@@ -2455,7 +2449,7 @@ onMounted(() => {
새로고침해 주세요. 새로고침해 주세요.
</p> </p>
<label class="block text-xs text-zinc-400"> <label class="block text-xs text-zinc-400">
턴 간격 {{ RESET_OPTION_COPY.turnTerm.label }}
<select <select
v-model.number="profileActions[profile.profileName].turnTermMinutes" v-model.number="profileActions[profile.profileName].turnTermMinutes"
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-900 px-2 py-2 text-sm text-white" class="mt-1 w-full rounded border border-zinc-700 bg-zinc-900 px-2 py-2 text-sm text-white"
@@ -2471,7 +2465,7 @@ onMounted(() => {
</select> </select>
</label> </label>
<label class="block text-xs text-zinc-400"> <label class="block text-xs text-zinc-400">
장수 생성 제한 {{ RESET_OPTION_COPY.blockGeneralCreate.label }}
<select <select
v-model.number="profileActions[profile.profileName].blockGeneralCreate" v-model.number="profileActions[profile.profileName].blockGeneralCreate"
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-900 px-2 py-2 text-sm text-white" class="mt-1 w-full rounded border border-zinc-700 bg-zinc-900 px-2 py-2 text-sm text-white"
@@ -2488,11 +2482,11 @@ onMounted(() => {
type="checkbox" type="checkbox"
data-testid="runtime-autorun-enabled" data-testid="runtime-autorun-enabled"
/> />
유저 자동턴 사용 {{ RESET_OPTION_COPY.autorun.label }} 사용
</label> </label>
<template v-if="profileActions[profile.profileName].autorunEnabled"> <template v-if="profileActions[profile.profileName].autorunEnabled">
<label class="block text-xs text-zinc-400"> <label class="block text-xs text-zinc-400">
자동턴 제한 분 {{ RESET_OPTION_COPY.autorunLimit.label }}
<input <input
v-model="profileActions[profile.profileName].autorunUserMinutes" v-model="profileActions[profile.profileName].autorunUserMinutes"
type="number" type="number"
@@ -2505,7 +2499,7 @@ onMounted(() => {
</label> </label>
<div class="flex flex-wrap items-center gap-3 text-xs text-zinc-300"> <div class="flex flex-wrap items-center gap-3 text-xs text-zinc-300">
<label <label
v-for="option in resetAutorunLabels" v-for="option in RESET_AUTORUN_LABELS"
:key="`runtime-${option.value}`" :key="`runtime-${option.value}`"
class="flex items-center gap-1" class="flex items-center gap-1"
> >
@@ -2,11 +2,14 @@
import { formatServerDateTime, serverDateTimeInputToIso } from '@sammo-ts/common'; import { formatServerDateTime, serverDateTimeInputToIso } from '@sammo-ts/common';
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'; import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import CompactHelp from '../components/CompactHelp.vue';
import ServerProfileTabs from '../components/ServerProfileTabs.vue'; import ServerProfileTabs from '../components/ServerProfileTabs.vue';
import { useToast } from '../composables/useToast'; import { useToast } from '../composables/useToast';
import AdminConsoleLayout from '../layouts/AdminConsoleLayout.vue'; import AdminConsoleLayout from '../layouts/AdminConsoleLayout.vue';
import { import {
normalizeProfileResetDefaults, normalizeProfileResetDefaults,
RESET_AUTORUN_LABELS,
RESET_OPTION_COPY,
SYSTEM_PROFILE_RESET_DEFAULTS, SYSTEM_PROFILE_RESET_DEFAULTS,
type ProfileResetDefaults, type ProfileResetDefaults,
type ResetAutorunOption, type ResetAutorunOption,
@@ -150,6 +153,15 @@ const form = reactive({
scheduledAt: '', scheduledAt: '',
reason: '', reason: '',
}); });
const RESET_AUTORUN_FORM_KEYS = {
develop: 'autorunDevelop',
warp: 'autorunWarp',
recruit: 'autorunRecruit',
recruit_high: 'autorunRecruitHigh',
train: 'autorunTrain',
battle: 'autorunBattle',
chief: 'autorunChief',
} as const satisfies Record<ResetAutorunOption, keyof typeof form>;
const gatewayForm = reactive({ const gatewayForm = reactive({
sourceMode: 'BRANCH' as 'BRANCH' | 'COMMIT', sourceMode: 'BRANCH' as 'BRANCH' | 'COMMIT',
sourceRef: 'main', sourceRef: 'main',
@@ -1037,11 +1049,19 @@ onBeforeUnmount(() => {
</option> </option>
</select> </select>
</label> </label>
<label class="text-xs text-zinc-400"> <div class="space-y-1 text-xs text-zinc-400">
간격 <div class="flex items-center gap-1.5">
<label for="reset-turn-term">{{ RESET_OPTION_COPY.turnTerm.label }}</label>
<CompactHelp
:label="RESET_OPTION_COPY.turnTerm.label"
:text="RESET_OPTION_COPY.turnTerm.help"
test-id="reset-help-turn-term"
/>
</div>
<select <select
id="reset-turn-term"
v-model.number="form.turnTermMinutes" v-model.number="form.turnTermMinutes"
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-sm text-white" class="w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-sm text-white"
data-testid="reset-turn-term" data-testid="reset-turn-term"
> >
<option <option
@@ -1052,7 +1072,7 @@ onBeforeUnmount(() => {
{{ minutes }} {{ minutes }}
</option> </option>
</select> </select>
</label> </div>
</div> </div>
<details v-if="mode === 'scenario'" class="rounded border border-zinc-800 bg-zinc-950/50 p-4"> <details v-if="mode === 'scenario'" class="rounded border border-zinc-800 bg-zinc-950/50 p-4">
@@ -1064,104 +1084,232 @@ onBeforeUnmount(() => {
: '서버별 기본값이 없어 시스템 기본값을 적용했습니다.' : '서버별 기본값이 없어 시스템 기본값을 적용했습니다.'
}} }}
</p> </p>
<div class="mt-4 grid gap-4 md:grid-cols-2 text-sm"> <div class="mt-4 grid gap-4 text-sm md:grid-cols-2">
<label <div class="space-y-1">
>동기화 <div class="flex items-center gap-1.5">
<select v-model="form.sync" class="ml-2 rounded bg-zinc-900 px-2 py-1"> <label for="reset-sync">{{ RESET_OPTION_COPY.sync.label }}</label>
<option :value="true">사용</option> <CompactHelp
<option :value="false">미사용</option> :label="RESET_OPTION_COPY.sync.label"
</select> :text="RESET_OPTION_COPY.sync.help"
</label> test-id="reset-help-sync"
<label />
>가상 장수 </div>
<select v-model.number="form.fiction" class="ml-2 rounded bg-zinc-900 px-2 py-1">
<option :value="1">허용</option>
<option :value="0">금지</option>
</select>
</label>
<label
>연장
<select v-model="form.extend" class="ml-2 rounded bg-zinc-900 px-2 py-1">
<option :value="true">사용</option>
<option :value="false">미사용</option>
</select>
</label>
<label
>가입 방식
<select v-model="form.joinMode" class="ml-2 rounded bg-zinc-900 px-2 py-1">
<option value="full">전체</option>
<option value="onlyRandom">랜덤만</option>
</select>
</label>
<label
>장수 생성 제한
<select <select
v-model.number="form.blockGeneralCreate" id="reset-sync"
class="ml-2 rounded bg-zinc-900 px-2 py-1" v-model="form.sync"
class="w-full rounded bg-zinc-900 px-2 py-1.5"
> >
<option :value="0">없음</option> <option :value="true">사용</option>
<option :value="1">제한</option> <option :value="false">미사용</option>
<option :value="2">차단</option>
</select> </select>
</label> </div>
<label <div class="space-y-1">
>NPC 모드 <div class="flex items-center gap-1.5">
<label for="reset-fiction">{{ RESET_OPTION_COPY.fiction.label }}</label>
<CompactHelp
:label="RESET_OPTION_COPY.fiction.label"
:text="RESET_OPTION_COPY.fiction.help"
test-id="reset-help-fiction"
/>
</div>
<select <select
id="reset-fiction"
v-model.number="form.fiction"
class="w-full rounded bg-zinc-900 px-2 py-1.5"
data-testid="reset-fiction"
>
<option :value="0">연의</option>
<option :value="1">가상</option>
</select>
</div>
<div class="space-y-1">
<div class="flex items-center gap-1.5">
<label for="reset-extend">{{ RESET_OPTION_COPY.extend.label }}</label>
<CompactHelp
:label="RESET_OPTION_COPY.extend.label"
:text="RESET_OPTION_COPY.extend.help"
test-id="reset-help-extend"
/>
</div>
<select
id="reset-extend"
v-model="form.extend"
class="w-full rounded bg-zinc-900 px-2 py-1.5"
data-testid="reset-extend"
>
<option :value="true">포함</option>
<option :value="false">미포함</option>
</select>
</div>
<div class="space-y-1">
<div class="flex items-center gap-1.5">
<label for="reset-block-general-create">
{{ RESET_OPTION_COPY.blockGeneralCreate.label }}
</label>
<CompactHelp
:label="RESET_OPTION_COPY.blockGeneralCreate.label"
:text="RESET_OPTION_COPY.blockGeneralCreate.help"
test-id="reset-help-block-general-create"
/>
</div>
<select
id="reset-block-general-create"
v-model.number="form.blockGeneralCreate"
class="w-full rounded bg-zinc-900 px-2 py-1.5"
data-testid="reset-block-general-create"
>
<option :value="0">가능</option>
<option :value="2">장수명 무작위</option>
<option :value="1">불가</option>
</select>
</div>
<div class="space-y-1">
<div class="flex items-center gap-1.5">
<label for="reset-npc-mode">{{ RESET_OPTION_COPY.npcMode.label }}</label>
<CompactHelp
:label="RESET_OPTION_COPY.npcMode.label"
:text="RESET_OPTION_COPY.npcMode.help"
test-id="reset-help-npc-mode"
/>
</div>
<select
id="reset-npc-mode"
v-model.number="form.npcMode" v-model.number="form.npcMode"
class="ml-2 rounded bg-zinc-900 px-2 py-1" class="w-full rounded bg-zinc-900 px-2 py-1.5"
data-testid="reset-npc-mode" data-testid="reset-npc-mode"
> >
<option :value="0">기본</option> <option :value="0">불가</option>
<option :value="1">확장</option> <option :value="1">가능</option>
<option :value="2">전체</option> <option :value="2">선택 생성 가능</option>
</select> </select>
</label>
<label
>이미지 표시
<select v-model.number="form.showImgLevel" class="ml-2 rounded bg-zinc-900 px-2 py-1">
<option v-for="level in [0, 1, 2, 3]" :key="level" :value="level">
{{ level }}
</option>
</select>
</label>
<label class="flex items-center gap-2">
<input v-model="form.tournamentTrig" type="checkbox" /> 토너먼트 사용
</label>
<label class="flex items-center gap-2">
<input v-model="form.autorunEnabled" type="checkbox" /> 유저 자동턴
</label>
<input
v-if="form.autorunEnabled"
v-model.number="form.autorunUserMinutes"
type="number"
min="1"
max="43200"
class="rounded border border-zinc-700 bg-zinc-900 px-2 py-1"
aria-label="자동턴 제한 "
/>
<div v-if="form.autorunEnabled" class="md:col-span-2 flex flex-wrap gap-3 text-xs">
<label><input v-model="form.autorunDevelop" type="checkbox" /> 내정</label>
<label><input v-model="form.autorunWarp" type="checkbox" /> 이동</label>
<label><input v-model="form.autorunRecruit" type="checkbox" /> 징병</label>
<label
><input
v-model="form.autorunRecruitHigh"
type="checkbox"
data-testid="reset-autorun-recruit-high"
/>
고급 징병</label
>
<label><input v-model="form.autorunTrain" type="checkbox" /> 훈련</label>
<label><input v-model="form.autorunBattle" type="checkbox" /> 전투</label>
<label
><input
v-model="form.autorunChief"
type="checkbox"
data-testid="reset-autorun-chief"
/>
중신</label
>
</div> </div>
<div class="space-y-1">
<div class="flex items-center gap-1.5">
<label for="reset-autorun-enabled">{{ RESET_OPTION_COPY.autorun.label }}</label>
<CompactHelp
:label="RESET_OPTION_COPY.autorun.label"
:text="RESET_OPTION_COPY.autorun.help"
test-id="reset-help-autorun"
/>
</div>
<select
id="reset-autorun-enabled"
v-model="form.autorunEnabled"
class="w-full rounded bg-zinc-900 px-2 py-1.5"
data-testid="reset-autorun-enabled"
>
<option :value="false">꺼짐</option>
<option :value="true">사용</option>
</select>
</div>
<div class="space-y-1">
<div class="flex items-center gap-1.5">
<label for="reset-join-mode">{{ RESET_OPTION_COPY.joinMode.label }}</label>
<CompactHelp
:label="RESET_OPTION_COPY.joinMode.label"
:text="RESET_OPTION_COPY.joinMode.help"
test-id="reset-help-join-mode"
/>
</div>
<select
id="reset-join-mode"
v-model="form.joinMode"
class="w-full rounded bg-zinc-900 px-2 py-1.5"
data-testid="reset-join-mode"
>
<option value="full">일반</option>
<option value="onlyRandom">랜덤 임관</option>
</select>
</div>
<div class="space-y-1">
<div class="flex items-center gap-1.5">
<label for="reset-show-img-level">{{ RESET_OPTION_COPY.showImgLevel.label }}</label>
<CompactHelp
:label="RESET_OPTION_COPY.showImgLevel.label"
:text="RESET_OPTION_COPY.showImgLevel.help"
test-id="reset-help-show-img-level"
/>
</div>
<select
id="reset-show-img-level"
v-model.number="form.showImgLevel"
class="w-full rounded bg-zinc-900 px-2 py-1.5"
data-testid="reset-show-img-level"
>
<option :value="0">안함</option>
<option :value="1">전콘</option>
<option :value="2">전콘, 병종</option>
<option :value="3">전콘, 병종, NPC</option>
</select>
</div>
<div class="space-y-1">
<div class="flex items-center gap-1.5">
<label for="reset-tournament-trig">
{{ RESET_OPTION_COPY.tournamentTrig.label }}
</label>
<CompactHelp
:label="RESET_OPTION_COPY.tournamentTrig.label"
:text="RESET_OPTION_COPY.tournamentTrig.help"
test-id="reset-help-tournament-trig"
/>
</div>
<select
id="reset-tournament-trig"
v-model="form.tournamentTrig"
class="w-full rounded bg-zinc-900 px-2 py-1.5"
data-testid="reset-tournament-trig"
>
<option :value="false">수동</option>
<option :value="true">자동</option>
</select>
</div>
<div v-if="form.autorunEnabled" class="space-y-1">
<div class="flex items-center gap-1.5">
<label for="reset-autorun-minutes">{{
RESET_OPTION_COPY.autorunLimit.label
}}</label>
<CompactHelp
:label="RESET_OPTION_COPY.autorunLimit.label"
:text="RESET_OPTION_COPY.autorunLimit.help"
test-id="reset-help-autorun-limit"
/>
</div>
<input
id="reset-autorun-minutes"
v-model.number="form.autorunUserMinutes"
type="number"
min="1"
max="43200"
class="w-full rounded border border-zinc-700 bg-zinc-900 px-2 py-1.5"
data-testid="reset-autorun-minutes"
/>
</div>
<fieldset
v-if="form.autorunEnabled"
class="space-y-2 rounded border border-zinc-800 p-3 md:col-span-2"
>
<legend class="px-1 text-xs text-zinc-400">자율행동 종류</legend>
<div class="flex flex-wrap gap-x-4 gap-y-2 text-xs">
<label
v-for="option in RESET_AUTORUN_LABELS"
:key="option.value"
class="flex items-center gap-1.5"
>
<input
v-model="form[RESET_AUTORUN_FORM_KEYS[option.value]]"
type="checkbox"
:data-testid="
option.value === 'recruit_high'
? 'reset-autorun-recruit-high'
: option.value === 'chief'
? 'reset-autorun-chief'
: undefined
"
/>
{{ option.label }}
</label>
</div>
</fieldset>
</div> </div>
</details> </details>
@@ -1230,9 +1378,9 @@ onBeforeUnmount(() => {
class="rounded border border-amber-800/80 bg-amber-950/30 px-4 py-3 text-sm text-amber-100" class="rounded border border-amber-800/80 bg-amber-950/30 px-4 py-3 text-sm text-amber-100"
data-testid="gateway-build-recovery-guide" data-testid="gateway-build-recovery-guide"
> >
<strong>빌드가 멈춘 경우:</strong> 로그의 마지막 단계가 build일 때만 <strong>빌드가 멈춘 경우:</strong> 로그의 마지막 단계가 build일 때만 <strong>빌드 중단</strong>
<strong>빌드 중단</strong> 누르고 CANCELLED를 확인한 <strong>재시도</strong>하세요. 누르고 CANCELLED를 확인한 <strong>재시도</strong>하세요. migration 또는 process 전환이 시작된
migration 또는 process 전환이 시작된 뒤에는 DB와 runtime 보호를 위해 중단할 없습니다. 뒤에는 DB와 runtime 보호를 위해 중단할 없습니다.
</div> </div>
<div class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between"> <div class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div> <div>
@@ -1541,9 +1689,9 @@ onBeforeUnmount(() => {
class="mb-4 rounded border border-amber-800/80 bg-amber-950/30 px-4 py-3 text-sm text-amber-100" class="mb-4 rounded border border-amber-800/80 bg-amber-950/30 px-4 py-3 text-sm text-amber-100"
data-testid="profile-build-recovery-guide" data-testid="profile-build-recovery-guide"
> >
<strong>DB 보존 업데이트 빌드가 멈춘 경우:</strong> <strong>빌드 중단</strong>을 누르고 <strong>DB 보존 업데이트 빌드가 멈춘 경우:</strong> <strong>빌드 중단</strong>을 누르고 CANCELLED를
CANCELLED를 확인한 뒤 <strong>재시도</strong>하세요. 기존 profile runtime과 게임 DB는 확인한 뒤 <strong>재시도</strong>하세요. 기존 profile runtime과 게임 DB는 유지됩니다.
유지됩니다. RESET·migration·process 전환 단계는 이 화면에서 강제 중단하지 않습니다. RESET·migration·process 전환 단계는 이 화면에서 강제 중단하지 않습니다.
</div> </div>
<div class="mb-4 flex items-center justify-between"> <div class="mb-4 flex items-center justify-between">
<h3 class="text-lg font-semibold">작업 이력</h3> <h3 class="text-lg font-semibold">작업 이력</h3>