diff --git a/app/game-frontend/e2e/joinLayout.spec.ts b/app/game-frontend/e2e/joinLayout.spec.ts index d050ba78..aceb67a0 100644 --- a/app/game-frontend/e2e/joinLayout.spec.ts +++ b/app/game-frontend/e2e/joinLayout.spec.ts @@ -157,6 +157,22 @@ 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([ + '랜덤형', + '통솔무력형', + '통솔지력형', + '무력지력형', + ]); + await page.evaluate(() => { + const values = [0.9, 0.8, 0.5]; + Math.random = () => values.shift() ?? 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 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]); + }); +});