Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 97 additions & 19 deletions apps/utility-sw/src/Commands/Practice.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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)
{
Expand All @@ -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: "")]
Expand Down Expand Up @@ -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: "")]
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1074,6 +1149,7 @@ public void OnReload(ICommandContext context)

_replay.ClearGhosts(steamId);
_replay.ClearMarkers();
ForgetSpot(steamId);

_library.Refresh(
steamId,
Expand Down Expand Up @@ -1369,7 +1445,8 @@ public void OnRefresh(ICommandContext context)
$" {ChatColors.Green}utility practice {ChatColors.Grey}-- infinite utility, buy anywhere",
$" {ChatColors.Default}.save <name> {ChatColors.Grey}saves the throw you just made",
$" {ChatColors.Default}.load <query> {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",
};

Expand All @@ -1380,7 +1457,7 @@ public void OnRefresh(ICommandContext context)
$" {ChatColors.Default}.load <query> {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 <n> {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",
Expand All @@ -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)
Expand Down Expand Up @@ -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.
Expand All @@ -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);
}
);
Expand Down
56 changes: 47 additions & 9 deletions apps/utility-sw/src/Events/PracticePlayer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand All @@ -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)
Expand Down Expand Up @@ -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}"
);
}

Expand Down
66 changes: 63 additions & 3 deletions apps/utility-sw/src/Services/PracticeReplay.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -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<CBaseEntity>(
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()
Expand Down
7 changes: 7 additions & 0 deletions apps/utility-sw/src/Services/PracticeSystem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading