feat(scoreboard): unit 1 — session tallies, with the gift double-count guard

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.
This commit is contained in:
2026-10-30 14:35:42 -07:00
parent e92ce4bb63
commit 3ed8386303
2 changed files with 188 additions and 0 deletions
+120
View File
@@ -0,0 +1,120 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace ytLive.Models;
/// <summary>The session's scoreboard tallies — the facts a creator puts on screen to prove the
/// channel is moving.
///
/// <para>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.</para>
///
/// <para>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.</para></summary>
public sealed class ScoreboardState
{
/// <summary>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.</summary>
private readonly Dictionary<string, double> _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; }
/// <summary>Members gained this session: paid memberships plus gifted ones (counted once, from
/// the GIFT PURCHASE — see <see cref="Apply"/>).</summary>
public int NewMembers { get; private set; }
public int MilestoneCount { get; private set; }
public IReadOnlyDictionary<string, double> MoneyByCurrency => _moneyByCurrency;
/// <summary>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.</summary>
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;
/// <summary>Folds one chat message into the tallies. Every event kind the poll decodes is
/// handled; plain chat is ignored.
///
/// <para><b>giftMembershipReceivedEvent deliberately does nothing.</b> 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").</para></summary>
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;
}
}
/// <summary>A new session starts from zero.</summary>
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}";
}