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