From 39bf49bdaa7a180f6272d6ec6bf1af717f825baa Mon Sep 17 00:00:00 2001 From: hided62 Date: Mon, 24 Aug 2026 04:38:40 +0000 Subject: [PATCH 1/5] =?UTF-8?q?fix:=20Gateway=20=EA=B8=B0=EC=88=98=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=EB=AA=85=EC=9D=84=20=EB=AA=85=EC=98=88?= =?UTF-8?q?=EC=9D=98=20=EC=A0=84=EB=8B=B9=EA=B3=BC=20=EC=99=95=EC=A1=B0?= =?UTF-8?q?=EC=97=90=20=EC=A0=84=EB=8B=AC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RESET에서 Gateway의 한국어 표시명을 기수 메타데이터로 저장하고 통일 archive가 내부 profile key 대신 해당 snapshot을 사용하도록 한다. seeder와 Hall/왕조 투영 회귀 테스트를 함께 추가한다. --- .../src/scenario/scenarioSeeder.ts | 4 +++ app/game-engine/test/scenarioSeeder.test.ts | 2 ++ .../test/unificationPersistence.test.ts | 12 +++++++-- .../src/orchestrator/gatewayOrchestrator.ts | 11 ++++++++ app/gateway-api/test/orchestratorPlan.test.ts | 25 +++++++++++++++++++ 5 files changed, 52 insertions(+), 2 deletions(-) diff --git a/app/game-engine/src/scenario/scenarioSeeder.ts b/app/game-engine/src/scenario/scenarioSeeder.ts index fabe1c5d..67a34404 100644 --- a/app/game-engine/src/scenario/scenarioSeeder.ts +++ b/app/game-engine/src/scenario/scenarioSeeder.ts @@ -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(); } diff --git a/app/game-engine/test/scenarioSeeder.test.ts b/app/game-engine/test/scenarioSeeder.test.ts index b81d9a3e..11a337d4 100644 --- a/app/game-engine/test/scenarioSeeder.test.ts +++ b/app/game-engine/test/scenarioSeeder.test.ts @@ -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; const autorunOptions = (autorun.options ?? {}) as Record; expect(autorunOptions.develop).toBe(true); diff --git a/app/game-engine/test/unificationPersistence.test.ts b/app/game-engine/test/unificationPersistence.test.ts index e180fad9..f1b89d1c 100644 --- a/app/game-engine/test/unificationPersistence.test.ts +++ b/app/game-engine/test/unificationPersistence.test.ts @@ -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 { diff --git a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts index 522f1021..816f506b 100644 --- a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts +++ b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts @@ -23,6 +23,7 @@ import { import { isRecord } from '@sammo-ts/common'; import { resolveGatewayPostgresConfigFromEnv } from '../gatewayPostgresConfig.js'; +import { 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 +): string => { + const meta = normalizeMeta(profile.meta); + return resolveGatewayProfileKoreanName(profile.profile, meta.korName); +}; + const readMetaNumber = (meta: Record, key: string): number | null => { const raw = meta[key]; if (typeof raw === 'number' && Number.isFinite(raw)) { @@ -2010,6 +2018,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, diff --git a/app/gateway-api/test/orchestratorPlan.test.ts b/app/gateway-api/test/orchestratorPlan.test.ts index 5ee3535e..60cc4069 100644 --- a/app/gateway-api/test/orchestratorPlan.test.ts +++ b/app/gateway-api/test/orchestratorPlan.test.ts @@ -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', From 531fa55c5f86ff08ffb375f36fd819a79354f9a0 Mon Sep 17 00:00:00 2001 From: hided62 Date: Mon, 24 Aug 2026 04:50:06 +0000 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20=EA=B8=B4=EA=B8=89=EC=B2=9C=EB=8F=84?= =?UTF-8?q?=20=ED=9B=84=EB=B3=B4=EB=A5=BC=20=EC=A7=80=EB=8F=84=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20=EA=B1=B0=EB=A6=AC=EB=A1=9C=20=EC=84=A0=ED=83=9D?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/actions/turn/general/che_출병.ts | 5 +- packages/logic/src/war/aftermath.ts | 60 +++++----- packages/logic/src/war/types.ts | 4 +- packages/logic/test/warAftermath.test.ts | 103 +++++++++++++++++- ...rnCommandCoreReference.integration.test.ts | 6 +- 5 files changed, 143 insertions(+), 35 deletions(-) diff --git a/packages/logic/src/actions/turn/general/che_출병.ts b/packages/logic/src/actions/turn/general/che_출병.ts index 01df8565..83b48c06 100644 --- a/packages/logic/src/actions/turn/general/che_출병.ts +++ b/packages/logic/src/actions/turn/general/che_출병.ts @@ -59,7 +59,7 @@ export interface DispatchResolveContext< nations: Nation[]; generals: General[]; 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; diff --git a/packages/logic/src/war/aftermath.ts b/packages/logic/src/war/aftermath.ts index 2ba028c5..a405cc8b 100644 --- a/packages/logic/src/war/aftermath.ts +++ b/packages/logic/src/war/aftermath.ts @@ -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 = ( // 수도 함락 시 수도 이전 및 내부 사기/자원 페널티. 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, '이'); diff --git a/packages/logic/src/war/types.ts b/packages/logic/src/war/types.ts index ee03a100..20c85ebe 100644 --- a/packages/logic/src/war/types.ts +++ b/packages/logic/src/war/types.ts @@ -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[]; unitSet: UnitSetDefinition; + /** 긴급천도 BFS에 사용하는 Ref CityConst::path 대응 topology. */ + map: MapDefinition; config: WarAftermathConfig; time: WarTimeContext; /** Ref Message::gameNow() at the command transaction's logical tick. */ diff --git a/packages/logic/test/warAftermath.test.ts b/packages/logic/test/warAftermath.test.ts index 46dd71b7..e74f241d 100644 --- a/packages/logic/test/warAftermath.test.ts +++ b/packages/logic/test/warAftermath.test.ts @@ -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): 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, diff --git a/tools/integration-tests/test/turnCommandCoreReference.integration.test.ts b/tools/integration-tests/test/turnCommandCoreReference.integration.test.ts index eb699cb9..dda7323e 100644 --- a/tools/integration-tests/test/turnCommandCoreReference.integration.test.ts +++ b/tools/integration-tests/test/turnCommandCoreReference.integration.test.ts @@ -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 }); From 9cf4034db880d37e9adaa84c60e19e564a9c6b36 Mon Sep 17 00:00:00 2001 From: hided62 Date: Mon, 24 Aug 2026 05:32:01 +0000 Subject: [PATCH 3/5] =?UTF-8?q?feat(scenario):=20CHE=200=EA=B8=B0=20?= =?UTF-8?q?=EC=97=AC=EB=AA=85=20=EC=8B=9C=EB=82=98=EB=A6=AC=EC=98=A4?= =?UTF-8?q?=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 일반 공백지 규칙을 유지하면서 가동 전 M장 50명과 유니크 획득 계수 2배를 적용한다. 시나리오 합성, 실제 M장 생성, Gateway 카탈로그와 Chromium 선택지를 회귀 검증한다. --- .../test/monthlyCreateManyNpcAction.test.ts | 19 +++++++++++++ app/game-engine/test/scenarioLoader.test.ts | 28 +++++++++++++++++++ app/gateway-api/test/scenarioCatalog.test.ts | 15 ++++++++++ .../e2e/server-operations.spec.ts | 13 ++++++++- resources/scenario/scenario_916.json | 14 ++++++++++ 5 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 resources/scenario/scenario_916.json diff --git a/app/game-engine/test/monthlyCreateManyNpcAction.test.ts b/app/game-engine/test/monthlyCreateManyNpcAction.test.ts index ceab6680..37a6579a 100644 --- a/app/game-engine/test/monthlyCreateManyNpcAction.test.ts +++ b/app/game-engine/test/monthlyCreateManyNpcAction.test.ts @@ -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('장수 50명이 등장하였습니다.'); + }); + it('uses and consumes an available U30 candidate without random-name or random-stat fallback', async () => { const info = { generalName: '풀장수', diff --git a/app/game-engine/test/scenarioLoader.test.ts b/app/game-engine/test/scenarioLoader.test.ts index afb9eaa2..3628fc58 100644 --- a/app/game-engine/test/scenarioLoader.test.ts +++ b/app/game-engine/test/scenarioLoader.test.ts @@ -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([ diff --git a/app/gateway-api/test/scenarioCatalog.test.ts b/app/gateway-api/test/scenarioCatalog.test.ts index 6e10874a..eefe56be 100644 --- a/app/gateway-api/test/scenarioCatalog.test.ts +++ b/app/gateway-api/test/scenarioCatalog.test.ts @@ -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 () => { diff --git a/app/gateway-frontend/e2e/server-operations.spec.ts b/app/gateway-frontend/e2e/server-operations.spec.ts index f48a20e2..b70f1aac 100644 --- a/app/gateway-frontend/e2e/server-operations.spec.ts +++ b/app/gateway-frontend/e2e/server-operations.spec.ts @@ -125,6 +125,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 } }); @@ -538,6 +548,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 +561,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 }); diff --git a/resources/scenario/scenario_916.json b/resources/scenario/scenario_916.json new file mode 100644 index 00000000..879514e2 --- /dev/null +++ b/resources/scenario/scenario_916.json @@ -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"]] + ] +} From 2f4440f737d4856a7164b3aa85b3035a277197d0 Mon Sep 17 00:00:00 2001 From: hided62 Date: Mon, 24 Aug 2026 06:05:03 +0000 Subject: [PATCH 4/5] =?UTF-8?q?fix(gateway):=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EC=9E=91=EC=97=85=20=EC=9D=B4=EB=A0=A5=EC=9D=84=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EC=A4=91=EC=8B=AC=EC=9C=BC=EB=A1=9C=20=EA=B0=84?= =?UTF-8?q?=EA=B2=B0=ED=99=94=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 서버별 화면의 작업 이력을 세 열 요약과 접근 가능한 상세 행으로 재구성한다. 500px Chromium에서 가로 넘침 없이 상태, 로그, 상세와 재시도 동작을 확인한다. --- .../e2e/general-icon-lifecycle.spec.ts | 15 +- .../e2e/hwe-lifecycle.spec.ts | 15 +- .../e2e/server-operations.spec.ts | 99 +++--- .../src/views/ServerOperationsView.vue | 285 ++++++++++++------ docs/admin-console.md | 5 + 5 files changed, 277 insertions(+), 142 deletions(-) diff --git a/app/gateway-frontend/e2e/general-icon-lifecycle.spec.ts b/app/gateway-frontend/e2e/general-icon-lifecycle.spec.ts index 461fd81a..6bf08071 100644 --- a/app/gateway-frontend/e2e/general-icon-lifecycle.spec.ts +++ b/app/gateway-frontend/e2e/general-icon-lifecycle.spec.ts @@ -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, diff --git a/app/gateway-frontend/e2e/hwe-lifecycle.spec.ts b/app/gateway-frontend/e2e/hwe-lifecycle.spec.ts index 48cb8287..069e3149 100644 --- a/app/gateway-frontend/e2e/hwe-lifecycle.spec.ts +++ b/app/gateway-frontend/e2e/hwe-lifecycle.spec.ts @@ -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'); diff --git a/app/gateway-frontend/e2e/server-operations.spec.ts b/app/gateway-frontend/e2e/server-operations.spec.ts index b70f1aac..a5b2eccb 100644 --- a/app/gateway-frontend/e2e/server-operations.spec.ts +++ b/app/gateway-frontend/e2e/server-operations.spec.ts @@ -637,47 +637,59 @@ 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-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('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'), @@ -693,7 +705,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') @@ -705,7 +717,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'); @@ -735,18 +747,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( @@ -755,6 +770,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) @@ -783,7 +805,7 @@ 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('game-frontend build complete'); @@ -848,7 +870,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('장수 기록 보존'); @@ -1416,7 +1438,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); }); @@ -1688,17 +1710,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]!, @@ -1709,7 +1734,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 }); @@ -1717,8 +1742,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 }); }); diff --git a/app/gateway-frontend/src/views/ServerOperationsView.vue b/app/gateway-frontend/src/views/ServerOperationsView.vue index 4f0742a7..371bb2df 100644 --- a/app/gateway-frontend/src/views/ServerOperationsView.vue +++ b/app/gateway-frontend/src/views/ServerOperationsView.vue @@ -86,6 +86,7 @@ type GatewayReleaseLog = { const scenarios = ref([]); const operations = ref([]); const selectedProfileOperationId = ref(''); +const expandedProfileOperationId = ref(''); const profileOperationLogs = ref([]); const profileOperationLogCursor = ref(); const profileOperationLogStatus = ref(''); @@ -269,6 +270,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 = { + DEPLOY: '버전 업데이트', + RESET: '시나리오 초기화', + CANCEL_GAME: '게임 취소', + START: '서버 시작', + STOP: '서버 중지', + }; + return labels[type]; +}; + +const operationStatusLabel = (status: Operation['status']): string => { + const labels: Record = { + 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 = ''; @@ -1753,7 +1788,7 @@ onBeforeUnmount(() => { -
+
{

작업 이력

- 3초마다 상태 갱신 + 진행 상태를 3초마다 갱신
-
- +
+
- - - - - - - - - - + + + - - - - + - - - - - + - - - - - - - - - - - + + + + + + - +
+
+
서버 ID
+
+ {{ operation.profileName }} +
+
+
+
작업 ID
+
+ {{ operation.id }} +
+
+
+
소스
+
+ {{ operation.sourceMode ?? '-' }} + {{ operation.sourceRef ?? '' }} +
+
+
+
해석 커밋
+
+ {{ operation.resolvedCommitSha ?? '-' }} +
+
+
+
요청자
+
+ {{ operation.requestedBy }} +
+
+
+
완료 시각
+
+ {{ formatTime(operation.completedAt) }} +
+
+
+
사유
+
+ {{ operation.reason }} +
+
+
+
오류
+
+ {{ operation.error }} +
+
+
+ + + + - +
요청/예약작업 ID프로필작업요청 · 작업 상태소스해석 커밋요청자/사유완료/오류동작보기
- {{ formatTime(operation.createdAt) }} -
- 예약 {{ formatTime(operation.scheduledAt) }} -
-
{{ operation.id }}{{ operation.profileName }}{{ operation.type }}{{ operation.status }} -
{{ operation.sourceMode ?? '-' }}
-
- {{ operation.sourceRef }} -
-
{{ shortSha(operation.resolvedCommitSha) }} -
{{ operation.requestedBy }}
-
{{ operation.reason }}
-
- {{ formatTime(operation.completedAt) }} -
- {{ operation.error }} -
-
-
-
+
+ {{ operationTypeLabel(operation.type) }} +
+
+ {{ formatTime(operation.createdAt) }} +
+
- 로그 - -
+ - {{ operation.status === 'RUNNING' ? '빌드 중단' : '취소' }} - - +
+ + + + +
+
+
- 재시도 - -
-
작업 이력이 없습니다.작업 이력이 없습니다.
diff --git a/docs/admin-console.md b/docs/admin-console.md index bc8536d6..e3283860 100644 --- a/docs/admin-console.md +++ b/docs/admin-console.md @@ -40,6 +40,11 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로 이미 고정됩니다. 따라서 작업 화면에서 전체 profile 목록이나 중복 실행 상태를 기다리지 않고 작업 form과 해당 서버의 operation 이력을 먼저 표시합니다. 상세 runtime·빌드 상태는 상태 설정 탭에서 확인합니다. +- 버전 업데이트·시나리오 초기화·게임 취소 화면의 작업 이력은 요청·작업, 상태, + 보기의 3열 요약으로 표시합니다. `진행 중`, `완료`, `실패` 등 현재 상태와 로그를 + 먼저 확인하고, 서버 ID·작업 ID·소스·커밋·요청자·사유·완료 시각·오류 원문은 + 각 작업의 `상세` 행에서 확인합니다. 상세 버튼은 `aria-expanded`와 + `aria-controls`로 연결되며 500px 화면에서도 페이지 가로 스크롤 없이 열립니다. - 공통 좌측 메뉴는 `admin.profiles.listNavigation`으로 접근 가능한 profile의 이름과 표시명만 읽습니다. 이 요청은 PM2 runtime 상태를 포함하는 본문용 `admin.profiles.list`와 별도의 non-batch 요청으로 전송하므로, 상태 조회가 늦거나 From d80ea1bd0e6ae27d0093d63e9ab0bb7cebc7bfc2 Mon Sep 17 00:00:00 2001 From: hided62 Date: Mon, 24 Aug 2026 07:15:25 +0000 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20Gateway=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=EC=97=90=EC=84=9C=20raw=20=ED=94=84=EB=A1=9C=ED=95=84=20ID=20?= =?UTF-8?q?=EB=85=B8=EC=B6=9C=EC=9D=84=20=EC=A0=9C=EA=B1=B0=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 불변 profileName은 라우팅과 저장 경계에 유지하고 사용자 출력은 공통 표시명을 사용한다. 기본 인스턴스 suffix를 숨기고 비기본 인스턴스만 사람이 읽을 수 있게 구분한다. --- app/gateway-api/src/adminRouter.ts | 45 +++++- .../src/orchestrator/gatewayOrchestrator.ts | 22 ++- app/gateway-api/src/profileOrder.ts | 13 ++ app/gateway-api/src/webPush/coordinator.ts | 47 ++++-- app/gateway-api/test/adminOperations.test.ts | 1 + app/gateway-api/test/profileOrder.test.ts | 10 ++ .../e2e/admin-account-controls.spec.ts | 10 +- .../e2e/admin-runtime-actions.spec.ts | 4 +- .../e2e/lobby-admin-navigation.spec.ts | 23 +-- .../e2e/server-operations.spec.ts | 18 ++- .../e2e/web-push-settings.spec.ts | 4 + .../src/components/ServerProfileTabs.vue | 3 +- .../composables/useAdminProfileNavigation.ts | 30 ++++ .../src/layouts/AdminConsoleLayout.vue | 40 ++---- .../src/views/AccountView.vue | 3 +- app/gateway-frontend/src/views/AdminView.vue | 135 +++++++++++++----- .../src/views/ServerOperationsView.vue | 90 +++++++++--- .../test/profile-display-boundary.test.mjs | 38 +++++ docs/admin-console.md | 12 +- 19 files changed, 421 insertions(+), 127 deletions(-) create mode 100644 app/gateway-frontend/src/composables/useAdminProfileNavigation.ts create mode 100644 app/gateway-frontend/test/profile-display-boundary.test.mjs diff --git a/app/gateway-api/src/adminRouter.ts b/app/gateway-api/src/adminRouter.ts index 4307d71a..ff99494a 100644 --- a/app/gateway-api/src/adminRouter.ts +++ b/app/gateway-api/src/adminRouter.ts @@ -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); diff --git a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts index 816f506b..b541f0af 100644 --- a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts +++ b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts @@ -23,7 +23,7 @@ import { import { isRecord } from '@sammo-ts/common'; import { resolveGatewayPostgresConfigFromEnv } from '../gatewayPostgresConfig.js'; -import { resolveGatewayProfileKoreanName } from '../profileOrder.js'; +import { resolveGatewayProfileDisplayName, resolveGatewayProfileKoreanName } from '../profileOrder.js'; import { buildTurboReleaseCommand, @@ -1626,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, }); @@ -2233,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 { @@ -2435,7 +2443,13 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { private async stageStaticProfileFrontend(profile: GatewayProfileRecord): Promise { 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); diff --git a/app/gateway-api/src/profileOrder.ts b/app/gateway-api/src/profileOrder.ts index 0a86a710..236b8401 100644 --- a/app/gateway-api/src/profileOrder.ts +++ b/app/gateway-api/src/profileOrder.ts @@ -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 } diff --git a/app/gateway-api/src/webPush/coordinator.ts b/app/gateway-api/src/webPush/coordinator.ts index 25759d4b..dbea569a 100644 --- a/app/gateway-api/src/webPush/coordinator.ts +++ b/app/gateway-api/src/webPush/coordinator.ts @@ -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 | 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 | 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 }, diff --git a/app/gateway-api/test/adminOperations.test.ts b/app/gateway-api/test/adminOperations.test.ts index 1c35892d..b466c631 100644 --- a/app/gateway-api/test/adminOperations.test.ts +++ b/app/gateway-api/test/adminOperations.test.ts @@ -380,6 +380,7 @@ describe('admin profile navigation API', () => { profileName: 'che:2', profile: 'che', instanceKey: '2', + displayName: '체 [2]', currentScenario: '2', meta: { korName: '체' }, }, diff --git a/app/gateway-api/test/profileOrder.test.ts b/app/gateway-api/test/profileOrder.test.ts index 8dd710b4..35b12ba1 100644 --- a/app/gateway-api/test/profileOrder.test.ts +++ b/app/gateway-api/test/profileOrder.test.ts @@ -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]'); + }); +}); diff --git a/app/gateway-frontend/e2e/admin-account-controls.spec.ts b/app/gateway-frontend/e2e/admin-account-controls.spec.ts index cdb45e28..b2ac3bd1 100644 --- a/app/gateway-frontend/e2e/admin-account-controls.spec.ts +++ b/app/gateway-frontend/e2e/admin-account-controls.spec.ts @@ -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('..'); diff --git a/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts b/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts index 7c27a78b..8ec646c3 100644 --- a/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts +++ b/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts @@ -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]); diff --git a/app/gateway-frontend/e2e/lobby-admin-navigation.spec.ts b/app/gateway-frontend/e2e/lobby-admin-navigation.spec.ts index d3ae7b0c..7d4b39dd 100644 --- a/app/gateway-frontend/e2e/lobby-admin-navigation.spec.ts +++ b/app/gateway-frontend/e2e/lobby-admin-navigation.spec.ts @@ -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); diff --git a/app/gateway-frontend/e2e/server-operations.spec.ts b/app/gateway-frontend/e2e/server-operations.spec.ts index a5b2eccb..cdbdf58d 100644 --- a/app/gateway-frontend/e2e/server-operations.spec.ts +++ b/app/gateway-frontend/e2e/server-operations.spec.ts @@ -71,6 +71,7 @@ const profile = (runtimeRunning: boolean, resetDefaults?: Record { profileName: 'che:default', profile: 'che', instanceKey: 'default', + displayName: '천하서버', currentScenario: '2', meta: { korName: '천하서버' }, }, @@ -639,7 +641,7 @@ test('separates branch and commit semantics and submits a reset from the dedicat await expect(page.getByText('초기화 작업을 등록했습니다.').first()).toBeVisible(); 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'); @@ -652,7 +654,8 @@ test('separates branch and commit semantics and submits a reset from the dedicat await detailsToggle.click(); await expect(detailsToggle).toHaveAttribute('aria-expanded', 'true'); const operationDetail = operationTable.getByTestId('operation-detail'); - await expect(operationDetail).toContainText('che:default'); + 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) => { @@ -807,7 +810,7 @@ test('separates DB-preserving profile deployment from DB reset', async ({ page } await expect(page.getByText('DB 보존 배포 작업을 등록했습니다.').first()).toBeVisible(); 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); @@ -834,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(); @@ -843,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(); @@ -1348,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) => { diff --git a/app/gateway-frontend/e2e/web-push-settings.spec.ts b/app/gateway-frontend/e2e/web-push-settings.spec.ts index e885531f..f32da536 100644 --- a/app/gateway-frontend/e2e/web-push-settings.spec.ts +++ b/app/gateway-frontend/e2e/web-push-settings.spec.ts @@ -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); diff --git a/app/gateway-frontend/src/components/ServerProfileTabs.vue b/app/gateway-frontend/src/components/ServerProfileTabs.vue index 8f9d9bd5..174d408a 100644 --- a/app/gateway-frontend/src/components/ServerProfileTabs.vue +++ b/app/gateway-frontend/src/components/ServerProfileTabs.vue @@ -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(() =>