diff --git a/SS14.ServerHub.Shared/Data/AdvertisedServer.cs b/SS14.ServerHub.Shared/Data/AdvertisedServer.cs
index 08d58ed..cac84e7 100644
--- a/SS14.ServerHub.Shared/Data/AdvertisedServer.cs
+++ b/SS14.ServerHub.Shared/Data/AdvertisedServer.cs
@@ -33,4 +33,9 @@ public sealed class AdvertisedServer
/// IP address of the client doing the advertise request. Not actually related to the advertised data.
///
[Column(TypeName = "inet")] public IPAddress? AdvertiserAddress { get; set; }
+
+ ///
+ /// Extra tags inferred from the server information.
+ ///
+ public string[] InferredTags { get; set; } = Array.Empty();
}
\ No newline at end of file
diff --git a/SS14.ServerHub.Shared/Data/ServerStatusArchive.cs b/SS14.ServerHub.Shared/Data/ServerStatusArchive.cs
index 28de226..d2b6a2e 100644
--- a/SS14.ServerHub.Shared/Data/ServerStatusArchive.cs
+++ b/SS14.ServerHub.Shared/Data/ServerStatusArchive.cs
@@ -21,4 +21,9 @@ public sealed class ServerStatusArchive
public IPAddress? AdvertiserAddress { get; set; }
public AdvertisedServer AdvertisedServer { get; set; } = default!;
+
+ ///
+ /// Corresponds to .
+ ///
+ public string[] InferredTags { get; set; } = Array.Empty();
}
\ No newline at end of file
diff --git a/SS14.ServerHub.Shared/Migrations/20240331202957_InferredTags.Designer.cs b/SS14.ServerHub.Shared/Migrations/20240331202957_InferredTags.Designer.cs
new file mode 100644
index 0000000..d113de2
--- /dev/null
+++ b/SS14.ServerHub.Shared/Migrations/20240331202957_InferredTags.Designer.cs
@@ -0,0 +1,308 @@
+//
+using System;
+using System.Net;
+using System.Text.Json;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using SS14.ServerHub.Shared.Data;
+
+#nullable disable
+
+namespace SS14.ServerHub.Shared.Migrations
+{
+ [DbContext(typeof(HubDbContext))]
+ [Migration("20240331202957_InferredTags")]
+ partial class InferredTags
+ {
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "6.0.1")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("SS14.ServerHub.Shared.Data.AdvertisedServer", b =>
+ {
+ b.Property("AdvertisedServerId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AdvertisedServerId"));
+
+ b.Property("Address")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("AdvertiserAddress")
+ .HasColumnType("inet");
+
+ b.Property("Expires")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("InferredTags")
+ .IsRequired()
+ .HasColumnType("text[]");
+
+ b.Property("InfoData")
+ .HasColumnType("jsonb");
+
+ b.Property("StatusData")
+ .HasColumnType("jsonb");
+
+ b.HasKey("AdvertisedServerId");
+
+ b.HasIndex("Address")
+ .IsUnique();
+
+ b.ToTable("AdvertisedServer");
+
+ b.HasCheckConstraint("AddressSs14Uri", "\"Address\" LIKE 'ss14://%' OR \"Address\" LIKE 'ss14s://%'");
+ });
+
+ modelBuilder.Entity("SS14.ServerHub.Shared.Data.HubAudit", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Actor")
+ .HasColumnType("uuid");
+
+ b.Property("Data")
+ .IsRequired()
+ .HasColumnType("jsonb");
+
+ b.Property("Time")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Type")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Time");
+
+ b.ToTable("HubAudit");
+ });
+
+ modelBuilder.Entity("SS14.ServerHub.Shared.Data.ServerStatusArchive", b =>
+ {
+ b.Property("AdvertisedServerId")
+ .HasColumnType("integer");
+
+ b.Property("ServerStatusArchiveId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ServerStatusArchiveId"));
+
+ b.Property("AdvertiserAddress")
+ .HasColumnType("inet");
+
+ b.Property("InferredTags")
+ .IsRequired()
+ .HasColumnType("text[]");
+
+ b.Property("StatusData")
+ .IsRequired()
+ .HasColumnType("jsonb");
+
+ b.Property("Time")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("AdvertisedServerId", "ServerStatusArchiveId");
+
+ b.ToTable("ServerStatusArchive");
+ });
+
+ modelBuilder.Entity("SS14.ServerHub.Shared.Data.TrackedCommunity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Created")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("IsBanned")
+ .HasColumnType("boolean");
+
+ b.Property("LastUpdated")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Notes")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.ToTable("TrackedCommunity");
+ });
+
+ modelBuilder.Entity("SS14.ServerHub.Shared.Data.TrackedCommunityAddress", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property>("Address")
+ .HasColumnType("inet");
+
+ b.Property("TrackedCommunityId")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TrackedCommunityId");
+
+ b.ToTable("TrackedCommunityAddress");
+ });
+
+ modelBuilder.Entity("SS14.ServerHub.Shared.Data.TrackedCommunityDomain", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("DomainName")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("TrackedCommunityId")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TrackedCommunityId");
+
+ b.ToTable("TrackedCommunityDomain");
+ });
+
+ modelBuilder.Entity("SS14.ServerHub.Shared.Data.TrackedCommunityInfoMatch", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Field")
+ .HasColumnType("integer");
+
+ b.Property("Path")
+ .IsRequired()
+ .HasColumnType("jsonpath");
+
+ b.Property("TrackedCommunityId")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TrackedCommunityId");
+
+ b.ToTable("TrackedCommunityInfoMatch");
+ });
+
+ modelBuilder.Entity("SS14.ServerHub.Shared.Data.UniqueServerName", b =>
+ {
+ b.Property("AdvertisedServerId")
+ .HasColumnType("integer");
+
+ b.Property("Name")
+ .HasColumnType("text");
+
+ b.Property("FirstSeen")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LastSeen")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("AdvertisedServerId", "Name");
+
+ b.ToTable("UniqueServerName");
+ });
+
+ modelBuilder.Entity("SS14.ServerHub.Shared.Data.ServerStatusArchive", b =>
+ {
+ b.HasOne("SS14.ServerHub.Shared.Data.AdvertisedServer", "AdvertisedServer")
+ .WithMany()
+ .HasForeignKey("AdvertisedServerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("AdvertisedServer");
+ });
+
+ modelBuilder.Entity("SS14.ServerHub.Shared.Data.TrackedCommunityAddress", b =>
+ {
+ b.HasOne("SS14.ServerHub.Shared.Data.TrackedCommunity", "TrackedCommunity")
+ .WithMany("Addresses")
+ .HasForeignKey("TrackedCommunityId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("TrackedCommunity");
+ });
+
+ modelBuilder.Entity("SS14.ServerHub.Shared.Data.TrackedCommunityDomain", b =>
+ {
+ b.HasOne("SS14.ServerHub.Shared.Data.TrackedCommunity", "TrackedCommunity")
+ .WithMany("Domains")
+ .HasForeignKey("TrackedCommunityId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("TrackedCommunity");
+ });
+
+ modelBuilder.Entity("SS14.ServerHub.Shared.Data.TrackedCommunityInfoMatch", b =>
+ {
+ b.HasOne("SS14.ServerHub.Shared.Data.TrackedCommunity", "TrackedCommunity")
+ .WithMany("InfoMatches")
+ .HasForeignKey("TrackedCommunityId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("TrackedCommunity");
+ });
+
+ modelBuilder.Entity("SS14.ServerHub.Shared.Data.UniqueServerName", b =>
+ {
+ b.HasOne("SS14.ServerHub.Shared.Data.AdvertisedServer", "AdvertisedServer")
+ .WithMany()
+ .HasForeignKey("AdvertisedServerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("AdvertisedServer");
+ });
+
+ modelBuilder.Entity("SS14.ServerHub.Shared.Data.TrackedCommunity", b =>
+ {
+ b.Navigation("Addresses");
+
+ b.Navigation("Domains");
+
+ b.Navigation("InfoMatches");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/SS14.ServerHub.Shared/Migrations/20240331202957_InferredTags.cs b/SS14.ServerHub.Shared/Migrations/20240331202957_InferredTags.cs
new file mode 100644
index 0000000..cb4a8b2
--- /dev/null
+++ b/SS14.ServerHub.Shared/Migrations/20240331202957_InferredTags.cs
@@ -0,0 +1,38 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace SS14.ServerHub.Shared.Migrations
+{
+ public partial class InferredTags : Migration
+ {
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn(
+ name: "InferredTags",
+ table: "ServerStatusArchive",
+ type: "text[]",
+ nullable: false,
+ defaultValue: new string[0]);
+
+ migrationBuilder.AddColumn(
+ name: "InferredTags",
+ table: "AdvertisedServer",
+ type: "text[]",
+ nullable: false,
+ defaultValue: new string[0]);
+ }
+
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "InferredTags",
+ table: "ServerStatusArchive");
+
+ migrationBuilder.DropColumn(
+ name: "InferredTags",
+ table: "AdvertisedServer");
+ }
+ }
+}
diff --git a/SS14.ServerHub.Shared/Migrations/HubDbContextModelSnapshot.cs b/SS14.ServerHub.Shared/Migrations/HubDbContextModelSnapshot.cs
index 54fb953..8fbc5a8 100644
--- a/SS14.ServerHub.Shared/Migrations/HubDbContextModelSnapshot.cs
+++ b/SS14.ServerHub.Shared/Migrations/HubDbContextModelSnapshot.cs
@@ -42,6 +42,10 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property("Expires")
.HasColumnType("timestamp with time zone");
+ b.Property("InferredTags")
+ .IsRequired()
+ .HasColumnType("text[]");
+
b.Property("InfoData")
.HasColumnType("jsonb");
@@ -100,6 +104,10 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property("AdvertiserAddress")
.HasColumnType("inet");
+ b.Property("InferredTags")
+ .IsRequired()
+ .HasColumnType("text[]");
+
b.Property("StatusData")
.IsRequired()
.HasColumnType("jsonb");
@@ -265,7 +273,7 @@ protected override void BuildModel(ModelBuilder modelBuilder)
modelBuilder.Entity("SS14.ServerHub.Shared.Data.TrackedCommunityInfoMatch", b =>
{
b.HasOne("SS14.ServerHub.Shared.Data.TrackedCommunity", "TrackedCommunity")
- .WithMany()
+ .WithMany("InfoMatches")
.HasForeignKey("TrackedCommunityId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
@@ -289,6 +297,8 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Navigation("Addresses");
b.Navigation("Domains");
+
+ b.Navigation("InfoMatches");
});
#pragma warning restore 612, 618
}
diff --git a/SS14.ServerHub/Controllers/ServerListController.cs b/SS14.ServerHub/Controllers/ServerListController.cs
index afda82b..96bd00c 100644
--- a/SS14.ServerHub/Controllers/ServerListController.cs
+++ b/SS14.ServerHub/Controllers/ServerListController.cs
@@ -14,6 +14,7 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
+using SS14.ServerHub.ServerData;
using SS14.ServerHub.Shared;
using SS14.ServerHub.Shared.Data;
using SS14.ServerHub.Utility;
@@ -47,7 +48,7 @@ public async Task> Get()
{
var dbInfos = await _dbContext.AdvertisedServer
.Where(s => s.Expires > DateTime.UtcNow)
- .Select(s => new ServerInfo(s.Address, s.StatusData == null ? null : new RawJson(s.StatusData)))
+ .Select(s => new ServerInfo(s.Address, s.StatusData == null ? null : new RawJson(s.StatusData), s.InferredTags))
.ToArrayAsync();
return dbInfos;
@@ -117,6 +118,8 @@ parsedAddress.Scheme is not (Ss14UriHelper.SchemeSs14 or Ss14UriHelper.SchemeSs1
return Unauthorized("Your server has been blocked from advertising on the hub. If you believe this to be in error, please contact us.");
}
+ var inferredTags = InferTags(statusJson);
+
// Check if a server with this address already exists.
var addressEntity =
await _dbContext.AdvertisedServer.SingleOrDefaultAsync(a => a.Address == advertise.Address);
@@ -136,13 +139,15 @@ parsedAddress.Scheme is not (Ss14UriHelper.SchemeSs14 or Ss14UriHelper.SchemeSs1
addressEntity.StatusData = statusJson;
addressEntity.InfoData = infoJson;
addressEntity.AdvertiserAddress = senderIp;
+ addressEntity.InferredTags = inferredTags;
_dbContext.ServerStatusArchive.Add(new ServerStatusArchive
{
Time = timeNow,
AdvertisedServer = addressEntity,
AdvertiserAddress = senderIp,
- StatusData = statusJson
+ StatusData = statusJson,
+ InferredTags = inferredTags
});
await _dbContext.SaveChangesAsync();
@@ -258,6 +263,13 @@ private BanCheckResult CheckMatchedCommunitiesForBan(Uri address, List b.TrackedCommunity.IsBanned);
}
+ private static string[] InferTags(byte[] statusDataJson)
+ {
+ var statusData = JsonSerializer.Deserialize(statusDataJson)!;
+
+ return ServerTagInfer.InferTags(statusData.Name!, statusData.Tags ?? Array.Empty());
+ }
+
private enum BanCheckResult
{
Banned,
@@ -265,14 +277,16 @@ private enum BanCheckResult
FailedResolve
}
- public sealed record ServerInfo(string Address, RawJson? StatusData);
+ public sealed record ServerInfo(string Address, RawJson? StatusData, string[] InferredTags);
public sealed record ServerAdvertise(string Address);
// ReSharper disable once ClassNeverInstantiated.Local
private sealed record ServerStatus(
[property: JsonPropertyName("name")] string? Name,
[property: JsonPropertyName("players")]
- int PlayerCount);
+ int PlayerCount,
+ [property: JsonPropertyName("tags")]
+ string[]? Tags);
[JsonConverter(typeof(RawJsonConverter))]
public sealed record RawJson(byte[] Json)
diff --git a/SS14.ServerHub/ServerData/ServerTagInfer.cs b/SS14.ServerHub/ServerData/ServerTagInfer.cs
new file mode 100644
index 0000000..535d84a
--- /dev/null
+++ b/SS14.ServerHub/ServerData/ServerTagInfer.cs
@@ -0,0 +1,69 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.RegularExpressions;
+
+namespace SS14.ServerHub.ServerData;
+
+///
+/// Helper code for inferring server tags from other metadata, such as their name.
+/// Intended as a stopgap measure before servers properly tag their stuff in the API.
+///
+public static partial class ServerTagInfer
+{
+ private static readonly Regex TagLikeRegex = MyRegex();
+
+ public static string[] InferTags(string currentName, string[] currentTags)
+ {
+ var addedTags = new List();
+
+ // no_tag_infer stops all inference logic.
+ if (currentTags.Contains(Tags.TagNoTagInfer))
+ return Array.Empty();
+
+ // Extract all name [tags] via regex.
+ var tagLikes = TagLikeRegex
+ .Matches(currentName)
+ .Select(x => x.Groups[1].Captures[0].Value)
+ .ToHashSet(StringComparer.CurrentCultureIgnoreCase);
+
+ // Infer language tags for [EN] and [RU] if there's no language tags.
+ // Not adding any other languages, advertise it properly with the API.
+ if (!currentTags.Any(t => t.StartsWith(Tags.TagLanguage, StringComparison.OrdinalIgnoreCase)))
+ {
+ if (tagLikes.Contains("en"))
+ {
+ addedTags.Add(Tags.TagLanguage + "en");
+ }
+ else if (tagLikes.Contains("ru") || tagLikes.Contains("rus"))
+ {
+ addedTags.Add(Tags.TagLanguage + "ru");
+ }
+ }
+
+ // Infer 18+
+ if (!currentTags.Contains(Tags.TagEighteenPlus))
+ {
+ if (tagLikes.Contains("18+") || tagLikes.Contains("+18") || tagLikes.Contains("18") || tagLikes.Contains("ERP"))
+ addedTags.Add(Tags.TagEighteenPlus);
+ }
+
+ // Infer NRP/LRP/MRP/HRP if no RP tags.
+ if (!currentTags.Any(t => t.StartsWith(Tags.TagRolePlay, StringComparison.OrdinalIgnoreCase)))
+ {
+ if (tagLikes.Contains("nrp"))
+ addedTags.Add(Tags.TagRolePlay + Tags.RolePlayNone);
+ else if (tagLikes.Contains("lrp"))
+ addedTags.Add(Tags.TagRolePlay + Tags.RolePlayLow);
+ else if (tagLikes.Contains("mrp"))
+ addedTags.Add(Tags.TagRolePlay + Tags.RolePlayMedium);
+ else if (tagLikes.Contains("hrp"))
+ addedTags.Add(Tags.TagRolePlay + Tags.RolePlayHigh);
+ }
+
+ return addedTags.ToArray();
+ }
+
+ [GeneratedRegex("\\[(.*?)\\]")]
+ private static partial Regex MyRegex();
+}
diff --git a/SS14.ServerHub/ServerData/Tags.cs b/SS14.ServerHub/ServerData/Tags.cs
new file mode 100644
index 0000000..f11b232
--- /dev/null
+++ b/SS14.ServerHub/ServerData/Tags.cs
@@ -0,0 +1,76 @@
+using System;
+using System.Diagnostics.CodeAnalysis;
+
+namespace SS14.ServerHub.ServerData;
+
+///
+/// Contains definitions for standard tags returned by game servers.
+///
+public static class Tags
+{
+ // @formatter:off
+
+ // Base tag definitions.
+ public const string TagEighteenPlus = "18+";
+ public const string TagRegion = "region:";
+ public const string TagLanguage = "lang:";
+ public const string TagRolePlay = "rp:";
+ public const string TagNoTagInfer = "no_tag_infer";
+
+ // Region tags.
+ public const string RegionAfricaCentral = "af_c";
+ public const string RegionAfricaNorth = "af_n";
+ public const string RegionAfricaSouth = "af_s";
+ public const string RegionAntarctica = "ata";
+ public const string RegionAsiaEast = "as_e";
+ public const string RegionAsiaNorth = "as_n";
+ public const string RegionAsiaSouthEast = "as_se";
+ public const string RegionCentralAmerica = "am_c";
+ public const string RegionEuropeEast = "eu_e";
+ public const string RegionEuropeWest = "eu_w";
+ public const string RegionGreenland = "grl";
+ public const string RegionIndia = "ind";
+ public const string RegionMiddleEast = "me";
+ public const string RegionMoon = "luna";
+ public const string RegionNorthAmericaCentral = "am_n_c";
+ public const string RegionNorthAmericaEast = "am_n_e";
+ public const string RegionNorthAmericaWest = "am_n_w";
+ public const string RegionOceania = "oce";
+ public const string RegionSouthAmericaEast = "am_s_e";
+ public const string RegionSouthAmericaSouth = "am_s_s";
+ public const string RegionSouthAmericaWest = "am_s_w";
+
+ // RolePlay level tags.
+ public const string RolePlayNone = "none";
+ public const string RolePlayLow = "low";
+ public const string RolePlayMedium = "med";
+ public const string RolePlayHigh = "high";
+ // @formatter:on
+
+ public static bool TryRegion(string tag, [NotNullWhen(true)] out string? region)
+ {
+ return TryTagPrefix(tag, TagRegion, out region);
+ }
+
+ public static bool TryLanguage(string tag, [NotNullWhen(true)] out string? language)
+ {
+ return TryTagPrefix(tag, TagLanguage, out language);
+ }
+
+ public static bool TryRolePlay(string tag, [NotNullWhen(true)] out string? rolePlay)
+ {
+ return TryTagPrefix(tag, TagRolePlay, out rolePlay);
+ }
+
+ public static bool TryTagPrefix(string tag, string prefix, [NotNullWhen(true)] out string? value)
+ {
+ if (!tag.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
+ {
+ value = null;
+ return false;
+ }
+
+ value = tag[prefix.Length..];
+ return true;
+ }
+}
\ No newline at end of file