fix(gateway): 3분 리셋 턴 간격을 허용한다
Ref의 실제 서버 계약인 120의 양의 약수 전체를 RESET, profile 기본값, 실행 중 설정에 일관되게 적용한다. 3분 DB seed와 Gateway API, 실제 Chromium 회귀 검증을 추가한다.
This commit is contained in:
@@ -349,7 +349,7 @@ describeDb('scenario database seed', () => {
|
|||||||
databaseUrl,
|
databaseUrl,
|
||||||
now: new Date('2030-01-01T00:00:00Z'),
|
now: new Date('2030-01-01T00:00:00Z'),
|
||||||
installOptions: {
|
installOptions: {
|
||||||
turnTermMinutes: 60,
|
turnTermMinutes: 3,
|
||||||
sync: false,
|
sync: false,
|
||||||
fiction: 1,
|
fiction: 1,
|
||||||
extend: false,
|
extend: false,
|
||||||
@@ -383,7 +383,7 @@ describeDb('scenario database seed', () => {
|
|||||||
if (!worldState) {
|
if (!worldState) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
expect(worldState.tickSeconds).toBe(3600);
|
expect(worldState.tickSeconds).toBe(180);
|
||||||
expect(worldState.currentMonth).toBe(1);
|
expect(worldState.currentMonth).toBe(1);
|
||||||
|
|
||||||
const config = (worldState.config ?? {}) as Record<string, unknown>;
|
const config = (worldState.config ?? {}) as Record<string, unknown>;
|
||||||
@@ -395,7 +395,7 @@ describeDb('scenario database seed', () => {
|
|||||||
expect(meta.develcost).toBe(
|
expect(meta.develcost).toBe(
|
||||||
(worldState.currentYear - (scenario.startYear ?? worldState.currentYear) + 10) * 2
|
(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<string, unknown>;
|
const autorun = (meta.autorun_user ?? {}) as Record<string, unknown>;
|
||||||
const autorunOptions = (autorun.options ?? {}) as Record<string, unknown>;
|
const autorunOptions = (autorun.options ?? {}) as Record<string, unknown>;
|
||||||
expect(autorunOptions.develop).toBe(true);
|
expect(autorunOptions.develop).toBe(true);
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ const zServerAction = z.enum([
|
|||||||
'SHUTDOWN',
|
'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 AUTORUN_USER_OPTIONS = ['develop', 'warp', 'recruit', 'recruit_high', 'train', 'battle', 'chief'] as const;
|
||||||
|
|
||||||
const ADMIN_ROLE_PREFIX = 'admin.';
|
const ADMIN_ROLE_PREFIX = 'admin.';
|
||||||
@@ -430,7 +429,9 @@ const zInstallAutorun = z.object({
|
|||||||
options: z.array(z.enum(AUTORUN_USER_OPTIONS)),
|
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
|
const zRuntimeSettings = z
|
||||||
.object({
|
.object({
|
||||||
@@ -438,7 +439,7 @@ const zRuntimeSettings = z
|
|||||||
.number()
|
.number()
|
||||||
.int()
|
.int()
|
||||||
.refine((value) => isAllowedTurnTerm(value), {
|
.refine((value) => isAllowedTurnTerm(value), {
|
||||||
message: 'turnTermMinutes must divide 120.',
|
message: '턴 시간은 120의 약수여야 합니다.',
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
blockGeneralCreate: z.union([z.literal(0), z.literal(1), z.literal(2)]).optional(),
|
blockGeneralCreate: z.union([z.literal(0), z.literal(1), z.literal(2)]).optional(),
|
||||||
@@ -464,7 +465,7 @@ const zInstallOptions = z.object({
|
|||||||
.number()
|
.number()
|
||||||
.int()
|
.int()
|
||||||
.refine((value) => isAllowedTurnTerm(value), {
|
.refine((value) => isAllowedTurnTerm(value), {
|
||||||
message: 'turnTermMinutes must divide 120.',
|
message: '턴 시간은 120의 약수여야 합니다.',
|
||||||
}),
|
}),
|
||||||
sync: z.boolean(),
|
sync: z.boolean(),
|
||||||
fiction: z.number().int().min(0).max(1),
|
fiction: z.number().int().min(0).max(1),
|
||||||
|
|||||||
@@ -825,7 +825,7 @@ describe('admin operation API', () => {
|
|||||||
{ adminRoles: ['admin.profiles.settings:che:2'], firstUserIsAdmin: false }
|
{ adminRoles: ['admin.profiles.settings:che:2'], firstUserIsAdmin: false }
|
||||||
);
|
);
|
||||||
const resetDefaults = {
|
const resetDefaults = {
|
||||||
turnTermMinutes: 10,
|
turnTermMinutes: 3,
|
||||||
sync: true,
|
sync: true,
|
||||||
fiction: 1 as const,
|
fiction: 1 as const,
|
||||||
extend: true,
|
extend: true,
|
||||||
@@ -853,6 +853,44 @@ describe('admin operation API', () => {
|
|||||||
).rejects.toBeDefined();
|
).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 () => {
|
it('stores event season zero as the next season number', async () => {
|
||||||
const harness = await buildCaller(
|
const harness = await buildCaller(
|
||||||
async () => {
|
async () => {
|
||||||
|
|||||||
@@ -983,7 +983,7 @@ test('loads server metadata defaults into the reset form and submits them', asyn
|
|||||||
runtimeRunning: true,
|
runtimeRunning: true,
|
||||||
requestBodies: [],
|
requestBodies: [],
|
||||||
resetDefaults: {
|
resetDefaults: {
|
||||||
turnTermMinutes: 20,
|
turnTermMinutes: 3,
|
||||||
sync: false,
|
sync: false,
|
||||||
fiction: 0,
|
fiction: 0,
|
||||||
extend: false,
|
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());
|
page.on('dialog', (dialog) => dialog.accept());
|
||||||
|
|
||||||
await page.goto('admin/servers/che%3Adefault/scenario');
|
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 page.getByText('고급 시나리오 옵션').click();
|
||||||
await expect(page.getByTestId('reset-defaults-source')).toContainText('서버의 메타');
|
await expect(page.getByTestId('reset-defaults-source')).toContainText('서버의 메타');
|
||||||
await expect(page.getByTestId('reset-npc-mode')).toHaveValue('1');
|
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(
|
const request = JSON.stringify(
|
||||||
state.requestBodies.find((entry) => entry.operation === 'admin.operations.requestReset')?.body
|
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('"npcMode":1');
|
||||||
expect(request).toContain('"joinMode":"onlyRandom"');
|
expect(request).toContain('"joinMode":"onlyRandom"');
|
||||||
expect(request).toContain('"limitMinutes":720');
|
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.goto('admin/servers/che%3Adefault');
|
||||||
await page.getByText('서버 리셋 기본 옵션').click();
|
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.getByTestId('meta-reset-npc-mode').selectOption('2');
|
||||||
|
|
||||||
await page.getByRole('button', { name: '메타 저장' }).click();
|
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
|
state.requestBodies.find((entry) => entry.operation === 'admin.profiles.updateMeta')?.body
|
||||||
);
|
);
|
||||||
expect(request).toContain('"resetDefaults"');
|
expect(request).toContain('"resetDefaults"');
|
||||||
expect(request).toContain('"turnTermMinutes":10');
|
expect(request).toContain('"turnTermMinutes":3');
|
||||||
expect(request).toContain('"npcMode":2');
|
expect(request).toContain('"npcMode":2');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 = [
|
export const RESET_AUTORUN_OPTIONS = [
|
||||||
'develop',
|
'develop',
|
||||||
'warp',
|
'warp',
|
||||||
@@ -24,7 +26,7 @@ export const RESET_AUTORUN_LABELS: ReadonlyArray<{ value: ResetAutorunOption; la
|
|||||||
export const RESET_OPTION_COPY = {
|
export const RESET_OPTION_COPY = {
|
||||||
turnTerm: {
|
turnTerm: {
|
||||||
label: '턴 시간(분)',
|
label: '턴 시간(분)',
|
||||||
help: '한 턴이 진행되는 실제 시간입니다. ref 초기화 화면과 같은 분 단위 값입니다.',
|
help: '한 턴이 진행되는 실제 시간입니다. 하루 경계를 정수 게임 연도로 유지하기 위해 120의 약수만 사용할 수 있습니다.',
|
||||||
},
|
},
|
||||||
sync: {
|
sync: {
|
||||||
label: '시간 동기화',
|
label: '시간 동기화',
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useToast } from '../composables/useToast';
|
|||||||
import AdminConsoleLayout from '../layouts/AdminConsoleLayout.vue';
|
import AdminConsoleLayout from '../layouts/AdminConsoleLayout.vue';
|
||||||
import {
|
import {
|
||||||
normalizeProfileResetDefaults,
|
normalizeProfileResetDefaults,
|
||||||
|
PROFILE_TURN_TERM_MINUTES,
|
||||||
RESET_AUTORUN_LABELS,
|
RESET_AUTORUN_LABELS,
|
||||||
RESET_OPTION_COPY,
|
RESET_OPTION_COPY,
|
||||||
SYSTEM_PROFILE_RESET_DEFAULTS,
|
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"
|
class="w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-sm text-white"
|
||||||
data-testid="reset-turn-term"
|
data-testid="reset-turn-term"
|
||||||
>
|
>
|
||||||
<option
|
<option v-for="minutes in PROFILE_TURN_TERM_MINUTES" :key="minutes" :value="minutes">
|
||||||
v-for="minutes in [1, 2, 3, 4, 5, 6, 10, 12, 15, 20, 24, 30, 40, 60, 120]"
|
|
||||||
:key="minutes"
|
|
||||||
:value="minutes"
|
|
||||||
>
|
|
||||||
{{ minutes }}분
|
{{ minutes }}분
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -71,6 +71,8 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로
|
|||||||
턴 간격, 동기화, 가상 장수, 연장, 가입 방식, 장수 생성 제한, NPC, 이미지,
|
턴 간격, 동기화, 가상 장수, 연장, 가입 방식, 장수 생성 제한, NPC, 이미지,
|
||||||
토너먼트와 유저 자동턴 기본값을 저장합니다. 시나리오 초기화 화면은 대상
|
토너먼트와 유저 자동턴 기본값을 저장합니다. 시나리오 초기화 화면은 대상
|
||||||
서버를 URL에서 결정한 뒤 이 메타를 별도 권한 검사로 읽어 폼에 적용합니다.
|
서버를 URL에서 결정한 뒤 이 메타를 별도 권한 검사로 읽어 폼에 적용합니다.
|
||||||
|
턴 간격은 하루를 `12개월 × 턴 간격`의 정수 개 게임 연도로 나눌 수 있는
|
||||||
|
120의 양의 약수만 허용하므로 3분과 8분도 유효합니다.
|
||||||
유저 자동턴은 내정, 이동, 징병, 고급 징병, 훈련, 전투, 중신의 7개 동작을
|
유저 자동턴은 내정, 이동, 징병, 고급 징병, 훈련, 전투, 중신의 7개 동작을
|
||||||
각각 보존해 RESET payload로 전달합니다.
|
각각 보존해 RESET payload로 전달합니다.
|
||||||
메타가 없거나 유효하지 않으면 기존 시스템 기본값을 사용합니다. 시나리오와
|
메타가 없거나 유효하지 않으면 기존 시스템 기본값을 사용합니다. 시나리오와
|
||||||
|
|||||||
@@ -175,6 +175,8 @@ migration과 scenario seed부터 진행합니다. 상위 권한자가 버전 업
|
|||||||
장수·연장·토너먼트 사용, 기본 NPC, 이미지 단계 3, 전체 가입과 유저 자동턴
|
장수·연장·토너먼트 사용, 기본 NPC, 이미지 단계 3, 전체 가입과 유저 자동턴
|
||||||
미사용이라는 기존 시스템 기본값으로 돌아갑니다. 실행마다 달라지는 scenario와
|
미사용이라는 기존 시스템 기본값으로 돌아갑니다. 실행마다 달라지는 scenario와
|
||||||
예약·가오픈·정식 오픈 시각은 자동 입력하지 않습니다.
|
예약·가오픈·정식 오픈 시각은 자동 입력하지 않습니다.
|
||||||
|
턴 간격은 120의 양의 약수만 허용하며 3분과 8분도 RESET·저장 기본값·실행 중
|
||||||
|
변경에서 동일하게 사용할 수 있습니다.
|
||||||
|
|
||||||
이 모드는 build와 migration 후 기존 season/tick metadata를 읽고 scenario seeder를 실행합니다.
|
이 모드는 build와 migration 후 기존 season/tick metadata를 읽고 scenario seeder를 실행합니다.
|
||||||
빈 profile schema도 migration을 먼저 적용하므로 최초 `world_state` 조회가
|
빈 profile schema도 migration을 먼저 적용하므로 최초 `world_state` 조회가
|
||||||
|
|||||||
Reference in New Issue
Block a user