Merge pull request #19 from Corfirean/codex/release-all-pending

Release client-only connections and scaling controls in Manager 0.6.3
This commit is contained in:
Corfirean authored and GitHub committed 2026-10-05 03:26:16 +05:00
commit 07ec22798f
28 files changed
+478 -59

No files matched your search

Generated
+3 -3
View File
@@ -491,7 +491,7 @@ dependencies = [
[[package]]
name = "coa-core"
version = "0.6.2"
version = "0.6.3"
dependencies = [
"base64 0.22.1",
"chrono",
@@ -519,7 +519,7 @@ dependencies = [
[[package]]
name = "coa-release"
version = "0.6.2"
version = "0.6.3"
dependencies = [
"chrono",
"coa-core",
@@ -528,7 +528,7 @@ dependencies = [
[[package]]
name = "coa-server-manager"
version = "0.6.2"
version = "0.6.3"
dependencies = [
"coa-core",
"serde",
+1 -1
View File
@@ -3,7 +3,7 @@ resolver = "2"
members = ["crates/coa-core", "crates/coa-release", "src-tauri"]
[workspace.package]
version = "0.6.2"
version = "0.6.3"
edition = "2021"
license = "AGPL-3.0-only"
@@ -0,0 +1,7 @@
---
area: manager
type: added
audience: admins
title: Configure partial dungeon parties and open-world life steal in module settings
---
Choose whether Dungeon Finder enters with the current party and enable open-world life steal with a healing percentage from 0 to 100. Available on server builds that support these options.
@@ -0,0 +1,7 @@
---
area: manager
type: added
audience: players
title: Download and update the game client without installing a server
---
Choose to host a server or connect to someone else's. Players joining a friend can download, update and launch the client, save the host's address and use the Manager without installing a local server.
+1 -1
View File
@@ -180,7 +180,7 @@ fn backup_dir(meta: &Path) -> PathBuf {
meta.join("backups").join("client")
}
fn host_ok(h: &str) -> bool {
pub fn host_ok(h: &str) -> bool {
!h.is_empty() && h.len() <= 253 && h.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | ':')) && !h.starts_with('-')
}
+1
View File
@@ -3,6 +3,7 @@ pub mod backup;
pub mod cleanbase;
pub mod companions;
pub mod client;
pub mod remote_client;
pub mod clientdl;
pub mod config;
pub mod console;
+42
View File
@@ -305,6 +305,15 @@ fn save_settings_in(cat: &[Entry], root: &Path, meta: &Path, id: &str, changes:
return Err(Error::Invalid("Enemy damage must be a number from 0.25 to 2.0.".into()));
}
}
if id == "content-scaling" && key == "CoAContentScaling.World.Leech.Percent" {
let percent = value.parse::<f32>().map_err(|_| Error::Invalid("Life steal must be a number from 0 to 100 percent.".into()))?;
if !percent.is_finite() || !(0.0..=100.0).contains(&percent) {
return Err(Error::Invalid("Life steal must be a number from 0 to 100 percent.".into()));
}
}
if id == "content-scaling" && matches!(key.as_str(), "CoAContentScaling.LFG.AllowPartialGroups" | "CoAContentScaling.World.Leech.Enable") && !matches!(value, "0" | "1") {
return Err(Error::Invalid(format!("{key} must be 0 or 1.")));
}
if id == "ah-bot" && key.starts_with("AuctionHouseBot.ListProportion.Category") && key.contains(".Quality") {
let weight = value.parse::<u32>().map_err(|_| Error::Invalid("Auction listing weights must be whole numbers from 0 to 1000.".into()))?;
if weight > 1000 {
@@ -527,6 +536,39 @@ mod tests {
assert_eq!(settings_in(&cat, &root, "ah-bot").unwrap().iter().find(|s| s.key == key).unwrap().value, "0");
}
#[test]
fn partial_lfg_and_world_leech_settings_round_trip_and_reject_invalid_values() {
let d = tempfile::tempdir().unwrap();
let (root, meta) = server(d.path());
let cat = catalog();
let conf = dir(&root).join("mod-coa-content-scaling.conf.dist");
let partial = "CoAContentScaling.LFG.AllowPartialGroups";
let enable = "CoAContentScaling.World.Leech.Enable";
let percent = "CoAContentScaling.World.Leech.Percent";
let change = |k: &str, v: &str| BTreeMap::from([(k.into(), v.into())]);
fs::write(&conf, "CoAContentScaling.Enable = 1\n").unwrap();
for key in [partial, enable, percent] {
assert!(save_settings_in(&cat, &root, &meta, "content-scaling", &change(key, "1")).is_err());
}
fs::write(&conf, format!("CoAContentScaling.Enable = 1\n{partial} = 1\n{enable} = 0\n{percent} = 5.0\n")).unwrap();
for bad in ["NaN", "inf", "-0.1", "100.1", "abc"] {
assert!(save_settings_in(&cat, &root, &meta, "content-scaling", &change(percent, bad)).is_err());
}
for key in [partial, enable] {
for bad in ["2", "-1", "true", "abc"] {
assert!(save_settings_in(&cat, &root, &meta, "content-scaling", &change(key, bad)).is_err());
}
for good in ["0", "1"] {
save_settings_in(&cat, &root, &meta, "content-scaling", &change(key, good)).unwrap();
}
}
for good in ["0", "10.5", "100"] {
save_settings_in(&cat, &root, &meta, "content-scaling", &change(percent, good)).unwrap();
let settings = settings_in(&cat, &root, "content-scaling").unwrap();
assert_eq!(settings.iter().find(|s| s.key == percent).unwrap().value, good);
}
}
#[test]
fn damage_difficulty_is_validated_and_older_server_builds_do_not_accept_it() {
let d = tempfile::tempdir().unwrap();
+64
View File
@@ -0,0 +1,64 @@
//! A player's client connection, independent of any local server installation.
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::{client, fsx, Error, Result};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Profile {
pub host: String,
pub client_path: Option<String>,
}
pub fn load(dir: &Path) -> Result<Profile> {
match std::fs::read(dir.join("connection.json")) {
Ok(bytes) => Ok(serde_json::from_slice(&bytes)?),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Profile::default()),
Err(e) => Err(e.into()),
}
}
pub fn save(dir: &Path, profile: &Profile) -> Result<()> {
if !profile.host.is_empty() && !client::host_ok(&profile.host) {
return Err(Error::Invalid("Enter a server IP address or hostname, without a URL or port.".into()));
}
if let Some(path) = &profile.client_path {
let client = PathBuf::from(path);
if client::detect(&client, None).is_none() {
return Err(Error::Invalid("This folder does not look like a game client (it needs Data and the game executable).".into()));
}
if client::is_running(&client) {
return Err(Error::Invalid("Close the game before changing its connection.".into()));
}
if !profile.host.is_empty() { client::set_realmlist(&client, dir, &profile.host)?; }
}
fsx::atomic_write_json(&dir.join("connection.json"), profile)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn connection_needs_no_server_and_survives_restart() {
let dir = tempfile::tempdir().unwrap();
let profile = Profile { host: "192.168.1.10".into(), client_path: None };
save(dir.path(), &profile).unwrap();
assert_eq!(load(dir.path()).unwrap().host, profile.host);
assert!(!dir.path().join("install.json").exists());
}
#[test]
fn linking_client_sets_host_without_touching_other_game_settings() {
let dir = tempfile::tempdir().unwrap();
let game = dir.path().join("game");
std::fs::create_dir_all(game.join("Data/enUS")).unwrap();
std::fs::write(game.join("Wow.exe"), b"fixture").unwrap();
std::fs::write(game.join("Data/enUS/realmlist.wtf"), "set realmlist 127.0.0.1\n").unwrap();
std::fs::create_dir_all(game.join("WTF")).unwrap();
std::fs::write(game.join("WTF/Config.wtf"), "SET realmName \"My realm\"\n").unwrap();
let profile = Profile { host: "192.168.1.10".into(), client_path: Some(game.to_string_lossy().into()) };
save(dir.path(), &profile).unwrap();
assert!(std::fs::read_to_string(game.join("Data/enUS/realmlist.wtf")).unwrap().contains(&profile.host));
assert_eq!(std::fs::read_to_string(game.join("WTF/Config.wtf")).unwrap(), "SET realmName \"My realm\"\n");
assert!(save(dir.path(), &Profile { host: "bad\nset realmlist other".into(), ..profile }).is_err());
assert_eq!(load(dir.path()).unwrap().host, "192.168.1.10");
}
}
+6 -1
View File
@@ -578,6 +578,11 @@ The reference install classifies as **Healthy (customised)**: repack shape, bina
* **UI:** PLAY becomes **UPDATE CLIENT** when a tracked client is outdated (with "Play without updating"); with no client linked it
becomes **Set up game client** (choose an existing folder, or download into `<chosen folder>/CoA Client` with a free-space check).
An existing client that is not tracked can be adopted from Settings > Game client ("Keep this client up to date").
A fresh download gets `realmlist.wtf` for each installed locale (the manifest has none) and the companion addon.
A fresh download gets `realmlist.wtf` for each installed locale (the manifest has none) and, for local hosts, the companion addon.
* **Joining another server:** the welcome screen offers hosting or joining. Joining uses a separate player profile in
the Manager's `remote-client/connection.json`, storing the host address and client folder without a server registry entry,
installation metadata, database or server processes. Only Overview and application Settings appear in the sidebar.
Downloading, adopting and updating clients use the same verified downloader; Play applies the saved realmlist without
forcing a local realm selection. The player can return to hosting without removing their saved connection.
* **Open points:** ask the launcher maintainers before putting heavy load on their API; DBCs live inside the MPQ patch archives
(extracting them would need an MPQ reader), so the server's `Data\dbc` still comes from our `base` package for now.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "coa-server-manager",
"version": "0.6.2",
"version": "0.6.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "coa-server-manager",
"version": "0.6.2",
"version": "0.6.3",
"dependencies": {
"@tauri-apps/api": "^2.12.0",
"@tauri-apps/plugin-dialog": "^2.8.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "coa-server-manager",
"private": true,
"version": "0.6.2",
"version": "0.6.3",
"license": "AGPL-3.0-only",
"type": "module",
"scripts": {
+99 -31
View File
@@ -929,6 +929,68 @@ async fn add_companions(state: State<'_, AppState>, id: String, count: u32) -> s
.await
}
const REMOTE_CLIENT_ID: &str = "@remote-client";
fn remote_dir() -> PathBuf { data_dir().join("remote-client") }
#[tauri::command]
fn remote_connection() -> std::result::Result<coa_core::remote_client::Profile, UiError> {
Ok(coa_core::remote_client::load(&remote_dir())?)
}
#[tauri::command]
fn remote_connect(state: State<'_, AppState>, host: String) -> std::result::Result<coa_core::remote_client::Profile, UiError> {
let job = state.client_cancel.lock().unwrap();
if job.is_some() { return Err(Error::Invalid("Wait for the client operation to finish.".into()).into()); }
let dir = remote_dir();
let mut profile = coa_core::remote_client::load(&dir)?;
let host = host.trim();
if host.is_empty() { return Err(Error::Invalid("Enter the host's IP address or hostname.".into()).into()); }
profile.host = host.into();
coa_core::remote_client::save(&dir, &profile)?;
Ok(profile)
}
struct ClientContext {
dir: PathBuf,
path: Option<PathBuf>,
source: Option<PathBuf>,
host: String,
root: Option<PathBuf>,
}
fn client_context(state: &AppState, id: &str) -> Result<ClientContext> {
if id == REMOTE_CLIENT_ID {
let dir = remote_dir();
let profile = coa_core::remote_client::load(&dir)?;
return Ok(ClientContext { dir, path: profile.client_path.map(PathBuf::from), source: None, host: profile.host, root: None });
}
let root = path_of(state, id)?;
let (dir, meta) = install_meta(&root)?;
let source = coa_core::client::addon_source(&root);
Ok(ClientContext { dir, path: meta.client_path.map(PathBuf::from), source, host: "127.0.0.1".into(), root: Some(root) })
}
impl ClientContext {
fn linked(&self) -> Option<PathBuf> {
self.path.clone().filter(|p| coa_core::client::detect(p, None).is_some())
}
fn require(&self) -> Result<PathBuf> {
self.linked().ok_or_else(|| Error::Invalid("Set up a game client first.".into()))
}
fn link(&self, path: &str) -> Result<()> {
if let Some(root) = &self.root {
let (_, mut meta) = install_meta(root)?;
meta.client_path = Some(path.into());
coa_core::fsx::atomic_write_json(&self.dir.join("install.json"), &meta)
} else {
let mut profile = coa_core::remote_client::load(&self.dir)?;
profile.client_path = Some(path.into());
coa_core::remote_client::save(&self.dir, &profile)
}
}
}
fn client_of(root: &std::path::Path) -> Result<(PathBuf, InstallMeta, Option<PathBuf>)> {
let (dir, meta) = install_meta(root)?;
let client = meta.client_path.clone().map(PathBuf::from).ok_or_else(|| Error::Invalid("No game client is set up for this server yet.".into()))?;
@@ -937,22 +999,19 @@ fn client_of(root: &std::path::Path) -> Result<(PathBuf, InstallMeta, Option<Pat
#[tauri::command]
fn client_info(state: State<'_, AppState>, id: String) -> std::result::Result<Option<coa_core::client::ClientInfo>, UiError> {
let root = path_of(&state, &id)?;
let (_, meta) = install_meta(&root)?;
let source = coa_core::client::addon_source(&root);
Ok(meta.client_path.and_then(|p| coa_core::client::detect(std::path::Path::new(&p), source.as_deref())))
let ctx = client_context(&state, &id)?;
Ok(ctx.path.and_then(|p| coa_core::client::detect(&p, ctx.source.as_deref())))
}
#[tauri::command]
fn set_client(state: State<'_, AppState>, id: String, path: String) -> std::result::Result<coa_core::client::ClientInfo, UiError> {
let root = path_of(&state, &id)?;
let (dir, mut meta) = install_meta(&root)?;
let source = coa_core::client::addon_source(&root);
let info = coa_core::client::detect(std::path::Path::new(&path), source.as_deref())
let job = state.client_cancel.lock().unwrap();
if job.is_some() { return Err(Error::Invalid("Wait for the client operation to finish.".into()).into()); }
let ctx = client_context(&state, &id)?;
let info = coa_core::client::detect(std::path::Path::new(&path), ctx.source.as_deref())
.ok_or_else(|| Error::Invalid("This folder does not look like a game client (it needs Data and the game executable).".into()))?;
meta.client_path = Some(info.path.clone());
coa_core::fsx::atomic_write_json(&dir.join("install.json"), &meta).map_err(UiError::from)?;
Ok(info)
ctx.link(&info.path)?;
Ok(coa_core::client::detect(std::path::Path::new(&info.path), ctx.source.as_deref()).unwrap_or(info))
}
#[tauri::command]
@@ -991,9 +1050,9 @@ fn linked_client(root: &std::path::Path) -> Result<Option<PathBuf>> {
/// Cheap: one small manifest request and the local state file; no game file is read.
#[tauri::command]
async fn client_status(state: State<'_, AppState>, id: String) -> std::result::Result<ClientStatus, UiError> {
let root = path_of(&state, &id)?;
let ctx = client_context(&state, &id)?;
blocking(move || {
let client = linked_client(&root)?;
let client = ctx.linked();
let latest = coa_core::clientdl::fetch_latest().ok();
let local = client.as_deref().map(coa_core::clientdl::local);
let managed = local.as_ref().map(|l| l.managed).unwrap_or(false);
@@ -1040,10 +1099,10 @@ async fn client_download_check(parent: String) -> std::result::Result<ClientDown
/// Compare the linked client with the published one. Hashes files only where the recorded state cannot vouch for them.
#[tauri::command]
async fn client_plan(app: AppHandle, state: State<'_, AppState>, id: String) -> std::result::Result<coa_core::clientdl::Plan, UiError> {
let root = path_of(&state, &id)?;
let ctx = client_context(&state, &id)?;
let cancel = begin_client_job(&state)?;
let result = blocking(move || {
let client = linked_client(&root)?.ok_or_else(|| Error::Invalid("No game client is set up for this server yet.".into()))?;
let client = ctx.require()?;
let manifest = coa_core::clientdl::fetch_latest()?;
let current = coa_core::clientdl::load_state(&client).unwrap_or_default();
coa_core::clientdl::plan(&client, &manifest, &current, &cancel, &|s| {
@@ -1058,11 +1117,13 @@ async fn client_plan(app: AppHandle, state: State<'_, AppState>, id: String) ->
/// Bring the linked client to the published version. `keep_modified` leaves files the player changed alone.
#[tauri::command]
async fn client_sync(app: AppHandle, state: State<'_, AppState>, id: String, keep_modified: bool) -> std::result::Result<(), UiError> {
let root = path_of(&state, &id)?;
let ctx = client_context(&state, &id)?;
let cancel = begin_client_job(&state)?;
let result = blocking(move || {
let client = linked_client(&root)?.ok_or_else(|| Error::Invalid("No game client is set up for this server yet.".into()))?;
run_client_sync(&app, &client, keep_modified, &cancel)
let client = ctx.require()?;
run_client_sync(&app, &client, keep_modified, &cancel)?;
if ctx.root.is_none() && !ctx.host.is_empty() { coa_core::client::set_realmlist(&client, &ctx.dir, &ctx.host)?; }
Ok(())
})
.await;
end_client_job(&state);
@@ -1086,13 +1147,12 @@ fn run_client_sync(app: &AppHandle, client: &std::path::Path, keep_modified: boo
Ok(())
}
/// Download the whole client into `<parent>/CoA Client`, then link it to this server.
/// Download the client into `<parent>/CoA Client`, then link it to a host or player profile.
#[tauri::command]
async fn client_download(app: AppHandle, state: State<'_, AppState>, id: String, parent: String) -> std::result::Result<coa_core::client::ClientInfo, UiError> {
let root = path_of(&state, &id)?;
let ctx = client_context(&state, &id)?;
let cancel = begin_client_job(&state)?;
let result = blocking(move || {
let (dir, mut meta) = install_meta(&root)?;
let parent = PathBuf::from(&parent);
if !parent.is_dir() {
return Err(Error::Invalid("Choose an existing folder to put the game client in.".into()));
@@ -1105,16 +1165,11 @@ async fn client_download(app: AppHandle, state: State<'_, AppState>, id: String,
}
std::fs::create_dir_all(&dest)?;
run_client_sync(&app, &dest, false, &cancel)?;
let source = coa_core::client::addon_source(&root);
let info = coa_core::client::detect(&dest, source.as_deref()).ok_or_else(|| Error::Invalid("The downloaded client looks incomplete.".into()))?;
meta.client_path = Some(info.path.clone());
coa_core::fsx::atomic_write_json(&dir.join("install.json"), &meta)?;
// a fresh client has no realmlist; this server is on this computer
coa_core::client::set_realmlist(&dest, &dir, "127.0.0.1")?;
if let Some(src) = source {
coa_core::client::install_addon(&dest, &dir, &src)?;
}
Ok(coa_core::client::detect(&dest, coa_core::client::addon_source(&root).as_deref()).unwrap_or(info))
let info = coa_core::client::detect(&dest, ctx.source.as_deref()).ok_or_else(|| Error::Invalid("The downloaded client looks incomplete.".into()))?;
ctx.link(&info.path)?;
if !ctx.host.is_empty() { coa_core::client::set_realmlist(&dest, &ctx.dir, &ctx.host)?; }
if let Some(src) = &ctx.source { coa_core::client::install_addon(&dest, &ctx.dir, src)?; }
Ok(coa_core::client::detect(&dest, ctx.source.as_deref()).unwrap_or(info))
})
.await;
end_client_job(&state);
@@ -1149,6 +1204,17 @@ fn end_client_job(state: &AppState) {
/// Launch the game client independently of the local server.
#[tauri::command]
async fn play(state: State<'_, AppState>, id: String) -> std::result::Result<DriverOutcome, UiError> {
if id == REMOTE_CLIENT_ID {
let ctx = client_context(&state, &id)?;
let client = ctx.require()?;
return blocking(move || {
if ctx.host.is_empty() { return Err(Error::Invalid("Enter the host's IP address or hostname first.".into())); }
if coa_core::client::is_running(&client) { return Err(Error::Invalid("The game is already running.".into())); }
coa_core::client::set_realmlist(&client, &ctx.dir, &ctx.host)?;
coa_core::client::launch(&client)?;
Ok(DriverOutcome { ok: true, exit_code: None, code: None, human: None, output: String::new() })
}).await;
}
let root = path_of(&state, &id)?;
let (dir, _, client) = client_of(&root)?;
let client = client.unwrap();
@@ -1501,6 +1567,8 @@ pub fn run() {
open_link,
companion_sizes,
add_companions,
remote_connection,
remote_connect,
client_info,
set_client,
client_realmlist,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "CoA Server Manager",
"version": "0.6.2",
"version": "0.6.3",
"identifier": "com.coa.servermanager",
"build": {
"beforeDevCommand": "npm run dev",
+17 -2
View File
@@ -4,14 +4,20 @@ import { Welcome } from "@/screens/Welcome";
import { ImportServer } from "@/screens/ImportServer";
import { Shell } from "@/screens/Shell";
import { InstallServer } from "@/screens/InstallServer";
import { RemoteClient } from "@/screens/RemoteClient";
import { UpdateBanner } from "@/components/UpdateBanner";
import { autoUpdateEnabled, installOnClose, lookForUpdate } from "@/lib/selfUpdate";
type View = "welcome" | "add" | "import" | "install";
type View = "welcome" | "add" | "import" | "install" | "remote";
function initialView(): View {
try { return localStorage.getItem("coa-play-mode") === "remote" ? "remote" : "welcome"; }
catch { return "welcome"; }
}
function Main() {
const [servers, setServers] = useState<ServerSummary[] | null>(null);
const [view, setView] = useState<View>("welcome");
const [view, setView] = useState<View>(initialView);
const [activeId, setActiveId] = useState<string | null>(null);
const refresh = useCallback(async () => {
@@ -26,6 +32,11 @@ function Main() {
if (servers === null) return null;
if (view === "remote") return <RemoteClient onHost={() => {
try { localStorage.setItem("coa-play-mode", "host"); } catch { /* storage unavailable */ }
setView(servers.length ? "welcome" : "add");
}} />;
if (servers.length > 0 && view === "welcome" && activeId) {
return (
<Shell
@@ -71,6 +82,10 @@ function Main() {
return (
<Welcome
onConnect={() => {
try { localStorage.setItem("coa-play-mode", "remote"); } catch { /* storage unavailable */ }
setView("remote");
}}
onImport={() => setView("import")}
onInstall={() => setView("install")}
onBack={servers.length > 0 ? () => setView("welcome") : undefined}
+6 -2
View File
@@ -10,6 +10,7 @@ export {};
let added = new URLSearchParams(location.search).has("demo"), running = false;
let realm: "coa" | "wildcard" = "coa", wildcardCreated = false;
let simultaneous = false;
let remoteHost = localStorage.getItem("demo-remote-host") ?? "";
const moduleEdits: Record<string, Record<string, string>> = {};
const modState: Record<string, boolean> = { "companions": true, "content-scaling": true, "client-compat": true, "tbc-content": true, "wotlk-content": true, "npc-enchanter": true };
const report = {path:"C:\\games\\CoA-Repack",classification:"healthy",items:[
@@ -30,6 +31,9 @@ export {};
if(cmd==="check_database") return [{realm, migrations:[],problems:[],full_schema:false}];
if(cmd==="repair_server") { if(running) throw new Error("Stop the server before repairing it."); return {restored:[],applied:[],backup:"demo-before-repair",database:[{realm,migrations:[],problems:[],full_schema:false}],error:null}; }
if(cmd==="realm_select"){await new Promise(r=>setTimeout(r,600));realm=args.mode;wildcardCreated ||= realm==="wildcard";modState.companions=realm==="coa";return {active:realm,wildcard_created:wildcardCreated,supported:true,recovery_pending:false,simultaneous,secondary_world_port:simultaneous?8086:null,secondary_running:simultaneous&&running};}
if(cmd==="remote_connection") return {host:remoteHost,client_path:null};
if(cmd==="remote_connect") { remoteHost=args.host.trim(); localStorage.setItem("demo-remote-host",remoteHost); return {host:remoteHost,client_path:null}; }
if(cmd==="play") return {ok:true,exit_code:null,code:null,human:null,output:""};
if(cmd==="list_servers") return added?[{id:"1",name:"CoA-Repack",path:report.path}]:[];
if(cmd==="scan_server") return report;
if(cmd==="add_server"){added=true;return {id:"1",name:"CoA-Repack",path:report.path};}
@@ -75,7 +79,7 @@ export {};
"npc-enchanter":[{key:"Enchanter.Enable",value:"1",default:"1",doc:"Enable the module."},{key:"Enchanter.Announce",value:"0",default:"0",doc:"Announce the module when the player logs in."},{key:"Enchanter.CostCopper",value:"0",default:"0",doc:"What one enchantment costs, in copper (10000 = 1 gold). 0 = free."},{key:"Enchanter.MessageTimer",value:"60000",default:"60000",doc:"How often the NPC emotes, in milliseconds."}],
"playerbots":[{"key": "AiPlayerbot.RandomBotAutologin", "value": "1", "default": "1", "doc": ""}, {"key": "AiPlayerbot.MinRandomBots", "value": "500", "default": "500", "doc": ""}, {"key": "AiPlayerbot.MaxRandomBots", "value": "500", "default": "500", "doc": ""}, {"key": "AiPlayerbot.RandomBotAccountCount", "value": "0", "default": "0", "doc": ""}, {"key": "AiPlayerbot.RandomBotMinLevel", "value": "1", "default": "1", "doc": ""}, {"key": "AiPlayerbot.RandomBotMaxLevel", "value": "60", "default": "60", "doc": ""}, {"key": "AiPlayerbot.SyncLevelWithPlayers", "value": "0", "default": "0", "doc": ""}, {"key": "AiPlayerbot.RandomBotInvitePlayer", "value": "0", "default": "0", "doc": ""}, {"key": "AiPlayerbot.RandomBotJoinLfg", "value": "1", "default": "1", "doc": ""}, {"key": "AiPlayerbot.RandomBotJoinBG", "value": "1", "default": "1", "doc": ""}, {"key": "AiPlayerbot.RandomBotAutoJoinBG", "value": "0", "default": "0", "doc": ""}, {"key": "AiPlayerbot.RandomBotTalk", "value": "1", "default": "1", "doc": ""}, {"key": "AiPlayerbot.RandomBotEmote", "value": "0", "default": "0", "doc": ""}, {"key": "AiPlayerbot.RandomBotSuggestDungeons", "value": "1", "default": "1", "doc": ""}, {"key": "AiPlayerbot.CoaClassesOnly", "value": "1", "default": "1", "doc": ""}, {"key": "AiPlayerbot.CoaSmartHeal", "value": "1", "default": "1", "doc": ""}, {"key": "AiPlayerbot.CoaSmartTank", "value": "1", "default": "1", "doc": ""}, {"key": "AiPlayerbot.CoaSpecRotations", "value": "1", "default": "1", "doc": ""}, {"key": "AiPlayerbot.CoaInterruptCoordination", "value": "1", "default": "1", "doc": ""}, {"key": "AiPlayerbot.MaxAddedBots", "value": "40", "default": "40", "doc": ""}, {"key": "AiPlayerbot.BotAutologin", "value": "0", "default": "0", "doc": ""}, {"key": "AiPlayerbot.DisabledWithoutRealPlayer", "value": "0", "default": "0", "doc": ""}, {"key": "AiPlayerbot.AddClassCommand", "value": "1", "default": "1", "doc": ""}, {"key": "AiPlayerbot.KeepAltsInGroup", "value": "0", "default": "0", "doc": ""}, {"key": "AiPlayerbot.CoaRecruitSameFaction", "value": "1", "default": "1", "doc": ""}, {"key": "AiPlayerbot.CoaLfgBots", "value": "1", "default": "1", "doc": ""}, {"key": "AiPlayerbot.RandomBotXPRate", "value": "1.0", "default": "1.0", "doc": ""}, {"key": "AiPlayerbot.FollowDistance", "value": "1.5", "default": "1.5", "doc": ""}, {"key": "AiPlayerbot.Enabled", "value": "0", "default": "0", "doc": ""}, {"key": "AiPlayerbot.RandomBotAccountPrefix", "value": "\"rndbot\"", "default": "\"rndbot\"", "doc": ""}, {"key": "AiPlayerbot.RandomBotUpdateInterval", "value": "20", "default": "20", "doc": ""}],
"companions":[{key:"CoaBots.Enable",value:"1",default:"1",doc:"Master switch of the companion bots."},{key:"CoaBots.RandomSpawn.DefaultCount",value:"10",default:"10",doc:""}],
"content-scaling":[{key:"CoAContentScaling.Enable",value:"1",default:"1",doc:"Scale dungeons and raids to the group."}, ...[{key:"CoAContentScaling.Difficulty.DamageMultiplier",value:"1.0"},{key:"CoAContentScaling.SoloAssist.Mode",value:"1"},{key:"CoAContentScaling.GroupScaling.Enable",value:"1"},{key:"CoAContentScaling.LFG.DefaultMode",value:"CurrentParty"},{key:"CoAContentScaling.LFG.DefaultChallengeSize",value:"0"},{key:"CoAContentScaling.Debug",value:"0"}].map(s=>({...s,default:s.value,doc:""}))],
"content-scaling":[{key:"CoAContentScaling.Enable",value:"1",default:"1",doc:"Scale dungeons and raids to the group."}, ...[{key:"CoAContentScaling.LFG.AllowPartialGroups",value:"1"},{key:"CoAContentScaling.World.Leech.Enable",value:"0"},{key:"CoAContentScaling.World.Leech.Percent",value:"5.0"},{key:"CoAContentScaling.Difficulty.DamageMultiplier",value:"1.0"},{key:"CoAContentScaling.SoloAssist.Mode",value:"1"},{key:"CoAContentScaling.GroupScaling.Enable",value:"1"},{key:"CoAContentScaling.LFG.DefaultMode",value:"CurrentParty"},{key:"CoAContentScaling.LFG.DefaultChallengeSize",value:"0"},{key:"CoAContentScaling.Debug",value:"0"}].map(s=>({...s,default:s.value,doc:""}))],
"ah-bot":[...Object.keys(auctionCategories).flatMap(category => Object.keys(auctionQualities).map(quality => ({key:`AuctionHouseBot.ListProportion.Category${category}.Quality${quality}`,value:String(quality === "Normal" && !["Quest","Key","Generic","Reagent"].includes(category) ? 50 : quality === "Rare" && ["Armor","Weapon"].includes(category) ? 5 : 0),default:String(quality === "Normal" && !["Quest","Key","Generic","Reagent"].includes(category) ? 50 : quality === "Rare" && ["Armor","Weapon"].includes(category) ? 5 : 0),doc:""}))), {key:"AuctionHouseBot.Enable",value:"false",default:"false",doc:""}, ...[{key:"AuctionHouseBot.GUIDs",value:"0"},{key:"AuctionHouseBot.EnableSeller",value:"true"},{key:"AuctionHouseBot.CoA.ObtainableItemsOnly",value:"true"},{key:"AuctionHouseBot.ItemsPerCycle",value:"150"},{key:"AuctionHouseBot.MinutesBetweenSellCycle",value:"1"},{key:"AuctionHouseBot.Alliance.MinItems",value:"15000"},{key:"AuctionHouseBot.Alliance.MaxItems",value:"15000"}].map(s=>({...s,default:s.value,doc:""}))],
"tbc-content":[{key:"CoATBC.Enable",value:"1",default:"1",doc:"Outland dungeons and raids."}],
"wotlk-content":[{key:"CoAWotLK.Enable",value:"1",default:"1",doc:"Northrend dungeons and raids."}],
@@ -92,7 +96,7 @@ export {};
if(cmd==="account_set_access"){const a=(window as any).__acc.find((x: any)=>x.name===args.name);if(a)a.access=args.level;return null;}
if(cmd==="account_set_password") return null;
if(cmd==="account_rename"){const a=(window as any).__acc.find((x: any)=>x.name===args.name);if(a)a.name=String(args.newName).toUpperCase();return null;}
if(cmd==="client_info"){const c=(window as any).__client??"none";return c==="none"?null:{path:"C:\\games\\CoA Client",executable:"Ascension.exe",realmlists:[{path:"x",host:"127.0.0.1"}],addon:{installed:true,version:"1",up_to_date:true},other_addons:2};}
if(cmd==="client_info"){const c=(window as any).__client??"none";return c==="none"?null:{path:"C:\\games\\CoA Client",executable:"Ascension.exe",realmlists:[{path:"x",host:args.id==="@remote-client"?remoteHost:"127.0.0.1"}],addon:{installed:true,version:"1",up_to_date:true},other_addons:2};}
if(cmd==="set_client"){(window as any).__client="foreign";return {path:args.path,executable:"Ascension.exe",realmlists:[],addon:{installed:false,version:null,up_to_date:null},other_addons:0};}
if(cmd==="plugin:event|listen"){const w=window as any;(w.__handlers??={})[args.event]=[...(w.__handlers[args.event]??[]),args.handler];return 1;}
if(cmd==="plugin:event|unlisten") return null;
+15
View File
@@ -2,6 +2,21 @@ import type en from "./en";
/** Deutsch. Entwurf einer Übersetzung; vor der Veröffentlichung von Muttersprachlern zu prüfen. */
const de: Partial<Record<keyof typeof en, string>> = {
"remote.clientReady": "Dein Client ist bereit. Speichere die Hostadresse und klicke auf Spielen.",
"remote.clientIntro": "Wähle einen vorhandenen Spielclient oder lade ihn hier herunter. Ein lokaler Server ist nicht nötig.",
"welcome.host.title": "Einen Server hosten",
"welcome.host.text": "Einen neuen Server installieren oder einen vorhandenen verwalten.",
"welcome.connect.title": "Mit anderem Server verbinden",
"welcome.connect.text": "Den Spielclient herunterladen oder aktualisieren und bei Freunden spielen.",
"remote.intro": "Richte deinen Spielclient ein. Dein Freund betreibt den Server.",
"remote.address": "Serveradresse",
"remote.addressHint": "Für LAN-Spiele frage den Host nach der lokalen IP-Adresse seines Computers.",
"remote.save": "Adresse speichern",
"remote.saved": "Serveradresse gespeichert.",
"remote.accountHint": "Bitte den Host, ein Spielkonto für dich anzulegen.",
"remote.hostInstead": "Eigenen Server hosten",
"welcome.roleSubtitle": "Wie möchtest du spielen?",
"nav.overview": "Übersicht",
"nav.bots": "Bots",
"nav.server": "Server",
+15
View File
@@ -1,5 +1,20 @@
/** English is the source of truth: every key must exist here; other locales fall back to it. */
const en = {
"remote.clientReady": "Your client is ready. Save the host’s address, then press Play.",
"remote.clientIntro": "Choose an existing game client or download one here. No local server is needed.",
"welcome.host.title": "Host a server",
"welcome.host.text": "Install a new server or manage one you already have.",
"welcome.connect.title": "Connect to another server",
"welcome.connect.text": "Download or update the game client and play on a friend’s server.",
"remote.intro": "Set up your game client. Your friend runs the server.",
"remote.address": "Server address",
"remote.addressHint": "For LAN play, ask the host for their computer’s local IP address.",
"remote.save": "Save address",
"remote.saved": "Server address saved.",
"remote.accountHint": "Ask the host to create a game account for you.",
"remote.hostInstead": "Host my own server",
"welcome.roleSubtitle": "How would you like to play?",
"realm.profileLabel": "Realm",
"realm.startupTitle": "Realm startup",
"realm.secondWorld": "Second world",
+15
View File
@@ -2,6 +2,21 @@ import type en from "./en";
/** Español. Traducción provisional; debe revisarla un hablante nativo antes de publicarse. */
const es: Partial<Record<keyof typeof en, string>> = {
"remote.clientReady": "Tu cliente está listo. Guarda la dirección del anfitrión y pulsa Jugar.",
"remote.clientIntro": "Elige un cliente existente o descárgalo aquí. No necesitas un servidor local.",
"welcome.host.title": "Alojar un servidor",
"welcome.host.text": "Instalar un servidor nuevo o gestionar uno existente.",
"welcome.connect.title": "Conectarse a otro servidor",
"welcome.connect.text": "Descargar o actualizar el cliente y jugar en el servidor de un amigo.",
"remote.intro": "Configura tu cliente. Tu amigo aloja el servidor.",
"remote.address": "Dirección del servidor",
"remote.addressHint": "Para jugar en LAN, pide al anfitrión la IP local de su ordenador.",
"remote.save": "Guardar dirección",
"remote.saved": "Dirección del servidor guardada.",
"remote.accountHint": "Pide al anfitrión que te cree una cuenta de juego.",
"remote.hostInstead": "Alojar mi servidor",
"welcome.roleSubtitle": "¿Cómo quieres jugar?",
"nav.overview": "Resumen",
"nav.bots": "Bots",
"nav.server": "Servidor",
+15
View File
@@ -2,6 +2,21 @@ import type en from "./en";
/** Français. Traduction provisoire ; à faire relire par un locuteur natif avant la publication. */
const fr: Partial<Record<keyof typeof en, string>> = {
"remote.clientReady": "Votre client est prêt. Enregistrez l’adresse de l’hôte, puis cliquez sur Jouer.",
"remote.clientIntro": "Choisissez un client existant ou téléchargez-le ici. Aucun serveur local n’est nécessaire.",
"welcome.host.title": "Héberger un serveur",
"welcome.host.text": "Installer un serveur ou gérer un serveur existant.",
"welcome.connect.title": "Rejoindre un autre serveur",
"welcome.connect.text": "Télécharger ou mettre à jour le client et jouer sur le serveur d’un ami.",
"remote.intro": "Configurez votre client. Votre ami héberge le serveur.",
"remote.address": "Adresse du serveur",
"remote.addressHint": "Pour jouer en réseau local, demandez l’adresse IP locale de l’ordinateur hôte.",
"remote.save": "Enregistrer l’adresse",
"remote.saved": "Adresse du serveur enregistrée.",
"remote.accountHint": "Demandez à l’hôte de créer un compte de jeu pour vous.",
"remote.hostInstead": "Héberger mon serveur",
"welcome.roleSubtitle": "Comment souhaitez-vous jouer ?",
"nav.overview": "Vue d’ensemble",
"nav.bots": "Bots",
"nav.server": "Serveur",
+15
View File
@@ -2,6 +2,21 @@ import type en from "./en";
/** Русский. Черновой перевод; требует проверки носителем перед релизом. */
const ru: Partial<Record<keyof typeof en, string>> = {
"remote.clientReady": "Клиент готов. Сохраните адрес хоста и нажмите «Играть».",
"remote.clientIntro": "Выберите существующий клиент игры или скачайте его здесь. Локальный сервер не требуется.",
"welcome.host.title": "Хочу быть хостом",
"welcome.host.text": "Установить новый сервер или управлять существующим.",
"welcome.connect.title": "Подключиться к другому серверу",
"welcome.connect.text": "Скачать или обновить клиент и играть на сервере друга.",
"remote.intro": "Настройте игровой клиент. Сервер запускает ваш друг.",
"remote.address": "Адрес сервера",
"remote.addressHint": "Для игры по локальной сети попросите у хоста локальный IP его компьютера.",
"remote.save": "Сохранить адрес",
"remote.saved": "Адрес сервера сохранён.",
"remote.accountHint": "Попросите хоста создать для вас игровой аккаунт.",
"remote.hostInstead": "Создать свой сервер",
"welcome.roleSubtitle": "Как вы хотите играть?",
"realm.profileLabel": "Реалм",
"realm.startupTitle": "Запуск реалмов",
"realm.secondWorld": "Второй мир",
+15
View File
@@ -1,5 +1,20 @@
/** Simplified Chinese localization (zh-CN) */
const zh = {
"remote.clientReady": "客户端已就绪。保存服主地址,然后点击开始游戏。",
"remote.clientIntro": "选择已有游戏客户端,或在此下载。无需安装本地服务器。",
"welcome.host.title": "我要搭建服务器",
"welcome.host.text": "安装新服务器或管理已有服务器。",
"welcome.connect.title": "连接其他服务器",
"welcome.connect.text": "下载或更新游戏客户端,在朋友的服务器上游玩。",
"remote.intro": "设置游戏客户端,服务器由朋友运行。",
"remote.address": "服务器地址",
"remote.addressHint": "在局域网中游玩时,请向服主询问其电脑的局域网 IP 地址。",
"remote.save": "保存地址",
"remote.saved": "服务器地址已保存。",
"remote.accountHint": "请服主为你创建游戏账号。",
"remote.hostInstead": "搭建自己的服务器",
"welcome.roleSubtitle": "你想如何游玩?",
"realm.profileLabel": "服务器",
"realm.switching": "正在切换服务器…",
"realm.create": "创建 Wildcard 服务器",
+5
View File
@@ -1,5 +1,8 @@
import { invoke } from "@tauri-apps/api/core";
export const REMOTE_CLIENT_ID = "@remote-client";
export interface RemoteConnection { host: string; client_path: string | null }
export type Classification = "healthy" | "partial" | "unknown-custom" | "incompatible";
export type ItemStatus = "found" | "missing" | "attention";
@@ -376,6 +379,8 @@ export interface ConsoleLine {
}
export const api = {
remoteConnection: () => invoke<RemoteConnection>("remote_connection"),
remoteConnect: (host: string) => invoke<RemoteConnection>("remote_connect", { host }),
realmProfiles: (id: string) => invoke<RealmProfiles>("realm_profiles", { id }),
realmSelect: (id: string, mode: RealmMode, restart: boolean) => invoke<RealmProfiles>("realm_select", { id, mode, restart }),
realmSimultaneous: (id: string, enabled: boolean) => invoke<RealmProfiles>("realm_simultaneous", { id, enabled }),
+3
View File
@@ -46,6 +46,9 @@ add("AiPlayerbot.CoaLfgBots", "bool", ["CoA bots fill dungeon groups", "Боты
add("AiPlayerbot.RandomBotXPRate", "float", ["Bot experience multiplier", "Множитель опыта ботов", "Bot-Erfahrungsmultiplikator", "Multiplicateur d’expérience des bots", "Multiplicador de experiencia de bots", "机器人经验倍率"], ["1 uses the normal experience rate for random bots.", "1 — обычная скорость получения опыта случайными ботами.", "1 nutzt die normale Erfahrungsrate für Zufallsbots.", "1 conserve le gain d’expérience normal des bots aléatoires.", "1 usa la experiencia normal de bots aleatorios.", "1使用随机机器人的正常经验获取倍率。"], { min: 0 });
add("AiPlayerbot.FollowDistance", "float", ["Follow distance", "Дистанция следования", "Folgeabstand", "Distance de suivi", "Distancia de seguimiento", "跟随距离"], ["Distance bots maintain while following their owner.", "Расстояние до владельца при следовании.", "Abstand zum Besitzer beim Folgen.", "Distance maintenue en suivant le propriétaire.", "Distancia mantenida al seguir al dueño.", "跟随主人时保持的距离。"], { min: 0 });
const scaling = "CoAContentScaling.";
add(scaling + "LFG.AllowPartialGroups", "bool", ["Enter with the current party", "Входить текущим составом", "Mit aktueller Gruppe betreten", "Entrer avec le groupe actuel", "Entrar con el grupo actual", "以当前队伍进入"], ["Enter Dungeon Finder with 1–5 players without waiting for a full party. Requires group scaling. BotFill keeps filling empty slots.", "Вход через ЛФГ составом 1–5 игроков без ожидания полной группы. Требует скейлинга группы. BotFill продолжает заполнять свободные места.", "Dungeonbrowser mit 1–5 Spielern ohne volle Gruppe. Erfordert Gruppenanpassung. BotFill füllt freie Plätze.", "Entrée avec 1 à 5 joueurs sans attendre un groupe complet. Nécessite l’adaptation au groupe. BotFill remplit les places libres.", "Entrar con 1–5 jugadores sin esperar un grupo completo. Requiere ajuste al grupo. BotFill llena los huecos.", "以1至5名玩家进入,无需等待满员。需要队伍缩放。BotFill仍会填补空位。"]);
add(scaling + "World.Leech.Enable", "bool", ["Open-world life steal", "Отхил от урона в открытом мире", "Lebensraub in der offenen Welt", "Vol de vie en monde ouvert", "Robo de vida en mundo abierto", "开放世界吸血"], ["Heal from your damage to open-world creatures. Excludes players, their pets and instances. Requires the module; group scaling and solo assistance are optional.", "Восстанавливает здоровье от вашего урона существам открытого мира. Исключает игроков, их питомцев и инстансы. Требует включения модуля; скейлинг группы и соло-помощь необязательны.", "Heilt durch eigenen Schaden an Weltkreaturen. Ohne Spieler, deren Begleiter und Instanzen. Erfordert das Modul; Gruppenanpassung und Solohilfe sind optional.", "Soigne grâce aux dégâts aux créatures du monde ouvert. Exclut joueurs, familiers et instances. Nécessite le module ; adaptation au groupe et aide solo facultatives.", "Cura con tu daño a criaturas del mundo abierto. Excluye jugadores, mascotas e instancias. Requiere el módulo; ajuste al grupo y ayuda individual opcionales.", "对开放世界生物造成伤害时恢复生命。不适用于玩家、玩家宠物和副本。需要启用模块,队伍缩放和单人辅助可选。"]);
add(scaling + "World.Leech.Percent", "float", ["Life steal (%)", "Отхил от нанесённого урона (%)", "Lebensraub (%)", "Vol de vie (%)", "Robo de vida (%)", "吸血比例(%)"], ["Percentage of damage returned as health: 0–100. 10 means 10%. Requires open-world life steal. Overkill gives no extra healing.", "Процент урона, возвращаемый здоровьем: 0–100. Значение 10 означает 10%. Требует включения отхила в открытом мире. Избыточный урон при добивании не увеличивает отхил.", "Schadensanteil als Heilung: 0–100. 10 bedeutet 10%. Erfordert Welt-Lebensraub. Überzähliger Schaden heilt nicht zusätzlich.", "Pourcentage des dégâts rendu en vie : 0–100. 10 signifie 10%. Nécessite le vol de vie. Les dégâts excédentaires ne soignent pas davantage.", "Porcentaje del daño recuperado como salud: 0–100. 10 significa 10%. Requiere robo de vida. El daño excedente no aporta curación adicional.", "将伤害的0至100%转化为生命。10表示10%。需要启用吸血。过量伤害不会额外治疗。"], { min: 0, max: 100 });
add(scaling + "Difficulty.DamageMultiplier", "range", ["Enemy damage", "Урон противников", "Gegnerschaden", "Dégâts ennemis", "Daño enemigo", "敌人伤害"], ["Dungeon and raid damage after group scaling and solo assistance. 100% keeps the normal balance; lower is easier. Applies to everyone on this server. Does not change health, loot or instant-kill mechanics.", "Урон в подземельях и рейдах после скейлинга группы и соло-помощи. 100% — обычный баланс, ниже — легче. Действует для всех игроков сервера. Не меняет здоровье, добычу и механики мгновенной смерти.", "Schaden in Dungeons und Schlachtzügen nach Gruppenanpassung und Solohilfe. 100% ist normal, weniger ist leichter. Gilt für alle Spieler. Ändert weder Gesundheit noch Beute oder Soforttötungen.", "Dégâts en donjon et raid après adaptation au groupe et aide solo. 100% conserve l’équilibrage normal ; moins est plus facile. Pour tous les joueurs. Ne change ni la vie, ni le butin, ni les morts instantanées.", "Daño en mazmorras y bandas tras el ajuste del grupo y la ayuda individual. 100% mantiene el equilibrio normal; menos es más fácil. Para todos los jugadores. No cambia salud, botín ni muerte instantánea.", "队伍缩放和单人辅助后的地下城与团队副本伤害。100%为正常,降低后更容易。对全服玩家生效,不改变生命值、掉落或即死机制。"], { min: 0.25, max: 2 });
add(scaling + "SoloAssist.Mode", "enum", ["Solo assistance", "Помощь в соло", "Solohilfe", "Aide solo", "Ayuda individual", "单人辅助"], ["Additional damage reduction when the scaling target is one player.", "Дополнительное снижение урона, когда расчёт сложности рассчитан на одного игрока.", "Zusätzliche Schadensreduktion bei einem Skalierungsziel von einem Spieler.", "Réduction supplémentaire quand la difficulté est calculée pour un joueur.", "Reducción adicional cuando la dificultad se calcula para un jugador.", "难度目标为一名玩家时额外减伤。"], { options: [
{ value: "None", label: ["None", "Без помощи", "Keine", "Aucune", "Ninguna", "无"] },
+9 -6
View File
@@ -7,7 +7,7 @@ import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { useHuman, useT } from "@/i18n";
export function ClientCard({ serverId }: { serverId: string }) {
export function ClientCard({ serverId, remote = false }: { serverId: string; remote?: boolean }) {
const t = useT();
const human = useHuman();
const [info, setInfo] = useState<ClientInfo | null | undefined>(undefined);
@@ -33,6 +33,7 @@ export function ClientCard({ serverId }: { serverId: string }) {
const msg = await fn();
if (msg) setNote(msg);
await refresh();
void checkClient(serverId);
} catch (e) {
setError(asUiError(e));
} finally {
@@ -54,7 +55,7 @@ export function ClientCard({ serverId }: { serverId: string }) {
{info === undefined && <p className="mt-2 text-sm text-muted">{t("client.looking")}</p>}
{info === null && (
<>
<p className="mt-1 text-sm text-muted">{t("client.intro")}</p>
<p className="mt-1 text-sm text-muted">{t(remote ? "remote.clientIntro" : "client.intro")}</p>
<Button className="mt-4" onClick={() => setDialog("setup")} disabled={busy}>{t("btn.setupClient")}</Button>
</>
)}
@@ -65,7 +66,7 @@ export function ClientCard({ serverId }: { serverId: string }) {
<dt className="text-muted">{t("client.connectsTo")}</dt>
<dd>
{hosts.length ? hosts.filter(Boolean).join(", ") : "—"}
{pointsLocal ? <span className="ml-2 text-ok">{t("client.thisComputer")}</span> : <span className="ml-2 text-warn">{t("client.notThisServer")}</span>}
{!remote && (pointsLocal ? <span className="ml-2 text-ok">{t("client.thisComputer")}</span> : <span className="ml-2 text-warn">{t("client.notThisServer")}</span>)}
</dd>
<dt className="text-muted">{t("client.version")}</dt>
<dd>
@@ -74,27 +75,29 @@ export function ClientCard({ serverId }: { serverId: string }) {
: t("client.versionUntracked")}
{status?.update_available && <span className="ml-2 text-warn">{t("client.updateAvailable")}</span>}
</dd>
{!remote && <>
<dt className="text-muted">{t("client.addon")}</dt>
<dd>
{info.addon.installed ? (info.addon.version ? t("client.installedVersion", { v: info.addon.version }) : t("client.installed")) : t("client.addonNotInstalled")}
{info.addon.up_to_date === false && <span className="ml-2 text-warn">{t("client.updateAvailable")}</span>}
<span className="mt-1 block text-xs text-muted">{t("client.addonWhat")}</span>
</dd>
</>}
<dt className="text-muted">{t("client.otherAddons")}</dt>
<dd>{t("client.leftAsIs", { n: info.other_addons })}</dd>
</dl>
<div className="mt-4 flex flex-wrap gap-2">
{!pointsLocal && (
{!remote && !pointsLocal && (
<Button size="sm" disabled={busy} onClick={() => void run(async () => { await api.setRealmlist(serverId, "127.0.0.1"); return t("client.pointedNote"); })}>
{t("client.pointLocal")}
</Button>
)}
{(!info.addon.installed || info.addon.up_to_date === false) && (
{!remote && (!info.addon.installed || info.addon.up_to_date === false) && (
<Button size="sm" disabled={busy} onClick={() => void run(async () => { await api.installAddon(serverId); return t("client.addonNote"); })}>
{info.addon.installed ? t("client.updateAddon") : t("client.installAddon")}
</Button>
)}
{status?.update_available && (
{(status?.update_available || (remote && status?.managed)) && (
<Button size="sm" variant="primary" disabled={busy} onClick={() => setDialog("update")}>{t("client.upd.start")}</Button>
)}
{status && !status.managed && (
+2 -2
View File
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react";
import { open } from "@tauri-apps/plugin-dialog";
import { listen } from "@tauri-apps/api/event";
import { Check, Loader2 } from "lucide-react";
import { api, asUiError, type ClientDownloadCheck, type ClientPlan, type ClientStep, type UiError } from "@/lib/api";
import { api, asUiError, REMOTE_CLIENT_ID, type ClientDownloadCheck, type ClientPlan, type ClientStep, type UiError } from "@/lib/api";
import { formatBytes } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
@@ -261,7 +261,7 @@ export function ClientDialog(props: {
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-ok/15 text-ok"><Check className="h-5 w-5" aria-hidden /></span>
<div>
<p className="font-medium">{mode === "setup" ? t("client.dl.done") : t("client.upd.done")}</p>
<p className="text-sm text-muted">{mode === "setup" ? t("client.dl.doneText") : t("client.upd.doneText")}</p>
<p className="text-sm text-muted">{mode === "setup" ? t(serverId === REMOTE_CLIENT_ID ? "remote.clientReady" : "client.dl.doneText") : t("client.upd.doneText")}</p>
</div>
</div>
<div className="mt-5 flex justify-end">
+85
View File
@@ -0,0 +1,85 @@
import { useEffect, useState } from "react";
import { Gauge, Settings } from "lucide-react";
import { api, asUiError, REMOTE_CLIENT_ID } from "@/lib/api";
import { checkClient, startClientPolling, useClientStatus } from "@/lib/clientUpdate";
import { ClientCard } from "@/screens/ClientCard";
import { ClientDialog, type ClientDialogMode } from "@/screens/ClientDialog";
import { AboutCard } from "@/screens/AboutCard";
import { LanguagePicker } from "@/components/LanguagePicker";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { useHuman, useT } from "@/i18n";
import { cn } from "@/lib/utils";
import logo from "@/assets/logo.png";
export function RemoteClient({ onHost }: { onHost: () => void }) {
const t = useT();
const human = useHuman();
const [page, setPage] = useState<"overview" | "settings">("overview");
const [host, setHost] = useState("");
const [savedHost, setSavedHost] = useState("");
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [note, setNote] = useState<string | null>(null);
const [revision, setRevision] = useState(0);
const [dialog, setDialog] = useState<ClientDialogMode | null>(null);
const { status } = useClientStatus(REMOTE_CLIENT_ID);
function fail(e: unknown) {
const err = asUiError(e);
setError(err.human.code === "unknown" ? err.technical : human(err.human).message);
}
useEffect(() => {
void api.remoteConnection().then(p => { setHost(p.host); setSavedHost(p.host); }).catch(fail).finally(() => setLoading(false));
return startClientPolling(REMOTE_CLIENT_ID);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { void checkClient(REMOTE_CLIENT_ID); }, [revision]);
async function save() {
setBusy(true); setError(null); setNote(null);
try {
const profile = await api.remoteConnect(host);
setHost(profile.host); setSavedHost(profile.host); setRevision(n => n + 1);
setNote(t("remote.saved"));
} catch (e) { fail(e); } finally { setBusy(false); }
}
async function launch() {
setBusy(true); setError(null); setNote(null);
try { await api.play(REMOTE_CLIENT_ID); } catch (e) { fail(e); } finally { setBusy(false); }
}
function play() {
if (!status?.linked) setDialog("setup");
else if (status.update_available) setDialog("update");
else void launch();
}
return <div className="flex h-full">
<nav className="flex w-60 shrink-0 flex-col border-r border-line bg-[#0b0c0e] p-3" aria-label={t("nav.main")}>
<div className="flex items-center gap-2 px-3 pb-4 pt-2 text-sm font-semibold tracking-wide text-gold"><img src={logo} alt="" className="h-7 w-7" />{t("app.name")}</div>
<ul className="flex flex-col gap-0.5">
{(["overview", "settings"] as const).map(id => { const Icon = id === "overview" ? Gauge : Settings; return <li key={id}>
<button onClick={() => setPage(id)} aria-current={page === id ? "page" : undefined} className={cn("flex h-10 w-full cursor-pointer items-center gap-3 rounded-md px-3 text-left text-[15px]", page === id ? "bg-white/[0.07] text-ink" : "text-muted hover:bg-white/5 hover:text-ink")}><Icon className="h-[18px] w-[18px]" aria-hidden />{t(id === "overview" ? "nav.overview" : "nav.settings")}</button>
</li>; })}
</ul>
<div className="mt-auto border-t border-line pt-3"><Button variant="ghost" size="sm" className="w-full justify-start" onClick={onHost}>{t("remote.hostInstead")}</Button></div>
</nav>
<main className="h-full flex-1 overflow-y-auto px-10 py-8">
{page === "settings" ? <><div className="flex items-center justify-between"><h1 className="text-2xl font-semibold">{t("nav.settings")}</h1><LanguagePicker /></div><AboutCard /></> : <>
<div className="flex items-center justify-between"><h1 className="text-2xl font-semibold">{t("welcome.connect.title")}</h1><LanguagePicker /></div>
<p className="mt-2 text-muted">{t("remote.intro")}</p>
<Card className="mt-6 p-6">
<form onSubmit={e => { e.preventDefault(); void save(); }}>
<label htmlFor="remote-host" className="font-semibold">{t("remote.address")}</label>
<p id="remote-host-hint" className="mt-1 text-sm text-muted">{t("remote.addressHint")}</p>
<div className="mt-4 flex gap-3"><input id="remote-host" aria-describedby="remote-host-hint" value={host} onChange={e => { setHost(e.target.value); setNote(null); }} disabled={loading || busy} placeholder="192.168.1.10" className="min-w-0 flex-1 rounded-md border border-line bg-card px-3 py-2" /><Button type="submit" disabled={loading || busy || !host.trim()}>{t("remote.save")}</Button></div>
</form>
<p className="mt-3 text-sm text-muted">{t("remote.accountHint")}</p>
<Button variant="primary" className="mt-4" disabled={loading || busy || !savedHost || host.trim() !== savedHost || status === null} onClick={play}>{t("btn.play")}</Button>
{note && <p className="mt-3 text-sm text-ok" role="status">{note}</p>}
{error && <p className="mt-3 text-sm text-bad" role="alert">{error}</p>}
</Card>
<ClientCard key={revision} serverId={REMOTE_CLIENT_ID} remote />
</>}
</main>
{dialog && <ClientDialog serverId={REMOTE_CLIENT_ID} mode={dialog} onClose={() => setDialog(null)} onChanged={() => setRevision(n => n + 1)} onPlayAnyway={() => void launch()} />}
</div>;
}
+21 -6
View File
@@ -1,20 +1,34 @@
import { FolderSearch, Download } from "lucide-react";
import { useState } from "react";
import { FolderSearch, Download, Server, Globe } from "lucide-react";
import { useT } from "@/i18n";
import logo from "@/assets/logo.png";
import { LanguagePicker } from "@/components/LanguagePicker";
export function Welcome({ onImport, onInstall, onBack }: { onImport: () => void; onInstall: () => void; onBack?: () => void }) {
export function Welcome({ onImport, onInstall, onConnect, onBack }: { onImport: () => void; onInstall: () => void; onConnect: () => void; onBack?: () => void }) {
const t = useT();
const [hosting, setHosting] = useState(false);
return (
<main className="relative flex h-full flex-col items-center justify-center px-8">
<LanguagePicker className="absolute right-6 top-5" />
<div className="mb-10 text-center">
<img src={logo} alt="" aria-hidden className="mx-auto mb-5 h-24 w-24 drop-shadow-[0_6px_24px_rgb(201_162_74/0.35)]" />
<h1 className="text-3xl font-semibold tracking-tight">{t("welcome.title")}</h1>
<p className="mt-2 text-muted">{t("welcome.subtitle")}</p>
<p className="mt-2 text-muted">{t(hosting ? "welcome.subtitle" : "welcome.roleSubtitle")}</p>
</div>
<div className="grid w-full max-w-3xl grid-cols-2 gap-5">
{!hosting ? <>
<button onClick={() => setHosting(true)} className="cursor-pointer rounded-card border border-line bg-card/90 p-7 text-left transition-colors hover:border-gold/60">
<Server className="mb-4 h-7 w-7 text-gold" aria-hidden />
<h2 className="text-lg font-semibold">{t("welcome.host.title")}</h2>
<p className="mt-1 text-sm text-muted">{t("welcome.host.text")}</p>
</button>
<button onClick={onConnect} className="cursor-pointer rounded-card border border-line bg-card/90 p-7 text-left transition-colors hover:border-gold/60">
<Globe className="mb-4 h-7 w-7 text-gold" aria-hidden />
<h2 className="text-lg font-semibold">{t("welcome.connect.title")}</h2>
<p className="mt-1 text-sm text-muted">{t("welcome.connect.text")}</p>
</button>
</> : <>
<button
onClick={onInstall}
className="cursor-pointer rounded-card border border-line bg-card/90 p-7 text-left shadow-[0_8px_30px_rgb(0_0_0/0.28)] transition-colors hover:border-gold/60"
@@ -34,10 +48,11 @@ export function Welcome({ onImport, onInstall, onBack }: { onImport: () => void;
{t("welcome.import.text")}
</p>
</button>
</>}
</div>
{onBack && (
<button onClick={onBack} className="mt-8 cursor-pointer text-sm text-muted hover:text-ink">
{t("welcome.back")}
{(hosting || onBack) && (
<button onClick={hosting ? () => setHosting(false) : onBack} className="mt-8 cursor-pointer text-sm text-muted hover:text-ink">
{t(hosting ? "client.dl.back" : "welcome.back")}
</button>
)}
</main>
+5
View File
@@ -23,6 +23,11 @@ for (const [value, expected] of [["0", "None"], ["1", "Light"], ["2", "Full"], [
}
const difficulty = moduleField("CoAContentScaling.Difficulty.DamageMultiplier", "ru");
if (difficulty.type !== "range" || difficulty.min !== 0.25 || difficulty.max !== 2) throw new Error("Damage slider bounds disagree with the server");
for (const key of ["CoAContentScaling.LFG.AllowPartialGroups", "CoAContentScaling.World.Leech.Enable"]) {
if (moduleField(key, "ru")?.type !== "bool") throw new Error(`${key}: expected a toggle`);
}
const leech = moduleField("CoAContentScaling.World.Leech.Percent", "ru");
if (leech?.type !== "float" || leech.min !== 0 || leech.max !== 100 || leech.advanced) throw new Error("Life steal must use a visible percentage field from 0 to 100");
const sample = [
{ key: "AuctionHouseBot.ListProportion.CategoryWeapon.QualityNormal", value: "20", default: "10" },
{ key: "AuctionHouseBot.ListProportion.CategoryWeapon.QualityRare", value: "7", default: "5" },