using System;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using ACE.Common;
using ACE.Entity.Enum;
using ACE.Entity.Enum.Properties;
using ACE.Entity.Models;
using ACE.Server.Entity;
using ACE.Server.Entity.Actions;
using ACE.Server.Factories;
using ACE.Server.Managers;
using ACE.Server.Network.Enum;
using ACE.Server.Network.GameMessages.Messages;
using ACE.Server.Network.Sequence;
using ACE.Server.Network.Structure;
using ACE.Server.Physics;
using ACE.Server.Physics.Common;
namespace ACE.Server.WorldObjects
{
partial class Player
{
private readonly ActionQueue actionQueue = new ActionQueue();
private int initialAge;
private DateTime initialAgeTime;
private const double ageUpdateInterval = 7;
private double nextAgeUpdateTime;
private double houseRentWarnTimestamp;
private const double houseRentWarnInterval = 3600;
public void Player_Tick(double currentUnixTime)
{
if (CharacterSaveFailed)
{
// Boot the player as their Character object is not saving properly
if (!IsLoggingOut)
{
log.Error($"{Session.Player.Name} | 0x{Guid} | Account: {Account.AccountName} - disconnected for CharacterSaveFailed");
//Session.SendCharacterError(CharacterError.AccountLogin); // forces client to error screen
Session.Terminate(SessionTerminationReason.CharacterSaveFailed, new GameMessageCharacterError(CharacterError.AccountLogin));
//Session.LogOffPlayer(true);
CharacterSaveFailed = false;
}
return;
}
if (BiotaSaveFailed)
{
// Boot the player as their Biota object is not saving properly
if (!IsLoggingOut)
{
log.Error($"{Session.Player.Name} | 0x{Guid} | Account: {Account.AccountName} - disconnected for BiotaSaveFailed");
//Session.SendCharacterError(CharacterError.AccountLogin); // forces client to error screen
Session.Terminate(SessionTerminationReason.BiotaSaveFailed, new GameMessageCharacterError(CharacterError.AccountLogin));
//Session.LogOffPlayer(true);
BiotaSaveFailed = false;
}
return;
}
actionQueue.RunActions();
if (nextAgeUpdateTime <= currentUnixTime)
{
nextAgeUpdateTime = currentUnixTime + ageUpdateInterval;
if (initialAgeTime == DateTime.MinValue)
{
initialAge = Age ?? 1;
initialAgeTime = DateTime.UtcNow;
}
Age = initialAge + (int)(DateTime.UtcNow - initialAgeTime).TotalSeconds;
// In retail, this is sent every 7 seconds. If you adjust ageUpdateInterval from 7, you'll need to re-add logic to send this every 7s (if you want to match retail)
Session.Network.EnqueueSend(new GameMessagePrivateUpdatePropertyInt(this, PropertyInt.Age, Age ?? 1));
}
if (FellowVitalUpdate && Fellowship != null)
{
Fellowship.OnVitalUpdate(this);
FellowVitalUpdate = false;
}
if (House != null && PropertyManager.GetBool("house_rent_enabled").Item)
{
if (houseRentWarnTimestamp > 0 && currentUnixTime > houseRentWarnTimestamp)
{
HouseManager.GetHouse(House.Guid.Full, (house) =>
{
if (house != null && house.HouseStatus == HouseStatus.Active && !house.SlumLord.IsRentPaid())
Session.Network.EnqueueSend(new GameMessageSystemChat($"Warning! You have not paid your maintenance costs for the last {(house.IsApartment ? "90" : "30")} day maintenance period. Please pay these costs by this deadline or you will lose your house, and all your items within it.", ChatMessageType.Broadcast));
});
houseRentWarnTimestamp = Time.GetFutureUnixTime(houseRentWarnInterval);
}
else if (houseRentWarnTimestamp == 0)
houseRentWarnTimestamp = Time.GetFutureUnixTime(houseRentWarnInterval);
}
}
private static readonly TimeSpan MaximumTeleportTime = TimeSpan.FromMinutes(5);
///
/// Called every ~5 seconds for Players
///
public override void Heartbeat(double currentUnixTime)
{
NotifyLandblocks();
ManaConsumersTick();
HandleTargetVitals();
LifestoneProtectionTick();
PK_DeathTick();
GagsTick();
PhysicsObj.ObjMaint.DestroyObjects();
// Check if we're due for our periodic SavePlayer
if (LastRequestedDatabaseSave == DateTime.MinValue)
LastRequestedDatabaseSave = DateTime.UtcNow;
if (LastRequestedDatabaseSave.AddSeconds(PlayerSaveIntervalSecs) <= DateTime.UtcNow)
SavePlayerToDatabase();
if (Teleporting && DateTime.UtcNow > Time.GetDateTimeFromTimestamp(LastTeleportStartTimestamp ?? 0).Add(MaximumTeleportTime))
{
if (Session != null)
Session.LogOffPlayer(true);
else
LogOut();
}
base.Heartbeat(currentUnixTime);
}
public static float MaxSpeed = 50;
public static float MaxSpeedSq = MaxSpeed * MaxSpeed;
public static bool DebugPlayerMoveToStatePhysics { get; set; } = false;
///
/// Flag indicates if player is doing full physics simulation
///
public bool FastTick => IsPKType;
///
/// For advanced spellcasting / players glitching around during powersliding,
/// the reason for this retail bug is from 2 different functions for player movement
///
/// The client's self-player uses DoMotion/StopMotion
/// The server and other players on the client use apply_raw_movement
///
/// When a 3+ button powerslide is performed, this bugs out apply_raw_movement,
/// and causes the player to spin in place. With DoMotion/StopMotion, it performs a powerslide.
///
/// With this option enabled (retail defaults to false), the player's position on the server
/// will match up closely with the player's client during powerslides.
///
/// Since the client uses apply_raw_movement to simulate the movement of nearby players,
/// the other players will still glitch around on screen, even with this option enabled.
///
/// If you wish for the positions of other players to be less glitchy, the 'MoveToState_UpdatePosition_Threshold'
/// can be lowered to achieve that
///
public void OnMoveToState(MoveToState moveToState)
{
if (!FastTick)
return;
if (DebugPlayerMoveToStatePhysics)
Console.WriteLine(moveToState.RawMotionState);
if (RecordCast.Enabled)
RecordCast.OnMoveToState(moveToState);
if (!PhysicsObj.IsMovingOrAnimating)
PhysicsObj.UpdateTime = PhysicsTimer.CurrentTime;
if (!PropertyManager.GetBool("client_movement_formula").Item || moveToState.StandingLongJump)
OnMoveToState_ServerMethod(moveToState);
else
OnMoveToState_ClientMethod(moveToState);
if (MagicState.IsCasting && MagicState.PendingTurnRelease && moveToState.RawMotionState.TurnCommand == 0)
OnTurnRelease();
}
public void OnMoveToState_ClientMethod(MoveToState moveToState)
{
var rawState = moveToState.RawMotionState;
var prevState = LastMoveToState?.RawMotionState ?? RawMotionState.None;
var mvp = new Physics.Animation.MovementParameters();
mvp.HoldKeyToApply = rawState.CurrentHoldKey;
if (!PhysicsObj.IsMovingOrAnimating)
PhysicsObj.UpdateTime = PhysicsTimer.CurrentTime;
// ForwardCommand
if (rawState.ForwardCommand != MotionCommand.Invalid)
{
// press new key
if (prevState.ForwardCommand == MotionCommand.Invalid)
{
PhysicsObj.DoMotion((uint)MotionCommand.Ready, mvp);
PhysicsObj.DoMotion((uint)rawState.ForwardCommand, mvp);
}
// press alternate key
else if (prevState.ForwardCommand != rawState.ForwardCommand)
{
PhysicsObj.DoMotion((uint)rawState.ForwardCommand, mvp);
}
}
else if (prevState.ForwardCommand != MotionCommand.Invalid)
{
// release key
PhysicsObj.StopMotion((uint)prevState.ForwardCommand, mvp, true);
}
// StrafeCommand
if (rawState.SidestepCommand != MotionCommand.Invalid)
{
// press new key
if (prevState.SidestepCommand == MotionCommand.Invalid)
{
PhysicsObj.DoMotion((uint)rawState.SidestepCommand, mvp);
}
// press alternate key
else if (prevState.SidestepCommand != rawState.SidestepCommand)
{
PhysicsObj.DoMotion((uint)rawState.SidestepCommand, mvp);
}
}
else if (prevState.SidestepCommand != MotionCommand.Invalid)
{
// release key
PhysicsObj.StopMotion((uint)prevState.SidestepCommand, mvp, true);
}
// TurnCommand
if (rawState.TurnCommand != MotionCommand.Invalid)
{
// press new key
if (prevState.TurnCommand == MotionCommand.Invalid)
{
PhysicsObj.DoMotion((uint)rawState.TurnCommand, mvp);
}
// press alternate key
else if (prevState.TurnCommand != rawState.TurnCommand)
{
PhysicsObj.DoMotion((uint)rawState.TurnCommand, mvp);
}
}
else if (prevState.TurnCommand != MotionCommand.Invalid)
{
// release key
PhysicsObj.StopMotion((uint)prevState.TurnCommand, mvp, true);
}
}
public void OnMoveToState_ServerMethod(MoveToState moveToState)
{
var minterp = PhysicsObj.get_minterp();
minterp.RawState.SetState(moveToState.RawMotionState);
if (moveToState.StandingLongJump)
{
minterp.RawState.ForwardCommand = (uint)MotionCommand.Ready;
minterp.RawState.SideStepCommand = 0;
}
var allowJump = minterp.motion_allows_jump(minterp.InterpretedState.ForwardCommand) == WeenieError.None;
//PhysicsObj.cancel_moveto();
minterp.apply_raw_movement(true, allowJump);
}
public bool InUpdate;
public override bool UpdateObjectPhysics()
{
try
{
stopwatch.Restart();
bool landblockUpdate = false;
InUpdate = true;
// update position through physics engine
if (RequestedLocation != null)
{
landblockUpdate = UpdatePlayerPosition(RequestedLocation);
RequestedLocation = null;
}
if (FastTick && PhysicsObj.IsMovingOrAnimating || PhysicsObj.Velocity != Vector3.Zero)
{
UpdatePlayerPhysics();
if (MoveToParams?.Callback != null && !PhysicsObj.IsMovingOrAnimating)
HandleMoveToCallback();
}
InUpdate = false;
return landblockUpdate;
}
finally
{
var elapsedSeconds = stopwatch.Elapsed.TotalSeconds;
ServerPerformanceMonitor.AddToCumulativeEvent(ServerPerformanceMonitor.CumulativeEventHistoryType.Player_Tick_UpdateObjectPhysics, elapsedSeconds);
if (elapsedSeconds >= 1) // Yea, that ain't good....
log.Warn($"[PERFORMANCE][PHYSICS] {Guid}:{Name} took {(elapsedSeconds * 1000):N1} ms to process UpdateObjectPhysics() at loc: {Location}");
else if (elapsedSeconds >= 0.010)
log.DebugFormat("[PERFORMANCE][PHYSICS] {0}:{1} took {2:N1} ms to process UpdateObjectPhysics() at loc: {3}", Guid, Name, (elapsedSeconds * 1000), Location);
}
}
public void UpdatePlayerPhysics()
{
if (DebugPlayerMoveToStatePhysics)
Console.WriteLine($"{Name}.UpdatePlayerPhysics({PhysicsObj.PartArray.Sequence.CurrAnim.Value.Anim.ID:X8})");
//Console.WriteLine($"{PhysicsObj.Position.Frame.Origin}");
//Console.WriteLine($"{PhysicsObj.Position.Frame.get_heading()}");
PhysicsObj.update_object();
// sync ace position?
Location.Rotation = PhysicsObj.Position.Frame.Orientation;
if (!FastTick) return;
// ensure PKLogout position is synced up for other players
if (PKLogout)
{
EnqueueBroadcast(new GameMessageUpdateMotion(this, new Motion(MotionStance.NonCombat, MotionCommand.Ready)));
PhysicsObj.StopCompletely(true);
if (!PhysicsObj.IsMovingOrAnimating)
{
SyncLocation();
EnqueueBroadcast(new GameMessageUpdatePosition(this));
}
}
// this fixes some differences between client movement (DoMotion/StopMotion) and server movement (apply_raw_movement)
//
// scenario: start casting a self-spell, and then immediately start holding the run forward key during the windup
// on client: player will start running forward after the cast has completed
// on server: player will stand still
// this block of code can improve the sync between these 2 methods,
// however there are some bugs that originate in acclient that cannot be resolved on the server
// for example, equip a wand, and then start running forward in non-combat mode. switch to magic combat mode, and then release forward during the stance swap
// the client will never send a 'client released forward' MoveToState in this scenario unfortunately.
// because of this, it's better to have the 'client blip forward' bug without it, than to have the client invisibly running forward on the server.
// commenting out this block because of this...
/*if (!PhysicsObj.IsMovingOrAnimating && LastMoveToState != null)
{
// apply latest MoveToState, if applicable
//if ((LastMoveToState.RawMotionState.Flags & (RawMotionFlags.ForwardCommand | RawMotionFlags.SideStepCommand | RawMotionFlags.TurnCommand)) != 0)
if ((LastMoveToState.RawMotionState.Flags & RawMotionFlags.ForwardCommand) != 0 && LastMoveToState.RawMotionState.ForwardHoldKey == HoldKey.Invalid)
{
if (DebugPlayerMoveToStatePhysics)
Console.WriteLine("Re-applying movement: " + LastMoveToState.RawMotionState.Flags);
OnMoveToState(LastMoveToState);
// re-broadcast MoveToState to other clients only
EnqueueBroadcast(false, new GameMessageUpdateMotion(this, CurrentMovementData));
}
LastMoveToState = null;
}*/
if (MagicState.IsCasting && MagicState.PendingTurnRelease)
CheckTurn();
}
///
/// The maximum rate UpdatePosition packets from MoveToState will be broadcast for each player
/// AutonomousPosition still always broadcasts UpdatePosition
///
/// The default value (1 second) was estimated from this retail video:
/// https://youtu.be/o5lp7hWhtWQ?t=112
///
/// If you wish for players to glitch around less during powerslides, lower this value
///
public static TimeSpan MoveToState_UpdatePosition_Threshold = TimeSpan.FromSeconds(1);
///
/// Used by physics engine to actually update a player position
/// Automatically notifies clients of updated position
///
/// The new position being requested, before verification through physics engine
/// TRUE if object moves to a different landblock
public bool UpdatePlayerPosition(ACE.Entity.Position newPosition, bool forceUpdate = false)
{
//Console.WriteLine($"{Name}.UpdatePlayerPhysics({newPosition}, {forceUpdate}, {Teleporting})");
bool verifyContact = false;
// possible bug: while teleporting, client can still send AutoPos packets from old landblock
if (Teleporting && !forceUpdate) return false;
// pre-validate movement
if (!ValidateMovement(newPosition))
{
log.Error($"{Name}.UpdatePlayerPosition() - movement pre-validation failed from {Location} to {newPosition}");
return false;
}
try
{
if (!forceUpdate) // This is needed beacuse this function might be called recursively
stopwatch.Restart();
var success = true;
if (PhysicsObj != null)
{
var distSq = Location.SquaredDistanceTo(newPosition);
if (distSq > PhysicsGlobals.EpsilonSq)
{
/*var p = new Physics.Common.Position(newPosition);
var dist = PhysicsObj.Position.Distance(p);
Console.WriteLine($"Dist: {dist}");*/
if (newPosition.Landblock == 0x18A && Location.Landblock != 0x18A)
log.Info($"{Name} is getting swanky");
if (!Teleporting)
{
var blockDist = PhysicsObj.GetBlockDist(Location.Cell, newPosition.Cell);
// verify movement
if (distSq > MaxSpeedSq && blockDist > 1)
{
//Session.Network.EnqueueSend(new GameMessageSystemChat("Movement error", ChatMessageType.Broadcast));
log.Warn($"MOVEMENT SPEED: {Name} trying to move from {Location} to {newPosition}, speed: {Math.Sqrt(distSq)}");
return false;
}
// verify z-pos
if (blockDist == 0 && LastGroundPos != null && newPosition.PositionZ - LastGroundPos.PositionZ > 10 && DateTime.UtcNow - LastJumpTime > TimeSpan.FromSeconds(1) && GetCreatureSkill(Skill.Jump).Current < 1000)
verifyContact = true;
}
var curCell = LScape.get_landcell(newPosition.Cell);
if (curCell != null)
{
//if (PhysicsObj.CurCell == null || curCell.ID != PhysicsObj.CurCell.ID)
//PhysicsObj.change_cell_server(curCell);
PhysicsObj.set_request_pos(newPosition.Pos, newPosition.Rotation, curCell, Location.LandblockId.Raw);
if (FastTick)
success = PhysicsObj.update_object_server_new();
else
success = PhysicsObj.update_object_server();
if (PhysicsObj.CurCell == null && curCell.ID >> 16 != 0x18A)
{
PhysicsObj.CurCell = curCell;
}
if (verifyContact && IsJumping)
{
var blockDist = PhysicsObj.GetBlockDist(newPosition.Cell, LastGroundPos.Cell);
if (blockDist <= 1)
{
log.Warn($"z-pos hacking detected for {Name}, lastGroundPos: {LastGroundPos.ToLOCString()} - requestPos: {newPosition.ToLOCString()}");
Location = new ACE.Entity.Position(LastGroundPos);
Sequences.GetNextSequence(SequenceType.ObjectForcePosition);
SendUpdatePosition();
return false;
}
}
CheckMonsters();
}
}
else
PhysicsObj.Position.Frame.Orientation = newPosition.Rotation;
}
// double update path: landblock physics update -> updateplayerphysics() -> update_object_server() -> Teleport() -> updateplayerphysics() -> return to end of original branch
if (Teleporting && !forceUpdate) return true;
if (!success) return false;
var landblockUpdate = Location.Cell >> 16 != newPosition.Cell >> 16;
Location = newPosition;
if (RecordCast.Enabled)
RecordCast.Log($"CurPos: {Location.ToLOCString()}");
if (RequestedLocationBroadcast || DateTime.UtcNow - LastUpdatePosition >= MoveToState_UpdatePosition_Threshold)
SendUpdatePosition();
else
Session.Network.EnqueueSend(new GameMessageUpdatePosition(this));
if (!InUpdate)
LandblockManager.RelocateObjectForPhysics(this, true);
return landblockUpdate;
}
finally
{
if (!forceUpdate) // This is needed beacuse this function might be called recursively
{
var elapsedSeconds = stopwatch.Elapsed.TotalSeconds;
ServerPerformanceMonitor.AddToCumulativeEvent(ServerPerformanceMonitor.CumulativeEventHistoryType.Player_Tick_UpdateObjectPhysics, elapsedSeconds);
if (elapsedSeconds >= 0.100) // Yea, that ain't good....
log.Warn($"[PERFORMANCE][PHYSICS] {Guid}:{Name} took {(elapsedSeconds * 1000):N1} ms to process UpdatePlayerPosition() at loc: {Location}");
else if (elapsedSeconds >= 0.010)
log.DebugFormat("[PERFORMANCE][PHYSICS] {0}:{1} took {2:N1} ms to process UpdatePlayerPosition() at loc: {3}", Guid, Name, (elapsedSeconds * 1000), Location);
}
}
}
private static HashSet buggedCells = new HashSet()
{
0xD6990112,
0xD599012C
};
public bool ValidateMovement(ACE.Entity.Position newPosition)
{
if (CurrentLandblock == null)
return false;
if (!Teleporting && Location.Landblock != newPosition.Cell >> 16)
{
if ((Location.Cell & 0xFFFF) >= 0x100 && (newPosition.Cell & 0xFFFF) >= 0x100)
{
if (!buggedCells.Contains(Location.Cell) || !buggedCells.Contains(newPosition.Cell))
return false;
}
if (CurrentLandblock.IsDungeon)
{
var destBlock = LScape.get_landblock(newPosition.Cell);
if (destBlock != null && destBlock.IsDungeon)
return false;
}
}
return true;
}
public bool SyncLocationWithPhysics()
{
if (PhysicsObj.CurCell == null)
{
Console.WriteLine($"{Name}.SyncLocationWithPhysics(): CurCell is null!");
return false;
}
var blockcell = PhysicsObj.Position.ObjCellID;
var pos = PhysicsObj.Position.Frame.Origin;
var rotate = PhysicsObj.Position.Frame.Orientation;
var landblockUpdate = blockcell << 16 != CurrentLandblock.Id.Landblock;
Location = new ACE.Entity.Position(blockcell, pos, rotate);
return landblockUpdate;
}
private bool gagNoticeSent = false;
public void GagsTick()
{
if (IsGagged)
{
if (!gagNoticeSent)
{
SendGagNotice();
gagNoticeSent = true;
}
// check for gag expiration, if expired, remove gag.
GagDuration -= CachedHeartbeatInterval;
if (GagDuration <= 0)
{
IsGagged = false;
GagTimestamp = 0;
GagDuration = 0;
SaveBiotaToDatabase();
SendUngagNotice();
gagNoticeSent = false;
}
}
}
///
/// Prepare new action to run on this player
///
public override void EnqueueAction(IAction action)
{
actionQueue.EnqueueAction(action);
}
///
/// Called every ~5 secs for equipped mana consuming items
///
public void ManaConsumersTick()
{
if (!EquippedObjectsLoaded) return;
foreach (var item in EquippedObjects.Values)
{
if (!item.IsAffecting)
continue;
if (item.ItemCurMana == null || item.ItemMaxMana == null || item.ManaRate == null)
continue;
var burnRate = -item.ManaRate.Value;
if (LumAugItemManaUsage != 0)
burnRate *= GetNegativeRatingMod(LumAugItemManaUsage * 5);
item.ItemManaRateAccumulator += (float)(burnRate * CachedHeartbeatInterval);
if (item.ItemManaRateAccumulator < 1)
continue;
var manaToBurn = (int)Math.Floor(item.ItemManaRateAccumulator);
if (manaToBurn > item.ItemCurMana)
manaToBurn = item.ItemCurMana.Value;
item.ItemCurMana -= manaToBurn;
item.ItemManaRateAccumulator -= manaToBurn;
if (item.ItemCurMana > 0)
CheckLowMana(item, burnRate);
else
HandleManaDepleted(item);
}
}
private bool CheckLowMana(WorldObject item, double burnRate)
{
const int lowManaWarningSeconds = 120;
var secondsUntilEmpty = item.ItemCurMana / burnRate;
if (secondsUntilEmpty > lowManaWarningSeconds)
{
item.ItemManaDepletionMessage = false;
return false;
}
if (!item.ItemManaDepletionMessage)
{
Session.Network.EnqueueSend(new GameMessageSystemChat($"Your {item.Name} is low on Mana.", ChatMessageType.Magic));
item.ItemManaDepletionMessage = true;
}
return true;
}
private void HandleManaDepleted(WorldObject item)
{
var msg = new GameMessageSystemChat($"Your {item.Name} is out of Mana.", ChatMessageType.Magic);
var sound = new GameMessageSound(Guid, Sound.ItemManaDepleted);
Session.Network.EnqueueSend(msg, sound);
// unsure if these messages / sounds were ever sent in retail,
// or if it just purged the enchantments invisibly
// doing a delay here to prevent 'SpellExpired' sounds from overlapping with 'ItemManaDepleted'
var actionChain = new ActionChain();
actionChain.AddDelaySeconds(2.0f);
actionChain.AddAction(this, () =>
{
foreach (var spellId in item.Biota.GetKnownSpellsIds(item.BiotaDatabaseLock))
RemoveItemSpell(item, (uint)spellId);
});
actionChain.EnqueueChain();
item.OnSpellsDeactivated();
}
public override void HandleMotionDone(uint motionID, bool success)
{
//Console.WriteLine($"{Name}.HandleMotionDone({(MotionCommand)motionID}, {success})");
if (!FastTick) return;
if (FoodState.IsChugging)
HandleMotionDone_UseConsumable(motionID, success);
if (MagicState.IsCasting)
HandleMotionDone_Magic(motionID, success);
}
}
}