feat: 예약 오픈 일정을 빌드 전에 공개

초기화 예약의 공개 선택을 operation payload에 저장하고 로비에서 준비 단계별로 표시합니다. RESERVED 인계와 STOPPED 무요청 경계를 테스트합니다.
This commit is contained in:
2026-08-23 13:59:31 +00:00
parent 4b97088d69
commit 14ffa76c49
9 changed files with 645 additions and 26 deletions
+98 -7
View File
@@ -111,8 +111,7 @@ const formatAnnouncementDate = (value: string | null | undefined): string =>
const profileScenarioTitle = (profileName: string): string =>
profileDetails.value[profileName]?.scenarioTitle.trim() || '-';
const npcModeText = (mode: number): string => ['불가', '가능', '선택 생성'][mode] ?? '불가';
const autorunDetailText = (info: LobbyInfo): string => {
const autorun = info.autorunUser;
const autorunDetailText = (autorun: LobbyInfo['autorunUser']): string => {
if (!autorun) return '';
const enabled = new Set(autorun.options);
@@ -134,8 +133,14 @@ const autorunDetailText = (info: LobbyInfo): string => {
labels.push(limit);
return labels.join(', ');
};
const autorunTooltipId = (profileName: string): string =>
`profile-autorun-${profileName.replaceAll(/[^a-zA-Z0-9_-]/g, '-')}`;
const autorunTooltipId = (profileName: string, scope = 'current'): string =>
'profile-autorun-' + scope + '-' + profileName.replaceAll(/[^a-zA-Z0-9_-]/g, '-');
const upcomingResetPhaseText = (profile: LobbyProfile): string => {
if (profile.upcomingReset?.phase === 'DELAYED') return '준비 지연 · 일정 확인 중';
if (profile.upcomingReset?.phase === 'READY') return '오픈 준비 완료 · 가오픈 대기';
if (profile.upcomingReset?.phase === 'PREPARING') return '오픈 준비 중';
return '오픈 예정 · 빌드 대기';
};
const isProfileRuntimeAvailable = (profile: LobbyProfile): boolean => profile.lifecycle.userAccessible;
const unavailableProfileText = (profile: LobbyProfile): string => {
if (!profile.lifecycle.dataInitialized) return '- DB 초기화 전 · 접근 불가 -';
@@ -503,8 +508,75 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
<!-- Server Info -->
<td class="profile-info-cell px-4 py-4 border-r border-zinc-800">
<div
v-if="profile.upcomingReset"
class="upcoming-reset-announcement"
data-testid="upcoming-reset-announcement"
>
<div
class="upcoming-reset-phase"
:class="{ 'is-delayed': profile.upcomingReset.phase === 'DELAYED' }"
data-testid="upcoming-reset-phase"
>
{{ upcomingResetPhaseText(profile) }}
</div>
<div data-testid="upcoming-reset-scheduled-at">
- 초기화 시작 :
{{ formatAnnouncementDate(profile.upcomingReset.scheduledAt) }} -
</div>
<div data-testid="upcoming-reset-preopen-at">
- 가오픈 일시 :
{{ formatAnnouncementDate(profile.upcomingReset.preopenAt) }} -
</div>
<div data-testid="upcoming-reset-open-at">
- 오픈 일시 : {{ formatAnnouncementDate(profile.upcomingReset.openAt) }} -
</div>
<div data-testid="upcoming-reset-scenario-announcement">
<span class="text-orange-400" data-testid="upcoming-reset-scenario-title">
{{ profile.upcomingReset.scenarioTitle }} </span
>{{ ' ' }}
<span class="text-green-400">
{{ profile.upcomingReset.turnTermMinutes }}분 턴 서버
</span>
</div>
<div class="profile-announcement-settings text-xs text-zinc-500">
(상성 설정:{{ profile.upcomingReset.fictionMode }}), (빙의 여부:{{
npcModeText(profile.upcomingReset.npcMode)
}}), (최대 스탯:{{ profile.upcomingReset.defaultStatTotal }}), (기타
설정:<template v-if="profile.upcomingReset.otherTextInfo"
>{{ profile.upcomingReset.otherTextInfo
}}<template v-if="profile.upcomingReset.autorunUser"
>,
</template></template
><span
v-if="profile.upcomingReset.autorunUser"
class="copyable-autorun"
tabindex="0"
:aria-describedby="autorunTooltipId(profile.profileName, 'upcoming')"
>자율행동<span
:id="autorunTooltipId(profile.profileName, 'upcoming')"
class="copyable-autorun-detail"
role="tooltip"
><span class="copyable-autorun-bracket">[</span
><span>{{
autorunDetailText(profile.upcomingReset.autorunUser)
}}</span
><span class="copyable-autorun-bracket">]</span></span
></span
><template
v-if="
!profile.upcomingReset.otherTextInfo &&
!profile.upcomingReset.autorunUser
"
>없음</template
>)
</div>
</div>
<template v-if="profileDetails[profile.profileName]">
<div class="space-y-1">
<div
class="space-y-1"
:class="{ 'mt-3 border-t border-zinc-800 pt-3': profile.upcomingReset }"
>
<template v-if="profile.status === 'PREOPEN'">
<div
v-if="profileDetails[profile.profileName]?.preopenAt"
@@ -586,7 +658,9 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
role="tooltip"
><span class="copyable-autorun-bracket">[</span
><span>{{
autorunDetailText(profileDetails[profile.profileName]!)
autorunDetailText(
profileDetails[profile.profileName]!.autorunUser
)
}}</span
><span class="copyable-autorun-bracket">]</span></span
></span
@@ -594,7 +668,7 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
</div>
</div>
</template>
<template v-else-if="!isProfileRuntimeAvailable(profile)">
<template v-else-if="!isProfileRuntimeAvailable(profile) && !profile.upcomingReset">
<div class="text-center text-zinc-600 py-2">
{{ unavailableProfileText(profile) }}
</div>
@@ -929,6 +1003,23 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
user-select: none;
}
.upcoming-reset-announcement {
border-left: 2px solid #f59e0b;
padding-left: 10px;
line-height: 1.5;
}
.upcoming-reset-phase {
margin-bottom: 4px;
color: #fbbf24;
font-size: 12px;
font-weight: 700;
}
.upcoming-reset-phase.is-delayed {
color: #fca5a5;
}
.copyable-autorun {
position: relative;
cursor: help;
@@ -152,6 +152,7 @@ const form = reactive({
openAt: '',
preopenAt: '',
scheduledAt: '',
publishSchedule: false,
reason: '',
});
const RESET_AUTORUN_FORM_KEYS = {
@@ -653,12 +654,16 @@ const requestReset = async () => {
errorMessage.value = '초기화 소스와 시나리오를 먼저 선택해주세요.';
return;
}
if (form.publishSchedule && (!form.scheduledAt || !form.preopenAt || !form.openAt)) {
errorMessage.value = '로비 일정 공개에는 초기화 시작, 가오픈 시작과 정식 오픈을 모두 입력해주세요.';
return;
}
const scenarioId = form.scenarioId;
const sourceLabel =
form.sourceMode === 'CURRENT' ? '서버 지정 버전' : form.sourceMode === 'BRANCH' ? '브랜치' : '커밋';
if (
!window.confirm(
`${selectedProfileName.value}의 게임 DB를 초기화합니다.\n${sourceLabel}${form.sourceMode === 'CURRENT' ? '' : `: ${form.sourceRef}`}\n시나리오: ${scenarioId}`
`${selectedProfileName.value}의 게임 DB를 초기화합니다.\n${sourceLabel}${form.sourceMode === 'CURRENT' ? '' : `: ${form.sourceRef}`}\n시나리오: ${scenarioId}${form.publishSchedule ? '\n예약 등록 즉시 로비에 오픈 일정을 공개합니다.' : ''}`
)
) {
return;
@@ -670,6 +675,7 @@ const requestReset = async () => {
sourceMode: form.sourceMode,
sourceRef: form.sourceMode === 'CURRENT' ? undefined : form.sourceRef.trim(),
scheduledAt: toIso(form.scheduledAt),
publishSchedule: form.publishSchedule,
reason: form.reason.trim() || undefined,
install: {
scenarioId,
@@ -1384,6 +1390,23 @@ onBeforeUnmount(() => {
/>
</div>
</div>
<label
class="flex cursor-pointer items-start gap-3 rounded border border-zinc-800 bg-zinc-950/50 px-3 py-2 text-sm text-zinc-200"
>
<input
v-model="form.publishSchedule"
type="checkbox"
class="publish-schedule-checkbox mt-0.5 h-4 w-4 accent-amber-500"
data-testid="reset-publish-schedule"
/>
<span>
<span class="block font-medium">예약 등록 즉시 로비에 오픈 일정 공개</span>
<span class="mt-0.5 block text-xs leading-5 text-zinc-500">
빌드는 초기화 시작 시각까지 대기합니다. 공개하려면 시각을 모두 입력해야
합니다.
</span>
</span>
</label>
</div>
<input
@@ -1859,3 +1882,10 @@ onBeforeUnmount(() => {
</div>
</AdminConsoleLayout>
</template>
<style scoped>
.publish-schedule-checkbox:focus-visible {
outline: 2px solid #fcd34d;
outline-offset: 2px;
}
</style>