From 3ed8386303c35247293765f94b763d3b9ff82b68 Mon Sep 17 00:00:00 2001 From: gramps Date: Fri, 30 Oct 2026 14:35:42 -0700 Subject: [PATCH] =?UTF-8?q?feat(scoreboard):=20unit=201=20=E2=80=94=20sess?= =?UTF-8?q?ion=20tallies,=20with=20the=20gift=20double-count=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ScoreboardState folds the six live-chat event kinds into the numbers a creator puts on screen: last member, last Super Chat, last gift, members gained, milestones, and session money per currency. Scope is the running session (creator ruling): reset at start, dead at End, no persistence. Two rules are load-bearing: - money is keyed by currency and never summed across them; the tile shows the dominant currency by VALUE, so a single EUR tip cannot flip the display away from the larger USD total - a gifted membership is counted ONCE, from membershipGiftingEvent (which carries the count); giftMembershipReceivedEvent is the recipient's view of the same purchase and changes nothing. The test fires five recipient events after one purchase of five and the total must not move. --- Models/ScoreboardState.cs | 120 +++++++++++++++++++++++++++ ytLive.Tests/ScoreboardStateTests.cs | 68 +++++++++++++++ 2 files changed, 188 insertions(+) create mode 100644 Models/ScoreboardState.cs create mode 100644 ytLive.Tests/ScoreboardStateTests.cs diff --git a/Models/ScoreboardState.cs b/Models/ScoreboardState.cs new file mode 100644 index 0000000..85245a1 --- /dev/null +++ b/Models/ScoreboardState.cs @@ -0,0 +1,120 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace ytLive.Models; + +/// The session's scoreboard tallies — the facts a creator puts on screen to prove the +/// channel is moving. +/// +/// Scope is the RUNNING SESSION (creator ruling 2026-10-30): the counts start at zero when a +/// session starts and die with it. No persistence, nothing to reconcile, and every number is true +/// for exactly what this session saw — a stored "today" that survives a Windows restart would be +/// right only for the hours the app happened to be running, which is a lie the creator cannot +/// check. +/// +/// The two polled tiles (subscribers, watch hours) are NOT here: they are channel state, not +/// session events, and live on the polled side of the scoreboard. +public sealed class ScoreboardState +{ + /// Super Chat + Super Sticker money, per currency. Adding across currencies would be + /// the one arithmetic nobody forgives, so totals are keyed and only the dominant one is shown. + private readonly Dictionary _moneyByCurrency = new(StringComparer.OrdinalIgnoreCase); + + public string? LastMemberName { get; private set; } + public string? LastSuperChatAuthor { get; private set; } + public string? LastSuperChatDisplay { get; private set; } + public double LastSuperChatAmount { get; private set; } + public string? LastSuperChatCurrency { get; private set; } + public string? LastGifterName { get; private set; } + public int LastGiftCount { get; private set; } + + /// Members gained this session: paid memberships plus gifted ones (counted once, from + /// the GIFT PURCHASE — see ). + public int NewMembers { get; private set; } + + public int MilestoneCount { get; private set; } + + public IReadOnlyDictionary MoneyByCurrency => _moneyByCurrency; + + /// The session's dominant currency (most money) and its total, or ("", 0) when nothing + /// has come in. Chosen by VALUE so a single EUR tip beside ten USD cents cannot flip the display + /// to the smaller currency. + public (string Currency, double Total) SessionTotal => + _moneyByCurrency.Count == 0 + ? (string.Empty, 0) + : _moneyByCurrency.OrderByDescending(pair => pair.Value).First() is var top + ? (top.Key, top.Value) + : (string.Empty, 0); + + public bool HasMixedCurrencies => _moneyByCurrency.Count > 1; + + /// Folds one chat message into the tallies. Every event kind the poll decodes is + /// handled; plain chat is ignored. + /// + /// giftMembershipReceivedEvent deliberately does nothing. It is the RECIPIENT's view + /// of a purchase already counted by membershipGiftingEvent, so counting it would double both the + /// members and the money (creator ruling 2026-10-30: "come on"). + public void Apply(ChatMessage message) + { + switch (message.Kind) + { + case ChatEventKind.NewMember: + NewMembers++; + LastMemberName = message.AuthorName; + break; + + case ChatEventKind.MemberGift: + // The purchase: one event carrying how many memberships were bought. + var count = Math.Max(1, message.GiftNewMembersCount); + NewMembers += count; + LastGiftCount = count; + LastGifterName = message.AuthorName; + break; + + case ChatEventKind.GiftReceived: + break; // recipient side of the event above — never counted + + case ChatEventKind.MemberMilestone: + MilestoneCount++; + break; + + case ChatEventKind.SuperChat: + case ChatEventKind.SuperSticker: + LastSuperChatAuthor = message.AuthorName; + LastSuperChatAmount = message.SuperChatAmount; + LastSuperChatCurrency = string.IsNullOrWhiteSpace(message.SuperChatCurrency) + ? "USD" + : message.SuperChatCurrency!; + LastSuperChatDisplay = message.SuperChatDisplayString + ?? FormatMoney(LastSuperChatAmount, LastSuperChatCurrency); + if (message.SuperChatAmount > 0) + { + _moneyByCurrency.TryGetValue(LastSuperChatCurrency!, out var running); + _moneyByCurrency[LastSuperChatCurrency!] = running + message.SuperChatAmount; + } + break; + } + } + + /// A new session starts from zero. + public void Reset() => CopyFrom(new ScoreboardState()); + + private void CopyFrom(ScoreboardState other) + { + _moneyByCurrency.Clear(); + foreach (var pair in other._moneyByCurrency) _moneyByCurrency[pair.Key] = pair.Value; + LastMemberName = other.LastMemberName; + LastSuperChatAuthor = other.LastSuperChatAuthor; + LastSuperChatDisplay = other.LastSuperChatDisplay; + LastSuperChatAmount = other.LastSuperChatAmount; + LastSuperChatCurrency = other.LastSuperChatCurrency; + LastGifterName = other.LastGifterName; + LastGiftCount = other.LastGiftCount; + NewMembers = other.NewMembers; + MilestoneCount = other.MilestoneCount; + } + + private static string FormatMoney(double amount, string? currency) => + string.IsNullOrEmpty(currency) ? amount.ToString("0.00") : $"{currency} {amount:0.00}"; +} diff --git a/ytLive.Tests/ScoreboardStateTests.cs b/ytLive.Tests/ScoreboardStateTests.cs new file mode 100644 index 0000000..eb2aa1a --- /dev/null +++ b/ytLive.Tests/ScoreboardStateTests.cs @@ -0,0 +1,68 @@ +using Xunit; +using ytLive.Models; + +namespace ytLive.Tests; + +/// Session-scoped scoreboard tallies. The rules that matter: money is never added across +/// currencies, and a gifted membership is counted ONCE (from the purchase), never again from the +/// recipient's event — that double-count is the easy mistake, and the creator called it out. +public class ScoreboardStateTests +{ + private static ChatMessage Message(ChatEventKind kind, string author = "viewer", double amount = 0, + string? currency = "USD", int giftCount = 0, string? display = null) => new() + { + Kind = kind, AuthorName = author, SuperChatAmount = amount, + SuperChatCurrency = currency, GiftNewMembersCount = giftCount, SuperChatDisplayString = display, + }; + + [Fact] + public void Tallies_follow_the_six_events_and_never_double_count_a_gift() + { + var state = new ScoreboardState(); + + state.Apply(Message(ChatEventKind.NewMember, "Paid")); + Assert.Equal(1, state.NewMembers); + Assert.Equal("Paid", state.LastMemberName); + + // A gift purchase of 5 memberships counts 5... + state.Apply(Message(ChatEventKind.MemberGift, "Gifter", giftCount: 5)); + Assert.Equal(6, state.NewMembers); + Assert.Equal(5, state.LastGiftCount); + Assert.Equal("Gifter", state.LastGifterName); + + // ...and the recipients' own events add NOTHING (they are the same memberships). + for (var i = 0; i < 5; i++) state.Apply(Message(ChatEventKind.GiftReceived, $"Recipient{i}", giftCount: 1)); + Assert.Equal(6, state.NewMembers); + Assert.Equal(5, state.LastGiftCount); + Assert.Equal("Gifter", state.LastGifterName); + + state.Apply(Message(ChatEventKind.MemberMilestone, "Longtimer")); + Assert.Equal(1, state.MilestoneCount); + + // Super Chat and Super Sticker both carry money; the last one drives the "latest" tile. + state.Apply(Message(ChatEventKind.SuperChat, "Tipper", 10, "USD", display: "$10.00")); + state.Apply(Message(ChatEventKind.SuperSticker, "Sticker", 5, "USD")); + Assert.Equal(15, state.SessionTotal.Total, 3); + Assert.Equal("USD", state.SessionTotal.Currency); + Assert.Equal("Sticker", state.LastSuperChatAuthor); + Assert.False(state.HasMixedCurrencies); + + // A second currency is tracked separately, never summed into the first. + state.Apply(Message(ChatEventKind.SuperChat, "Euro", 3, "EUR")); + Assert.Equal(15, state.MoneyByCurrency["USD"], 3); + Assert.Equal(3, state.MoneyByCurrency["EUR"], 3); + Assert.True(state.HasMixedCurrencies); + Assert.Equal("USD", state.SessionTotal.Currency); // dominant by value, not by recency + + // Plain chat is ignored entirely. + state.Apply(Message(ChatEventKind.None, "Chatter")); + Assert.Equal(6, state.NewMembers); + Assert.Equal(1, state.MilestoneCount); + + // A new session starts clean. + state.Reset(); + Assert.Equal(0, state.NewMembers); + Assert.Equal(0, state.SessionTotal.Total); + Assert.Null(state.LastMemberName); + } +}