187 lines
8.5 KiB
C#
187 lines
8.5 KiB
C#
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];
|
||
}
|
||
}
|