Support new http matchmaking (#685)

* Add GameFilters interface

* Add Newtonsoft.Json

Used by InnerSloth

* Port 2025.3.25 Data

* Generate OptionNames

* Fix missing CategorizedOptionNames

* Remove Newtonsoft.Json

* Add missing GameFilterList

* Basic support for new filters

* Add FiltersController

Server need to tell clients its filters

* Add Tag filter in filtergames

* Add GameTags in legacy game options

* Correctly deserialize Filters

* Minor text fixes

---------

Co-authored-by: miniduikboot <mini@duikbo.at>
This commit is contained in:
NikoCat233andminiduikboot authored and GitHub committed 2026-08-13 22:03:04 +02:00
1 parent f0689ac2e2
commit 1cd298608a
27 files changed
+585 -16

No files matched your search

@@ -85,6 +85,14 @@ public sealed class SourceGenerator : IIncrementalGenerator
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);
@@ -0,0 +1,17 @@
{
"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
}
@@ -0,0 +1,4 @@
{
"Invalid": 0,
"MapId": 1
}
@@ -0,0 +1,14 @@
{
"KillCooldown": 0,
"PlayerSpeed": 1,
"EmergencyMeetingCount": 2,
"EmergencyMeetingCooldown": 3,
"DiscussionTime": 4,
"VotingTime": 5,
"CrewmateVision": 6,
"HidingTime": 7,
"MaxTimeInVent": 8,
"ImposterVision": 9,
"FinalHideTime": 10,
"FinalHideImposterSpeed": 11
}
@@ -0,0 +1,4 @@
{
"Invalid": 0,
"KillDistances": 1
}
@@ -0,0 +1,28 @@
{
"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
}
@@ -0,0 +1,5 @@
{
"Invalid": 0,
"MaxImpostors": 1,
"MinPlayers": 2
}
@@ -0,0 +1,21 @@
{
"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
}
@@ -1624,4 +1624,4 @@
"DetectiveNotesTitlePostIt": 4080,
"DetectiveNotesPostIt": 4081,
"DetectiveNotesSuspectNumber": 4082
}
}
@@ -0,0 +1,4 @@
{
"Invalid": 0,
"Keywords": 1
}
+2 -2
View File
@@ -1,5 +1,5 @@
{
"dumpostorVersion": "1.0.1-dev",
"gameVersion": "2025.10.14",
"gameVersion": "2024.10.29",
"platformType": "StandaloneItch"
}
}
@@ -0,0 +1,19 @@
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; }
}
}
@@ -0,0 +1,19 @@
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; }
}
}
@@ -0,0 +1,15 @@
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; }
}
}
@@ -0,0 +1,84 @@
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");
}
}
}
@@ -0,0 +1,23 @@
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; }
}
}
@@ -0,0 +1,13 @@
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; }
}
}
@@ -0,0 +1,7 @@
namespace Impostor.Api.Innersloth.GameFilters
{
public interface ISubFilter
{
string FilterType { get; }
}
}
@@ -0,0 +1,19 @@
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; }
}
}
@@ -0,0 +1,15 @@
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; }
}
}
@@ -0,0 +1,15 @@
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; }
}
}
@@ -0,0 +1,15 @@
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; }
}
}
@@ -49,6 +49,8 @@ public interface IGameOptions
/// </summary>
public bool IsDefaults { get; set; }
public GameTags Tag { get; set; }
public void Serialize(IMessageWriter writer);
public static void EnsureVersionIsModular<TCaller>(byte version)
@@ -145,6 +145,8 @@ public class LegacyGameOptionsData : IGameOptions
/// </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);
@@ -0,0 +1,56 @@
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,
});
}
}
+55 -13
View File
@@ -5,12 +5,16 @@ using System.Net;
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;
@@ -25,6 +29,7 @@ public sealed class GamesController : ControllerBase
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.
@@ -32,12 +37,13 @@ public sealed class GamesController : ControllerBase
/// <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>
@@ -116,20 +122,51 @@ public sealed class GamesController : ControllerBase
}
[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
{
allGamesCount = _gameManager.Games.Count(),
matchingGamesCount = 0,
},
};
return BadRequest(new MatchmakerResponse(new MatchmakerError(DisconnectReason.ServerError, "filter query parameter not provided")));
}
return Ok(response);
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"))
{
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);
}
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)
@@ -177,13 +214,18 @@ public sealed class GamesController : ControllerBase
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
+118
View File
@@ -1,10 +1,12 @@
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;
@@ -103,6 +105,122 @@ public sealed class ListingManager
}
}
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)