diff --git a/Content.Server/Administration/Commands/BQL/BqlParser.cs b/Content.Server/Administration/Commands/BQL/BqlParser.cs new file mode 100644 index 0000000000..1165cd7fe8 --- /dev/null +++ b/Content.Server/Administration/Commands/BQL/BqlParser.cs @@ -0,0 +1,166 @@ +using System; +using System.Linq; +using System.Collections.Generic; +using System.Text.RegularExpressions; +using Content.Shared.Tag; +using Robust.Shared.GameObjects; +using Robust.Shared.IoC; + +// this is all really shit but it works and only runs once a command. +namespace Content.Server.Administration.Commands.BQL +{ + public static class BqlParser + { + private enum TokenKind + { + With, + Named, + ParentedTo, + Prototyped, + Tagged, + Do, + String, + } + + private readonly struct Token + { + public readonly TokenKind Kind; + public readonly string Text; + + private Token(TokenKind kind, string text) + { + Kind = kind; + Text = text; + } + + //I didn't want to write a proper parser. --moony + public static Tuple ExtractOneToken(string inp) + { + inp = inp.TrimStart(); + return inp switch + { + _ when inp.StartsWith("with ") => new Tuple(inp[4..], new Token(TokenKind.With, "with")), + _ when inp.StartsWith("named ") => new Tuple(inp[5..], new Token(TokenKind.Named, "named")), + _ when inp.StartsWith("parented_to ") => new Tuple(inp[11..], new Token(TokenKind.ParentedTo, "parented_to")), + _ when inp.StartsWith("prototyped ") => new Tuple(inp[10..], new Token(TokenKind.Prototyped, "prototyped")), + _ when inp.StartsWith("tagged ") => new Tuple(inp[6..], new Token(TokenKind.Tagged, "tagged")), + _ when inp.StartsWith("do ") => new Tuple(inp[2..], new Token(TokenKind.Do, "do")), + _ => ExtractStringToken(inp) + }; + } + + private static Tuple ExtractStringToken(string inp) + { + inp = inp.TrimStart(); + if (inp.StartsWith("\"")) + { + var acc = ""; + var skipNext = false; + foreach (var rune in inp[1..]) + { + if (skipNext) + { + acc += rune; + skipNext = false; + continue; + } + + switch (rune) + { + case '\\': + skipNext = true; + continue; + case '"': + return new Tuple(inp[(acc.Length+2)..], new Token(TokenKind.String, acc)); + default: + acc += rune; + continue; + } + } + + throw new Exception("Missing a \" somewhere."); + } + + if (inp.Contains(" ") == false) + { + return new Tuple("", new Token(TokenKind.String, inp)); + } + var word = inp[..inp.IndexOf(" ", StringComparison.Ordinal)]; + var rem = inp[inp.IndexOf(" ", StringComparison.Ordinal)..]; + return new Tuple(rem, new Token(TokenKind.String, word)); + } + } + + // Extracts and evaluates a query, then returns the rest. + public static Tuple> DoEntityQuery(string query, IEntityManager entityManager) + { + var remainingQuery = query; + var componentFactory = IoCManager.Resolve(); + var entities = entityManager.GetEntities(); + + while (true) + { + Token t; + (remainingQuery, t) = Token.ExtractOneToken(remainingQuery); + + switch (t.Kind) + { + case TokenKind.With: + { + Token nt; + (remainingQuery, nt) = Token.ExtractOneToken(remainingQuery); + var comp = componentFactory.GetRegistration(nt.Text).Type; + entities = entities.Where(e => e.HasComponent(comp)); + break; + } + case TokenKind.Named: + { + Token nt; + (remainingQuery, nt) = Token.ExtractOneToken(remainingQuery); + var r = new Regex("^" + nt.Text + "$"); + entities = entities.Where(e => r.IsMatch(e.Name)); + break; + } + case TokenKind.Tagged: + { + Token nt; + (remainingQuery, nt) = Token.ExtractOneToken(remainingQuery); + var text = nt.Text; + entities = entities.Where(e => + { + if (e.TryGetComponent(out var tagComponent)) + { + return tagComponent.Tags.Contains(text); + } + + return false; + }); + break; + } + case TokenKind.ParentedTo: + { + Token nt; + (remainingQuery, nt) = Token.ExtractOneToken(remainingQuery); + var uid = EntityUid.Parse(nt.Text); + entities = entities.Where(e => e.Transform.Parent?.Owner.Uid == uid); + break; + } + case TokenKind.Prototyped: + { + Token nt; + (remainingQuery, nt) = Token.ExtractOneToken(remainingQuery); + entities = entities.Where(e => e.Prototype?.ID == nt.Text); + break; + } + case TokenKind.Do: + return new Tuple>(remainingQuery, entities); + default: + throw new Exception("Unknown token called " + t.Text + ", which was parsed as a "+ t.Kind.ToString()); + } + + if (remainingQuery.TrimStart() == "") + return new Tuple>(remainingQuery, entities); + } + } + } +} diff --git a/Content.Server/Administration/Commands/BQL/ForAllCommand.cs b/Content.Server/Administration/Commands/BQL/ForAllCommand.cs new file mode 100644 index 0000000000..db22e738c3 --- /dev/null +++ b/Content.Server/Administration/Commands/BQL/ForAllCommand.cs @@ -0,0 +1,38 @@ +using System; +using System.Linq; +using Content.Server.Commands; +using Content.Shared.Administration; +using Robust.Shared.Console; +using Robust.Shared.GameObjects; +using Robust.Shared.IoC; + +namespace Content.Server.Administration.Commands.BQL +{ + [AdminCommand(AdminFlags.Admin)] + public class ForAllCommand : IConsoleCommand + { + public string Command => "forall"; + public string Description => "Runs a command over all entities with a given component"; + public string Help => "Usage: forall "; + public void Execute(IConsoleShell shell, string argStr, string[] args) + { + if (args.Length < 2) + { + shell.WriteLine(Help); + return; + } + + var entityManager = IoCManager.Resolve(); + var (command, entities) = BqlParser.DoEntityQuery(argStr[6..], entityManager); + + foreach (var ent in entities.ToList()) + { + var cmds = CommandUtils.SubstituteEntityDetails(shell, ent, command).Split(";"); + foreach (var cmd in cmds) + { + shell.ExecuteCommand(cmd); + } + } + } + } +} diff --git a/Content.Server/Commands/CommandUtils.cs b/Content.Server/Commands/CommandUtils.cs index 29951dbce6..a1f42144b8 100644 --- a/Content.Server/Commands/CommandUtils.cs +++ b/Content.Server/Commands/CommandUtils.cs @@ -1,5 +1,6 @@ using System; using System.Diagnostics.CodeAnalysis; +using System.Globalization; using Robust.Server.Player; using Robust.Shared.Console; using Robust.Shared.GameObjects; @@ -51,5 +52,38 @@ namespace Content.Server.Commands attachedEntity = session.AttachedEntity; return true; } + + public static string SubstituteEntityDetails(IConsoleShell shell, IEntity ent, string ruleString) + { + // gross, is there a better way to do this? + ruleString = ruleString.Replace("$ID", ent.Uid.ToString()); + ruleString = ruleString.Replace("$WX", + ent.Transform.WorldPosition.X.ToString(CultureInfo.InvariantCulture)); + ruleString = ruleString.Replace("$WY", + ent.Transform.WorldPosition.Y.ToString(CultureInfo.InvariantCulture)); + ruleString = ruleString.Replace("$LX", + ent.Transform.LocalPosition.X.ToString(CultureInfo.InvariantCulture)); + ruleString = ruleString.Replace("$LY", + ent.Transform.LocalPosition.Y.ToString(CultureInfo.InvariantCulture)); + ruleString = ruleString.Replace("$NAME", ent.Name); + + if (shell.Player is IPlayerSession player) + { + if (player.AttachedEntity != null) + { + var p = player.AttachedEntity; + ruleString = ruleString.Replace("$PID", ent.Uid.ToString()); + ruleString = ruleString.Replace("$PWX", + p.Transform.WorldPosition.X.ToString(CultureInfo.InvariantCulture)); + ruleString = ruleString.Replace("$PWY", + p.Transform.WorldPosition.Y.ToString(CultureInfo.InvariantCulture)); + ruleString = ruleString.Replace("$PLX", + p.Transform.LocalPosition.X.ToString(CultureInfo.InvariantCulture)); + ruleString = ruleString.Replace("$PLY", + p.Transform.LocalPosition.Y.ToString(CultureInfo.InvariantCulture)); + } + } + return ruleString; + } } }