using Ananta.SDK.Network; using Ananta.SDK.Rpc; using Ananta.Server.ClientData.Client4229938; using Ananta.Server.Configuration; using Ananta.Server.Protocol.Client4229938; using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game; namespace Ananta.Server.Handlers.Game; /// /// 车载电台的服务端那一半 —— 2026-09-27 落地(kpect 捐赠端的候选 2)。 /// /// ## 先说清楚:歌曲解锁早就做完了 /// 59 首歌在登录帧里就全部塞进了 PlayerRadioSongsData.SongInfoDict /// (RuntimePayloadFactory),而 RadioPlayerManager.IsSongUnlocked 查的就是这张表。 /// 所以「车上有几首歌、能不能播」不归这条 RPC 管 —— 别再往这里找原因。 /// /// ## 这条 RPC 真正管什么:歌与歌之间的「串词」 /// AskGetVehicleRadioContent(67725622) 是 INVOKE,返回 UX.Game.RadioRandomContent。 /// 客户端 RadioPlayerManager.GetRadioVoiceData 拿到后存进 radioInfo.randomContent, /// 等歌播到 cue 点(OnRadioMusicMark)时调 GetVoicesByContent 把 /// ContentType / ContentId / StartContentId / EndContentId 换成三条 SoundId 播出去 —— /// 也就是 DJ 开场口播 → 广告/新闻/突发新闻 → DJ 收尾口播。 /// /// 移植前回的是 DefaultReturnCatalog 的**全零** typed-default(15 字节 /// FF 00 …,wire 合法但四个 id 全是 0)⇒ 客户端四条表一条都查不到, /// 日志里是四行「GetVoicesByContent … 配表找不到」⇒ **只有歌、没有串词**。 /// /// ## 内容从哪来 /// RadioContentCatalog4229938(47 行,投影自 ConfigDump_v3 的 /// RadioContentDJs/Ads/News/SpecialNews 四条表)。ContentType 取值是 /// UX.Game.RadioContentType:1=Ad 2=News 3=Chat(DJ) 4=Comment(DJ) 5=SpecialNews, /// 3/4 在客户端查同一张 RadioContentDJs。 /// /// ## 回滚 /// gameplay.vehicleRadio.enabled=false ⇒ 回到全零 typed-default。 /// internal sealed partial class GameRouter { /// 会话键前缀:每类串词上次下发的时间戳(冷却用)。 private const string RadioContentLastTickPrefix = "radio-content-last-"; /// 会话键:AskSwitchVehicleRadio 报上来的当前电台序号(只用于日志/对照)。 private const string RadioCurrentStationKey = "radio-current-station"; [Handler(MethodId.AskGetVehicleRadioContent)] private async Task AskGetVehicleRadioContent(Connection conn, UxRpcMessage msg) { if (!msg.TryGetArgs(out var args) || args is null) { conn.Log.Warn($"[VEHICLE-RADIO] undecodable bytes={msg.Body.Length} hex={Convert.ToHexString(msg.Body, 0, Math.Min(msg.Body.Length, 32))}"); await conn.ReturnEmptyOkAsync(msg); return; } var cfg = PrivateServerConfigStore.Current.Gameplay.VehicleRadio; if (!cfg.Enabled) { // 关着:走原来的 typed-default(wire 合法的全零体),不引入任何新行为。 if (DefaultReturnCatalog4229938.TryGet(msg.MethodId, out var fallbackBody, out var shape)) { conn.Log.Info($"[VEHICLE-RADIO] radio={args.radioId} song={args.songIdx} -> typed-default {shape} (enabled=false)"); await msg.Context.ReturnAsync(fallbackBody); return; } await conn.ReturnEmptyOkAsync(msg); return; } var content = PickRadioContent(conn.Session, cfg); await conn.ReturnAsync(msg, content); conn.Log.Info($"[VEHICLE-RADIO] radio={args.radioId} song={args.songIdx} " + $"-> type={content.ContentType} content={content.ContentId} " + $"start={content.StartContentId} end={content.EndContentId} mark={content.MarkType}"); } /// /// AskSwitchVehicleRadio (67959772):客户端切台的**通知**。 /// 切台在客户端本地已经完成了才发这一帧,服务端没有任何权威状态要维护 —— /// 这里只记录序号(配合 [VEHICLE-RADIO] 日志做对照),并回一个空 OK。 /// [Handler(MethodId.AskSwitchVehicleRadio)] private async Task AskSwitchVehicleRadio(Connection conn, UxRpcMessage msg) { if (msg.TryGetArgs(out var args) && args is not null) { conn.Session.Items[RadioCurrentStationKey] = args.radioId; conn.Log.Info($"[VEHICLE-RADIO] switch station={args.radioId}"); } else { conn.Log.Warn($"[VEHICLE-RADIO] switch undecodable bytes={msg.Body.Length} hex={Convert.ToHexString(msg.Body, 0, Math.Min(msg.Body.Length, 32))}"); } await conn.ReturnEmptyOkAsync(msg); } /// /// 按权重抽一条串词;DJ 类(3/4)额外配一对开场/收尾口播 id, /// 突发新闻(5)用配置行自带的 DJComeId / DJBackId。 /// private static GameMethods.RadioRandomContent4229938 PickRadioContent( TcpSession session, VehicleRadioSettings cfg) { var all = RadioContentCatalog4229938.Entries; if (all.Count == 0) return new GameMethods.RadioRandomContent4229938(); var allowed = cfg.ContentTypes is { Length: > 0 } ? all.Where(e => cfg.ContentTypes.Contains(e.ContentType)).ToList() : [.. all]; // 冷却:官方静态表 RadioContentConfig 是 600 秒(DJChatCd / NewsCd / AdCd 都是 600)。 if (cfg.ContentCooldownSeconds > 0) { var now = Environment.TickCount64; var fresh = allowed.Where(e => { var last = session.Items.TryGetValue(RadioContentLastTickPrefix + e.ContentType, out var raw) && raw is long ticks ? ticks : long.MinValue; return now - last >= cfg.ContentCooldownSeconds * 1000L; }).ToList(); // 全在冷却里就退回 DJ 口播(最不容易听腻的那一类),再不行就整表随便挑。 if (fresh.Count > 0) allowed = fresh; else if (allowed.Any(e => e.ContentType is 3 or 4)) allowed = [.. allowed.Where(e => e.ContentType is 3 or 4)]; } if (allowed.Count == 0) return new GameMethods.RadioRandomContent4229938(); var picked = WeightedPick(allowed, RadioContentCatalog4229938.EffectiveWeight); session.Items[RadioContentLastTickPrefix + picked.ContentType] = Environment.TickCount64; uint start = 0, end = 0; if (picked.ContentType is 3 or 4) { // DJ 口播:开场/收尾各挑一条**别的** DJ 行(同一条播三遍太明显)。 var djs = RadioContentCatalog4229938.ByType(3); if (djs.Count >= 3) { var others = djs.Where(d => d.Id != picked.Id).ToList(); start = others[Random.Shared.Next(others.Count)].Id; var rest = others.Where(d => d.Id != start).ToList(); end = rest.Count > 0 ? rest[Random.Shared.Next(rest.Count)].Id : start; } else if (djs.Count == 2) { var other = djs.FirstOrDefault(d => d.Id != picked.Id); start = other?.Id ?? 0; end = start; } } else if (picked.ContentType == 5) { // 突发新闻行自带 DJComeId / DJBackId —— 投影时已经写进 Start/EndContentId。 start = picked.StartContentId; end = picked.EndContentId; } return new GameMethods.RadioRandomContent4229938 { ContentType = (byte)Math.Min(picked.ContentType, byte.MaxValue), ContentId = picked.Id, StartContentId = start, EndContentId = end, MarkType = (byte)Math.Min(cfg.MarkType, (uint)byte.MaxValue), }; } private static T WeightedPick(List items, Func weightOf) { var total = items.Sum(weightOf); if (total <= 0) return items[Random.Shared.Next(items.Count)]; var roll = Random.Shared.Next(total); foreach (var item in items) { roll -= Math.Max(0, weightOf(item)); if (roll < 0) return item; } return items[^1]; } }