#include "AdventureCombatMgr.h" #include "AdventureInteractionMgr.h" #include "Group.h" #include "AdventureCombatPolicy.h" #include "AdventureBehaviorMgr.h" #include "ClientlessLuaEngine.h" #include "ClientlessPlayerMgr.h" #include "ClientlessActionExecutorMgr.h" #include "ClientlessTravelMgr.h" #include "Creature.h" #include "DBCStructure.h" #include "Random.h" #include "Corpse.h" #include "Player.h" #include "PlayerAI.h" #include "ObjectAccessor.h" #include "MotionMaster.h" #include "MoveSpline.h" #include "Map.h" #include "Spell.h" #include "SpellAuras.h" #include "WorldSession.h" #include "WorldPacket.h" #include "Opcodes.h" #include "GameTime.h" #include "Log.h" #include #include // Native player spell checks, cooldowns, rage and autoattacks. No charmer/fake AI // installation and no generated resources or synthetic damage. class AdventureCombatMgr::Fighter : public PlayerAI { public: explicit Fighter(Player* player):PlayerAI(player) { } void UpdateAI(uint32) override { } void Tick(Unit* target,uint32 diff) { _rend=_rend>diff?_rend-diff:0;_filler=_filler>diff?_filler-diff:0; me->SetFacingToObject(target); if(me->HasUnitState(UNIT_STATE_CASTING))return; if(me->getClass()==CLASS_WARRIOR && !me->HasAura(2457) && Cast(2457,me))return; if(!me->IsWithinMeleeRange(target))return; // MMAP approach is owned by manager. if(me->GetVictim()!=target)me->Attack(target,true); DoAutoAttackIfReady(); if(me->getClass()!=CLASS_WARRIOR)return; if(!me->HasAura(6673) && Cast(6673,me))return; if(Cast(12294,target))return; if(!_rend && !target->GetAuraApplicationOfRankedSpell(772,me->GetGUID()) && Cast(772,target)) {_rend=12000;return;} if(!_filler)for(uint32 id:{7384u,1464u,78u})if(Cast(id,target)){_filler=3000;break;} } private: bool Cast(uint32 id,Unit* target) { auto cast=VerifySpellCast(id,target);if(!cast)return false; uint32 actual=cast.first->GetSpellInfo()->Id; SpellCastTargets targets;targets.SetUnitTarget(cast.second); auto result=cast.first->prepare(targets); if(result==SPELL_CAST_OK)TC_LOG_INFO("server.adventurebot","COMBAT_CAST guid=%u target='%s' spell=%u",me->GetGUID().GetCounter(),target->GetGUID().ToString().c_str(),actual); return result==SPELL_CAST_OK; } uint32 _rend=0,_filler=0; }; AdventureCombatMgr* AdventureCombatMgr::instance(){static AdventureCombatMgr mgr;return &mgr;} bool AdventureCombatMgr::Valid(Player* p,Unit* c,bool training) const { if(!c || !c->IsAlive() || !c->IsInWorld() || !p->IsInPhase(c) || !p->CanSeeOrDetect(c) || !p->IsValidAttackTarget(c))return false; if(auto creature=c->ToCreature()) return !creature->IsEvadingAttacks() && (!training || !creature->GetControllingPlayer()) && (training?(creature->GetScriptName()=="npc_training_dummy" && creature->getLevel()<=p->getLevel()+2 && !creature->isWorldBoss()):creature->GetScriptName()!="npc_training_dummy"); return !training && c->ToPlayer(); // Native PvP/sanctuary/duel checks above. } bool AdventureCombatMgr::Reachable(Player* p,Unit* c) const { if(!p->IsWithinLOSInMap(c))return false; if(p->IsWithinMeleeRange(c))return true; Position end;c->GetNearPoint(p,end.m_positionX,end.m_positionY,end.m_positionZ,0.5f,c->GetAngle(p)); ClientlessPathDiagnostics path; return sClientlessTravelMgr->AnalyzePath(p,end,path) && path.Complete && !path.Suspicious && path.PathLength<=100; } bool AdventureCombatMgr::Safe(Player* p,Creature* c) const { if(!Valid(p,c,false) || c->GetControllingPlayer() || p->GetExactDist(c)>40 || (c->GetVictim() && c->GetVictim()!=p))return false; std::vector others;p->GetCreatureListWithEntryInGrid(others,0,35); unsigned hostiles=0; for(auto other:others)if(other->IsAlive() && p->IsInPhase(other) && p->IsHostileTo(other) && other->GetExactDist(c)<12 && !other->GetControllingPlayer())++hostiles; // Include neutral quarry itself in the risk count. if(!p->IsHostileTo(c))++hostiles; auto maxMana=p->GetMaxPower(POWER_MANA); return AdventureCombatPolicy::SafeFirstAttack(p->getLevel(),c->getLevel(),p->GetHealthPct(), c->isElite(),c->isWorldBoss(),hostiles,p->GetPowerType()==POWER_MANA, maxMana?100.0f*p->GetPower(POWER_MANA)/maxMana:0) && Reachable(p,c); } Creature* AdventureCombatMgr::Dummy(Player* p) const { if(!p->IsAlive() || p->IsInCombat() || p->GetTransport() || p->IsInFlight() || p->IsBeingTeleported())return nullptr; Creature* best=nullptr;std::vector all;p->GetCreatureListWithEntryInGrid(all,0,60); for(auto c:all)if(Valid(p,c,true) && (!best || p->GetExactDist(c)GetExactDist(best)) && Reachable(p,c))best=c; return best; } bool AdventureCombatMgr::TrainingAvailable(Player* p) const{return Dummy(p)!=nullptr;} bool AdventureCombatMgr::Start(Player* p,Record& r,Unit* c,bool training,bool defending,time_t until) { if(!Valid(p,c,training) || !p->IsAlive() || p->IsBeingTeleported() || p->GetTransport() || p->IsInFlight() || p->IsCharmed())return false; sAdventureBehaviorMgr->InterruptForCombat(p); sClientlessLuaEngine->DismountGroundMount(p,"combat-start"); p->AttackStop();p->CastStop();p->StopMoving();p->GetMotionMaster()->Clear();p->SetStandState(UNIT_STAND_STATE_STAND); r.AI=std::make_shared(p);r.Target=c->GetGUID();r.Training=training;r.Defending=defending; r.Suspended=true;r.Until=until;r.Timer=0;r.UnreachableMs=0; p->SetSelection(c->GetGUID()); TC_LOG_INFO("server.adventurebot","COMBAT_START guid=%u target='%s' training=%u defending=%u until=%llu",p->GetGUID().GetCounter(),c->GetGUID().ToString().c_str(),training,defending,static_cast(until)); return true; } bool AdventureCombatMgr::Train(Player* p,uint32 durationMs,time_t deadline) { auto c=Dummy(p);if(!c)return false; auto now=GameTime::GetGameTime();auto until=now+std::max(10u,std::min(durationMs/1000,180u)); if(deadline && deadlineGetGUID().GetCounter()],c,true,false,until); } bool AdventureCombatMgr::Attack(Player* p,uint32 spawn) { auto c=p->GetMap()->GetCreatureBySpawnId(spawn); return !p->IsInCombat() && Safe(p,c) && Start(p,_records[p->GetGUID().GetCounter()],c,false,false,0); } void AdventureCombatMgr::DamageTaken(Player* p,Unit* attacker,uint32 damage) { auto c=attacker; if(!damage || !p->IsAlive() || !c)return; auto& r=_records[p->GetGUID().GetCounter()]; if(std::find(r.Damagers.begin(),r.Damagers.end(),c->GetGUID())==r.Damagers.end())r.Damagers.push_back(c->GetGUID()); } void AdventureCombatMgr::Finish(Player* p,Record& r) { TC_LOG_INFO("server.adventurebot","COMBAT_END guid=%u target='%s' training=%u",p->GetGUID().GetCounter(),r.Target.ToString().c_str(),r.Training); bool training=r.Training; if(!training && r.Helper)sAdventureInteractionMgr->CombatHelp(p,r.Helper); r.Helper.Clear(); p->AttackStop();p->CastStop();p->StopMoving();p->GetMotionMaster()->Clear();p->SetSelection(ObjectGuid::Empty); sClientlessTravelMgr->CancelTravel(p->GetGUID().GetCounter()); if(training && std::all_of(p->getAttackers().begin(),p->getAttackers().end(),[](Unit* u) {auto c=u->ToCreature();return c && c->GetScriptName()=="npc_training_dummy";}))p->CombatStop(true); // Only the bounded dummy scene may end its own combat. r.Fleeing=false;r.ThreatPositions.clear(); r.AI.reset();r.Target.Clear();r.Training=false;r.Defending=false;r.UnreachableMs=0;r.Cooldown=10000; } void AdventureCombatMgr::Stop(Player* p) { auto it=_records.find(p->GetGUID().GetCounter());if(it!=_records.end())Finish(p,it->second); } void AdventureCombatMgr::Offline(Player* p){_records.erase(p->GetGUID().GetCounter());} Unit* AdventureCombatMgr::PvPTarget(Player* p) const { auto profile=sClientlessLuaEngine->GetAdventureProfile(p->GetGUID().GetCounter()); if(!profile)return nullptr; auto trait=[&](char const* key) {auto it=profile->Personality.find(key);return it==profile->Personality.end()?0.0f:it->second;}; auto chance=AdventureCombatPolicy::PvPWillingness(sAdventureBehaviorMgr->CombatIntent(p), trait("adventurousness"),trait("danger_tolerance")); if(chance<=0 || p->GetHealthPct()GetPowerType()==POWER_MANA && p->GetMaxPower(POWER_MANA) && 100.0f*p->GetPower(POWER_MANA)/p->GetMaxPower(POWER_MANA)IsHostileTo(target) || !AdventureCombatPolicy::PvPLevel(p->getLevel(),target->getLevel()) || p->GetExactDist(target)>AdventureCombatPolicy::PvPRadius || (best && p->GetExactDist(target)>=p->GetExactDist(best)))return; if(auto player=target->ToPlayer()) {if(player->GetTeam()==p->GetTeam())return;} else { auto creature=target->ToCreature(); auto faction=target->GetFactionTemplateEntry(); auto enemy=p->GetTeam()==ALLIANCE?FACTION_GROUP_MASK_HORDE:FACTION_GROUP_MASK_ALLIANCE; if(!creature || creature->GetControllingPlayer() || !faction || !(faction->FactionGroup & enemy) || creature->isElite() || creature->isWorldBoss())return; } if(Reachable(p,target))best=target; }; std::vector players;p->GetPlayerListInGrid(players,AdventureCombatPolicy::PvPRadius); for(auto target:players)consider(target); std::vector creatures;p->GetCreatureListWithEntryInGrid(creatures,0,AdventureCombatPolicy::PvPRadius); for(auto target:creatures)consider(target); return best && frand(0,1)CombatIntent(p); if(intent!="adventure" && intent!="profession")return nullptr; auto profile=sClientlessLuaEngine->GetAdventureProfile(p->GetGUID().GetCounter()); if(intent=="profession" && (!profile || !profile->PrimaryProfessions.count("skinning")))return nullptr; if(intent=="adventure" && p->movespline->Finalized())return nullptr; Creature* best=nullptr;std::vector all;p->GetCreatureListWithEntryInGrid(all,0,AdventureCombatPolicy::SearchRadius); for(auto c:all) { auto faction=c->GetFactionTemplateEntry(); if(faction && (faction->FactionGroup & (FACTION_GROUP_MASK_ALLIANCE|FACTION_GROUP_MASK_HORDE)))continue; if(best && p->GetExactDist(c)>=p->GetExactDist(best))continue; if(intent=="adventure") { if(!p->IsHostileTo(c))continue; bool nearPath=false;auto path=sClientlessTravelMgr->GetActivePath(p->GetGUID().GetCounter()); if(!path)continue; auto const& points=*path; for(std::size_t i=1;iGetPositionX(),c->GetPositionY(),c->GetPositionZ(),points[i-1].GetPositionX(),points[i-1].GetPositionY(),points[i-1].GetPositionZ(),points[i].GetPositionX(),points[i].GetPositionY(),points[i].GetPositionZ())<=AdventureCombatPolicy::PathCorridor){nearPath=true;break;} if(!nearPath)continue; } else if(c->GetCreatureTemplate()->type!=CREATURE_TYPE_BEAST || !c->GetCreatureTemplate()->SkinLootId)continue; if(Safe(p,c))best=c; } return best; } bool AdventureCombatMgr::Flee(Player* p,Record& r) { if(p->GetGroup()) { if(r.Fleeing) { sClientlessTravelMgr->CancelTravel(p->GetGUID().GetCounter());p->StopMoving(); r.Fleeing=false;r.ThreatPositions.clear(); TC_LOG_INFO("server.adventurebot","COMBAT_FLEE_CANCEL guid=%u reason='group'",p->GetGUID().GetCounter()); } return false; } std::vector threats; auto add=[&](Unit* u) { if(u && u!=p && u->IsAlive() && u->IsInWorld() && p->IsInPhase(u) && p->CanSeeOrDetect(u) && std::find(threats.begin(),threats.end(),u)==threats.end())threats.push_back(u); }; for(auto u:p->getAttackers())add(u); for(auto id:r.Damagers)add(ObjectAccessor::GetUnit(*p,id)); if(r.Defending || (p->IsInCombat() && !r.Training))add(ObjectAccessor::GetUnit(*p,r.Target)); bool danger=false; for(auto u:threats) { auto c=u->ToCreature(); if(AdventureCombatPolicy::ShouldFlee(false,p->GetHealthPct(),p->getLevel(),u->getLevel(), c && c->isElite(),c && c->isWorldBoss()))danger=true; } if(!r.Fleeing && (!danger || r.Training))return false; if(!r.Fleeing) { r.Fleeing=true;r.NextEscape=0; sAdventureBehaviorMgr->InterruptForCombat(p); sClientlessLuaEngine->DismountGroundMount(p,"combat-flee"); p->AttackStop();p->CastStop();p->StopMoving();p->GetMotionMaster()->Clear(); TC_LOG_INFO("server.adventurebot","COMBAT_FLEE_START guid=%u",p->GetGUID().GetCounter()); } // Keep last known threat positions when attackers vanish from visibility. if(!threats.empty()) { r.ThreatPositions.clear(); for(auto u:threats)r.ThreatPositions.push_back(u->GetPosition()); } auto clearance=[&](Position const& point) { float distance=1000; for(auto const& threat:r.ThreatPositions)distance=std::min(distance,point.GetExactDist(threat)); return distance; }; if(!p->IsInCombat() && clearance(p->GetPosition())>=50) { r.Fleeing=false;r.ThreatPositions.clear();r.Damagers.clear(); Finish(p,r);r.Cooldown=30000; TC_LOG_INFO("server.adventurebot","COMBAT_FLEE_END guid=%u",p->GetGUID().GetCounter()); return true; } auto now=GameTime::GetGameTime(); if(now creatures;p->GetCreatureListWithEntryInGrid(creatures,0,80); std::vector players;p->GetPlayerListInGrid(players,80); std::vector nearby; for(auto c:creatures)nearby.push_back(c); for(auto other:players)nearby.push_back(other); Position best;bool found=false;float score=-1e30f; float current=clearance(p->GetPosition()); for(float radius:{18.0f,30.0f})for(unsigned i=0;i<12;++i) { float angle=i*6.2831853f/12; float x=p->GetPositionX()+radius*std::cos(angle),y=p->GetPositionY()+radius*std::sin(angle); float z=p->GetMap()->GetHeight(p->GetPhaseShift(),x,y,p->GetPositionZ()+5,true,15); if(z<=INVALID_HEIGHT)continue; Position end;end.Relocate(x,y,z); float distance=clearance(end); if(distanceIsAlive() || !p->IsInPhase(u) || !p->CanSeeOrDetect(u) || !p->IsHostileTo(u) || std::find(threats.begin(),threats.end(),u)!=threats.end())continue; if(AdventureCombatPolicy::SegmentDistance(u->GetPositionX(),u->GetPositionY(),u->GetPositionZ(), p->GetPositionX(),p->GetPositionY(),p->GetPositionZ(),x,y,z)<12){unsafe=true;break;} } if(unsafe)continue; ClientlessPathDiagnostics path; if(!sClientlessTravelMgr->AnalyzePath(p,end,path) || !path.Complete || path.Suspicious || path.PathLength>60)continue; float value=distance-0.15f*path.PathLength; if(value>score){score=value;best=end;found=true;} } sClientlessTravelMgr->CancelTravel(p->GetGUID().GetCounter()); p->AttackStop();p->CastStop(); if(found && sClientlessTravelMgr->StartTravel(p,best)) TC_LOG_INFO("server.adventurebot","COMBAT_FLEE_PATH guid=%u clearance=%.2f",p->GetGUID().GetCounter(),clearance(best)); else { p->StopMoving(); auto target=ObjectAccessor::GetUnit(*p,r.Target); if(r.AI && Valid(p,target,false) && p->IsWithinMeleeRange(target))r.AI->Tick(target,500); TC_LOG_INFO("server.adventurebot","COMBAT_FLEE_BLOCKED guid=%u",p->GetGUID().GetCounter()); } return true; } bool AdventureCombatMgr::Death(Player* p,Record& r) { if(!r.Dead) { if(r.AI)Finish(p,r); r.Fleeing=false;r.ThreatPositions.clear(); sAdventureBehaviorMgr->InterruptForCombat(p);r.Damagers.clear();r.Dead=true;r.Suspended=true; TC_LOG_INFO("server.adventurebot","DEATH_RELEASE guid=%u",p->GetGUID().GetCounter()); } if(p->InBattleground() || p->InArena() || p->IsBeingTeleported())return true; if(!p->HasFlag(PLAYER_FLAGS,PLAYER_FLAGS_GHOST)) { WorldPacket release(CMSG_REPOP_REQUEST,1);release<GetSession()->HandleRepopRequestOpcode(release); if(auto corpse=p->GetCorpse())corpse->SaveToDB(); p->SaveToDB(); return true; } if(r.CemeteryPending) { r.CemeteryPending=false; p->ResurrectPlayer(0.5f,false);p->SpawnCorpseBones(); TC_LOG_INFO("server.adventurebot","DEATH_CEMETERY_REVIVE guid=%u",p->GetGUID().GetCounter()); return true; } auto corpse=p->GetCorpse();if(!corpse)return true; auto now=GameTime::GetGameTime(); if(corpse->IsWithinDistInMap(p,CORPSE_RECLAIM_RADIUS,true)) { if(nowGetGhostTime()+p->GetCorpseReclaimDelay(corpse->GetType()==CORPSE_RESURRECTABLE_PVP))return true; WorldPacket reclaim(CMSG_RECLAIM_CORPSE,8);reclaim<GetGUID();p->GetSession()->HandleReclaimCorpseOpcode(reclaim); if(p->IsAlive())TC_LOG_INFO("server.adventurebot","DEATH_RECLAIM guid=%u",p->GetGUID().GetCounter()); return true; } auto travel=sClientlessTravelMgr->GetStatus(p->GetGUID().GetCounter()); if(travel && travel->State==ClientlessTravelState::Moving)return true; if(travel && (travel->State==ClientlessTravelState::Stuck || travel->State==ClientlessTravelState::PathRejected)) { r.FailedCorpsePoints.push_back(travel->Target); sClientlessTravelMgr->CancelTravel(p->GetGUID().GetCounter()); } if(nowGetMapId()==corpse->GetMapId() && p->GetInstanceId()==corpse->GetInstanceId())for(unsigned ring=0;ring<3;++ring)for(unsigned i=0;i<(ring?12u:1u);++i) { Position end;float radius=ring==1?20.0f:ring==2?32.0f:0.0f;float angle=i*6.2831853f/12; float x=corpse->GetPositionX()+radius*std::cos(angle),y=corpse->GetPositionY()+radius*std::sin(angle); float z=p->GetMap()->GetHeight(p->GetPhaseShift(),x,y,corpse->GetPositionZ()+3,true,8); if(z<=INVALID_HEIGHT)continue; end.Relocate(x,y,z); if(end.GetExactDist(corpse)>CORPSE_RECLAIM_RADIUS-2)continue; if(std::any_of(r.FailedCorpsePoints.begin(),r.FailedCorpsePoints.end(),[&](Position const& failed){return failed.GetExactDist(end)<3;}))continue; ClientlessPathDiagnostics path; if(sClientlessTravelMgr->AnalyzePath(p,end,path) && path.Complete && !path.Suspicious && path.PathLengthStartTravel(p,best); TC_LOG_INFO("server.adventurebot","DEATH_CORPSE_RUN guid=%u path=%.2f",p->GetGUID().GetCounter(),shortest); } else if(AdventureCombatPolicy::CemeteryFallback(now,corpse->GetGhostTime(),true)) { // Native nearest graveyard relocation. No healer interaction, sickness, // durability repair/loss or resource cheat; native resurrection at 50%. r.CemeteryPending=true; p->RepopAtGraveyard(); } return true; } bool AdventureCombatMgr::Update(Player* p,uint32 diff) { if(!p->IsInWorld())return false; auto& r=_records[p->GetGUID().GetCounter()]; r.Cooldown=r.Cooldown>diff?r.Cooldown-diff:0; if(p->IsBeingTeleported() || p->GetTransport() || p->IsInFlight() || p->IsCharmed())return r.Fleeing || r.AI || !p->IsAlive(); if(r.Timer>diff){r.Timer-=diff;return r.Fleeing || r.AI || !p->IsAlive() || r.Dead;} r.Timer=500; if(!p->IsAlive())return Death(p,r); if(r.Dead){r.Dead=false;r.Suspended=false;r.NextCorpseSearch=0;r.FailedCorpsePoints.clear();r.Cooldown=30000;r.Timer=0;return false;} // First actual damager wins, including interrupting a proactive/training // target. Keep all subsequent damagers ordered for after that focus dies. if(!r.Defending)while(!r.Damagers.empty()) { auto id=r.Damagers.front();r.Damagers.pop_front();auto c=ObjectAccessor::GetUnit(*p,id); if(!Valid(p,c,false))continue; if(r.AI)Finish(p,r); Start(p,r,c,false,true,0);break; } if(Flee(p,r))return true; if(!r.AI && (!p->GetGroup() || p->GetGroup()->GetLeaderGUID()==p->GetGUID()) && !p->IsInCombat() && !r.Cooldown) { auto now=GameTime::GetGameTime(); if(now>=r.NextPvP) { r.NextPvP=now+30; if(auto target=PvPTarget(p)) { TC_LOG_INFO("server.adventurebot","COMBAT_PVP_INITIATE guid=%u target='%s'",p->GetGUID().GetCounter(),target->GetGUID().ToString().c_str()); Start(p,r,target,false,false,0); } } if(!r.AI && !p->GetGroup())if(auto c=Proactive(p))Start(p,r,c,false,false,0); } if(!r.AI)return false; auto c=ObjectAccessor::GetUnit(*p,r.Target); if(!Valid(p,c,r.Training) || (r.Until && GameTime::GetGameTime()>=r.Until) || p->GetExactDist(c)>80) {Finish(p,r);return true;} if(!Reachable(p,c)) { r.UnreachableMs+=500; if(r.UnreachableMs>=20000){TC_LOG_WARN("server.adventurebot","COMBAT_TARGET_UNREACHABLE guid=%u",p->GetGUID().GetCounter());Finish(p,r);} return true; } r.UnreachableMs=0; if(!p->IsWithinMeleeRange(c)) { auto nav=sClientlessTravelMgr->GetStatus(p->GetGUID().GetCounter()); if(!nav || nav->State!=ClientlessTravelState::Moving || nav->Target.GetExactDist(c)>5) { Position end;c->GetNearPoint(p,end.m_positionX,end.m_positionY,end.m_positionZ,0.5f,c->GetAngle(p)); sClientlessTravelMgr->StartTravel(p,end); } return true; } sClientlessTravelMgr->CancelTravel(p->GetGUID().GetCounter()); p->StopMoving();r.AI->Tick(c,500); return true; } bool AdventureCombatMgr::Busy(Player* player) const { auto it=_records.find(player->GetGUID().GetCounter()); return it!=_records.end() && (it->second.Fleeing || it->second.AI || it->second.Dead || !it->second.Damagers.empty()); } bool AdventureCombatMgr::Assist(Player* p,Unit* target) { if(!p->GetGroup() || Busy(p) || !Valid(p,target,false) || p->GetExactDist(target)>40 || !Reachable(p,target) || p->GetHealthPct()<30)return false; if(auto c=target->ToCreature()) if(c->isWorldBoss() || c->getLevel()>p->getLevel()+2)return false; return Start(p,_records[p->GetGUID().GetCounter()],target,false,false,0); } void AdventureCombatMgr::SharedTargetDamage(Player* p,ObjectGuid helper,ObjectGuid target) { auto it=_records.find(p->GetGUID().GetCounter()); if(it!=_records.end() && it->second.Target==target && !it->second.Training)it->second.Helper=helper; }