From a42a552d8ae28b1b97b5a7a5ae3827921efd3caa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20B=C3=ADl?= Date: Fri, 25 Sep 2026 13:46:08 +0000 Subject: [PATCH 1/2] Expose underage on IPlayer The player profile carries underage (-1 unknown / 0 no / 1 yes) since oddsfeedschema v1.1.0. IPlayer.Underage returns it as the new UnderageStatus enum; a missing attribute reads as Unknown. Co-Authored-By: Claude Fable 5.1 --- .../API/Entities/PlayerUnderageTests.cs | 33 +++++++++++++++++++ .../API/Entities/Abstractions/IPlayer.cs | 2 ++ .../Entities/Abstractions/UnderageStatus.cs | 12 +++++++ .../API/Entities/LocalizedPlayer.cs | 2 ++ .../Oddin.OddsFeedSdk/API/Entities/Player.cs | 2 ++ .../API/Entities/UnderageStatusParser.cs | 14 ++++++++ .../API/Models/PlayerModel.cs | 4 +++ .../Oddin.OddsFeedSdk/API/PlayerCache.cs | 2 ++ 8 files changed, 71 insertions(+) create mode 100644 src/Oddin.OddsFeedSdk/Oddin.OddsFeedSdk.Tests/API/Entities/PlayerUnderageTests.cs create mode 100644 src/Oddin.OddsFeedSdk/Oddin.OddsFeedSdk/API/Entities/Abstractions/UnderageStatus.cs create mode 100644 src/Oddin.OddsFeedSdk/Oddin.OddsFeedSdk/API/Entities/UnderageStatusParser.cs diff --git a/src/Oddin.OddsFeedSdk/Oddin.OddsFeedSdk.Tests/API/Entities/PlayerUnderageTests.cs b/src/Oddin.OddsFeedSdk/Oddin.OddsFeedSdk.Tests/API/Entities/PlayerUnderageTests.cs new file mode 100644 index 0000000..fd9e9ee --- /dev/null +++ b/src/Oddin.OddsFeedSdk/Oddin.OddsFeedSdk.Tests/API/Entities/PlayerUnderageTests.cs @@ -0,0 +1,33 @@ +using System.Globalization; +using Oddin.OddsFeedSdk.API.Entities; +using Oddin.OddsFeedSdk.API.Entities.Abstractions; +using Oddin.OddsFeedSdk.API.Models; +using Oddin.OddsFeedSdk.Common; +using Xunit; + +namespace Oddin.OddsFeedSdk.Tests.API.Entities; + +public class PlayerUnderageTests +{ + [Theory] + [InlineData("1", UnderageStatus.Yes)] + [InlineData("0", UnderageStatus.No)] + [InlineData("-1", UnderageStatus.Unknown)] + [InlineData(null, UnderageStatus.Unknown)] + [InlineData("", UnderageStatus.Unknown)] + [InlineData("true", UnderageStatus.Unknown)] + public void WireEncodingMapsOntoTheEnum(string wire, UnderageStatus expected) + => Assert.Equal(expected, UnderageStatusParser.Parse(wire)); + + [Fact] + public void LocalizedPlayerDefaultsToUnknown() + => Assert.Equal(UnderageStatus.Unknown, new LocalizedPlayer(new URN("od:player:1")).Underage); + + [Fact] + public void PlayerModelDeserializesUnderage() + { + const string xml = ""; + Assert.True(XmlHelper.TryDeserialize(xml, out var profile)); + Assert.Equal("1", profile.player.underage); + } +} diff --git a/src/Oddin.OddsFeedSdk/Oddin.OddsFeedSdk/API/Entities/Abstractions/IPlayer.cs b/src/Oddin.OddsFeedSdk/Oddin.OddsFeedSdk/API/Entities/Abstractions/IPlayer.cs index 47a9937..50f6541 100644 --- a/src/Oddin.OddsFeedSdk/Oddin.OddsFeedSdk/API/Entities/Abstractions/IPlayer.cs +++ b/src/Oddin.OddsFeedSdk/Oddin.OddsFeedSdk/API/Entities/Abstractions/IPlayer.cs @@ -14,6 +14,8 @@ public interface IPlayer IReadOnlyDictionary SportIDs { get; } + UnderageStatus Underage { get; } + string GetName(CultureInfo culture); string GetFullName(CultureInfo culture); diff --git a/src/Oddin.OddsFeedSdk/Oddin.OddsFeedSdk/API/Entities/Abstractions/UnderageStatus.cs b/src/Oddin.OddsFeedSdk/Oddin.OddsFeedSdk/API/Entities/Abstractions/UnderageStatus.cs new file mode 100644 index 0000000..deb1930 --- /dev/null +++ b/src/Oddin.OddsFeedSdk/Oddin.OddsFeedSdk/API/Entities/Abstractions/UnderageStatus.cs @@ -0,0 +1,12 @@ +namespace Oddin.OddsFeedSdk.API.Entities.Abstractions; + +/// +/// Whether a competitor or player is flagged as underage. The feed encodes it +/// as -1 (unknown), 0 (no) and 1 (yes); anything else reads as . +/// +public enum UnderageStatus +{ + Unknown = -1, + No = 0, + Yes = 1 +} diff --git a/src/Oddin.OddsFeedSdk/Oddin.OddsFeedSdk/API/Entities/LocalizedPlayer.cs b/src/Oddin.OddsFeedSdk/Oddin.OddsFeedSdk/API/Entities/LocalizedPlayer.cs index bc8c373..c48d4b2 100644 --- a/src/Oddin.OddsFeedSdk/Oddin.OddsFeedSdk/API/Entities/LocalizedPlayer.cs +++ b/src/Oddin.OddsFeedSdk/Oddin.OddsFeedSdk/API/Entities/LocalizedPlayer.cs @@ -17,6 +17,8 @@ internal class LocalizedPlayer : ILocalizedItem internal IDictionary SportID { get; set; } = new Dictionary(); + internal UnderageStatus Underage { get; set; } = UnderageStatus.Unknown; + public IEnumerable LoadedLocals => GetLoadedLocals(); private IEnumerable GetLoadedLocals() diff --git a/src/Oddin.OddsFeedSdk/Oddin.OddsFeedSdk/API/Entities/Player.cs b/src/Oddin.OddsFeedSdk/Oddin.OddsFeedSdk/API/Entities/Player.cs index 5e7ca6c..a58d960 100644 --- a/src/Oddin.OddsFeedSdk/Oddin.OddsFeedSdk/API/Entities/Player.cs +++ b/src/Oddin.OddsFeedSdk/Oddin.OddsFeedSdk/API/Entities/Player.cs @@ -69,6 +69,8 @@ public IReadOnlyDictionary SportIDs } } + public UnderageStatus Underage => FetchPlayer(_cultures)?.Underage ?? UnderageStatus.Unknown; + public string GetName(CultureInfo culture) => FetchPlayer(new[] { culture }) ?.Name diff --git a/src/Oddin.OddsFeedSdk/Oddin.OddsFeedSdk/API/Entities/UnderageStatusParser.cs b/src/Oddin.OddsFeedSdk/Oddin.OddsFeedSdk/API/Entities/UnderageStatusParser.cs new file mode 100644 index 0000000..b60d608 --- /dev/null +++ b/src/Oddin.OddsFeedSdk/Oddin.OddsFeedSdk/API/Entities/UnderageStatusParser.cs @@ -0,0 +1,14 @@ +using Oddin.OddsFeedSdk.API.Entities.Abstractions; + +namespace Oddin.OddsFeedSdk.API.Entities; + +internal static class UnderageStatusParser +{ + internal static UnderageStatus Parse(string value) => + value switch + { + "0" => UnderageStatus.No, + "1" => UnderageStatus.Yes, + _ => UnderageStatus.Unknown + }; +} diff --git a/src/Oddin.OddsFeedSdk/Oddin.OddsFeedSdk/API/Models/PlayerModel.cs b/src/Oddin.OddsFeedSdk/Oddin.OddsFeedSdk/API/Models/PlayerModel.cs index fe47828..f7e3986 100644 --- a/src/Oddin.OddsFeedSdk/Oddin.OddsFeedSdk/API/Models/PlayerModel.cs +++ b/src/Oddin.OddsFeedSdk/Oddin.OddsFeedSdk/API/Models/PlayerModel.cs @@ -118,6 +118,10 @@ public string full_name /// [System.Xml.Serialization.XmlAttributeAttribute("sport")] public string sportID { get; set; } + + /// + [System.Xml.Serialization.XmlAttributeAttribute("underage")] + public string underage { get; set; } } /// diff --git a/src/Oddin.OddsFeedSdk/Oddin.OddsFeedSdk/API/PlayerCache.cs b/src/Oddin.OddsFeedSdk/Oddin.OddsFeedSdk/API/PlayerCache.cs index c1765a1..c8dff3e 100644 --- a/src/Oddin.OddsFeedSdk/Oddin.OddsFeedSdk/API/PlayerCache.cs +++ b/src/Oddin.OddsFeedSdk/Oddin.OddsFeedSdk/API/PlayerCache.cs @@ -147,6 +147,7 @@ private void RefreshOrInsertItem(URN id, CultureInfo culture, player_profilePlay item.Name[culture] = data.name; item.FullName[culture] = data.full_name; item.SportID[culture] = data.sportID; + item.Underage = UnderageStatusParser.Parse(data.underage); } else { @@ -154,6 +155,7 @@ private void RefreshOrInsertItem(URN id, CultureInfo culture, player_profilePlay item.Name[culture] = data.name; item.FullName[culture] = data.full_name; item.SportID[culture] = data.sportID; + item.Underage = UnderageStatusParser.Parse(data.underage); } _cache.Set(id.ToString(), item, _cacheTtl.AsCachePolicy()); From 10cfc6665939a986db3b9239f7418c97089c936c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20B=C3=ADl?= Date: Fri, 25 Sep 2026 14:12:44 +0000 Subject: [PATCH 2/2] Keep a known underage value when a payload omits it, test the cache path Nested players are side-loaded from every competitor profile response, so a later payload without the attribute (another culture, or a producer that does not send it on nested players) reset a known Yes/No to Unknown, and the culture was marked loaded so nothing corrected it before the TTL. Assign only when the attribute is present; an explicit -1 still retracts. Co-Authored-By: Claude Fable 5.1 --- .../API/Entities/PlayerUnderageTests.cs | 41 +++++++++++++++++++ .../Oddin.OddsFeedSdk/API/PlayerCache.cs | 6 ++- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/src/Oddin.OddsFeedSdk/Oddin.OddsFeedSdk.Tests/API/Entities/PlayerUnderageTests.cs b/src/Oddin.OddsFeedSdk/Oddin.OddsFeedSdk.Tests/API/Entities/PlayerUnderageTests.cs index fd9e9ee..2ce86e6 100644 --- a/src/Oddin.OddsFeedSdk/Oddin.OddsFeedSdk.Tests/API/Entities/PlayerUnderageTests.cs +++ b/src/Oddin.OddsFeedSdk/Oddin.OddsFeedSdk.Tests/API/Entities/PlayerUnderageTests.cs @@ -1,4 +1,10 @@ +using System.Collections.Generic; using System.Globalization; +using System.Reflection; +using Oddin.OddsFeedSdk.API; +using Oddin.OddsFeedSdk.API.Abstractions; +using Oddin.OddsFeedSdk.Configuration.Abstractions; +using Oddin.OddsFeedSdk.Tests.API; using Oddin.OddsFeedSdk.API.Entities; using Oddin.OddsFeedSdk.API.Entities.Abstractions; using Oddin.OddsFeedSdk.API.Models; @@ -30,4 +36,39 @@ public void PlayerModelDeserializesUnderage() Assert.True(XmlHelper.TryDeserialize(xml, out var profile)); Assert.Equal("1", profile.player.underage); } + + // Nested players are side-loaded from every competitor profile response, so a + // later payload (another culture, a nested player) that omits the attribute + // must keep a known value; only an explicit -1 retracts it. + [Fact] + public void CacheCarriesUnderageThroughToThePlayerAndKeepsItWhenAPayloadOmitsIt() + { + var api = DispatchProxy.Create(); + var proxy = (CacheObserverResilienceTests.PublishOnlyApiClientProxy)api; + using var cache = new PlayerCache(api); + var id = new URN("od:player:1"); + var en = new CultureInfo("en"); + var de = new CultureInfo("de"); + var fr = new CultureInfo("fr"); + + proxy.Publish(Profile(NestedPlayer("1")), en); + var player = new Player(id, cache, null, ExceptionHandlingStrategy.CATCH, new[] { en }); + Assert.Equal(UnderageStatus.Yes, player.Underage); + + proxy.Publish(Profile(NestedPlayer(null)), de); + Assert.Equal(UnderageStatus.Yes, cache.GetPlayer(id, new[] { en, de }).Underage); + + proxy.Publish(Profile(NestedPlayer("-1")), fr); + Assert.Equal(UnderageStatus.Unknown, cache.GetPlayer(id, new[] { en, de, fr }).Underage); + } + + private static player_profilePlayer NestedPlayer(string underage) => + new() { id = "od:player:1", name = "P", sportID = "od:sport:1", underage = underage }; + + private static competitorProfileEndpoint Profile(params player_profilePlayer[] players) => + new() + { + competitor = new teamExtended { id = "od:competitor:1", name = "Team" }, + players = new List(players) + }; } diff --git a/src/Oddin.OddsFeedSdk/Oddin.OddsFeedSdk/API/PlayerCache.cs b/src/Oddin.OddsFeedSdk/Oddin.OddsFeedSdk/API/PlayerCache.cs index c8dff3e..eb0ea3d 100644 --- a/src/Oddin.OddsFeedSdk/Oddin.OddsFeedSdk/API/PlayerCache.cs +++ b/src/Oddin.OddsFeedSdk/Oddin.OddsFeedSdk/API/PlayerCache.cs @@ -147,7 +147,8 @@ private void RefreshOrInsertItem(URN id, CultureInfo culture, player_profilePlay item.Name[culture] = data.name; item.FullName[culture] = data.full_name; item.SportID[culture] = data.sportID; - item.Underage = UnderageStatusParser.Parse(data.underage); + if (data.underage is not null) + item.Underage = UnderageStatusParser.Parse(data.underage); } else { @@ -155,7 +156,8 @@ private void RefreshOrInsertItem(URN id, CultureInfo culture, player_profilePlay item.Name[culture] = data.name; item.FullName[culture] = data.full_name; item.SportID[culture] = data.sportID; - item.Underage = UnderageStatusParser.Parse(data.underage); + if (data.underage is not null) + item.Underage = UnderageStatusParser.Parse(data.underage); } _cache.Set(id.ToString(), item, _cacheTtl.AsCachePolicy());