merge: 최신 main을 transport 권한과 커맨드 내구 행렬에 통합한다
This commit is contained in:
@@ -54,6 +54,7 @@ export interface ScenarioInstallOptions {
|
||||
season?: number;
|
||||
firstGameIdx?: number;
|
||||
serverId?: string;
|
||||
serverName?: string;
|
||||
installOperationId?: string;
|
||||
installCommitSha?: string;
|
||||
}
|
||||
@@ -315,6 +316,9 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
|
||||
if (typeof install?.serverId === 'string' && install.serverId.trim()) {
|
||||
worldMeta.serverId = install.serverId.trim();
|
||||
}
|
||||
if (typeof install?.serverName === 'string' && install.serverName.trim()) {
|
||||
worldMeta.serverName = install.serverName.trim();
|
||||
}
|
||||
if (typeof install?.installOperationId === 'string' && install.installOperationId.trim()) {
|
||||
worldMeta.installOperationId = install.installOperationId.trim();
|
||||
}
|
||||
|
||||
@@ -151,6 +151,25 @@ const buildHarness = (
|
||||
};
|
||||
|
||||
describe('CreateManyNPC monthly action', () => {
|
||||
it('creates exactly 50 ordinary M generals without fill-count expansion', async () => {
|
||||
const { world, reservedTurns, handler, environment } = buildHarness();
|
||||
|
||||
await handler([50, 0], environment, {
|
||||
id: 1,
|
||||
targetCode: 'month',
|
||||
priority: 1,
|
||||
condition: true,
|
||||
action: [],
|
||||
meta: {},
|
||||
});
|
||||
|
||||
const created = world.peekDirtyState().createdGenerals;
|
||||
expect(created).toHaveLength(50);
|
||||
expect(created.every((general) => general.npcState === 3 && general.name.startsWith('ⓜ'))).toBe(true);
|
||||
expect(reservedTurns.peekDirtyState().generalInitializationIds).toHaveLength(50);
|
||||
expect(world.peekDirtyState().logs.map((log) => log.text)).toContain('장수 <C>50</>명이 <S>등장</>하였습니다.');
|
||||
});
|
||||
|
||||
it('uses and consumes an available U30 candidate without random-name or random-stat fallback', async () => {
|
||||
const info = {
|
||||
generalName: '풀장수',
|
||||
|
||||
@@ -52,10 +52,38 @@ describe('tracked scenario resources', () => {
|
||||
|
||||
expect(scenarioIds).toContain(914);
|
||||
expect(scenarioIds).toContain(915);
|
||||
expect(scenarioIds).toContain(916);
|
||||
const scenarios = await Promise.all(scenarioIds.map((scenarioId) => loadScenarioDefinitionById(scenarioId)));
|
||||
expect(scenarios.every((scenario) => scenario.title.length > 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps scenario 916 equal to ordinary blank land except for its launch modifiers', async () => {
|
||||
const [ordinaryBlank, dawn] = await Promise.all(
|
||||
[0, 916].map((scenarioId) => loadScenarioDefinitionById(scenarioId))
|
||||
);
|
||||
const { uniqueTrialCoef, ...dawnConst } = dawn.config.const;
|
||||
|
||||
expect(dawn.title).toBe('【공백지】 여명');
|
||||
expect(uniqueTrialCoef).toBe(2);
|
||||
expect({ ...dawn.config, const: dawnConst }).toEqual(ordinaryBlank.config);
|
||||
expect(dawn.history).toEqual(ordinaryBlank.history);
|
||||
expect(dawn.events[0]).toEqual([
|
||||
'month',
|
||||
1000,
|
||||
['or', ['Date', '==', null, 12], ['Date', '==', null, 6]],
|
||||
['CreateManyNPC', 50, 0],
|
||||
['DeleteEvent'],
|
||||
]);
|
||||
expect(dawn.events.slice(1)).toEqual(ordinaryBlank.events.slice(1));
|
||||
|
||||
expect({
|
||||
...dawn,
|
||||
title: ordinaryBlank.title,
|
||||
events: ordinaryBlank.events,
|
||||
config: ordinaryBlank.config,
|
||||
}).toEqual(ordinaryBlank);
|
||||
});
|
||||
|
||||
refSourceIt('preserves the Ref scenario 915 S100 pool and event order exactly', async () => {
|
||||
const referencePath = path.join(resolveRefSourceRoot(), 'hwe', 'scenario', 'scenario_915.json');
|
||||
const [scenario, referenceSource] = await Promise.all([
|
||||
|
||||
@@ -367,6 +367,7 @@ describeDb('scenario database seed', () => {
|
||||
},
|
||||
preopenAt: new Date('2030-01-01T01:00:00Z'),
|
||||
openAt: new Date('2030-01-01T02:00:00Z'),
|
||||
serverName: '훼',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -400,6 +401,7 @@ describeDb('scenario database seed', () => {
|
||||
(worldState.currentYear - (scenario.startYear ?? worldState.currentYear) + 10) * 2
|
||||
);
|
||||
expect(meta.killturn).toBe(1600);
|
||||
expect(meta.serverName).toBe('훼');
|
||||
const autorun = (meta.autorun_user ?? {}) as Record<string, unknown>;
|
||||
const autorunOptions = (autorun.options ?? {}) as Record<string, unknown>;
|
||||
expect(autorunOptions.develop).toBe(true);
|
||||
|
||||
@@ -260,7 +260,12 @@ describe('persistUnificationFinalization', () => {
|
||||
expect(hallCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
aux: expect.objectContaining({ ownerDisplayName: '표시 이름', fgColor: '#000000' }),
|
||||
aux: expect.objectContaining({
|
||||
ownerDisplayName: '표시 이름',
|
||||
fgColor: '#000000',
|
||||
serverName: '테스트',
|
||||
serverIdx: 2,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
);
|
||||
@@ -279,7 +284,10 @@ describe('persistUnificationFinalization', () => {
|
||||
);
|
||||
expect(emperorCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ aux: { winnerNationId: 1, generationKey: input.generationKey } }),
|
||||
data: expect.objectContaining({
|
||||
phase: '테스트2기',
|
||||
aux: { winnerNationId: 1, generationKey: input.generationKey },
|
||||
}),
|
||||
})
|
||||
);
|
||||
const archiveWrite = oldGeneralUpsert.mock.calls[0]?.[0] as {
|
||||
|
||||
@@ -27,7 +27,11 @@ import type { GatewayApiContext } from './context.js';
|
||||
import { resolveLocalAccountProfilePolicy } from './auth/localAccountPolicy.js';
|
||||
import { GATEWAY_BUILD_STATUSES, GATEWAY_PROFILE_STATUSES } from './orchestrator/profileRepository.js';
|
||||
import { readProfileReleaseSource } from './orchestrator/profileReleaseSource.js';
|
||||
import { orderGatewayProfiles, resolveGatewayProfileKoreanName } from './profileOrder.js';
|
||||
import {
|
||||
orderGatewayProfiles,
|
||||
resolveGatewayProfileDisplayName,
|
||||
resolveGatewayProfileKoreanName,
|
||||
} from './profileOrder.js';
|
||||
import { purifyGatewayNoticeHtml } from './security/gatewayNoticeHtml.js';
|
||||
|
||||
const zProfileStatus = z.enum(GATEWAY_PROFILE_STATUSES);
|
||||
@@ -650,7 +654,25 @@ export const adminRouter = router({
|
||||
})
|
||||
.optional()
|
||||
)
|
||||
.query(({ ctx, input }) => (ctx as GatewayApiContext).adminAudit.list(input)),
|
||||
.query(async ({ ctx, input }) => {
|
||||
const gatewayCtx = ctx as GatewayApiContext;
|
||||
const [events, profiles] = await Promise.all([
|
||||
gatewayCtx.adminAudit.list(input),
|
||||
gatewayCtx.profiles.listProfiles(),
|
||||
]);
|
||||
const displayNames = new Map(
|
||||
profiles.map((profile) => [
|
||||
profile.profileName,
|
||||
resolveGatewayProfileDisplayName(profile.profile, profile.instanceKey, profile.meta.korName),
|
||||
])
|
||||
);
|
||||
return events.map((event) => ({
|
||||
...event,
|
||||
...(event.profileName && displayNames.has(event.profileName)
|
||||
? { profileDisplayName: displayNames.get(event.profileName) }
|
||||
: {}),
|
||||
}));
|
||||
}),
|
||||
}),
|
||||
system: router({
|
||||
getNotice: adminProcedure.query(async ({ ctx }) => {
|
||||
@@ -760,6 +782,13 @@ export const adminRouter = router({
|
||||
specialAccessGrants,
|
||||
profiles: profiles.map((profile) => ({
|
||||
profileName: profile.profileName,
|
||||
profile: profile.profile,
|
||||
instanceKey: profile.instanceKey,
|
||||
displayName: resolveGatewayProfileDisplayName(
|
||||
profile.profile,
|
||||
profile.instanceKey,
|
||||
profile.meta.korName
|
||||
),
|
||||
...resolveLocalAccountProfilePolicy({
|
||||
profile: profile.profile,
|
||||
profileName: profile.profileName,
|
||||
@@ -1727,6 +1756,11 @@ export const adminRouter = router({
|
||||
profileName: profile.profileName,
|
||||
profile: profile.profile,
|
||||
instanceKey: profile.instanceKey,
|
||||
displayName: resolveGatewayProfileDisplayName(
|
||||
profile.profile,
|
||||
profile.instanceKey,
|
||||
profile.meta.korName
|
||||
),
|
||||
currentScenario: profile.currentScenario,
|
||||
meta: {
|
||||
korName: resolveGatewayProfileKoreanName(profile.profile, profile.meta.korName),
|
||||
@@ -1771,6 +1805,11 @@ export const adminRouter = router({
|
||||
const runtimeSettingsMap = new Map(runtimeSettings.map((settings) => [settings.profileName, settings]));
|
||||
return profiles.map((profile) => ({
|
||||
...profile,
|
||||
displayName: resolveGatewayProfileDisplayName(
|
||||
profile.profile,
|
||||
profile.instanceKey,
|
||||
profile.meta.korName
|
||||
),
|
||||
runtimeActions: runtimeActionsByProfile.get(profile.profileName) ?? [],
|
||||
runtimeSettings: runtimeSettingsMap.get(profile.profileName) ?? null,
|
||||
activeOperation: activeOperationByProfile.get(profile.profileName) ?? null,
|
||||
@@ -1805,7 +1844,7 @@ export const adminRouter = router({
|
||||
if (sourceMode === 'CURRENT') {
|
||||
if (!input?.profileName) {
|
||||
if (!adminAuth.isSuperuser) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'profileName is required.' });
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '대상 서버를 선택해야 합니다.' });
|
||||
}
|
||||
} else {
|
||||
assertPermission(adminAuth, ROLE_ADMIN_SCENARIO_RESET, input.profileName);
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import { isRecord } from '@sammo-ts/common';
|
||||
|
||||
import { resolveGatewayPostgresConfigFromEnv } from '../gatewayPostgresConfig.js';
|
||||
import { resolveGatewayProfileDisplayName, resolveGatewayProfileKoreanName } from '../profileOrder.js';
|
||||
|
||||
import {
|
||||
buildTurboReleaseCommand,
|
||||
@@ -281,6 +282,13 @@ const buildServerId = (profileName: string, now: Date, installOperationId?: stri
|
||||
return `${profileName}_${year}${month}${day}_${suffix}`;
|
||||
};
|
||||
|
||||
export const resolveProfileArchiveServerName = (
|
||||
profile: Pick<GatewayProfileRecord, 'profileName' | 'profile' | 'meta'>
|
||||
): string => {
|
||||
const meta = normalizeMeta(profile.meta);
|
||||
return resolveGatewayProfileKoreanName(profile.profile, meta.korName);
|
||||
};
|
||||
|
||||
const readMetaNumber = (meta: Record<string, unknown>, key: string): number | null => {
|
||||
const raw = meta[key];
|
||||
if (typeof raw === 'number' && Number.isFinite(raw)) {
|
||||
@@ -1618,7 +1626,11 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
this.processConfig.workspaceRoot
|
||||
)),
|
||||
];
|
||||
await this.appendOperationLog(operationId, 'build', `${profile.profileName} 구성 요소를 빌드합니다.`);
|
||||
await this.appendOperationLog(
|
||||
operationId,
|
||||
'build',
|
||||
`${resolveGatewayProfileDisplayName(profile.profile, profile.instanceKey, profile.meta.korName)} 구성 요소를 빌드합니다.`
|
||||
);
|
||||
const result = await this.releaseBuildRunner.run(commands, this.buildProgress(operationId, 'build'), {
|
||||
signal: this.activeOperationAbortSignal,
|
||||
});
|
||||
@@ -2010,6 +2022,9 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
season,
|
||||
firstGameIdx,
|
||||
serverId,
|
||||
// Snapshot the Gateway display name into this season. Hall and
|
||||
// dynasty archives must not fall back to the runtime instance key.
|
||||
serverName: resolveProfileArchiveServerName(profile),
|
||||
installCommitSha: commitSha,
|
||||
},
|
||||
adminUser,
|
||||
@@ -2222,7 +2237,11 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
await this.appendOperationLog(
|
||||
operationId,
|
||||
'build',
|
||||
`${profile?.profileName ?? 'profile'} 구성 요소를 빌드합니다.`
|
||||
`${
|
||||
profile
|
||||
? resolveGatewayProfileDisplayName(profile.profile, profile.instanceKey, profile.meta.korName)
|
||||
: '대상 서버'
|
||||
} 구성 요소를 빌드합니다.`
|
||||
);
|
||||
}
|
||||
return {
|
||||
@@ -2424,7 +2443,13 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
|
||||
private async stageStaticProfileFrontend(profile: GatewayProfileRecord): Promise<StagedFrontendArtifact> {
|
||||
if (!profile.buildCommitSha) {
|
||||
throw new Error(`Profile ${profile.profileName} is missing the build commit SHA.`);
|
||||
throw new Error(
|
||||
`${resolveGatewayProfileDisplayName(
|
||||
profile.profile,
|
||||
profile.instanceKey,
|
||||
profile.meta.korName
|
||||
)} 서버의 build commit SHA가 없습니다.`
|
||||
);
|
||||
}
|
||||
const runtimeWorkspace = profile.buildWorkspace ?? this.processConfig.workspaceRoot;
|
||||
const sharedSourceRoot = buildSharedProfileFrontendOutDir(runtimeWorkspace);
|
||||
|
||||
@@ -20,6 +20,19 @@ export const resolveGatewayProfileKoreanName = (profile: string, configuredName?
|
||||
return gatewayProfileKoreanNames.get(profile) ?? profile;
|
||||
};
|
||||
|
||||
/**
|
||||
* User-facing profile label. The immutable profileName (`che:default`) remains
|
||||
* an internal routing/storage key and must not leak into ordinary UI copy.
|
||||
*/
|
||||
export const resolveGatewayProfileDisplayName = (
|
||||
profile: string,
|
||||
instanceKey: string,
|
||||
configuredName?: unknown
|
||||
): string => {
|
||||
const koreanName = resolveGatewayProfileKoreanName(profile, configuredName);
|
||||
return instanceKey === 'default' ? koreanName : `${koreanName} [${instanceKey}]`;
|
||||
};
|
||||
|
||||
const compareGatewayProfiles = (
|
||||
left: { profile: string; instanceKey: string },
|
||||
right: { profile: string; instanceKey: string }
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
import { GatewayPrisma, type GatewayPrismaClient } from '@sammo-ts/infra';
|
||||
import webPush from 'web-push';
|
||||
|
||||
import { resolveGatewayProfileDisplayName } from '../profileOrder.js';
|
||||
|
||||
export interface WebPushCoordinatorConfig {
|
||||
enabled: boolean;
|
||||
vapidSubject?: string;
|
||||
@@ -104,7 +106,14 @@ export class WebPushCoordinator {
|
||||
const [profiles, preferences, subscriptionCount, currentSubscription] = await Promise.all([
|
||||
this.prisma.gatewayProfile.findMany({
|
||||
orderBy: [{ profile: 'asc' }, { instanceKey: 'asc' }],
|
||||
select: { profileName: true, profile: true, currentScenario: true, status: true },
|
||||
select: {
|
||||
profileName: true,
|
||||
profile: true,
|
||||
instanceKey: true,
|
||||
currentScenario: true,
|
||||
status: true,
|
||||
meta: true,
|
||||
},
|
||||
}),
|
||||
this.prisma.webPushPreference.findMany({
|
||||
where: { userId },
|
||||
@@ -128,7 +137,15 @@ export class WebPushCoordinator {
|
||||
capability: this.getCapability(),
|
||||
eventTypes: WEB_PUSH_EVENT_TYPES,
|
||||
profiles: profiles.map((profile) => ({
|
||||
...profile,
|
||||
profileName: profile.profileName,
|
||||
profile: profile.profile,
|
||||
instanceKey: profile.instanceKey,
|
||||
displayName: resolveGatewayProfileDisplayName(
|
||||
profile.profile,
|
||||
profile.instanceKey,
|
||||
(profile.meta as Record<string, unknown> | null)?.korName
|
||||
),
|
||||
currentScenario: profile.currentScenario,
|
||||
status: String(profile.status),
|
||||
})),
|
||||
preferences: preferences.filter((preference) => isWebPushEventType(preference.eventType)),
|
||||
@@ -218,7 +235,7 @@ export class WebPushCoordinator {
|
||||
if (!this.configured) return false;
|
||||
const profile = await tx.gatewayProfile.findUnique({
|
||||
where: { profileName: event.profileName },
|
||||
select: { profile: true, profileName: true },
|
||||
select: { profile: true, profileName: true, instanceKey: true, meta: true },
|
||||
});
|
||||
if (!profile) return false;
|
||||
const receipt = await tx.webPushEventReceipt.createMany({
|
||||
@@ -258,7 +275,16 @@ export class WebPushCoordinator {
|
||||
ids.push(subscription.id);
|
||||
subscriptionIdsByUser.set(subscription.userId, ids);
|
||||
}
|
||||
const copy = copyFor(event.eventType, profile.profile, event.year, event.month);
|
||||
const copy = copyFor(
|
||||
event.eventType,
|
||||
resolveGatewayProfileDisplayName(
|
||||
profile.profile,
|
||||
profile.instanceKey,
|
||||
(profile.meta as Record<string, unknown> | null)?.korName
|
||||
),
|
||||
event.year,
|
||||
event.month
|
||||
);
|
||||
for (const userId of selectedUserIds) {
|
||||
const subscriptionIds = subscriptionIdsByUser.get(userId) ?? [];
|
||||
if (subscriptionIds.length === 0) continue;
|
||||
@@ -395,10 +421,7 @@ export class WebPushCoordinator {
|
||||
});
|
||||
|
||||
for (const delivery of claimed) {
|
||||
if (
|
||||
delivery.subscription.expirationTime &&
|
||||
delivery.subscription.expirationTime.getTime() <= Date.now()
|
||||
) {
|
||||
if (delivery.subscription.expirationTime && delivery.subscription.expirationTime.getTime() <= Date.now()) {
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.webPushDelivery.updateMany({
|
||||
where: { id: delivery.id, lockOwner: this.owner },
|
||||
@@ -445,11 +468,15 @@ export class WebPushCoordinator {
|
||||
typeof error === 'object' && error !== null && 'statusCode' in error
|
||||
? Number((error as { statusCode?: unknown }).statusCode)
|
||||
: 0;
|
||||
const terminal = statusCode === 404 || statusCode === 410 || (statusCode >= 400 && statusCode < 500 && statusCode !== 429);
|
||||
const terminal =
|
||||
statusCode === 404 ||
|
||||
statusCode === 410 ||
|
||||
(statusCode >= 400 && statusCode < 500 && statusCode !== 429);
|
||||
const attempts = delivery.attempts;
|
||||
const exhausted = attempts >= 8;
|
||||
const delaySeconds = Math.min(300, 2 ** Math.min(attempts, 8));
|
||||
const safeError = statusCode > 0 ? `Push service returned HTTP ${statusCode}.` : 'Push service request failed.';
|
||||
const safeError =
|
||||
statusCode > 0 ? `Push service returned HTTP ${statusCode}.` : 'Push service request failed.';
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.webPushDelivery.updateMany({
|
||||
where: { id: delivery.id, lockOwner: this.owner },
|
||||
|
||||
@@ -380,6 +380,7 @@ describe('admin profile navigation API', () => {
|
||||
profileName: 'che:2',
|
||||
profile: 'che',
|
||||
instanceKey: '2',
|
||||
displayName: '체 [2]',
|
||||
currentScenario: '2',
|
||||
meta: { korName: '체' },
|
||||
},
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
buildSharedProfileFrontendCommands,
|
||||
buildWorkspaceCommands,
|
||||
planProfileReconcile,
|
||||
resolveProfileArchiveServerName,
|
||||
resolveResetLifecycleStatus,
|
||||
} from '../src/orchestrator/gatewayOrchestrator.js';
|
||||
import { GATEWAY_PROFILE_ORDER } from '../src/profileOrder.js';
|
||||
@@ -134,6 +135,30 @@ describe('resolveResetLifecycleStatus', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveProfileArchiveServerName', () => {
|
||||
it('uses the configured Gateway name and never the runtime instance key', () => {
|
||||
expect(
|
||||
resolveProfileArchiveServerName({
|
||||
...buildProfile(),
|
||||
profile: 'hwe',
|
||||
profileName: 'hwe:default',
|
||||
meta: { korName: ' 훼 ' },
|
||||
})
|
||||
).toBe('훼');
|
||||
});
|
||||
|
||||
it('uses the canonical profile label when Gateway has no override', () => {
|
||||
expect(
|
||||
resolveProfileArchiveServerName({
|
||||
...buildProfile(),
|
||||
profile: 'hwe',
|
||||
profileName: 'hwe:default',
|
||||
meta: {},
|
||||
})
|
||||
).toBe('훼');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildProcessDefinitions', () => {
|
||||
const processConfig = {
|
||||
workspaceRoot: '/srv/sammo/main',
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
GATEWAY_PROFILE_KOREAN_NAMES,
|
||||
GATEWAY_PROFILE_ORDER,
|
||||
orderGatewayProfiles,
|
||||
resolveGatewayProfileDisplayName,
|
||||
resolveGatewayProfileKoreanName,
|
||||
} from '../src/profileOrder.js';
|
||||
|
||||
@@ -56,3 +57,12 @@ describe('resolveGatewayProfileKoreanName', () => {
|
||||
expect(resolveGatewayProfileKoreanName('custom')).toBe('custom');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveGatewayProfileDisplayName', () => {
|
||||
it('hides the default instance key and distinguishes non-default instances', () => {
|
||||
expect(resolveGatewayProfileDisplayName('che', 'default')).toBe('체');
|
||||
expect(resolveGatewayProfileDisplayName('hwe', '2')).toBe('훼 [2]');
|
||||
expect(resolveGatewayProfileDisplayName('che', 'default', ' 천하서버 ')).toBe('천하서버');
|
||||
expect(resolveGatewayProfileDisplayName('custom', 'blue')).toBe('custom [blue]');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,16 @@ import { describe, expect, it } from 'vitest';
|
||||
import { listScenarioPreviews, resolveGitCommitSha } from '../src/scenario/scenarioCatalog.js';
|
||||
|
||||
describe('scenarioCatalog git ref support', () => {
|
||||
it('includes the CHE zero-season dawn scenario in the local catalog', async () => {
|
||||
const previews = await listScenarioPreviews();
|
||||
|
||||
expect(previews.find((scenario) => scenario.id === 916)).toMatchObject({
|
||||
id: 916,
|
||||
title: '【공백지】 여명',
|
||||
year: 180,
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves HEAD to a commit hash', async () => {
|
||||
const commitSha = await resolveGitCommitSha('HEAD');
|
||||
expect(commitSha).toMatch(/^[0-9a-f]{40}$/i);
|
||||
@@ -18,6 +28,11 @@ describe('scenarioCatalog git ref support', () => {
|
||||
expect(previews.every((scenario) => scenario.fiction === null || Number.isInteger(scenario.fiction))).toBe(
|
||||
true
|
||||
);
|
||||
expect(previews.find((scenario) => scenario.id === 916)).toMatchObject({
|
||||
id: 916,
|
||||
title: '【공백지】 여명',
|
||||
year: 180,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects without crashing when git cannot be spawned', async () => {
|
||||
|
||||
@@ -131,6 +131,9 @@ const installFixture = async (page: Page) => {
|
||||
profiles: [
|
||||
{
|
||||
profileName: 'che:default',
|
||||
profile: 'che',
|
||||
instanceKey: 'default',
|
||||
displayName: '체',
|
||||
requiresKakaoVerification: true,
|
||||
kakaoVerified: false,
|
||||
accessAllowed: true,
|
||||
@@ -219,15 +222,16 @@ test('operates OAuth grace and scheduled deletion with reasoned audit history',
|
||||
await page.getByRole('button', { name: /접근 · 권한/ }).click();
|
||||
await expect(page.getByRole('option', { name: /Profile 전체 운영/ })).toHaveCount(0);
|
||||
await expect(page.getByRole('option', { name: /Profile 실행 관리/ })).toHaveCount(1);
|
||||
await expect(page.getByRole('cell', { name: 'che:default' })).toBeVisible();
|
||||
await expect(page.getByRole('cell', { name: '체' })).toBeVisible();
|
||||
await expect(page.getByText('che:default', { exact: true })).toHaveCount(0);
|
||||
await page.screenshot({ path: testInfo.outputPath('gateway-admin-account-controls-desktop.png'), fullPage: true });
|
||||
await page.getByPlaceholder('권한·제재·복구·탈퇴 조치 사유 (필수)').fill('본인 확인 처리 중');
|
||||
await page.getByLabel('특수 접근 만료 시각').fill('2026-08-20T00:00');
|
||||
await page.getByPlaceholder('che 또는 che:2 (쉼표 구분, 비우면 전체)').fill('che');
|
||||
await page.getByRole('checkbox', { name: '체' }).check();
|
||||
await page.getByPlaceholder('권한·제재·복구·탈퇴 조치 사유 (필수)').fill('휴대폰 분실 임시 복구');
|
||||
await page.getByRole('button', { name: '특수 접근 부여', exact: true }).click();
|
||||
await expect(page.getByText('특수 접근 자격을 부여했습니다.').first()).toBeVisible();
|
||||
await expect(page.getByText(/RECOVERY · che/)).toBeVisible();
|
||||
await expect(page.getByText(/RECOVERY · 체/)).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath('gateway-admin-special-access-granted.png'), fullPage: true });
|
||||
|
||||
const gracePanel = page.getByRole('heading', { name: 'Kakao 인증 유예' }).locator('..');
|
||||
|
||||
@@ -165,6 +165,7 @@ const installFixture = async (
|
||||
profileName: 'hwe:default',
|
||||
profile: 'hwe',
|
||||
instanceKey: 'default',
|
||||
displayName: '훼',
|
||||
currentScenario: options.currentScenario === undefined ? '1010' : options.currentScenario,
|
||||
meta: {},
|
||||
},
|
||||
@@ -177,6 +178,7 @@ const installFixture = async (
|
||||
profileName: 'hwe:default',
|
||||
profile: 'hwe',
|
||||
instanceKey: 'default',
|
||||
displayName: '훼',
|
||||
currentScenario: options.currentScenario === undefined ? '1010' : options.currentScenario,
|
||||
scenario: options.currentScenario ?? 'default',
|
||||
apiPort: 15015,
|
||||
@@ -542,7 +544,7 @@ test('directs profile deployment to the selected server version tab', async ({ p
|
||||
const tabAndHeaderGeometry = await Promise.all([
|
||||
tabs.evaluate((element) => element.getBoundingClientRect().top),
|
||||
page
|
||||
.getByText('서버 ID: hwe:default · 인스턴스: default', { exact: true })
|
||||
.getByText('현재 시나리오: 1010', { exact: true })
|
||||
.evaluate((element) => element.getBoundingClientRect().top),
|
||||
]);
|
||||
expect(tabAndHeaderGeometry[0]).toBeLessThan(tabAndHeaderGeometry[1]);
|
||||
|
||||
@@ -34,15 +34,20 @@ const resetScenario = async (page: Page, scenarioId: string, sourceCommit: strin
|
||||
await expect(page.getByText(/개 시나리오를 확인했습니다/)).toBeVisible();
|
||||
await page.getByTestId('scenario-select').selectOption(scenarioId);
|
||||
|
||||
const latestOperation = page.getByTestId('operations-table').locator('tbody tr').first();
|
||||
const latestOperation = page.getByTestId('operation-summary-row').first();
|
||||
const previousLatestOperation = await latestOperation.textContent();
|
||||
await page.getByTestId('request-reset').click();
|
||||
await expect(page.getByText('초기화 작업을 시작했습니다.')).toBeVisible();
|
||||
await expect.poll(() => latestOperation.textContent(), { timeout: 15_000 }).not.toBe(previousLatestOperation);
|
||||
await expect(latestOperation).toContainText(sourceCommit, { timeout: 15_000 });
|
||||
await expect(latestOperation.locator('td').nth(3)).toHaveText('SUCCEEDED', {
|
||||
timeout: 300_000,
|
||||
});
|
||||
await latestOperation.getByTestId('operation-details-toggle').click();
|
||||
await expect(page.getByTestId('operation-detail').first()).toContainText(sourceCommit, { timeout: 15_000 });
|
||||
await expect(latestOperation.locator('[data-operation-status]')).toHaveAttribute(
|
||||
'data-operation-status',
|
||||
'SUCCEEDED',
|
||||
{
|
||||
timeout: 300_000,
|
||||
}
|
||||
);
|
||||
const profileStatus = page.getByTestId('selected-profile-status');
|
||||
await expect(profileStatus.locator(':scope > div').nth(0)).toContainText('RUNNING', {
|
||||
timeout: 30_000,
|
||||
|
||||
@@ -95,7 +95,7 @@ test('admin resets and opens hwe, then two users create generals and reach main'
|
||||
await page.getByTestId('load-scenarios').click();
|
||||
await expect(page.getByText(/개 시나리오를 확인했습니다/)).toBeVisible();
|
||||
await page.getByTestId('scenario-select').selectOption(scenarioId);
|
||||
const latestOperation = page.getByTestId('operations-table').locator('tbody tr').first();
|
||||
const latestOperation = page.getByTestId('operation-summary-row').first();
|
||||
const previousLatestOperation = await latestOperation.textContent();
|
||||
await page.getByTestId('request-reset').click();
|
||||
await expect(page.getByText('초기화 작업을 등록했습니다.').first()).toBeVisible();
|
||||
@@ -105,12 +105,17 @@ test('admin resets and opens hwe, then two users create generals and reach main'
|
||||
timeout: 15_000,
|
||||
})
|
||||
.not.toBe(previousLatestOperation);
|
||||
await expect(latestOperation).toContainText(sourceCommit, {
|
||||
await latestOperation.getByTestId('operation-details-toggle').click();
|
||||
await expect(page.getByTestId('operation-detail').first()).toContainText(sourceCommit, {
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(latestOperation.locator('td').nth(4)).toHaveText('SUCCEEDED', {
|
||||
timeout: 300_000,
|
||||
});
|
||||
await expect(latestOperation.locator('[data-operation-status]')).toHaveAttribute(
|
||||
'data-operation-status',
|
||||
'SUCCEEDED',
|
||||
{
|
||||
timeout: 300_000,
|
||||
}
|
||||
);
|
||||
}
|
||||
await expect(profileStatus).toContainText('RUNNING', { timeout: 30_000 });
|
||||
await expect(profileStatus).toContainText('SUCCEEDED');
|
||||
|
||||
@@ -48,6 +48,7 @@ const installGatewayFixture = async (page: Page, roles: string[]) => {
|
||||
profileName: 'hwe:2',
|
||||
profile: 'hwe',
|
||||
instanceKey: '2',
|
||||
displayName: '환상서버 [2]',
|
||||
currentScenario: '1010',
|
||||
scenario: '1010',
|
||||
status: 'RUNNING',
|
||||
@@ -68,6 +69,7 @@ const installGatewayFixture = async (page: Page, roles: string[]) => {
|
||||
profileName: 'hwe:2',
|
||||
profile: 'hwe',
|
||||
instanceKey: '2',
|
||||
displayName: '환상서버 [2]',
|
||||
currentScenario: '1010',
|
||||
meta: { korName: '환상서버' },
|
||||
},
|
||||
@@ -306,23 +308,26 @@ test('keeps mobile account actions on clean rows for users and administrators',
|
||||
const adminLink = adminPage.getByRole('link', { name: '관리자 페이지' });
|
||||
const baseBackground = await adminLink.evaluate((element) => getComputedStyle(element).backgroundColor);
|
||||
await adminLink.hover();
|
||||
await expect.poll(() => adminLink.evaluate((element) => getComputedStyle(element).backgroundColor)).not.toBe(
|
||||
baseBackground
|
||||
);
|
||||
await expect
|
||||
.poll(() => adminLink.evaluate((element) => getComputedStyle(element).backgroundColor))
|
||||
.not.toBe(baseBackground);
|
||||
await adminLink.focus();
|
||||
await expect(adminLink).toBeFocused();
|
||||
await adminPage.screenshot({ path: testInfo.outputPath('gateway-account-actions-admin-500px.png'), fullPage: true });
|
||||
await adminPage.screenshot({
|
||||
path: testInfo.outputPath('gateway-account-actions-admin-500px.png'),
|
||||
fullPage: true,
|
||||
});
|
||||
|
||||
await adminPage.setViewportSize({ width: 390, height: 844 });
|
||||
const adminNarrowGeometry = await measureAccountActions(adminPage);
|
||||
expect(adminNarrowGeometry.documentWidth).toBe(adminNarrowGeometry.viewportWidth);
|
||||
expect(adminNarrowGeometry.items[0]?.top).toBeCloseTo(adminNarrowGeometry.items[1]?.top ?? 0, 0);
|
||||
expect(adminNarrowGeometry.items[2]?.top).toBeCloseTo(
|
||||
(adminNarrowGeometry.items[1]?.bottom ?? 0) + 16,
|
||||
0
|
||||
);
|
||||
expect(adminNarrowGeometry.items[2]?.top).toBeCloseTo((adminNarrowGeometry.items[1]?.bottom ?? 0) + 16, 0);
|
||||
expect(adminNarrowGeometry.items.every(({ scrollWidth, clientWidth }) => scrollWidth <= clientWidth)).toBe(true);
|
||||
await adminPage.screenshot({ path: testInfo.outputPath('gateway-account-actions-admin-390px.png'), fullPage: true });
|
||||
await adminPage.screenshot({
|
||||
path: testInfo.outputPath('gateway-account-actions-admin-390px.png'),
|
||||
fullPage: true,
|
||||
});
|
||||
|
||||
await adminPage.setViewportSize({ width: 360, height: 800 });
|
||||
const adminSmallGeometry = await measureAccountActions(adminPage);
|
||||
|
||||
@@ -71,6 +71,7 @@ const profile = (runtimeRunning: boolean, resetDefaults?: Record<string, unknown
|
||||
profileName: 'che:default',
|
||||
profile: 'che',
|
||||
instanceKey: 'default',
|
||||
displayName: '천하서버',
|
||||
currentScenario: '2',
|
||||
scenario: '2',
|
||||
apiPort: 15003,
|
||||
@@ -125,6 +126,16 @@ const scenarios = [
|
||||
nations: [],
|
||||
isCurrent: false,
|
||||
},
|
||||
{
|
||||
id: 916,
|
||||
title: '【공백지】 여명',
|
||||
year: 180,
|
||||
npcCount: 0,
|
||||
npcExCount: 0,
|
||||
npcNeutralCount: 0,
|
||||
nations: [],
|
||||
isCurrent: false,
|
||||
},
|
||||
];
|
||||
|
||||
const response = (data: unknown) => ({ result: { data } });
|
||||
@@ -190,6 +201,7 @@ const installFixture = async (page: Page, state: FixtureState) => {
|
||||
profileName: 'che:default',
|
||||
profile: 'che',
|
||||
instanceKey: 'default',
|
||||
displayName: '천하서버',
|
||||
currentScenario: '2',
|
||||
meta: { korName: '천하서버' },
|
||||
},
|
||||
@@ -538,6 +550,7 @@ test('separates branch and commit semantics and submits a reset from the dedicat
|
||||
await expect(page.getByTestId('scenario-select')).toHaveValue('2');
|
||||
await expect(page.getByTestId('request-reset')).toBeEnabled();
|
||||
await expect(page.getByTestId('scenario-select').locator('option:checked')).toContainText('현재 시나리오');
|
||||
await expect(page.getByTestId('scenario-select').locator('option[value="916"]')).toContainText('【공백지】 여명');
|
||||
const catalogGeometry = await page.getByTestId('scenario-select').evaluate((select) => {
|
||||
const scenarioSelect = select as HTMLSelectElement;
|
||||
const rect = select.getBoundingClientRect();
|
||||
@@ -550,7 +563,7 @@ test('separates branch and commit semantics and submits a reset from the dedicat
|
||||
value: scenarioSelect.value,
|
||||
};
|
||||
});
|
||||
expect(catalogGeometry.optionCount).toBe(3);
|
||||
expect(catalogGeometry.optionCount).toBe(4);
|
||||
expect(catalogGeometry.value).toBe('2');
|
||||
expect(catalogGeometry.width).toBeGreaterThan(300);
|
||||
await page.screenshot({ path: testInfo.outputPath('current-scenario-catalog.png'), fullPage: true });
|
||||
@@ -626,47 +639,60 @@ test('separates branch and commit semantics and submits a reset from the dedicat
|
||||
await page.getByTestId('request-reset').click();
|
||||
|
||||
await expect(page.getByText('초기화 작업을 등록했습니다.').first()).toBeVisible();
|
||||
await expect(page.getByTestId('operations-table')).toContainText('RESET');
|
||||
await expect(page.getByTestId('operations-table')).toContainText('시나리오 초기화');
|
||||
await expect(page.getByTestId('profile-operation-log-panel')).toBeVisible();
|
||||
await expect(page.getByTestId('profile-operation-log')).toContainText('che:default 구성 요소를 빌드합니다.');
|
||||
await expect(page.getByTestId('profile-operation-log')).toContainText('천하서버 구성 요소를 빌드합니다.');
|
||||
await expect(page.getByTestId('profile-operation-log')).toContainText('시나리오 초기 데이터 생성을 완료했습니다.');
|
||||
await expect(page.getByTestId('profile-operation-log-status')).toContainText('SUCCEEDED');
|
||||
const operationTable = page.getByTestId('operations-table');
|
||||
await expect(operationTable.getByRole('columnheader')).toHaveText(['요청 · 작업', '상태', '보기']);
|
||||
await expect(operationTable.getByText('시나리오 초기화', { exact: true })).toBeVisible();
|
||||
await expect(operationTable.getByText('완료', { exact: true })).toBeVisible();
|
||||
await expect(operationTable.getByText('che:default', { exact: true })).toBeHidden();
|
||||
const detailsToggle = operationTable.getByTestId('operation-details-toggle');
|
||||
await expect(detailsToggle).toHaveAttribute('aria-expanded', 'false');
|
||||
await detailsToggle.click();
|
||||
await expect(detailsToggle).toHaveAttribute('aria-expanded', 'true');
|
||||
const operationDetail = operationTable.getByTestId('operation-detail');
|
||||
await expect(operationDetail).toContainText('천하서버');
|
||||
await expect(operationDetail).not.toContainText('che:default');
|
||||
await expect(operationDetail).toContainText('0123456789abcdef0123456789abcdef01234567');
|
||||
await expect(operationDetail).toContainText('admin');
|
||||
const operationTableGeometry = await page.getByTestId('operations-table').evaluate((table) => {
|
||||
const columnWidths = Array.from(table.querySelectorAll('thead th')).map(
|
||||
(heading) => heading.getBoundingClientRect().width
|
||||
);
|
||||
const rowHeight = table.querySelector('tbody tr')?.getBoundingClientRect().height ?? 0;
|
||||
const summaryRowHeight = table
|
||||
.querySelector('[data-testid="operation-summary-row"]')
|
||||
?.getBoundingClientRect().height;
|
||||
return {
|
||||
columnWidths,
|
||||
rowHeight,
|
||||
summaryRowHeight,
|
||||
tableWidth: table.getBoundingClientRect().width,
|
||||
scrollerWidth: table.parentElement?.getBoundingClientRect().width ?? 0,
|
||||
scrollerScrollWidth: table.parentElement?.scrollWidth ?? 0,
|
||||
tableLayout: getComputedStyle(table).tableLayout,
|
||||
};
|
||||
});
|
||||
const sourceRefGeometry = await page.getByTestId('operation-source-ref').evaluate((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
title: element.getAttribute('title'),
|
||||
text: element.textContent?.trim(),
|
||||
overflow: style.overflow,
|
||||
textOverflow: style.textOverflow,
|
||||
whiteSpace: style.whiteSpace,
|
||||
};
|
||||
});
|
||||
expect(operationTableGeometry.tableLayout).toBe('fixed');
|
||||
expect(operationTableGeometry.tableWidth).toBeGreaterThanOrEqual(1_300);
|
||||
expect(operationTableGeometry.tableWidth).toBeGreaterThan(operationTableGeometry.scrollerWidth);
|
||||
expect(operationTableGeometry.columnWidths[0]).toBeGreaterThanOrEqual(159);
|
||||
expect(operationTableGeometry.columnWidths[1]).toBeGreaterThanOrEqual(263);
|
||||
expect(operationTableGeometry.columnWidths[5]).toBeLessThanOrEqual(113);
|
||||
expect(operationTableGeometry.columnWidths[7]).toBeGreaterThanOrEqual(175);
|
||||
expect(operationTableGeometry.columnWidths[1]).toBeGreaterThan(operationTableGeometry.columnWidths[5]! * 2);
|
||||
expect(operationTableGeometry.rowHeight).toBeLessThanOrEqual(50);
|
||||
expect(operationTableGeometry.columnWidths).toHaveLength(3);
|
||||
expect(operationTableGeometry.tableWidth).toBeLessThanOrEqual(operationTableGeometry.scrollerWidth + 1);
|
||||
expect(operationTableGeometry.scrollerScrollWidth).toBeLessThanOrEqual(operationTableGeometry.scrollerWidth + 1);
|
||||
expect(operationTableGeometry.columnWidths[0]).toBeGreaterThan(operationTableGeometry.columnWidths[1]!);
|
||||
expect(operationTableGeometry.columnWidths[2]).toBeGreaterThan(operationTableGeometry.columnWidths[1]!);
|
||||
expect(operationTableGeometry.summaryRowHeight).toBeLessThanOrEqual(90);
|
||||
expect(sourceRefGeometry).toEqual({
|
||||
title: '0123456789abcdef0123456789abcdef01234567',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
text: 'COMMIT 0123456789abcdef0123456789abcdef01234567',
|
||||
overflow: 'visible',
|
||||
whiteSpace: 'normal',
|
||||
});
|
||||
await writeFile(
|
||||
testInfo.outputPath('operation-table-metrics.json'),
|
||||
@@ -682,7 +708,7 @@ test('separates branch and commit semantics and submits a reset from the dedicat
|
||||
expect(JSON.stringify(resetRequest?.body)).toContain('"openAt":"2030-08-13T02:00:00.000Z"');
|
||||
await page.screenshot({ path: testInfo.outputPath('reset-operation-log-desktop.png'), fullPage: true });
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.setViewportSize({ width: 500, height: 844 });
|
||||
const mobileGeometry = await page
|
||||
.getByTestId('server-operations-page')
|
||||
.locator('section')
|
||||
@@ -694,7 +720,7 @@ test('separates branch and commit semantics and submits a reset from the dedicat
|
||||
});
|
||||
return children;
|
||||
});
|
||||
expect(mobileGeometry[0]!.width).toBeLessThanOrEqual(390);
|
||||
expect(mobileGeometry[0]!.width).toBeLessThanOrEqual(500);
|
||||
const mobilePublishGeometry = await publishSchedule.evaluate((element) => {
|
||||
const label = element.closest('label');
|
||||
if (!label) throw new Error('expected publish schedule label');
|
||||
@@ -724,18 +750,21 @@ test('separates branch and commit semantics and submits a reset from the dedicat
|
||||
const mobileOperationTableGeometry = await page.getByTestId('operations-table').evaluate((table) => {
|
||||
const scroller = table.parentElement!;
|
||||
const scrollerRect = scroller.getBoundingClientRect();
|
||||
const detailRect = table.querySelector('[data-testid="operation-detail"]')?.getBoundingClientRect();
|
||||
return {
|
||||
tableWidth: table.getBoundingClientRect().width,
|
||||
scrollerX: scrollerRect.x,
|
||||
scrollerWidth: scrollerRect.width,
|
||||
scrollerScrollWidth: scroller.scrollWidth,
|
||||
detailX: detailRect?.x,
|
||||
detailRight: detailRect?.right,
|
||||
viewportWidth: document.documentElement.clientWidth,
|
||||
documentScrollWidth: document.documentElement.scrollWidth,
|
||||
};
|
||||
});
|
||||
expect(mobileOperationTableGeometry.tableWidth).toBeGreaterThanOrEqual(1_300);
|
||||
expect(mobileOperationTableGeometry.scrollerScrollWidth).toBeGreaterThan(
|
||||
mobileOperationTableGeometry.scrollerWidth
|
||||
expect(mobileOperationTableGeometry.tableWidth).toBeLessThanOrEqual(mobileOperationTableGeometry.scrollerWidth + 1);
|
||||
expect(mobileOperationTableGeometry.scrollerScrollWidth).toBeLessThanOrEqual(
|
||||
mobileOperationTableGeometry.scrollerWidth + 1
|
||||
);
|
||||
expect(mobileOperationTableGeometry.scrollerX).toBeGreaterThanOrEqual(0);
|
||||
expect(mobileOperationTableGeometry.scrollerX + mobileOperationTableGeometry.scrollerWidth).toBeLessThanOrEqual(
|
||||
@@ -744,6 +773,13 @@ test('separates branch and commit semantics and submits a reset from the dedicat
|
||||
expect(mobileOperationTableGeometry.documentScrollWidth).toBeLessThanOrEqual(
|
||||
mobileOperationTableGeometry.viewportWidth
|
||||
);
|
||||
expect(mobileOperationTableGeometry.detailX).toBeGreaterThanOrEqual(mobileOperationTableGeometry.scrollerX);
|
||||
expect(mobileOperationTableGeometry.detailRight).toBeLessThanOrEqual(
|
||||
mobileOperationTableGeometry.scrollerX + mobileOperationTableGeometry.scrollerWidth
|
||||
);
|
||||
await page.screenshot({ path: testInfo.outputPath('mobile-operation-detail.png'), fullPage: true });
|
||||
await detailsToggle.click();
|
||||
await expect(operationDetail).toBeHidden();
|
||||
await writeFile(
|
||||
testInfo.outputPath('operation-table-mobile-metrics.json'),
|
||||
JSON.stringify(mobileOperationTableGeometry, null, 2)
|
||||
@@ -772,9 +808,9 @@ test('separates DB-preserving profile deployment from DB reset', async ({ page }
|
||||
await page.getByTestId('request-deploy').click();
|
||||
|
||||
await expect(page.getByText('DB 보존 배포 작업을 등록했습니다.').first()).toBeVisible();
|
||||
await expect(page.getByTestId('operations-table')).toContainText('DEPLOY');
|
||||
await expect(page.getByTestId('operations-table')).toContainText('버전 업데이트');
|
||||
await expect(page.getByTestId('profile-operation-log-panel')).toBeVisible();
|
||||
await expect(page.getByTestId('profile-operation-log')).toContainText('che:default 구성 요소를 빌드합니다.');
|
||||
await expect(page.getByTestId('profile-operation-log')).toContainText('천하서버 구성 요소를 빌드합니다.');
|
||||
await expect(page.getByTestId('profile-operation-log')).toContainText('game-frontend build complete');
|
||||
await expect(page.getByTestId('profile-operation-log-status')).toContainText('SUCCEEDED');
|
||||
expect(state.requestBodies.some((entry) => entry.operation === 'admin.operations.requestDeploy')).toBe(true);
|
||||
@@ -801,7 +837,7 @@ test('submits a separately authorized destructive game cancellation on desktop a
|
||||
|
||||
await page.goto('admin/servers/che%3Adefault/cancel');
|
||||
await expect(page).toHaveURL(/\/gateway\/admin\/servers\/che%3Adefault\/cancel$/);
|
||||
await expect(page.getByRole('heading', { name: 'che:default 게임 취소' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: '천하서버 게임 취소' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: '게임 취소', exact: true })).toHaveAttribute('aria-current', 'page');
|
||||
await expect(page.getByRole('link', { name: '시나리오 초기화', exact: true })).toHaveCount(0);
|
||||
await expect(page.getByTestId('request-game-cancellation')).toBeDisabled();
|
||||
@@ -810,7 +846,7 @@ test('submits a separately authorized destructive game cancellation on desktop a
|
||||
await page.getByTestId('cancellation-general-mode').selectOption('RETAIN');
|
||||
await page.getByTestId('cancellation-retention-percent').fill('35');
|
||||
await page.getByTestId('cancellation-reason').fill('잘못된 시나리오로 개장함');
|
||||
await page.getByTestId('cancellation-confirmation').fill('che:default');
|
||||
await page.getByTestId('cancellation-confirmation').fill('천하서버 게임 취소');
|
||||
const cancelButton = page.getByTestId('request-game-cancellation');
|
||||
await expect(cancelButton).toBeEnabled();
|
||||
await cancelButton.hover();
|
||||
@@ -837,7 +873,7 @@ test('submits a separately authorized destructive game cancellation on desktop a
|
||||
|
||||
await cancelButton.click();
|
||||
await expect(page.getByText('게임 취소 작업을 등록했습니다.').first()).toBeVisible();
|
||||
await expect(page.getByTestId('operations-table')).toContainText('CANCEL_GAME');
|
||||
await expect(page.getByTestId('operations-table')).toContainText('게임 취소');
|
||||
expect(confirmations).toHaveLength(1);
|
||||
expect(confirmations[0]).toContain('기수 행 물리 삭제');
|
||||
expect(confirmations[0]).toContain('장수 기록 보존');
|
||||
@@ -1315,10 +1351,11 @@ test('renders the stable server identity without exposing the default suffix as
|
||||
const navigation = page.getByRole('navigation', { name: '관리자 메뉴' });
|
||||
const profileLink = navigation.getByRole('link', { name: '천하서버' });
|
||||
await expect(profileLink).toBeVisible({ timeout: 900 });
|
||||
await expect(profileLink).toHaveAttribute('title', '서버 ID: che:default');
|
||||
await expect(profileLink).toHaveAttribute('title', '천하서버 서버 관리');
|
||||
await expect(navigation).not.toContainText('천하서버 (che:default)');
|
||||
await expect(navigation.getByRole('link', { name: 'Gateway 릴리스' })).toBeVisible({ timeout: 900 });
|
||||
await expect(page.getByText('서버 ID: che:default · 인스턴스: default')).toBeVisible();
|
||||
await expect(page.getByText('서버 ID: che:default · 인스턴스: default')).toHaveCount(0);
|
||||
await expect(page.getByText('천하서버', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('현재 시나리오: 2')).toBeVisible();
|
||||
await profileLink.focus();
|
||||
const desktop = await profileLink.evaluate((element) => {
|
||||
@@ -1405,7 +1442,7 @@ test('stops a running profile build while keeping the existing runtime available
|
||||
await page.getByRole('button', { name: '빌드 중단' }).click();
|
||||
|
||||
await expect(page.getByText('프로필 빌드를 중단했습니다.').first()).toBeVisible();
|
||||
await expect(page.getByTestId('operations-table').getByText('CANCELLED', { exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId('operations-table').getByText('중단됨', { exact: true })).toBeVisible();
|
||||
expect(state.requestBodies.some((entry) => entry.operation === 'admin.operations.cancel')).toBe(true);
|
||||
});
|
||||
|
||||
@@ -1677,17 +1714,20 @@ test('renders a failed reset, retries it as a new operation, and reaches success
|
||||
page.on('dialog', (dialog) => dialog.accept());
|
||||
|
||||
await page.goto('admin/servers/che%3Adefault/scenario');
|
||||
await expect(page.getByTestId('operations-table').getByText('FAILED', { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('cell', { name: 'fedcba987654', exact: true })).toBeVisible();
|
||||
const operationTable = page.getByTestId('operations-table');
|
||||
await expect(operationTable.getByText('실패', { exact: true })).toBeVisible();
|
||||
await expect(operationTable.getByText(longError)).toBeHidden();
|
||||
await operationTable.getByRole('button', { name: '오류 상세' }).click();
|
||||
await expect(operationTable.getByText('fedcba9876543210fedcba9876543210fedcba98', { exact: true })).toBeVisible();
|
||||
const failure = page.getByTestId('operations-table').getByText(longError);
|
||||
await expect(failure).toBeVisible();
|
||||
expect(await failure.evaluate((element) => getComputedStyle(element).color)).toBe('oklch(0.704 0.191 22.216)');
|
||||
expect(await failure.evaluate((element) => getComputedStyle(element).color)).toBe('oklch(0.808 0.114 19.571)');
|
||||
|
||||
await page.getByRole('button', { name: '재시도' }).click();
|
||||
await expect(page.getByText('재시도 작업을 등록했습니다.').first()).toBeVisible();
|
||||
await expect(page.getByTestId('operations-table').getByText('FAILED', { exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId('operations-table').getByText('QUEUED', { exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId('operations-table').locator('tbody tr')).toHaveCount(2);
|
||||
await expect(page.getByTestId('operations-table').getByText('실패', { exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId('operations-table').getByText('대기 중', { exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId('operation-summary-row')).toHaveCount(2);
|
||||
|
||||
state.operations[0] = {
|
||||
...state.operations[0]!,
|
||||
@@ -1698,7 +1738,7 @@ test('renders a failed reset, retries it as a new operation, and reaches success
|
||||
};
|
||||
state.runtimeRunning = true;
|
||||
await page.getByTestId('refresh-operations').click();
|
||||
await expect(page.getByTestId('operations-table').getByText('SUCCEEDED', { exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId('operations-table').getByText('완료', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('운영 프로필', { exact: true })).toHaveCount(0);
|
||||
|
||||
await page.screenshot({ path: testInfo.outputPath('failed-retry-succeeded-desktop.png'), fullPage: true });
|
||||
@@ -1706,8 +1746,14 @@ test('renders a failed reset, retries it as a new operation, and reaches success
|
||||
const tableGeometry = await page.getByTestId('operations-table').evaluate((table) => {
|
||||
const tableRect = table.getBoundingClientRect();
|
||||
const scrollerRect = table.parentElement!.getBoundingClientRect();
|
||||
return { tableWidth: tableRect.width, scrollerWidth: scrollerRect.width };
|
||||
return {
|
||||
tableWidth: tableRect.width,
|
||||
scrollerWidth: scrollerRect.width,
|
||||
documentScrollWidth: document.documentElement.scrollWidth,
|
||||
viewportWidth: document.documentElement.clientWidth,
|
||||
};
|
||||
});
|
||||
expect(tableGeometry.tableWidth).toBeGreaterThan(tableGeometry.scrollerWidth);
|
||||
expect(tableGeometry.tableWidth).toBeLessThanOrEqual(tableGeometry.scrollerWidth + 1);
|
||||
expect(tableGeometry.documentScrollWidth).toBeLessThanOrEqual(tableGeometry.viewportWidth);
|
||||
await page.screenshot({ path: testInfo.outputPath('failed-retry-succeeded-mobile.png'), fullPage: true });
|
||||
});
|
||||
|
||||
@@ -59,6 +59,8 @@ const installFixture = async (page: Page) => {
|
||||
{
|
||||
profileName: 'hwe:default',
|
||||
profile: 'hwe',
|
||||
instanceKey: 'default',
|
||||
displayName: '훼',
|
||||
currentScenario: 'default',
|
||||
status: 'RUNNING',
|
||||
},
|
||||
@@ -93,6 +95,8 @@ test('web push settings are default-off and remain configurable while delivery i
|
||||
const table = page.locator('#notification-table');
|
||||
await expect(table).toBeVisible();
|
||||
await expect(table).toContainText('준비됨 · 운영 비활성');
|
||||
await expect(table).toContainText('훼 · default');
|
||||
await expect(table).not.toContainText('hwe:default');
|
||||
await expect(page.getByRole('button', { name: '이 기기 알림 켜기' })).toBeDisabled();
|
||||
const checkboxes = table.getByRole('checkbox');
|
||||
await expect(checkboxes).toHaveCount(9);
|
||||
|
||||
@@ -5,6 +5,7 @@ type ServerProfileTab = 'status' | 'version' | 'scenario' | 'cancel';
|
||||
|
||||
const props = defineProps<{
|
||||
profileName: string;
|
||||
profileLabel: string;
|
||||
activeTab: ServerProfileTab;
|
||||
canDeploy: boolean;
|
||||
canReset: boolean;
|
||||
@@ -45,7 +46,7 @@ const tabs = computed(() =>
|
||||
<nav
|
||||
class="server-profile-tabs"
|
||||
:style="{ '--server-tab-count': tabs.length }"
|
||||
:aria-label="`${profileName} 서버 관리 탭`"
|
||||
:aria-label="`${profileLabel} 서버 관리 탭`"
|
||||
data-testid="server-profile-tabs"
|
||||
>
|
||||
<RouterLink
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { directTrpc } from '../utils/trpc';
|
||||
|
||||
export type AdminProfileNavigationItem = {
|
||||
profileName: string;
|
||||
profile: string;
|
||||
instanceKey: string;
|
||||
displayName?: string;
|
||||
currentScenario: string | null;
|
||||
meta?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
let cachedProfiles: AdminProfileNavigationItem[] | undefined;
|
||||
let cachedAt = 0;
|
||||
let inFlight: Promise<AdminProfileNavigationItem[]> | undefined;
|
||||
|
||||
export const loadAdminProfileNavigation = async (): Promise<AdminProfileNavigationItem[]> => {
|
||||
if (cachedProfiles && Date.now() - cachedAt < 5_000) return cachedProfiles;
|
||||
if (inFlight) return inFlight;
|
||||
inFlight = directTrpc.admin.profiles.listNavigation
|
||||
.query()
|
||||
.then((profiles) => {
|
||||
cachedProfiles = profiles as AdminProfileNavigationItem[];
|
||||
cachedAt = Date.now();
|
||||
return cachedProfiles;
|
||||
})
|
||||
.finally(() => {
|
||||
inFlight = undefined;
|
||||
});
|
||||
return inFlight;
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { computed, onMounted, ref } from 'vue';
|
||||
import DefaultLayout from './DefaultLayout.vue';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { directTrpc } from '../utils/trpc';
|
||||
import { loadAdminProfileNavigation, type AdminProfileNavigationItem } from '../composables/useAdminProfileNavigation';
|
||||
|
||||
defineProps<{
|
||||
title: string;
|
||||
@@ -14,30 +15,9 @@ const menuOpen = ref(false);
|
||||
const auth = useAuthStore();
|
||||
const adminNavigationClient = directTrpc.admin as unknown as {
|
||||
capabilities: { list: { query: () => Promise<Array<{ permission: string; scopes?: string[] }>> } };
|
||||
profiles: {
|
||||
listNavigation: {
|
||||
query: () => Promise<
|
||||
Array<{
|
||||
profileName: string;
|
||||
profile: string;
|
||||
instanceKey: string;
|
||||
currentScenario: string | null;
|
||||
meta?: Record<string, unknown>;
|
||||
}>
|
||||
>;
|
||||
};
|
||||
};
|
||||
};
|
||||
const capabilities = ref<Array<{ permission: string; scopes?: string[] }>>([]);
|
||||
const profiles = ref<
|
||||
Array<{
|
||||
profileName: string;
|
||||
profile: string;
|
||||
instanceKey: string;
|
||||
currentScenario: string | null;
|
||||
meta?: Record<string, unknown>;
|
||||
}>
|
||||
>([]);
|
||||
const profiles = ref<AdminProfileNavigationItem[]>([]);
|
||||
|
||||
const isRootAdmin = computed(() =>
|
||||
(auth.user?.roles ?? []).some((role) => role === 'superuser' || role === 'admin' || role === 'admin.superuser')
|
||||
@@ -52,10 +32,12 @@ const hasAnyProfileCapability = computed(() =>
|
||||
)
|
||||
);
|
||||
|
||||
const profileLabel = (profile: (typeof profiles.value)[number]): string => {
|
||||
const korName = profile.meta?.korName;
|
||||
const displayName = typeof korName === 'string' && korName.trim() ? korName.trim() : profile.profile;
|
||||
return profile.instanceKey === 'default' ? displayName : `${displayName} [${profile.instanceKey}]`;
|
||||
const profileDisplayName = (profile: (typeof profiles.value)[number]): string => {
|
||||
if (profile.displayName?.trim()) return profile.displayName.trim();
|
||||
const configuredName = profile.meta?.korName;
|
||||
const baseName =
|
||||
typeof configuredName === 'string' && configuredName.trim() ? configuredName.trim() : profile.profile;
|
||||
return profile.instanceKey === 'default' ? baseName : `${baseName} [${profile.instanceKey}]`;
|
||||
};
|
||||
|
||||
const navigation = computed(() => [
|
||||
@@ -86,8 +68,8 @@ const navigation = computed(() => [
|
||||
},
|
||||
...profiles.value.map((profile) => ({
|
||||
to: `/admin/servers/${encodeURIComponent(profile.profileName)}`,
|
||||
label: profileLabel(profile),
|
||||
title: `서버 ID: ${profile.profileName}`,
|
||||
label: profileDisplayName(profile),
|
||||
title: `${profileDisplayName(profile)} 서버 관리`,
|
||||
icon: '└',
|
||||
exact: false,
|
||||
visible: true,
|
||||
@@ -129,7 +111,7 @@ const navigation = computed(() => [
|
||||
onMounted(async () => {
|
||||
const [capabilityResult, profileResult] = await Promise.allSettled([
|
||||
adminNavigationClient.capabilities.list.query(),
|
||||
adminNavigationClient.profiles.listNavigation.query(),
|
||||
loadAdminProfileNavigation(),
|
||||
]);
|
||||
capabilities.value = capabilityResult.status === 'fulfilled' ? capabilityResult.value : [];
|
||||
profiles.value = profileResult.status === 'fulfilled' ? profileResult.value : [];
|
||||
|
||||
@@ -851,7 +851,8 @@ onBeforeUnmount(() => {
|
||||
:key="profile.profileName"
|
||||
:value="profile.profileName"
|
||||
>
|
||||
{{ profile.profile }} · {{ profile.currentScenario ?? profile.profileName }}
|
||||
{{ profile.displayName ?? profile.profile }} ·
|
||||
{{ profile.currentScenario ?? '시나리오 미설정' }}
|
||||
</option>
|
||||
</select>
|
||||
<span v-if="currentProfile" class="notification-profile-status">{{
|
||||
|
||||
@@ -134,6 +134,7 @@ type AdminAuditEvent = {
|
||||
targetType?: string;
|
||||
targetId?: string;
|
||||
profileName?: string;
|
||||
profileDisplayName?: string;
|
||||
action: string;
|
||||
outcome: 'STARTED' | 'SUCCEEDED' | 'FAILED';
|
||||
reason?: string;
|
||||
@@ -144,6 +145,9 @@ type AdminAuditEvent = {
|
||||
|
||||
type KakaoGracePolicy = {
|
||||
profileName: string;
|
||||
profile: string;
|
||||
instanceKey: string;
|
||||
displayName: string;
|
||||
requiresKakaoVerification: boolean;
|
||||
kakaoVerified: boolean;
|
||||
accessAllowed: boolean;
|
||||
@@ -185,6 +189,7 @@ type AdminProfile = {
|
||||
profileName: string;
|
||||
profile: string;
|
||||
instanceKey: string;
|
||||
displayName?: string;
|
||||
currentScenario: string | null;
|
||||
/** @deprecated Rollback-compatible mirror of currentScenario. */
|
||||
scenario: string;
|
||||
@@ -444,6 +449,14 @@ const visibleProfiles = computed(() =>
|
||||
props.profileName ? profiles.value.filter((profile) => profile.profileName === props.profileName) : profiles.value
|
||||
);
|
||||
|
||||
const adminProfileDisplayName = (profile: AdminProfile): string => {
|
||||
if (profile.displayName?.trim()) return profile.displayName.trim();
|
||||
const configuredName = profile.meta.korName;
|
||||
const baseName =
|
||||
typeof configuredName === 'string' && configuredName.trim() ? configuredName.trim() : profile.profile;
|
||||
return profile.instanceKey === 'default' ? baseName : `${baseName} [${profile.instanceKey}]`;
|
||||
};
|
||||
|
||||
const runtimeActionPending = (profile: AdminProfile): boolean => {
|
||||
return profile.runtimeActions.some((action) => action.status === 'REQUESTED' || action.status === 'PARTIAL');
|
||||
};
|
||||
@@ -566,7 +579,7 @@ const kakaoGraceStatus = ref('');
|
||||
const kakaoPolicies = ref<KakaoGracePolicy[]>([]);
|
||||
const specialAccessGrants = ref<SpecialAccountAccessGrant[]>([]);
|
||||
const specialAccessKind = ref<SpecialAccountAccessGrant['kind']>('RECOVERY');
|
||||
const specialAccessProfiles = ref('');
|
||||
const specialAccessProfiles = ref<string[]>([]);
|
||||
const specialAccessAllowsGeneralCreation = ref(true);
|
||||
const specialAccessExpiresAt = ref('');
|
||||
const specialAccessStatus = ref('');
|
||||
@@ -574,6 +587,38 @@ const userHistory = ref<AdminAuditEvent[]>([]);
|
||||
const globalAuditHistory = ref<AdminAuditEvent[]>([]);
|
||||
const globalAuditStatus = ref('');
|
||||
|
||||
const profileScopeLabel = (scope: string): string => {
|
||||
const exact = kakaoPolicies.value.find((policy) => policy.profileName === scope);
|
||||
if (exact) return exact.displayName;
|
||||
const base = kakaoPolicies.value.filter((policy) => policy.profile === scope);
|
||||
if (!base.length) return '삭제되었거나 접근할 수 없는 서버';
|
||||
const baseName = base[0]!.displayName.replace(/ \[[^\]]+\]$/, '');
|
||||
return base.length > 1 ? `${baseName} 전체 인스턴스` : baseName;
|
||||
};
|
||||
|
||||
const specialAccessScopeOptions = computed(() => {
|
||||
const grouped = new Map<string, KakaoGracePolicy[]>();
|
||||
for (const policy of kakaoPolicies.value) {
|
||||
const bucket = grouped.get(policy.profile) ?? [];
|
||||
bucket.push(policy);
|
||||
grouped.set(policy.profile, bucket);
|
||||
}
|
||||
return [...grouped.entries()].flatMap(([profile, entries]) => {
|
||||
if (entries.length === 1) return [{ value: profile, label: entries[0]!.displayName }];
|
||||
const baseName = entries[0]!.displayName.replace(/ \[[^\]]+\]$/, '');
|
||||
return [
|
||||
{ value: profile, label: `${baseName} 전체 인스턴스` },
|
||||
...entries.map((entry) => ({ value: entry.profileName, label: entry.displayName })),
|
||||
];
|
||||
});
|
||||
});
|
||||
|
||||
const auditTargetLabel = (event: AdminAuditEvent): string => {
|
||||
if (event.profileDisplayName) return event.profileDisplayName;
|
||||
if (event.targetType === 'PROFILE' || event.profileName) return '삭제되었거나 접근할 수 없는 서버';
|
||||
return event.targetId ?? '';
|
||||
};
|
||||
|
||||
const { feedback: showFeedbackToast } = useToast();
|
||||
const actionFeedback = [
|
||||
noticeStatus,
|
||||
@@ -1059,7 +1104,7 @@ const applyCapabilitySelection = () => {
|
||||
const capability = capabilities.value.find((entry) => entry.permission === selectedCapability.value);
|
||||
if (!capability) return;
|
||||
if (capability.scope === 'PROFILE' && !capabilityProfile.value.trim()) {
|
||||
rolesStatus.value = 'Profile 범위를 입력하세요.';
|
||||
rolesStatus.value = '대상 서버를 선택하세요.';
|
||||
return;
|
||||
}
|
||||
rolesInput.value =
|
||||
@@ -1101,10 +1146,7 @@ const grantSpecialAccess = async () => {
|
||||
await adminClient.users.grantSpecialAccess.mutate({
|
||||
userId: userResult.value.id,
|
||||
kind: specialAccessKind.value,
|
||||
profiles: specialAccessProfiles.value
|
||||
.split(',')
|
||||
.map((profile) => profile.trim())
|
||||
.filter(Boolean),
|
||||
profiles: specialAccessProfiles.value,
|
||||
allowsGeneralCreation: specialAccessAllowsGeneralCreation.value,
|
||||
expiresAt: specialAccessExpiresAt.value
|
||||
? (serverDateTimeInputToIso(specialAccessExpiresAt.value) ?? null)
|
||||
@@ -1266,7 +1308,7 @@ const applyRestriction = async () => {
|
||||
const reason = requireUserActionReason();
|
||||
if (!reason) return;
|
||||
if (!restrictionProfile.value.trim()) {
|
||||
restrictionStatus.value = '서버 프로필명을 입력하세요.';
|
||||
restrictionStatus.value = '대상 서버를 선택하세요.';
|
||||
return;
|
||||
}
|
||||
const features = restrictionFeatures.value
|
||||
@@ -1301,7 +1343,7 @@ const clearRestriction = async () => {
|
||||
const reason = requireUserActionReason();
|
||||
if (!reason) return;
|
||||
if (!restrictionProfile.value.trim()) {
|
||||
restrictionStatus.value = '서버 프로필명을 입력하세요.';
|
||||
restrictionStatus.value = '대상 서버를 선택하세요.';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -1706,13 +1748,21 @@ onMounted(() => {
|
||||
{{ capability.label }} · {{ capability.risk }}
|
||||
</option>
|
||||
</select>
|
||||
<input
|
||||
<select
|
||||
v-model="capabilityProfile"
|
||||
type="text"
|
||||
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
||||
placeholder="Profile 범위 (예: che:default)"
|
||||
:disabled="!hasUser"
|
||||
/>
|
||||
:disabled="!hasUser || !kakaoPolicies.length"
|
||||
aria-label="권한 대상 서버"
|
||||
>
|
||||
<option value="">대상 서버 선택</option>
|
||||
<option
|
||||
v-for="policy in kakaoPolicies"
|
||||
:key="policy.profileName"
|
||||
:value="policy.profileName"
|
||||
>
|
||||
{{ policy.displayName }}
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
class="bg-zinc-700 hover:bg-zinc-600 px-3 py-2 rounded text-sm"
|
||||
:disabled="!hasUser"
|
||||
@@ -1779,13 +1829,22 @@ onMounted(() => {
|
||||
:disabled="!hasUser"
|
||||
aria-label="특수 접근 만료 시각"
|
||||
/>
|
||||
<input
|
||||
v-model="specialAccessProfiles"
|
||||
type="text"
|
||||
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
||||
placeholder="che 또는 che:2 (쉼표 구분, 비우면 전체)"
|
||||
:disabled="!hasUser"
|
||||
/>
|
||||
<fieldset class="rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-sm text-white">
|
||||
<legend class="px-1 text-xs text-zinc-400">허용 서버 (비우면 전체)</legend>
|
||||
<label
|
||||
v-for="option in specialAccessScopeOptions"
|
||||
:key="option.value"
|
||||
class="mr-4 inline-flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
v-model="specialAccessProfiles"
|
||||
type="checkbox"
|
||||
:value="option.value"
|
||||
:disabled="!hasUser"
|
||||
/>
|
||||
{{ option.label }}
|
||||
</label>
|
||||
</fieldset>
|
||||
<label class="flex items-center gap-2 text-sm text-zinc-300 px-2">
|
||||
<input
|
||||
v-model="specialAccessAllowsGeneralCreation"
|
||||
@@ -1812,7 +1871,11 @@ onMounted(() => {
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<span class="font-semibold text-amber-200">
|
||||
{{ grant.kind }} ·
|
||||
{{ grant.profiles.length ? grant.profiles.join(', ') : '전체 profile' }}
|
||||
{{
|
||||
grant.profiles.length
|
||||
? grant.profiles.map(profileScopeLabel).join(', ')
|
||||
: '전체 서버'
|
||||
}}
|
||||
</span>
|
||||
<button
|
||||
v-if="!grant.revokedAt"
|
||||
@@ -1871,7 +1934,7 @@ onMounted(() => {
|
||||
<table class="w-full min-w-[620px] text-xs">
|
||||
<thead class="text-zinc-500">
|
||||
<tr>
|
||||
<th class="p-2 text-left">Profile</th>
|
||||
<th class="p-2 text-left">서버</th>
|
||||
<th>접근</th>
|
||||
<th>장수 생성</th>
|
||||
<th>기본 접근 유예</th>
|
||||
@@ -1885,7 +1948,7 @@ onMounted(() => {
|
||||
:key="policy.profileName"
|
||||
class="border-t border-zinc-800"
|
||||
>
|
||||
<td class="p-2">{{ policy.profileName }}</td>
|
||||
<td class="p-2">{{ policy.displayName }}</td>
|
||||
<td class="text-center">{{ policy.accessAllowed ? '허용' : '차단' }}</td>
|
||||
<td class="text-center">{{ policy.canCreateGeneral ? '허용' : '차단' }}</td>
|
||||
<td class="text-center">{{ policy.accessGraceDays }}일</td>
|
||||
@@ -1944,13 +2007,21 @@ onMounted(() => {
|
||||
>
|
||||
<h4 class="text-base font-semibold">서버별 기능 제재 (서버 시간 UTC+9)</h4>
|
||||
<div class="grid gap-2">
|
||||
<input
|
||||
<select
|
||||
v-model="restrictionProfile"
|
||||
type="text"
|
||||
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
||||
placeholder="profile:scenario"
|
||||
:disabled="!hasUser"
|
||||
/>
|
||||
:disabled="!hasUser || !kakaoPolicies.length"
|
||||
aria-label="제재 대상 서버"
|
||||
>
|
||||
<option value="">대상 서버 선택</option>
|
||||
<option
|
||||
v-for="policy in kakaoPolicies"
|
||||
:key="policy.profileName"
|
||||
:value="policy.profileName"
|
||||
>
|
||||
{{ policy.displayName }}
|
||||
</option>
|
||||
</select>
|
||||
<input
|
||||
v-model="restrictionFeatures"
|
||||
type="text"
|
||||
@@ -2115,7 +2186,7 @@ onMounted(() => {
|
||||
</div>
|
||||
<div class="text-zinc-400">
|
||||
{{ event.actorUsername }} · {{ event.targetType ?? '-' }}
|
||||
{{ event.targetId ?? event.profileName ?? '' }} · {{ event.reason ?? '사유 없음' }}
|
||||
{{ auditTargetLabel(event) }} · {{ event.reason ?? '사유 없음' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2180,6 +2251,7 @@ onMounted(() => {
|
||||
>
|
||||
<ServerProfileTabs
|
||||
:profile-name="profile.profileName"
|
||||
:profile-label="adminProfileDisplayName(profile)"
|
||||
active-tab="status"
|
||||
:can-deploy="hasCapability('admin.profiles.deploy', profile.profileName)"
|
||||
:can-reset="hasCapability('admin.scenarios.reset', profile.profileName)"
|
||||
@@ -2189,10 +2261,7 @@ onMounted(() => {
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2">
|
||||
<div>
|
||||
<div class="text-base font-semibold">
|
||||
{{ profile.meta.korName ?? profile.profile }}
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500">
|
||||
서버 ID: {{ profile.profileName }} · 인스턴스: {{ profile.instanceKey }}
|
||||
{{ adminProfileDisplayName(profile) }}
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500">
|
||||
현재 시나리오: {{ profile.currentScenario ?? '미설정' }}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch }
|
||||
import CompactHelp from '../components/CompactHelp.vue';
|
||||
import ServerProfileTabs from '../components/ServerProfileTabs.vue';
|
||||
import { useToast } from '../composables/useToast';
|
||||
import { loadAdminProfileNavigation, type AdminProfileNavigationItem } from '../composables/useAdminProfileNavigation';
|
||||
import AdminConsoleLayout from '../layouts/AdminConsoleLayout.vue';
|
||||
import {
|
||||
normalizeProfileResetDefaults,
|
||||
@@ -86,6 +87,7 @@ type GatewayReleaseLog = {
|
||||
const scenarios = ref<Scenario[]>([]);
|
||||
const operations = ref<Operation[]>([]);
|
||||
const selectedProfileOperationId = ref('');
|
||||
const expandedProfileOperationId = ref('');
|
||||
const profileOperationLogs = ref<GatewayReleaseLog[]>([]);
|
||||
const profileOperationLogCursor = ref<string>();
|
||||
const profileOperationLogStatus = ref('');
|
||||
@@ -102,6 +104,28 @@ const gatewayReleaseLogConnection = ref<'idle' | 'connected' | 'reconnecting'>('
|
||||
const gatewayReleaseLogViewport = ref<HTMLElement>();
|
||||
const gatewayReleaseAvailable = ref(false);
|
||||
const selectedProfileName = computed(() => props.profileName ?? '');
|
||||
const profileIdentities = ref<AdminProfileNavigationItem[]>([]);
|
||||
const profileDisplayName = (profileName: string): string => {
|
||||
const profile = profileIdentities.value.find((candidate) => candidate.profileName === profileName);
|
||||
if (!profile) return '삭제되었거나 접근할 수 없는 서버';
|
||||
if (profile.displayName?.trim()) return profile.displayName.trim();
|
||||
const configuredName = profile.meta?.korName;
|
||||
const baseName =
|
||||
typeof configuredName === 'string' && configuredName.trim() ? configuredName.trim() : profile.profile;
|
||||
return profile.instanceKey === 'default' ? baseName : `${baseName} [${profile.instanceKey}]`;
|
||||
};
|
||||
const selectedProfileIdentityReady = computed(() =>
|
||||
profileIdentities.value.some((profile) => profile.profileName === selectedProfileName.value)
|
||||
);
|
||||
const selectedProfileDisplayName = computed(() => {
|
||||
if (!selectedProfileName.value) return '대상 서버';
|
||||
return selectedProfileIdentityReady.value ? profileDisplayName(selectedProfileName.value) : '대상 서버';
|
||||
});
|
||||
const cancellationConfirmation = computed(() => `${selectedProfileDisplayName.value} 게임 취소`);
|
||||
const displayOperationText = (value?: string): string => {
|
||||
if (!value || !selectedProfileName.value) return value ?? '';
|
||||
return value.replaceAll(selectedProfileName.value, selectedProfileDisplayName.value);
|
||||
};
|
||||
const capabilities = ref<Array<{ permission: string; scopes?: string[] }>>([]);
|
||||
const loading = ref(false);
|
||||
const catalogLoading = ref(false);
|
||||
@@ -204,7 +228,7 @@ const profileOperationLogEmptyMessage = computed(() => {
|
||||
return '오케스트레이터 로그를 기다리고 있습니다…';
|
||||
}
|
||||
if (operation.error) {
|
||||
return `이 작업에는 진행 로그가 기록되지 않았습니다. 작업 오류: ${operation.error}`;
|
||||
return `이 작업에는 진행 로그가 기록되지 않았습니다. 작업 오류: ${displayOperationText(operation.error)}`;
|
||||
}
|
||||
return '이 작업에는 진행 로그가 기록되지 않았습니다. 로그 기능 적용 전 작업일 수 있습니다.';
|
||||
});
|
||||
@@ -228,9 +252,9 @@ const hasCapability = (permission: string): boolean =>
|
||||
|
||||
const pageTitle = computed(() => {
|
||||
if (props.mode === 'gateway') return 'Gateway 릴리스';
|
||||
if (props.mode === 'cancel') return `${props.profileName ?? ''} 게임 취소`;
|
||||
if (props.mode === 'scenario') return `${props.profileName ?? ''} 시나리오 초기화`;
|
||||
return `${props.profileName ?? ''} 버전 업데이트`;
|
||||
if (props.mode === 'cancel') return `${selectedProfileDisplayName.value} 게임 취소`;
|
||||
if (props.mode === 'scenario') return `${selectedProfileDisplayName.value} 시나리오 초기화`;
|
||||
return `${selectedProfileDisplayName.value} 버전 업데이트`;
|
||||
});
|
||||
|
||||
const pageDescription = computed(() => {
|
||||
@@ -269,6 +293,40 @@ const formatTime = (value?: string): string => formatServerDateTime(value, { fal
|
||||
const formatLogTime = (value: string): string => formatServerDateTime(value, { format: 'timeSeconds' });
|
||||
const shortSha = (value?: string): string => (value ? value.slice(0, 12) : '-');
|
||||
|
||||
const operationTypeLabel = (type: Operation['type']): string => {
|
||||
const labels: Record<Operation['type'], string> = {
|
||||
DEPLOY: '버전 업데이트',
|
||||
RESET: '시나리오 초기화',
|
||||
CANCEL_GAME: '게임 취소',
|
||||
START: '서버 시작',
|
||||
STOP: '서버 중지',
|
||||
};
|
||||
return labels[type];
|
||||
};
|
||||
|
||||
const operationStatusLabel = (status: Operation['status']): string => {
|
||||
const labels: Record<Operation['status'], string> = {
|
||||
QUEUED: '대기 중',
|
||||
RUNNING: '진행 중',
|
||||
SUCCEEDED: '완료',
|
||||
FAILED: '실패',
|
||||
CANCELLED: '중단됨',
|
||||
};
|
||||
return labels[status];
|
||||
};
|
||||
|
||||
const operationStatusClass = (status: Operation['status']): string => {
|
||||
if (status === 'RUNNING') return 'border-emerald-700 bg-emerald-950/70 text-emerald-200';
|
||||
if (status === 'QUEUED') return 'border-amber-700 bg-amber-950/70 text-amber-200';
|
||||
if (status === 'SUCCEEDED') return 'border-cyan-800 bg-cyan-950/60 text-cyan-200';
|
||||
if (status === 'FAILED') return 'border-red-800 bg-red-950/70 text-red-200';
|
||||
return 'border-zinc-700 bg-zinc-800 text-zinc-300';
|
||||
};
|
||||
|
||||
const toggleProfileOperationDetails = (operationId: string) => {
|
||||
expandedProfileOperationId.value = expandedProfileOperationId.value === operationId ? '' : operationId;
|
||||
};
|
||||
|
||||
const clearStatus = () => {
|
||||
message.value = '';
|
||||
errorMessage.value = '';
|
||||
@@ -310,6 +368,15 @@ const loadResetDefaults = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const loadProfileIdentities = async () => {
|
||||
if (props.mode === 'gateway') return;
|
||||
try {
|
||||
profileIdentities.value = await loadAdminProfileNavigation();
|
||||
} catch {
|
||||
profileIdentities.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
const loadCapabilities = async () => {
|
||||
try {
|
||||
capabilities.value = (await adminClient.capabilities.list.query()) as typeof capabilities.value;
|
||||
@@ -496,6 +563,7 @@ const requestDeploy = async () => {
|
||||
clearStatus();
|
||||
if (
|
||||
!selectedProfileName.value ||
|
||||
!selectedProfileIdentityReady.value ||
|
||||
activeOperation.value ||
|
||||
!form.sourceRef.trim() ||
|
||||
form.sourceMode === 'CURRENT'
|
||||
@@ -504,7 +572,7 @@ const requestDeploy = async () => {
|
||||
}
|
||||
if (
|
||||
!window.confirm(
|
||||
`${selectedProfileName.value}의 인게임 DB를 유지하고 ${form.sourceRef.trim()} 버전으로 배포하시겠습니까?`
|
||||
`${selectedProfileDisplayName.value}의 인게임 DB를 유지하고 ${form.sourceRef.trim()} 버전으로 배포하시겠습니까?`
|
||||
)
|
||||
) {
|
||||
return;
|
||||
@@ -647,7 +715,7 @@ const selectedAutorunOptions = (): ResetAutorunOption[] => {
|
||||
|
||||
const requestReset = async () => {
|
||||
clearStatus();
|
||||
if (!selectedProfileName.value || activeOperation.value) {
|
||||
if (!selectedProfileName.value || !selectedProfileIdentityReady.value || activeOperation.value) {
|
||||
return;
|
||||
}
|
||||
if ((form.sourceMode !== 'CURRENT' && !form.sourceRef.trim()) || form.scenarioId === null) {
|
||||
@@ -663,7 +731,7 @@ const requestReset = async () => {
|
||||
form.sourceMode === 'CURRENT' ? '서버 지정 버전' : form.sourceMode === 'BRANCH' ? '브랜치' : '커밋';
|
||||
if (
|
||||
!window.confirm(
|
||||
`${selectedProfileName.value}의 게임 DB를 초기화합니다.\n${sourceLabel}${form.sourceMode === 'CURRENT' ? '' : `: ${form.sourceRef}`}\n시나리오: ${scenarioId}${form.publishSchedule ? '\n예약 등록 즉시 로비에 오픈 일정을 공개합니다.' : ''}`
|
||||
`${selectedProfileDisplayName.value}의 게임 DB를 초기화합니다.\n${sourceLabel}${form.sourceMode === 'CURRENT' ? '' : `: ${form.sourceRef}`}\n시나리오: ${scenarioId}${form.publishSchedule ? '\n예약 등록 즉시 로비에 오픈 일정을 공개합니다.' : ''}`
|
||||
)
|
||||
) {
|
||||
return;
|
||||
@@ -711,13 +779,13 @@ const requestReset = async () => {
|
||||
const requestGameCancellation = async () => {
|
||||
clearStatus();
|
||||
const profileName = selectedProfileName.value;
|
||||
if (!profileName || activeOperation.value) return;
|
||||
if (!profileName || !selectedProfileIdentityReady.value || activeOperation.value) return;
|
||||
if (cancellationForm.reason.trim().length < 5) {
|
||||
errorMessage.value = '취소 사유를 5자 이상 입력해주세요.';
|
||||
return;
|
||||
}
|
||||
if (cancellationForm.confirmation.trim() !== profileName) {
|
||||
errorMessage.value = `확인란에 ${profileName}을 정확히 입력해주세요.`;
|
||||
if (cancellationForm.confirmation.trim() !== cancellationConfirmation.value) {
|
||||
errorMessage.value = `확인란에 ${cancellationConfirmation.value}를 정확히 입력해주세요.`;
|
||||
return;
|
||||
}
|
||||
const historyText =
|
||||
@@ -725,7 +793,7 @@ const requestGameCancellation = async () => {
|
||||
const generalText = cancellationForm.generalMode === 'RETAIN' ? '장수 기록 보존' : '장수 기록 삭제';
|
||||
if (
|
||||
!window.confirm(
|
||||
`${profileName}의 진행 중 게임을 취소합니다.\n${historyText}\n${generalText}\n유산 획득분 ${cancellationForm.earnedPointRetentionPercent}% 보전\n취소 후 시나리오 초기화 전에는 재개할 수 없습니다.`
|
||||
`${selectedProfileDisplayName.value}의 진행 중 게임을 취소합니다.\n${historyText}\n${generalText}\n유산 획득분 ${cancellationForm.earnedPointRetentionPercent}% 보전\n취소 후 시나리오 초기화 전에는 재개할 수 없습니다.`
|
||||
)
|
||||
) {
|
||||
return;
|
||||
@@ -818,6 +886,7 @@ onMounted(async () => {
|
||||
componentMounted = true;
|
||||
await Promise.all([
|
||||
loadCapabilities(),
|
||||
loadProfileIdentities(),
|
||||
loadState(),
|
||||
loadResetDefaults(),
|
||||
props.mode === 'scenario' ? loadScenarios() : Promise.resolve(),
|
||||
@@ -852,6 +921,7 @@ onBeforeUnmount(() => {
|
||||
<ServerProfileTabs
|
||||
v-if="mode !== 'gateway' && profileName"
|
||||
:profile-name="profileName"
|
||||
:profile-label="selectedProfileDisplayName"
|
||||
:active-tab="mode === 'scenario' ? 'scenario' : mode === 'cancel' ? 'cancel' : 'version'"
|
||||
:can-deploy="hasCapability('admin.profiles.deploy')"
|
||||
:can-reset="hasCapability('admin.scenarios.reset')"
|
||||
@@ -942,11 +1012,11 @@ onBeforeUnmount(() => {
|
||||
></textarea>
|
||||
</label>
|
||||
<label class="block text-sm text-zinc-300">
|
||||
확인을 위해 <strong>{{ selectedProfileName }}</strong> 입력
|
||||
확인을 위해 <strong>{{ cancellationConfirmation }}</strong> 입력
|
||||
<input
|
||||
v-model="cancellationForm.confirmation"
|
||||
class="mt-1 w-full rounded border border-red-800 bg-zinc-950 px-3 py-2 font-mono"
|
||||
:placeholder="selectedProfileName"
|
||||
:placeholder="cancellationConfirmation"
|
||||
data-testid="cancellation-confirmation"
|
||||
/>
|
||||
</label>
|
||||
@@ -955,9 +1025,10 @@ onBeforeUnmount(() => {
|
||||
class="w-full rounded bg-red-700 px-4 py-3 font-bold text-white hover:bg-red-600 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
:disabled="
|
||||
submitting ||
|
||||
!selectedProfileIdentityReady ||
|
||||
Boolean(activeOperation) ||
|
||||
cancellationForm.reason.trim().length < 5 ||
|
||||
cancellationForm.confirmation.trim() !== selectedProfileName
|
||||
cancellationForm.confirmation.trim() !== cancellationConfirmation
|
||||
"
|
||||
data-testid="request-game-cancellation"
|
||||
>
|
||||
@@ -1419,7 +1490,12 @@ onBeforeUnmount(() => {
|
||||
v-if="mode === 'version'"
|
||||
type="submit"
|
||||
class="rounded bg-sky-700 px-4 py-3 font-bold text-white hover:bg-sky-600 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
:disabled="submitting || Boolean(activeOperation) || !form.sourceRef.trim()"
|
||||
:disabled="
|
||||
submitting ||
|
||||
!selectedProfileIdentityReady ||
|
||||
Boolean(activeOperation) ||
|
||||
!form.sourceRef.trim()
|
||||
"
|
||||
data-testid="request-deploy"
|
||||
@click="requestDeploy"
|
||||
>
|
||||
@@ -1429,7 +1505,12 @@ onBeforeUnmount(() => {
|
||||
v-else
|
||||
type="submit"
|
||||
class="w-full rounded bg-amber-500 px-4 py-3 font-bold text-black hover:bg-amber-400 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
:disabled="submitting || Boolean(activeOperation) || form.scenarioId === null"
|
||||
:disabled="
|
||||
submitting ||
|
||||
!selectedProfileIdentityReady ||
|
||||
Boolean(activeOperation) ||
|
||||
form.scenarioId === null
|
||||
"
|
||||
data-testid="request-reset"
|
||||
>
|
||||
{{ form.scheduledAt ? '시나리오 초기화 예약' : '시나리오 초기화' }}
|
||||
@@ -1748,12 +1829,14 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<span class="text-zinc-600">{{ formatLogTime(entry.createdAt) }}</span>
|
||||
<span class="ml-2 text-violet-300">[{{ entry.phase }}]</span>
|
||||
<span class="ml-2 whitespace-pre-wrap break-all">{{ entry.message }}</span>
|
||||
<span class="ml-2 whitespace-pre-wrap break-all">{{
|
||||
displayOperationText(entry.message)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="mode !== 'gateway'" class="rounded-lg border border-zinc-800 bg-zinc-900 p-5">
|
||||
<section v-if="mode !== 'gateway'" class="rounded-lg border border-zinc-800 bg-zinc-900 p-3 sm:p-5">
|
||||
<div
|
||||
class="mb-4 rounded border border-amber-800/80 bg-amber-950/30 px-4 py-3 text-sm text-amber-100"
|
||||
data-testid="profile-build-recovery-guide"
|
||||
@@ -1764,116 +1847,170 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold">작업 이력</h3>
|
||||
<span class="text-xs text-zinc-500">3초마다 상태 갱신</span>
|
||||
<span class="text-right text-xs text-zinc-500">진행 상태를 3초마다 갱신</span>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full min-w-[1300px] table-fixed text-left text-sm" data-testid="operations-table">
|
||||
<div>
|
||||
<table class="w-full table-fixed text-left text-xs sm:text-sm" data-testid="operations-table">
|
||||
<colgroup>
|
||||
<col style="width: 160px" />
|
||||
<col style="width: 264px" />
|
||||
<col style="width: 72px" />
|
||||
<col style="width: 64px" />
|
||||
<col style="width: 88px" />
|
||||
<col style="width: 112px" />
|
||||
<col style="width: 104px" />
|
||||
<col style="width: 176px" />
|
||||
<col style="width: 176px" />
|
||||
<col style="width: 84px" />
|
||||
<col class="w-[42%]" />
|
||||
<col class="w-[24%]" />
|
||||
<col class="w-[34%]" />
|
||||
</colgroup>
|
||||
<thead class="border-b border-zinc-700 text-xs text-zinc-500">
|
||||
<tr>
|
||||
<th class="p-2">요청/예약</th>
|
||||
<th class="p-2">작업 ID</th>
|
||||
<th class="p-2">프로필</th>
|
||||
<th class="p-2">작업</th>
|
||||
<th class="p-2">요청 · 작업</th>
|
||||
<th class="p-2">상태</th>
|
||||
<th class="p-2">소스</th>
|
||||
<th class="p-2">해석 커밋</th>
|
||||
<th class="p-2">요청자/사유</th>
|
||||
<th class="p-2">완료/오류</th>
|
||||
<th class="p-2">동작</th>
|
||||
<th class="p-2">보기</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="operation in operations"
|
||||
:key="operation.id"
|
||||
class="border-b border-zinc-800 align-top"
|
||||
>
|
||||
<td class="p-2 text-xs">
|
||||
{{ formatTime(operation.createdAt) }}
|
||||
<div v-if="operation.scheduledAt" class="mt-1 text-amber-300">
|
||||
예약 {{ formatTime(operation.scheduledAt) }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="max-w-48 break-all p-2 font-mono text-xs">{{ operation.id }}</td>
|
||||
<td class="p-2">{{ operation.profileName }}</td>
|
||||
<td class="p-2">{{ operation.type }}</td>
|
||||
<td class="p-2 font-semibold">{{ operation.status }}</td>
|
||||
<td class="p-2 font-mono text-xs">
|
||||
<div>{{ operation.sourceMode ?? '-' }}</div>
|
||||
<div
|
||||
v-if="operation.sourceRef"
|
||||
class="truncate"
|
||||
data-testid="operation-source-ref"
|
||||
:title="operation.sourceRef"
|
||||
>
|
||||
{{ operation.sourceRef }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="p-2 font-mono text-xs">{{ shortSha(operation.resolvedCommitSha) }}</td>
|
||||
<td class="max-w-xs p-2 text-xs">
|
||||
<div class="font-mono">{{ operation.requestedBy }}</div>
|
||||
<div v-if="operation.reason" class="mt-1 text-zinc-400">{{ operation.reason }}</div>
|
||||
</td>
|
||||
<td class="max-w-xs p-2 text-xs">
|
||||
{{ formatTime(operation.completedAt) }}
|
||||
<div
|
||||
v-if="operation.error"
|
||||
class="mt-1"
|
||||
:class="operation.status === 'FAILED' ? 'text-red-400' : 'text-amber-300'"
|
||||
>
|
||||
{{ operation.error }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="p-2">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-zinc-700 px-2 py-1 text-xs text-zinc-300 hover:bg-zinc-800"
|
||||
:class="
|
||||
operation.id === selectedProfileOperationId
|
||||
? 'border-violet-500 text-violet-200'
|
||||
: ''
|
||||
"
|
||||
@click="selectProfileOperation(operation.id)"
|
||||
<template v-for="operation in operations" :key="operation.id">
|
||||
<tr class="border-b border-zinc-800 align-top" data-testid="operation-summary-row">
|
||||
<td class="p-2">
|
||||
<div class="font-semibold text-zinc-100">
|
||||
{{ operationTypeLabel(operation.type) }}
|
||||
</div>
|
||||
<div class="mt-1 text-[11px] leading-4 text-zinc-400">
|
||||
{{ formatTime(operation.createdAt) }}
|
||||
</div>
|
||||
<div
|
||||
v-if="operation.scheduledAt"
|
||||
class="mt-1 text-[11px] leading-4 text-amber-300"
|
||||
>
|
||||
로그
|
||||
</button>
|
||||
<button
|
||||
v-if="
|
||||
operation.status === 'QUEUED' ||
|
||||
(operation.status === 'RUNNING' && operation.type === 'DEPLOY')
|
||||
"
|
||||
class="rounded border border-red-800 px-2 py-1 text-xs text-red-300 hover:bg-red-950"
|
||||
@click="cancelOperation(operation)"
|
||||
예약 {{ formatTime(operation.scheduledAt) }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="p-2">
|
||||
<span
|
||||
class="inline-flex min-h-7 items-center rounded-full border px-2 py-1 text-[11px] font-semibold leading-none"
|
||||
:class="operationStatusClass(operation.status)"
|
||||
:data-operation-status="operation.status"
|
||||
>
|
||||
{{ operation.status === 'RUNNING' ? '빌드 중단' : '취소' }}
|
||||
</button>
|
||||
<button
|
||||
v-else-if="
|
||||
operation.status === 'FAILED' || operation.status === 'CANCELLED'
|
||||
"
|
||||
class="rounded border border-amber-700 px-2 py-1 text-xs text-amber-300 hover:bg-amber-950"
|
||||
@click="retryOperation(operation)"
|
||||
{{ operationStatusLabel(operation.status) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="p-2">
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-zinc-700 px-2 py-1 text-xs text-zinc-300 hover:bg-zinc-800 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-violet-400"
|
||||
:class="
|
||||
operation.id === selectedProfileOperationId
|
||||
? 'border-violet-500 text-violet-200'
|
||||
: ''
|
||||
"
|
||||
@click="selectProfileOperation(operation.id)"
|
||||
>
|
||||
로그
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-zinc-700 px-2 py-1 text-xs text-zinc-300 hover:bg-zinc-800 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-violet-400"
|
||||
:aria-expanded="expandedProfileOperationId === operation.id"
|
||||
:aria-controls="`profile-operation-detail-${operation.id}`"
|
||||
data-testid="operation-details-toggle"
|
||||
@click="toggleProfileOperationDetails(operation.id)"
|
||||
>
|
||||
{{ operation.error ? '오류 상세' : '상세' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="
|
||||
operation.status === 'QUEUED' ||
|
||||
(operation.status === 'RUNNING' && operation.type === 'DEPLOY')
|
||||
"
|
||||
type="button"
|
||||
class="rounded border border-red-800 px-2 py-1 text-xs text-red-300 hover:bg-red-950 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-400"
|
||||
@click="cancelOperation(operation)"
|
||||
>
|
||||
{{ operation.status === 'RUNNING' ? '빌드 중단' : '취소' }}
|
||||
</button>
|
||||
<button
|
||||
v-else-if="
|
||||
operation.status === 'FAILED' || operation.status === 'CANCELLED'
|
||||
"
|
||||
type="button"
|
||||
class="rounded border border-amber-700 px-2 py-1 text-xs text-amber-300 hover:bg-amber-950 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-400"
|
||||
@click="retryOperation(operation)"
|
||||
>
|
||||
재시도
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
v-show="expandedProfileOperationId === operation.id"
|
||||
:id="`profile-operation-detail-${operation.id}`"
|
||||
class="border-b border-zinc-700 bg-zinc-950/60"
|
||||
data-testid="operation-detail"
|
||||
>
|
||||
<td colspan="3" class="p-3 sm:p-4">
|
||||
<div
|
||||
class="rounded border border-zinc-800 bg-zinc-950 px-3 py-3"
|
||||
role="region"
|
||||
:aria-label="`${operationTypeLabel(operation.type)} 상세`"
|
||||
>
|
||||
재시도
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<dl class="grid grid-cols-1 gap-x-6 gap-y-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt class="text-[11px] font-semibold text-zinc-500">서버</dt>
|
||||
<dd class="mt-1 break-all text-xs text-zinc-300">
|
||||
{{ profileDisplayName(operation.profileName) }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-[11px] font-semibold text-zinc-500">작업 ID</dt>
|
||||
<dd class="mt-1 break-all font-mono text-xs text-zinc-300">
|
||||
{{ operation.id }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-[11px] font-semibold text-zinc-500">소스</dt>
|
||||
<dd
|
||||
class="mt-1 break-all font-mono text-xs text-zinc-300"
|
||||
data-testid="operation-source-ref"
|
||||
>
|
||||
{{ operation.sourceMode ?? '-' }}
|
||||
{{ operation.sourceRef ?? '' }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-[11px] font-semibold text-zinc-500">해석 커밋</dt>
|
||||
<dd class="mt-1 break-all font-mono text-xs text-zinc-300">
|
||||
{{ operation.resolvedCommitSha ?? '-' }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-[11px] font-semibold text-zinc-500">요청자</dt>
|
||||
<dd class="mt-1 break-all font-mono text-xs text-zinc-300">
|
||||
{{ operation.requestedBy }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-[11px] font-semibold text-zinc-500">완료 시각</dt>
|
||||
<dd class="mt-1 text-xs text-zinc-300">
|
||||
{{ formatTime(operation.completedAt) }}
|
||||
</dd>
|
||||
</div>
|
||||
<div v-if="operation.reason" class="sm:col-span-2">
|
||||
<dt class="text-[11px] font-semibold text-zinc-500">사유</dt>
|
||||
<dd
|
||||
class="mt-1 whitespace-pre-wrap break-all text-xs text-zinc-300"
|
||||
>
|
||||
{{ operation.reason }}
|
||||
</dd>
|
||||
</div>
|
||||
<div v-if="operation.error" class="sm:col-span-2">
|
||||
<dt class="text-[11px] font-semibold text-red-400">오류</dt>
|
||||
<dd class="mt-1 whitespace-pre-wrap break-all text-xs text-red-300">
|
||||
{{ displayOperationText(operation.error) }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-if="operations.length === 0">
|
||||
<td colspan="10" class="p-6 text-center text-zinc-500">작업 이력이 없습니다.</td>
|
||||
<td colspan="3" class="p-6 text-center text-zinc-500">작업 이력이 없습니다.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readdir, readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
const collectVueFiles = async (directory) => {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
const nested = await Promise.all(
|
||||
entries.map((entry) => {
|
||||
const path = join(directory, entry.name);
|
||||
return entry.isDirectory() ? collectVueFiles(path) : entry.name.endsWith('.vue') ? [path] : [];
|
||||
})
|
||||
);
|
||||
return nested.flat();
|
||||
};
|
||||
|
||||
describe('gateway profile display boundary', () => {
|
||||
it('keeps immutable raw profile names out of ordinary Vue copy', async () => {
|
||||
const files = await collectVueFiles(new URL('../src', import.meta.url).pathname);
|
||||
const forbidden = [
|
||||
{ pattern: /\{\{\s*(?:profile|policy|operation)\.profileName\s*\}\}/, reason: 'direct interpolation' },
|
||||
{ pattern: /서버 ID:/, reason: 'raw server ID label' },
|
||||
{ pattern: /che:default/, reason: 'raw default profile example' },
|
||||
{ pattern: /profile:scenario/, reason: 'raw profile placeholder' },
|
||||
{
|
||||
pattern: /currentScenario\s*\?\?\s*profile\.profileName/,
|
||||
reason: 'raw notification fallback',
|
||||
},
|
||||
];
|
||||
|
||||
for (const file of files) {
|
||||
const source = await readFile(file, 'utf8');
|
||||
for (const contract of forbidden) {
|
||||
assert.doesNotMatch(source, contract.pattern, `${file}: ${contract.reason}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
+11
-4
@@ -34,16 +34,23 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로
|
||||
`aria-current`로 구분하며 desktop과 mobile에서 본문보다 먼저 표시합니다.
|
||||
- `profileName`은 `${profile}:${instanceKey}` 형식의 불변 기술 ID입니다.
|
||||
`che:default`의 `default`는 현재 시나리오가 아니라 기본 인스턴스 키입니다.
|
||||
좌측 메뉴는 기본 인스턴스의 suffix를 숨기고 표시명만 보여 주며, 상태 상세에서
|
||||
기술 ID·인스턴스 키·nullable 현재 시나리오를 분리해 확인할 수 있습니다.
|
||||
route, 권한 scope, mutation payload, DB FK, process와 Redis namespace는 이 값을
|
||||
유지합니다. 화면·알림·확인 문구·작업 로그는 API의 `displayName`만 소비하여 기본
|
||||
인스턴스는 `체`, 비기본 인스턴스는 `체 [2]`처럼 구분합니다.
|
||||
- 버전 업데이트와 시나리오 초기화 route는 URL의 `profileName`으로 대상 서버가
|
||||
이미 고정됩니다. 따라서 작업 화면에서 전체 profile 목록이나 중복 실행 상태를
|
||||
기다리지 않고 작업 form과 해당 서버의 operation 이력을 먼저 표시합니다. 상세
|
||||
runtime·빌드 상태는 상태 설정 탭에서 확인합니다.
|
||||
- 버전 업데이트·시나리오 초기화·게임 취소 화면의 작업 이력은 요청·작업, 상태,
|
||||
보기의 3열 요약으로 표시합니다. `진행 중`, `완료`, `실패` 등 현재 상태와 로그를
|
||||
먼저 확인하고, 서버 표시명·작업 ID·소스·커밋·요청자·사유·완료 시각·오류 원문은
|
||||
각 작업의 `상세` 행에서 확인합니다. 상세 버튼은 `aria-expanded`와
|
||||
`aria-controls`로 연결되며 500px 화면에서도 페이지 가로 스크롤 없이 열립니다.
|
||||
- 공통 좌측 메뉴는 `admin.profiles.listNavigation`으로 접근 가능한 profile의 이름과
|
||||
표시명만 읽습니다. 이 요청은 PM2 runtime 상태를 포함하는 본문용
|
||||
`admin.profiles.list`와 별도의 non-batch 요청으로 전송하므로, 상태 조회가 늦거나
|
||||
중단되어도 관리자 capability와 서버 메뉴를 함께 기다리게 하지 않습니다.
|
||||
`admin.profiles.list`와 별도의 non-batch 요청으로 전송하되 한 페이지 안의 layout과
|
||||
작업 화면은 같은 in-flight 결과를 공유합니다. 상태 조회가 늦거나 중단되어도
|
||||
관리자 capability와 서버 메뉴를 함께 기다리게 하지 않습니다.
|
||||
- `DEPLOY`는 현재 game DB를 유지하고 migration/build를 적용합니다. `RESET`은
|
||||
현재 시즌 데이터를 새 시나리오로 교체하며 장기 보존 자료를 유지합니다.
|
||||
- `CANCEL_GAME`은 `RESET`과 다른 비가역 운영 작업입니다. `PREOPEN`, `RUNNING`,
|
||||
|
||||
@@ -59,7 +59,7 @@ export interface DispatchResolveContext<
|
||||
nations: Nation[];
|
||||
generals: General<TriggerState>[];
|
||||
unitSet: UnitSetDefinition;
|
||||
map?: MapDefinition;
|
||||
map: MapDefinition;
|
||||
diplomacy?: Array<{ fromNationId: number; toNationId: number; state: number; term: number }>;
|
||||
time: WarTimeContext;
|
||||
seedBase: string;
|
||||
@@ -647,6 +647,7 @@ export class ActionDefinition<
|
||||
cities,
|
||||
generals,
|
||||
unitSet,
|
||||
map: context.map,
|
||||
config: context.aftermathConfig,
|
||||
time,
|
||||
messageTime: context.messageTime,
|
||||
@@ -801,7 +802,7 @@ export class ActionDefinition<
|
||||
|
||||
// 예약 턴 실행에 필요한 전투 컨텍스트를 구성한다.
|
||||
export const actionContextBuilder: ActionContextBuilder = (base, options) => {
|
||||
if (!options.unitSet || !options.worldRef) {
|
||||
if (!options.unitSet || !options.worldRef || !options.map) {
|
||||
return null;
|
||||
}
|
||||
const destCityId = options.actionArgs.destCityId;
|
||||
|
||||
@@ -6,7 +6,9 @@ import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js'
|
||||
import { ActionLogger } from '@sammo-ts/logic/logging/actionLogger.js';
|
||||
import { LogFormat, type LogEntryDraft } from '@sammo-ts/logic/logging/types.js';
|
||||
import { buildScoutMessageDraft } from '@sammo-ts/logic/messages/scoutMessage.js';
|
||||
import { searchDistanceEntries } from '@sammo-ts/logic/world/distance.js';
|
||||
import { buildCrewTypeIndex, getTechCost, getTechLevel } from '@sammo-ts/logic/world/unitSet.js';
|
||||
import type { MapDefinition } from '@sammo-ts/logic/world/types.js';
|
||||
import { LEGACY_DEFAULT_MAX_LEVEL } from '@sammo-ts/logic/scenario/constants.js';
|
||||
import type { WarUnitReport } from './types.js';
|
||||
import type {
|
||||
@@ -197,43 +199,45 @@ const resolveConquerNation = (city: City, attackerNationId: number, nations: Nat
|
||||
return entries[0]![0];
|
||||
};
|
||||
|
||||
const getCityPosition = (city: City): { x: number; y: number } | null => {
|
||||
const x = getMetaNumber(city.meta, 'positionX', Number.NaN);
|
||||
const y = getMetaNumber(city.meta, 'positionY', Number.NaN);
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
||||
return null;
|
||||
}
|
||||
return { x, y };
|
||||
};
|
||||
|
||||
const findNextCapital = (
|
||||
cities: City[],
|
||||
defenderNationId: number,
|
||||
capturedCityId: number,
|
||||
oldCapital: City
|
||||
): City | null => {
|
||||
map: MapDefinition
|
||||
): City => {
|
||||
const candidates = cities.filter((city) => city.nationId === defenderNationId && city.id !== capturedCityId);
|
||||
if (!candidates.length) {
|
||||
return null;
|
||||
throw new Error('도시가 남지 않았는데 긴천을 시도하고 있습니다');
|
||||
}
|
||||
|
||||
const oldPos = getCityPosition(oldCapital);
|
||||
if (!oldPos) {
|
||||
return candidates.sort((lhs, rhs) => rhs.population - lhs.population)[0]!;
|
||||
const candidatesById = new Map(candidates.map((city) => [city.id, city] as const));
|
||||
let nearestDistance: number | null = null;
|
||||
let nextCapital: City | null = null;
|
||||
|
||||
// Ref searchDistance(..., true)는 CityConst::path 순서의 BFS 결과를
|
||||
// 거리별로 훑는다. 첫 보유 거리만 보고, 같은 인구면 뒤 도시로
|
||||
// 교체하는 findNextCapital의 >= 동률 처리까지 그대로 보존한다.
|
||||
for (const [cityId, distance] of searchDistanceEntries(map, capturedCityId, 99)) {
|
||||
if (distance === 0) {
|
||||
continue;
|
||||
}
|
||||
if (nearestDistance !== null && distance > nearestDistance) {
|
||||
break;
|
||||
}
|
||||
const candidate = candidatesById.get(cityId);
|
||||
if (!candidate) {
|
||||
continue;
|
||||
}
|
||||
nearestDistance ??= distance;
|
||||
if (!nextCapital || candidate.population >= nextCapital.population) {
|
||||
nextCapital = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return candidates
|
||||
.map((city) => {
|
||||
const pos = getCityPosition(city);
|
||||
const distance = pos ? Math.hypot(pos.x - oldPos.x, pos.y - oldPos.y) : Number.MAX_SAFE_INTEGER;
|
||||
return { city, distance };
|
||||
})
|
||||
.sort((lhs, rhs) => {
|
||||
if (lhs.distance !== rhs.distance) {
|
||||
return lhs.distance - rhs.distance;
|
||||
}
|
||||
return rhs.city.population - lhs.city.population;
|
||||
})[0]!.city;
|
||||
if (!nextCapital) {
|
||||
throw new Error('도시가 남지 않았는데 긴천을 시도하고 있습니다');
|
||||
}
|
||||
return nextCapital;
|
||||
};
|
||||
|
||||
const pushLogger = (
|
||||
@@ -450,7 +454,7 @@ const resolveConquerCity = <TriggerState extends GeneralTriggerState>(
|
||||
|
||||
// 수도 함락 시 수도 이전 및 내부 사기/자원 페널티.
|
||||
if (!nationCollapsed && defenderNation && defenderNation.capitalCityId === defenderCity.id) {
|
||||
const nextCapital = findNextCapital(cities, defenderNationId, defenderCity.id, defenderCity);
|
||||
const nextCapital = findNextCapital(cities, defenderNationId, defenderCity.id, input.map);
|
||||
if (nextCapital) {
|
||||
const josaRo = JosaUtil.pick(nextCapital.name, '로');
|
||||
const josaYi = JosaUtil.pick(defenderNation.name, '이');
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { ActionLogger } from '@sammo-ts/logic/logging/actionLogger.js';
|
||||
import type { LogEntryDraft } from '@sammo-ts/logic/logging/types.js';
|
||||
import type { MessageDraft } from '@sammo-ts/logic/messages/message.js';
|
||||
import type { TracePort } from '@sammo-ts/logic/ports/trace.js';
|
||||
import type { UnitSetDefinition } from '@sammo-ts/logic/world/types.js';
|
||||
import type { MapDefinition, UnitSetDefinition } from '@sammo-ts/logic/world/types.js';
|
||||
import type { WarActionModule } from './actions.js';
|
||||
import type { WarTriggerRegistry } from './triggers.js';
|
||||
import type { LegacyWarLogFlushSequence } from './legacyFlushSequence.js';
|
||||
@@ -195,6 +195,8 @@ export interface WarAftermathInput<TriggerState extends GeneralTriggerState = Ge
|
||||
cities: City[];
|
||||
generals: General<TriggerState>[];
|
||||
unitSet: UnitSetDefinition;
|
||||
/** 긴급천도 BFS에 사용하는 Ref CityConst::path 대응 topology. */
|
||||
map: MapDefinition;
|
||||
config: WarAftermathConfig;
|
||||
time: WarTimeContext;
|
||||
/** Ref Message::gameNow() at the command transaction's logical tick. */
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ConstantRNG, RandUtil } from '@sammo-ts/common';
|
||||
|
||||
import type { City, General, Nation } from '../src/domain/entities.js';
|
||||
import type { GeneralActionModule } from '../src/actionModules/general.js';
|
||||
import type { UnitSetDefinition } from '../src/world/types.js';
|
||||
import type { MapDefinition, UnitSetDefinition } from '../src/world/types.js';
|
||||
import { resolveWarAftermath } from '../src/war/aftermath.js';
|
||||
import type { WarAftermathConfig } from '../src/war/types.js';
|
||||
import { LogFormat } from '../src/logging/types.js';
|
||||
@@ -74,6 +74,43 @@ const buildCity = (id: number, nationId: number): City => ({
|
||||
meta: {},
|
||||
});
|
||||
|
||||
const buildMap = (connections: Record<number, number[]>): MapDefinition => ({
|
||||
id: 'test',
|
||||
name: 'test',
|
||||
cities: Object.entries(connections).map(([rawId, cityConnections]) => ({
|
||||
id: Number(rawId),
|
||||
name: `City${rawId}`,
|
||||
level: 2,
|
||||
region: 1,
|
||||
position: { x: Number(rawId), y: Number(rawId) },
|
||||
connections: cityConnections,
|
||||
max: {
|
||||
population: 10000,
|
||||
agriculture: 1000,
|
||||
commerce: 1000,
|
||||
security: 1000,
|
||||
defence: 200,
|
||||
wall: 200,
|
||||
},
|
||||
initial: {
|
||||
population: 10000,
|
||||
agriculture: 1000,
|
||||
commerce: 1000,
|
||||
security: 1000,
|
||||
defence: 100,
|
||||
wall: 100,
|
||||
},
|
||||
})),
|
||||
});
|
||||
|
||||
const DEFAULT_MAP = buildMap({
|
||||
1: [2, 3, 4, 5],
|
||||
2: [1, 3, 4, 5],
|
||||
3: [1, 2, 4, 5],
|
||||
4: [1, 2, 3, 5],
|
||||
5: [1, 2, 3, 4],
|
||||
});
|
||||
|
||||
const buildNation = (id: number): Nation => ({
|
||||
id,
|
||||
name: `Nation${id}`,
|
||||
@@ -189,6 +226,7 @@ describe('war aftermath', () => {
|
||||
cities: [attackerCity, defenderCity],
|
||||
generals: [attacker],
|
||||
unitSet: buildUnitSet(),
|
||||
map: DEFAULT_MAP,
|
||||
config: { ...buildConfig(), maxTechLevel: 15 },
|
||||
time: { year: 200, month: 1, startYear: 180 },
|
||||
messageTime: MESSAGE_TIME,
|
||||
@@ -230,6 +268,7 @@ describe('war aftermath', () => {
|
||||
cities: [attackerCity, defenderCity],
|
||||
generals: [attacker],
|
||||
unitSet: buildUnitSet(),
|
||||
map: DEFAULT_MAP,
|
||||
config: buildConfig(),
|
||||
time: {
|
||||
year: 200,
|
||||
@@ -274,6 +313,7 @@ describe('war aftermath', () => {
|
||||
cities: [attackerCity, defenderCity],
|
||||
generals: [attacker],
|
||||
unitSet: buildUnitSet(),
|
||||
map: DEFAULT_MAP,
|
||||
config: buildConfig(),
|
||||
time: { year: 200, month: 1, startYear: 180 },
|
||||
messageTime: MESSAGE_TIME,
|
||||
@@ -320,6 +360,7 @@ describe('war aftermath', () => {
|
||||
cities: [attackerCity, defenderCity, nextCapital],
|
||||
generals: [attacker, defender],
|
||||
unitSet: buildUnitSet(),
|
||||
map: DEFAULT_MAP,
|
||||
config: buildConfig(),
|
||||
time: {
|
||||
year: 200,
|
||||
@@ -340,6 +381,61 @@ describe('war aftermath', () => {
|
||||
expect(outcome.logs.find((log) => log.text.startsWith('수뇌는'))?.format).toBe(LogFormat.MONTH);
|
||||
});
|
||||
|
||||
it('chooses the most populous city at the nearest map-path distance for emergency relocation', () => {
|
||||
const attackerNation = buildNation(1);
|
||||
const defenderNation = buildNation(2);
|
||||
const attackerCity = buildCity(1, 1);
|
||||
const defenderCity = buildCity(2, 2);
|
||||
const firstNearest = buildCity(3, 2);
|
||||
const lastNearest = buildCity(4, 2);
|
||||
const coordinateNearButTwoHopsAway = buildCity(5, 2);
|
||||
firstNearest.population = 30_000;
|
||||
lastNearest.population = 30_000;
|
||||
coordinateNearButTwoHopsAway.population = 90_000;
|
||||
defenderCity.meta.positionX = 0;
|
||||
defenderCity.meta.positionY = 0;
|
||||
firstNearest.meta.positionX = 100;
|
||||
firstNearest.meta.positionY = 100;
|
||||
lastNearest.meta.positionX = 200;
|
||||
lastNearest.meta.positionY = 200;
|
||||
coordinateNearButTwoHopsAway.meta.positionX = 0;
|
||||
coordinateNearButTwoHopsAway.meta.positionY = 1;
|
||||
const attacker = buildGeneral(1, 1, 1);
|
||||
const defender = buildGeneral(2, 2, 2);
|
||||
|
||||
resolveWarAftermath({
|
||||
battle: {
|
||||
attacker,
|
||||
defenders: [],
|
||||
defenderCity,
|
||||
logs: [],
|
||||
conquered: true,
|
||||
reports: [],
|
||||
},
|
||||
attackerNation,
|
||||
defenderNation,
|
||||
attackerCity,
|
||||
defenderCity,
|
||||
nations: [attackerNation, defenderNation],
|
||||
cities: [attackerCity, defenderCity, firstNearest, lastNearest, coordinateNearButTwoHopsAway],
|
||||
generals: [attacker, defender],
|
||||
unitSet: buildUnitSet(),
|
||||
map: buildMap({
|
||||
1: [],
|
||||
2: [3, 4],
|
||||
3: [2, 5],
|
||||
4: [2],
|
||||
5: [3],
|
||||
}),
|
||||
config: buildConfig(),
|
||||
time: { year: 200, month: 1, startYear: 180 },
|
||||
messageTime: MESSAGE_TIME,
|
||||
});
|
||||
|
||||
// Ref replaces on equal population, so the later city in the BFS layer wins.
|
||||
expect(defenderNation.capitalCityId).toBe(lastNearest.id);
|
||||
});
|
||||
|
||||
it('uses the city battle phase, not retained casualties, for conquered supply-city rice', () => {
|
||||
const attackerNation = buildNation(1);
|
||||
const defenderNation = buildNation(2);
|
||||
@@ -378,6 +474,7 @@ describe('war aftermath', () => {
|
||||
cities: [attackerCity, defenderCity, defenderCapital],
|
||||
generals: [attacker],
|
||||
unitSet: buildUnitSet(),
|
||||
map: DEFAULT_MAP,
|
||||
config: buildConfig(),
|
||||
time: { year: 200, month: 1, startYear: 180 },
|
||||
messageTime: MESSAGE_TIME,
|
||||
@@ -438,6 +535,7 @@ describe('war aftermath', () => {
|
||||
cities: [attackerCity, defenderCity],
|
||||
generals: [attacker, defender],
|
||||
unitSet: buildUnitSet(),
|
||||
map: DEFAULT_MAP,
|
||||
config: buildConfig(),
|
||||
time: {
|
||||
year: 200,
|
||||
@@ -510,6 +608,7 @@ describe('war aftermath', () => {
|
||||
// The caller order deliberately puts the lord first.
|
||||
generals: [attacker, lord, npc],
|
||||
unitSet: buildUnitSet(),
|
||||
map: DEFAULT_MAP,
|
||||
config: {
|
||||
...buildConfig(),
|
||||
joinMode: 'full',
|
||||
@@ -607,6 +706,7 @@ describe('war aftermath', () => {
|
||||
cities: [attackerCity, defenderCity],
|
||||
generals: [attacker, firstDefender, secondDefender, elsewhere],
|
||||
unitSet: buildUnitSet(),
|
||||
map: DEFAULT_MAP,
|
||||
config: buildConfig(),
|
||||
time: {
|
||||
year: 200,
|
||||
@@ -694,6 +794,7 @@ describe('war aftermath', () => {
|
||||
cities: [attackerCity, defenderCity, defenderCapital],
|
||||
generals: [attacker],
|
||||
unitSet: buildUnitSet(),
|
||||
map: DEFAULT_MAP,
|
||||
config: buildConfig(),
|
||||
time: {
|
||||
year: 200,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"title": "【공백지】 여명",
|
||||
"extends": ["scenario_0.json"],
|
||||
"const": {
|
||||
"uniqueTrialCoef": 2
|
||||
},
|
||||
"events": [
|
||||
["month", 1000, ["or", ["Date", "==", null, 12], ["Date", "==", null, 6]], ["CreateManyNPC", 50, 0], ["DeleteEvent"]],
|
||||
["month", 1000, ["Date", "==", 181, 1], ["RaiseNPCNation"], ["DeleteEvent"]],
|
||||
["month", 999, ["Date", "==", 181, 1], ["OpenNationBetting", 4, 5000], ["OpenNationBetting", 1, 2000], ["DeleteEvent"]],
|
||||
["month", 999, ["and", ["Date", ">=", 183, 1], ["RemainNation", "<=", 8]], ["OpenNationBetting", 1, 1000], ["DeleteEvent"]],
|
||||
["destroy_nation", 1000, ["and", ["Date", ">=", 183, 1], ["RemainNation", "==", 1]], ["BlockScoutAction"], ["DeleteEvent"]]
|
||||
]
|
||||
}
|
||||
@@ -164,15 +164,15 @@ integration('core ↔ legacy command-boundary differential', () => {
|
||||
}
|
||||
if (fixturePath.endsWith('live-sortie-emergency-capital.json')) {
|
||||
const defenderNation = reference.after.nations.find((nation) => nation.id === 2);
|
||||
expect(defenderNation?.capitalCityId).toBe(71);
|
||||
expect(defenderNation?.capitalCityId).toBe(35);
|
||||
expect(defenderNation?.gold).toBe(50_000);
|
||||
expect(defenderNation?.rice).toBe(40_000);
|
||||
expect(reference.after.generals.find((general) => general.id === 2)).toMatchObject({
|
||||
nationId: 2,
|
||||
cityId: 71,
|
||||
cityId: 35,
|
||||
atmos: 80,
|
||||
});
|
||||
expect(reference.after.cities.find((city) => city.id === 71)?.supplyState).toBe(1);
|
||||
expect(reference.after.cities.find((city) => city.id === 35)?.supplyState).toBe(1);
|
||||
}
|
||||
if (fixturePath.endsWith('live-sortie-collapse-conflict.json')) {
|
||||
expect(reference.before.cities.find((city) => city.id === 71)?.conflict).toEqual({ 2: 1234 });
|
||||
|
||||
Reference in New Issue
Block a user