Files
LlamaCasty/Models/ScoreboardState.cs
gramps 3ed8386303 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.
2026-10-30 14:35:42 -07:00

121 lines
5.5 KiB
C#

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}";
}