Files
Ananta-ps/Ananta.Server/Ananta.Handlers/Game/GameRouter.VehicleRadio.cs
T

187 lines
8.5 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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];
}
}