Files
Ananta-ps/Ananta.Server/Ananta.Core/Protocol/Client4229938/LivehouseProgressStore.cs
T

127 lines
4.6 KiB
C#

namespace Ananta.Server.Protocol.Client4229938;
/// <summary>
/// 一首歌的历史成绩 —— Livehouse 音游(节奏打击)per-player 存档的最小形状。
///
/// <para>
/// 字段直接对应 wire 上的 <c>MusicClientInfoPush.RecordInfo</c>
/// (见 <c>Ananta.RpcTypes\Client4229938\MusicPlayWireContracts.cs</c> 的顶部注释):
/// <b>Perfect / Great / Miss / Special</b>,顺序不可换。
/// </para>
/// <para>
/// 这 4 + 1 个字段全是 primitive ⇒ 同一份 record 既做内存形状也做
/// <c>config/player-progress.json</c> 里的落盘形状,不需要额外的 <c>Save*</c> DTO
/// (对比 <c>FashionColoringRecord4229938</c>:它因为嵌套字典才必须配 <c>SaveFashionColoring</c>)。
/// </para>
/// </summary>
internal sealed record LivehouseRecord4229938(
uint Perfect,
uint Great,
uint Miss,
uint Special,
bool AlreadyReward);
/// <summary>
/// Livehouse 音游进度:a) 每首歌的最好成绩 b) 「首次引导播过了没有」。
///
/// <para>
/// 挂在 <see cref="WorldEntryState"/> 上并通过 <c>PlayerProgressStore</c> 落盘
/// (硬规则 2:跨 session 状态必须走 WorldEntryState + PlayerProgressStore),
/// 这样重登之后曲目列表里的等级打点还在。
/// </para>
/// <para>
/// 为什么不记「当前正在打的那一首」:<c>LiveHouseMusicStart</c> /
/// <c>LiveHouseMusicInterrupt</c> 那两条 notify 只是开局/中断通知,
/// 真正的成绩由 <c>SetLiveHouseMusicResult</c> 送来;把「进行中」放进持久化存档
/// 会在中途退出时留下脏状态 ⇒ 放在 session 里(handler 自己的字典),不进这里。
/// </para>
/// </summary>
internal static class LivehouseProgressStore
{
/// <summary>
/// 取全部曲目成绩的<b>快照</b>。
///
/// ⛑ 必须在调用方**没有**持有 <c>state.SyncRoot</c> 时调用(本方法自己会上锁)。
/// </summary>
internal static Dictionary<uint, LivehouseRecord4229938> Snapshot(WorldEntryState? state)
{
var result = new Dictionary<uint, LivehouseRecord4229938>();
if (state is null)
return result;
lock (state.SyncRoot)
{
foreach (var (musicId, record) in state.LivehouseRecords)
{
if (musicId == 0 || record is null)
continue;
result[musicId] = record;
}
}
return result;
}
/// <summary>
/// 记一次通关成绩。<c>counts</c> 的前 4 项依次为 Perfect / Great / Miss / Special。
///
/// <para>
/// ⛑ 「更好」的判定按 <b>Perfect 数</b>递增,而不是「最后一次」:
/// 客户端 <c>AskLiveHouseMusicList</c> 回来的东西会被直接灌进
/// <c>gMusicGameManager.RecordMusicInfo</c>,回写最后一次成绩会让
/// 「打崩一局把 S 级洗成 C 级」成为常态。
/// </para>
/// </summary>
internal static void Record(WorldEntryState state, uint musicId, IReadOnlyList<uint> counts)
{
if (musicId == 0 || counts.Count == 0)
return;
var perfect = counts.Count > 0 ? counts[0] : 0u;
var great = counts.Count > 1 ? counts[1] : 0u;
var miss = counts.Count > 2 ? counts[2] : 0u;
var special = counts.Count > 3 ? counts[3] : 0u;
lock (state.SyncRoot)
{
var old = state.LivehouseRecords.GetValueOrDefault(musicId);
if (old is not null && old.Perfect >= perfect && old.Special >= special)
return;
state.LivehouseRecords[musicId] = new LivehouseRecord4229938(
perfect, great, miss, special, old?.AlreadyReward ?? false);
}
}
/// <summary>
/// 消费「首次游玩」标记:第一次调用返回 <c>true</c>,之后恒 <c>false</c>。
///
/// <para>
/// 这是 <c>AskQueryLiveHouseInfo</c> 唯一会写入 <c>FirstPlay</c> 的地方。
/// 客户端拿它决定要不要播 <c>LivehouseIntroTL</c>(见 Field notes 2026-10-04)。
/// </para>
/// </summary>
internal static bool TakeFirstPlay(WorldEntryState state)
{
lock (state.SyncRoot)
{
if (state.LivehouseFirstPlayDone)
return false;
state.LivehouseFirstPlayDone = true;
return true;
}
}
/// <summary>全部抹掉 —— 供 economy reset 走同一套「抹干净」语义。</summary>
internal static void Clear(WorldEntryState state)
{
lock (state.SyncRoot)
{
state.LivehouseRecords.Clear();
state.LivehouseFirstPlayDone = false;
}
}
}