From 8145f089441f3262e28f27c04bf6d44a8e403dc9 Mon Sep 17 00:00:00 2001 From: hided62 Date: Fri, 21 Aug 2026 16:39:50 +0000 Subject: [PATCH] =?UTF-8?q?fix(gateway):=203=EB=B6=84=20=EB=A6=AC=EC=85=8B?= =?UTF-8?q?=20=ED=84=B4=20=EA=B0=84=EA=B2=A9=EC=9D=84=20=ED=97=88=EC=9A=A9?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ref의 실제 서버 계약인 120의 양의 약수 전체를 RESET, profile 기본값, 실행 중 설정에 일관되게 적용한다. 3분 DB seed와 Gateway API, 실제 Chromium 회귀 검증을 추가한다. --- app/game-engine/test/scenarioSeeder.test.ts | 6 +-- app/gateway-api/src/adminRouter.ts | 9 +++-- app/gateway-api/test/adminOperations.test.ts | 40 ++++++++++++++++++- .../e2e/server-operations.spec.ts | 10 ++--- .../src/utils/resetDefaults.ts | 6 ++- .../src/views/ServerOperationsView.vue | 7 +--- docs/admin-console.md | 2 + docs/release-operations.md | 2 + 8 files changed, 62 insertions(+), 20 deletions(-) diff --git a/app/game-engine/test/scenarioSeeder.test.ts b/app/game-engine/test/scenarioSeeder.test.ts index 10ea88b4..112b16a1 100644 --- a/app/game-engine/test/scenarioSeeder.test.ts +++ b/app/game-engine/test/scenarioSeeder.test.ts @@ -349,7 +349,7 @@ describeDb('scenario database seed', () => { databaseUrl, now: new Date('2030-01-01T00:00:00Z'), installOptions: { - turnTermMinutes: 60, + turnTermMinutes: 3, sync: false, fiction: 1, extend: false, @@ -383,7 +383,7 @@ describeDb('scenario database seed', () => { if (!worldState) { return; } - expect(worldState.tickSeconds).toBe(3600); + expect(worldState.tickSeconds).toBe(180); expect(worldState.currentMonth).toBe(1); const config = (worldState.config ?? {}) as Record; @@ -395,7 +395,7 @@ describeDb('scenario database seed', () => { expect(meta.develcost).toBe( (worldState.currentYear - (scenario.startYear ?? worldState.currentYear) + 10) * 2 ); - expect(meta.killturn).toBe(80); + expect(meta.killturn).toBe(1600); const autorun = (meta.autorun_user ?? {}) as Record; const autorunOptions = (autorun.options ?? {}) as Record; expect(autorunOptions.develop).toBe(true); diff --git a/app/gateway-api/src/adminRouter.ts b/app/gateway-api/src/adminRouter.ts index 6d170cca..dfd67bf6 100644 --- a/app/gateway-api/src/adminRouter.ts +++ b/app/gateway-api/src/adminRouter.ts @@ -49,7 +49,6 @@ const zServerAction = z.enum([ 'SHUTDOWN', ]); -const TURN_TERM_MINUTES = [1, 2, 5, 10, 20, 30, 60, 120] as const; const AUTORUN_USER_OPTIONS = ['develop', 'warp', 'recruit', 'recruit_high', 'train', 'battle', 'chief'] as const; const ADMIN_ROLE_PREFIX = 'admin.'; @@ -430,7 +429,9 @@ const zInstallAutorun = z.object({ options: z.array(z.enum(AUTORUN_USER_OPTIONS)), }); -const isAllowedTurnTerm = (value: number): boolean => TURN_TERM_MINUTES.some((term) => term === value); +// Ref aligns a 12-month game year inside a 24-hour wall-clock day. That makes +// every positive divisor of 120 valid, including 3 and 8 minutes. +const isAllowedTurnTerm = (value: number): boolean => Number.isInteger(value) && value > 0 && 120 % value === 0; const zRuntimeSettings = z .object({ @@ -438,7 +439,7 @@ const zRuntimeSettings = z .number() .int() .refine((value) => isAllowedTurnTerm(value), { - message: 'turnTermMinutes must divide 120.', + message: '턴 시간은 120의 약수여야 합니다.', }) .optional(), blockGeneralCreate: z.union([z.literal(0), z.literal(1), z.literal(2)]).optional(), @@ -464,7 +465,7 @@ const zInstallOptions = z.object({ .number() .int() .refine((value) => isAllowedTurnTerm(value), { - message: 'turnTermMinutes must divide 120.', + message: '턴 시간은 120의 약수여야 합니다.', }), sync: z.boolean(), fiction: z.number().int().min(0).max(1), diff --git a/app/gateway-api/test/adminOperations.test.ts b/app/gateway-api/test/adminOperations.test.ts index ccadf931..025c10a9 100644 --- a/app/gateway-api/test/adminOperations.test.ts +++ b/app/gateway-api/test/adminOperations.test.ts @@ -825,7 +825,7 @@ describe('admin operation API', () => { { adminRoles: ['admin.profiles.settings:che:2'], firstUserIsAdmin: false } ); const resetDefaults = { - turnTermMinutes: 10, + turnTermMinutes: 3, sync: true, fiction: 1 as const, extend: true, @@ -853,6 +853,44 @@ describe('admin operation API', () => { ).rejects.toBeDefined(); }); + it('queues a three-minute reset because it is a valid divisor of 120', async () => { + const harness = await buildCaller(async (input) => ({ + id: '66666666-6666-4666-8666-666666666666', + profileName: input.profileName, + type: 'RESET', + status: 'QUEUED', + sourceMode: input.sourceMode, + sourceRef: input.sourceRef, + payload: input.payload ?? {}, + requestedBy: input.requestedBy, + createdAt: '2026-08-21T00:00:00.000Z', + updatedAt: '2026-08-21T00:00:00.000Z', + })); + + await harness.caller.admin.operations.requestReset({ + profileName: 'che:2', + sourceMode: 'COMMIT', + sourceRef: 'HEAD', + install: { + scenarioId: 1010, + turnTermMinutes: 3, + sync: true, + fiction: 1, + extend: true, + blockGeneralCreate: 0, + npcMode: 0, + showImgLevel: 3, + tournamentTrig: true, + joinMode: 'full', + }, + }); + + expect(harness.createdInputs[0]).toMatchObject({ + type: 'RESET', + payload: { install: { turnTermMinutes: 3 } }, + }); + }); + it('stores event season zero as the next season number', async () => { const harness = await buildCaller( async () => { diff --git a/app/gateway-frontend/e2e/server-operations.spec.ts b/app/gateway-frontend/e2e/server-operations.spec.ts index a531b73b..c9ed73c1 100644 --- a/app/gateway-frontend/e2e/server-operations.spec.ts +++ b/app/gateway-frontend/e2e/server-operations.spec.ts @@ -983,7 +983,7 @@ test('loads server metadata defaults into the reset form and submits them', asyn runtimeRunning: true, requestBodies: [], resetDefaults: { - turnTermMinutes: 20, + turnTermMinutes: 3, sync: false, fiction: 0, extend: false, @@ -999,7 +999,7 @@ test('loads server metadata defaults into the reset form and submits them', asyn page.on('dialog', (dialog) => dialog.accept()); await page.goto('admin/servers/che%3Adefault/scenario'); - await expect(page.getByTestId('reset-turn-term')).toHaveValue('20'); + await expect(page.getByTestId('reset-turn-term')).toHaveValue('3'); await page.getByText('고급 시나리오 옵션').click(); await expect(page.getByTestId('reset-defaults-source')).toContainText('서버의 메타'); await expect(page.getByTestId('reset-npc-mode')).toHaveValue('1'); @@ -1013,7 +1013,7 @@ test('loads server metadata defaults into the reset form and submits them', asyn const request = JSON.stringify( state.requestBodies.find((entry) => entry.operation === 'admin.operations.requestReset')?.body ); - expect(request).toContain('"turnTermMinutes":20'); + expect(request).toContain('"turnTermMinutes":3'); expect(request).toContain('"npcMode":1'); expect(request).toContain('"joinMode":"onlyRandom"'); expect(request).toContain('"limitMinutes":720'); @@ -1125,7 +1125,7 @@ test('edits server reset defaults through profile metadata settings', async ({ p await page.goto('admin/servers/che%3Adefault'); await page.getByText('서버 리셋 기본 옵션').click(); - await page.getByTestId('meta-reset-turn-term').selectOption('10'); + await page.getByTestId('meta-reset-turn-term').selectOption('3'); await page.getByTestId('meta-reset-npc-mode').selectOption('2'); await page.getByRole('button', { name: '메타 저장' }).click(); @@ -1172,7 +1172,7 @@ test('edits server reset defaults through profile metadata settings', async ({ p state.requestBodies.find((entry) => entry.operation === 'admin.profiles.updateMeta')?.body ); expect(request).toContain('"resetDefaults"'); - expect(request).toContain('"turnTermMinutes":10'); + expect(request).toContain('"turnTermMinutes":3'); expect(request).toContain('"npcMode":2'); }); diff --git a/app/gateway-frontend/src/utils/resetDefaults.ts b/app/gateway-frontend/src/utils/resetDefaults.ts index bd39095f..2d7cdd5f 100644 --- a/app/gateway-frontend/src/utils/resetDefaults.ts +++ b/app/gateway-frontend/src/utils/resetDefaults.ts @@ -1,4 +1,6 @@ -export const PROFILE_TURN_TERM_MINUTES = [1, 2, 5, 10, 20, 30, 60, 120] as const; +// A 12-month game year must fit an integer number of times in one wall-clock +// day, so every positive divisor of 120 is valid. +export const PROFILE_TURN_TERM_MINUTES = [1, 2, 3, 4, 5, 6, 8, 10, 12, 15, 20, 24, 30, 40, 60, 120] as const; export const RESET_AUTORUN_OPTIONS = [ 'develop', 'warp', @@ -24,7 +26,7 @@ export const RESET_AUTORUN_LABELS: ReadonlyArray<{ value: ResetAutorunOption; la export const RESET_OPTION_COPY = { turnTerm: { label: '턴 시간(분)', - help: '한 턴이 진행되는 실제 시간입니다. ref 초기화 화면과 같은 분 단위 값입니다.', + help: '한 턴이 진행되는 실제 시간입니다. 하루 경계를 정수 게임 연도로 유지하기 위해 120의 약수만 사용할 수 있습니다.', }, sync: { label: '시간 동기화', diff --git a/app/gateway-frontend/src/views/ServerOperationsView.vue b/app/gateway-frontend/src/views/ServerOperationsView.vue index 437ba1a3..3320ab78 100644 --- a/app/gateway-frontend/src/views/ServerOperationsView.vue +++ b/app/gateway-frontend/src/views/ServerOperationsView.vue @@ -8,6 +8,7 @@ import { useToast } from '../composables/useToast'; import AdminConsoleLayout from '../layouts/AdminConsoleLayout.vue'; import { normalizeProfileResetDefaults, + PROFILE_TURN_TERM_MINUTES, RESET_AUTORUN_LABELS, RESET_OPTION_COPY, SYSTEM_PROFILE_RESET_DEFAULTS, @@ -1078,11 +1079,7 @@ onBeforeUnmount(() => { class="w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-sm text-white" data-testid="reset-turn-term" > - diff --git a/docs/admin-console.md b/docs/admin-console.md index 230a694e..bc8536d6 100644 --- a/docs/admin-console.md +++ b/docs/admin-console.md @@ -71,6 +71,8 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로 턴 간격, 동기화, 가상 장수, 연장, 가입 방식, 장수 생성 제한, NPC, 이미지, 토너먼트와 유저 자동턴 기본값을 저장합니다. 시나리오 초기화 화면은 대상 서버를 URL에서 결정한 뒤 이 메타를 별도 권한 검사로 읽어 폼에 적용합니다. + 턴 간격은 하루를 `12개월 × 턴 간격`의 정수 개 게임 연도로 나눌 수 있는 + 120의 양의 약수만 허용하므로 3분과 8분도 유효합니다. 유저 자동턴은 내정, 이동, 징병, 고급 징병, 훈련, 전투, 중신의 7개 동작을 각각 보존해 RESET payload로 전달합니다. 메타가 없거나 유효하지 않으면 기존 시스템 기본값을 사용합니다. 시나리오와 diff --git a/docs/release-operations.md b/docs/release-operations.md index ae0dba6a..a0dc5612 100644 --- a/docs/release-operations.md +++ b/docs/release-operations.md @@ -175,6 +175,8 @@ migration과 scenario seed부터 진행합니다. 상위 권한자가 버전 업 장수·연장·토너먼트 사용, 기본 NPC, 이미지 단계 3, 전체 가입과 유저 자동턴 미사용이라는 기존 시스템 기본값으로 돌아갑니다. 실행마다 달라지는 scenario와 예약·가오픈·정식 오픈 시각은 자동 입력하지 않습니다. +턴 간격은 120의 양의 약수만 허용하며 3분과 8분도 RESET·저장 기본값·실행 중 +변경에서 동일하게 사용할 수 있습니다. 이 모드는 build와 migration 후 기존 season/tick metadata를 읽고 scenario seeder를 실행합니다. 빈 profile schema도 migration을 먼저 적용하므로 최초 `world_state` 조회가