diff --git a/apps/utility-sw/src/Commands/Practice.cs b/apps/utility-sw/src/Commands/Practice.cs index f5c37d6..9274c5c 100644 --- a/apps/utility-sw/src/Commands/Practice.cs +++ b/apps/utility-sw/src/Commands/Practice.cs @@ -144,6 +144,7 @@ private void SaveThrow(ulong steamId, string name) // becomes the loaded one and gets its markers straight away. PracticeState saved = _system.StateFor(steamId); + saved.Cleared = false; saved.Loaded = thrown; saved.Results.Clear(); saved.Results.Add(thrown); @@ -316,6 +317,11 @@ public void OnPrev(ICommandContext context) Step(context, -1); } + // sv_rethrow_last_grenade, which is what the word means everywhere else: + // the grenade goes again from where it was thrown and the player stays put, + // so they can stand off to the side and watch the line they cannot see + // while making it. .load, .last and .back are the commands that move you -- + // this one moving you as well is what left it with nothing of its own to do. [Command("rethrow", registerRaw: false, permission: "")] public void OnRethrow(ICommandContext context) { @@ -325,17 +331,66 @@ public void OnRethrow(ICommandContext context) { return; } - _logger.LogInformation("[nade-render] OnRethrow for {steam}", player.SteamID); - LineupRecord? loaded = _system.StateFor(player.SteamID).Loaded; + if (!_config.ReplayEnabled) + { + Reply(context, $" {ChatColors.Red}replay is disabled on this server"); + return; + } - if (loaded == null) + ulong steamId = player.SteamID; + + // The loaded lineup first, the player's own last throw second: after a + // .load the reference throw is the one worth seeing again, and with + // nothing loaded "rethrow" can only mean the grenade they just threw. + // Answering that one with "nothing loaded" is most of why this read as + // a command that did not work. + LineupRecord? lineup = _system.StateFor(steamId).Loaded ?? _recorder.LastThrow(steamId); + + if (lineup == null) + { + Reply( + context, + $" {ChatColors.Red}throw something first, or {ChatColors.Default}.load" + + $" {ChatColors.Red}a lineup" + ); + + return; + } + + CCSPlayerPawn? pawn = player.PlayerPawn; + + if (pawn == null || !pawn.IsValid) { - Reply(context, $" {ChatColors.Red}nothing loaded"); + Reply(context, $" {ChatColors.Red}you have to be alive to rethrow"); return; } - Apply(player, loaded); + // A lineup fitted to a demo has a path but no seed, so there is nothing + // to hand the engine. Said out loud rather than logged: a command that + // silently does nothing is the bug being fixed here. + if (!lineup.IsExactlyReplayable()) + { + Reply( + context, + $" {ChatColors.Yellow}{DrillUtility.Name(lineup)} {ChatColors.Grey}has no recorded" + + $" throw to replay -- {ChatColors.Default}.load{ChatColors.Grey} it and" + + $" throw it yourself" + ); + + return; + } + + _logger.LogInformation("[nade-render] OnRethrow for {steam}", steamId); + + // Forced: np_ghost_projectile decides whether .load and .next throw as + // they go, but a player who typed the word has already asked. + _replay.ThrowGhostProjectile(player, lineup, force: true); + + Reply( + context, + $" {ChatColors.Green}rethrown {ChatColors.Default}{DrillUtility.Name(lineup)}" + ); } [Command("last", registerRaw: false, permission: "")] @@ -414,17 +469,36 @@ public void OnClear(ICommandContext context) state.Index = -1; state.Bloom = false; + // Held until the next .load, .next, walk-on or save. Sweeping alone + // left the spot watcher free to redraw the spot under their feet on + // its next pass -- a quarter of a second, or the first time they + // glanced at another ring -- which is why .clear read as broken. + state.Cleared = true; + _replay.ClearGhosts(player.SteamID); + // What everybody else's .clear means, and what a player standing in + // their own smoke is asking for. The ping key does this on its own -- + // this is the same call, for anybody who types it. + int thrown = _replay.ClearThrownUtility(); + // Swept rather than cleared: ClearMarkers can only despawn what this // instance still has a handle to, and anything a previous load left // behind is exactly what makes .clear look like it did nothing. - // _standingIn is deliberately NOT reset -- the spot the player is - // stood on stays cleared until they step off it and back on, instead - // of redrawing itself a second later. _replay.SweepMarkers(); - Reply(context, $" {ChatColors.Green}cleared"); + // Safe to drop now the watcher is held off: nothing can match against + // it, so the first redraw after they ask again is decided by where + // they are then rather than by where they were when they cleared. + ForgetSpot(player.SteamID); + + Reply( + context, + thrown > 0 + ? $" {ChatColors.Green}cleared {ChatColors.Grey}the preview and the utility" + + $" in the world" + : $" {ChatColors.Green}cleared" + ); } [Command("bloom", registerRaw: false, permission: "")] @@ -893,6 +967,7 @@ public void OnCrosshair(ICommandContext context) // Redrawn rather than left until the player steps off the spot and back // on: a toggle that appears to do nothing gets pressed again. _replay.ClearMarkers(); + ForgetSpot(player.SteamID); Reply( context, @@ -1074,6 +1149,7 @@ public void OnReload(ICommandContext context) _replay.ClearGhosts(steamId); _replay.ClearMarkers(); + ForgetSpot(steamId); _library.Refresh( steamId, @@ -1369,7 +1445,8 @@ public void OnRefresh(ICommandContext context) $" {ChatColors.Green}utility practice {ChatColors.Grey}-- infinite utility, buy anywhere", $" {ChatColors.Default}.save {ChatColors.Grey}saves the throw you just made", $" {ChatColors.Default}.load {ChatColors.Grey}stands you on a saved lineup", - $" {ChatColors.Default}.rethrow {ChatColors.Grey}back to the loaded lineup", + $" {ChatColors.Default}.rethrow {ChatColors.Grey}throws it again from where it was thrown", + $" {ChatColors.Grey}your {ChatColors.Default}ping key {ChatColors.Grey}clears smokes and fires", $" {ChatColors.Default}.help {ChatColors.Grey}everything else", }; @@ -1380,7 +1457,7 @@ public void OnRefresh(ICommandContext context) $" {ChatColors.Default}.load {ChatColors.Grey}teleports you to a lineup", $" {ChatColors.Default}.next / .prev {ChatColors.Grey}walk the last search", $" {ChatColors.Default}.jump {ChatColors.Grey}stand where the loaded lineup lands", - $" {ChatColors.Default}.rethrow {ChatColors.Grey}back to the loaded lineup", + $" {ChatColors.Default}.rethrow {ChatColors.Grey}throws the loaded lineup again, without moving you", $" {ChatColors.Default}.last / .back {ChatColors.Grey}back to a throw you made", $" {ChatColors.Default}.map / .here {ChatColors.Grey}pick off the minimap, or only what you can throw from here", $" {ChatColors.Default}.edit {ChatColors.Grey}rename the loaded lineup or change who sees it", @@ -1398,7 +1475,9 @@ public void OnRefresh(ICommandContext context) $" {ChatColors.Default}.colors {ChatColors.Grey}a colour per throw, smoke and trail", $" {ChatColors.Default}.crosshair {ChatColors.Grey}hide the aim marker and throw it blind", $" {ChatColors.Default}.spawns / .spawn next {ChatColors.Grey}where rounds start from", - $" {ChatColors.Default}.noclip / .god / .timer / .solo / .clear", + $" {ChatColors.Default}.clear {ChatColors.Grey}or your {ChatColors.Default}ping key" + + $" {ChatColors.Grey}-- smokes, fires and the preview", + $" {ChatColors.Default}.noclip / .god / .timer / .solo", }; private void StartPlaybook(IPlayer player, ICommandContext context) @@ -1551,7 +1630,12 @@ private void Apply(IPlayer player, LineupRecord lineup) return; } - _system.StateFor(player.SteamID).Loaded = lineup; + PracticeState applying = _system.StateFor(player.SteamID); + + // Asking for a lineup is asking to see it: whatever .clear held off + // starts drawing again from here. + applying.Cleared = false; + applying.Loaded = lineup; // Standing the player on the lineup needs nothing but the flat fields, // so it happens now; the line itself may still be a round trip away. @@ -1574,12 +1658,6 @@ private void Apply(IPlayer player, LineupRecord lineup) return; } - IPlayer? still = _system.Find(steamId); - - if (still != null && still.IsValid) - { - } - DrawBloom(steamId, fetched); } ); diff --git a/apps/utility-sw/src/Events/PracticePlayer.cs b/apps/utility-sw/src/Events/PracticePlayer.cs index 78361d2..254be06 100644 --- a/apps/utility-sw/src/Events/PracticePlayer.cs +++ b/apps/utility-sw/src/Events/PracticePlayer.cs @@ -46,6 +46,31 @@ public HookResult OnPlayerJoinTeam(EventPlayerTeam @event) return HookResult.Continue; } + // The ping key, because that is where every other practice server put it + // and because clearing is the one thing you want BETWEEN two attempts -- + // the moment chat is worst at. The smoke in the way is in front of you now, + // not after you have typed six characters into it. + [GameEventHandler(HookMode.Post)] + public HookResult OnPlayerPing(EventPlayerPing @event) + { + IPlayer? player = @event.UserIdPlayer; + + if (player == null || !player.IsValid || player.IsFakeClient) + { + return HookResult.Continue; + } + + int cleared = _replay.ClearThrownUtility(); + + // Silent when there was nothing to clear: a ping is also just a ping. + if (cleared > 0) + { + Tell(player.SteamID, $" {ChatColors.Green}cleared {ChatColors.Grey}the utility in the world"); + } + + return HookResult.Continue; + } + [GameEventHandler(HookMode.Post)] public HookResult OnPlayerBlind(EventPlayerBlind @event) { @@ -56,12 +81,25 @@ public HookResult OnPlayerBlind(EventPlayerBlind @event) IPlayer? blinded = @event.UserIdPlayer; IPlayer? thrower = @event.AttackerPlayer; - // Only a bot's blindness is worth reporting. A player who flashed - // themselves already knows, and one who flashed a team-mate is told by - // the team-mate. - if (blinded != null && blinded.IsValid && blinded.IsFakeClient && duration > 0f) + if (duration > 0f && blinded != null && blinded.IsValid) { - ReportFlash(thrower, duration); + if (blinded.IsFakeClient) + { + ReportFlash(thrower, duration, "a bot"); + } + else + { + // NoFlash below zeroes the blindness, so the screen never goes + // white and the number IS the feedback: "how long would that + // have had me" is the whole question a flash lineup asks, and + // the server was answering it for the bot only. + ReportFlash(blinded, duration, "you"); + + if (thrower != null && thrower.IsValid && thrower.SteamID != blinded.SteamID) + { + ReportFlash(thrower, duration, blinded.Controller.PlayerName); + } + } } if (!_config.NoFlash) @@ -139,16 +177,16 @@ public HookResult OnPlayerHurt(EventPlayerHurt @event) private const int BotFullHealth = 100; - private void ReportFlash(IPlayer? thrower, float duration) + private void ReportFlash(IPlayer? told, float duration, string who) { - if (thrower == null || !thrower.IsValid || thrower.IsFakeClient) + if (told == null || !told.IsValid || told.IsFakeClient) { return; } Tell( - thrower.SteamID, - $" {ChatColors.Default}{duration:0.00}s {ChatColors.Grey}of flash on a bot" + told.SteamID, + $" {ChatColors.Default}{duration:0.00}s {ChatColors.Grey}of flash on {who}" ); } diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index 10b5da4..40ead77 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -854,12 +854,16 @@ public void ClearBloomSmoke(ulong steamId) // Tier 2: a real grenade, launched from the physics seed the engine gave // us at record time rather than from the player's eye angles, so it lands // where the recorded one did instead of near it. - public void ThrowGhostProjectile(IPlayer player, LineupRecord lineup) + // force is .rethrow asking by name. The switch below is what a render pod + // sets to have every load throw itself; a player typing the command has + // already said which throw they want, so it is not the switch's to refuse. + public void ThrowGhostProjectile(IPlayer player, LineupRecord lineup, bool force = false) { _logger.LogInformation( - "[nade-render] ThrowGhostProjectile: emitGrenades={emit} ghostProjectile={ghost} exactlyReplayable={exact} hasSeed={seed} confidence={conf}", + "[nade-render] ThrowGhostProjectile: emitGrenades={emit} ghostProjectile={ghost} forced={force} exactlyReplayable={exact} hasSeed={seed} confidence={conf}", EmitGrenades, _config.GhostProjectile, + force, lineup.IsExactlyReplayable(), lineup.HasPhysicsSeed(), lineup.confidence @@ -871,7 +875,7 @@ public void ThrowGhostProjectile(IPlayer player, LineupRecord lineup) return; } - if (!_config.GhostProjectile) + if (!_config.GhostProjectile && !force) { _logger.LogInformation("[nade-render] skip: GhostProjectile off"); return; @@ -2168,6 +2172,62 @@ private static CEntityKeyValues Tagged() return keys; } + // Live utility, as opposed to the preview drawn over it. Smokes and fires + // are what stands between two attempts at the same lineup, and anything + // still in the air goes with them: a rethrow answered by the grenade you + // are trying to replace is not an answer. + private static readonly string[] ThrownUtilityClasses = + { + "smokegrenade_projectile", + "flashbang_projectile", + "hegrenade_projectile", + "molotov_projectile", + "decoy_projectile", + "inferno", + }; + + public int ClearThrownUtility() + { + int cleared = 0; + + foreach (string designer in ThrownUtilityClasses) + { + try + { + foreach ( + CBaseEntity entity in _core.EntitySystem.GetAllEntitiesByDesignerName( + designer + ) + ) + { + if (!entity.IsValid) + { + continue; + } + + // Taken out of the world by hand, so nothing will ever + // report its detonation: the bookkeeping goes with it or it + // sits in the table until the reaper times it out. + _ghostThrows.Remove(entity.Index); + + entity.Despawn(); + cleared += 1; + } + } + catch (Exception error) + { + _logger.LogWarning(error, "unable to clear {designer}", designer); + } + } + + // The bloom preview is a real smoke, so it went with the rest. Its + // handles have to go too: a dead one can be recycled into a new entity, + // and despawning THAT is worse than leaving the entry behind. + _bloomSmoke.Clear(); + + return cleared; + } + // Despawns every marker in the world, ours or a previous instance's, then // forgets the handles. Safe to call when there is nothing to find. public int SweepMarkers() diff --git a/apps/utility-sw/src/Services/PracticeSystem.cs b/apps/utility-sw/src/Services/PracticeSystem.cs index 4f05c2c..e2da8f6 100644 --- a/apps/utility-sw/src/Services/PracticeSystem.cs +++ b/apps/utility-sw/src/Services/PracticeSystem.cs @@ -57,6 +57,13 @@ public class PracticeState // has not asked for it should not be paying for it. public bool Bloom { get; set; } + // Set by .clear and held until the player asks for something again. The + // spot watcher redraws whatever is under their feet four times a second + // and re-adopts the lineup they are looking at, so without a flag saying + // "they asked for nothing" it put the markers back inside a quarter of a + // second -- which is .clear appearing to do nothing at all. + public bool Cleared { get; set; } + // Lineups this player has already been told are not exact. Said once per // lineup: a warning repeated on every .rethrow is a warning nobody reads. diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index 73fc598..91caf27 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -476,6 +476,14 @@ private static float ToleranceFor(LineupRecord lineup) // light up everything throwable from it without redrawing every tick. private readonly Dictionary _standingIn = new(); + // What the watcher believes is drawn for a player. Anything that wipes the + // world without moving them has to say so here: left stale, the key matches + // on the next pass and suppresses the redraw that was supposed to follow. + private void ForgetSpot(ulong steamId) + { + _standingIn.Remove(steamId); + } + // IN_USE. Read every tick rather than on the 4Hz spot sweep because a tap // is shorter than a quarter of a second and a walk-up that does nothing is // worse than not offering it. @@ -556,6 +564,74 @@ private void PlaceBots() return; } + // A bot added to a live round never gets a spawn of its own: nothing + // here ends a round (mp_ignore_round_win_conditions), so the second + // .bot only ever added a dead name to the scoreboard. The same call + // brings back one a practice HE killed outright. + // + // Placed a beat later when anything was revived: a respawn has no pawn + // until the engine has run a tick, and the spots are handed out in + // order -- standing up only the bots that are already alive would give + // one of them the spot belonging to the bot still on its way back. + if (ReviveBots()) + { + Core.Scheduler.DelayBySeconds(BotPlaceDelaySeconds, StandBotsOnSpots); + + return; + } + + StandBotsOnSpots(); + } + + // A bot on no team cannot be spawned, and asking would put the engine in + // the position of choosing one -- which is how a bot ends up playing the + // round it was placed to stand still through. + private bool ReviveBots() + { + bool revived = false; + + foreach (IPlayer player in Core.PlayerManager.GetAllPlayers()) + { + if (player == null || !player.IsValid || !player.IsFakeClient || player.IsAlive) + { + continue; + } + + if (player.Controller.Team is not (Team.CT or Team.T)) + { + continue; + } + + player.Respawn(); + revived = true; + } + + return revived; + } + + // Nothing here brings a bot back on its own -- see PlaceBots -- so a dead + // one is a target that has quietly stopped being one. Checked rather than + // run blind: with every bot up this is one loop and no teleports. + private void KeepBotsStanding() + { + if (_bots.Count == 0) + { + return; + } + + foreach (IPlayer player in Core.PlayerManager.GetAllPlayers()) + { + if (player != null && player.IsValid && player.IsFakeClient && !player.IsAlive) + { + PlaceBots(); + + return; + } + } + } + + private void StandBotsOnSpots() + { int index = 0; foreach (IPlayer player in Core.PlayerManager.GetAllPlayers()) @@ -676,7 +752,11 @@ private void StandOnNearest(IPlayer player, CCSPlayerPawn pawn) if (_replay.StandOn(player, target)) { - _system.StateFor(player.SteamID).Loaded = target; + PracticeState state = _system.StateFor(player.SteamID); + + // Walking onto a spot on purpose is asking to see it again. + state.Cleared = false; + state.Loaded = target; } } @@ -718,6 +798,14 @@ private void SpotWatch() continue; } + // .clear asked for an empty world, and this is the loop that would + // otherwise hand it straight back: both the markers and the + // Loaded assignment below. + if (_system.StateFor(player.SteamID).Cleared) + { + continue; + } + Vector origin = pawn.AbsOrigin ?? new Vector(0, 0, 0); var at = new Vec3(origin.X, origin.Y, origin.Z); @@ -1445,6 +1533,7 @@ private void OnSecond() _session.RetryIfMissing(TimeSpan.FromSeconds(15)); EndWarmup(); RespawnTheDead(); + KeepBotsStanding(); KeepEveryoneStocked(); ReportOccupancy(); _system.Tick(); @@ -1881,6 +1970,14 @@ private void ShowLibraryFor(ulong steamId, IReadOnlyList library) return; } + // A refresh nobody asked for does not get to undo .clear: the drain + // runs on its own schedule, and the panel pushing an edit is not the + // player asking for their markers back. + if (_system.StateFor(steamId).Cleared) + { + return; + } + _replay.ShowLibrary(library); }