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}"); } }); } }