From a67d51804bd1045d401245cab59d8d8f92f1ae2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sun, 4 Oct 2026 22:09:26 +0500 Subject: [PATCH] Expose partial LFG and open-world leech module settings --- .../20261004-scaling-lfg-leech-settings.md | 7 ++++ crates/coa-core/src/modules.rs | 42 +++++++++++++++++++ src/dev/mock.ts | 2 +- src/lib/moduleSettings.ts | 3 ++ tools/check-module-settings.mjs | 5 +++ 5 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 changelog.d/20261004-scaling-lfg-leech-settings.md diff --git a/changelog.d/20261004-scaling-lfg-leech-settings.md b/changelog.d/20261004-scaling-lfg-leech-settings.md new file mode 100644 index 0000000..0299c22 --- /dev/null +++ b/changelog.d/20261004-scaling-lfg-leech-settings.md @@ -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. diff --git a/crates/coa-core/src/modules.rs b/crates/coa-core/src/modules.rs index f5df2eb..20585f3 100644 --- a/crates/coa-core/src/modules.rs +++ b/crates/coa-core/src/modules.rs @@ -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::().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::().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(); diff --git a/src/dev/mock.ts b/src/dev/mock.ts index 435710b..4996c1c 100644 --- a/src/dev/mock.ts +++ b/src/dev/mock.ts @@ -79,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."}], diff --git a/src/lib/moduleSettings.ts b/src/lib/moduleSettings.ts index c08fb5a..c43362e 100644 --- a/src/lib/moduleSettings.ts +++ b/src/lib/moduleSettings.ts @@ -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", "无"] }, diff --git a/tools/check-module-settings.mjs b/tools/check-module-settings.mjs index 8aef9f5..ea6d6d9 100644 --- a/tools/check-module-settings.mjs +++ b/tools/check-module-settings.mjs @@ -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" },