merge: 최신 main을 전투·명예·유산 패리티 작업에 통합한다
This commit is contained in:
@@ -299,6 +299,10 @@ integration('generic general creation through the durable turn daemon', () => {
|
||||
userId,
|
||||
name: created.name,
|
||||
cityId: city.id,
|
||||
role: {
|
||||
specialDomestic: null,
|
||||
specialWar: null,
|
||||
},
|
||||
inheritancePoints: {
|
||||
previous: 7351,
|
||||
},
|
||||
@@ -363,6 +367,10 @@ integration('generic general creation through the durable turn daemon', () => {
|
||||
userId,
|
||||
name: created.name,
|
||||
cityId: city.id,
|
||||
role: {
|
||||
specialDomestic: null,
|
||||
specialWar: null,
|
||||
},
|
||||
inheritancePoints: {
|
||||
previous: 7351,
|
||||
},
|
||||
|
||||
@@ -90,6 +90,9 @@ const fail = (code: JoinCreateGeneralErrorCode, message: string): never => {
|
||||
const normalizeJoinName = (value: string): string =>
|
||||
normalizeTroopName(value).replace(LEGACY_JOIN_REMOVED_CHARACTERS, '');
|
||||
|
||||
export const normalizeJoinSpecialityCode = (value: unknown): string | null =>
|
||||
typeof value === 'string' && value !== '' && value !== 'None' ? value : null;
|
||||
|
||||
export const resolveLegacyPenalty = (
|
||||
rawPenalty: Record<string, unknown> | undefined,
|
||||
profileId: string,
|
||||
@@ -658,8 +661,8 @@ export const createGeneralFromJoin = async (options: {
|
||||
const scenarioId = Number(worldMeta.scenarioId ?? worldState.scenarioCode);
|
||||
let specialityDomesticAge = resolveSpecialityAge(retirementYear, age, relativeYear, 12);
|
||||
let specialityWarAge = resolveSpecialityAge(retirementYear, age, relativeYear, 6);
|
||||
let specialWar = typeof configConst.defaultSpecialWar === 'string' ? configConst.defaultSpecialWar : 'None';
|
||||
let specialWarName = specialWar;
|
||||
let specialWar = normalizeJoinSpecialityCode(configConst.defaultSpecialWar);
|
||||
let specialWarName = specialWar ?? 'None';
|
||||
if (genius) {
|
||||
specialityWarAge = age;
|
||||
if (input.inheritSpecial) {
|
||||
@@ -677,10 +680,10 @@ export const createGeneralFromJoin = async (options: {
|
||||
) ?? specialWar;
|
||||
}
|
||||
const [trait] = await loadWarTraitModules(
|
||||
[specialWar].filter((key) => isWarTraitKey(key)),
|
||||
specialWar && isWarTraitKey(specialWar) ? [specialWar] : [],
|
||||
new WarTraitLoader()
|
||||
);
|
||||
specialWarName = trait?.name ?? specialWar;
|
||||
specialWarName = trait?.name ?? specialWar ?? 'None';
|
||||
}
|
||||
if (Number.isFinite(scenarioId) && scenarioId >= 1000) {
|
||||
specialityDomesticAge = age + 3;
|
||||
@@ -762,8 +765,12 @@ export const createGeneralFromJoin = async (options: {
|
||||
startAge: age,
|
||||
role: {
|
||||
personality,
|
||||
specialDomestic:
|
||||
typeof configConst.defaultSpecialDomestic === 'string' ? configConst.defaultSpecialDomestic : 'None',
|
||||
// Ref persists an empty speciality as the sentinel `None`, while
|
||||
// the Core in-memory domain uses null. Keeping the sentinel here
|
||||
// makes a newly joined general differ from the same row after a
|
||||
// daemon reload and prevents the monthly speciality handler from
|
||||
// recognizing the empty slot until that reload.
|
||||
specialDomestic: normalizeJoinSpecialityCode(configConst.defaultSpecialDomestic),
|
||||
specialWar,
|
||||
items: {
|
||||
horse: null,
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
buildJoinCreateGeneralSeed,
|
||||
cutJoinTurnTime,
|
||||
JOIN_WELCOME_MESSAGE,
|
||||
normalizeJoinSpecialityCode,
|
||||
resolveJoinTurnTime,
|
||||
} from '../src/turn/joinCreateGeneralService.js';
|
||||
|
||||
@@ -53,4 +54,11 @@ describe('generic join legacy time contracts', () => {
|
||||
expect(JOIN_WELCOME_MESSAGE).toBe('삼국지 모의전투 HiDCHe의 세계에 오신 것을 환영합니다 ^o^');
|
||||
expect(JOIN_WELCOME_MESSAGE).not.toContain('PHP');
|
||||
});
|
||||
|
||||
it('normalizes the Ref empty-speciality sentinel at the join boundary', () => {
|
||||
expect(normalizeJoinSpecialityCode(undefined)).toBeNull();
|
||||
expect(normalizeJoinSpecialityCode('')).toBeNull();
|
||||
expect(normalizeJoinSpecialityCode('None')).toBeNull();
|
||||
expect(normalizeJoinSpecialityCode('che_견고')).toBe('che_견고');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -62,7 +62,8 @@ export const ADMIN_CAPABILITIES: readonly AdminCapabilityDefinition[] = [
|
||||
{
|
||||
permission: 'admin.scenarios.reset',
|
||||
label: '시나리오 초기화',
|
||||
description: '지정 profile의 현재 배포 버전으로 게임 DB와 시나리오를 초기화합니다.',
|
||||
description:
|
||||
'지정 profile의 현재 배포 버전으로 게임 DB와 시나리오를 초기화하고, 천하통일 서버를 닫아 정리합니다.',
|
||||
risk: 'CRITICAL',
|
||||
scope: 'PROFILE',
|
||||
},
|
||||
@@ -123,6 +124,7 @@ export const resolveAdminActionCapability = (path: string, rawInput?: unknown):
|
||||
if (path.endsWith('.profiles.requestAction') && rawInput && typeof rawInput === 'object') {
|
||||
const action = (rawInput as { action?: unknown }).action;
|
||||
if (action === 'RESET_SCHEDULED') return 'admin.reset.schedule';
|
||||
if (action === 'CLOSE_COMPLETED') return 'admin.scenarios.reset';
|
||||
if (action === 'RESUME') return 'admin.resume.when-stopped';
|
||||
if (action === 'UPDATE_RUNTIME_SETTINGS') return 'admin.profiles.runtime';
|
||||
if (action === 'OPEN_SURVEY') return 'admin.survey.open';
|
||||
|
||||
@@ -45,6 +45,7 @@ const zServerAction = z.enum([
|
||||
'RESUME',
|
||||
'PAUSE',
|
||||
'STOP',
|
||||
'CLOSE_COMPLETED',
|
||||
'ACCELERATE',
|
||||
'DELAY',
|
||||
'UPDATE_RUNTIME_SETTINGS',
|
||||
@@ -2227,6 +2228,7 @@ export const adminRouter = router({
|
||||
canManageProfiles || hasScopedPermission(adminAuth, ROLE_RESUME_WHEN_STOPPED, profile.profileName);
|
||||
const canOpenSurvey =
|
||||
canManageProfiles || hasScopedPermission(adminAuth, ROLE_SURVEY_OPEN, profile.profileName);
|
||||
const canResetScenario = hasScopedPermission(adminAuth, ROLE_ADMIN_SCENARIO_RESET, profile.profileName);
|
||||
|
||||
if (input.action === 'RESUME') {
|
||||
if (profile.status !== 'STOPPED' && profile.status !== 'PAUSED') {
|
||||
@@ -2257,6 +2259,28 @@ export const adminRouter = router({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Stop is allowed only while the profile runtime is available.',
|
||||
});
|
||||
} else if (input.action === 'CLOSE_COMPLETED') {
|
||||
if (!canResetScenario) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Scenario reset permission is required.',
|
||||
});
|
||||
}
|
||||
if (!gatewayProfileCapabilities(profile.status).runtimeExpected) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Completed cleanup is allowed only while the profile runtime is available.',
|
||||
});
|
||||
}
|
||||
const [runtimeSettings] =
|
||||
(await ctx.orchestrator.listRuntimeSettings?.([profile.profileName])) ?? [];
|
||||
const isUnited = profile.status === 'COMPLETED' || Number(runtimeSettings?.isUnited ?? 0) !== 0;
|
||||
if (!isUnited) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Only a unified game can be closed by a scenario opener.',
|
||||
});
|
||||
}
|
||||
} else if (input.action === 'OPEN_SURVEY') {
|
||||
if (!canOpenSurvey) {
|
||||
throw new TRPCError({
|
||||
@@ -2323,6 +2347,7 @@ export const adminRouter = router({
|
||||
RESUME: 'RUNNING',
|
||||
PAUSE: 'PAUSED',
|
||||
STOP: 'STOPPED',
|
||||
CLOSE_COMPLETED: 'STOPPED',
|
||||
SHUTDOWN: 'DISABLED',
|
||||
} as const;
|
||||
const mappedStatus = statusMap[input.action as keyof typeof statusMap];
|
||||
|
||||
@@ -111,6 +111,8 @@ export interface ProfileRuntimeSnapshot extends ProfileRuntimeState {
|
||||
|
||||
export interface ProfileRuntimeSettingsSnapshot {
|
||||
profileName: string;
|
||||
/** Ref game_env.isunited compatibility value. Any non-zero value means unification has begun. */
|
||||
isUnited: number;
|
||||
turnTermMinutes: number;
|
||||
blockGeneralCreate: 0 | 1 | 2;
|
||||
autorunUser: {
|
||||
@@ -933,6 +935,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
);
|
||||
return {
|
||||
profileName,
|
||||
isUnited: Number(meta.isunited ?? meta.isUnited ?? 0),
|
||||
turnTermMinutes: Math.max(1, Math.round(row.tickSeconds / 60)),
|
||||
blockGeneralCreate,
|
||||
autorunUser:
|
||||
|
||||
@@ -31,6 +31,7 @@ const buildCaller = async (
|
||||
initialProfileStatus?: GatewayProfileRecord['status'];
|
||||
profileScenario?: string | null;
|
||||
profileMeta?: GatewayProfileRecord['meta'];
|
||||
gameIsUnited?: number;
|
||||
releaseCommitSha?: string;
|
||||
initialOperation?: GatewayOperationRecord;
|
||||
profileLogVisibilityAfterPolls?: number;
|
||||
@@ -291,6 +292,7 @@ const buildCaller = async (
|
||||
listRuntimeSettings: async () => [
|
||||
{
|
||||
profileName: 'che:2',
|
||||
isUnited: options.gameIsUnited ?? 0,
|
||||
turnTermMinutes: 20,
|
||||
blockGeneralCreate: 2,
|
||||
autorunUser: { limitMinutes: 720, options: ['develop', 'recruit_high', 'chief'] },
|
||||
@@ -405,6 +407,7 @@ describe('admin profile navigation API', () => {
|
||||
|
||||
expect(result[0]?.runtimeSettings).toEqual({
|
||||
profileName: 'che:2',
|
||||
isUnited: 0,
|
||||
turnTermMinutes: 20,
|
||||
blockGeneralCreate: 2,
|
||||
autorunUser: { limitMinutes: 720, options: ['develop', 'recruit_high', 'chief'] },
|
||||
@@ -1427,6 +1430,63 @@ describe('admin runtime clock action API', () => {
|
||||
expect(harness.getReconcileCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('lets a scoped scenario opener close a unified game without runtime authority', async () => {
|
||||
const harness = await buildCaller(unusedCreateOperation, {
|
||||
adminRoles: ['user', 'admin.scenarios.reset:che:2'],
|
||||
firstUserIsAdmin: false,
|
||||
initialProfileStatus: 'RUNNING',
|
||||
gameIsUnited: 2,
|
||||
});
|
||||
|
||||
await expect(
|
||||
harness.caller.admin.profiles.requestAction({
|
||||
profileName: 'che:2',
|
||||
action: 'CLOSE_COMPLETED',
|
||||
})
|
||||
).resolves.toMatchObject({ ok: true });
|
||||
expect(harness.updatedStatuses).toEqual(['STOPPED']);
|
||||
expect(harness.getReconcileCount()).toBe(1);
|
||||
expect(harness.auditEvents.at(-1)).toMatchObject({ capability: 'admin.scenarios.reset' });
|
||||
});
|
||||
|
||||
it('does not let a scenario opener close a game before unification', async () => {
|
||||
const harness = await buildCaller(unusedCreateOperation, {
|
||||
adminRoles: ['user', 'admin.scenarios.reset:che:2'],
|
||||
firstUserIsAdmin: false,
|
||||
initialProfileStatus: 'RUNNING',
|
||||
gameIsUnited: 0,
|
||||
});
|
||||
|
||||
await expect(
|
||||
harness.caller.admin.profiles.requestAction({
|
||||
profileName: 'che:2',
|
||||
action: 'CLOSE_COMPLETED',
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Only a unified game can be closed by a scenario opener.',
|
||||
});
|
||||
expect(harness.updatedStatuses).toEqual([]);
|
||||
expect(harness.getReconcileCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('does not turn runtime authority into scenario-opener cleanup authority', async () => {
|
||||
const harness = await buildCaller(unusedCreateOperation, {
|
||||
adminRoles: ['user', 'admin.profiles.runtime:che:2'],
|
||||
firstUserIsAdmin: false,
|
||||
initialProfileStatus: 'RUNNING',
|
||||
gameIsUnited: 2,
|
||||
});
|
||||
|
||||
await expect(
|
||||
harness.caller.admin.profiles.requestAction({
|
||||
profileName: 'che:2',
|
||||
action: 'CLOSE_COMPLETED',
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
||||
expect(harness.updatedStatuses).toEqual([]);
|
||||
});
|
||||
|
||||
it('creates a first-class clock action owned by the authenticated administrator', async () => {
|
||||
const harness = await buildCaller(unusedCreateOperation);
|
||||
|
||||
|
||||
@@ -37,8 +37,10 @@ const installFixture = async (
|
||||
initialActions?: RuntimeAction[];
|
||||
afterRequestActions?: RuntimeAction[];
|
||||
pendingProfileReads?: number;
|
||||
profileStatus?: 'RUNNING' | 'PAUSED' | 'STOPPED';
|
||||
profileStatus?: 'RUNNING' | 'PAUSED' | 'COMPLETED' | 'STOPPED';
|
||||
currentScenario?: string | null;
|
||||
gameIsUnited?: number;
|
||||
openerOnly?: boolean;
|
||||
} = {}
|
||||
) => {
|
||||
let requested = false;
|
||||
@@ -46,6 +48,7 @@ const installFixture = async (
|
||||
let installActive = false;
|
||||
let postRequestProfileReads = 0;
|
||||
let requestedRuntimeSettings = false;
|
||||
let completedCloseRequested = false;
|
||||
const requestBodies: unknown[] = [];
|
||||
let releaseRequest = (): void => {};
|
||||
const requestGate = options.deferRequest
|
||||
@@ -67,7 +70,9 @@ const installFixture = async (
|
||||
const operations = operationNames(route);
|
||||
if (operations.includes('admin.profiles.requestAction')) {
|
||||
requested = true;
|
||||
requestedRuntimeSettings = JSON.stringify(body).includes('UPDATE_RUNTIME_SETTINGS');
|
||||
const requestJson = JSON.stringify(body);
|
||||
requestedRuntimeSettings = requestJson.includes('UPDATE_RUNTIME_SETTINGS');
|
||||
completedCloseRequested = requestJson.includes('CLOSE_COMPLETED');
|
||||
requestBodies.push(body);
|
||||
await requestGate;
|
||||
}
|
||||
@@ -94,7 +99,7 @@ const installFixture = async (
|
||||
return response({ enabled: true });
|
||||
}
|
||||
if (operation === 'admin.capabilities.list') {
|
||||
return response([
|
||||
const capabilities = [
|
||||
{
|
||||
permission: 'admin.users.manage',
|
||||
label: '사용자·제재 관리',
|
||||
@@ -134,7 +139,12 @@ const installFixture = async (
|
||||
scope: 'PROFILE',
|
||||
scopes: ['*'],
|
||||
},
|
||||
]);
|
||||
];
|
||||
return response(
|
||||
options.openerOnly
|
||||
? capabilities.filter((entry) => entry.permission === 'admin.scenarios.reset')
|
||||
: capabilities
|
||||
);
|
||||
}
|
||||
if (operation === 'admin.profiles.listScenarios') {
|
||||
return response([
|
||||
@@ -170,11 +180,12 @@ const installFixture = async (
|
||||
currentScenario: options.currentScenario === undefined ? '1010' : options.currentScenario,
|
||||
scenario: options.currentScenario ?? 'default',
|
||||
apiPort: 15015,
|
||||
status: options.profileStatus ?? 'RUNNING',
|
||||
status: completedCloseRequested ? 'STOPPED' : (options.profileStatus ?? 'RUNNING'),
|
||||
buildStatus: 'SUCCEEDED',
|
||||
meta: {},
|
||||
runtimeSettings: requestedRuntimeSettings
|
||||
? {
|
||||
isUnited: options.gameIsUnited ?? 0,
|
||||
turnTermMinutes: 20,
|
||||
blockGeneralCreate: 1,
|
||||
autorunUser: {
|
||||
@@ -183,6 +194,7 @@ const installFixture = async (
|
||||
},
|
||||
}
|
||||
: {
|
||||
isUnited: options.gameIsUnited ?? 0,
|
||||
turnTermMinutes: 10,
|
||||
blockGeneralCreate: 2,
|
||||
autorunUser: null,
|
||||
@@ -384,6 +396,63 @@ test('distinguishes a turn pause from an inaccessible stopped server in operator
|
||||
await expect(page.getByRole('button', { name: '중지', exact: true })).toBeEnabled();
|
||||
});
|
||||
|
||||
test('lets a scenario opener close only a unified server and keeps the control usable on mobile', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const fixture = await installFixture(page, { gameIsUnited: 2, openerOnly: true });
|
||||
|
||||
await page.goto('/gateway/admin/servers/hwe%3Adefault');
|
||||
await expect(page.getByTestId('profile-lifecycle-description')).toContainText('천하통일 완료');
|
||||
await expect(page.getByTestId('runtime-settings')).toHaveCount(0);
|
||||
const cleanup = page.getByTestId('completed-profile-cleanup');
|
||||
await expect(cleanup).toContainText('현재 기수 DB와 완료 기록은 보존');
|
||||
const submit = page.getByTestId('completed-profile-cleanup-submit');
|
||||
const desktopGeometry = await cleanup.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
left: rect.left,
|
||||
right: rect.right,
|
||||
viewport: window.innerWidth,
|
||||
fontSize: style.fontSize,
|
||||
color: style.color,
|
||||
};
|
||||
});
|
||||
expect(desktopGeometry.left).toBeGreaterThanOrEqual(0);
|
||||
expect(desktopGeometry.right).toBeLessThanOrEqual(desktopGeometry.viewport);
|
||||
expect(desktopGeometry.fontSize).toBe('14px');
|
||||
expect(desktopGeometry.color).not.toBe('rgba(0, 0, 0, 0)');
|
||||
const buttonBackground = await submit.evaluate((element) => getComputedStyle(element).backgroundColor);
|
||||
await submit.hover();
|
||||
const hoverBackground = await submit.evaluate((element) => getComputedStyle(element).backgroundColor);
|
||||
expect(hoverBackground).not.toBe(buttonBackground);
|
||||
await submit.focus();
|
||||
await expect(submit).toBeFocused();
|
||||
await page.screenshot({ path: testInfo.outputPath('completed-profile-cleanup-desktop.png'), fullPage: true });
|
||||
|
||||
await submit.click();
|
||||
await expect.poll(() => JSON.stringify(fixture.requestBodies)).toContain('CLOSE_COMPLETED');
|
||||
await expect(page.getByTestId('completed-profile-cleanup')).toHaveCount(0);
|
||||
await expect(page.getByTestId('profile-lifecycle-description')).toContainText('게임 접근 불가');
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const lifecycle = page.getByTestId('profile-lifecycle-description');
|
||||
const geometry = await lifecycle.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return { left: rect.left, right: rect.right, viewport: window.innerWidth };
|
||||
});
|
||||
expect(geometry.left).toBeGreaterThanOrEqual(0);
|
||||
expect(geometry.right).toBeLessThanOrEqual(geometry.viewport);
|
||||
await page.screenshot({ path: testInfo.outputPath('completed-profile-cleanup-mobile.png'), fullPage: true });
|
||||
});
|
||||
|
||||
test('does not offer completed cleanup to a scenario opener before unification', async ({ page }) => {
|
||||
await installFixture(page, { gameIsUnited: 0, openerOnly: true });
|
||||
|
||||
await page.goto('/gateway/admin/servers/hwe%3Adefault');
|
||||
await expect(page.getByTestId('completed-profile-cleanup')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('shows an initialized STOPPED server as inaccessible and only restartable', async ({ page }) => {
|
||||
await installFixture(page, { profileStatus: 'STOPPED' });
|
||||
|
||||
|
||||
@@ -502,7 +502,7 @@ test('does not contact a STOPPED game runtime and labels it inaccessible', async
|
||||
await page.screenshot({ path: testInfo.outputPath('gateway-stopped-profile-lobby.png'), fullPage: true });
|
||||
});
|
||||
|
||||
test('shows a compact upcoming reset announcement without contacting the stopped runtime', async ({
|
||||
test('matches the normal preopen copy text and labels an immediate announcement with its build time', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const gameOperations = await installFixture(page, {
|
||||
@@ -525,15 +525,15 @@ test('shows a compact upcoming reset announcement without contacting the stopped
|
||||
options: ['develop', 'battle'],
|
||||
},
|
||||
},
|
||||
includeStoppedProfile: true,
|
||||
});
|
||||
await page.setViewportSize({ width: 1200, height: 900 });
|
||||
|
||||
await page.goto('lobby');
|
||||
const row = page.locator('tbody tr').filter({ hasText: '체섭' });
|
||||
const announcement = row.getByTestId('upcoming-reset-announcement');
|
||||
const marker = announcement.getByTestId('upcoming-reset-options-marker');
|
||||
const swatch = announcement.getByTestId('upcoming-reset-options-swatch');
|
||||
const tooltip = announcement.getByTestId('upcoming-reset-options-tooltip');
|
||||
const badge = announcement.getByTestId('reserved-announcement-badge');
|
||||
const tooltip = announcement.getByTestId('reserved-announcement-build-tooltip');
|
||||
await expect(row.getByTestId('upcoming-reset-phase')).toHaveCount(0);
|
||||
await expect(row.getByTestId('upcoming-reset-scheduled-at')).toHaveCount(0);
|
||||
await expect(row.getByTestId('upcoming-reset-preopen-at')).toHaveText('- 가오픈 일시 : 2026-08-27 14:30:00 -');
|
||||
@@ -542,14 +542,15 @@ test('shows a compact upcoming reset announcement without contacting the stopped
|
||||
await expect(
|
||||
row.getByTestId('upcoming-reset-scenario-announcement').locator(':scope > .text-green-400')
|
||||
).toHaveText('60분 턴 서버');
|
||||
expect(
|
||||
(await announcement.evaluate((element) => (element as HTMLElement).innerText)).replace(/\s+/g, ' ').trim()
|
||||
).toBe(
|
||||
'- 가오픈 일시 : 2026-08-27 14:30:00 - - 오픈 일시 : 2026-08-27 20:00:00 - 【가상】황건적의 난 60분 턴 서버'
|
||||
await expect(announcement.locator('.profile-announcement-settings')).toHaveText(
|
||||
'(상성 설정:가상), (빙의 여부:가능), (최대 스탯:70), ' +
|
||||
'(기타 설정:랜덤 임관, 자율행동[내정, 출병, 24시간 유효])'
|
||||
);
|
||||
await expect(tooltip).toBeHidden();
|
||||
await expect(marker).toHaveAttribute('aria-label', '초기화 옵션 보기');
|
||||
await expect(swatch).toHaveCSS('background-color', 'rgb(217, 119, 6)');
|
||||
await expect(badge).toHaveText(/예약 공지/);
|
||||
await expect(badge).toHaveAttribute('aria-label', '예약 공지의 실제 빌드 시작 시각 보기');
|
||||
await expect(badge).toHaveCSS('user-select', 'none');
|
||||
await expect(badge).toHaveCSS('border-top-color', 'rgb(217, 119, 6)');
|
||||
await expect(row).not.toContainText('서버 중지 · 접근 불가');
|
||||
await expect(row).not.toContainText('정보를 불러오는 중');
|
||||
await expect(row.getByRole('button', { name: '입장' })).toHaveCount(0);
|
||||
@@ -557,39 +558,63 @@ test('shows a compact upcoming reset announcement without contacting the stopped
|
||||
expect(gameOperations).toEqual([]);
|
||||
|
||||
const desktopGeometry = await announcement.evaluate((element) => {
|
||||
const marker = element.querySelector<HTMLElement>('[data-testid="upcoming-reset-options-marker"]');
|
||||
const swatch = element.querySelector<HTMLElement>('[data-testid="upcoming-reset-options-swatch"]');
|
||||
if (!marker || !swatch) throw new Error('expected upcoming reset option marker');
|
||||
const badge = element.querySelector<HTMLElement>('[data-testid="reserved-announcement-badge"]');
|
||||
if (!badge) throw new Error('expected reserved announcement badge');
|
||||
return {
|
||||
announcement: element.getBoundingClientRect().toJSON(),
|
||||
cell: element.parentElement?.getBoundingClientRect().toJSON(),
|
||||
marker: marker.getBoundingClientRect().toJSON(),
|
||||
swatch: swatch.getBoundingClientRect().toJSON(),
|
||||
badge: badge.getBoundingClientRect().toJSON(),
|
||||
documentWidth: document.documentElement.scrollWidth,
|
||||
viewportWidth: window.innerWidth,
|
||||
};
|
||||
});
|
||||
expect(desktopGeometry.announcement.left).toBeGreaterThanOrEqual(desktopGeometry.cell?.left ?? 0);
|
||||
expect(desktopGeometry.announcement.right).toBeLessThanOrEqual(desktopGeometry.cell?.right ?? 0);
|
||||
expect(desktopGeometry.marker.width).toBe(18);
|
||||
expect(desktopGeometry.marker.height).toBe(18);
|
||||
expect(desktopGeometry.swatch.width).toBe(8);
|
||||
expect(desktopGeometry.swatch.height).toBe(8);
|
||||
await marker.hover();
|
||||
expect(desktopGeometry.badge.width).toBeGreaterThan(40);
|
||||
expect(desktopGeometry.badge.height).toBe(18);
|
||||
const desktopRowDivider = await page
|
||||
.locator('tbody tr')
|
||||
.first()
|
||||
.evaluate((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
return { color: style.borderBottomColor, width: style.borderBottomWidth };
|
||||
});
|
||||
expect(desktopRowDivider).toEqual({ color: 'rgb(82, 82, 91)', width: '1px' });
|
||||
await badge.hover();
|
||||
await expect(tooltip).toBeVisible();
|
||||
await expect(tooltip).toContainText('초기화 옵션');
|
||||
await expect(tooltip).toContainText('상성 설정: 가상 · 빙의 여부: 가능 · 최대 스탯: 70');
|
||||
await expect(tooltip).toContainText('기타 설정: 랜덤 임관 · 자율행동: 내정, 출병, 24시간 유효');
|
||||
await expect(tooltip).not.toContainText('초기화 시작');
|
||||
await expect(tooltip).not.toContainText('빌드 대기');
|
||||
await expect(tooltip).toHaveText('실제 빌드 시작 : 2026-08-27 14:00:00');
|
||||
await expect(tooltip).not.toContainText('초기화 옵션');
|
||||
const desktopTooltipGeometry = await tooltip.evaluate((element) => element.getBoundingClientRect().toJSON());
|
||||
expect(desktopTooltipGeometry.left).toBeGreaterThanOrEqual(8);
|
||||
expect(desktopTooltipGeometry.right).toBeLessThanOrEqual(desktopGeometry.viewportWidth - 8);
|
||||
expect(desktopTooltipGeometry.top).toBeGreaterThanOrEqual(desktopGeometry.cell?.top ?? 0);
|
||||
expect(desktopTooltipGeometry.bottom).toBeLessThanOrEqual(desktopGeometry.cell?.bottom ?? 0);
|
||||
await marker.focus();
|
||||
await expect(marker).toBeFocused();
|
||||
await expect(marker).toHaveCSS('outline-width', '2px');
|
||||
await badge.focus();
|
||||
await expect(badge).toBeFocused();
|
||||
await expect(badge).toHaveCSS('outline-width', '2px');
|
||||
|
||||
await page.mouse.click(8, 8);
|
||||
const selectionStart = await announcement.getByTestId('upcoming-reset-preopen-at').boundingBox();
|
||||
const selectionEnd = await announcement.locator('.profile-announcement-settings').boundingBox();
|
||||
if (!selectionStart || !selectionEnd) throw new Error('expected upcoming announcement selection geometry');
|
||||
await page.mouse.move(selectionStart.x + 1, selectionStart.y + selectionStart.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(selectionEnd.x + selectionEnd.width - 1, selectionEnd.y + selectionEnd.height / 2, {
|
||||
steps: 24,
|
||||
});
|
||||
await page.mouse.up();
|
||||
const compactSelection = (await page.evaluate(() => window.getSelection()?.toString() ?? ''))
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
expect(compactSelection).toBe(
|
||||
'- 가오픈 일시 : 2026-08-27 14:30:00 - - 오픈 일시 : 2026-08-27 20:00:00 - ' +
|
||||
'【가상】황건적의 난 60분 턴 서버 ' +
|
||||
'(상성 설정:가상), (빙의 여부:가능), (최대 스탯:70), ' +
|
||||
'(기타 설정:랜덤 임관, 자율행동[내정, 출병, 24시간 유효])'
|
||||
);
|
||||
expect(compactSelection).not.toContain('예약 공지');
|
||||
expect(compactSelection).not.toContain('실제 빌드 시작');
|
||||
await page.evaluate(() => window.getSelection()?.removeAllRanges());
|
||||
await page.screenshot({ path: testInfo.outputPath('gateway-upcoming-reset-desktop.png'), fullPage: true });
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
@@ -606,7 +631,7 @@ test('shows a compact upcoming reset announcement without contacting the stopped
|
||||
expect(mobileGeometry.left).toBeGreaterThanOrEqual(0);
|
||||
expect(mobileGeometry.right).toBeLessThanOrEqual(mobileGeometry.viewportWidth);
|
||||
expect(mobileGeometry.documentWidth).toBe(mobileGeometry.viewportWidth);
|
||||
await marker.hover();
|
||||
await badge.hover();
|
||||
await expect(tooltip).toBeVisible();
|
||||
const mobileTooltipGeometry = await tooltip.evaluate((element) => element.getBoundingClientRect().toJSON());
|
||||
expect(mobileTooltipGeometry.left).toBeGreaterThanOrEqual(8);
|
||||
@@ -636,12 +661,14 @@ test('keeps the announcement through the RESERVED handoff after the build comple
|
||||
|
||||
await page.goto('lobby');
|
||||
const row = page.locator('tbody tr').filter({ hasText: 'hwe섭' });
|
||||
const marker = row.getByTestId('upcoming-reset-options-marker');
|
||||
const badge = row.getByTestId('reserved-announcement-badge');
|
||||
await expect(row.getByTestId('upcoming-reset-phase')).toHaveCount(0);
|
||||
await expect(row.getByTestId('upcoming-reset-scenario-title')).toHaveText('【가상】황건적의 난');
|
||||
await marker.hover();
|
||||
await expect(row.getByTestId('upcoming-reset-options-tooltip')).toContainText('초기화 옵션');
|
||||
await expect(row.getByTestId('upcoming-reset-options-tooltip')).not.toContainText('오픈 준비 완료');
|
||||
await badge.hover();
|
||||
await expect(row.getByTestId('reserved-announcement-build-tooltip')).toHaveText(
|
||||
'실제 빌드 시작 : 2026-08-27 14:00:00'
|
||||
);
|
||||
await expect(row.getByTestId('reserved-announcement-build-tooltip')).not.toContainText('오픈 준비 완료');
|
||||
await expect(row).not.toContainText('준 비 중 · 접근 불가');
|
||||
await expect(row.getByRole('button', { name: '입장' })).toHaveCount(0);
|
||||
expect(gameOperations).toEqual([]);
|
||||
@@ -690,6 +717,8 @@ test('uses two-row mobile server cards without horizontal scrolling and keeps re
|
||||
const rowRect = row.getBoundingClientRect();
|
||||
const buttonRect = button.getBoundingClientRect();
|
||||
const style = getComputedStyle(button);
|
||||
const bodyStyle = getComputedStyle(scrollElement.querySelector('tbody')!);
|
||||
const firstRowStyle = getComputedStyle(rows[0]!);
|
||||
const rect = (element: Element) => {
|
||||
const value = element.getBoundingClientRect();
|
||||
return { top: value.top, right: value.right, bottom: value.bottom, left: value.left, width: value.width };
|
||||
@@ -715,6 +744,10 @@ test('uses two-row mobile server cards without horizontal scrolling and keeps re
|
||||
viewportWidth: window.innerWidth,
|
||||
outlineStyle: style.outlineStyle,
|
||||
outlineWidth: style.outlineWidth,
|
||||
rowGap: bodyStyle.rowGap,
|
||||
dividerBackground: bodyStyle.backgroundColor,
|
||||
firstRowBorderColor: firstRowStyle.borderBottomColor,
|
||||
firstRowBorderWidth: firstRowStyle.borderBottomWidth,
|
||||
};
|
||||
});
|
||||
expect(geometry.pageScrollWidth).toBe(geometry.viewportWidth);
|
||||
@@ -724,6 +757,10 @@ test('uses two-row mobile server cards without horizontal scrolling and keeps re
|
||||
expect(geometry.rows).toHaveLength(2);
|
||||
expect(geometry.rows[0]?.bottom).toBeLessThanOrEqual(geometry.rows[1]?.top ?? 0);
|
||||
expect(geometry.rows.every((item) => item.width === geometry.scroll.clientWidth)).toBe(true);
|
||||
expect(geometry.rowGap).toBe('2px');
|
||||
expect(geometry.dividerBackground).toBe('rgb(63, 63, 70)');
|
||||
expect(geometry.firstRowBorderColor).toBe('rgb(82, 82, 91)');
|
||||
expect(geometry.firstRowBorderWidth).toBe('1px');
|
||||
expect(geometry.button.left).toBeGreaterThanOrEqual(geometry.scroll.left);
|
||||
expect(geometry.button.right).toBeLessThanOrEqual(geometry.scroll.right);
|
||||
expect(geometry.cells.server.top).toBeCloseTo(geometry.cells.info.top, 0);
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
<script setup lang="ts">
|
||||
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
|
||||
import { computed } from 'vue';
|
||||
|
||||
type AutorunUser = {
|
||||
limitMinutes: number;
|
||||
options: string[];
|
||||
};
|
||||
|
||||
const props = defineProps<{
|
||||
profileName: string;
|
||||
preopenAt: string | null | undefined;
|
||||
openAt: string | null | undefined;
|
||||
scenarioTitle: string;
|
||||
turnTermMinutes: number;
|
||||
fictionMode: string;
|
||||
npcMode: number;
|
||||
defaultStatTotal: number;
|
||||
otherTextInfo: string;
|
||||
autorunUser: AutorunUser | null;
|
||||
scheduledAt?: string | null;
|
||||
testIdPrefix: 'profile' | 'upcoming-reset';
|
||||
}>();
|
||||
|
||||
const formatAnnouncementDate = (value: string | null | undefined): string =>
|
||||
formatServerDateTime(value, { fallback: '-' });
|
||||
const npcModeText = (mode: number): string => ['불가', '가능', '선택 생성'][mode] ?? '불가';
|
||||
const autorunDetailText = (autorun: AutorunUser): string => {
|
||||
const enabled = new Set(autorun.options);
|
||||
const labels: string[] = [];
|
||||
if (enabled.has('develop')) labels.push('내정');
|
||||
if (enabled.has('warp')) labels.push('순간이동');
|
||||
if (enabled.has('recruit_high')) labels.push('모병');
|
||||
else if (enabled.has('recruit')) labels.push('징병');
|
||||
if (enabled.has('train')) labels.push('훈련/사기진작');
|
||||
if (enabled.has('battle')) labels.push('출병');
|
||||
if (enabled.has('chief')) labels.push('사령턴');
|
||||
|
||||
const limit =
|
||||
autorun.limitMinutes >= 43_200
|
||||
? '항상 유효'
|
||||
: autorun.limitMinutes % 60 === 0
|
||||
? `${autorun.limitMinutes / 60}시간 유효`
|
||||
: `${autorun.limitMinutes}분 유효`;
|
||||
labels.push(limit);
|
||||
return labels.join(', ');
|
||||
};
|
||||
const safeProfileName = computed(() => props.profileName.replaceAll(/[^a-zA-Z0-9_-]/g, '-'));
|
||||
const autorunTooltipId = computed(() => `profile-autorun-${props.testIdPrefix}-${safeProfileName.value}`);
|
||||
const buildTimeTooltipId = computed(() => `reserved-announcement-build-time-${safeProfileName.value}`);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="preopen-announcement" :data-testid="`${testIdPrefix}-announcement`">
|
||||
<div v-if="preopenAt" :data-testid="`${testIdPrefix}-preopen-at`">
|
||||
- 가오픈 일시 : {{ formatAnnouncementDate(preopenAt) }} -
|
||||
</div>
|
||||
<div :data-testid="`${testIdPrefix}-open-at`">- 오픈 일시 : {{ formatAnnouncementDate(openAt) }} -</div>
|
||||
<div :data-testid="`${testIdPrefix}-scenario-announcement`">
|
||||
<span class="text-orange-400" :data-testid="`${testIdPrefix}-scenario-title`">{{ scenarioTitle }}</span
|
||||
>{{ ' ' }}
|
||||
<span class="text-green-400">{{ turnTermMinutes }}분 턴 서버</span>
|
||||
<span
|
||||
v-if="scheduledAt"
|
||||
class="reserved-announcement-badge"
|
||||
tabindex="0"
|
||||
aria-label="예약 공지의 실제 빌드 시작 시각 보기"
|
||||
:aria-describedby="buildTimeTooltipId"
|
||||
data-testid="reserved-announcement-badge"
|
||||
>
|
||||
예약 공지
|
||||
<span
|
||||
:id="buildTimeTooltipId"
|
||||
class="reserved-announcement-build-tooltip"
|
||||
role="tooltip"
|
||||
data-testid="reserved-announcement-build-tooltip"
|
||||
>실제 빌드 시작 : {{ formatAnnouncementDate(scheduledAt) }}</span
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
<div class="profile-announcement-settings text-xs text-zinc-500">
|
||||
(상성 설정:{{ fictionMode }}), (빙의 여부:{{ npcModeText(npcMode) }}), (최대 스탯:{{ defaultStatTotal }}),
|
||||
(기타 설정:<template v-if="otherTextInfo"
|
||||
>{{ otherTextInfo }}<template v-if="autorunUser">, </template></template
|
||||
><span v-if="autorunUser" class="copyable-autorun" tabindex="0" :aria-describedby="autorunTooltipId"
|
||||
>자율행동<span :id="autorunTooltipId" class="copyable-autorun-detail" role="tooltip"
|
||||
><span class="copyable-autorun-bracket">[</span><span>{{ autorunDetailText(autorunUser) }}</span
|
||||
><span class="copyable-autorun-bracket">]</span></span
|
||||
></span
|
||||
>)
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.preopen-announcement {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.reserved-announcement-badge {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: 6px;
|
||||
padding: 1px 4px;
|
||||
border: 1px solid #d97706;
|
||||
border-radius: 2px;
|
||||
background: rgb(120 53 15 / 35%);
|
||||
color: #fbbf24;
|
||||
cursor: help;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
line-height: 14px;
|
||||
user-select: none;
|
||||
vertical-align: text-bottom;
|
||||
}
|
||||
|
||||
.reserved-announcement-build-tooltip {
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
bottom: calc(100% + 6px);
|
||||
left: 50%;
|
||||
visibility: hidden;
|
||||
box-sizing: border-box;
|
||||
width: max-content;
|
||||
max-width: min(320px, calc(100vw - 32px));
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #52525b;
|
||||
border-radius: 4px;
|
||||
background: #18181b;
|
||||
box-shadow: 0 4px 12px rgb(0 0 0 / 45%);
|
||||
color: #f4f4f5;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 1.4;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
text-align: left;
|
||||
transform: translateX(-50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.reserved-announcement-badge:hover .reserved-announcement-build-tooltip,
|
||||
.reserved-announcement-badge:focus-visible .reserved-announcement-build-tooltip {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.reserved-announcement-badge:focus-visible {
|
||||
outline: 2px solid #fdba74;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.copyable-autorun {
|
||||
position: relative;
|
||||
cursor: help;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.copyable-autorun-detail {
|
||||
display: inline;
|
||||
color: transparent;
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
.copyable-autorun-bracket {
|
||||
color: transparent;
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
.copyable-autorun:hover .copyable-autorun-detail,
|
||||
.copyable-autorun:focus-visible .copyable-autorun-detail {
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
right: 0;
|
||||
bottom: calc(100% + 6px);
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
width: max-content;
|
||||
max-width: min(520px, calc(100vw - 32px));
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #52525b;
|
||||
border-radius: 4px;
|
||||
background: #18181b;
|
||||
box-shadow: 0 4px 12px rgb(0 0 0 / 45%);
|
||||
color: #f4f4f5;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
text-align: left;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.copyable-autorun:focus-visible {
|
||||
border-radius: 2px;
|
||||
outline: 2px solid #fdba74;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.reserved-announcement-badge:hover .reserved-announcement-build-tooltip,
|
||||
.reserved-announcement-badge:focus-visible .reserved-announcement-build-tooltip,
|
||||
.copyable-autorun:hover .copyable-autorun-detail,
|
||||
.copyable-autorun:focus-visible .copyable-autorun-detail {
|
||||
position: fixed;
|
||||
top: auto;
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
left: 16px;
|
||||
width: auto;
|
||||
max-width: none;
|
||||
transform: none;
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -217,12 +217,16 @@ type AdminProfile = {
|
||||
}>;
|
||||
};
|
||||
|
||||
type ProfileRuntimeSettings = Pick<ProfileResetDefaults, 'turnTermMinutes' | 'blockGeneralCreate' | 'autorunUser'>;
|
||||
type ProfileRuntimeSettings = Pick<ProfileResetDefaults, 'turnTermMinutes' | 'blockGeneralCreate' | 'autorunUser'> & {
|
||||
isUnited: number;
|
||||
};
|
||||
type ProfileRuntimeSettingsUpdate = Omit<ProfileRuntimeSettings, 'isUnited'>;
|
||||
|
||||
type AdminAction =
|
||||
| 'RESUME'
|
||||
| 'PAUSE'
|
||||
| 'STOP'
|
||||
| 'CLOSE_COMPLETED'
|
||||
| 'ACCELERATE'
|
||||
| 'DELAY'
|
||||
| 'UPDATE_RUNTIME_SETTINGS'
|
||||
@@ -366,7 +370,7 @@ type AdminClient = {
|
||||
profileName: string;
|
||||
action: AdminAction;
|
||||
durationMinutes?: number;
|
||||
runtimeSettings?: ProfileRuntimeSettings;
|
||||
runtimeSettings?: ProfileRuntimeSettingsUpdate;
|
||||
scheduledAt?: string;
|
||||
reason?: string;
|
||||
}) => Promise<{ ok: boolean; action?: AdminProfile['runtimeActions'][number] }>;
|
||||
@@ -446,13 +450,15 @@ const runtimeActionPending = (profile: AdminProfile): boolean => {
|
||||
|
||||
const profileLifecycleText = (profile: AdminProfile): string => {
|
||||
if (profile.currentScenario === null) return 'DB 초기화 전 · 게임 접근 불가';
|
||||
if (profile.status === 'PAUSED') return '턴 일시정지 · 게임 조회와 예약턴 입력 가능 · 운영자 재개 가능';
|
||||
if (profile.status === 'STOPPED') return '서버 프로세스 중지 · 게임 접근 불가 · 운영자 서버 재개 가능';
|
||||
if (profile.status === 'CANCELLED') return '취소 게임 · 접근 및 재개 불가 · 새 시나리오 초기화 필요';
|
||||
if (profile.status === 'DISABLED') return '비활성 · 게임 접근 불가';
|
||||
if (Number(profile.runtimeSettings?.isUnited ?? 0) !== 0)
|
||||
return '천하통일 완료 · 종료 기수 조회 가능 · 외부 목록 정리 대기';
|
||||
if (profile.status === 'PAUSED') return '턴 일시정지 · 게임 조회와 예약턴 입력 가능 · 운영자 재개 가능';
|
||||
if (profile.status === 'RUNNING') return '서버 운영 및 턴 진행 중';
|
||||
if (profile.status === 'PREOPEN') return '서버 접근 가능 · 개장 전 턴 정지';
|
||||
if (profile.status === 'COMPLETED') return '종료 기수 조회 가능 · 턴 정지';
|
||||
if (profile.status === 'CANCELLED') return '취소 게임 · 접근 및 재개 불가 · 새 시나리오 초기화 필요';
|
||||
if (profile.status === 'DISABLED') return '비활성 · 게임 접근 불가';
|
||||
return '준비 중 · 게임 접근 불가';
|
||||
};
|
||||
|
||||
@@ -460,6 +466,9 @@ const canResumeProfile = (profile: AdminProfile): boolean =>
|
||||
profile.currentScenario !== null && gatewayProfileCapabilities(profile.status).operatorResumable;
|
||||
const canPauseProfile = (profile: AdminProfile): boolean => profile.status === 'RUNNING';
|
||||
const canStopProfile = (profile: AdminProfile): boolean => gatewayProfileCapabilities(profile.status).runtimeExpected;
|
||||
const canCloseCompletedProfile = (profile: AdminProfile): boolean =>
|
||||
gatewayProfileCapabilities(profile.status).runtimeExpected &&
|
||||
(profile.status === 'COMPLETED' || Number(profile.runtimeSettings?.isUnited ?? 0) !== 0);
|
||||
|
||||
const validDuration = (profileName: string): boolean => {
|
||||
const value = Number(profileActions.value[profileName]?.durationMinutes);
|
||||
@@ -885,7 +894,7 @@ const requestProfileAction = async (profileName: string, action: AdminAction) =>
|
||||
? serverDateTimeInputToIso(actionState.scheduledAt)
|
||||
: undefined;
|
||||
const reason = actionState?.reason.trim() || undefined;
|
||||
const runtimeSettings: ProfileRuntimeSettings | undefined =
|
||||
const runtimeSettings: ProfileRuntimeSettingsUpdate | undefined =
|
||||
action === 'UPDATE_RUNTIME_SETTINGS' && actionState
|
||||
? {
|
||||
turnTermMinutes: actionState.turnTermMinutes,
|
||||
@@ -2206,6 +2215,29 @@ onMounted(() => {
|
||||
|
||||
<div class="text-xs text-zinc-400">빌드 커밋: {{ profile.buildCommitSha ?? '미지정' }}</div>
|
||||
|
||||
<div
|
||||
v-if="
|
||||
hasCapability('admin.scenarios.reset', profile.profileName) &&
|
||||
canCloseCompletedProfile(profile)
|
||||
"
|
||||
class="rounded border border-amber-700/70 bg-amber-950/30 p-3 text-sm text-amber-100"
|
||||
data-testid="completed-profile-cleanup"
|
||||
>
|
||||
<div class="font-semibold">천하통일 서버 정리</div>
|
||||
<p class="mt-1 text-xs text-amber-200/80">
|
||||
서버를 외부 목록에서 닫고 실행 프로세스를 중지합니다. 현재 기수 DB와 완료 기록은
|
||||
보존되며, 다음 시나리오 초기화로 다시 열 수 있습니다.
|
||||
</p>
|
||||
<button
|
||||
class="mt-3 rounded bg-red-700 px-3 py-2 font-semibold text-white hover:bg-red-600 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
data-testid="completed-profile-cleanup-submit"
|
||||
:disabled="profileActionSubmitting[profile.profileName]"
|
||||
@click="requestProfileAction(profile.profileName, 'CLOSE_COMPLETED')"
|
||||
>
|
||||
완료 서버 닫기
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-3">
|
||||
<div
|
||||
v-if="hasCapability('admin.profiles.settings', profile.profileName)"
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { AppRouter } from '@sammo-ts/gateway-api';
|
||||
import { writeGameSessionTransfer } from '@sammo-ts/common/auth/gameSessionTransfer';
|
||||
import DefaultLayout from '../layouts/DefaultLayout.vue';
|
||||
import MapPreview from '../components/MapPreview.vue';
|
||||
import ProfilePreopenAnnouncement from '../components/ProfilePreopenAnnouncement.vue';
|
||||
import { useToast } from '../composables/useToast';
|
||||
import { trpc } from '../utils/trpc';
|
||||
import { createGameTrpc } from '../utils/gameTrpc';
|
||||
@@ -106,11 +107,8 @@ const handleMapTabKeydown = (event: KeyboardEvent, profileName: string): void =>
|
||||
|
||||
const formatGraceEndsAt = (value: string | null | undefined): string => formatServerDateTime(value);
|
||||
const serverSeasonStatus = (info: LobbyInfo) => resolveServerSeasonStatus(info);
|
||||
const formatAnnouncementDate = (value: string | null | undefined): string =>
|
||||
formatServerDateTime(value, { fallback: '-' });
|
||||
const profileScenarioTitle = (profileName: string): string =>
|
||||
profileDetails.value[profileName]?.scenarioTitle.trim() || '-';
|
||||
const npcModeText = (mode: number): string => ['불가', '가능', '선택 생성'][mode] ?? '불가';
|
||||
const autorunDetailText = (autorun: LobbyInfo['autorunUser']): string => {
|
||||
if (!autorun) return '';
|
||||
|
||||
@@ -133,10 +131,8 @@ const autorunDetailText = (autorun: LobbyInfo['autorunUser']): string => {
|
||||
labels.push(limit);
|
||||
return labels.join(', ');
|
||||
};
|
||||
const autorunTooltipId = (profileName: string, scope = 'current'): string =>
|
||||
'profile-autorun-' + scope + '-' + profileName.replaceAll(/[^a-zA-Z0-9_-]/g, '-');
|
||||
const upcomingResetOptionsTooltipId = (profileName: string): string =>
|
||||
'upcoming-reset-options-' + profileName.replaceAll(/[^a-zA-Z0-9_-]/g, '-');
|
||||
const autorunTooltipId = (profileName: string): string =>
|
||||
'profile-autorun-current-' + profileName.replaceAll(/[^a-zA-Z0-9_-]/g, '-');
|
||||
const isProfileRuntimeAvailable = (profile: LobbyProfile): boolean => profile.lifecycle.userAccessible;
|
||||
const unavailableProfileText = (profile: LobbyProfile): string => {
|
||||
if (!profile.lifecycle.dataInitialized) return '- DB 초기화 전 · 접근 불가 -';
|
||||
@@ -453,7 +449,7 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
|
||||
<th class="px-4 py-3 border-b border-zinc-700 w-32 text-center">선 택</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-zinc-800">
|
||||
<tbody class="profile-table-body">
|
||||
<tr
|
||||
v-for="profile in profiles"
|
||||
:key="profile.profileName"
|
||||
@@ -504,100 +500,47 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
|
||||
|
||||
<!-- Server Info -->
|
||||
<td class="profile-info-cell px-4 py-4 border-r border-zinc-800">
|
||||
<div
|
||||
<ProfilePreopenAnnouncement
|
||||
v-if="profile.upcomingReset"
|
||||
class="upcoming-reset-announcement"
|
||||
data-testid="upcoming-reset-announcement"
|
||||
>
|
||||
<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>
|
||||
<span
|
||||
class="upcoming-reset-options-marker"
|
||||
tabindex="0"
|
||||
aria-label="초기화 옵션 보기"
|
||||
:aria-describedby="upcomingResetOptionsTooltipId(profile.profileName)"
|
||||
data-testid="upcoming-reset-options-marker"
|
||||
>
|
||||
<span
|
||||
class="upcoming-reset-options-swatch"
|
||||
data-testid="upcoming-reset-options-swatch"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span
|
||||
:id="upcomingResetOptionsTooltipId(profile.profileName)"
|
||||
class="upcoming-reset-options-tooltip"
|
||||
role="tooltip"
|
||||
data-testid="upcoming-reset-options-tooltip"
|
||||
>
|
||||
<strong>초기화 옵션</strong>
|
||||
<span
|
||||
>상성 설정: {{ profile.upcomingReset.fictionMode }} · 빙의 여부:
|
||||
{{ npcModeText(profile.upcomingReset.npcMode) }} · 최대 스탯:
|
||||
{{ profile.upcomingReset.defaultStatTotal }}</span
|
||||
>
|
||||
<span
|
||||
>기타 설정: {{ profile.upcomingReset.otherTextInfo || '없음'
|
||||
}}<template v-if="profile.upcomingReset.autorunUser">
|
||||
· 자율행동:
|
||||
{{
|
||||
autorunDetailText(profile.upcomingReset.autorunUser)
|
||||
}}</template
|
||||
></span
|
||||
>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
:profile-name="profile.profileName"
|
||||
:preopen-at="profile.upcomingReset.preopenAt"
|
||||
:open-at="profile.upcomingReset.openAt"
|
||||
:scenario-title="profile.upcomingReset.scenarioTitle"
|
||||
:turn-term-minutes="profile.upcomingReset.turnTermMinutes"
|
||||
:fiction-mode="profile.upcomingReset.fictionMode"
|
||||
:npc-mode="profile.upcomingReset.npcMode"
|
||||
:default-stat-total="profile.upcomingReset.defaultStatTotal"
|
||||
:other-text-info="profile.upcomingReset.otherTextInfo"
|
||||
:autorun-user="profile.upcomingReset.autorunUser"
|
||||
:scheduled-at="profile.upcomingReset.scheduledAt"
|
||||
test-id-prefix="upcoming-reset"
|
||||
/>
|
||||
<template v-if="profileDetails[profile.profileName]">
|
||||
<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"
|
||||
data-testid="profile-preopen-at"
|
||||
>
|
||||
- 가오픈 일시 :
|
||||
{{
|
||||
formatAnnouncementDate(
|
||||
profileDetails[profile.profileName]?.preopenAt
|
||||
)
|
||||
}}
|
||||
-
|
||||
</div>
|
||||
<div data-testid="profile-open-at">
|
||||
- 오픈 일시 :
|
||||
{{
|
||||
formatAnnouncementDate(
|
||||
profileDetails[profile.profileName]?.opentime ||
|
||||
profileDetails[profile.profileName]?.starttime
|
||||
)
|
||||
}}
|
||||
-
|
||||
</div>
|
||||
<div data-testid="profile-scenario-announcement">
|
||||
<span
|
||||
class="text-orange-400"
|
||||
data-testid="profile-scenario-title"
|
||||
>{{ profileScenarioTitle(profile.profileName) }}</span
|
||||
>{{ ' ' }}
|
||||
<span class="text-green-400">
|
||||
{{ profileDetails[profile.profileName]?.turnTerm }}분 턴 서버
|
||||
</span>
|
||||
</div>
|
||||
<ProfilePreopenAnnouncement
|
||||
:profile-name="profile.profileName"
|
||||
:preopen-at="profileDetails[profile.profileName]!.preopenAt"
|
||||
:open-at="
|
||||
profileDetails[profile.profileName]!.opentime ||
|
||||
profileDetails[profile.profileName]!.starttime
|
||||
"
|
||||
:scenario-title="profileScenarioTitle(profile.profileName)"
|
||||
:turn-term-minutes="profileDetails[profile.profileName]!.turnTerm"
|
||||
:fiction-mode="profileDetails[profile.profileName]!.fictionMode"
|
||||
:npc-mode="profileDetails[profile.profileName]!.npcMode"
|
||||
:default-stat-total="
|
||||
profileDetails[profile.profileName]!.defaultStatTotal
|
||||
"
|
||||
:other-text-info="
|
||||
profileDetails[profile.profileName]!.otherTextInfo
|
||||
"
|
||||
:autorun-user="profileDetails[profile.profileName]!.autorunUser"
|
||||
test-id-prefix="profile"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div>
|
||||
@@ -620,15 +563,11 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
<div class="profile-announcement-settings text-xs text-zinc-500">
|
||||
<div
|
||||
v-if="profile.status !== 'PREOPEN'"
|
||||
class="profile-announcement-settings text-xs text-zinc-500"
|
||||
>
|
||||
(상성 설정:{{ profileDetails[profile.profileName]?.fictionMode }}),
|
||||
<template v-if="profile.status === 'PREOPEN'">
|
||||
(빙의 여부:{{
|
||||
npcModeText(profileDetails[profile.profileName]?.npcMode ?? 0)
|
||||
}}), (최대 스탯:{{
|
||||
profileDetails[profile.profileName]?.defaultStatTotal
|
||||
}}),
|
||||
</template>
|
||||
(기타 설정:<template
|
||||
v-if="profileDetails[profile.profileName]?.otherTextInfo"
|
||||
>{{ profileDetails[profile.profileName]?.otherTextInfo
|
||||
@@ -987,73 +926,14 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
|
||||
min-width: 760px;
|
||||
}
|
||||
|
||||
.profile-table-body > tr:not(:last-child) {
|
||||
border-bottom: 1px solid #52525b;
|
||||
}
|
||||
|
||||
.season-status {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.upcoming-reset-announcement {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.upcoming-reset-options-marker {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: 3px;
|
||||
border-radius: 2px;
|
||||
cursor: help;
|
||||
vertical-align: text-bottom;
|
||||
}
|
||||
|
||||
.upcoming-reset-options-swatch {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border: 1px solid #fbbf24;
|
||||
border-radius: 1px;
|
||||
background: #d97706;
|
||||
box-shadow: 0 0 0 1px rgb(24 24 27 / 70%);
|
||||
}
|
||||
|
||||
.upcoming-reset-options-tooltip {
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
bottom: -16px;
|
||||
left: calc(100% + 6px);
|
||||
display: flex;
|
||||
visibility: hidden;
|
||||
box-sizing: border-box;
|
||||
width: max-content;
|
||||
max-width: min(400px, calc(100vw - 32px));
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 7px 9px;
|
||||
border: 1px solid #52525b;
|
||||
border-radius: 4px;
|
||||
background: #18181b;
|
||||
box-shadow: 0 4px 12px rgb(0 0 0 / 45%);
|
||||
color: #f4f4f5;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
text-align: left;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.upcoming-reset-options-marker:hover .upcoming-reset-options-tooltip,
|
||||
.upcoming-reset-options-marker:focus-visible .upcoming-reset-options-tooltip {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.upcoming-reset-options-marker:focus-visible {
|
||||
outline: 2px solid #fdba74;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.copyable-autorun {
|
||||
position: relative;
|
||||
cursor: help;
|
||||
@@ -1101,17 +981,6 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.upcoming-reset-options-marker:hover .upcoming-reset-options-tooltip,
|
||||
.upcoming-reset-options-marker:focus-visible .upcoming-reset-options-tooltip {
|
||||
position: fixed;
|
||||
top: auto;
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
left: 16px;
|
||||
width: auto;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.copyable-autorun:hover .copyable-autorun-detail,
|
||||
.copyable-autorun:focus-visible .copyable-autorun-detail {
|
||||
position: fixed;
|
||||
@@ -1148,8 +1017,8 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
|
||||
|
||||
.profile-table tbody {
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
background: #27272a;
|
||||
gap: 2px;
|
||||
background: #3f3f46;
|
||||
}
|
||||
|
||||
.profile-table tbody tr {
|
||||
@@ -1171,7 +1040,7 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
|
||||
|
||||
.profile-server-cell {
|
||||
grid-area: server;
|
||||
border-bottom: 1px solid #27272a;
|
||||
border-bottom: 1px solid #3f3f46;
|
||||
}
|
||||
|
||||
.profile-server-cell > div {
|
||||
@@ -1182,7 +1051,7 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
|
||||
.profile-info-cell {
|
||||
grid-area: info;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid #27272a;
|
||||
border-bottom: 1px solid #3f3f46;
|
||||
}
|
||||
|
||||
.profile-portrait-cell {
|
||||
|
||||
Reference in New Issue
Block a user