feat: add per-profile reset defaults

This commit is contained in:
2026-08-09 14:43:10 +00:00
parent 0484a84927
commit 74ef723c7e
8 changed files with 618 additions and 28 deletions
@@ -0,0 +1,74 @@
export const RESET_AUTORUN_OPTIONS = ['develop', 'warp', 'recruit', 'train', 'battle'] as const;
export type ResetAutorunOption = (typeof RESET_AUTORUN_OPTIONS)[number];
export type ProfileResetDefaults = {
turnTermMinutes: number;
sync: boolean;
fiction: 0 | 1;
extend: boolean;
blockGeneralCreate: 0 | 1 | 2;
npcMode: 0 | 1 | 2;
showImgLevel: 0 | 1 | 2 | 3;
tournamentTrig: boolean;
joinMode: 'full' | 'onlyRandom';
autorunUser: {
limitMinutes: number;
options: ResetAutorunOption[];
} | null;
};
export const SYSTEM_PROFILE_RESET_DEFAULTS: ProfileResetDefaults = {
turnTermMinutes: 60,
sync: true,
fiction: 1,
extend: true,
blockGeneralCreate: 0,
npcMode: 0,
showImgLevel: 3,
tournamentTrig: true,
joinMode: 'full',
autorunUser: null,
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value && typeof value === 'object' && !Array.isArray(value));
const enumNumber = <T extends number>(value: unknown, allowed: readonly T[], fallback: T): T =>
typeof value === 'number' && allowed.includes(value as T) ? (value as T) : fallback;
export const normalizeProfileResetDefaults = (value: unknown): ProfileResetDefaults => {
const raw = isRecord(value) ? value : {};
const rawAutorun = isRecord(raw.autorunUser) ? raw.autorunUser : null;
const autorunOptions = Array.isArray(rawAutorun?.options)
? rawAutorun.options.filter((option): option is ResetAutorunOption =>
RESET_AUTORUN_OPTIONS.includes(option as ResetAutorunOption)
)
: [];
const autorunLimit = rawAutorun?.limitMinutes;
return {
turnTermMinutes: enumNumber(raw.turnTermMinutes, [1, 2, 3, 4, 5, 6, 10, 12, 15, 20, 24, 30, 40, 60, 120], 60),
sync: typeof raw.sync === 'boolean' ? raw.sync : SYSTEM_PROFILE_RESET_DEFAULTS.sync,
fiction: enumNumber(raw.fiction, [0, 1], SYSTEM_PROFILE_RESET_DEFAULTS.fiction),
extend: typeof raw.extend === 'boolean' ? raw.extend : SYSTEM_PROFILE_RESET_DEFAULTS.extend,
blockGeneralCreate: enumNumber(
raw.blockGeneralCreate,
[0, 1, 2],
SYSTEM_PROFILE_RESET_DEFAULTS.blockGeneralCreate
),
npcMode: enumNumber(raw.npcMode, [0, 1, 2], SYSTEM_PROFILE_RESET_DEFAULTS.npcMode),
showImgLevel: enumNumber(raw.showImgLevel, [0, 1, 2, 3], SYSTEM_PROFILE_RESET_DEFAULTS.showImgLevel),
tournamentTrig:
typeof raw.tournamentTrig === 'boolean' ? raw.tournamentTrig : SYSTEM_PROFILE_RESET_DEFAULTS.tournamentTrig,
joinMode: raw.joinMode === 'onlyRandom' ? 'onlyRandom' : 'full',
autorunUser:
typeof autorunLimit === 'number' &&
Number.isInteger(autorunLimit) &&
autorunLimit > 0 &&
autorunLimit <= 43200 &&
autorunOptions.length > 0
? { limitMinutes: autorunLimit, options: autorunOptions }
: null,
};
};
@@ -2,6 +2,11 @@
import { computed, onMounted, ref } from 'vue';
import ServerProfileTabs from '../components/ServerProfileTabs.vue';
import AdminConsoleLayout from '../layouts/AdminConsoleLayout.vue';
import {
normalizeProfileResetDefaults,
type ProfileResetDefaults,
type ResetAutorunOption,
} from '../utils/resetDefaults';
import { trpc } from '../utils/trpc';
type AdminSection = 'users' | 'servers' | 'system' | 'audit';
@@ -325,6 +330,7 @@ type AdminClient = {
nextSeasonIdx?: number | null;
localAccountAccessGraceDays?: number | null;
localAccountGeneralCreationGraceDays?: number | null;
resetDefaults?: ProfileResetDefaults | null;
};
reason: string;
}) => Promise<AdminProfile | null>;
@@ -379,6 +385,8 @@ const profileEdits = ref<
nextSeasonIdx: string;
localAccountAccessGraceDays: string;
localAccountGeneralCreationGraceDays: string;
resetDefaults: ProfileResetDefaults;
resetAutorunEnabled: boolean;
reason: string;
}
>
@@ -393,6 +401,13 @@ const profileActions = ref<
}
>
>({});
const resetAutorunLabels: Array<{ value: ResetAutorunOption; label: string }> = [
{ value: 'develop', label: '내정' },
{ value: 'warp', label: '이동' },
{ value: 'recruit', label: '징병' },
{ value: 'train', label: '훈련' },
{ value: 'battle', label: '전투' },
];
const profileActionStatus = ref<Record<string, string>>({});
const profileActionSubmitting = ref<Record<string, boolean>>({});
const visibleProfiles = computed(() =>
@@ -543,6 +558,7 @@ const saveNotice = async () => {
const ensureProfileBuffers = (profile: AdminProfile) => {
if (!profileEdits.value[profile.profileName]) {
const meta = (profile.meta ?? {}) as Record<string, unknown>;
const resetDefaults = normalizeProfileResetDefaults(meta.resetDefaults);
profileEdits.value[profile.profileName] = {
korName: String(meta.korName ?? profile.profile),
color: String(meta.color ?? '#ffffff'),
@@ -560,6 +576,8 @@ const ensureProfileBuffers = (profile: AdminProfile) => {
typeof meta.localAccountGeneralCreationGraceDays === 'number'
? String(Math.floor(meta.localAccountGeneralCreationGraceDays))
: '',
resetDefaults,
resetAutorunEnabled: resetDefaults.autorunUser !== null,
reason: '',
};
}
@@ -663,6 +681,19 @@ const updateProfileMeta = async (profileName: string) => {
profileActionStatus.value = { ...profileActionStatus.value, [profileName]: '변경 사유를 입력하세요.' };
return;
}
if (
edit.resetAutorunEnabled &&
(!edit.resetDefaults.autorunUser ||
edit.resetDefaults.autorunUser.limitMinutes <= 0 ||
edit.resetDefaults.autorunUser.limitMinutes > 43200 ||
edit.resetDefaults.autorunUser.options.length === 0)
) {
profileActionStatus.value = {
...profileActionStatus.value,
[profileName]: '유저 자동턴은 제한 시간과 한 개 이상의 동작을 선택해야 합니다.',
};
return;
}
const patch = {
korName: edit.korName.trim() || null,
color: edit.color.trim() || null,
@@ -671,6 +702,10 @@ const updateProfileMeta = async (profileName: string) => {
nextSeasonIdx: nextSeasonIdx === null ? null : Math.floor(nextSeasonIdx),
localAccountAccessGraceDays: accessGraceDays,
localAccountGeneralCreationGraceDays: creationGraceDays,
resetDefaults: {
...edit.resetDefaults,
autorunUser: edit.resetAutorunEnabled ? edit.resetDefaults.autorunUser : null,
},
};
try {
const updated = await adminClient.profiles.updateMeta.mutate({
@@ -695,6 +730,15 @@ const updateProfileMeta = async (profileName: string) => {
}
};
const ensureResetAutorun = (profileName: string) => {
const edit = profileEdits.value[profileName];
if (!edit?.resetAutorunEnabled || edit.resetDefaults.autorunUser) return;
edit.resetDefaults.autorunUser = {
limitMinutes: 1440,
options: resetAutorunLabels.map(({ value }) => value),
};
};
const requestProfileAction = async (profileName: string, action: AdminAction) => {
if (profileActionSubmitting.value[profileName]) {
return;
@@ -2035,6 +2079,168 @@ onMounted(() => {
placeholder="예: 12"
/>
<div class="text-xs text-zinc-500">리셋 시 적용할 시즌 번호를 지정합니다.</div>
<details class="rounded border border-zinc-700 bg-zinc-950/60 p-3">
<summary class="cursor-pointer text-sm font-semibold text-zinc-200">
서버 리셋 기본 옵션
</summary>
<p class="mt-2 text-xs text-zinc-500">
이 서버의 시나리오 초기화 화면을 열 때 자동으로 채울 값을 저장합니다.
시나리오와 예약·오픈 시각은 실행할 때 선택합니다.
</p>
<div class="mt-3 grid gap-3 text-xs sm:grid-cols-2">
<label>
턴 간격
<select
v-model.number="
profileEdits[profile.profileName].resetDefaults.turnTermMinutes
"
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-900 px-2 py-2"
data-testid="meta-reset-turn-term"
>
<option
v-for="minutes in [
1, 2, 3, 4, 5, 6, 10, 12, 15, 20, 24, 30, 40, 60, 120,
]"
:key="minutes"
:value="minutes"
>
{{ minutes }}분
</option>
</select>
</label>
<label>
가입 방식
<select
v-model="profileEdits[profile.profileName].resetDefaults.joinMode"
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-900 px-2 py-2"
>
<option value="full">전체</option>
<option value="onlyRandom">랜덤만</option>
</select>
</label>
<label>
가상 장수
<select
v-model.number="
profileEdits[profile.profileName].resetDefaults.fiction
"
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>
</select>
</label>
<label>
장수 생성 제한
<select
v-model.number="
profileEdits[profile.profileName].resetDefaults
.blockGeneralCreate
"
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-900 px-2 py-2"
>
<option :value="0">없음</option>
<option :value="1">제한</option>
<option :value="2">차단</option>
</select>
</label>
<label>
NPC 모드
<select
v-model.number="
profileEdits[profile.profileName].resetDefaults.npcMode
"
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-900 px-2 py-2"
data-testid="meta-reset-npc-mode"
>
<option :value="0">기본</option>
<option :value="1">확장</option>
<option :value="2">전체</option>
</select>
</label>
<label>
이미지 표시
<select
v-model.number="
profileEdits[profile.profileName].resetDefaults.showImgLevel
"
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">
{{ level }}
</option>
</select>
</label>
<label class="flex items-center gap-2">
<input
v-model="profileEdits[profile.profileName].resetDefaults.sync"
type="checkbox"
/>
동기화 사용
</label>
<label class="flex items-center gap-2">
<input
v-model="profileEdits[profile.profileName].resetDefaults.extend"
type="checkbox"
/>
연장 사용
</label>
<label class="flex items-center gap-2">
<input
v-model="
profileEdits[profile.profileName].resetDefaults.tournamentTrig
"
type="checkbox"
/>
토너먼트 사용
</label>
<label class="flex items-center gap-2">
<input
v-model="profileEdits[profile.profileName].resetAutorunEnabled"
type="checkbox"
@change="ensureResetAutorun(profile.profileName)"
/>
유저 자동턴
</label>
<template
v-if="
profileEdits[profile.profileName].resetAutorunEnabled &&
profileEdits[profile.profileName].resetDefaults.autorunUser
"
>
<label>
자동턴 제한 분
<input
v-model.number="
profileEdits[profile.profileName].resetDefaults.autorunUser!
.limitMinutes
"
type="number"
min="1"
max="43200"
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-900 px-2 py-2"
/>
</label>
<div class="flex flex-wrap items-center gap-3 sm:col-span-2">
<label
v-for="option in resetAutorunLabels"
:key="option.value"
class="flex items-center gap-1"
>
<input
v-model="
profileEdits[profile.profileName].resetDefaults
.autorunUser!.options
"
type="checkbox"
:value="option.value"
/>
{{ option.label }}
</label>
</div>
</template>
</div>
</details>
<label class="text-xs text-zinc-400">Kakao 미인증 접근 유예일</label>
<input
v-model="profileEdits[profile.profileName].localAccountAccessGraceDays"
@@ -3,6 +3,11 @@ import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch }
import ServerProfileTabs from '../components/ServerProfileTabs.vue';
import AdminConsoleLayout from '../layouts/AdminConsoleLayout.vue';
import {
normalizeProfileResetDefaults,
SYSTEM_PROFILE_RESET_DEFAULTS,
type ProfileResetDefaults,
} from '../utils/resetDefaults';
import { directTrpc, trpc } from '../utils/trpc';
type OperationPageMode = 'version' | 'scenario' | 'gateway';
@@ -90,6 +95,7 @@ const catalogAttempted = ref(false);
const submitting = ref(false);
const message = ref('');
const errorMessage = ref('');
const resetDefaultsSource = ref<'SYSTEM' | 'PROFILE'>('SYSTEM');
let pollTimer: ReturnType<typeof setInterval> | undefined;
let stateRequestInFlight = false;
let releaseLogLoopGeneration = 0;
@@ -99,15 +105,15 @@ const form = reactive({
sourceMode: (props.mode === 'scenario' ? 'CURRENT' : 'BRANCH') as 'CURRENT' | 'BRANCH' | 'COMMIT',
sourceRef: 'main',
scenarioId: null as number | null,
turnTermMinutes: 60,
sync: true,
fiction: 1,
extend: true,
blockGeneralCreate: 0,
npcMode: 0,
showImgLevel: 3,
tournamentTrig: true,
joinMode: 'full' as 'full' | 'onlyRandom',
turnTermMinutes: SYSTEM_PROFILE_RESET_DEFAULTS.turnTermMinutes,
sync: SYSTEM_PROFILE_RESET_DEFAULTS.sync,
fiction: SYSTEM_PROFILE_RESET_DEFAULTS.fiction,
extend: SYSTEM_PROFILE_RESET_DEFAULTS.extend,
blockGeneralCreate: SYSTEM_PROFILE_RESET_DEFAULTS.blockGeneralCreate,
npcMode: SYSTEM_PROFILE_RESET_DEFAULTS.npcMode,
showImgLevel: SYSTEM_PROFILE_RESET_DEFAULTS.showImgLevel,
tournamentTrig: SYSTEM_PROFILE_RESET_DEFAULTS.tournamentTrig,
joinMode: SYSTEM_PROFILE_RESET_DEFAULTS.joinMode,
autorunEnabled: false,
autorunUserMinutes: 1440,
autorunDevelop: true,
@@ -177,7 +183,12 @@ const toIso = (value: string): string | undefined => {
const formatTime = (value?: string): string => (value ? new Date(value).toLocaleString('ko-KR') : '-');
const formatLogTime = (value: string): string =>
new Date(value).toLocaleTimeString('ko-KR', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
new Date(value).toLocaleTimeString('ko-KR', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
const shortSha = (value?: string): string => (value ? value.slice(0, 12) : '-');
const clearStatus = () => {
@@ -185,6 +196,40 @@ const clearStatus = () => {
errorMessage.value = '';
};
const applyResetDefaults = (defaults: ProfileResetDefaults) => {
form.turnTermMinutes = defaults.turnTermMinutes;
form.sync = defaults.sync;
form.fiction = defaults.fiction;
form.extend = defaults.extend;
form.blockGeneralCreate = defaults.blockGeneralCreate;
form.npcMode = defaults.npcMode;
form.showImgLevel = defaults.showImgLevel;
form.tournamentTrig = defaults.tournamentTrig;
form.joinMode = defaults.joinMode;
form.autorunEnabled = defaults.autorunUser !== null;
form.autorunUserMinutes = defaults.autorunUser?.limitMinutes ?? 1440;
const autorunOptions = new Set(defaults.autorunUser?.options ?? []);
form.autorunDevelop = autorunOptions.has('develop');
form.autorunWarp = autorunOptions.has('warp');
form.autorunRecruit = autorunOptions.has('recruit');
form.autorunTrain = autorunOptions.has('train');
form.autorunBattle = autorunOptions.has('battle');
};
const loadResetDefaults = async () => {
if (props.mode !== 'scenario' || !selectedProfileName.value) return;
try {
const result = await adminClient.profiles.getResetDefaults.query({
profileName: selectedProfileName.value,
});
applyResetDefaults(normalizeProfileResetDefaults(result.defaults));
resetDefaultsSource.value = result.source;
} catch {
applyResetDefaults(SYSTEM_PROFILE_RESET_DEFAULTS);
resetDefaultsSource.value = 'SYSTEM';
}
};
const loadCapabilities = async () => {
try {
capabilities.value = (await adminClient.capabilities.list.query()) as typeof capabilities.value;
@@ -244,7 +289,11 @@ const scrollReleaseLogToEnd = async () => {
};
const pollGatewayReleaseLogs = async (operationId: string, generation: number) => {
while (componentMounted && generation === releaseLogLoopGeneration && selectedGatewayOperationId.value === operationId) {
while (
componentMounted &&
generation === releaseLogLoopGeneration &&
selectedGatewayOperationId.value === operationId
) {
try {
const result = await adminClient.releases.logs.query({
id: operationId,
@@ -516,6 +565,7 @@ onMounted(async () => {
await Promise.all([
loadCapabilities(),
loadState(),
loadResetDefaults(),
props.mode === 'scenario' ? loadScenarios() : Promise.resolve(),
]);
pollTimer = setInterval(() => void loadState(true), 3000);
@@ -671,9 +721,10 @@ onBeforeUnmount(() => {
<select
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"
data-testid="reset-turn-term"
>
<option
v-for="minutes in [1, 2, 5, 10, 20, 30, 60, 120]"
v-for="minutes in [1, 2, 3, 4, 5, 6, 10, 12, 15, 20, 24, 30, 40, 60, 120]"
:key="minutes"
:value="minutes"
>
@@ -685,6 +736,13 @@ onBeforeUnmount(() => {
<details v-if="mode === 'scenario'" class="rounded border border-zinc-800 bg-zinc-950/50 p-4">
<summary class="cursor-pointer text-sm font-semibold">고급 시나리오 옵션</summary>
<p class="mt-2 text-xs text-zinc-500" data-testid="reset-defaults-source">
{{
resetDefaultsSource === 'PROFILE'
? '이 서버의 메타에 저장된 기본값을 적용했습니다.'
: '서버별 기본값이 없어 시스템 기본값을 적용했습니다.'
}}
</p>
<div class="mt-4 grid gap-4 md:grid-cols-2 text-sm">
<label
>동기화
@@ -727,7 +785,11 @@ onBeforeUnmount(() => {
</label>
<label
>NPC 모드
<select v-model.number="form.npcMode" class="ml-2 rounded bg-zinc-900 px-2 py-1">
<select
v-model.number="form.npcMode"
class="ml-2 rounded bg-zinc-900 px-2 py-1"
data-testid="reset-npc-mode"
>
<option :value="0">기본</option>
<option :value="1">확장</option>
<option :value="2">전체</option>
@@ -936,7 +998,13 @@ onBeforeUnmount(() => {
<div
v-for="entry in gatewayReleaseLogs"
:key="entry.cursor"
:class="entry.level === 'ERROR' ? 'text-red-300' : entry.level === 'OUTPUT' ? 'text-zinc-300' : 'text-cyan-300'"
:class="
entry.level === 'ERROR'
? 'text-red-300'
: entry.level === 'OUTPUT'
? 'text-zinc-300'
: 'text-cyan-300'
"
>
<span class="text-zinc-600">{{ formatLogTime(entry.createdAt) }}</span>
<span class="ml-2 text-violet-300">[{{ entry.phase }}]</span>
@@ -973,7 +1041,11 @@ onBeforeUnmount(() => {
<button
type="button"
class="rounded border border-zinc-700 px-2 py-1 text-zinc-300 hover:bg-zinc-800"
:class="operation.id === selectedGatewayOperationId ? 'border-violet-500 text-violet-200' : ''"
:class="
operation.id === selectedGatewayOperationId
? 'border-violet-500 text-violet-200'
: ''
"
@click="selectGatewayReleaseOperation(operation.id)"
>
보기