diff --git a/README.md b/README.md index 63280eba..3bfa7389 100644 --- a/README.md +++ b/README.md @@ -231,6 +231,9 @@ commit-worktree build 경로에서 구성합니다. 각 서버의 `버전 업데이트`는 profile의 game migration만 적용하고 현재 게임 DB를 seed하지 않습니다. 별도 `시나리오 초기화`는 Git 업데이트 없이 현재 게시 commit을 기본으로 사용하며, 필요할 때만 새 버전 배포와 결합합니다. +상태 설정·버전 업데이트·시나리오 초기화는 서버별 상단 탭으로 이동하며, +버전/초기화 화면은 URL에 고정된 profile을 다시 선택하거나 전체 profile 상태를 +기다리지 않습니다. 초기화는 현재 시즌 테이블을 새 시나리오로 교체하지만 `hall`, `ng_games`, 연감, 과거 장수·국가와 상속 자료는 보존합니다. Gateway API·frontend·orchestrator는 외부 release-controller가 함께 전환합니다. 설치와 CLI self-upgrade 절차는 diff --git a/app/game-frontend/e2e/joinLayout.spec.ts b/app/game-frontend/e2e/joinLayout.spec.ts index d050ba78..c336ed87 100644 --- a/app/game-frontend/e2e/joinLayout.spec.ts +++ b/app/game-frontend/e2e/joinLayout.spec.ts @@ -157,6 +157,44 @@ test('prioritizes core general fields and keeps context and inheritance progress await expect(page.getByLabel('장수명')).toHaveValue('생성장수'); await expect(page.getByLabel('성격')).toBeVisible(); await expect(page.locator('.create-form').getByLabel('통솔')).toHaveValue('55'); + const statActions = page.getByRole('group', { name: '능력치 빠른 설정' }); + await expect(statActions.getByRole('button')).toHaveText([ + '랜덤형', + '통솔무력형', + '통솔지력형', + '무력지력형', + ]); + const setRandomValues = async (values: number[]) => { + await page.evaluate((nextValues) => { + let index = 0; + Math.random = () => nextValues[index++] ?? nextValues.at(-1) ?? 0.5; + }, values); + }; + + await setRandomValues([0.2, 0.4, 0.6]); + await statActions.getByRole('button', { name: '랜덤형', exact: true }).click(); + await expect(page.locator('.create-form').getByLabel('통솔')).toHaveValue('36'); + await expect(page.locator('.create-form').getByLabel('무력')).toHaveValue('55'); + await expect(page.locator('.create-form').getByLabel('지력')).toHaveValue('74'); + + await setRandomValues([0.9, 0.8, 0.5]); + await statActions.getByRole('button', { name: '통솔무력형' }).click(); + await expect(page.locator('.create-form').getByLabel('통솔')).toHaveValue('75'); + await expect(page.locator('.create-form').getByLabel('무력')).toHaveValue('75'); + await expect(page.locator('.create-form').getByLabel('지력')).toHaveValue('15'); + + await setRandomValues([0.9, 0.5, 0.8]); + await statActions.getByRole('button', { name: '통솔지력형' }).click(); + await expect(page.locator('.create-form').getByLabel('통솔')).toHaveValue('75'); + await expect(page.locator('.create-form').getByLabel('무력')).toHaveValue('15'); + await expect(page.locator('.create-form').getByLabel('지력')).toHaveValue('75'); + + await setRandomValues([0.5, 0.9, 0.8]); + await statActions.getByRole('button', { name: '무력지력형' }).click(); + await expect(page.locator('.create-form').getByLabel('통솔')).toHaveValue('15'); + await expect(page.locator('.create-form').getByLabel('무력')).toHaveValue('75'); + await expect(page.locator('.create-form').getByLabel('지력')).toHaveValue('75'); + await expect(page.locator('.stat-summary')).toContainText('능력치 합계: 165'); await expect(advanced).not.toHaveAttribute('open'); await expect(page.getByText('전투 특기 선택')).toBeHidden(); expect(state.mapRequests).toBe(0); diff --git a/app/game-frontend/src/utils/generalStats.ts b/app/game-frontend/src/utils/generalStats.ts new file mode 100644 index 00000000..737c6c0a --- /dev/null +++ b/app/game-frontend/src/utils/generalStats.ts @@ -0,0 +1,139 @@ +export type GeneralStatRules = { + min: number; + max: number; + total: number; +}; + +export type GeneralStats = [leadership: number, strength: number, intel: number]; + +type RandomSource = () => number; + +export const abilityRand = (stats: GeneralStatRules, random: RandomSource = Math.random): GeneralStats => { + let leadership = random() * 65 + 10; + let strength = random() * 65 + 10; + let intel = random() * 65 + 10; + const rate = leadership + strength + intel; + + leadership = Math.floor((leadership / rate) * stats.total); + strength = Math.floor((strength / rate) * stats.total); + intel = Math.floor((intel / rate) * stats.total); + + while (leadership + strength + intel < stats.total) { + leadership += 1; + } + + if ( + leadership > stats.max || + strength > stats.max || + intel > stats.max || + leadership < stats.min || + strength < stats.min || + intel < stats.min + ) { + return abilityRand(stats, random); + } + + return [leadership, strength, intel]; +}; + +export const abilityLeadpow = (stats: GeneralStatRules, random: RandomSource = Math.random): GeneralStats => { + let leadership = random() * 6; + let strength = random() * 6; + let intel = random(); + const rate = leadership + strength + intel; + + leadership = Math.floor((leadership / rate) * stats.total); + strength = Math.floor((strength / rate) * stats.total); + intel = Math.floor((intel / rate) * stats.total); + + while (leadership + strength + intel < stats.total) { + strength += 1; + } + + if (intel < stats.min) { + leadership -= stats.min - intel; + intel = stats.min; + } + if (leadership > stats.max) { + strength += leadership - stats.max; + leadership = stats.max; + } + if (strength > stats.max) { + leadership += strength - stats.max; + strength = stats.max; + } + if (leadership > stats.max) { + intel += leadership - stats.max; + leadership = stats.max; + } + + return [leadership, strength, intel]; +}; + +export const abilityLeadint = (stats: GeneralStatRules, random: RandomSource = Math.random): GeneralStats => { + let leadership = random() * 6; + let strength = random(); + let intel = random() * 6; + const rate = leadership + strength + intel; + + leadership = Math.floor((leadership / rate) * stats.total); + strength = Math.floor((strength / rate) * stats.total); + intel = Math.floor((intel / rate) * stats.total); + + while (leadership + strength + intel < stats.total) { + intel += 1; + } + + if (strength < stats.min) { + leadership -= stats.min - strength; + strength = stats.min; + } + if (leadership > stats.max) { + intel += leadership - stats.max; + leadership = stats.max; + } + if (intel > stats.max) { + leadership += intel - stats.max; + intel = stats.max; + } + if (leadership > stats.max) { + strength += leadership - stats.max; + leadership = stats.max; + } + + return [leadership, strength, intel]; +}; + +export const abilityPowint = (stats: GeneralStatRules, random: RandomSource = Math.random): GeneralStats => { + let leadership = random(); + let strength = random() * 6; + let intel = random() * 6; + const rate = leadership + strength + intel; + + leadership = Math.floor((leadership / rate) * stats.total); + strength = Math.floor((strength / rate) * stats.total); + intel = Math.floor((intel / rate) * stats.total); + + while (leadership + strength + intel < stats.total) { + intel += 1; + } + + if (leadership < stats.min) { + strength -= stats.min - leadership; + leadership = stats.min; + } + if (strength > stats.max) { + intel += strength - stats.max; + strength = stats.max; + } + if (intel > stats.max) { + strength += intel - stats.max; + intel = stats.max; + } + if (strength > stats.max) { + leadership += strength - stats.max; + strength = stats.max; + } + + return [leadership, strength, intel]; +}; diff --git a/app/game-frontend/src/views/JoinView.vue b/app/game-frontend/src/views/JoinView.vue index fae3bf93..384053f7 100644 --- a/app/game-frontend/src/views/JoinView.vue +++ b/app/game-frontend/src/views/JoinView.vue @@ -10,6 +10,7 @@ import { cityLevelMap, formatOfficerLevelText, regionMap } from '../utils/nation import { getNpcColor } from '../utils/npcColor'; import { formatSeoulDateTime } from '../utils/legacyDateTime'; import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon'; +import { abilityLeadint, abilityLeadpow, abilityPowint, abilityRand, type GeneralStats } from '../utils/generalStats'; type JoinConfig = Awaited>; type JoinInput = Parameters[0]; @@ -365,8 +366,6 @@ const inheritTurntimeChoice = computed({ }, }); -const randomInt = (min: number, max: number) => Math.floor(Math.random() * (max - min + 1)) + min; - const applyBalancedStats = () => { const rules = statRules.value; if (!rules) { @@ -378,36 +377,40 @@ const applyBalancedStats = () => { form.value.intel = base; }; +const applyStats = (stats: GeneralStats) => { + [form.value.leadership, form.value.strength, form.value.intel] = stats; +}; + const applyRandomStats = () => { const rules = statRules.value; if (!rules) { return; } - for (let i = 0; i < 40; i += 1) { - const leadership = randomInt(rules.min, rules.max); - const strength = randomInt(rules.min, rules.max); - const intel = rules.total - leadership - strength; - if (intel >= rules.min && intel <= rules.max) { - form.value.leadership = leadership; - form.value.strength = strength; - form.value.intel = intel; - return; - } - } - applyBalancedStats(); + applyStats(abilityRand(rules)); }; -const applyFocusedStats = (focus: 'leadership' | 'strength' | 'intel') => { +const applyLeadpowStats = () => { const rules = statRules.value; if (!rules) { return; } - const focusValue = Math.min(rules.max, rules.min + Math.floor(rules.total * 0.45)); - const remain = rules.total - focusValue; - const side = Math.floor(remain / 2); - form.value.leadership = focus === 'leadership' ? focusValue : side; - form.value.strength = focus === 'strength' ? focusValue : side; - form.value.intel = focus === 'intel' ? focusValue : remain - side; + applyStats(abilityLeadpow(rules)); +}; + +const applyLeadintStats = () => { + const rules = statRules.value; + if (!rules) { + return; + } + applyStats(abilityLeadint(rules)); +}; + +const applyPowintStats = () => { + const rules = statRules.value; + if (!rules) { + return; + } + applyStats(abilityPowint(rules)); }; const loadConfig = async () => { @@ -709,12 +712,11 @@ onUnmounted(() => { -
+
- - - - + + +
@@ -751,7 +753,7 @@ onUnmounted(() => { - +
diff --git a/app/game-frontend/test/generalStats.test.ts b/app/game-frontend/test/generalStats.test.ts new file mode 100644 index 00000000..8372af1d --- /dev/null +++ b/app/game-frontend/test/generalStats.test.ts @@ -0,0 +1,22 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { abilityLeadint, abilityLeadpow, abilityPowint, abilityRand } from '../src/utils/generalStats.ts'; + +const rules = { min: 15, max: 80, total: 165 }; +const sequence = (...values: number[]) => { + let index = 0; + return () => values[index++] ?? values.at(-1) ?? 0.5; +}; + +void describe('generalStats Ref presets', () => { + void it('normalizes the random preset to the configured total', () => { + assert.deepEqual(abilityRand(rules, sequence(0.2, 0.4, 0.6)), [36, 55, 74]); + }); + + void it('preserves the Ref two-stat weighted distributions and min/max correction order', () => { + assert.deepEqual(abilityLeadpow(rules, sequence(0.9, 0.8, 0.5)), [75, 75, 15]); + assert.deepEqual(abilityLeadint(rules, sequence(0.9, 0.5, 0.8)), [75, 15, 75]); + assert.deepEqual(abilityPowint(rules, sequence(0.5, 0.9, 0.8)), [15, 75, 75]); + }); +}); diff --git a/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts b/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts index 0e6430db..039d83c8 100644 --- a/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts +++ b/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts @@ -323,10 +323,29 @@ test('renders an ignored terminal outcome without calling it applied', async ({ await expect(page.getByText(/적용됨|요청 완료/)).toHaveCount(0); }); -test('directs profile deployment to the selected server version tab', async ({ page }) => { +test('directs profile deployment to the selected server version tab', async ({ page }, testInfo) => { await installFixture(page); await page.goto('admin/servers'); + const tabs = page.getByTestId('server-profile-tabs'); + await expect(tabs).toBeVisible(); + await expect(tabs.getByRole('link', { name: '상태 설정', exact: true })).toHaveAttribute('aria-current', 'page'); + await expect(page.getByText('버전과 시즌 수명주기', { exact: true })).toHaveCount(0); + const versionTab = tabs.getByRole('link', { name: '버전 업데이트', exact: true }); + const idleTabBackground = await versionTab.evaluate((element) => getComputedStyle(element).backgroundColor); + await versionTab.hover(); + await expect + .poll(() => versionTab.evaluate((element) => getComputedStyle(element).backgroundColor)) + .not.toBe(idleTabBackground); + await versionTab.focus(); + await expect(versionTab).toBeFocused(); + const tabAndHeaderGeometry = await Promise.all([ + tabs.evaluate((element) => element.getBoundingClientRect().top), + page.getByText('hwe:default (hwe)', { exact: true }).evaluate((element) => element.getBoundingClientRect().top), + ]); + expect(tabAndHeaderGeometry[0]).toBeLessThan(tabAndHeaderGeometry[1]); + await page.screenshot({ path: testInfo.outputPath('status-tabs-desktop.png'), fullPage: true }); + const releaseLink = page.getByRole('link', { name: '버전 업데이트', exact: true }).last(); await expect(releaseLink).toBeVisible(); await expect(releaseLink).toHaveAttribute('href', '/gateway/admin/servers/hwe%3Adefault/version'); @@ -339,4 +358,5 @@ test('directs profile deployment to the selected server version tab', async ({ p }); expect(linkGeometry.left).toBeGreaterThanOrEqual(0); expect(linkGeometry.right).toBeLessThanOrEqual(linkGeometry.viewportWidth); + await page.screenshot({ path: testInfo.outputPath('status-tabs-mobile.png'), fullPage: true }); }); diff --git a/app/gateway-frontend/e2e/server-operations.spec.ts b/app/gateway-frontend/e2e/server-operations.spec.ts index aa456413..c42aaed7 100644 --- a/app/gateway-frontend/e2e/server-operations.spec.ts +++ b/app/gateway-frontend/e2e/server-operations.spec.ts @@ -34,6 +34,9 @@ type FixtureState = { runtimeRunning: boolean; requestBodies: Array<{ operation: string; body: unknown }>; capabilities?: Array<{ permission: string; scope: 'GLOBAL' | 'PROFILE'; scopes: string[] }>; + profileListDelayMs?: number; + profileListRequests?: number; + profileListResolved?: boolean; }; const profile = (runtimeRunning: boolean) => ({ @@ -93,6 +96,13 @@ const installFixture = async (page: Page, state: FixtureState) => { await page.route('**/gateway/api/trpc/**', async (route) => { const names = operationNames(route); const body = route.request().postDataJSON() as unknown; + if (names.includes('admin.profiles.list')) { + state.profileListRequests = (state.profileListRequests ?? 0) + 1; + if (state.profileListDelayMs) { + await new Promise((resolve) => setTimeout(resolve, state.profileListDelayMs)); + } + state.profileListResolved = true; + } const results = names.map((name) => { if (route.request().method() === 'POST') { state.requestBodies.push({ operation: name, body }); @@ -235,8 +245,14 @@ test('separates branch and commit semantics and submits a reset from the dedicat await expect(page.getByTestId('server-operations-page')).toBeVisible(); await expect(page).toHaveURL(/\/gateway\/admin\/servers\/che%3A2\/scenario$/); await expect(page.getByTestId('source-current')).toBeChecked(); - await expect(page.getByTestId('source-help')).toContainText('현재 서버 커밋'); + await expect(page.getByTestId('source-help')).toContainText('현재 서버에 배포된 커밋'); await expect(page.getByTestId('scenario-select')).toHaveValue('2'); + await expect(page.getByTestId('server-profile-tabs')).toBeVisible(); + await expect(page.getByRole('link', { name: '시나리오 초기화', exact: true })).toHaveAttribute( + 'aria-current', + 'page' + ); + await expect(page.getByText('운영 프로필', { exact: true })).toHaveCount(0); const desktopGeometry = await page .getByTestId('server-operations-page') @@ -249,8 +265,8 @@ test('separates branch and commit semantics and submits a reset from the dedicat }); return children; }); - expect(desktopGeometry).toHaveLength(2); - expect(desktopGeometry[1]!.x).toBeGreaterThan(desktopGeometry[0]!.x); + expect(desktopGeometry).toHaveLength(1); + expect(desktopGeometry[0]!.width).toBeGreaterThan(800); await page.getByTestId('source-commit').check(); const sourceInput = page.getByTestId('source-ref'); await sourceInput.focus(); @@ -297,8 +313,19 @@ test('separates branch and commit semantics and submits a reset from the dedicat }); return children; }); - expect(mobileGeometry[1]!.y).toBeGreaterThan(mobileGeometry[0]!.y); expect(mobileGeometry[0]!.width).toBeLessThanOrEqual(390); + const mobileTabs = await page + .getByTestId('server-profile-tabs') + .locator('a') + .evaluateAll((links) => + links.map((link) => { + const rect = link.getBoundingClientRect(); + return { top: rect.top, width: rect.width, height: rect.height }; + }) + ); + expect(mobileTabs).toHaveLength(3); + expect(mobileTabs[1]!.top).toBeGreaterThan(mobileTabs[0]!.top); + expect(mobileTabs.every((tab) => tab.height >= 44)).toBe(true); await page.screenshot({ path: testInfo.outputPath('mobile-operations.png'), fullPage: true }); }); @@ -308,7 +335,12 @@ test('separates DB-preserving profile deployment from DB reset', async ({ page } page.on('dialog', (dialog) => dialog.accept()); await page.goto('admin/servers/che%3A2/version'); - await expect(page.getByText('Game frontend')).toBeVisible(); + await expect(page.getByRole('heading', { name: 'DB 보존 버전 업데이트' })).toBeVisible(); + await expect(page.getByText('운영 프로필', { exact: true })).toHaveCount(0); + await expect(page.getByRole('link', { name: '버전 업데이트', exact: true })).toHaveAttribute( + 'aria-current', + 'page' + ); await page.getByTestId('request-deploy').click(); await expect(page.getByText('DB 보존 배포 작업을 등록했습니다.')).toBeVisible(); @@ -317,6 +349,24 @@ test('separates DB-preserving profile deployment from DB reset', async ({ page } expect(state.requestBodies.some((entry) => entry.operation === 'admin.operations.requestReset')).toBe(false); }); +test('renders the fixed-profile version form without waiting for the server list', async ({ page }) => { + const state: FixtureState = { + operations: [], + gatewayOperations: [], + runtimeRunning: true, + requestBodies: [], + profileListDelayMs: 1500, + profileListResolved: false, + }; + await installFixture(page, state); + + await page.goto('admin/servers/che%3A2/version'); + await expect(page.getByTestId('request-deploy')).toBeVisible({ timeout: 900 }); + expect(state.profileListResolved).toBe(false); + await expect.poll(() => state.profileListResolved).toBe(true); + expect(state.profileListRequests).toBe(1); +}); + test('scenario-only operator resets the current version without Git or Gateway controls', async ({ page }) => { const state: FixtureState = { operations: [], @@ -420,7 +470,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.getByText('SUCCEEDED', { exact: true })).toBeVisible(); - await expect(page.getByText('RUNNING', { exact: true }).first()).toBeVisible(); + await expect(page.getByText('운영 프로필', { exact: true })).toHaveCount(0); await page.screenshot({ path: testInfo.outputPath('failed-retry-succeeded-desktop.png'), fullPage: true }); await page.setViewportSize({ width: 390, height: 844 }); diff --git a/app/gateway-frontend/src/components/ServerProfileTabs.vue b/app/gateway-frontend/src/components/ServerProfileTabs.vue new file mode 100644 index 00000000..6851e282 --- /dev/null +++ b/app/gateway-frontend/src/components/ServerProfileTabs.vue @@ -0,0 +1,108 @@ + + + + + diff --git a/app/gateway-frontend/src/layouts/AdminConsoleLayout.vue b/app/gateway-frontend/src/layouts/AdminConsoleLayout.vue index 5299e1c5..47fcd9e1 100644 --- a/app/gateway-frontend/src/layouts/AdminConsoleLayout.vue +++ b/app/gateway-frontend/src/layouts/AdminConsoleLayout.vue @@ -109,16 +109,12 @@ const navigation = computed(() => [ ]); onMounted(async () => { - try { - capabilities.value = await adminClient.capabilities.list.query(); - } catch { - capabilities.value = []; - } - try { - profiles.value = await adminClient.profiles.list.query(); - } catch { - profiles.value = []; - } + const [capabilityResult, profileResult] = await Promise.allSettled([ + adminClient.capabilities.list.query(), + adminClient.profiles.list.query(), + ]); + capabilities.value = capabilityResult.status === 'fulfilled' ? capabilityResult.value : []; + profiles.value = profileResult.status === 'fulfilled' ? profileResult.value : []; }); diff --git a/app/gateway-frontend/src/views/AdminView.vue b/app/gateway-frontend/src/views/AdminView.vue index 8ab8d0f2..88f97d3a 100644 --- a/app/gateway-frontend/src/views/AdminView.vue +++ b/app/gateway-frontend/src/views/AdminView.vue @@ -1,5 +1,6 @@