This repository has been archived on 2026-10-31. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files

186 lines
8.5 KiB
C#
Raw Permalink Normal View History

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;
/// <summary>
/// 车载电台的<b>服务端那一半</b> —— 2026-09-27 落地(kpect 捐赠端的候选 2)。
///
/// ## 先说清楚:歌曲解锁早就做完了
/// 59 首歌在登录帧里就全部塞进了 <c>PlayerRadioSongsData.SongInfoDict</c>
/// (<c>RuntimePayloadFactory</c>),而 <c>RadioPlayerManager.IsSongUnlocked</c> 查的就是这张表。
/// 所以「车上有几首歌、能不能播」<b>不归这条 RPC 管</b> —— 别再往这里找原因。
///
/// ## 这条 RPC 真正管什么:歌与歌之间的「串词」
/// <c>AskGetVehicleRadioContent</c>(67725622) 是 INVOKE,返回 <c>UX.Game.RadioRandomContent</c>。
/// 客户端 <c>RadioPlayerManager.GetRadioVoiceData</c> 拿到后存进 <c>radioInfo.randomContent</c>,
/// 等歌播到 cue 点(<c>OnRadioMusicMark</c>)时调 <c>GetVoicesByContent</c> 把
/// ContentType / ContentId / StartContentId / EndContentId 换成三条 SoundId 播出去 ——
/// 也就是 DJ 开场口播 → 广告/新闻/突发新闻 → DJ 收尾口播。
///
/// 移植前回的是 <c>DefaultReturnCatalog</c> 的**全零** typed-default(15 字节
/// <c>FF 00 …</c>,wire 合法但四个 id 全是 0)⇒ 客户端四条表一条都查不到,
/// 日志里是四行「GetVoicesByContent … 配表找不到」⇒ **只有歌、没有串词**。
///
/// ## 内容从哪来
/// <c>RadioContentCatalog4229938</c>(47 行,投影自 ConfigDump_v3 的
/// RadioContentDJs/Ads/News/SpecialNews 四条表)。ContentType 取值是
/// <c>UX.Game.RadioContentType</c>:1=Ad 2=News 3=Chat(DJ) 4=Comment(DJ) 5=SpecialNews,
/// 3/4 在客户端查同一张 <c>RadioContentDJs</c>。
///
/// ## 回滚
/// <c>gameplay.vehicleRadio.enabled=false</c> ⇒ 回到全零 typed-default。
/// </summary>
internal sealed partial class GameRouter
{
/// <summary>会话键前缀:每类串词上次下发的时间戳(冷却用)。</summary>
private const string RadioContentLastTickPrefix = "radio-content-last-";
/// <summary>会话键:<c>AskSwitchVehicleRadio</c> 报上来的当前电台序号(只用于日志/对照)。</summary>
private const string RadioCurrentStationKey = "radio-current-station";
[Handler(MethodId.AskGetVehicleRadioContent)]
private async Task AskGetVehicleRadioContent(Connection conn, UxRpcMessage msg)
{
if (!msg.TryGetArgs<GameMethods.AskGetVehicleRadioContentArgs4229938>(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}");
}
/// <summary>
/// AskSwitchVehicleRadio (67959772):客户端切台的**通知**。
/// 切台在客户端本地已经完成了才发这一帧,服务端没有任何权威状态要维护 ——
/// 这里只记录序号(配合 <c>[VEHICLE-RADIO]</c> 日志做对照),并回一个空 OK。
/// </summary>
[Handler(MethodId.AskSwitchVehicleRadio)]
private async Task AskSwitchVehicleRadio(Connection conn, UxRpcMessage msg)
{
if (msg.TryGetArgs<GameMethods.AskSwitchVehicleRadioArgs4229938>(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);
}
/// <summary>
/// 按权重抽一条串词;DJ 类(3/4)额外配一对开场/收尾口播 id,
/// 突发新闻(5)用配置行自带的 DJComeId / DJBackId。
/// </summary>
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<T>(List<T> items, Func<T, int> 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];
}
}