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