merge: add per-profile reset defaults

This commit is contained in:
2026-08-09 14:43:27 +00:00
8 changed files with 618 additions and 28 deletions
+41
View File
@@ -464,6 +464,24 @@ const zInstallOptions = z.object({
gitRef: z.string().min(1).max(128).optional(), gitRef: z.string().min(1).max(128).optional(),
}); });
const zOperationInstallOptions = zInstallOptions.omit({ gitRef: true }); const zOperationInstallOptions = zInstallOptions.omit({ gitRef: true });
const zProfileResetDefaults = zInstallOptions.omit({
scenarioId: true,
openAt: true,
preopenAt: true,
gitRef: true,
});
const SYSTEM_PROFILE_RESET_DEFAULTS: z.infer<typeof zProfileResetDefaults> = {
turnTermMinutes: 60,
sync: true,
fiction: 1,
extend: true,
blockGeneralCreate: 0,
npcMode: 0,
showImgLevel: 3,
tournamentTrig: true,
joinMode: 'full',
autorunUser: null,
};
const zSourceMode = z.enum(['BRANCH', 'COMMIT']); const zSourceMode = z.enum(['BRANCH', 'COMMIT']);
const zResetSourceMode = z.enum(['CURRENT', 'BRANCH', 'COMMIT']); const zResetSourceMode = z.enum(['CURRENT', 'BRANCH', 'COMMIT']);
@@ -533,6 +551,16 @@ const readMetaObject = (value: unknown): Record<string, unknown> => {
return value as Record<string, unknown>; return value as Record<string, unknown>;
}; };
const readProfileResetDefaults = (
meta: Record<string, unknown>
): { defaults: z.infer<typeof zProfileResetDefaults>; source: 'SYSTEM' | 'PROFILE' } => {
const parsed = zProfileResetDefaults.partial().safeParse(meta.resetDefaults);
if (meta.resetDefaults === undefined || !parsed.success) {
return { defaults: { ...SYSTEM_PROFILE_RESET_DEFAULTS }, source: 'SYSTEM' };
}
return { defaults: { ...SYSTEM_PROFILE_RESET_DEFAULTS, ...parsed.data }, source: 'PROFILE' };
};
const applyMetaPatch = ( const applyMetaPatch = (
meta: Record<string, unknown>, meta: Record<string, unknown>,
patch: Record<string, unknown | null | undefined> patch: Record<string, unknown | null | undefined>
@@ -1474,6 +1502,18 @@ export const adminRouter = router({
}), }),
}), }),
profiles: router({ profiles: router({
getResetDefaults: adminProcedure
.input(z.object({ profileName: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const adminAuth = requireAdminAuth(ctx);
assertAnyPermission(adminAuth, [ROLE_ADMIN_PROFILES, ROLE_ADMIN_SCENARIO_RESET], input.profileName);
const profile = await ctx.profiles.getProfile(input.profileName);
if (!profile) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' });
}
const meta = readMetaObject(profile.meta);
return readProfileResetDefaults(meta);
}),
listNavigation: adminProcedure.query(async ({ ctx }) => { listNavigation: adminProcedure.query(async ({ ctx }) => {
const adminAuth = requireAdminAuth(ctx); const adminAuth = requireAdminAuth(ctx);
return orderGatewayProfiles(await ctx.profiles.listProfiles()) return orderGatewayProfiles(await ctx.profiles.listProfiles())
@@ -1660,6 +1700,7 @@ export const adminRouter = router({
nextSeasonIdx: z.number().int().min(0).nullable().optional(), nextSeasonIdx: z.number().int().min(0).nullable().optional(),
localAccountAccessGraceDays: z.number().int().min(0).max(365).nullable().optional(), localAccountAccessGraceDays: z.number().int().min(0).max(365).nullable().optional(),
localAccountGeneralCreationGraceDays: z.number().int().min(0).max(365).nullable().optional(), localAccountGeneralCreationGraceDays: z.number().int().min(0).max(365).nullable().optional(),
resetDefaults: zProfileResetDefaults.nullable().optional(),
}), }),
reason: z.string().trim().min(3).max(200), reason: z.string().trim().min(3).max(200),
}) })
+98 -1
View File
@@ -29,6 +29,7 @@ const buildCaller = async (
initialNotice?: string; initialNotice?: string;
initialProfileStatus?: GatewayProfileRecord['status']; initialProfileStatus?: GatewayProfileRecord['status'];
profileScenario?: string; profileScenario?: string;
profileMeta?: GatewayProfileRecord['meta'];
releaseLogVisibilityAfterPolls?: number; releaseLogVisibilityAfterPolls?: number;
} = {} } = {}
) => { ) => {
@@ -75,7 +76,7 @@ const buildCaller = async (
status: options.initialProfileStatus ?? ('STOPPED' as const), status: options.initialProfileStatus ?? ('STOPPED' as const),
buildStatus: 'SUCCEEDED' as const, buildStatus: 'SUCCEEDED' as const,
buildCommitSha: 'HEAD', buildCommitSha: 'HEAD',
meta: {}, meta: options.profileMeta ?? {},
createdAt: '2026-07-25T00:00:00.000Z', createdAt: '2026-07-25T00:00:00.000Z',
updatedAt: '2026-07-25T00:00:00.000Z', updatedAt: '2026-07-25T00:00:00.000Z',
}; };
@@ -511,6 +512,102 @@ describe('admin operation API', () => {
}); });
}); });
it('returns validated profile reset defaults to a scenario-only operator', async () => {
const harness = await buildCaller(
async () => {
throw new Error('not used');
},
{
adminRoles: ['admin.scenarios.reset:che:2'],
firstUserIsAdmin: false,
profileMeta: {
resetDefaults: {
turnTermMinutes: 20,
sync: false,
fiction: 0,
extend: false,
blockGeneralCreate: 2,
npcMode: 1,
showImgLevel: 1,
tournamentTrig: false,
joinMode: 'onlyRandom',
autorunUser: { limitMinutes: 720, options: ['develop', 'train'] },
},
},
}
);
await expect(harness.caller.admin.profiles.getResetDefaults({ profileName: 'che:2' })).resolves.toEqual({
source: 'PROFILE',
defaults: {
turnTermMinutes: 20,
sync: false,
fiction: 0,
extend: false,
blockGeneralCreate: 2,
npcMode: 1,
showImgLevel: 1,
tournamentTrig: false,
joinMode: 'onlyRandom',
autorunUser: { limitMinutes: 720, options: ['develop', 'train'] },
},
});
});
it('falls back to system reset defaults when profile metadata is malformed', async () => {
const harness = await buildCaller(
async () => {
throw new Error('not used');
},
{
adminRoles: ['admin.scenarios.reset:che:2'],
firstUserIsAdmin: false,
profileMeta: { resetDefaults: { npcMode: 99 } },
}
);
await expect(harness.caller.admin.profiles.getResetDefaults({ profileName: 'che:2' })).resolves.toMatchObject({
source: 'SYSTEM',
defaults: { turnTermMinutes: 60, npcMode: 0, tournamentTrig: true, autorunUser: null },
});
});
it('stores reset defaults only after validating the complete metadata object', async () => {
const harness = await buildCaller(
async () => {
throw new Error('not used');
},
{ adminRoles: ['admin.profiles.settings:che:2'], firstUserIsAdmin: false }
);
const resetDefaults = {
turnTermMinutes: 10,
sync: true,
fiction: 1 as const,
extend: true,
blockGeneralCreate: 0 as const,
npcMode: 2 as const,
showImgLevel: 3 as const,
tournamentTrig: true,
joinMode: 'full' as const,
autorunUser: null,
};
await harness.caller.admin.profiles.updateMeta({
profileName: 'che:2',
patch: { resetDefaults },
reason: 'set server reset defaults',
});
expect(harness.updatedMetas.at(-1)).toMatchObject({ resetDefaults });
await expect(
harness.caller.admin.profiles.updateMeta({
profileName: 'che:2',
patch: { resetDefaults: { ...resetDefaults, turnTermMinutes: 7 } },
reason: 'reject invalid reset defaults',
})
).rejects.toBeDefined();
});
it('does not let a scenario-only operator combine a Git update with reset', async () => { it('does not let a scenario-only operator combine a Git update with reset', async () => {
const harness = await buildCaller( const harness = await buildCaller(
async () => { async () => {
@@ -40,9 +40,10 @@ type FixtureState = {
profileNavigationRequests?: number; profileNavigationRequests?: number;
profileNavigationResolved?: boolean; profileNavigationResolved?: boolean;
scenarioFailuresRemaining?: number; scenarioFailuresRemaining?: number;
resetDefaults?: Record<string, unknown>;
}; };
const profile = (runtimeRunning: boolean) => ({ const profile = (runtimeRunning: boolean, resetDefaults?: Record<string, unknown>) => ({
profileName: 'che:2', profileName: 'che:2',
profile: 'che', profile: 'che',
scenario: '2', scenario: '2',
@@ -51,9 +52,11 @@ const profile = (runtimeRunning: boolean) => ({
buildStatus: 'SUCCEEDED', buildStatus: 'SUCCEEDED',
buildCommitSha: '0123456789abcdef0123456789abcdef01234567', buildCommitSha: '0123456789abcdef0123456789abcdef01234567',
buildWorkspace: '/srv/sammo/worktrees/0123456789abcdef0123456789abcdef01234567', buildWorkspace: '/srv/sammo/worktrees/0123456789abcdef0123456789abcdef01234567',
meta: {}, meta: resetDefaults ? { resetDefaults } : {},
createdAt: '2026-07-25T00:00:00.000Z', createdAt: '2026-07-25T00:00:00.000Z',
updatedAt: '2026-07-25T00:00:00.000Z', updatedAt: '2026-07-25T00:00:00.000Z',
activeOperation: null,
runtimeActions: [],
runtime: { runtime: {
profileName: 'che:2', profileName: 'che:2',
frontendRunning: runtimeRunning, frontendRunning: runtimeRunning,
@@ -132,7 +135,7 @@ const installFixture = async (page: Page, state: FixtureState) => {
state.requestBodies.push({ operation: name, body }); state.requestBodies.push({ operation: name, body });
} }
if (name === 'admin.profiles.list') { if (name === 'admin.profiles.list') {
return response([profile(state.runtimeRunning)]); return response([profile(state.runtimeRunning, state.resetDefaults)]);
} }
if (name === 'admin.profiles.listNavigation') { if (name === 'admin.profiles.listNavigation') {
return response([ return response([
@@ -206,6 +209,26 @@ const installFixture = async (page: Page, state: FixtureState) => {
expect(names).toEqual(['admin.profiles.listScenarios']); expect(names).toEqual(['admin.profiles.listScenarios']);
return response(scenarios); return response(scenarios);
} }
if (name === 'admin.profiles.getResetDefaults') {
return response({
source: state.resetDefaults ? 'PROFILE' : 'SYSTEM',
defaults: state.resetDefaults ?? {
turnTermMinutes: 60,
sync: true,
fiction: 1,
extend: true,
blockGeneralCreate: 0,
npcMode: 0,
showImgLevel: 3,
tournamentTrig: true,
joinMode: 'full',
autorunUser: null,
},
});
}
if (name === 'admin.profiles.updateMeta') {
return response(profile(state.runtimeRunning, state.resetDefaults));
}
if (name === 'admin.operations.requestReset') { if (name === 'admin.operations.requestReset') {
const operation: Operation = { const operation: Operation = {
id: '11111111-1111-4111-8111-111111111111', id: '11111111-1111-4111-8111-111111111111',
@@ -436,6 +459,68 @@ test('separates DB-preserving profile deployment from DB reset', async ({ page }
expect(state.requestBodies.some((entry) => entry.operation === 'admin.operations.requestReset')).toBe(false); expect(state.requestBodies.some((entry) => entry.operation === 'admin.operations.requestReset')).toBe(false);
}); });
test('loads server metadata defaults into the reset form and submits them', async ({ page }) => {
const state: FixtureState = {
operations: [],
gatewayOperations: [],
runtimeRunning: true,
requestBodies: [],
resetDefaults: {
turnTermMinutes: 20,
sync: false,
fiction: 0,
extend: false,
blockGeneralCreate: 2,
npcMode: 1,
showImgLevel: 1,
tournamentTrig: false,
joinMode: 'onlyRandom',
autorunUser: { limitMinutes: 720, options: ['develop', 'train'] },
},
};
await installFixture(page, state);
page.on('dialog', (dialog) => dialog.accept());
await page.goto('admin/servers/che%3A2/scenario');
await expect(page.getByTestId('reset-turn-term')).toHaveValue('20');
await page.getByText('고급 시나리오 옵션').click();
await expect(page.getByTestId('reset-defaults-source')).toContainText('서버의 메타');
await expect(page.getByTestId('reset-npc-mode')).toHaveValue('1');
await page.getByTestId('request-reset').click();
await expect
.poll(() => state.requestBodies.find((entry) => entry.operation === 'admin.operations.requestReset'))
.toBeTruthy();
const request = JSON.stringify(
state.requestBodies.find((entry) => entry.operation === 'admin.operations.requestReset')?.body
);
expect(request).toContain('"turnTermMinutes":20');
expect(request).toContain('"npcMode":1');
expect(request).toContain('"joinMode":"onlyRandom"');
expect(request).toContain('"limitMinutes":720');
expect(request).toContain('"options":["develop","train"]');
});
test('edits server reset defaults through profile metadata settings', async ({ page }) => {
const state: FixtureState = { operations: [], gatewayOperations: [], runtimeRunning: true, requestBodies: [] };
await installFixture(page, state);
await page.goto('admin/servers/che%3A2');
await page.getByText('서버 리셋 기본 옵션').click();
await page.getByTestId('meta-reset-turn-term').selectOption('10');
await page.getByTestId('meta-reset-npc-mode').selectOption('2');
await page.getByPlaceholder('변경 사유 (필수)').fill('set reset defaults');
await page.getByRole('button', { name: '메타 저장' }).click();
await expect(page.getByText('메타 저장 완료')).toBeVisible();
const request = JSON.stringify(
state.requestBodies.find((entry) => entry.operation === 'admin.profiles.updateMeta')?.body
);
expect(request).toContain('"resetDefaults"');
expect(request).toContain('"turnTermMinutes":10');
expect(request).toContain('"npcMode":2');
});
test('renders the fixed-profile version form without waiting for the server list', async ({ page }) => { test('renders the fixed-profile version form without waiting for the server list', async ({ page }) => {
const state: FixtureState = { const state: FixtureState = {
operations: [], operations: [],
@@ -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 { computed, onMounted, ref } from 'vue';
import ServerProfileTabs from '../components/ServerProfileTabs.vue'; import ServerProfileTabs from '../components/ServerProfileTabs.vue';
import AdminConsoleLayout from '../layouts/AdminConsoleLayout.vue'; import AdminConsoleLayout from '../layouts/AdminConsoleLayout.vue';
import {
normalizeProfileResetDefaults,
type ProfileResetDefaults,
type ResetAutorunOption,
} from '../utils/resetDefaults';
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
type AdminSection = 'users' | 'servers' | 'system' | 'audit'; type AdminSection = 'users' | 'servers' | 'system' | 'audit';
@@ -325,6 +330,7 @@ type AdminClient = {
nextSeasonIdx?: number | null; nextSeasonIdx?: number | null;
localAccountAccessGraceDays?: number | null; localAccountAccessGraceDays?: number | null;
localAccountGeneralCreationGraceDays?: number | null; localAccountGeneralCreationGraceDays?: number | null;
resetDefaults?: ProfileResetDefaults | null;
}; };
reason: string; reason: string;
}) => Promise<AdminProfile | null>; }) => Promise<AdminProfile | null>;
@@ -379,6 +385,8 @@ const profileEdits = ref<
nextSeasonIdx: string; nextSeasonIdx: string;
localAccountAccessGraceDays: string; localAccountAccessGraceDays: string;
localAccountGeneralCreationGraceDays: string; localAccountGeneralCreationGraceDays: string;
resetDefaults: ProfileResetDefaults;
resetAutorunEnabled: boolean;
reason: string; 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 profileActionStatus = ref<Record<string, string>>({});
const profileActionSubmitting = ref<Record<string, boolean>>({}); const profileActionSubmitting = ref<Record<string, boolean>>({});
const visibleProfiles = computed(() => const visibleProfiles = computed(() =>
@@ -543,6 +558,7 @@ const saveNotice = async () => {
const ensureProfileBuffers = (profile: AdminProfile) => { const ensureProfileBuffers = (profile: AdminProfile) => {
if (!profileEdits.value[profile.profileName]) { if (!profileEdits.value[profile.profileName]) {
const meta = (profile.meta ?? {}) as Record<string, unknown>; const meta = (profile.meta ?? {}) as Record<string, unknown>;
const resetDefaults = normalizeProfileResetDefaults(meta.resetDefaults);
profileEdits.value[profile.profileName] = { profileEdits.value[profile.profileName] = {
korName: String(meta.korName ?? profile.profile), korName: String(meta.korName ?? profile.profile),
color: String(meta.color ?? '#ffffff'), color: String(meta.color ?? '#ffffff'),
@@ -560,6 +576,8 @@ const ensureProfileBuffers = (profile: AdminProfile) => {
typeof meta.localAccountGeneralCreationGraceDays === 'number' typeof meta.localAccountGeneralCreationGraceDays === 'number'
? String(Math.floor(meta.localAccountGeneralCreationGraceDays)) ? String(Math.floor(meta.localAccountGeneralCreationGraceDays))
: '', : '',
resetDefaults,
resetAutorunEnabled: resetDefaults.autorunUser !== null,
reason: '', reason: '',
}; };
} }
@@ -663,6 +681,19 @@ const updateProfileMeta = async (profileName: string) => {
profileActionStatus.value = { ...profileActionStatus.value, [profileName]: '변경 사유를 입력하세요.' }; profileActionStatus.value = { ...profileActionStatus.value, [profileName]: '변경 사유를 입력하세요.' };
return; 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 = { const patch = {
korName: edit.korName.trim() || null, korName: edit.korName.trim() || null,
color: edit.color.trim() || null, color: edit.color.trim() || null,
@@ -671,6 +702,10 @@ const updateProfileMeta = async (profileName: string) => {
nextSeasonIdx: nextSeasonIdx === null ? null : Math.floor(nextSeasonIdx), nextSeasonIdx: nextSeasonIdx === null ? null : Math.floor(nextSeasonIdx),
localAccountAccessGraceDays: accessGraceDays, localAccountAccessGraceDays: accessGraceDays,
localAccountGeneralCreationGraceDays: creationGraceDays, localAccountGeneralCreationGraceDays: creationGraceDays,
resetDefaults: {
...edit.resetDefaults,
autorunUser: edit.resetAutorunEnabled ? edit.resetDefaults.autorunUser : null,
},
}; };
try { try {
const updated = await adminClient.profiles.updateMeta.mutate({ 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) => { const requestProfileAction = async (profileName: string, action: AdminAction) => {
if (profileActionSubmitting.value[profileName]) { if (profileActionSubmitting.value[profileName]) {
return; return;
@@ -2035,6 +2079,168 @@ onMounted(() => {
placeholder="예: 12" placeholder="예: 12"
/> />
<div class="text-xs text-zinc-500">리셋 시 적용할 시즌 번호를 지정합니다.</div> <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> <label class="text-xs text-zinc-400">Kakao 미인증 접근 유예일</label>
<input <input
v-model="profileEdits[profile.profileName].localAccountAccessGraceDays" 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 ServerProfileTabs from '../components/ServerProfileTabs.vue';
import AdminConsoleLayout from '../layouts/AdminConsoleLayout.vue'; import AdminConsoleLayout from '../layouts/AdminConsoleLayout.vue';
import {
normalizeProfileResetDefaults,
SYSTEM_PROFILE_RESET_DEFAULTS,
type ProfileResetDefaults,
} from '../utils/resetDefaults';
import { directTrpc, trpc } from '../utils/trpc'; import { directTrpc, trpc } from '../utils/trpc';
type OperationPageMode = 'version' | 'scenario' | 'gateway'; type OperationPageMode = 'version' | 'scenario' | 'gateway';
@@ -90,6 +95,7 @@ const catalogAttempted = ref(false);
const submitting = ref(false); const submitting = ref(false);
const message = ref(''); const message = ref('');
const errorMessage = ref(''); const errorMessage = ref('');
const resetDefaultsSource = ref<'SYSTEM' | 'PROFILE'>('SYSTEM');
let pollTimer: ReturnType<typeof setInterval> | undefined; let pollTimer: ReturnType<typeof setInterval> | undefined;
let stateRequestInFlight = false; let stateRequestInFlight = false;
let releaseLogLoopGeneration = 0; let releaseLogLoopGeneration = 0;
@@ -99,15 +105,15 @@ const form = reactive({
sourceMode: (props.mode === 'scenario' ? 'CURRENT' : 'BRANCH') as 'CURRENT' | 'BRANCH' | 'COMMIT', sourceMode: (props.mode === 'scenario' ? 'CURRENT' : 'BRANCH') as 'CURRENT' | 'BRANCH' | 'COMMIT',
sourceRef: 'main', sourceRef: 'main',
scenarioId: null as number | null, scenarioId: null as number | null,
turnTermMinutes: 60, turnTermMinutes: SYSTEM_PROFILE_RESET_DEFAULTS.turnTermMinutes,
sync: true, sync: SYSTEM_PROFILE_RESET_DEFAULTS.sync,
fiction: 1, fiction: SYSTEM_PROFILE_RESET_DEFAULTS.fiction,
extend: true, extend: SYSTEM_PROFILE_RESET_DEFAULTS.extend,
blockGeneralCreate: 0, blockGeneralCreate: SYSTEM_PROFILE_RESET_DEFAULTS.blockGeneralCreate,
npcMode: 0, npcMode: SYSTEM_PROFILE_RESET_DEFAULTS.npcMode,
showImgLevel: 3, showImgLevel: SYSTEM_PROFILE_RESET_DEFAULTS.showImgLevel,
tournamentTrig: true, tournamentTrig: SYSTEM_PROFILE_RESET_DEFAULTS.tournamentTrig,
joinMode: 'full' as 'full' | 'onlyRandom', joinMode: SYSTEM_PROFILE_RESET_DEFAULTS.joinMode,
autorunEnabled: false, autorunEnabled: false,
autorunUserMinutes: 1440, autorunUserMinutes: 1440,
autorunDevelop: true, 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 formatTime = (value?: string): string => (value ? new Date(value).toLocaleString('ko-KR') : '-');
const formatLogTime = (value: string): string => 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 shortSha = (value?: string): string => (value ? value.slice(0, 12) : '-');
const clearStatus = () => { const clearStatus = () => {
@@ -185,6 +196,40 @@ const clearStatus = () => {
errorMessage.value = ''; 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 () => { const loadCapabilities = async () => {
try { try {
capabilities.value = (await adminClient.capabilities.list.query()) as typeof capabilities.value; 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) => { const pollGatewayReleaseLogs = async (operationId: string, generation: number) => {
while (componentMounted && generation === releaseLogLoopGeneration && selectedGatewayOperationId.value === operationId) { while (
componentMounted &&
generation === releaseLogLoopGeneration &&
selectedGatewayOperationId.value === operationId
) {
try { try {
const result = await adminClient.releases.logs.query({ const result = await adminClient.releases.logs.query({
id: operationId, id: operationId,
@@ -516,6 +565,7 @@ onMounted(async () => {
await Promise.all([ await Promise.all([
loadCapabilities(), loadCapabilities(),
loadState(), loadState(),
loadResetDefaults(),
props.mode === 'scenario' ? loadScenarios() : Promise.resolve(), props.mode === 'scenario' ? loadScenarios() : Promise.resolve(),
]); ]);
pollTimer = setInterval(() => void loadState(true), 3000); pollTimer = setInterval(() => void loadState(true), 3000);
@@ -671,9 +721,10 @@ onBeforeUnmount(() => {
<select <select
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="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 <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" :key="minutes"
:value="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"> <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> <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"> <div class="mt-4 grid gap-4 md:grid-cols-2 text-sm">
<label <label
>동기화 >동기화
@@ -727,7 +785,11 @@ onBeforeUnmount(() => {
</label> </label>
<label <label
>NPC 모드 >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="0">기본</option>
<option :value="1">확장</option> <option :value="1">확장</option>
<option :value="2">전체</option> <option :value="2">전체</option>
@@ -936,7 +998,13 @@ onBeforeUnmount(() => {
<div <div
v-for="entry in gatewayReleaseLogs" v-for="entry in gatewayReleaseLogs"
:key="entry.cursor" :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="text-zinc-600">{{ formatLogTime(entry.createdAt) }}</span>
<span class="ml-2 text-violet-300">[{{ entry.phase }}]</span> <span class="ml-2 text-violet-300">[{{ entry.phase }}]</span>
@@ -973,7 +1041,11 @@ onBeforeUnmount(() => {
<button <button
type="button" type="button"
class="rounded border border-zinc-700 px-2 py-1 text-zinc-300 hover:bg-zinc-800" 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)" @click="selectGatewayReleaseOperation(operation.id)"
> >
보기 보기
+9 -2
View File
@@ -48,6 +48,13 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로
분리된 요청으로 읽습니다. API가 profile의 현재 scenario를 표시하며 화면은 분리된 요청으로 읽습니다. API가 profile의 현재 scenario를 표시하며 화면은
그 항목을 기본 선택합니다. scenario ID `0`도 유효한 값이고, 초기 요청이 그 항목을 기본 선택합니다. scenario ID `0`도 유효한 값이고, 초기 요청이
실패하면 현재 버전 모드에서 다시 확인할 수 있습니다. 실패하면 현재 버전 모드에서 다시 확인할 수 있습니다.
- 서버 상태의 `서버 리셋 기본 옵션``GatewayProfile.meta.resetDefaults`
턴 간격, 동기화, 가상 장수, 연장, 가입 방식, 장수 생성 제한, NPC, 이미지,
토너먼트와 유저 자동턴 기본값을 저장합니다. 시나리오 초기화 화면은 대상
서버를 URL에서 결정한 뒤 이 메타를 별도 권한 검사로 읽어 폼에 적용합니다.
메타가 없거나 유효하지 않으면 기존 시스템 기본값을 사용합니다. 시나리오와
예약·가오픈·정식 오픈 시각은 매 실행마다 선택하므로 서버 기본값에 포함하지
않습니다.
- Gateway 릴리스는 profile 작업과 다른 전역 `admin.releases.manage` 권한을 - Gateway 릴리스는 profile 작업과 다른 전역 `admin.releases.manage` 권한을
사용하며 외부 release-controller가 실행합니다. 선택한 릴리스 작업의 단계와 사용하며 외부 release-controller가 실행합니다. 선택한 릴리스 작업의 단계와
명령 출력을 관리자 화면이 long polling으로 이어 받아 표시하며, 완료된 이력의 명령 출력을 관리자 화면이 long polling으로 이어 받아 표시하며, 완료된 이력의
@@ -58,9 +65,9 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로
## Profile 권한 분류 ## Profile 권한 분류
| capability | 허용 작업 | | capability | 허용 작업 |
| -------------------------------- | ---------------------------------------------------------- | | -------------------------------- | --------------------------------------------------------- |
| `admin.profiles.runtime:<name>` | 시작·정지·일시정지·재개와 시간 조정 | | `admin.profiles.runtime:<name>` | 시작·정지·일시정지·재개와 시간 조정 |
| `admin.profiles.settings:<name>` | 표시색·표시명·인게임 공지·Kakao 미인증 접근/장수 생성 유예 | | `admin.profiles.settings:<name>` | 표시 정보·리셋 기본 옵션·Kakao 미인증 접근/장수 생성 유예 |
| `admin.profiles.deploy:<name>` | DB를 유지하는 Git 버전 업데이트, 초기화와 새 버전 결합 | | `admin.profiles.deploy:<name>` | DB를 유지하는 Git 버전 업데이트, 초기화와 새 버전 결합 |
| `admin.scenarios.reset:<name>` | 현재 배포 버전으로 시나리오 초기화 | | `admin.scenarios.reset:<name>` | 현재 배포 버전으로 시나리오 초기화 |
| `admin.reset.schedule:<name>` | 허용된 시나리오 초기화를 미래 시각에 예약 | | `admin.reset.schedule:<name>` | 허용된 시나리오 초기화를 미래 시각에 예약 |
+8
View File
@@ -76,6 +76,14 @@ profile 범위 권한과 별개인 전역 `admin.releases.manage` 권한이 필
`admin.profiles.deploy`를 모두 요구합니다. Source와 scenario를 확인한 뒤 turn 간격, 가오픈·정식 오픈, `admin.profiles.deploy`를 모두 요구합니다. Source와 scenario를 확인한 뒤 turn 간격, 가오픈·정식 오픈,
NPC와 자동 진행 설정을 확인하고 요청해 주세요. NPC와 자동 진행 설정을 확인하고 요청해 주세요.
턴 간격과 고급 옵션은 서버 상태 화면에서 저장한
`GatewayProfile.meta.resetDefaults`를 최초값으로 사용합니다. 서버별 기본값을
바꾸려면 `admin.profiles.settings:<name>` 권한으로 메타를 저장하고 시나리오
초기화 화면을 다시 여세요. 값이 없거나 유효하지 않으면 60분, 동기화·가상
장수·연장·토너먼트 사용, 기본 NPC, 이미지 단계 3, 전체 가입과 유저 자동턴
미사용이라는 기존 시스템 기본값으로 돌아갑니다. 실행마다 달라지는 scenario와
예약·가오픈·정식 오픈 시각은 자동 입력하지 않습니다.
이 모드는 build와 migration 후 기존 season/tick metadata를 읽고 scenario seeder를 실행합니다. 이 모드는 build와 migration 후 기존 season/tick metadata를 읽고 scenario seeder를 실행합니다.
빈 profile schema도 migration을 먼저 적용하므로 최초 `world_state` 조회가 빈 profile schema도 migration을 먼저 적용하므로 최초 `world_state` 조회가
table 부재로 실패하지 않습니다. 현 시즌의 장수, table 부재로 실패하지 않습니다. 현 시즌의 장수,