From 9786cfe0314350282cdbad6c9b0dc4e616e90666 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Tue, 5 May 2026 21:52:17 +0900 Subject: [PATCH] Refactor launcher profiles and port automation --- README.md | 95 +++---- admin/data/catalog.json | 60 +++-- admin/public/app.js | 177 +++++++------ admin/public/index.html | 84 ++++--- admin/server.js | 84 +++++-- app/assets/css/launcher.css | 71 +++++- app/assets/js/catalogmanager.js | 132 +++++++--- app/assets/js/portmanager.js | 299 ++++++++++++++++++++++ app/assets/js/processbuilder.js | 23 +- app/assets/js/profileassetmanager.js | 66 +++-- app/assets/js/scripts/install.js | 144 +++++------ app/assets/js/scripts/landing.js | 125 ++++++---- app/assets/js/scripts/library.js | 222 ++++------------- app/assets/js/serverruntime.js | 224 +++++++++-------- app/assets/lang/en_US.toml | 11 + app/assets/lang/ko_KR.toml | 11 + app/assets/launcher/catalog.json | 60 +++-- app/landing.ejs | 1 + docs/admin-site.md | 18 +- docs/launcher-catalog.md | 91 +++---- package-lock.json | 357 +++++++++++++++++++++++++-- package.json | 1 + 22 files changed, 1558 insertions(+), 798 deletions(-) create mode 100644 app/assets/js/portmanager.js diff --git a/README.md b/README.md index de1f7d3..f3b4dbe 100644 --- a/README.md +++ b/README.md @@ -1,65 +1,42 @@ # Minecraft Launcher -Electron 기반 커스텀 마인크래프트 런처입니다. `MRSLauncher`를 최신 소스로 가져온 뒤, 단일 모드팩 구조를 `설치 페이지 + 라이브러리` 구조로 확장했습니다. +Electron 기반 커스텀 마인크래프트 런처입니다. `MRSLauncher`를 최신 소스로 가져온 뒤, 설치 페이지 + 라이브러리 구조와 관리자 사이트를 붙였습니다. -## 현재 상태 +## 현재 구조 -- 여러 프로필을 설치 페이지에서 라이브러리로 추가 가능 -- 프로필 종류 지원: - - `modpack` - - `map` - - `server-pack` -- 라이브러리에서 프로필 선택, 제거, 자료 준비, 실행 화면 이동, 바로 실행 가능 -- 프로필별 `distribution.json` 전환 가능 -- `map` 프로필은 월드 ZIP/로컬 폴더를 `saves/`에 설치하고 `quickPlaySingleplayer`로 바로 실행 -- `server-pack` 프로필은 로컬 서버 번들 설치, 서버 시작/중지, 선택형 터널 명령 실행, 공개 주소 표시 지원 -- 라이브러리의 주소 입력칸에 `host:port`를 넣으면 실행 시 자동 접속 -- 설치 페이지는 관리자가 미리 등록한 프로필을 보여주는 읽기 전용 카탈로그 화면 -- 설치 페이지에서 프로필 제목, 요약, 상세 설명, 실행 조건을 확인 가능 +- 프로필은 `맵`을 기본으로 두고 `모드`, `플러그인`, `서버` 기능을 조합합니다. +- 설치 페이지는 관리자 등록 프로필을 읽기 전용으로 보여줍니다. +- 라이브러리에서는 프로필 설치, 선택, 제거만 하고, 서버 프로필은 접속 주소를 직접 입력할 수 있습니다. +- `PLAY`를 누르면: + - 서버 기능이 없는 프로필은 맵을 싱글플레이로 실행 + - 서버 기능이 있고 접속 주소가 있으면 해당 주소로 접속 + - 서버 기능이 있고 접속 주소가 없으면 로컬 서버를 먼저 실행한 뒤 `localhost`로 접속 +- 메인 화면 왼쪽 아래는 선택된 서버 프로필의 자동 포트 개방 상태를 표시합니다. -## 중요한 제한 +## 자동 포트 개방 -포트포워딩 없이 외부 사용자가 접속하게 만드는 기능은 런처만으로 해결되지 않습니다. +- 현재 구현은 `UPnP + Windows 방화벽` 기준입니다. +- 성공하면 자동 개방 상태를 표시합니다. +- 이미 열려 있으면 기존 포트를 그대로 사용합니다. +- 실패하면 `직접 포트포워딩 해주세요` 안내를 표시합니다. +- 접속 주소를 직접 입력한 경우에는 자동 포트 개방을 건너뜁니다. -필요한 것 중 하나: +## 관리자 사이트 -- 별도 릴레이 서버 -- 터널링 도구 -- VPN/NAT traversal 백엔드 +```bash +npm run admin +``` -현재 구현은 `server-pack` 프로필에 `tunnelCommand`를 넣어 외부 도구를 호출하는 자리까지 제공합니다. +- 기본 주소: `http://127.0.0.1:8787` +- `distribution.json` 업로드 / 새로 만들기 / 직접 편집 가능 +- 월드 ZIP 업로드 가능 +- 서버용 버킷 JAR 업로드 가능 +- 서버 메모리, 최대 인원수, 화이트리스트, 포트 설정 가능 -추후 설계 문서: +문서: -- [docs/portforwarding-free-connection-plan.md](docs/portforwarding-free-connection-plan.md) - -## 프로젝트 구조 - -- `app/` - - Electron renderer 자산 - - 설치/라이브러리/로그인/설정 화면 -- `app/assets/js/catalogmanager.js` - - 관리자 등록 카탈로그 로드, 프로필 메타데이터 정규화 -- `app/assets/js/profileassetmanager.js` - - 맵 ZIP, 서버 번들 ZIP/폴더 설치 -- `app/assets/js/serverruntime.js` - - 로컬 서버 실행, 선택형 터널 프로세스 관리 -- `src/main/index.ts` - - TypeScript 메인 프로세스 엔트리 -- `index.js` - - `dist/main/index.js` 우선 실행, 없으면 `index.legacy.js` 폴백 - -## 카탈로그 / 프로필 - -기본 카탈로그: - -- `app/assets/launcher/catalog.json` - -원격 카탈로그: - -- 관리자 배포 설정으로 연결 - -세부 스키마는 [docs/launcher-catalog.md](docs/launcher-catalog.md)를 보면 됩니다. +- [docs/admin-site.md](docs/admin-site.md) +- [docs/launcher-catalog.md](docs/launcher-catalog.md) ## 개발 @@ -73,17 +50,6 @@ Electron 기반 커스텀 마인크래프트 런처입니다. `MRSLauncher`를 npm install ``` -관리자 사이트 실행: - -```bash -npm run admin -``` - -설치 페이지용 프로필을 웹 UI로 관리할 수 있습니다. -- `distribution.json`도 사이트 안에서 직접 만들고 수정할 수 있습니다. - -- 문서: [docs/admin-site.md](docs/admin-site.md) - 개발 실행: ```bash @@ -96,8 +62,6 @@ npm start npm run smoke:win ``` -이 명령은 TypeScript 메인 프로세스를 빌드한 뒤 Electron 앱을 실제로 한 번 띄우고, `LAUNCHER_SMOKE_EXIT` 환경변수로 자동 종료합니다. - TypeScript 메인 프로세스만 빌드: ```bash @@ -107,11 +71,10 @@ npm run build 배포 빌드: ```bash -npm run dist +npm run dist:win ``` ## 참고 - Upstream: `https://github.com/peunsu/MRSLauncher` - Original base: `https://github.com/dscalzi/HeliosLauncher` -- CI: `.github/workflows/windows-smoke.yml` 에서 Windows smoke run 수행 diff --git a/admin/data/catalog.json b/admin/data/catalog.json index 9bff7a3..795d3db 100644 --- a/admin/data/catalog.json +++ b/admin/data/catalog.json @@ -2,37 +2,45 @@ "version": 1, "profiles": [ { - "id": "mrs-concatenation-lite", - "name": "Concatenation Lite", - "kind": "modpack", - "description": "기존 MRS 분배 인덱스를 사용하는 예시 프로필입니다. 프로필 카탈로그 구조가 동작하는지 확인할 때 사용할 수 있습니다.", - "details": "Mystic Red Space에서 운영하던 기존 분배 인덱스를 그대로 사용합니다.\n\n사용자는 이 항목을 라이브러리에 추가한 뒤 바로 실행할 수 있습니다.\n관리자는 distribution 파일만 교체해서 실서비스용 항목으로 바꿀 수 있습니다.", - "distributionUrl": "https://cdn.mysticred.space/launcher/distribution.json" + "id": "template-map-base", + "name": "Map Base Template", + "description": "맵만 사용하는 기본 프로필 예시입니다.", + "details": "맵 기반 기본 프로필입니다. 월드 ZIP과 distribution 파일만 있으면 싱글플레이 실행 흐름으로 사용할 수 있습니다.", + "modsEnabled": false, + "pluginsEnabled": false, + "serverEnabled": false, + "distributionUrl": "https://example.com/launcher/map-base.distribution.json", + "worldArchiveUrl": "https://example.com/worlds/map-base.zip", + "worldDirectoryName": "Map Base" }, { - "id": "template-original-map", - "name": "Original Map Template", - "kind": "map", - "description": "오리지널 맵용 템플릿입니다. vanilla 또는 맵 전용 distribution URL과 월드 ZIP 경로를 채우면 싱글플레이 빠른 실행에 사용할 수 있습니다.", - "details": "관리자는 이 항목에 월드 ZIP과 클라이언트 distribution URL을 미리 등록합니다.\n\n사용자는 설치 페이지에서 설명을 읽고 라이브러리에 추가한 뒤, 라이브러리에서 맵 자료를 준비하고 바로 실행할 수 있습니다.", - "distributionUrl": "https://example.com/launcher/vanilla-map-distribution.json", - "worldArchiveUrl": "https://example.com/maps/original-map.zip", - "worldDirectoryName": "Original Map" + "id": "template-map-mods", + "name": "Map + Mods Template", + "description": "맵과 모드를 함께 쓰는 프로필 예시입니다.", + "details": "맵 기반에 모드 구성이 포함된 프로필입니다. distribution 파일은 모드가 포함된 클라이언트용으로 준비하면 됩니다.", + "modsEnabled": true, + "pluginsEnabled": false, + "serverEnabled": false, + "distributionUrl": "https://example.com/launcher/map-mods.distribution.json", + "worldArchiveUrl": "https://example.com/worlds/map-mods.zip", + "worldDirectoryName": "Map Mods" }, { - "id": "template-plugin-server-pack", - "name": "Plugin Server Pack Template", - "kind": "server-pack", - "description": "플러그인 맵 + 서버 접속용 템플릿입니다. 클라이언트 distribution, 서버 번들 ZIP, 서버 시작 명령, 선택형 터널 명령을 연결하면 됩니다.", - "details": "관리자는 클라이언트 배포 파일, 서버 번들, 서버 시작 명령을 미리 등록합니다.\n\n사용자는 설치 페이지에서 상세 내용을 확인하고 라이브러리에 추가한 뒤, 라이브러리에서 직접 서버를 켜거나 수동 주소를 넣어 접속할 수 있습니다.", - "distributionUrl": "https://example.com/launcher/server-pack-client-distribution.json", - "serverBundleUrl": "https://example.com/serverpacks/plugin-world-server.zip", - "serverDirectoryName": "plugin-world-server", - "serverLaunchCommand": "java -jar server.jar nogui", - "serverWorkingDirectory": "", + "id": "template-map-plugin-server", + "name": "Map + Plugin Server Template", + "description": "맵, 플러그인, 서버를 함께 쓰는 프로필 예시입니다.", + "details": "플러그인을 켜면 서버도 같이 사용합니다. 주소를 비우면 로컬 서버를 띄우고, 주소를 입력하면 해당 서버로 바로 접속하는 흐름에 맞춘 예시입니다.", + "modsEnabled": false, + "pluginsEnabled": true, + "serverEnabled": true, + "distributionUrl": "https://example.com/launcher/map-plugin-server.distribution.json", + "worldArchiveUrl": "https://example.com/worlds/plugin-map.zip", + "worldDirectoryName": "Plugin Map", + "serverJarUrl": "https://example.com/server/paper.jar", "serverPort": 25565, - "tunnelCommand": "", - "tunnelAddressRegex": "" + "serverMemoryMb": 4096, + "serverMaxPlayers": 20, + "serverWhitelistEnabled": false } ] } diff --git a/admin/public/app.js b/admin/public/app.js index 551f51d..f865e0c 100644 --- a/admin/public/app.js +++ b/admin/public/app.js @@ -19,8 +19,8 @@ const editorHint = document.getElementById('editorHint') const saveCatalogButton = document.getElementById('saveCatalogButton') const duplicateProfileButton = document.getElementById('duplicateProfileButton') const deleteProfileButton = document.getElementById('deleteProfileButton') -const mapSection = document.getElementById('mapSection') -const serverPackSection = document.getElementById('serverPackSection') +const addProfileButton = document.getElementById('addProfileButton') +const serverSection = document.getElementById('serverSection') const editDistributionButton = document.getElementById('editDistributionButton') const createDistributionButton = document.getElementById('createDistributionButton') const distributionEditorModal = document.getElementById('distributionEditorModal') @@ -32,20 +32,20 @@ const saveDistributionFileButton = document.getElementById('saveDistributionFile const fieldElements = { id: document.getElementById('field-id'), - kind: document.getElementById('field-kind'), name: document.getElementById('field-name'), description: document.getElementById('field-description'), details: document.getElementById('field-details'), + modsEnabled: document.getElementById('field-modsEnabled'), + pluginsEnabled: document.getElementById('field-pluginsEnabled'), + serverEnabled: document.getElementById('field-serverEnabled'), distributionUrl: document.getElementById('field-distributionUrl'), worldArchiveUrl: document.getElementById('field-worldArchiveUrl'), worldDirectoryName: document.getElementById('field-worldDirectoryName'), - serverBundleUrl: document.getElementById('field-serverBundleUrl'), - serverDirectoryName: document.getElementById('field-serverDirectoryName'), - serverLaunchCommand: document.getElementById('field-serverLaunchCommand'), - serverWorkingDirectory: document.getElementById('field-serverWorkingDirectory'), + serverJarUrl: document.getElementById('field-serverJarUrl'), serverPort: document.getElementById('field-serverPort'), - tunnelCommand: document.getElementById('field-tunnelCommand'), - tunnelAddressRegex: document.getElementById('field-tunnelAddressRegex') + serverMemoryMb: document.getElementById('field-serverMemoryMb'), + serverMaxPlayers: document.getElementById('field-serverMaxPlayers'), + serverWhitelistEnabled: document.getElementById('field-serverWhitelistEnabled') } function slugify(value){ @@ -60,24 +60,24 @@ function isRemoteUrl(value){ return /^https?:\/\//i.test(String(value ?? '').trim()) } -function createProfile(kind){ +function createProfile(){ const timestamp = Date.now() return { - id: `${kind}-${timestamp}`, - name: kind === 'map' ? '새 맵 프로필' : kind === 'server-pack' ? '새 서버팩 프로필' : '새 모드팩 프로필', - kind, + id: `profile-${timestamp}`, + name: '새 프로필', description: '', details: '', + modsEnabled: false, + pluginsEnabled: false, + serverEnabled: false, distributionUrl: '', worldArchiveUrl: '', worldDirectoryName: '', - serverBundleUrl: '', - serverDirectoryName: `${kind}-${timestamp}-server`, - serverLaunchCommand: '', - serverWorkingDirectory: '', + serverJarUrl: '', serverPort: 25565, - tunnelCommand: '', - tunnelAddressRegex: '' + serverMemoryMb: 4096, + serverMaxPlayers: 20, + serverWhitelistEnabled: false } } @@ -108,6 +108,20 @@ function selectProfile(profileId){ populateEditor() } +function describeProfileFeatures(profile){ + const badges = ['맵'] + if(profile.modsEnabled){ + badges.push('모드') + } + if(profile.pluginsEnabled){ + badges.push('플러그인') + } + if(profile.serverEnabled){ + badges.push('서버') + } + return badges +} + function renderSidebar(){ profileList.innerHTML = '' profileCount.textContent = `${state.catalog.profiles.length}개` @@ -126,10 +140,9 @@ function renderSidebar(){ const meta = document.createElement('div') meta.className = 'profileListMeta' - meta.innerHTML = ` - ${profile.kind} - ${profile.distributionUrl ? 'distribution 연결' : 'distribution 비어있음'} - ` + meta.innerHTML = describeProfileFeatures(profile) + .map((label) => `${label}`) + .join('') const description = document.createElement('div') description.className = 'profileListDescription' @@ -146,9 +159,18 @@ function renderSidebar(){ } } -function syncKindSections(kind){ - mapSection.hidden = kind !== 'map' - serverPackSection.hidden = kind !== 'server-pack' +function syncFeatureDependencies(profile, showMessage = false){ + if(profile.pluginsEnabled && !profile.serverEnabled){ + profile.serverEnabled = true + if(showMessage){ + showStatus('플러그인 사용을 켜면 서버 사용도 자동으로 같이 켜집니다.', 'info') + } + } +} + +function syncServerSection(profile){ + serverSection.hidden = !profile?.serverEnabled + fieldElements.serverEnabled.disabled = profile?.pluginsEnabled === true } function updateDistributionEditorHint(profile, pathOverride){ @@ -183,29 +205,32 @@ function populateEditor(){ if(!profile){ editorHint.textContent = '왼쪽에서 프로필을 선택하거나 새 프로필을 추가하세요.' + syncServerSection(null) updateDistributionEditorHint(null) return } - editorHint.textContent = `${profile.kind} 프로필을 편집 중입니다. 저장하면 런처 카탈로그도 같이 갱신됩니다.` + syncFeatureDependencies(profile) + + editorHint.textContent = '맵은 기본이고, 모드/플러그인/서버를 체크해서 조합하는 프로필입니다. 저장하면 런처 카탈로그도 같이 갱신됩니다.' fieldElements.id.value = profile.id ?? '' - fieldElements.kind.value = profile.kind ?? 'modpack' fieldElements.name.value = profile.name ?? '' fieldElements.description.value = profile.description ?? '' fieldElements.details.value = profile.details ?? '' + fieldElements.modsEnabled.checked = profile.modsEnabled === true + fieldElements.pluginsEnabled.checked = profile.pluginsEnabled === true + fieldElements.serverEnabled.checked = profile.serverEnabled === true fieldElements.distributionUrl.value = profile.distributionUrl ?? '' fieldElements.worldArchiveUrl.value = profile.worldArchiveUrl ?? '' fieldElements.worldDirectoryName.value = profile.worldDirectoryName ?? '' - fieldElements.serverBundleUrl.value = profile.serverBundleUrl ?? '' - fieldElements.serverDirectoryName.value = profile.serverDirectoryName ?? '' - fieldElements.serverLaunchCommand.value = profile.serverLaunchCommand ?? '' - fieldElements.serverWorkingDirectory.value = profile.serverWorkingDirectory ?? '' + fieldElements.serverJarUrl.value = profile.serverJarUrl ?? '' fieldElements.serverPort.value = profile.serverPort ?? 25565 - fieldElements.tunnelCommand.value = profile.tunnelCommand ?? '' - fieldElements.tunnelAddressRegex.value = profile.tunnelAddressRegex ?? '' + fieldElements.serverMemoryMb.value = profile.serverMemoryMb ?? 4096 + fieldElements.serverMaxPlayers.value = profile.serverMaxPlayers ?? 20 + fieldElements.serverWhitelistEnabled.checked = profile.serverWhitelistEnabled === true - syncKindSections(profile.kind) + syncServerSection(profile) updateDistributionEditorHint(profile) } @@ -216,8 +241,10 @@ function updateSelectedProfile(patch){ } Object.assign(profile, patch) + syncFeatureDependencies(profile) markDirty(true) renderSidebar() + syncServerSection(profile) if(Object.prototype.hasOwnProperty.call(patch, 'distributionUrl')){ updateDistributionEditorHint(profile, patch.distributionUrl) @@ -248,15 +275,27 @@ function bindTextField(fieldName){ }) } -function bindNumberField(fieldName){ +function bindNumberField(fieldName, fallback){ fieldElements[fieldName].addEventListener('input', (event) => { - const value = Number.parseInt(event.target.value || '25565', 10) + const value = Number.parseInt(event.target.value || String(fallback), 10) updateSelectedProfile({ - [fieldName]: Number.isFinite(value) ? value : 25565 + [fieldName]: Number.isFinite(value) ? value : fallback }) }) } +function bindCheckboxField(fieldName){ + fieldElements[fieldName].addEventListener('change', (event) => { + updateSelectedProfile({ + [fieldName]: event.target.checked + }) + + if(fieldName === 'pluginsEnabled' || fieldName === 'serverEnabled'){ + populateEditor() + } + }) +} + function bindProfileForm(){ profileEditorForm.addEventListener('submit', (event) => { event.preventDefault() @@ -269,30 +308,14 @@ function bindProfileForm(){ bindTextField('distributionUrl') bindTextField('worldArchiveUrl') bindTextField('worldDirectoryName') - bindTextField('serverBundleUrl') - bindTextField('serverDirectoryName') - bindTextField('serverLaunchCommand') - bindTextField('serverWorkingDirectory') - bindNumberField('serverPort') - bindTextField('tunnelCommand') - bindTextField('tunnelAddressRegex') - - fieldElements.kind.addEventListener('change', (event) => { - const profile = getSelectedProfile() - if(!profile){ - return - } - - profile.kind = event.target.value - - if(profile.kind === 'server-pack' && !profile.serverDirectoryName){ - profile.serverDirectoryName = `${slugify(profile.id || profile.name) || 'profile'}-server` - } - - markDirty(true) - renderSidebar() - populateEditor() - }) + bindTextField('serverJarUrl') + bindNumberField('serverPort', 25565) + bindNumberField('serverMemoryMb', 4096) + bindNumberField('serverMaxPlayers', 20) + bindCheckboxField('modsEnabled') + bindCheckboxField('pluginsEnabled') + bindCheckboxField('serverEnabled') + bindCheckboxField('serverWhitelistEnabled') for(const button of document.querySelectorAll('.uploadButton')){ button.addEventListener('click', async () => { @@ -507,12 +530,23 @@ async function loadMeta(){ localCatalogUrl.href = meta.localCatalogUrl } +function normalizeLoadedProfile(profile){ + return { + ...createProfile(), + ...profile, + modsEnabled: profile.modsEnabled === true, + pluginsEnabled: profile.pluginsEnabled === true, + serverEnabled: profile.serverEnabled === true || profile.pluginsEnabled === true, + serverWhitelistEnabled: profile.serverWhitelistEnabled === true + } +} + async function loadCatalog(){ const response = await fetch('/api/catalog') const catalog = await response.json() state.catalog = { version: 1, - profiles: Array.isArray(catalog.profiles) ? catalog.profiles : [] + profiles: Array.isArray(catalog.profiles) ? catalog.profiles.map(normalizeLoadedProfile) : [] } if(state.catalog.profiles.length > 0){ @@ -526,8 +560,8 @@ async function loadCatalog(){ populateEditor() } -function addProfile(kind){ - const profile = createProfile(kind) +function addProfile(){ + const profile = createProfile() state.catalog.profiles.push(profile) markDirty(true) selectProfile(profile.id) @@ -587,7 +621,10 @@ async function saveCatalog(){ throw new Error(result.message || '카탈로그 저장에 실패했습니다.') } - state.catalog = result.catalog + state.catalog = { + version: 1, + profiles: result.catalog.profiles.map(normalizeLoadedProfile) + } if(!state.catalog.profiles.some((profile) => profile.id === state.selectedProfileId)){ state.selectedProfileId = state.catalog.profiles[0]?.id ?? null } @@ -605,11 +642,9 @@ async function saveCatalog(){ } function bindTopLevelActions(){ - for(const button of document.querySelectorAll('[data-add-kind]')){ - button.addEventListener('click', () => { - addProfile(button.dataset.addKind) - }) - } + addProfileButton.addEventListener('click', () => { + addProfile() + }) saveCatalogButton.addEventListener('click', async () => { await saveCatalog() diff --git a/admin/public/index.html b/admin/public/index.html index 46b5299..394c6ed 100644 --- a/admin/public/index.html +++ b/admin/public/index.html @@ -12,7 +12,7 @@
Launcher Admin

프로필 관리

-

설치 페이지에 표시할 실제 프로필을 UI로 관리합니다.

+

설치 페이지에 표시할 실제 프로필을 조합형 UI로 관리합니다.

@@ -27,9 +27,7 @@
- - - +
@@ -57,7 +55,7 @@

프로필이 없습니다

-

왼쪽 버튼으로 새 모드팩, 맵, 서버팩 프로필을 추가하세요.

+

왼쪽 버튼으로 새 프로필을 추가하세요. 맵은 기본이고, 모드/플러그인/서버는 체크해서 조합합니다.

+
+
+

구성 옵션

+
+
+ + + +
+ 맵은 모든 프로필의 기본입니다. 플러그인을 켜면 서버 사용도 자동으로 같이 켜집니다. +
+
+
+

클라이언트 배포

@@ -113,7 +126,7 @@
-