using Ananta.SDK.Network;
using Ananta.SDK.Serialization;
using Ananta.Server.Protocol.Client4229938;
using Ananta.Server.RpcTypes.Client4229938;
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
namespace Ananta.Server.Handlers.Game;
///
/// Server-authoritative player vitals: HP ledger, damage settlement, knock-out and respawn.
///
/// Why this lives on the server and not in the client's local settlement: enemy casts are
/// fabricated by us (GameRouter.EnemyAiActions), so nothing on the client ever resolves a real
/// hit against the player — the "damage" is animation only. Without this file the player's HP
/// bar is decoration, which is exactly why the knocked-out HUD could sit at 100% forever with
/// nothing able to write a full value back.
///
/// Damage only lands when the GM panel releases the vitals lock (default: locked).
///
internal sealed partial class GameRouter
{
///
/// Damage one enemy hit deals. Mirrors LocalEnemyHitDamage (100) against a 1000 HP pool, so
/// ten swings drop the player — the same count the player needs to drop an enemy.
///
internal const float PlayerHitDamage = 100f;
/// How long the player stays knocked out before the server revives them.
internal const int PlayerDownRespawnDelayMs = 8000;
/// HP floor while the vitals lock is on: the bar can drain, but never empties.
internal const float PlayerVitalsLockFloorHp = 1f;
///
/// Settles one enemy hit on the player.
///
/// Called from the attack AI at the moment a cast goes out (delayed into the animation window,
/// because the client never reports a hit). The bar always moves — that is the whole point of
/// making the HP server-authoritative. What the vitals lock changes is whether it can reach
/// zero: locked, the drain stops at and the player never
/// goes down; unlocked, zero means knock-out and the respawn timer.
///
internal static async Task ApplyEnemyHitToPlayerAsync(
TcpSession session, WorldEntryState state, ulong enemyUid, uint skillId)
{
float hp;
bool locked;
lock (state.SyncRoot)
{
if (state.PlayerDowned)
return;
locked = state.PlayerLifeLocked;
var floor = locked ? PlayerVitalsLockFloorHp : 0f;
hp = MathF.Max(floor, state.PlayerHp - PlayerHitDamage);
state.PlayerHp = hp;
}
await PublishPlayerHpAsync(session, state, hp);
session.Log.Info($"[PLAYER-HIT] enemy={enemyUid} skill={skillId} damage={PlayerHitDamage} hp={hp} locked={locked}");
if (hp <= 0f)
await EnterPlayerDownedAsync(session, state);
}
///
/// Writes the authoritative HP onto every id that can own a vitals bar, attribute table first
/// (hard rule 52) so the value is not clamped against a stale max.
///
internal static async Task PublishPlayerHpAsync(TcpSession session, WorldEntryState state, float hp)
{
var units = PlayerVitalsUnits(state);
foreach (var unit in units)
{
await session.NotifyAsync(MethodId.SyncUnitAttrs,
UxSerializer.Serialize(CombatCodec.UnitAttrs(unit, CombatCodec.MaxHp)), CancellationToken.None);
await session.NotifyAsync(MethodId.SyncUnitHp,
UxSerializer.Serialize(CombatCodec.UnitHp(unit, hp)), CancellationToken.None);
}
}
///
/// The unit every packet to the player is addressed to. There is only one observable id space:
/// AskUseSkill (Releaser), AskMultipleSkillHit2 (ReleaserId) and AskReportLogicAgentSyncData
/// (AgentId) all report Profile.InitialUnitId, so this is the single target for vitals writes.
///
internal static List PlayerVitalsUnits(WorldEntryState state)
{
var units = new List
{
state.ActiveSpiritUnitId != 0 ? state.ActiveSpiritUnitId : Profile.InitialUnitId,
};
return units;
}
///
/// HP reached zero: put the player into the knocked-out state the client already knows how to
/// render, and schedule the revive. Nothing here walks the retail death pipeline
/// (AskExitFallingDownToDeath) — that path has been observed black-screening on this build.
///
private static async Task EnterPlayerDownedAsync(TcpSession session, WorldEntryState state)
{
ulong unit;
lock (state.SyncRoot)
{
if (state.PlayerDowned)
return;
state.PlayerDowned = true;
unit = state.ActiveSpiritUnitId != 0 ? state.ActiveSpiritUnitId : Profile.InitialUnitId;
}
await session.NotifyAsync(MethodId.IGameSceneToClient_SyncPlayerFallingDownState,
UxSerializer.Serialize(new SceneMethods.SyncPlayerFallingDownState { uid = unit, isEnter = true }),
CancellationToken.None);
session.Log.Info($"[PLAYER-DOWN] unit={unit} hp=0 respawnInMs={PlayerDownRespawnDelayMs}");
// Fire-and-forget: the player stays down on their own timer, and a session that goes away
// mid-wait must not take the AI worker with it.
_ = Task.Run(async () =>
{
try
{
await Task.Delay(PlayerDownRespawnDelayMs, CancellationToken.None);
await GameAdminBridge.RestorePlayerHealthAsync();
}
catch (Exception ex)
{
session.Log.Warn($"[PLAYER-DOWN] auto-respawn failed: {ex.Message}");
}
});
}
}