enumGenerator.Generate("SystemTypes", underlyingType: CSharpEnumUnderlyingType.Byte);
enumGenerator.Generate("Language", sourceName: "SupportedLangs");
enumGenerator.Generate("TaskTypes");
+ enumGenerator.Generate("BoolOptionNames", "Impostor.Api.Innersloth.GameFilters");
+ enumGenerator.Generate("ByteOptionNames", "Impostor.Api.Innersloth.GameFilters");
+ enumGenerator.Generate("CategorizedOptionNames", "Impostor.Api.Innersloth.GameFilters");
+ enumGenerator.Generate("FloatArrayOptionNames", "Impostor.Api.Innersloth.GameFilters");
+ enumGenerator.Generate("FloatOptionNames", "Impostor.Api.Innersloth.GameFilters");
+ enumGenerator.Generate("Int32ArrayOptionNames", "Impostor.Api.Innersloth.GameFilters");
+ enumGenerator.Generate("Int32OptionNames", "Impostor.Api.Innersloth.GameFilters");
+ enumGenerator.Generate("UInt32OptionNames", "Impostor.Api.Innersloth.GameFilters");
enumGenerator.Generate("RpcCalls", "Impostor.Api.Net.Inner", underlyingType: CSharpEnumUnderlyingType.Byte);
--- /dev/null
+{
+ "Invalid": 0,
+ "VisualTasks": 1,
+ "GhostsDoTasks": 2,
+ "ConfirmImpostor": 3,
+ "AnonymousVotes": 4,
+ "IsDefaults": 5,
+ "UseFlashlight": 6,
+ "SeekerFinalVents": 7,
+ "SeekerFinalMap": 8,
+ "SeekerPings": 9,
+ "ShowCrewmateNames": 10,
+ "Roles": 11,
+ "ShapeshifterLeaveSkin": 1000,
+ "ImpostorsCanSeeProtect": 1100,
+ "NoisemakerImpostorAlert": 1300
+}
\ No newline at end of file
--- /dev/null
+{
+ "Invalid": 0,
+ "MapId": 1
+}
\ No newline at end of file
--- /dev/null
+{
+ "KillCooldown": 0,
+ "PlayerSpeed": 1,
+ "EmergencyMeetingCount": 2,
+ "EmergencyMeetingCooldown": 3,
+ "DiscussionTime": 4,
+ "VotingTime": 5,
+ "CrewmateVision": 6,
+ "HidingTime": 7,
+ "MaxTimeInVent": 8,
+ "ImposterVision": 9,
+ "FinalHideTime": 10,
+ "FinalHideImposterSpeed": 11
+}
\ No newline at end of file
--- /dev/null
+{
+ "Invalid": 0,
+ "KillDistances": 1
+}
\ No newline at end of file
--- /dev/null
+{
+ "Invalid": 0,
+ "KillCooldown": 1,
+ "PlayerSpeedMod": 2,
+ "ImpostorLightMod": 3,
+ "CrewLightMod": 4,
+ "CrewmateTimeInVent": 100,
+ "FinalEscapeTime": 101,
+ "EscapeTime": 102,
+ "SeekerFinalSpeed": 103,
+ "MaxPingTime": 104,
+ "CrewmateFlashlightSize": 105,
+ "ImpostorFlashlightSize": 106,
+ "ShapeshifterCooldown": 1000,
+ "ShapeshifterDuration": 1001,
+ "ProtectionDurationSeconds": 1100,
+ "GuardianAngelCooldown": 1101,
+ "ScientistCooldown": 1200,
+ "ScientistBatteryCharge": 1201,
+ "EngineerCooldown": 1300,
+ "EngineerInVentMaxTime": 1301,
+ "PhantomCooldown": 1500,
+ "PhantomDuration": 1501,
+ "TrackerCooldown": 1550,
+ "TrackerDuration": 1551,
+ "TrackerDelay": 1552,
+ "NoisemakerAlertDuration": 1600
+}
\ No newline at end of file
--- /dev/null
+{
+ "Invalid": 0,
+ "MaxImpostors": 1,
+ "MinPlayers": 2
+}
\ No newline at end of file
--- /dev/null
+{
+ "Invalid": 0,
+ "NumImpostors": 1,
+ "KillDistance": 2,
+ "NumEmergencyMeetings": 3,
+ "EmergencyCooldown": 4,
+ "DiscussionTime": 5,
+ "VotingTime": 6,
+ "MaxImpostors": 7,
+ "MinPlayers": 8,
+ "MaxPlayers": 9,
+ "NumCommonTasks": 10,
+ "NumShortTasks": 11,
+ "NumLongTasks": 12,
+ "TaskBarMode": 13,
+ "Tag": 14,
+ "CrewmatesRemainingForVitals": 100,
+ "CrewmateVentUses": 101,
+ "ImpostorPlayerID": 102,
+ "RulePreset": 200
+}
\ No newline at end of file
"DetectiveNotesTitlePostIt": 4080,
"DetectiveNotesPostIt": 4081,
"DetectiveNotesSuspectNumber": 4082
-}
\ No newline at end of file
+}
--- /dev/null
+{
+ "Invalid": 0,
+ "Keywords": 1
+}
\ No newline at end of file
{
"dumpostorVersion": "1.0.1-dev",
- "gameVersion": "2025.10.14",
+ "gameVersion": "2024.10.29",
"platformType": "StandaloneItch"
-}
\ No newline at end of file
+}
--- /dev/null
+using System;
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+
+namespace Impostor.Api.Innersloth.GameFilters
+{
+ [Serializable]
+ public class BoolGameFilter : ISubFilter
+ {
+ [JsonPropertyName("FilterType")]
+ public string FilterType { get; } = "bool";
+
+ [JsonPropertyName("AcceptedValues")]
+ public required List<bool> AcceptedValues { get; set; }
+
+ [JsonPropertyName("OptionEnum")]
+ public required BoolOptionNames OptionEnum { get; set; }
+ }
+}
--- /dev/null
+using System;
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+
+namespace Impostor.Api.Innersloth.GameFilters
+{
+ [Serializable]
+ public class CategorizedGameFilter : ISubFilter
+ {
+ [JsonPropertyName("FilterType")]
+ public string FilterType { get; } = "cat";
+
+ [JsonPropertyName("AcceptedValues")]
+ public required List<int> AcceptedValues { get; set; }
+
+ [JsonPropertyName("OptionEnum")]
+ public required CategorizedOptionNames OptionEnum { get; set; }
+ }
+}
--- /dev/null
+using System;
+using System.Text.Json.Serialization;
+
+namespace Impostor.Api.Innersloth.GameFilters
+{
+ [Serializable]
+ public class ChatModeGameFilter : ISubFilter
+ {
+ [JsonPropertyName("FilterType")]
+ public string FilterType { get; } = "chat";
+
+ [JsonPropertyName("AcceptedValues")]
+ public required byte AcceptedValues { get; set; }
+ }
+}
--- /dev/null
+using System;
+using System.Runtime.Serialization;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Impostor.Api.Innersloth.GameFilters
+{
+ [Serializable]
+ public class GameFilter
+ {
+ [JsonConstructor]
+ public GameFilter(string optionType, string key, string subFilterString)
+ {
+ this.OptionType = optionType;
+ this.Key = key;
+ this.SubFilterString = subFilterString;
+ this.SubFilter = ResolveSubFilter(OptionType, SubFilterString);
+ }
+
+ [JsonIgnore]
+ public ISubFilter SubFilter { get; set; }
+
+ [JsonPropertyName("OptionType")]
+ public required string OptionType { get; set; }
+
+ [JsonPropertyName("Key")]
+ public required string Key { get; set; }
+
+ [JsonPropertyName("SubFilterString")]
+ public required string SubFilterString { get; set; }
+
+ public void ModifySubFilter(ISubFilter subFilter)
+ {
+ this.SubFilter = subFilter;
+ }
+
+ [OnSerializing]
+ internal void OnSerializing(StreamingContext context)
+ {
+ this.SubFilterString = JsonSerializer.Serialize(this.SubFilter);
+ }
+
+ [OnDeserialized]
+ internal void OnDeserialized(StreamingContext context)
+ {
+ this.SubFilter = this.ResolveSubFilter(this.OptionType, this.SubFilterString);
+ }
+
+ private ISubFilter ResolveSubFilter(string type, string filterString)
+ {
+ if (type != null)
+ {
+ switch (type.ToLower())
+ {
+ case "int":
+ return JsonSerializer.Deserialize<IntGameFilter>(filterString)
+ ?? throw new InvalidOperationException("Deserialization returned null for IntGameFilter");
+ case "platform":
+ return JsonSerializer.Deserialize<PlatformGameFilter>(filterString)
+ ?? throw new InvalidOperationException("Deserialization returned null for PlatformGameFilter");
+ case "cat":
+ return JsonSerializer.Deserialize<CategorizedGameFilter>(filterString)
+ ?? throw new InvalidOperationException("Deserialization returned null for CategorizedGameFilter");
+ case "languages":
+ return JsonSerializer.Deserialize<LanguageFilter>(filterString)
+ ?? throw new InvalidOperationException("Deserialization returned null for LanguageFilter");
+ case "chat":
+ return JsonSerializer.Deserialize<ChatModeGameFilter>(filterString)
+ ?? throw new InvalidOperationException("Deserialization returned null for ChatModeGameFilter");
+ case "map":
+ return JsonSerializer.Deserialize<MapGameFilter>(filterString)
+ ?? throw new InvalidOperationException("Deserialization returned null for MapGameFilter");
+ case "bool":
+ return JsonSerializer.Deserialize<BoolGameFilter>(filterString)
+ ?? throw new InvalidOperationException("Deserialization returned null for BoolGameFilter");
+ default:
+ throw new InvalidOperationException("No type matches subfilter");
+ }
+ }
+
+ throw new InvalidOperationException("Must provide type for sub filter");
+ }
+ }
+}
--- /dev/null
+using System;
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+
+namespace Impostor.Api.Innersloth.GameFilters
+{
+ [Serializable]
+ public class GameFilterSet
+ {
+ [JsonConstructor]
+ public GameFilterSet(GameModes gameMode, List<GameFilter> filters)
+ {
+ GameMode = gameMode;
+ Filters = filters;
+ }
+
+ [JsonPropertyName("GameMode")]
+ public required GameModes GameMode { get; set; }
+
+ [JsonPropertyName("Filters")]
+ public required List<GameFilter> Filters { get; set; }
+ }
+}
--- /dev/null
+using System;
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+
+namespace Impostor.Api.Innersloth.GameFilters
+{
+ [Serializable]
+ public class GameFiltersList
+ {
+ [JsonPropertyName("FilterSets")]
+ public required List<GameFilterSet> FilterSets { get; set; }
+ }
+}
--- /dev/null
+namespace Impostor.Api.Innersloth.GameFilters
+{
+ public interface ISubFilter
+ {
+ string FilterType { get; }
+ }
+}
--- /dev/null
+using System;
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+
+namespace Impostor.Api.Innersloth.GameFilters
+{
+ [Serializable]
+ public class IntGameFilter : ISubFilter
+ {
+ [JsonPropertyName("FilterType")]
+ public string FilterType { get; } = "int";
+
+ [JsonPropertyName("AcceptedValues")]
+ public required List<int> AcceptedValues { get; set; }
+
+ [JsonPropertyName("OptionEnum")]
+ public required Int32OptionNames OptionEnum { get; set; }
+ }
+}
--- /dev/null
+using System;
+using System.Text.Json.Serialization;
+
+namespace Impostor.Api.Innersloth.GameFilters
+{
+ [Serializable]
+ public class LanguageFilter : ISubFilter
+ {
+ [JsonPropertyName("FilterType")]
+ public string FilterType { get; } = "languages";
+
+ [JsonPropertyName("AcceptedValues")]
+ public required uint AcceptedValues { get; set; }
+ }
+}
--- /dev/null
+using System;
+using System.Text.Json.Serialization;
+
+namespace Impostor.Api.Innersloth.GameFilters
+{
+ [Serializable]
+ public class MapGameFilter : ISubFilter
+ {
+ [JsonPropertyName("FilterType")]
+ public string FilterType { get; } = "map";
+
+ [JsonPropertyName("AcceptedValues")]
+ public required byte AcceptedValues { get; set; }
+ }
+}
--- /dev/null
+using System;
+using System.Text.Json.Serialization;
+
+namespace Impostor.Api.Innersloth.GameFilters
+{
+ [Serializable]
+ public class PlatformGameFilter : ISubFilter
+ {
+ [JsonPropertyName("FilterType")]
+ public string FilterType { get; } = "platform";
+
+ [JsonPropertyName("AcceptedValues")]
+ public required uint AcceptedValues { get; set; }
+ }
+}
/// </summary>
public bool IsDefaults { get; set; }
+ public GameTags Tag { get; set; }
+
public void Serialize(IMessageWriter writer);
public static void EnsureVersionIsModular<TCaller>(byte version)
/// </summary>
public bool IsDefaults { get; set; } = true;
+ public GameTags Tag { get; set; } = 0;
+
public static LegacyGameOptionsData Deserialize(IMessageReader reader, byte version)
{
var options = new LegacyGameOptionsData(version);
--- /dev/null
+using System;
+using System.Net.Http.Headers;
+using System.Text.Json;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Impostor.Server.Http;
+
+[Route("/api/filters")]
+public sealed class FiltersController : ControllerBase
+{
+ private static readonly string[] Value = new[]
+ {
+ "Tags",
+
+ // "PlayerSpeed",
+ // "Roles",
+ // "KillCooldown",
+ // "VotingTime",
+ "NumImposters",
+
+ // "VisualTasks",
+ // "AnonymousVotes",
+ // "ConfirmEjects",
+ // "DiscussionTime",
+ // "EmergencyCooldown",
+ // "NumEmergencyMeetings",
+ // "NumCommonTasks",
+ // "NumShortTasks",
+ // "NumLongTasks",
+ // "KillDistance",
+ };
+
+ [HttpGet]
+ public IActionResult GetFilters([FromHeader] AuthenticationHeaderValue authorization)
+ {
+ if (authorization.Scheme != "Bearer" || authorization.Parameter == null)
+ {
+ return BadRequest();
+ }
+
+ TokenController.Token? token = null;
+ try
+ {
+ token = JsonSerializer.Deserialize<TokenController.Token>(Convert.FromBase64String(authorization.Parameter));
+ }
+ catch
+ {
+ return BadRequest();
+ }
+
+ return Ok(new
+ {
+ filters = Value,
+ });
+ }
+}
using System.Net.Http.Headers;
using System.Text.Json;
using System.Text.Json.Serialization;
+using System.Web;
using Impostor.Api.Config;
using Impostor.Api.Games;
using Impostor.Api.Games.Managers;
using Impostor.Api.Innersloth;
+using Impostor.Api.Innersloth.GameFilters;
+using Impostor.Hazel.UPnP;
using Impostor.Server.Extensions;
using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Impostor.Server.Http;
private readonly IGameManager _gameManager;
private readonly ListingManager _listingManager;
private readonly HostServer _hostServer;
+ private readonly ILogger<GamesController> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="GamesController"/> class.
/// <param name="gameManager">GameManager containing a list of games.</param>
/// <param name="listingManager">ListingManager responsible for filtering.</param>
/// <param name="serverConfig">Impostor configuration section containing the public ip address of this server.</param>
- public GamesController(IGameManager gameManager, ListingManager listingManager, IOptions<ServerConfig> serverConfig)
+ public GamesController(IGameManager gameManager, ListingManager listingManager, IOptions<ServerConfig> serverConfig, ILogger<GamesController> logger)
{
_gameManager = gameManager;
_listingManager = listingManager;
var config = serverConfig.Value;
_hostServer = HostServer.From(IPAddress.Parse(config.ResolvePublicIp()), config.PublicPort);
+ _logger = logger;
}
/// <summary>
}
[HttpGet("filtered")]
- public IActionResult ShowFilteredLobbies()
+ public IActionResult ShowFilteredLobbies([FromQuery] string filter)
{
- // TODO: implement this stub
- var response = new
+ if (string.IsNullOrEmpty(filter))
{
- games = Array.Empty<GameListing>(),
- metadata = new
+ return BadRequest(new MatchmakerResponse(new MatchmakerError(DisconnectReason.ServerError, "filter query parameter not provided")));
+ }
+
+ try
+ {
+ var decodedFilter = HttpUtility.UrlDecode(filter);
+ var filtersList = JsonSerializer.Deserialize<GameFiltersList>(decodedFilter);
+
+ // filterSets wont be null. It must at least have ChatFilter and LangFilter
+ // Vanilla game only builds one filterSet and InnerSloth officials only handles first one (though you can send multiple filter sets. sloths only handle the first)
+ if (filtersList == null || filtersList.FilterSets.Count != 1
+ || filtersList.FilterSets[0].Filters.Count < 2
+ || !filtersList.FilterSets[0].Filters.Any(x => x.OptionType == "languages")
+ || !filtersList.FilterSets[0].Filters.Any(x => x.OptionType == "chat"))
{
- allGamesCount = _gameManager.Games.Count(),
- matchingGamesCount = 0,
- },
- };
+ return BadRequest(new MatchmakerResponse(new MatchmakerError(DisconnectReason.ServerError, "Invalid filterSets")));
+ }
+
+ var filteredGames = _listingManager.FindListingsV2(HttpContext, filtersList);
+ var gameListings = filteredGames.Select(GameListing.From).ToList();
+
+ var response = new
+ {
+ games = gameListings,
+ metadata = new
+ {
+ allGamesCount = _gameManager.Games.Count(),
+ matchingGamesCount = gameListings.Count,
+ },
+ };
- return Ok(response);
+ return Ok(response);
+ }
+ catch (JsonException ex)
+ {
+ return BadRequest(new MatchmakerResponse(new MatchmakerError(DisconnectReason.ServerError, "Unable to deserialize filter json" + ex)));
+ }
+ catch (Exception ex)
+ {
+ return BadRequest(new MatchmakerResponse(new MatchmakerError(DisconnectReason.ServerError, "Unknown exception caught in filter" + ex)));
+ }
}
private static uint ConvertAddressToNumber(IPAddress address)
private class MatchmakerError
{
[SetsRequiredMembers]
- public MatchmakerError(DisconnectReason reason)
+ public MatchmakerError(DisconnectReason reason, string message = "")
{
Reason = reason;
+ Message = message;
}
[JsonPropertyName("Reason")]
public required DisconnectReason Reason { get; init; }
+
+ [JsonPropertyName("Message")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
+ public required string Message { get; init; } = string.Empty;
}
private class FindGameByCodeResponse
using System.Collections.Generic;
using System.Linq;
+using System.Text.Json;
using Impostor.Api.Config;
using Impostor.Api.Games;
using Impostor.Api.Games.Managers;
using Impostor.Api.Http;
using Impostor.Api.Innersloth;
+using Impostor.Api.Innersloth.GameFilters;
using Impostor.Api.Net.Manager;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
}
}
+ public IEnumerable<IGame> FindListingsV2(HttpContext ctx, GameFiltersList filtersList, int maxListings = 10)
+ {
+ if (filtersList == null || filtersList.FilterSets.Count == 0)
+ {
+ return Enumerable.Empty<IGame>();
+ }
+
+ var result = new List<IGame>();
+ var resultCount = 0;
+
+ // Apply custom filters from API
+ var filters = _listingFilters.Select(f => f.GetFilter(ctx)).ToArray();
+
+ foreach (var game in _gameManager.Games)
+ {
+ if (!game.IsPublic || game.GameState != GameStates.NotStarted || game.PlayerCount >= game.Options.MaxPlayers)
+ {
+ continue;
+ }
+
+ if (!filters.All(filter => filter(game)))
+ {
+ continue;
+ }
+
+ var matchesAnyFilterSet = false;
+ foreach (var filterSet in filtersList.FilterSets)
+ {
+ // Not sure how to handle normal fools and seek fools
+ if (game.Options.GameMode != filterSet.GameMode)
+ {
+ continue;
+ }
+
+ var matchesAllFilters = true;
+
+ // Hard coded to only check map, languages, impostornum, chatmode, tags
+ foreach (var filter in filterSet.Filters)
+ {
+ switch (filter.OptionType)
+ {
+ case "map":
+ if (filter.SubFilter is MapGameFilter mapFilter && ((1 << (int)game.Options.Map) & mapFilter.AcceptedValues) == 0)
+ {
+ matchesAllFilters = false;
+ }
+
+ break;
+ case "languages":
+ if (filter.SubFilter is LanguageFilter langFilter && game.Options.Keywords != (GameKeywords)langFilter.AcceptedValues)
+ {
+ matchesAllFilters = false;
+ }
+
+ break;
+ case "int":
+ if (filter.SubFilter is IntGameFilter intFilter)
+ {
+ switch (intFilter.OptionEnum)
+ {
+ case Int32OptionNames.NumImpostors:
+ if (!intFilter.AcceptedValues.Contains(game.Options.NumImpostors))
+ {
+ matchesAllFilters = false;
+ }
+
+ break;
+ case Int32OptionNames.Tag:
+ if (!intFilter.AcceptedValues.Contains((int)game.Options.Tag))
+ {
+ matchesAllFilters = false;
+ }
+
+ break;
+ }
+ }
+
+ break;
+ case "chat":
+ if (filter.SubFilter is ChatModeGameFilter chatFilter && game.Host != null)
+ {
+ if (((byte)game.Host.Client.ChatMode & chatFilter.AcceptedValues) == 0)
+ {
+ matchesAllFilters = false;
+ }
+ }
+
+ break;
+ }
+
+ if (!matchesAllFilters)
+ {
+ break;
+ }
+ }
+
+ if (matchesAllFilters)
+ {
+ matchesAnyFilterSet = true;
+ break;
+ }
+ }
+
+ if (matchesAnyFilterSet)
+ {
+ result.Add(game);
+ if (++resultCount >= maxListings)
+ {
+ break;
+ }
+ }
+ }
+
+ return result;
+ }
+
private static bool IsGameDesired(IGame game, int map, int impostorCount, GameKeywords language)
{
if ((map & (1 << (int)game.Options.Map)) == 0)