Solocraft : equilibrage reel du bonus solo

Le bonus etait ressenti comme hardcore et incoherent. Quatre causes, toutes corrigees.

1. La taille du groupe etait calculee puis ignoree : elle ne servait qu'au message
   d'annonce. Un joueur seul et un groupe de 5 recevaient le meme x5 chacun, donc le
   solo ramait et le groupe complet ecrasait tout. Le bonus vaut desormais
   difficulte / joueurs presents dans l'instance (5 joueurs en donjon 5 -> x1).

2. Seules les stats primaires etaient buffees. Les degats d'une attaque valent
   (degats_arme + AP/3.5) et seule la moitie AP en profitait : le joueur encaissait x5
   mais tapait a peine plus fort. Ajout d'un bonus sur UNIT_MOD_DAMAGE_MAINHAND/
   OFFHAND/RANGED, dose par Solocraft.DamagePct.

3. Les familiers derivent leurs PV et leur AP du maitre mais ne recalculaient pas apres
   le buff : un pet deja invoque restait a x1. Appel de UpdateAllStats sur pet et gardien.

4. Rejoindre ou quitter un groupe en pleine instance ne recalculait rien. Ajout d'un
   GroupScript.

Aussi : plus de soin complet gratuit si le buff est recalcule en combat, logs passes de
INFO a DEBUG sur scripts.solocraft (ils saturaient dc-world.log), Solocraft.Mythic rend
le mythique configurable, acces au conteneur protege par mutex.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
SylvaniaCore deploy
2026-08-15 16:37:10 +02:00
parent 7b781cf602
commit 069f92387c
2 changed files with 315 additions and 130 deletions
+278 -126
View File
@@ -16,6 +16,8 @@
*/
#include <map>
#include <mutex>
#include <algorithm>
#include "Config.h"
#include "Chat.h"
#include "Log.h"
@@ -23,162 +25,311 @@
#include "Unit.h"
#include "Player.h"
#include "Pet.h"
#include "TemporarySummon.h"
#include "Map.h"
#include "Group.h"
#include "GroupReference.h"
#include "ObjectAccessor.h"
#include "InstanceScript.h"
namespace {
class solocraft_player_instance_handler : public PlayerScript {
// Etat du buff reellement applique a un joueur. On memorise les pourcentages EXACTS
// passes a HandleStatModifier pour pouvoir les retirer a l'identique, meme si la
// config a ete rechargee (ou le groupe a change) entre l'application et le retrait.
struct SolocraftBuff
{
float statPct; // pourcentage TOTAL_PCT sur les stats primaires (400 = x5)
float weaponPct; // pourcentage TOTAL_PCT sur les degats d'arme
float effective; // multiplicateur effectif, pour l'affichage joueur
SolocraftBuff() : statPct(0.0f), weaponPct(0.0f), effective(1.0f) { }
};
// Accede depuis les threads de map (OnMapChanged) et depuis le thread world
// (GroupScript) -> les acces au conteneur sont proteges.
std::mutex _buffMutex;
std::map<uint64, SolocraftBuff> _buffs;
bool IsEnabled()
{
return sConfigMgr->GetBoolDefault("Solocraft.Enable", false);
}
// Difficulte BRUTE de la carte, c'est a dire pour combien de joueurs le contenu est
// calibre. Relue a chaque appel (evenement peu frequent) : 'reload config' en tmux
// suffit donc pour tuner l'equilibrage, sans rebuild ni restart.
float ReadRawDifficulty(Map* map)
{
if (!map)
return 1.0f;
// Mythique / Mythique+ : contenu reserve au groupe complet, aucun buff par defaut.
// Solocraft.Mythic = 1.0 desactive, une valeur > 1 l'active (ex: 5.0).
if (map->IsMythic())
return sConfigMgr->GetFloatDefault("Solocraft.Mythic", 1.0f);
if (map->IsRaid())
{
switch (map->GetMapDifficulty()->MaxPlayers)
{
case 10: return sConfigMgr->GetFloatDefault("Solocraft.Raid10", 10.0f);
case 25: return sConfigMgr->GetFloatDefault("Solocraft.Raid25", 25.0f);
case 30: return sConfigMgr->GetFloatDefault("Solocraft.Raid30", 30.0f);
case 40: return sConfigMgr->GetFloatDefault("Solocraft.Raid40", 40.0f);
default:
TC_LOG_WARN("scripts.solocraft", "[Solocraft] Nombre max de joueurs non reconnu (%d), repli sur Raid10",
map->GetMapDifficulty()->MaxPlayers);
return sConfigMgr->GetFloatDefault("Solocraft.Raid10", 10.0f);
}
}
if (map->IsDungeon())
{
if (map->IsHeroic())
return sConfigMgr->GetFloatDefault("Solocraft.Heroic", 10.0f);
return sConfigMgr->GetFloatDefault("Solocraft.Dungeon", 5.0f);
}
return 1.0f;
}
// Nombre de membres du groupe REELLEMENT presents dans l'instance.
// Les membres restes en ville ne doivent pas diluer le buff de ceux qui jouent.
uint32 CountPlayersInInstance(Player* player, Map* map)
{
Group* group = player->GetGroup();
if (!group)
return 1;
uint32 count = 0;
for (GroupReference* itr = group->GetFirstMember(); itr != nullptr; itr = itr->next())
{
Player* member = itr->GetSource();
if (member && member->IsInWorld() && member->GetMap() == map)
++count;
}
return std::max<uint32>(count, 1);
}
// Cœur du correctif d'equilibrage.
//
// L'ancienne version calculait bien la taille du groupe... puis ne s'en servait que
// dans le message d'annonce. Resultat : un joueur seul et un groupe de 5 recevaient
// le meme x5 chacun -> le solo ramait, le groupe complet ecrasait tout.
//
// effectif = difficulte brute / joueurs presents :
// donjon (D=5) en solo -> x5 (le joueur remplace 5 joueurs)
// donjon (D=5) a 5 -> x1 (aucun buff, contenu joue normalement)
// raid 25 (D=25) a 10 -> x2.5
void ComputeMultipliers(float rawDifficulty, uint32 numPlayers, SolocraftBuff& out)
{
float effective = rawDifficulty / float(numPlayers);
if (effective < 1.0f)
effective = 1.0f; // jamais de malus si le groupe est plus grand que prevu
// Deux leviers separes, tous deux reglables a chaud.
//
// Solocraft.StatsPct : part du bonus appliquee aux stats primaires. Elles portent
// les PV (endurance), la puissance des sorts et la puissance
// d'attaque -> a 100 le joueur solo a bien x5 PV et x5 AP.
//
// Solocraft.DamagePct : part appliquee aux degats d'arme. Indispensable : les degats
// d'une attaque valent (degats_arme + AP/3.5), et seule la
// moitie "AP" profitait du buff de stats. Le joueur devenait
// une eponge lente -- il encaissait x5 mais tapait a peine
// plus fort, d'ou la sensation de contenu hardcore.
// 20 vise un total d'environ x5 degats en solo une fois
// combine au x5 d'AP (double comptage compris).
const float statsPct = sConfigMgr->GetFloatDefault("Solocraft.StatsPct", 100.0f);
const float damagePct = sConfigMgr->GetFloatDefault("Solocraft.DamagePct", 20.0f);
out.effective = effective;
out.statPct = (effective - 1.0f) * statsPct;
out.weaponPct = (effective - 1.0f) * damagePct;
}
// Les familiers derivent leurs PV et leur puissance d'attaque de ceux du maitre
// (cf. Guardian::UpdateMaxHealth / Guardian::UpdateAttackPowerAndDamage), mais ils ne
// recalculent pas spontanement quand le maitre est buffe : un pet deja invoque a
// l'entree en instance restait a x1. Chasseur, demoniste, DK impie et chaman elem
// perdaient ainsi une grosse part de leurs degats -- d'ou l'ecart ressenti entre classes.
void RefreshPets(Player* player)
{
if (Pet* pet = player->GetPet())
pet->UpdateAllStats();
if (Guardian* guardian = player->GetGuardianPet())
if (guardian != player->GetPet())
guardian->UpdateAllStats();
}
void ApplyModifiers(Player* player, SolocraftBuff const& buff, bool apply)
{
for (int32 i = STAT_STRENGTH; i < MAX_STATS; ++i)
player->HandleStatModifier(UnitMods(UNIT_MOD_STAT_START + i), TOTAL_PCT, buff.statPct, apply);
if (buff.weaponPct != 0.0f)
{
player->HandleStatModifier(UNIT_MOD_DAMAGE_MAINHAND, TOTAL_PCT, buff.weaponPct, apply);
player->HandleStatModifier(UNIT_MOD_DAMAGE_OFFHAND, TOTAL_PCT, buff.weaponPct, apply);
player->HandleStatModifier(UNIT_MOD_DAMAGE_RANGED, TOTAL_PCT, buff.weaponPct, apply);
}
RefreshPets(player);
}
// Retire le buff en cours s'il y en a un. Retourne true si quelque chose a ete retire.
bool RemoveBuff(Player* player, bool announce)
{
SolocraftBuff previous;
{
std::lock_guard<std::mutex> lock(_buffMutex);
std::map<uint64, SolocraftBuff>::iterator itr = _buffs.find(player->GetGUID());
if (itr == _buffs.end())
return false;
previous = itr->second;
_buffs.erase(itr);
}
ApplyModifiers(player, previous, false);
TC_LOG_DEBUG("scripts.solocraft", "[Solocraft] Retrait joueur=%s statPct=%.1f weaponPct=%.1f maxHP=%u",
player->GetName().c_str(), previous.statPct, previous.weaponPct, (uint32)player->GetMaxHealth());
if (announce && sConfigMgr->GetBoolDefault("Solocraft.Announce", false) && player->GetSession())
ChatHandler(player->GetSession()).PSendSysMessage("|cffFF0000[SoloCraft]|r |cffFF8000Bonus retiré (x%0.2f).|r",
previous.effective);
return true;
}
// Recalcule et applique le buff correspondant a la situation actuelle du joueur.
void RefreshBuff(Player* player, bool announce)
{
if (!player || !IsEnabled())
return;
Map* map = player->GetMap();
if (!map)
return;
float rawDifficulty = ReadRawDifficulty(map);
uint32 numPlayers = CountPlayersInInstance(player, map);
SolocraftBuff buff;
ComputeMultipliers(rawDifficulty, numPlayers, buff);
// Rien a faire : pas de buff en cours et aucun a appliquer (cas de l'immense
// majorite des appels -- monde ouvert, champs de bataille, groupes de bots).
if (buff.statPct == 0.0f && buff.weaponPct == 0.0f)
{
RemoveBuff(player, announce);
return;
}
RemoveBuff(player, false);
{
std::lock_guard<std::mutex> lock(_buffMutex);
_buffs[player->GetGUID()] = buff;
}
TC_LOG_DEBUG("scripts.solocraft", "[Solocraft] Application joueur=%s carte=%s brut=%.2f joueurs=%u effectif=%.2f statPct=%.1f weaponPct=%.1f maxHPavant=%u",
player->GetName().c_str(), map->GetMapName(), rawDifficulty, numPlayers,
buff.effective, buff.statPct, buff.weaponPct, (uint32)player->GetMaxHealth());
ApplyModifiers(player, buff, true);
// Ne jamais soigner a plein en combat : sans ce garde, inviter puis exclure un
// joueur en plein combat de boss offrirait un heal complet gratuit a repetition.
if (!player->IsInCombat())
{
player->SetFullHealth();
if (player->GetPowerType() == POWER_MANA)
player->SetPower(POWER_MANA, player->GetMaxPower(POWER_MANA));
}
if (announce && sConfigMgr->GetBoolDefault("Solocraft.Announce", false) && player->GetSession())
{
ChatHandler(player->GetSession()).PSendSysMessage(
"|cffFF0000[SoloCraft]|r |cffFF8000%s - joueurs présents : %u - bonus x%0.2f (dégâts d'arme x%0.2f).|r",
map->GetMapName(), numPlayers, buff.effective, 1.0f + buff.weaponPct / 100.0f);
}
}
class solocraft_player_instance_handler : public PlayerScript
{
public:
solocraft_player_instance_handler() : PlayerScript("solocraft_player_instance_handler")
{
TC_LOG_INFO("scripts.solocraft.player.instance", "[Solocraft] solocraft_player_instance_handler Loaded");
TC_LOG_INFO("scripts.solocraft", "[Solocraft] solocraft_player_instance_handler Loaded");
}
void OnMapChanged(Player* player) override
{
TC_LOG_INFO("server.worldserver", "[SoloDBG] OnMapChanged player=%s mapId=%u maxHP=%u", player->GetName().c_str(), player->GetMapId(), (uint32)player->GetMaxHealth());
if (sConfigMgr->GetBoolDefault("Solocraft.Enable", false))
{
Map* map = player->GetMap();
float difficulty = CalculateDifficulty(map, player);
int numInGroup = GetNumInGroup(player);
ApplyBuffs(player, map, difficulty, numInGroup);
}
RefreshBuff(player, true);
}
void OnLogout(Player* player) override
{
// Les modificateurs de stats (TOTAL_PCT) sont remis a 1.0 a chaque login,
// mais _unitDifficulty (statique) survivait a la deconnexion. Un ClearBuffs
// ulterieur retrouvait alors une entree fantome et DIVISAIT des stats jamais
// buffees (perso niveau 1 rabaisse a 20% de ses stats -> 28 PV, cf. bug Selena).
_unitDifficulty.erase(player->GetGUID());
// mais l'entree du conteneur survivait a la deconnexion. Un retrait ulterieur
// retrouvait alors une entree fantome et DIVISAIT des stats jamais buffees
// (perso niveau 1 rabaisse a 20% de ses stats -> 28 PV, cf. bug Selena).
std::lock_guard<std::mutex> lock(_buffMutex);
_buffs.erase(player->GetGUID());
}
};
// Sans ce script, rejoindre ou quitter un groupe en pleine instance ne recalculait
// rien : un joueur entre seul (x5) gardait son bonus solo apres avoir invite quatre
// amis, et l'inverse -- un joueur reste seul apres le depart du groupe conservait un
// bonus divise par 5, ce qui rendait le donjon injouable.
class solocraft_group_handler : public GroupScript
{
public:
solocraft_group_handler() : GroupScript("solocraft_group_handler") { }
void OnAddMember(Group* group, ObjectGuid guid) override
{
RefreshGroup(group, guid);
}
void OnRemoveMember(Group* group, ObjectGuid guid, RemoveMethod /*method*/, ObjectGuid /*kicker*/, char const* /*reason*/) override
{
RefreshGroup(group, guid);
}
void OnDisband(Group* group) override
{
RefreshGroup(group, ObjectGuid::Empty);
}
private:
std::map<uint64, float> _unitDifficulty;
// Set the instance difficulty
float CalculateDifficulty(Map* map, Player* player)
static void RefreshGroup(Group* group, ObjectGuid changedMember)
{
// Valeurs lues a CHAQUE changement de carte (evenement peu frequent) plutot qu'une seule fois
// au boot -> 'reload config' (tmux: reload config) suffit desormais pour tuner, sans restart.
const float D5 = sConfigMgr->GetFloatDefault("Solocraft.Dungeon", 5.0f);
const float D5H = sConfigMgr->GetFloatDefault("Solocraft.Heroic", 10.0f);
const float D10 = sConfigMgr->GetFloatDefault("Solocraft.Raid10", 10.0f);
const float D25 = sConfigMgr->GetFloatDefault("Solocraft.Raid25", 25.0f);
const float D30 = sConfigMgr->GetFloatDefault("Solocraft.Raid30", 30.0f);
const float D40 = sConfigMgr->GetFloatDefault("Solocraft.Raid40", 40.0f);
float difficulty = 1.0;
if (map)
{
// Pas de Solocraft en Mythique / Mythique+ : Map::IsMythic() couvre le donjon Mythique (23),
// la clef Mythique+ (DIFFICULTY_MYTHIC_KEYSTONE 8) et le raid Mythique. Contenu reserve au groupe.
// On retourne 0 (aucun buff) ; ApplyBuffs/ClearBuffs retirera tout buff Solocraft anterieur.
if (map->IsMythic())
return 0.0f;
if (!IsEnabled())
return;
if (map->IsRaid())
{
switch (map->GetMapDifficulty()->MaxPlayers)
{
case 10:
difficulty = D10; break;
case 25:
difficulty = D25; break;
case 30:
difficulty = D30; break;
case 40:
difficulty = D40; break;
default:
TC_LOG_WARN("scripts.solocraft.player.instance", "[SoloCraft] Unrecognized max players %d, defaulting to 10 man difficulty",
map->GetMapDifficulty()->MaxPlayers);
difficulty = D10;
}
}
else if (map->IsDungeon())
{
if (map->IsHeroic())
difficulty = D5H;
else
difficulty = D5;
}
}
return difficulty - 1.0; // HandleStatModifier treats 100 as +100%, so a difficulty of 1 ends up being applied as a 200% buff
}
// Get the groups size
int GetNumInGroup(Player* player)
{
int numInGroup = 1;
Group* group = player->GetGroup();
if (group)
{
Group::MemberSlotList const& groupMembers = group->GetMemberSlots();
numInGroup = groupMembers.size();
}
return numInGroup;
}
// Apply the player buffs
void ApplyBuffs(Player* player, Map* map, float difficulty, int numInGroup)
{
ClearBuffs(player, map);
if (difficulty != 0)
{
// InstanceMap *instanceMap = map->ToInstanceMap();
// InstanceScript *instanceScript = instanceMap->GetInstanceScript();
// Announce to player
std::ostringstream ss;
ss << "|cffFF0000[SoloCraft] |cffFF8000" << player->GetName() << " entered %s - # of Players: %d - Difficulty Offset: %0.2f.";
ChatHandler(player->GetSession()).PSendSysMessage(ss.str().c_str(), map->GetMapName(), numInGroup, difficulty + 1.0);
// Adjust player stats
_unitDifficulty[player->GetGUID()] = difficulty;
TC_LOG_INFO("server.worldserver", "[SoloDBG] ApplyBuffs player=%s diff=%.2f stamPctBefore=%.3f maxHPbefore=%u", player->GetName().c_str(), difficulty, player->GetModifierValue(UNIT_MOD_STAT_STAMINA, TOTAL_PCT), (uint32)player->GetMaxHealth());
for (int32 i = STAT_STRENGTH; i < MAX_STATS; ++i)
for (GroupReference* itr = group->GetFirstMember(); itr != nullptr; itr = itr->next())
{
// Buff the player
player->HandleStatModifier(UnitMods(UNIT_MOD_STAT_START + i), TOTAL_PCT, difficulty * 100.0, true);
}
TC_LOG_INFO("server.worldserver", "[SoloDBG] ApplyBuffs DONE player=%s stamPctAfter=%.3f maxHPafter=%u", player->GetName().c_str(), player->GetModifierValue(UNIT_MOD_STAT_STAMINA, TOTAL_PCT), (uint32)player->GetMaxHealth());
// Set player health
player->SetFullHealth();
if (player->GetPowerType() == POWER_MANA)
{
// Buff the player's health
player->SetPower(POWER_MANA, player->GetMaxPower(POWER_MANA));
Player* member = itr->GetSource();
if (member && member->IsInWorld() && member->GetMap() && member->GetMap()->IsDungeon())
RefreshBuff(member, false);
}
}
}
void ClearBuffs(Player* player, Map* map)
{
std::map<uint64, float>::iterator unitDifficultyIterator = _unitDifficulty.find(player->GetGUID());
TC_LOG_INFO("server.worldserver", "[SoloDBG] ClearBuffs player=%s found=%d stamPct=%.3f maxHP=%u", player->GetName().c_str(), unitDifficultyIterator != _unitDifficulty.end() ? 1 : 0, player->GetModifierValue(UNIT_MOD_STAT_STAMINA, TOTAL_PCT), (uint32)player->GetMaxHealth());
if (unitDifficultyIterator != _unitDifficulty.end())
{
float difficulty = unitDifficultyIterator->second;
_unitDifficulty.erase(unitDifficultyIterator);
// Inform the player
std::ostringstream ss;
ss << "|cffFF0000[SoloCraft] |cffFF8000" << player->GetName() << " exited to %s - Reverting Difficulty Offset: %0.2f.";
ChatHandler(player->GetSession()).PSendSysMessage(ss.str().c_str(), map->GetMapName(), difficulty + 1.0);
// Clear the buffs
for (int32 i = STAT_STRENGTH; i < MAX_STATS; ++i)
{
player->HandleStatModifier(UnitMods(UNIT_MOD_STAT_START + i), TOTAL_PCT, difficulty * 100.0, false);
}
}
// Le membre concerne n'est deja (ou pas encore) plus dans la liste : il se
// retrouve peut-etre seul dans l'instance et doit etre recalcule a part.
if (!changedMember.IsEmpty())
if (Player* changed = ObjectAccessor::FindPlayer(changedMember))
if (changed->IsInWorld() && changed->GetMap() && changed->GetMap()->IsDungeon())
RefreshBuff(changed, false);
}
};
}
@@ -186,4 +337,5 @@ namespace {
void AddSC_solocraft()
{
new solocraft_player_instance_handler();
new solocraft_group_handler();
}
+37 -4
View File
@@ -4282,16 +4282,49 @@ Solocraft.Enable = 1
Solocraft.Announce = 1
# Difficulty Multipliers
# Description: Difficulty multipliers for dungeons.
# Defaults: 5, 10, 10, 25, 30, 40
# Description: Pour COMBIEN DE JOUEURS le contenu est calibre. Le bonus reellement
# accorde vaut cette valeur DIVISEE par le nombre de membres du groupe
# presents dans l'instance.
# Solocraft.Dungeon = 5 -> joueur seul : x5 ; groupe de 5 : x1 (aucun bonus)
# Solocraft.Raid25 = 25 -> groupe de 10 : x2.5
# Solocraft.Heroic peut depasser 5 pour compenser la difficulte accrue
# des mecaniques heroiques.
# Defaults: 5, 8, 10, 25, 30, 40
Solocraft.Dungeon = 5
Solocraft.Heroic = 10
Solocraft.Heroic = 8
Solocraft.Raid10 = 10
Solocraft.Raid25 = 25
Solocraft.Raid30 = 30
Solocraft.Raid40 = 40
# Solocraft.Mythic
# Description: Bonus en donjon Mythique / clef Mythique+ / raid Mythique.
# 1.0 = aucun bonus (contenu reserve au groupe complet).
# Default: 1.0
Solocraft.Mythic = 1.0
# Solocraft.StatsPct
# Description: Part du bonus (en %) appliquee aux stats primaires -- elles portent les
# PV via l'endurance, la puissance des sorts et la puissance d'attaque.
# 100 = un joueur seul en donjon a bien x5 PV.
# Default: 100
Solocraft.StatsPct = 100
# Solocraft.DamagePct
# Description: Part du bonus (en %) appliquee aux degats d'arme. Les degats d'une
# attaque valent (degats_arme + AP/3.5) et seule la moitie "AP" profite du
# bonus de stats : sans ce reglage le joueur encaisse x5 mais tape a peine
# plus fort (contenu ressenti comme hardcore). 20 vise environ x5 degats
# en solo, tous bonus cumules.
# Monter cette valeur si les combats restent trop longs, la baisser si le
# contenu devient trivial.
# Default: 20
Solocraft.DamagePct = 20
#
###################################################################################################