2026-09-15 18:13:46 +07:00
|
|
|
|
using System.Text.Json;
|
|
|
|
|
|
using Ananta.Server.Configuration;
|
|
|
|
|
|
using Ananta.Server.Protocol.Client4229938;
|
|
|
|
|
|
using Ananta.Server.RpcTypes.Client4229938;
|
|
|
|
|
|
|
|
|
|
|
|
namespace Ananta.Server.ClientData.Client4229938;
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// Reads authored client data directly from the extracted JSON configuration files.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// There is no generated character/switch catalog in the source tree. If you need to understand
|
|
|
|
|
|
/// why a character or animation is available, inspect FightSpiritConfig.json /
|
|
|
|
|
|
/// SwitchSpiritConfig.json in the configured client-data directory.
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
internal static class ClientConfigRepository
|
|
|
|
|
|
{
|
|
|
|
|
|
// This exact 4229938 client/VFS capture fails to resolve the streaming animation hashes for
|
|
|
|
|
|
// these generic switch timelines. Prefer another authored row when available; do not globally
|
|
|
|
|
|
// disable streaming animation for every cutscene.
|
|
|
|
|
|
private static readonly HashSet<string> KnownBrokenLocalStreamingSwitchTimelines4229938 = new(StringComparer.Ordinal)
|
|
|
|
|
|
{
|
|
|
|
|
|
"SwitchChar_common_09",
|
|
|
|
|
|
"SwitchChar_common_14",
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
private static ClientData Data => Cache.Value;
|
|
|
|
|
|
|
|
|
|
|
|
internal static IReadOnlyList<CharacterCatalogEntry> Characters() => Data.Characters;
|
|
|
|
|
|
|
2026-10-29 02:44:24 +07:00
|
|
|
|
// 科斯机甲(移植捐赠端 7k-v0.5 ClientConfigRepository.cs:31-34)。
|
|
|
|
|
|
// FightSpiritConfig 15022030 = "特摄战原型-科斯":IsPublish=false / CanJoin=false / Invisible=true,
|
|
|
|
|
|
// 所以它永远不在 Characters() 里 —— 这是"隐藏可控原型",只能由服务端显式下发。
|
|
|
|
|
|
// 原生客户端 CheckIsKesi 用 cardId 15022030 判断。
|
|
|
|
|
|
//
|
|
|
|
|
|
// ⛑⛑ UnitId **必须**落在 0x300000000000 段(= 52776558133248,GameAdminBridge.SummonedNpcUidBase),
|
|
|
|
|
|
// 不能用 100000099030 那种 100000000000+ 的值 —— 硬规则 165:那段是客户端**预分配的切人身份**,
|
|
|
|
|
|
// `SyncLogicAgentLeave`(68281244) 对它**静默 no-op**。2026-09-26 05:15 实测:返回原角色时
|
|
|
|
|
|
// 服务端确实发了 `reclaimedOldUnit=100000099030`,客户端一句都没执行 ⇒ 机甲 T-pose 残骸留在大世界。
|
|
|
|
|
|
// +800000 是为了避开 NextRetirableNpcUid() 的自增序数(留场/召唤共用,目前才用到个位数)。
|
|
|
|
|
|
internal static readonly CharacterCatalogEntry Kesi =
|
|
|
|
|
|
new(15022030, 0x300000000000UL + 800000UL, "科斯机甲(隐藏原型)", 25901028, 100);
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 角色 id 反查:科斯不在 roster 里,必须单独放行;其余走 <see cref="Characters()"/>。
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
internal static CharacterCatalogEntry? ControlCharacter(uint templateId)
|
|
|
|
|
|
=> templateId == Kesi.TemplateId ? Kesi : Characters().FirstOrDefault(x => x.TemplateId == templateId);
|
|
|
|
|
|
|
2026-09-15 18:13:46 +07:00
|
|
|
|
internal static IReadOnlyList<uint> UnlockSystemIds() => Data.UnlockSystemIds;
|
|
|
|
|
|
|
2026-10-29 02:44:24 +07:00
|
|
|
|
internal static IReadOnlyList<uint> ActionItemIds() => Data.ActionItemIds;
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// Every rank of the career that <paramref name="jobId"/> belongs to, lowest rank first.
|
|
|
|
|
|
/// A career is authored as a PreJob chain (401 -> 402 -> 403) and the client treats the whole
|
|
|
|
|
|
/// chain as one career class, so a character authored on any rank still owns every rank above
|
|
|
|
|
|
/// and below it. Returns just <paramref name="jobId"/> when the id is unknown or Jobless.
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
internal static IReadOnlyList<UrbanJobRow> UrbanJobChain(uint jobId)
|
|
|
|
|
|
{
|
|
|
|
|
|
var rows = Data.UrbanJobs;
|
|
|
|
|
|
if (rows.Length == 0 || jobId == 0)
|
|
|
|
|
|
return Array.Empty<UrbanJobRow>();
|
|
|
|
|
|
|
|
|
|
|
|
var byId = rows.ToDictionary(x => x.Id);
|
|
|
|
|
|
if (!byId.TryGetValue(jobId, out var seed))
|
|
|
|
|
|
return Array.Empty<UrbanJobRow>();
|
|
|
|
|
|
|
|
|
|
|
|
// Walk up to the root of the chain, then down again: the seed may be any rank of it.
|
|
|
|
|
|
var root = seed;
|
|
|
|
|
|
var guard = 0;
|
|
|
|
|
|
while (root.PreJob != 0 && byId.ContainsKey(root.PreJob) && guard++ < rows.Length)
|
|
|
|
|
|
root = byId[root.PreJob];
|
|
|
|
|
|
|
|
|
|
|
|
var chain = new List<UrbanJobRow> { root };
|
|
|
|
|
|
for (var i = 0; i < chain.Count; i++)
|
|
|
|
|
|
foreach (var row in rows)
|
|
|
|
|
|
if (row.PreJob == chain[i].Id && !chain.Contains(row))
|
|
|
|
|
|
chain.Add(row);
|
|
|
|
|
|
|
|
|
|
|
|
return chain.OrderBy(x => x.Level).ThenBy(x => x.Id).ToArray();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// The single authored row for <paramref name="jobId"/>, or <c>null</c> when it is unknown or
|
|
|
|
|
|
/// Jobless. Read it for <see cref="UrbanJobRow.JobClass"/> — that, not the job id, is what the
|
|
|
|
|
|
/// job-level table (<see cref="UrbanJobLevelCatalog4229938"/>) and the phone-app gate are keyed by.
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
internal static UrbanJobRow? UrbanJob(uint jobId)
|
|
|
|
|
|
=> jobId == 0 ? null : Data.UrbanJobs.FirstOrDefault(x => x.Id == jobId);
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// Auxiliary skills authored directly on the character row (<c>FightSpiritConfig.TempSkill</c>).
|
|
|
|
|
|
/// They appear in no weapon's skill list, so the weapon sandbox alone would reject every
|
|
|
|
|
|
/// request the client sends for them. Data-driven on purpose: never hard-code these ids.
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
internal static IReadOnlyList<uint> TempSkills(uint templateId)
|
|
|
|
|
|
=> Data.TempSkillsBySpirit.TryGetValue(templateId, out var ids) ? ids : Array.Empty<uint>();
|
|
|
|
|
|
|
2026-09-15 18:13:46 +07:00
|
|
|
|
internal static IReadOnlyList<uint> InstalledPhoneAppIds() => Data.InstalledPhoneAppIds;
|
|
|
|
|
|
|
|
|
|
|
|
internal static IReadOnlyList<uint> SelectableFashionIds() => Data.SelectableFashionIds;
|
|
|
|
|
|
|
|
|
|
|
|
internal static IReadOnlyList<uint> DefaultFashionIds(uint templateId)
|
|
|
|
|
|
=> Data.DefaultFashionIdsBySpirit.TryGetValue(templateId, out var ids) ? ids : Array.Empty<uint>();
|
|
|
|
|
|
|
|
|
|
|
|
internal static (Vec3 Position, float Facing) EntryPlacement(uint switchShowId)
|
|
|
|
|
|
=> Data.SwitchRows.TryGetValue(switchShowId, out var row)
|
|
|
|
|
|
? (row.Position, row.Facing)
|
|
|
|
|
|
: (Profile.WorldSpawn, Profile.WorldFacing);
|
|
|
|
|
|
|
2026-10-29 02:44:24 +07:00
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 面板「切人演出」用的**按动画分**清单 —— 一行 SwitchSpiritConfig = 一条切人演出。
|
|
|
|
|
|
///
|
|
|
|
|
|
/// ⛑ 捐赠端 GM 面板的「切人动画演出」子页主下拉也是这个粒度(201 条),第二个下拉
|
|
|
|
|
|
/// 才是「切成哪个角色」。我第一版按**角色**做是错的 —— 同一个角色有十几条演出,
|
|
|
|
|
|
/// 按角色只能触发服务端替他挑的那一条(受 <c>DisperseAnchorsByCharacter</c> / 距离 /
|
|
|
|
|
|
/// 权重影响),玩家想看「送人下车」那条根本选不到。
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
internal static IReadOnlyList<SwitchShowRow> SwitchShows()
|
|
|
|
|
|
=> Data.SwitchRows.Values
|
|
|
|
|
|
.OrderBy(x => x.Id)
|
|
|
|
|
|
.Select(ToSwitchShowRow)
|
|
|
|
|
|
.ToArray();
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>取单条切人演出;不存在返回 null。</summary>
|
|
|
|
|
|
internal static SwitchShowRow? SwitchShow(uint switchShowId)
|
|
|
|
|
|
=> Data.SwitchRows.TryGetValue(switchShowId, out var row) ? ToSwitchShowRow(row) : null;
|
|
|
|
|
|
|
|
|
|
|
|
private static SwitchShowRow ToSwitchShowRow(SwitchRow r)
|
|
|
|
|
|
=> new(
|
|
|
|
|
|
r.Id,
|
|
|
|
|
|
r.Timeline,
|
|
|
|
|
|
r.Description,
|
|
|
|
|
|
r.TimelineDescription,
|
|
|
|
|
|
r.SwitchType,
|
|
|
|
|
|
r.RaidId,
|
|
|
|
|
|
r.Weight,
|
|
|
|
|
|
r.Invalid,
|
|
|
|
|
|
r.LoginInvalid,
|
|
|
|
|
|
r.Position,
|
|
|
|
|
|
r.Facing,
|
|
|
|
|
|
r.UseZeroPosAndAngle,
|
|
|
|
|
|
r.FightSpiritIds.OrderBy(x => x).ToArray(),
|
|
|
|
|
|
r.FirstSwitchSpiritIds.OrderBy(x => x).ToArray(),
|
|
|
|
|
|
r.ForbidFightSpiritIds.OrderBy(x => x).ToArray(),
|
|
|
|
|
|
r.AgentIds.OrderBy(x => x).ToArray(),
|
|
|
|
|
|
// 是不是「通用行」:timeline 以 CommonTimelinePrefix 开头。
|
|
|
|
|
|
// ⛑ 语义照 ClientConfigRepository.IdsFor —— 只有通用行才允许 FightSpiritId 为空
|
|
|
|
|
|
// 表示「人人可用」;非通用行若 FightSpiritId/FirstSwitchSpiritId 都空 ⇒ 谁都演不了。
|
|
|
|
|
|
r.Timeline.StartsWith(
|
|
|
|
|
|
PrivateServerConfigStore.Current.Gameplay.SwitchAnimations.CommonTimelinePrefix,
|
|
|
|
|
|
StringComparison.Ordinal));
|
|
|
|
|
|
|
2026-09-15 18:13:46 +07:00
|
|
|
|
internal static IReadOnlyList<uint> SwitchAnimations(uint templateId)
|
|
|
|
|
|
=> Data.SwitchIdsBySpirit.TryGetValue(templateId, out var ids) ? ids : Data.CommonSwitchIds;
|
|
|
|
|
|
|
|
|
|
|
|
internal static IReadOnlyList<uint> EntryAnimations(uint templateId)
|
|
|
|
|
|
=> Data.EntryIdsBySpirit.TryGetValue(templateId, out var ids) ? ids : Data.CommonEntryIds;
|
|
|
|
|
|
|
|
|
|
|
|
internal static uint RandomEntryAnimation(uint templateId)
|
|
|
|
|
|
{
|
|
|
|
|
|
var ids = EntryAnimations(templateId);
|
|
|
|
|
|
if (ids.Count == 0)
|
|
|
|
|
|
throw new InvalidOperationException($"No world-entry animations for spirit {templateId} in SwitchSpiritConfig.json.");
|
|
|
|
|
|
|
|
|
|
|
|
// Entry presentation must agree with the configured world spawn. Prefer the nearest authored
|
|
|
|
|
|
// row that does not require external agents and whose streaming animation is present in this
|
|
|
|
|
|
// 4229938 client capture. This replaces the old random cross-map entry selection.
|
|
|
|
|
|
var candidates = ids
|
|
|
|
|
|
.Where(id => Data.SwitchRows.ContainsKey(id))
|
|
|
|
|
|
.Select(id => Data.SwitchRows[id])
|
|
|
|
|
|
.Where(row => row.AgentIds.Count == 0)
|
|
|
|
|
|
.Where(row => !KnownBrokenLocalStreamingSwitchTimelines4229938.Contains(row.Timeline))
|
|
|
|
|
|
.Select(row =>
|
|
|
|
|
|
{
|
|
|
|
|
|
var dx = (double)row.Position.X - Profile.WorldSpawn.X;
|
|
|
|
|
|
var dz = (double)row.Position.Z - Profile.WorldSpawn.Z;
|
|
|
|
|
|
return new { row.Id, Distance = Math.Sqrt(dx * dx + dz * dz), row.Weight };
|
|
|
|
|
|
})
|
|
|
|
|
|
.OrderBy(x => x.Distance)
|
|
|
|
|
|
.ThenByDescending(x => x.Weight)
|
|
|
|
|
|
.ThenBy(x => x.Id)
|
|
|
|
|
|
.ToArray();
|
|
|
|
|
|
|
|
|
|
|
|
return candidates.Length > 0 ? candidates[0].Id : ids[0];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
internal static SwitchAnimationChoice RandomSwitchAnimation(uint templateId, uint previousSwitchShowId, Vec3 position)
|
|
|
|
|
|
{
|
|
|
|
|
|
var ids = SwitchAnimations(templateId);
|
|
|
|
|
|
if (ids.Count == 0)
|
|
|
|
|
|
throw new InvalidOperationException($"No SwitchSpiritConfig animations for spirit {templateId}.");
|
|
|
|
|
|
|
|
|
|
|
|
// Generic server switching cannot satisfy authored companion-agent rows. Those rows carry
|
|
|
|
|
|
// AgentId entries that stock SwitchTeleport expects the server to spawn before landing.
|
|
|
|
|
|
var candidateRows = ids
|
|
|
|
|
|
.Where(id => Data.SwitchRows.ContainsKey(id))
|
|
|
|
|
|
.Select(id => Data.SwitchRows[id])
|
|
|
|
|
|
.Where(row => row.AgentIds.Count == 0)
|
|
|
|
|
|
.ToArray();
|
|
|
|
|
|
|
|
|
|
|
|
var preferredRows = candidateRows
|
|
|
|
|
|
.Where(row => !KnownBrokenLocalStreamingSwitchTimelines4229938.Contains(row.Timeline))
|
|
|
|
|
|
.ToArray();
|
|
|
|
|
|
if (preferredRows.Length == 0)
|
|
|
|
|
|
preferredRows = candidateRows;
|
|
|
|
|
|
|
|
|
|
|
|
var usableRows = preferredRows
|
|
|
|
|
|
.Select(row =>
|
|
|
|
|
|
{
|
|
|
|
|
|
var dx = (double)row.Position.X - position.X;
|
|
|
|
|
|
var dz = (double)row.Position.Z - position.Z;
|
|
|
|
|
|
return new SwitchAnimationCandidate(
|
|
|
|
|
|
row.Id,
|
|
|
|
|
|
row.Timeline,
|
|
|
|
|
|
row.Weight > 0f && float.IsFinite(row.Weight) ? row.Weight : 1f,
|
2026-10-29 02:44:24 +07:00
|
|
|
|
Math.Sqrt(dx * dx + dz * dz),
|
|
|
|
|
|
row.Position,
|
|
|
|
|
|
row.Facing,
|
|
|
|
|
|
row.UseZeroPosAndAngle);
|
2026-09-15 18:13:46 +07:00
|
|
|
|
})
|
|
|
|
|
|
.OrderBy(x => x.AnchorDistance)
|
|
|
|
|
|
.ThenByDescending(x => x.Weight)
|
|
|
|
|
|
.ThenBy(x => x.Id)
|
|
|
|
|
|
.ToArray();
|
|
|
|
|
|
if (usableRows.Length == 0)
|
|
|
|
|
|
throw new InvalidOperationException($"No usable SwitchSpiritConfig rows for spirit {templateId}.");
|
|
|
|
|
|
|
|
|
|
|
|
string? previousTimeline = null;
|
|
|
|
|
|
if (previousSwitchShowId != 0 && Data.SwitchRows.TryGetValue(previousSwitchShowId, out var previousRow))
|
|
|
|
|
|
previousTimeline = previousRow.Timeline;
|
|
|
|
|
|
|
|
|
|
|
|
// SwitchSpiritConfig rows are spatially authored. Randomly choosing a timeline first can bind a
|
|
|
|
|
|
// current position to an animation whose authored anchor is kilometers away. Pick the nearest
|
|
|
|
|
|
// compatible row; only avoid an immediate timeline repeat when an almost-equally-near alternative
|
|
|
|
|
|
// exists, so anti-repeat never wins over spatial validity.
|
|
|
|
|
|
var selected = usableRows[0];
|
|
|
|
|
|
if (PrivateServerConfigStore.Current.Gameplay.SwitchAnimations.AvoidImmediateRepeat &&
|
|
|
|
|
|
previousTimeline is not null)
|
|
|
|
|
|
{
|
|
|
|
|
|
var maxAlternativeDistance = selected.AnchorDistance + 150d;
|
|
|
|
|
|
var alternative = usableRows.FirstOrDefault(x =>
|
|
|
|
|
|
x.AnchorDistance <= maxAlternativeDistance &&
|
|
|
|
|
|
!string.Equals(x.Timeline, previousTimeline, StringComparison.Ordinal));
|
|
|
|
|
|
if (alternative is not null)
|
|
|
|
|
|
selected = alternative;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return new SwitchAnimationChoice(
|
|
|
|
|
|
selected.Id,
|
|
|
|
|
|
selected.Timeline,
|
|
|
|
|
|
selected.Weight,
|
|
|
|
|
|
(float)selected.AnchorDistance,
|
|
|
|
|
|
usableRows.Select(x => x.Timeline).Distinct(StringComparer.Ordinal).Count(),
|
|
|
|
|
|
usableRows.Length,
|
2026-10-29 02:44:24 +07:00
|
|
|
|
previousTimeline,
|
|
|
|
|
|
selected.Position,
|
|
|
|
|
|
selected.Facing,
|
|
|
|
|
|
selected.UseZeroPosAndAngle);
|
2026-09-15 18:13:46 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-29 02:44:24 +07:00
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 按角色分锚点:每个角色固定落到「属于自己的那一行」SwitchSpiritConfig,
|
|
|
|
|
|
/// 而不是「离玩家最近的那行」。用户 2026-09-24 17:5x 实测:最近锚点模式会让所有角色
|
|
|
|
|
|
/// 都落在咖啡馆/电影院门口那一两个点上,挤成一团;按 templateId 散列选行后,
|
|
|
|
|
|
/// 每个角色有自己固定的落点,全城分散。
|
|
|
|
|
|
/// 选择是确定性的(同一角色永远同一行),不做防连续重复 —— 一个角色的落点恒定。
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
internal static SwitchAnimationChoice SwitchAnimationForTemplate(uint templateId, uint previousSwitchShowId)
|
|
|
|
|
|
{
|
|
|
|
|
|
var ids = SwitchAnimations(templateId);
|
|
|
|
|
|
if (ids.Count == 0)
|
|
|
|
|
|
throw new InvalidOperationException($"No SwitchSpiritConfig animations for spirit {templateId}.");
|
|
|
|
|
|
|
|
|
|
|
|
var candidateRows = ids
|
|
|
|
|
|
.Where(id => Data.SwitchRows.ContainsKey(id))
|
|
|
|
|
|
.Select(id => Data.SwitchRows[id])
|
|
|
|
|
|
.Where(row => row.AgentIds.Count == 0)
|
|
|
|
|
|
.ToArray();
|
|
|
|
|
|
|
|
|
|
|
|
var preferredRows = candidateRows
|
|
|
|
|
|
.Where(row => !KnownBrokenLocalStreamingSwitchTimelines4229938.Contains(row.Timeline))
|
|
|
|
|
|
.ToArray();
|
|
|
|
|
|
if (preferredRows.Length == 0)
|
|
|
|
|
|
preferredRows = candidateRows;
|
|
|
|
|
|
if (preferredRows.Length == 0)
|
|
|
|
|
|
throw new InvalidOperationException($"No usable SwitchSpiritConfig rows for spirit {templateId}.");
|
|
|
|
|
|
|
|
|
|
|
|
// 按行 Id 排序后用 Knuth 乘散列取模 —— 相邻 templateId 也会散到不同的行。
|
|
|
|
|
|
var usableRows = preferredRows.OrderBy(x => x.Id).ToArray();
|
|
|
|
|
|
var index = (int)((templateId * 2654435761u) % (uint)usableRows.Length);
|
|
|
|
|
|
var selected = usableRows[index];
|
|
|
|
|
|
|
|
|
|
|
|
string? previousTimeline = null;
|
|
|
|
|
|
if (previousSwitchShowId != 0 && Data.SwitchRows.TryGetValue(previousSwitchShowId, out var previousRow))
|
|
|
|
|
|
previousTimeline = previousRow.Timeline;
|
|
|
|
|
|
|
|
|
|
|
|
return new SwitchAnimationChoice(
|
|
|
|
|
|
selected.Id,
|
|
|
|
|
|
selected.Timeline,
|
|
|
|
|
|
selected.Weight > 0f && float.IsFinite(selected.Weight) ? selected.Weight : 1f,
|
|
|
|
|
|
0f,
|
|
|
|
|
|
usableRows.Select(x => x.Timeline).Distinct(StringComparer.Ordinal).Count(),
|
|
|
|
|
|
usableRows.Length,
|
|
|
|
|
|
previousTimeline,
|
|
|
|
|
|
selected.Position,
|
|
|
|
|
|
selected.Facing,
|
|
|
|
|
|
selected.UseZeroPosAndAngle);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 切人演出行的落点。官方语义:<c>Position</c> 是被切过去的角色真正站立的位置(进场演出
|
|
|
|
|
|
/// <see cref="EntryPlacement"/> 用的就是同一对值),客户端的 SwitchTeleport 镜头飞的就是这个点。
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
internal static (Vec3 Position, float Facing, bool UseZeroPosAndAngle) SwitchPlacement(uint switchShowId)
|
|
|
|
|
|
=> Data.SwitchRows.TryGetValue(switchShowId, out var row)
|
|
|
|
|
|
? (row.Position, row.Facing, row.UseZeroPosAndAngle)
|
|
|
|
|
|
: (Profile.WorldSpawn, Profile.WorldFacing, false);
|
|
|
|
|
|
|
2026-09-15 18:13:46 +07:00
|
|
|
|
private static readonly Lazy<ClientData> Cache = new(Load);
|
|
|
|
|
|
|
|
|
|
|
|
private static ClientData Load()
|
|
|
|
|
|
{
|
|
|
|
|
|
var cfg = PrivateServerConfigStore.Current;
|
|
|
|
|
|
var root = PrivateServerConfigStore.ResolveProjectPath(cfg.Paths.ClientConfigs);
|
|
|
|
|
|
|
|
|
|
|
|
var fightSpirits = ReadRecords(Path.Combine(root, "FightSpiritConfig.json"));
|
|
|
|
|
|
var switches = ReadRecords(Path.Combine(root, "SwitchSpiritConfig.json"));
|
|
|
|
|
|
var unlockSystems = ReadRecords(Path.Combine(root, "SystemUnlockConfig.json"));
|
|
|
|
|
|
var fashions = ReadRecords(Path.Combine(root, "FashionConfig.json"));
|
|
|
|
|
|
var mobileApps = ReadRecords(Path.Combine(root, "MobileMenuSGuiConfig.json"));
|
2026-10-29 02:44:24 +07:00
|
|
|
|
var urbanJobs = ReadRecords(Path.Combine(root, "UrbanJobConfig.json"));
|
2026-09-15 18:13:46 +07:00
|
|
|
|
var roster = cfg.Content.Roster;
|
|
|
|
|
|
var excluded = roster.ExcludedTemplateIds.ToHashSet();
|
2026-10-29 02:44:24 +07:00
|
|
|
|
// Force-included rows (see RosterSettings.AdditionalTemplateIds): these escape both the
|
|
|
|
|
|
// explicit exclusion list and the placeholder-icon filter, so an unpublished row can join
|
|
|
|
|
|
// the roster without loosening those two rules for everything else.
|
|
|
|
|
|
var forcedInclusions = roster.AdditionalTemplateIds.ToHashSet();
|
|
|
|
|
|
// Panel display names for rows whose client-side copy was rewritten (rowOverrides). The
|
|
|
|
|
|
// server projection keeps the stock AgentId, so without this the panel would print the
|
|
|
|
|
|
// placeholder's own name ("OC FlightSpirit占位_幼女") instead of the character that row
|
|
|
|
|
|
// now carries in the client.
|
|
|
|
|
|
var labels = roster.RowOverrides
|
|
|
|
|
|
.Where(x => x.SpiritId != 0 && !string.IsNullOrWhiteSpace(x.Label))
|
|
|
|
|
|
.ToDictionary(x => x.SpiritId, x => x.Label.Trim());
|
2026-09-15 18:13:46 +07:00
|
|
|
|
|
|
|
|
|
|
var characters = fightSpirits
|
|
|
|
|
|
.Select(ParseCharacterSource)
|
|
|
|
|
|
.Where(x => x.HasValue)
|
|
|
|
|
|
.Select(x => x.GetValueOrDefault())
|
2026-10-29 02:44:24 +07:00
|
|
|
|
// A force-included row escapes the id window too, not just the two filters: the
|
|
|
|
|
|
// placeholder rows we clone characters onto live above TemplateIdMaxExclusive, and
|
|
|
|
|
|
// raising the window instead would drag ~68 dev rows (test robots, 武学 folders,
|
|
|
|
|
|
// PVP dummies) into the roster and onto the Z wheel.
|
|
|
|
|
|
.Where(x => forcedInclusions.Contains(x.TemplateId) ||
|
|
|
|
|
|
(x.TemplateId >= roster.TemplateIdMin &&
|
|
|
|
|
|
x.TemplateId < roster.TemplateIdMaxExclusive &&
|
|
|
|
|
|
!excluded.Contains(x.TemplateId) &&
|
|
|
|
|
|
// requireIconId=false 时放行"头像未制作"的行(SHeadIconID==0 或占位图 28000032)。
|
|
|
|
|
|
// 这些行在轮盘上会显示占位头像,但角色本身是可用的 —— 默认 true 保持旧行为。
|
|
|
|
|
|
(!roster.RequireIconId ||
|
|
|
|
|
|
(x.IconId > 0 && x.IconId != roster.GenericPlaceholderIconId))))
|
2026-09-15 18:13:46 +07:00
|
|
|
|
.OrderBy(x => x.TemplateId)
|
|
|
|
|
|
.Select((x, index) => new CharacterCatalogEntry(
|
|
|
|
|
|
x.TemplateId,
|
|
|
|
|
|
roster.UnitIdBase + (ulong)index,
|
2026-10-29 02:44:24 +07:00
|
|
|
|
labels.TryGetValue(x.TemplateId, out var label) ? label : x.Name,
|
|
|
|
|
|
x.IconId,
|
|
|
|
|
|
x.DefaultUrbanJob))
|
2026-09-15 18:13:46 +07:00
|
|
|
|
.ToArray();
|
|
|
|
|
|
|
|
|
|
|
|
if (characters.Length == 0)
|
|
|
|
|
|
throw new InvalidDataException("FightSpiritConfig.json produced an empty playable roster.");
|
|
|
|
|
|
if (characters[0].TemplateId != cfg.Player.InitialSpiritTemplateId)
|
|
|
|
|
|
throw new InvalidDataException(
|
|
|
|
|
|
$"Playable roster begins with {characters[0].TemplateId}, expected initial spirit {cfg.Player.InitialSpiritTemplateId}.");
|
|
|
|
|
|
|
2026-10-29 02:44:24 +07:00
|
|
|
|
// Authored auxiliary skills carried straight on the character row (FightSpiritConfig.TempSkill).
|
|
|
|
|
|
// They belong to no weapon, yet the client still sends them through C2S AskUseSkill — that is
|
|
|
|
|
|
// how Seymour's spider/drone summon buttons reach us. Read from the raw rows rather than from
|
|
|
|
|
|
// `characters` so a row outside the roster window still resolves.
|
|
|
|
|
|
var tempSkillsBySpirit = fightSpirits
|
|
|
|
|
|
.Select(x => (Id: TryUInt32(x, "Id", out var id) ? id : 0u, Skills: ReadUInt32Array(x, "TempSkill")))
|
|
|
|
|
|
.Where(x => x.Id != 0 && x.Skills.Count > 0)
|
|
|
|
|
|
.ToDictionary(x => x.Id, x => x.Skills.OrderBy(skillId => skillId).ToArray());
|
|
|
|
|
|
|
2026-09-15 18:13:46 +07:00
|
|
|
|
var switchRows = switches
|
|
|
|
|
|
.Select(ParseSwitchRow)
|
|
|
|
|
|
.Where(x => x is not null)
|
|
|
|
|
|
.Select(x => x!)
|
|
|
|
|
|
.Where(x => x.SwitchType == cfg.Gameplay.SwitchAnimations.SwitchType)
|
|
|
|
|
|
.Where(x => x.RaidId == cfg.World.RaidId)
|
|
|
|
|
|
.Where(x => !x.Invalid && x.Weight > 0f && !string.IsNullOrWhiteSpace(x.Timeline))
|
|
|
|
|
|
.ToDictionary(x => x.Id);
|
|
|
|
|
|
|
|
|
|
|
|
if (switchRows.Count == 0)
|
|
|
|
|
|
throw new InvalidDataException("SwitchSpiritConfig.json has no usable rows for the configured raid/switch type.");
|
|
|
|
|
|
|
|
|
|
|
|
uint[] IdsFor(uint templateId, bool entryOnly)
|
|
|
|
|
|
{
|
|
|
|
|
|
var prefix = cfg.Gameplay.SwitchAnimations.CommonTimelinePrefix;
|
|
|
|
|
|
var rows = switchRows.Values.Where(x => !entryOnly || !x.LoginInvalid);
|
|
|
|
|
|
var common = rows.Where(x =>
|
|
|
|
|
|
x.Timeline.StartsWith(prefix, StringComparison.Ordinal) &&
|
|
|
|
|
|
!x.ForbidFightSpiritIds.Contains(templateId) &&
|
|
|
|
|
|
(x.FightSpiritIds.Count == 0 && x.FirstSwitchSpiritIds.Count == 0 ||
|
|
|
|
|
|
x.FightSpiritIds.Contains(templateId) || x.FirstSwitchSpiritIds.Contains(templateId)));
|
|
|
|
|
|
var unique = rows.Where(x =>
|
|
|
|
|
|
!x.Timeline.StartsWith(prefix, StringComparison.Ordinal) &&
|
|
|
|
|
|
(x.FightSpiritIds.Contains(templateId) || x.FirstSwitchSpiritIds.Contains(templateId)));
|
|
|
|
|
|
return common.Concat(unique).Select(x => x.Id).Distinct().ToArray();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var switchIdsBySpirit = characters.ToDictionary(x => x.TemplateId, x => IdsFor(x.TemplateId, false));
|
|
|
|
|
|
var entryIdsBySpirit = characters.ToDictionary(x => x.TemplateId, x => IdsFor(x.TemplateId, true));
|
|
|
|
|
|
foreach (var character in characters)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (switchIdsBySpirit[character.TemplateId].Length == 0)
|
|
|
|
|
|
throw new InvalidDataException($"No switch animations for character {character.TemplateId} ({character.Name}).");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var commonSwitchIds = switchRows.Values
|
|
|
|
|
|
.Where(x => x.Timeline.StartsWith(cfg.Gameplay.SwitchAnimations.CommonTimelinePrefix, StringComparison.Ordinal))
|
|
|
|
|
|
.Select(x => x.Id).ToArray();
|
|
|
|
|
|
var commonEntryIds = switchRows.Values
|
|
|
|
|
|
.Where(x => !x.LoginInvalid && x.Timeline.StartsWith(cfg.Gameplay.SwitchAnimations.CommonTimelinePrefix, StringComparison.Ordinal))
|
|
|
|
|
|
.Select(x => x.Id).ToArray();
|
|
|
|
|
|
|
|
|
|
|
|
var excludedUnlocks = cfg.Content.ExcludedUnlockSystems.ToHashSet();
|
|
|
|
|
|
var unlockSystemIds = unlockSystems
|
|
|
|
|
|
.Select(x => TryUInt32(x, "Id", out var id) ? id : 0u)
|
|
|
|
|
|
.Where(id => id != 0 && !excludedUnlocks.Contains(id))
|
|
|
|
|
|
.Distinct()
|
|
|
|
|
|
.OrderBy(id => id)
|
|
|
|
|
|
.ToArray();
|
|
|
|
|
|
|
2026-10-29 02:44:24 +07:00
|
|
|
|
// UrbanJobConfig: the authored career ladder. Each row is one rank of one career class —
|
|
|
|
|
|
// 401 (Professional Hacker, Lv1) -> 402 (Advanced, Lv2) -> 403 (Ace, Lv3) via PreJob.
|
|
|
|
|
|
// The client gates career content on the job *class* of any job present in AvailableJobs
|
|
|
|
|
|
// (MainPhoneUtils.CheckJobCanShow reads JobClassIdList, e.g. EonBug's [11300004]), so a
|
|
|
|
|
|
// single job id is enough to open the module — but job panels read the rank from the
|
|
|
|
|
|
// highest registered job, so publish the whole authored chain up to its top rank.
|
|
|
|
|
|
var urbanJobRows = urbanJobs
|
|
|
|
|
|
.Select(ParseUrbanJobSource)
|
|
|
|
|
|
.Where(x => x is not null)
|
|
|
|
|
|
.Select(x => x!.Value)
|
|
|
|
|
|
.ToArray();
|
|
|
|
|
|
|
2026-09-15 18:13:46 +07:00
|
|
|
|
var installedPhoneAppIds = mobileApps
|
|
|
|
|
|
.Where(x => cfg.Content.IncludePhoneBottomApps || !TryBool(x, "IsBottom"))
|
|
|
|
|
|
.Select(x => TryUInt32(x, "Id", out var id) ? id : 0u)
|
|
|
|
|
|
.Where(id => id != 0)
|
|
|
|
|
|
.Distinct()
|
|
|
|
|
|
.OrderBy(id => id)
|
|
|
|
|
|
.ToArray();
|
|
|
|
|
|
|
|
|
|
|
|
var selectableFashionIds = fashions
|
2026-10-29 02:44:24 +07:00
|
|
|
|
.Where(x => TryBool(x, "IsShow") || TryBool(x, "IsGet") || TryBool(x, "IsDefault"))
|
2026-09-15 18:13:46 +07:00
|
|
|
|
.Select(x => TryUInt32(x, "Id", out var id) ? id : 0u)
|
|
|
|
|
|
.Where(id => id != 0)
|
|
|
|
|
|
.Distinct()
|
|
|
|
|
|
.OrderBy(id => id)
|
|
|
|
|
|
.ToArray();
|
|
|
|
|
|
|
|
|
|
|
|
var defaultFashionIdsBySpirit = fashions
|
|
|
|
|
|
.Where(x => TryBool(x, "IsDefault"))
|
|
|
|
|
|
.Select(x => new
|
|
|
|
|
|
{
|
|
|
|
|
|
FashionId = TryUInt32(x, "Id", out var fashionId) ? fashionId : 0u,
|
|
|
|
|
|
SpiritId = TryUInt32(x, "BelongSpiritId", out var spiritId) ? spiritId : 0u,
|
|
|
|
|
|
})
|
|
|
|
|
|
.Where(x => x.FashionId != 0 && x.SpiritId != 0)
|
|
|
|
|
|
.GroupBy(x => x.SpiritId)
|
|
|
|
|
|
.ToDictionary(g => g.Key, g => g.Select(x => x.FashionId).Distinct().OrderBy(id => id).ToArray());
|
|
|
|
|
|
|
2026-10-29 02:44:24 +07:00
|
|
|
|
// Pose/emote actions (ActionItemConfig): the client keeps every action LOCKED until
|
|
|
|
|
|
// its id appears in PlayerInteractionActionInfo.UnlockActionItemDict (or arrives via
|
|
|
|
|
|
// SyncUnlockInteractionActionItems). Publish the full authored set so the two-person
|
|
|
|
|
|
// ACTION panel (公主抱/牵手/拥抱/打招呼/...) is usable.
|
|
|
|
|
|
var actionItemIds = ReadRecords(Path.Combine(root, "ActionItemConfig.json"))
|
|
|
|
|
|
.Select(x => TryUInt32(x, "Id", out var id) ? id : 0u)
|
|
|
|
|
|
.Where(id => id != 0)
|
|
|
|
|
|
.Distinct()
|
|
|
|
|
|
.OrderBy(id => id)
|
|
|
|
|
|
.ToArray();
|
|
|
|
|
|
|
2026-09-15 18:13:46 +07:00
|
|
|
|
Console.WriteLine(
|
|
|
|
|
|
$"[CLIENT-CONFIG] build={cfg.Client.Version} characters={characters.Length} switchRows={switchRows.Count} " +
|
2026-10-29 02:44:24 +07:00
|
|
|
|
$"actionItems={actionItemIds.Length} tempSkillRows={tempSkillsBySpirit.Count} source={root}");
|
2026-09-15 18:13:46 +07:00
|
|
|
|
|
|
|
|
|
|
return new ClientData(
|
|
|
|
|
|
characters, switchRows, switchIdsBySpirit, entryIdsBySpirit, commonSwitchIds, commonEntryIds,
|
2026-10-29 02:44:24 +07:00
|
|
|
|
unlockSystemIds, installedPhoneAppIds, selectableFashionIds, defaultFashionIdsBySpirit,
|
|
|
|
|
|
actionItemIds, urbanJobRows, tempSkillsBySpirit);
|
2026-09-15 18:13:46 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static JsonElement[] ReadRecords(string path)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!File.Exists(path))
|
|
|
|
|
|
throw new FileNotFoundException($"Required client config is missing: {path}");
|
|
|
|
|
|
using var doc = JsonDocument.Parse(File.ReadAllText(path));
|
|
|
|
|
|
if (!doc.RootElement.TryGetProperty("records", out var records) || records.ValueKind != JsonValueKind.Array)
|
|
|
|
|
|
throw new InvalidDataException($"Client config does not contain a 'records' array: {path}");
|
|
|
|
|
|
return records.EnumerateArray().Select(x => x.Clone()).ToArray();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-29 02:44:24 +07:00
|
|
|
|
private static UrbanJobRow? ParseUrbanJobSource(JsonElement value)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!TryUInt32(value, "Id", out var id)) return null;
|
|
|
|
|
|
TryUInt32(value, "PreJob", out var preJob);
|
|
|
|
|
|
TryUInt32(value, "Level", out var level);
|
|
|
|
|
|
TryUInt32(value, "JobClass", out var jobClass);
|
|
|
|
|
|
return new UrbanJobRow(id, preJob, level == 0 ? 1u : level, jobClass);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-15 18:13:46 +07:00
|
|
|
|
private static CharacterSource? ParseCharacterSource(JsonElement value)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!TryUInt32(value, "Id", out var id)) return null;
|
|
|
|
|
|
var name = TryString(value, "Name") ?? string.Empty;
|
|
|
|
|
|
TryUInt32(value, "SHeadIconID", out var iconId);
|
2026-10-29 02:44:24 +07:00
|
|
|
|
// Authored urban job (UrbanJobConfig id). 0 = row has no job field; 100 = Jobless.
|
|
|
|
|
|
TryUInt32(value, "DefaultUrbanJob", out var defaultUrbanJob);
|
|
|
|
|
|
return new CharacterSource(id, name, iconId, defaultUrbanJob);
|
2026-09-15 18:13:46 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static SwitchRow? ParseSwitchRow(JsonElement value)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!TryUInt32(value, "Id", out var id)) return null;
|
|
|
|
|
|
var timeline = TryString(value, "TimeLine") ?? string.Empty;
|
|
|
|
|
|
TryInt32(value, "SwitchType", out var switchType);
|
|
|
|
|
|
TryUInt32(value, "RaidId", out var raidId);
|
|
|
|
|
|
TrySingle(value, "Weight", out var weight);
|
|
|
|
|
|
var invalid = TryBool(value, "Invalid");
|
|
|
|
|
|
var loginInvalid = TryBool(value, "LoginInvalid");
|
|
|
|
|
|
var position = ReadPosition(value, "Position", Profile.WorldSpawn);
|
|
|
|
|
|
var facing = TrySingle(value, "TimeLineAngleY", out var angle) ? angle : Profile.WorldFacing;
|
2026-10-29 02:44:24 +07:00
|
|
|
|
// UseZeroPosAndAngle=true 的行要求角色保持在玩家当前位置/朝向演出(不传送)。201 行里目前
|
|
|
|
|
|
// 全部是 false,但官方语义摆在这里:真出现 true 时若还按行 Position 落点就会把玩家强行拽走。
|
|
|
|
|
|
var useZero = TryBool(value, "UseZeroPosAndAngle");
|
2026-09-15 18:13:46 +07:00
|
|
|
|
return new SwitchRow(
|
2026-10-29 02:44:24 +07:00
|
|
|
|
id, timeline, weight, switchType, raidId, invalid, loginInvalid, position, facing, useZero,
|
2026-09-15 18:13:46 +07:00
|
|
|
|
ReadUInt32Array(value, "FightSpiritId"),
|
|
|
|
|
|
ReadUInt32Array(value, "FirstSwitchSpiritId"),
|
|
|
|
|
|
ReadUInt32Array(value, "ForbidFightSpiritId"),
|
2026-10-29 02:44:24 +07:00
|
|
|
|
ReadUInt32Array(value, "AgentId"),
|
|
|
|
|
|
TryString(value, "Description") ?? string.Empty,
|
|
|
|
|
|
TryString(value, "TimeLineDescription") ?? string.Empty);
|
2026-09-15 18:13:46 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static Vec3 ReadPosition(JsonElement value, string name, Vec3 fallback)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!value.TryGetProperty(name, out var item) || item.ValueKind != JsonValueKind.Array)
|
|
|
|
|
|
return fallback;
|
|
|
|
|
|
var values = item.EnumerateArray().Take(3).Select(x => x.TryGetSingle(out var n) ? n : float.NaN).ToArray();
|
|
|
|
|
|
return values.Length == 3 && values.All(float.IsFinite) ? new Vec3(values[0], values[1], values[2]) : fallback;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static HashSet<uint> ReadUInt32Array(JsonElement value, string name)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!value.TryGetProperty(name, out var item) || item.ValueKind != JsonValueKind.Array)
|
|
|
|
|
|
return new HashSet<uint>();
|
|
|
|
|
|
return item.EnumerateArray().Where(x => x.TryGetUInt32(out _)).Select(x => x.GetUInt32()).ToHashSet();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static bool TryUInt32(JsonElement value, string name, out uint result)
|
|
|
|
|
|
{
|
|
|
|
|
|
result = default;
|
|
|
|
|
|
return value.TryGetProperty(name, out var item) && item.TryGetUInt32(out result);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static bool TryInt32(JsonElement value, string name, out int result)
|
|
|
|
|
|
{
|
|
|
|
|
|
result = default;
|
|
|
|
|
|
return value.TryGetProperty(name, out var item) && item.TryGetInt32(out result);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static bool TrySingle(JsonElement value, string name, out float result)
|
|
|
|
|
|
{
|
|
|
|
|
|
result = default;
|
|
|
|
|
|
return value.TryGetProperty(name, out var item) && item.TryGetSingle(out result);
|
|
|
|
|
|
}
|
|
|
|
|
|
private static string? TryString(JsonElement value, string name)
|
|
|
|
|
|
=> value.TryGetProperty(name, out var item) && item.ValueKind == JsonValueKind.String ? item.GetString() : null;
|
|
|
|
|
|
private static bool TryBool(JsonElement value, string name)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!value.TryGetProperty(name, out var item)) return false;
|
|
|
|
|
|
return item.ValueKind switch
|
|
|
|
|
|
{
|
|
|
|
|
|
JsonValueKind.True => true,
|
|
|
|
|
|
JsonValueKind.False => false,
|
|
|
|
|
|
_ => false,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private sealed record ClientData(
|
|
|
|
|
|
CharacterCatalogEntry[] Characters,
|
|
|
|
|
|
Dictionary<uint, SwitchRow> SwitchRows,
|
|
|
|
|
|
Dictionary<uint, uint[]> SwitchIdsBySpirit,
|
|
|
|
|
|
Dictionary<uint, uint[]> EntryIdsBySpirit,
|
|
|
|
|
|
uint[] CommonSwitchIds,
|
|
|
|
|
|
uint[] CommonEntryIds,
|
|
|
|
|
|
uint[] UnlockSystemIds,
|
|
|
|
|
|
uint[] InstalledPhoneAppIds,
|
|
|
|
|
|
uint[] SelectableFashionIds,
|
2026-10-29 02:44:24 +07:00
|
|
|
|
Dictionary<uint, uint[]> DefaultFashionIdsBySpirit,
|
|
|
|
|
|
uint[] ActionItemIds,
|
|
|
|
|
|
UrbanJobRow[] UrbanJobs,
|
|
|
|
|
|
Dictionary<uint, uint[]> TempSkillsBySpirit);
|
|
|
|
|
|
|
|
|
|
|
|
private readonly record struct CharacterSource(uint TemplateId, string Name, uint IconId, uint DefaultUrbanJob);
|
2026-09-15 18:13:46 +07:00
|
|
|
|
|
2026-10-29 02:44:24 +07:00
|
|
|
|
/// <summary>One rank of one career: 401 (Professional Hacker) -> 402 -> 403 via <see cref="PreJob"/>.</summary>
|
|
|
|
|
|
internal readonly record struct UrbanJobRow(uint Id, uint PreJob, uint Level, uint JobClass);
|
2026-09-15 18:13:46 +07:00
|
|
|
|
private sealed record SwitchRow(
|
|
|
|
|
|
uint Id,
|
|
|
|
|
|
string Timeline,
|
|
|
|
|
|
float Weight,
|
|
|
|
|
|
int SwitchType,
|
|
|
|
|
|
uint RaidId,
|
|
|
|
|
|
bool Invalid,
|
|
|
|
|
|
bool LoginInvalid,
|
|
|
|
|
|
Vec3 Position,
|
|
|
|
|
|
float Facing,
|
2026-10-29 02:44:24 +07:00
|
|
|
|
bool UseZeroPosAndAngle,
|
2026-09-15 18:13:46 +07:00
|
|
|
|
HashSet<uint> FightSpiritIds,
|
|
|
|
|
|
HashSet<uint> FirstSwitchSpiritIds,
|
|
|
|
|
|
HashSet<uint> ForbidFightSpiritIds,
|
2026-10-29 02:44:24 +07:00
|
|
|
|
HashSet<uint> AgentIds,
|
|
|
|
|
|
// 面板「切人演出」按 **动画** 列清单时的显示名:Description = 地点(樱花大道),
|
|
|
|
|
|
// TimelineDescription = 演出动作(莱卡娅送人下车)。两者都是作者手写的中文,
|
|
|
|
|
|
// 201 行里绝大多数非空 ⇒ 比只显示 timeline 名(SwitchChar_common_01)好认得多。
|
|
|
|
|
|
string Description,
|
|
|
|
|
|
string TimelineDescription);
|
|
|
|
|
|
private sealed record SwitchAnimationCandidate(
|
|
|
|
|
|
uint Id, string Timeline, float Weight, double AnchorDistance, Vec3 Position, float Facing, bool UseZeroPosAndAngle);
|
|
|
|
|
|
|
|
|
|
|
|
// ------------------------------------------------------------------
|
|
|
|
|
|
// Racing / challenge loaders (R190, ported from the Lyco fork).
|
|
|
|
|
|
//
|
|
|
|
|
|
// All four are FAIL-SOFT: a missing json yields an empty table (the racing
|
|
|
|
|
|
// feature then degrades to "no tracks / no AI"), because v2.1's own
|
|
|
|
|
|
// ReadRecords throws on a missing file and racing is an additive feature —
|
|
|
|
|
|
// it must never break world entry the way a required table would.
|
|
|
|
|
|
// ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
private static readonly Lazy<Dictionary<uint, RacingCompetitionGroupRow>> RacingCompetitionGroupsCache = new(LoadRacingCompetitionGroups);
|
|
|
|
|
|
private static readonly Lazy<Dictionary<uint, RacingTrackRow>> RacingTracksCache = new(LoadRacingTracks);
|
|
|
|
|
|
private static readonly Lazy<ChallengeDefinitionRow[]> ChallengesCache = new(LoadChallenges);
|
|
|
|
|
|
private static readonly Lazy<Dictionary<uint, RacingAiRow>> RacingAiRowsCache = new(LoadRacingAiRows);
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>RacingDriverCompetitionGroupConfig — one authored racing league group.</summary>
|
|
|
|
|
|
internal static IReadOnlyDictionary<uint, RacingCompetitionGroupRow> RacingCompetitionGroups()
|
|
|
|
|
|
=> RacingCompetitionGroupsCache.Value;
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>RacingDriverTrackInformationConfig — one authored race track.</summary>
|
|
|
|
|
|
internal static IReadOnlyDictionary<uint, RacingTrackRow> RacingTracks() => RacingTracksCache.Value;
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>ChallengeConfig — one authored challenge (ChallengeType 2 = racing).</summary>
|
|
|
|
|
|
internal static IReadOnlyList<ChallengeDefinitionRow> Challenges() => ChallengesCache.Value;
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>RacingDriverRacingAIConfig — one authored racing opponent (cfg id → vehicle).</summary>
|
|
|
|
|
|
internal static IReadOnlyDictionary<uint, RacingAiRow> RacingAiRows() => RacingAiRowsCache.Value;
|
|
|
|
|
|
|
|
|
|
|
|
private static JsonElement[] ReadRecordsSafe(string root, string fileName)
|
|
|
|
|
|
{
|
|
|
|
|
|
try
|
|
|
|
|
|
{
|
|
|
|
|
|
return ReadRecords(Path.Combine(root, fileName));
|
|
|
|
|
|
}
|
|
|
|
|
|
catch (Exception)
|
|
|
|
|
|
{
|
|
|
|
|
|
// Missing / malformed racing table = empty roster, never a crash.
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static Dictionary<uint, RacingCompetitionGroupRow> LoadRacingCompetitionGroups()
|
|
|
|
|
|
{
|
|
|
|
|
|
var root = PrivateServerConfigStore.ResolveProjectPath(PrivateServerConfigStore.Current.Paths.ClientConfigs);
|
|
|
|
|
|
var result = new Dictionary<uint, RacingCompetitionGroupRow>();
|
|
|
|
|
|
foreach (var record in ReadRecordsSafe(root, "RacingDriverCompetitionGroupConfig.json"))
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!TryUInt32(record, "Id", out var id) || id == 0)
|
|
|
|
|
|
continue;
|
|
|
|
|
|
|
|
|
|
|
|
// ContestGroup is a list of { TrackId, EventId } objects.
|
|
|
|
|
|
var tracks = new List<(uint TrackId, uint EventId)>();
|
|
|
|
|
|
if (record.TryGetProperty("ContestGroup", out var group) && group.ValueKind == JsonValueKind.Array)
|
|
|
|
|
|
{
|
|
|
|
|
|
foreach (var item in group.EnumerateArray())
|
|
|
|
|
|
{
|
|
|
|
|
|
if (item.ValueKind != JsonValueKind.Object) continue;
|
|
|
|
|
|
TryUInt32(item, "TrackId", out var trackId);
|
|
|
|
|
|
TryUInt32(item, "EventId", out var eventId);
|
|
|
|
|
|
if (trackId != 0)
|
|
|
|
|
|
tracks.Add((trackId, eventId));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// CarPointsEarned is a list of { Rank, Points } objects.
|
|
|
|
|
|
var points = new Dictionary<uint, uint>();
|
|
|
|
|
|
if (record.TryGetProperty("CarPointsEarned", out var earns) && earns.ValueKind == JsonValueKind.Array)
|
|
|
|
|
|
{
|
|
|
|
|
|
foreach (var item in earns.EnumerateArray())
|
|
|
|
|
|
{
|
|
|
|
|
|
if (item.ValueKind != JsonValueKind.Object) continue;
|
|
|
|
|
|
TryUInt32(item, "Rank", out var rank);
|
|
|
|
|
|
TryUInt32(item, "Points", out var value);
|
|
|
|
|
|
if (rank != 0)
|
|
|
|
|
|
points[rank] = value;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
result[id] = new RacingCompetitionGroupRow(
|
|
|
|
|
|
id,
|
|
|
|
|
|
TryBool(record, "IsLocked"),
|
|
|
|
|
|
TryUInt32(record, "UnlockPoint", out var unlockPoint) ? unlockPoint : 0u,
|
|
|
|
|
|
TryUInt32(record, "UnlockStar", out var unlockStar) ? unlockStar : 0u,
|
|
|
|
|
|
TryString(record, "Description") ?? string.Empty,
|
|
|
|
|
|
tracks.ToArray(),
|
|
|
|
|
|
points);
|
|
|
|
|
|
}
|
|
|
|
|
|
return result;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static Dictionary<uint, RacingTrackRow> LoadRacingTracks()
|
|
|
|
|
|
{
|
|
|
|
|
|
var root = PrivateServerConfigStore.ResolveProjectPath(PrivateServerConfigStore.Current.Paths.ClientConfigs);
|
|
|
|
|
|
var result = new Dictionary<uint, RacingTrackRow>();
|
|
|
|
|
|
foreach (var record in ReadRecordsSafe(root, "RacingDriverTrackInformationConfig.json"))
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!TryUInt32(record, "Id", out var id) || id == 0)
|
|
|
|
|
|
continue;
|
|
|
|
|
|
result[id] = new RacingTrackRow(
|
|
|
|
|
|
id,
|
|
|
|
|
|
TryUInt32(record, "NumberOfLaps", out var laps) ? laps : 1u,
|
|
|
|
|
|
TryUInt32(record, "MaxGamePeople", out var seats) ? seats : 0u,
|
|
|
|
|
|
TryUInt32(record, "MaxGameTime", out var time) ? time : 0u,
|
|
|
|
|
|
TryUInt32(record, "Difficulty", out var difficulty) ? difficulty : 0u,
|
|
|
|
|
|
TryString(record, "ContestType") ?? string.Empty,
|
|
|
|
|
|
ReadUInt32Array(record, "EventIds").OrderBy(x => x).ToArray(),
|
|
|
|
|
|
ReadUInt32Array(record, "HighAIList").OrderBy(x => x).ToArray(),
|
|
|
|
|
|
ReadUInt32Array(record, "MiddleAIList").OrderBy(x => x).ToArray(),
|
|
|
|
|
|
ReadUInt32Array(record, "LowAIList").OrderBy(x => x).ToArray(),
|
|
|
|
|
|
TryString(record, "RaidSpoonName") ?? string.Empty,
|
|
|
|
|
|
TryString(record, "RacingPathName") ?? string.Empty,
|
|
|
|
|
|
TryUInt32(record, "MapLocation", out var mapLocation) ? mapLocation : 0u);
|
|
|
|
|
|
}
|
|
|
|
|
|
return result;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static Dictionary<uint, RacingAiRow> LoadRacingAiRows()
|
|
|
|
|
|
{
|
|
|
|
|
|
var root = PrivateServerConfigStore.ResolveProjectPath(PrivateServerConfigStore.Current.Paths.ClientConfigs);
|
|
|
|
|
|
var result = new Dictionary<uint, RacingAiRow>();
|
|
|
|
|
|
foreach (var record in ReadRecordsSafe(root, "RacingDriverRacingAIConfig.json"))
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!TryUInt32(record, "Id", out var id) || id == 0)
|
|
|
|
|
|
continue;
|
|
|
|
|
|
result[id] = new RacingAiRow(
|
|
|
|
|
|
id,
|
|
|
|
|
|
TryUInt32(record, "VehicleID", out var vehicleId) ? vehicleId : 0u,
|
|
|
|
|
|
TryUInt32(record, "VehicleTaskID", out var taskId) ? taskId : 0u,
|
|
|
|
|
|
TryUInt32(record, "SortScores", out var score) ? score : 0u,
|
|
|
|
|
|
TryUInt32(record, "AgentQuoteID", out var quote) ? quote : 0u);
|
|
|
|
|
|
}
|
|
|
|
|
|
return result;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static ChallengeDefinitionRow[] LoadChallenges()
|
|
|
|
|
|
{
|
|
|
|
|
|
var root = PrivateServerConfigStore.ResolveProjectPath(PrivateServerConfigStore.Current.Paths.ClientConfigs);
|
|
|
|
|
|
var result = new List<ChallengeDefinitionRow>();
|
|
|
|
|
|
foreach (var record in ReadRecordsSafe(root, "ChallengeConfig.json"))
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!TryUInt32(record, "Id", out var id) || id == 0)
|
|
|
|
|
|
continue;
|
|
|
|
|
|
result.Add(new ChallengeDefinitionRow(
|
|
|
|
|
|
id,
|
|
|
|
|
|
TryString(record, "Name") ?? string.Empty,
|
|
|
|
|
|
TryUInt32(record, "ChallengeType", out var type) ? type : 0u,
|
|
|
|
|
|
TryUInt32(record, "UrbanJobType", out var jobType) ? jobType : 0u,
|
|
|
|
|
|
ReadUInt32Array(record, "RelatedTask").OrderBy(x => x).ToArray()));
|
|
|
|
|
|
}
|
|
|
|
|
|
return result.OrderBy(x => x.Id).ToArray();
|
|
|
|
|
|
}
|
2026-09-15 18:13:46 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-29 02:44:24 +07:00
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// Racing / challenge rows (R190, ported from the Lyco fork).
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// One authored racing league group (RacingDriverCompetitionGroupConfig): its unlock gate, the
|
|
|
|
|
|
/// TrackId/EventId pairs inside it and the points awarded per finishing rank.
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
internal sealed record RacingCompetitionGroupRow(
|
|
|
|
|
|
uint Id,
|
|
|
|
|
|
bool IsLocked,
|
|
|
|
|
|
uint UnlockPoint,
|
|
|
|
|
|
uint UnlockStar,
|
|
|
|
|
|
string Description,
|
|
|
|
|
|
(uint TrackId, uint EventId)[] Tracks,
|
|
|
|
|
|
Dictionary<uint, uint> PointsByRank);
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// One authored challenge (ChallengeConfig.json). ChallengeType 2 == Racing; UrbanJobType 11300002
|
|
|
|
|
|
/// is the 赛车手 job class. RelatedTask is the task id the 挑战面板 hands to gTaskManager
|
|
|
|
|
|
/// (challenge 1003 "王牌车神·城中之夜" → RelatedTask 60000147, raid 23300888).
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
internal sealed record ChallengeDefinitionRow(
|
|
|
|
|
|
uint Id,
|
|
|
|
|
|
string Name,
|
|
|
|
|
|
uint ChallengeType,
|
|
|
|
|
|
uint UrbanJobType,
|
|
|
|
|
|
uint[] RelatedTasks);
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// One authored race track (RacingDriverTrackInformationConfig).
|
|
|
|
|
|
/// MaxGamePeople == 0 marks a map-location placeholder row (the Id 1000+ block), not a playable track.
|
|
|
|
|
|
/// Track 3 = "Ace Driver · Downtown Nights"(城中之夜), RaidSpoonName "23300888_RacingDriver".
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
internal sealed record RacingTrackRow(
|
|
|
|
|
|
uint Id,
|
|
|
|
|
|
uint Laps,
|
|
|
|
|
|
uint MaxPlayers,
|
|
|
|
|
|
uint MaxGameTime,
|
|
|
|
|
|
uint Difficulty,
|
|
|
|
|
|
string ContestType,
|
|
|
|
|
|
uint[] EventIds,
|
|
|
|
|
|
uint[] HighAI,
|
|
|
|
|
|
uint[] MiddleAI,
|
|
|
|
|
|
uint[] LowAI,
|
|
|
|
|
|
// The SCENE-spoon name ("<raidId>_RacingDriver"); the TASK graph alias is a different string
|
|
|
|
|
|
// (see GameRouter.RacingTaskGraph.cs RacingTaskSpoonVerifiedAliases).
|
|
|
|
|
|
string RaidSpoonName,
|
|
|
|
|
|
string RacingPathName,
|
|
|
|
|
|
uint MapLocation);
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// One authored racing opponent (RacingDriverRacingAIConfig). The client turns an AIVehicleInfos
|
|
|
|
|
|
/// value (= Id here) into "which vehicle config must exist in the scene" (VehicleId) plus
|
|
|
|
|
|
/// "which AI task drives it around the circuit" (VehicleTaskId).
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
internal sealed record RacingAiRow(
|
|
|
|
|
|
uint Id,
|
|
|
|
|
|
uint VehicleId,
|
|
|
|
|
|
uint VehicleTaskId,
|
|
|
|
|
|
uint SortScores,
|
|
|
|
|
|
uint AgentQuoteId);
|
|
|
|
|
|
|
|
|
|
|
|
internal sealed record CharacterCatalogEntry(uint TemplateId, ulong UnitId, string Name, uint IconId, uint DefaultUrbanJob = 0);
|
2026-09-15 18:13:46 +07:00
|
|
|
|
internal sealed record SwitchAnimationChoice(
|
2026-10-29 02:44:24 +07:00
|
|
|
|
uint Id, string Timeline, float Weight, float AnchorDistance, int TimelinePool, int RowPool, string? PreviousTimeline,
|
|
|
|
|
|
Vec3 Position, float Facing, bool UseZeroPosAndAngle);
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 一条**切人演出**(SwitchSpiritConfig 的一行)—— 面板「切人演出」卡的主下拉就是它。
|
|
|
|
|
|
///
|
|
|
|
|
|
/// ⛑ <see cref="Playable"/> 的判据照 <c>ClientConfigRepository.RandomSwitchAnimation</c> 的注释:
|
|
|
|
|
|
/// 「Generic server switching cannot satisfy authored companion-agent rows. Those rows carry AgentId
|
|
|
|
|
|
/// entries that stock SwitchTeleport expects the server to spawn before landing.」
|
|
|
|
|
|
/// ⇒ AgentId 非空的行服务端这条链路补不出随从,**列得出来但播不了**。
|
|
|
|
|
|
/// 面板默认只列 <see cref="Playable"/>,另有「显示全部」开关做诊断(照捐赠端 m-switch 的 `tlKind`)。
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
internal sealed record SwitchShowRow(
|
|
|
|
|
|
uint Id,
|
|
|
|
|
|
string Timeline,
|
|
|
|
|
|
string Description,
|
|
|
|
|
|
string TimelineDescription,
|
|
|
|
|
|
int SwitchType,
|
|
|
|
|
|
uint RaidId,
|
|
|
|
|
|
float Weight,
|
|
|
|
|
|
bool Invalid,
|
|
|
|
|
|
bool LoginInvalid,
|
|
|
|
|
|
Vec3 Position,
|
|
|
|
|
|
float Facing,
|
|
|
|
|
|
bool UseZeroPosAndAngle,
|
|
|
|
|
|
IReadOnlyList<uint> FightSpiritIds,
|
|
|
|
|
|
IReadOnlyList<uint> FirstSwitchSpiritIds,
|
|
|
|
|
|
IReadOnlyList<uint> ForbidFightSpiritIds,
|
|
|
|
|
|
IReadOnlyList<uint> AgentIds,
|
|
|
|
|
|
bool IsCommon)
|
|
|
|
|
|
{
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 这条行能不能由服务端这条切人链路播出来。<br/>
|
|
|
|
|
|
/// ⛑ AgentId 非空的行服务端<b>补不出随从</b>(照 <c>RandomSwitchAnimation</c> 的注释:
|
|
|
|
|
|
/// "Generic server switching cannot satisfy authored companion-agent rows")⇒ 列得出来但播不了。
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
internal bool Playable => !Invalid && AgentIds.Count == 0;
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>下拉里的显示名:#id 地点 · 动作 · timeline。</summary>
|
|
|
|
|
|
internal string Label =>
|
|
|
|
|
|
$"#{Id} {(Description.Length > 0 ? Description : "(无地点)")}" +
|
|
|
|
|
|
$"{(TimelineDescription.Length > 0 ? " · " + TimelineDescription : "")} · {Timeline}";
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 这条演出<b>哪些角色能演</b> —— 判据照 <c>ClientConfigRepository.IdsFor</c>:
|
|
|
|
|
|
/// · FightSpiritId 与 FirstSwitchSpiritId <b>都空</b>且是通用行 ⇒ 全 roster(扣掉 Forbid);
|
|
|
|
|
|
/// · 否则 ⇒ 只能是 Fight ∪ First 里列出的角色(扣掉 Forbid);
|
|
|
|
|
|
/// · 非通用行且两个清单都空 ⇒ 谁都演不了(空列表)。
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
internal IReadOnlyList<uint> EligibleSpiritIds(IReadOnlyList<uint> roster, IReadOnlyList<uint> forbidOverride)
|
|
|
|
|
|
{
|
|
|
|
|
|
// 参数化而非直接读 roster 表,是因为 ClientConfigRepository.Load 里 switchRows 的构建
|
|
|
|
|
|
// 早于 characters 完成,此处拿不到最终名单;由调用方(Bridge)传进来。
|
|
|
|
|
|
var named = FightSpiritIds.Concat(FirstSwitchSpiritIds).Distinct().ToHashSet();
|
|
|
|
|
|
var picked = (named.Count == 0 && IsCommon)
|
|
|
|
|
|
? roster.Except(ForbidFightSpiritIds.Concat(forbidOverride))
|
|
|
|
|
|
: named.Except(ForbidFightSpiritIds.Concat(forbidOverride));
|
|
|
|
|
|
return picked.OrderBy(x => x).ToArray();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|