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