2431 lines
120 KiB
C#
2431 lines
120 KiB
C#
using Ananta.Server.ClientData.Client4229938;
|
||
using Ananta.Server.Configuration;
|
||
using Ananta.Server.RpcTypes.Client4229938;
|
||
using Auto = Ananta.Server.RpcTypes.Client4229938.Auto;
|
||
using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game;
|
||
|
||
namespace Ananta.Server.Protocol.Client4229938;
|
||
|
||
/// <summary>
|
||
/// Builds the large build-4229938 RPC payloads directly from human config + extracted client JSON.
|
||
/// Nothing here reads generated .bin files: every packet is a normal RpcTypes object serialized by UxSerializer.
|
||
/// </summary>
|
||
internal static class RuntimePayloadFactory
|
||
{
|
||
/// <summary>
|
||
/// LT.ConfigGen.UrbanJobConfig.Jobless in the matching 4229938 client dump.
|
||
/// Zero is not a valid UrbanJobConfig id: the client's SpiritJobManager dereferences a missing
|
||
/// config as soon as that character later receives an authored job through SyncSpiritJobInfo.
|
||
/// </summary>
|
||
internal const uint JoblessUrbanJob4229938 = 100;
|
||
|
||
/// <summary>
|
||
/// ImageNewAvatarConfig ids used as PersonalZoneHeadInfo.SystemHeadId. The field is an
|
||
/// *ImageNewAvatarConfig* id (1..15 in this build), not an image resource id — see the note on
|
||
/// the login payload below. Id 1 and 2 are the two "<player>" rows (male / female); the rest
|
||
/// are named character avatars (Taffy / Garm / Bansy / ...).
|
||
/// </summary>
|
||
internal const uint MaleAvatarSystemHeadId4229938 = 1;
|
||
internal const uint FemaleAvatarSystemHeadId4229938 = 2;
|
||
|
||
private static PrivateServerConfig Config => PrivateServerConfigStore.Current;
|
||
|
||
internal static Auto.PlayerClientInfo MinimalPlayerInfo4229938(
|
||
WorldEntryState? state = null, ExtractionShooterSessionState? extraction = null)
|
||
{
|
||
// Keep only identity plus the actor-facing roster required to materialize the controlled
|
||
// spirit. Economy/social/vehicles/apps/tasks and the unrelated account armory stay absent.
|
||
var root = new Auto.PlayerClientInfo();
|
||
root.InfoLogin.Aid = Config.Client.Aid;
|
||
root.InfoLogin.Pid = Config.Player.Pid;
|
||
root.InfoLogin.AccountId = Config.Player.AccountId;
|
||
root.InfoLogin.Name = Config.Player.DisplayName;
|
||
root.InfoLogin.Level = 1;
|
||
root.InfoLogin.Sex = 1;
|
||
root.InfoLogin.UseSystemName = false;
|
||
root.InfoLogin.UniverseId = Config.Player.LoginUniverseId;
|
||
|
||
// Personal-zone head (头像). PlayerInfoLoginData.lua:14 copies InfoLogin.PzHeadInfo into
|
||
// bindData.infoPzHeadInfo and every avatar consumer reads SystemHeadId straight off it:
|
||
// ComputerMainPanelStore.lua:341 — the lock-screen avatar of any computer whose
|
||
// ComputerConfig.UserName is "<player>" (Taffy apartment 12130033/34, Uncle Hong's
|
||
// 12130049, the test PC 12130000). Other machines use their own ComputerConfig.UserHeadId
|
||
// and never touch this field, which is why an unpatched server still shows avatars there.
|
||
// HunLunManager.lua:322 GetHeadIconAndName -> ImageNewAvatarConfig.GetConfig(id).SguiImageId
|
||
// CallPhoneUtils.lua:424, ChatManager.lua:100, FriendManager.lua:336 — phone/chat/friends.
|
||
//
|
||
// ⛑️ SystemHeadId is an ImageNewAvatarConfig **row id**, not an image resource id: the table
|
||
// only holds 15 rows, so GetConfig(0) is nil and GetHeadIconAndName returns nil. Left at its
|
||
// default the lock screen renders no avatar at all — nothing to click, so the desktop behind
|
||
// it (and the file/mail apps on its task bar) is unreachable. HeadType 0 = System.
|
||
var avatarId = root.InfoLogin.Sex == 1
|
||
? MaleAvatarSystemHeadId4229938
|
||
: FemaleAvatarSystemHeadId4229938;
|
||
root.InfoLogin.PzHeadInfo.HeadType = 0;
|
||
root.InfoLogin.PzHeadInfo.SystemHeadId = avatarId;
|
||
root.InfoLogin.LinkPzHeadInfo.HeadType = 0;
|
||
root.InfoLogin.LinkPzHeadInfo.SystemHeadId = avatarId;
|
||
|
||
// World-entry cannot advertise a concrete WeaponTemplateId in SyncEnterScene while the
|
||
// login roster contains zero weapon instances. 4229938 preloads the local actor from the
|
||
// PlayerInfo spirit/armory stores before AskLoadSceneCompleted; an empty wheel makes that
|
||
// actor profile internally inconsistent and leaves the client waiting on presentation data.
|
||
// Keep only the weapon instances actually referenced by the 32 owned spirit wheels.
|
||
// A roster row the combat catalog does not know (hand-added template id, row removed from the
|
||
// catalog) has no weapon wheel to build, so it is dropped here rather than failing the whole
|
||
// login payload.
|
||
var spirits = ClientConfigRepository.Characters()
|
||
.Where(character => CombatCatalogRepository.TryLoadout(character.TemplateId) is not null)
|
||
.Select(character => MinimalActorCharacterSpirit4229938(character, state))
|
||
.ToList();
|
||
var actorArmory = spirits
|
||
.SelectMany(spirit => spirit.WeaponSlots)
|
||
.OfType<Auto.WeaponData>()
|
||
.Where(weapon => weapon.InstanceId != 0)
|
||
.GroupBy(weapon => weapon.InstanceId)
|
||
.Select(group => group.First())
|
||
.OrderBy(weapon => weapon.InstanceId)
|
||
.ToList();
|
||
|
||
root.InfoSpirit.Spirits = spirits;
|
||
root.InfoSpirit.ActiveSpirit = Config.Player.InitialSpiritTemplateId;
|
||
root.InfoSpirit.InfoPokemon.FastFightSquad = [Config.Player.InitialUnitId];
|
||
root.InfoSpirit.InfoArmory.Weapons = actorArmory;
|
||
|
||
// Phone apps: the client only renders an app icon when its id is present in
|
||
// PlayerClientInfoSpirit.InstalledApps (otherwise the home screen is empty and the app
|
||
// store shows everything as "not downloaded"). The set is already computed from
|
||
// MobileMenuSGuiConfig.json — it was never written into the login payload before.
|
||
root.InfoSpirit.InstalledApps = ClientConfigRepository.InstalledPhoneAppIds().ToList();
|
||
|
||
// T0-4: PlayerInfoSpiritData.lua:75-76 copies this single int into
|
||
// gPoliceJobManager.todayMissionCnt ("今日完成 N 单"). The contract already ships the field,
|
||
// so this only seeds its value — see GameplaySettings.PoliceTodayCompleteMissionCnt.
|
||
if (Config.Gameplay.PoliceTodayCompleteMissionCnt != 0)
|
||
{
|
||
root.InfoSpirit.InfoPolice.TodayCompleteMissionCnt = Config.Gameplay.PoliceTodayCompleteMissionCnt;
|
||
}
|
||
|
||
// Unlock only the styles actually referenced by the owned actor loadouts. This is actor
|
||
// presentation metadata, not the old server's global combat/unlock economy.
|
||
foreach (var styleId in ClientConfigRepository.Characters()
|
||
.Select(character => CombatCatalogRepository.TryLoadout(character.TemplateId)?.StyleByType.Values)
|
||
.Where(styles => styles is not null)
|
||
.SelectMany(styles => styles!)
|
||
.Where(styleId => styleId != 0)
|
||
.Distinct())
|
||
root.InfoSpirit.InfoFightStyle.FightStyleIsUnLocked[styleId] = true;
|
||
|
||
root.InfoMinor.Level = 1;
|
||
root.InfoMinor.MatchInfo.DeviceLevel = 1;
|
||
root.InfoMinor.MatchInfo.CurLinkDeviceLevel = 1;
|
||
|
||
// Provide the full summonable fleet so the client-side DriveManager recognises every
|
||
// drivable vehicle and the phone F1 car-call (142857) lists them all (AskSummonVehicle).
|
||
// User request: seed EVERY summonable vehicle from VehicleConfig (AllSummonable, 237
|
||
// entries — prefab + 1..16 seats) as owned. The sibling 08.05 build proved this
|
||
// PlayerInfo seeding is what activates the client F1 summon.
|
||
if (Config.Gameplay.Vehicles.Enabled)
|
||
{
|
||
var summonable = VehicleCatalog4229938.AllSummonable;
|
||
if (summonable.Count > 0)
|
||
{
|
||
root.InfoMinor.VehicleInfo = new Auto.PlayerVehicleInfo
|
||
{
|
||
UnlockedVehicles = summonable.Select(v => new Auto.PlayerVehicleDetail
|
||
{
|
||
Id = v.Id,
|
||
UnlockTime = 1,
|
||
SuitId = 0,
|
||
Parts = [],
|
||
}).ToList(),
|
||
RequisitionVehicleCount = summonable.Count,
|
||
ParkingVehicleId = 0,
|
||
};
|
||
}
|
||
}
|
||
// Seed phone contacts so the dialer has something to call without playing the story.
|
||
//
|
||
// The client matches contacts to its local PhoneContactConfig by phone number
|
||
// (CallPhoneUtils.GetConfigIdByPhoneNumber), so the server only owns the number: the name,
|
||
// avatar, remark and the whole dialogue body all resolve client-side. What used to be two
|
||
// hardcoded quick-call numbers (142857 叫车 / 206337 快递车) is now the full catalogue in
|
||
// Configs/PhoneContacts.json — same two rows are still in it, so nothing regresses.
|
||
//
|
||
// ⛑ 光有号码不够:拨号时 CallPhoneUtils.GetDialogArgsByPhoneNumber 会先问
|
||
// CheckConfigContactHasUnlock,未解锁播的是 LockDialogId("号码忙"),既不报错也不打日志。
|
||
// 解锁开关在下面 PhoneContactsUnlockAll 那段(module 3 的 FinishedTemplateIdList)。
|
||
var summonContacts = BuildPhoneContacts(Config.Gameplay.PhoneContactsEnabled);
|
||
foreach (var character in ClientConfigRepository.Characters())
|
||
{
|
||
root.InfoMinor.PlayerPhoneInfo.SpiritPhoneInfos[character.TemplateId] = new Auto.PhoneInfos
|
||
{
|
||
ContactList = summonContacts.Select(c => new Auto.PhoneContact
|
||
{
|
||
Remark = c.Remark,
|
||
PhoneNumber = c.Number,
|
||
}).ToList(),
|
||
ContactGroupList = [],
|
||
CallRecordList = [],
|
||
ContactOutgoingCallTimesDict = new Dictionary<string, uint>(),
|
||
};
|
||
}
|
||
|
||
// 趴卜 NPC 主页的「日程」标签。BubbleFriendHomePanelStore.RefreshContent 取
|
||
// NpcDaliyManager:GetTodayScheduleList(agentType) -> NpcTimeTableLists[agentType],而那份表
|
||
// 只有两个来源:SyncFavorNpcTimeTableInfos(64219939) 的推送,或登录帧里的
|
||
// FavorNpcDailyScheduleInfos。走登录帧就不用再单独推一条 notify,客户端进游戏就有。
|
||
// 数据来自 NpcDailySchedule.json(LifeScheduleTimetableConfig 的四个时段 × AgentDataSetsActivityConfig
|
||
// 的活动),见 _gen_npc_schedule.py。
|
||
if (Config.Gameplay.NpcDailyScheduleEnabled)
|
||
{
|
||
var scheduleEntries = NpcScheduleCatalog4229938.Entries;
|
||
if (NpcScheduleCatalog4229938.LoadError is not null)
|
||
{
|
||
// Never fatal: an unusable catalogue simply means the 日程 tab stays empty.
|
||
Console.Error.WriteLine($"[NPC-SCHEDULE] catalogue unavailable: {NpcScheduleCatalog4229938.LoadError}");
|
||
}
|
||
if (scheduleEntries.Count > 0)
|
||
{
|
||
var todayStart = new DateTimeOffset(DateTimeOffset.UtcNow.Date, TimeSpan.Zero).ToUnixTimeSeconds();
|
||
var tables = new Dictionary<uint, Auto.NpcTimeTableInfo>(scheduleEntries.Count);
|
||
|
||
foreach (var entry in scheduleEntries)
|
||
{
|
||
// ★ 坐标不能留空:NpcDaliyManager:GetNpcPosition 直接把 currentSchedule.Position
|
||
// 当 NPC 的所在位置,MapSubSystem_SpiritAcquisition 也拿它(配合 RaidId)落大地图
|
||
// 的「角色在哪」图钉。值来自 AgentDataSetsActivityConfig.MapPosition —— 官方给
|
||
// 这条活动编好的世界坐标(Richie 的跑步机就是 1191,0,2052),见 _gen_npc_life_cfg.py。
|
||
static Auto.NpcScheduleInfo Slice(NpcScheduleSlice4229938 slice) => new()
|
||
{
|
||
ActivityId = slice.ActivityId,
|
||
StartDaySecond = slice.StartDaySecond,
|
||
Position = NpcActivityCatalog4229938.TryGet(slice.ActivityId, out var at)
|
||
? new Auto.UXVector3 { X = at.X, Y = at.Y, Z = at.Z }
|
||
: new Auto.UXVector3(),
|
||
SpoonAgentId = 0,
|
||
EndDaySecond = slice.EndDaySecond,
|
||
RaidId = slice.RaidId,
|
||
};
|
||
|
||
tables[entry.AgentTag] = new Auto.NpcTimeTableInfo
|
||
{
|
||
Schedule0 = Slice(entry.Slices[0]),
|
||
Schedule1 = Slice(entry.Slices[1]),
|
||
Schedule2 = Slice(entry.Slices[2]),
|
||
Schedule3 = Slice(entry.Slices[3]),
|
||
Schedule4 = Slice(entry.Slices[4]),
|
||
CurrentSpoonAgentId = 0,
|
||
SpoonPosition = new Auto.UXVector3(),
|
||
TempSchedule = null,
|
||
IsTempScheduleOnly = false,
|
||
RefreshDay = todayStart,
|
||
};
|
||
}
|
||
|
||
root.InfoMinor.FavorNpcDailyScheduleInfos = tables;
|
||
}
|
||
}
|
||
|
||
// 眼界 (Tuite) 的内容列表。SocialNetWorkUtils.lua 里六个列表函数(GetRecommendList:143 /
|
||
// GetExploreTuiteList:400 / GetFollowList:431 / GetTuiteListByPubisher:445 /
|
||
// GetCollectionList:1687 / GetLocalTuiteList:3046)全部只读 playerTuiteInfo.TuiteList,
|
||
// GetTuiteInfo 再用 v.CfgId 去查客户端本地的 TuiteConfig 取正文与作者,所以服务端只要给出
|
||
// 「这条存在 + 计数 + 时间 + 关注收藏标记」即可,正文图文评论全在客户端。
|
||
// 数据来自 TuitePosts.json(_gen_tuite_posts.py 从 ConfigDump 的 TuiteConfig 204 行生成)。
|
||
if (Config.Gameplay.TuiteEnabled)
|
||
{
|
||
if (TuiteCatalog4229938.LoadError is not null)
|
||
{
|
||
// Never fatal: an unusable catalogue simply means 眼界 stays empty.
|
||
Console.Error.WriteLine($"[TUITE] catalogue unavailable: {TuiteCatalog4229938.LoadError}");
|
||
}
|
||
|
||
var tuitePosts = TuiteCatalog4229938.Posts;
|
||
if (tuitePosts.Count > 0)
|
||
{
|
||
var followAll = Config.Gameplay.TuiteFollowAll;
|
||
var publishedAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||
var tuiteList = new List<Auto.ClientTuiteInfo>(tuitePosts.Count);
|
||
|
||
for (var i = 0; i < tuitePosts.Count; i++)
|
||
{
|
||
var post = tuitePosts[i];
|
||
tuiteList.Add(new Auto.ClientTuiteInfo
|
||
{
|
||
// TuiteConfig.Id — the only field that unlocks the client's local text.
|
||
CfgId = post.Id,
|
||
// Rendered as "多久以前", so it must be in the past and strictly decreasing.
|
||
// Same helper the detail handlers use, so list and detail agree.
|
||
PublishTime = TuiteCatalog4229938.PublishTimeFor(post.Id, publishedAt),
|
||
IsCollect = false,
|
||
IsLike = false,
|
||
// GetFollowList keeps only IsFollow rows; see Gameplay.TuiteFollowAll.
|
||
IsFollow = followAll,
|
||
// Never true: the timeline blob is authored client-side (see TuiteCatalog4229938).
|
||
HasTimeline = false,
|
||
TimelineData = null,
|
||
LikeCount = post.Likes,
|
||
ViewCount = post.Views,
|
||
CommentCount = post.Comments.Count,
|
||
// Non-zero would light up the 领取 reward button (AskTuiteTakeReward).
|
||
DropId = 0,
|
||
OssKey = null,
|
||
});
|
||
}
|
||
|
||
root.InfoMinor.PlayerTuiteInfo = new Auto.PlayerTuiteClientInfo { TuiteList = tuiteList };
|
||
}
|
||
}
|
||
|
||
// Event-condition progress so the phone contacts count as unlocked.
|
||
// Module 3 = UX.Game.EventConditionImplModule.PhoneContact (IL2CPP metadata
|
||
// declaration order). EventConditionUtils.CheckHasUnlocked walks this list and returns
|
||
// unlocked as soon as it contains the PhoneContactUnlockConfig row's own Id:
|
||
// 之前只标 [1, 2](= 111111111 叫车 / 111111112 快递车),所以只有那两个号拨得通、
|
||
// 其余号码一律播 LockDialogId —— 表现为"号码忙",既不报错也不打日志,很容易误判成
|
||
// 服务端没发通讯录。现在整表标完成(79 行),任何角色都能拨。
|
||
// ⛑ 这条与上面的 ContactList 是两件事:前者决定"列表里有没有这个号",这条决定"拨不拨得通"。
|
||
var phoneProgress = new Auto.ModuleEventProgressInfoBySpirit
|
||
{
|
||
EventProgressInfoDict = new Dictionary<uint, Auto.EventProgressInfo>(),
|
||
FinishedTemplateIdList = BuildPhoneUnlockIds(Config.Gameplay.PhoneContactsUnlockAll),
|
||
};
|
||
var phoneModule = new Auto.ModuleEventProgressInfo
|
||
{
|
||
ProgressInfoDict = new Dictionary<uint, Auto.ModuleEventProgressInfoBySpirit>
|
||
{
|
||
[0] = phoneProgress,
|
||
},
|
||
};
|
||
foreach (var character in ClientConfigRepository.Characters())
|
||
phoneModule.ProgressInfoDict[character.TemplateId] = phoneProgress;
|
||
root.InfoMinor.ModuleEventProgressInfoDict[3] = phoneModule;
|
||
// -----------------------------------------------------------------------------------
|
||
// 派对解锁(2026-09-25 第 24 轮)
|
||
//
|
||
// ★ 症状:客户端里的「主题派对」点开是**锁上**的。
|
||
// ★ 根因(逐行可查,非推断):派对解锁判定走
|
||
// lua\LX6\Utils\EventConditionUtils.lua:6 M.CheckHasUnlocked(config, module, ...)
|
||
// :9 CheckHasUnlockedCondition(config, "MaxProgress")
|
||
// → :48 返回 config.MaxProgress > 0 ;PartyConfig.MaxProgress = 1 ⇒ **条件成立**
|
||
// :10 GetModuleFinishTemplateIdList(Party)
|
||
// → :63 moduleEventProgressInfo.FinishedTemplateIdList
|
||
// → :69 gPlayerManager.infoMinor.bindData.ModuleEventProgressInfoDict[module]
|
||
// :27 列表里有 `finishedTemplateId == config.Id`(= PartyConfig.Id 13030000…)⇒ 解锁
|
||
// 而服务端登录 payload **只填了 module 3(PhoneContact)**,没有 Party
|
||
// ⇒ FinishedTemplateIdList 为空 ⇒ 恒 false ⇒ 面板一律显示锁。
|
||
// (另:SystemUnlockConfig.Id=160 的 PopupName 就是「主题派对」,VersionCondition=[0,1],
|
||
// 它只管主菜单入口,管不了「派对面板里具体哪几个派对解锁」——那是本段的事。)
|
||
//
|
||
// ★ 模块号铁证(frida 反射 System.Enum.GetValues 读 UX.Game.EventConditionImplModule,
|
||
// 非按声明顺序猜):**Party = 30**;对照组 PhoneContact = 3,与上面既有注释完全吻合
|
||
// ⇒ 读值方法可靠。
|
||
//
|
||
// ⚠ 这只是「解锁」。派对真正开局还需要 StartSingleParty(63731129) 等,
|
||
// 那些是另一层(见 gym_tools\派对Party_研究报告_第24轮.md),本段不涉及。
|
||
// -----------------------------------------------------------------------------------
|
||
var partyProgress = new Auto.ModuleEventProgressInfoBySpirit
|
||
{
|
||
EventProgressInfoDict = new Dictionary<uint, Auto.EventProgressInfo>(),
|
||
// PartyConfig 全表 7 条:主城(新启) 13030000/13030002/13030003、联机 13030004/13030005、
|
||
// 凌云 13030001/13030006 —— 后两条按「凌云不用管」不解锁,需要时加进来即可。
|
||
FinishedTemplateIdList = [13030000, 13030002, 13030003, 13030004, 13030005],
|
||
};
|
||
var partyModule = new Auto.ModuleEventProgressInfo
|
||
{
|
||
ProgressInfoDict = new Dictionary<uint, Auto.ModuleEventProgressInfoBySpirit>
|
||
{
|
||
[0] = partyProgress,
|
||
},
|
||
};
|
||
foreach (var character in ClientConfigRepository.Characters())
|
||
partyModule.ProgressInfoDict[character.TemplateId] = partyProgress;
|
||
root.InfoMinor.ModuleEventProgressInfoDict[30] = partyModule;
|
||
|
||
// The v7 guide only constrains ActiveSpirit/actor identity; it does not require the rest of
|
||
// PlayerInfo to be zero. Keep build-local SystemUnlock metadata so the stock 4229938 HUD,
|
||
// map and input UI do not treat the account as a pre-tutorial profile. No semantic handlers
|
||
// are re-enabled by publishing these client-owned unlock ids.
|
||
root.InfoMinor.PlayerInfoGuide.UnlockSystems = ClientConfigRepository.UnlockSystemIds().ToList();
|
||
root.InfoMinor.PlayerFashionsInfo = MinimalPlayerFashions4229938(state);
|
||
SeedOwnedHouses4229938(root, state);
|
||
SeedWeaponSkins4229938(root, state);
|
||
|
||
// UnlockedQuestList is deliberately left empty. It was filled with all 4342 authored ids
|
||
// for a while, but the client only reads it from the favourites / ambient-event code — the
|
||
// task panel never looks at it, and every S2C route to that panel failed too
|
||
// (see GameRouter.Tasks.cs). Dropping it saves ~17 KB of login payload for no loss.
|
||
|
||
// NPC profiles (character compendium). Unlike the quest list, this one IS read:
|
||
// lua/LX6/Data/Player/PlayerData/PlayerInfoMinorNpcProfileData.lua InitPlayerInfo walks
|
||
// InfoNpcProfile.NpcProfiles and builds npcTrustInfo[ProfileId] from it.
|
||
//
|
||
// Deliberately NOT using SyncPlayerNpcProfileActivate(64753383) for this: its client impl
|
||
// calls gAgentTrustManager:PopUpAgentProfile, which pushes the profile onto a popup queue
|
||
// and then shows a "you met N people" dialog. 61 profiles means 61 queued popups. Filling
|
||
// the login payload gets the same data in with no popups at all.
|
||
var activateTime = (uint)DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||
foreach (var profile in NpcProfileCatalog4229938.Profiles)
|
||
{
|
||
root.InfoMinor.InfoNpcProfile.NpcProfiles[profile.Id] = new Auto.TrustNpcInfo
|
||
{
|
||
ProfileId = profile.Id,
|
||
// MaxTrust is the per-profile ceiling from ProfileAgentProfileConfig (1..4 here).
|
||
TrustValue = profile.MaxTrust,
|
||
ActivateTime = activateTime,
|
||
|
||
// IsNew=false keeps the red dot / "new" badge off.
|
||
IsNew = false,
|
||
|
||
// Reward and target lists are left empty on purpose: filling GotRewardList would
|
||
// mark rewards as claimed and quietly eat them, and we have no state machine that
|
||
// can actually grant them.
|
||
IsMaxTrustReward = false,
|
||
};
|
||
}
|
||
|
||
// Radio songs. PlayerRadioSongsData.SongInfoDict is read directly by the radio UI, and
|
||
// GameToClientImpl.SyncAddRadioSong does nothing at all when it is missing — so this
|
||
// dictionary is the unlock, and no follow-up S2C is needed.
|
||
foreach (var song in NpcProfileCatalog4229938.Songs)
|
||
root.InfoMinor.PlayerRadioSongsData.SongInfoDict[song.Id] = true;
|
||
|
||
// City compendium (城市图鉴). Same shape as the profiles above: the client stores the whole
|
||
// struct off the login payload (PlayerInfoMinorData.lua) and BaiKeArchiveManager reads
|
||
// CityPediaStatusDict out of it, so filling it here is the unlock — no S2C needed.
|
||
//
|
||
// SyncNewCityPediaInfo(64303542) would be the incremental route but its client impl pushes
|
||
// CityPediaUnlocked onto the popup manager, so 295 entries means 295 queued popups.
|
||
var pedia = root.InfoMinor.PlayerCityPediaInfos;
|
||
foreach (var entry in CityPediaCatalog4229938.Entries)
|
||
pedia.CityPediaStatusDict[entry.Id] = CityPediaCatalog4229938.StatusUnlockedAndRead;
|
||
|
||
// Credit is the collection score shown per category. Level is the id from
|
||
// CityPediaCollectionLevelConfig (7 = Legend); CreditByType is keyed by the Type column of
|
||
// CityPediaFirstClassConfig, NOT by class id.
|
||
//
|
||
// ClaimedLevelRewards stays empty on purpose — marking levels claimed would swallow the
|
||
// ClaimCityPediaLevelReward grants without ever paying them out.
|
||
pedia.CreditInfo.Level = CityPediaCatalog4229938.MaxLevel;
|
||
foreach (var creditType in CityPediaCatalog4229938.CreditTypes)
|
||
pedia.CreditInfo.CreditByType[creditType] = CityPediaCatalog4229938.MaxLevelValue;
|
||
|
||
// Achievements (成就). Also a login-payload unlock: PlayerInfoAchievementData.lua:21 copies
|
||
// InfoAchievement.AchievementInfos into the player data, and NewAchievementMgr replays the
|
||
// whole dictionary through OnAchievementFinished in its ctor, on panel open and after every
|
||
// scene switch. There is no server-side progress machine behind these ids, so every authored
|
||
// achievement is stamped as settled at login.
|
||
//
|
||
// Status is a bit mask, not a plain enum: 1 = Completed, 2 = Rewarded (UX.Game.AchievementStatus
|
||
// from the IL2CPP dump — the Lua enum names are obfuscated in the decompile). Stamping 3 keeps
|
||
// both "finished" and "already paid" true; NewAchievementMgr.CalcuateHasRedDotNum only counts
|
||
// entries with the Completed bit clear and the Rewarded bit set, so nothing raises a red dot.
|
||
//
|
||
// AchieveTime is a double unix timestamp and must be non-zero: getPriority reads ~= 0 as "has a
|
||
// record", and SAchievementDetailStore formats it as the completion date.
|
||
foreach (var achievement in AchievementCatalog4229938.Entries)
|
||
{
|
||
root.InfoAchievement.AchievementInfos[achievement.Id] = new Auto.AchievementDetail
|
||
{
|
||
AchieveTime = activateTime,
|
||
Status = AchievementCatalog4229938.StatusSettled,
|
||
};
|
||
}
|
||
|
||
// Challenges (挑战记录). PlayerInfoAchievementData.lua:12-13 copies both dictionaries
|
||
// straight into bindData, so this fill is the unlock — no S2C needed.
|
||
//
|
||
// ⛑⛑ Shape is settled by lua/LuaGen/AutoGen/RPCDeserializeAuto.lua (Auto.Reader[1305]):
|
||
// ChallengeRecordInfo = ReadDict7Bit(u32 key -> Complex(Reader[381] ChallengeRecord))
|
||
// NewChallengeRecordInfo = ReadDict7Bit(u32 key -> Complex(Reader[594] NewChallengeRecord))
|
||
// => dictionaries keyed by challenge id, NOT lists. The only other reference in the client is
|
||
// GameToClientImpl.lua:1099 (SyncCompletedChallenge) doing table.insert on ChallengeRecordInfo,
|
||
// which appends an *integer* key after login; that is a separate S2C channel and a client-side
|
||
// quirk, it does not change what the login payload has to emit.
|
||
//
|
||
// Every authored challenge gets a row so the panel has entries, while HighestLevel /
|
||
// ReceivedRewardLevel start at 0 so nothing reads as completed or claimable. Real progress
|
||
// arrives through SetNewChallengeData (checkpoint flags + score) and lives in
|
||
// ChallengeRecordStore4229938.
|
||
foreach (var challenge in ChallengeCatalog4229938.Entries)
|
||
{
|
||
var (highest, received, isNew, best, param) = ChallengeRecordStore4229938.Snapshot(challenge.Id);
|
||
|
||
root.InfoAchievement.ChallengeRecordInfo[challenge.Id] = new Auto.ChallengeRecord
|
||
{
|
||
ChallengeId = challenge.Id,
|
||
HighestLevel = highest,
|
||
ReceivedRewardLevel = received,
|
||
CurrentIsNewRewardLevel = isNew,
|
||
BestScore = (int)best,
|
||
};
|
||
|
||
root.InfoAchievement.NewChallengeRecordInfo[challenge.Id] = new Auto.NewChallengeRecord
|
||
{
|
||
ChallengeId = challenge.Id,
|
||
HighestLevel = highest,
|
||
ReceivedRewardLevel = received,
|
||
CurrentIsNewRewardLevel = isNew,
|
||
BestScore = best,
|
||
ParamData = param,
|
||
};
|
||
}
|
||
|
||
// Urban badges (徽章). PlayerInfoMinorData.lua:94 pipes InfoMinor.Badges through
|
||
// SpiritManager.FilterBadge, which drops rows whose UrbanBadgeConfig is missing or OnlyServer
|
||
// and re-keys the table by BadgeInfo.TemplateId. Consumers then look the badge up by
|
||
// UrbanBadgeConfig id and additionally require BadgeInfo.Active before treating it as owned
|
||
// (UrbanAbilityBadgePanelStore:325, UrbanAbilityBadgeV2PanelStore:228, TalentTreeMgr:1528,
|
||
// AkxFuxiExtraArgFuncs:28) — so the key, TemplateId and Active all have to agree.
|
||
//
|
||
// Deliberately NOT using SyncUrbanBadgeInfo per badge for this: its client impl calls
|
||
// gSpiritManager:PushPopWait for every id that is not already present, i.e. 148 queued
|
||
// popups. DropSend is write-only on the client side and stays false.
|
||
foreach (var badge in BadgeCatalog4229938.Entries)
|
||
{
|
||
root.InfoMinor.Badges[badge.Id] = new Auto.BadgeInfo
|
||
{
|
||
TemplateId = badge.Id,
|
||
Active = true,
|
||
DropSend = false,
|
||
};
|
||
}
|
||
|
||
// NPC cultivation (好感度 / NPC 卡片). PlayerInfoMinorNpcCultivationData.lua:9 copies
|
||
// InfoMinor.InfoNpcCultivation into the player data, then turns every NpcCardInfos row into a
|
||
// card via gNpcInteracsUtils:CreateNpcCardInfo; NpcChatManager_Story.lua:28 reads the same
|
||
// struct for story gating.
|
||
//
|
||
// NpcCardInfo.TemplateId is the *NpcCultivationConfig* id (910500xx) — not an agent id and
|
||
// not a FightSpiritConfig id. CreateNpcCardInfo is null-tolerant on every field except
|
||
// TemplateId, so a minimal card is safe; Favor is the only field with real gameplay weight.
|
||
// The favour ladder (NpcCultivationNpcFavorLevelConfig.UnlockFavor) tops out at 1000, so
|
||
// stamping 1000 opens every favour level. Set NpcCultivationCatalog4229938.MaxFavor to 0 to
|
||
// make progression start from scratch instead.
|
||
//
|
||
// LockedCardInfos stays empty, and the voice/story unlock lists are left empty rather than
|
||
// null — they are read by the voice and story panels, which index them directly.
|
||
foreach (var cultivation in NpcCultivationCatalog4229938.Cultivations)
|
||
{
|
||
root.InfoMinor.InfoNpcCultivation.NpcCardInfos.Add(new Auto.NpcCardInfo
|
||
{
|
||
TemplateId = cultivation.Id,
|
||
Favor = NpcCultivationCatalog4229938.MaxFavor,
|
||
MaxFavorInHistory = NpcCultivationCatalog4229938.MaxFavor,
|
||
ActivateTimestamp = activateTime,
|
||
UnlockedVoice = new List<uint>(),
|
||
UnlockedStoryDict = new Dictionary<uint, uint>(),
|
||
});
|
||
}
|
||
|
||
// InteractPoint and AvailableGiftSendCount are plain counters the client reads straight off
|
||
// this struct. InteractPointMaxLimit is 5 in the client's own config, and the client re-syncs
|
||
// the gift count itself whenever a gift is sent.
|
||
root.InfoMinor.InfoNpcCultivation.InteractPoint = NpcCultivationCatalog4229938.MaxInteractPoint;
|
||
root.InfoMinor.InfoNpcCultivation.AvailableGiftSendCount = NpcCultivationCatalog4229938.GiftSendCount;
|
||
|
||
FillNpcChats(root, DateTimeOffset.UtcNow.ToUnixTimeSeconds());
|
||
|
||
// Computers (电脑解锁). PlayerInfoMinorData.lua:18 copies InfoMinor.ComputerUnlockInfo into
|
||
// bindData wholesale. The three dictionaries are presence checks, not lists of state:
|
||
// ComputerIndexerPanelStore walks the computer's own FileList and hides any id missing from
|
||
// UnlockFiles, so every configured file/email/computer id is stamped here. Compare with
|
||
// the S2C route instead: GameToClientImpl.SyncUnlockComputerFile exists, but it is written
|
||
// for one id at a time and here nothing is supposed to be locked in the first place.
|
||
//
|
||
// IsRead stays false on purpose — ComputerUtils.lua:122 uses it as the unread badge.
|
||
// DeleteFiles / DeleteEmails stay empty: they list rows the player removed from a machine,
|
||
// and the desktop reproduces the full FileList until something is actually deleted.
|
||
var computer = root.InfoMinor.ComputerUnlockInfo;
|
||
computer.ComputerInfos ??= new Dictionary<uint, Auto.ComputerDetailInfo>();
|
||
computer.UnlockFiles ??= new Dictionary<uint, Auto.ComputerFile>();
|
||
computer.UnlockEmails ??= new Dictionary<uint, Auto.ComputerEmail>();
|
||
foreach (var id in ComputerCatalog4229938.Computers)
|
||
{
|
||
computer.ComputerInfos[id] = new Auto.ComputerDetailInfo
|
||
{
|
||
CfgId = id,
|
||
FirstOpenTime = activateTime,
|
||
DeleteFiles = new List<uint>(),
|
||
DeleteEmails = new List<uint>(),
|
||
};
|
||
}
|
||
foreach (var id in ComputerCatalog4229938.Files)
|
||
{
|
||
computer.UnlockFiles[id] = new Auto.ComputerFile
|
||
{
|
||
CfgId = id,
|
||
UnlockTime = activateTime,
|
||
IsRead = false,
|
||
};
|
||
}
|
||
foreach (var id in ComputerCatalog4229938.Emails)
|
||
{
|
||
computer.UnlockEmails[id] = new Auto.ComputerEmail
|
||
{
|
||
CfgId = id,
|
||
UnlockTime = activateTime,
|
||
IsRead = false,
|
||
};
|
||
}
|
||
|
||
// Factions (阵营声望). PlayerInfoAchievementData.lua:17 copies
|
||
// InfoAchievement.FactionInfoDic straight into bindData.FactionInfoDic, and every consumer
|
||
// then reads exactly one field off those rows — DispositionLevel — with higher meaning
|
||
// friendlier:
|
||
// NPCShopPanelStore.lua:473/479 DispositionLevel -> shop attitude and FactionCtrl
|
||
// DeliverGiftsPanelStore.lua:512 level > 6 -> favour x1.4, level > 5 -> x1.2
|
||
// SwapCharacterListPanelStore.lua:543 level below the cap locks the character out
|
||
// ClientUtils.lua:614 GetFameLevel returns it, falling back to 999 when the row is absent
|
||
// So each faction is stamped with its own ceiling from FactionConfig; Disposition carries the
|
||
// matching raw value because the map/tooltip overlays read that one instead.
|
||
//
|
||
// Influence, GreetCount and InteractionCount stay at zero: they are progression counters for
|
||
// territory control and repeated NPC contact, and fabricating them would misreport play time.
|
||
// CounterAttackData must stay non-null (a null complex marker breaks deserialization).
|
||
//
|
||
// CountryReputationInfo is deliberately left empty: the only client code that touches it is
|
||
// GameToClientImpl.SyncCountryReputation, which merely writes dict[country] = reputation.
|
||
// Nothing ever reads it, so filling it would invent numbers with no consumer.
|
||
foreach (var faction in FactionCatalog4229938.Entries)
|
||
{
|
||
root.InfoAchievement.FactionInfoDic[faction.Id] = new Auto.FactionInfo
|
||
{
|
||
Disposition = faction.MaxDisposition,
|
||
DispositionLevel = faction.MaxDispositionLevel,
|
||
IsUnlock = true,
|
||
CounterAttackData = new Auto.FactionCounterAttackData(),
|
||
};
|
||
}
|
||
|
||
// Phone menu skin parts (手机皮肤). PlayerInfoSpiritData.lua:18 copies
|
||
// InfoSpirit.AvailableSkinParts into the player data. MainPhoneUtils.CheckSkinPartAvailable
|
||
// then calls LTConfig.MobileMenuSkinPartConfig.GetConfig(id) for EVERY entry and dereferences
|
||
// .Type, so an id missing from that table would throw — which is why this catalog is built
|
||
// from MobileMenuSkinPartConfig itself (112 parts) rather than from the suit table.
|
||
//
|
||
// Note the whole list is replaced whenever SyncSpiritMobileSkinPartInfo arrives; the server
|
||
// does not send it, so the login-payload fill is what the phone panel sees.
|
||
foreach (var part in NpcCultivationCatalog4229938.SkinParts)
|
||
root.InfoSpirit.AvailableSkinParts.Add(part.Id);
|
||
|
||
// Map fog is deliberately NOT published here. Publishing SceneFogMaps (one All=true entry
|
||
// per raid) was tried and verified useless in-game: the map stayed fully fogged. The
|
||
// client's fog view never reads this field — MapSystem_FogMap asks MapFogDataMgr, which is
|
||
// fed only by SyncSceneFogMapAllUnlock(sceneId, unlocked), and that key is a SCENE id
|
||
// (RaidConfig.SceneId) rather than a raid id. See GameRouter.PublishMapFogUnlockAsync.
|
||
|
||
Console.WriteLine(
|
||
$"[LOGIN-UNLOCK] apps={root.InfoSpirit.InstalledApps.Count} " +
|
||
$"systems={root.InfoMinor.PlayerInfoGuide.UnlockSystems.Count} " +
|
||
$"houses={root.InfoMinor.housesInfo.HouseInfoList.Count} " +
|
||
$"profiles={root.InfoMinor.InfoNpcProfile.NpcProfiles.Count} " +
|
||
$"radioSongs={root.InfoMinor.PlayerRadioSongsData.SongInfoDict.Count} " +
|
||
$"cityPedia={root.InfoMinor.PlayerCityPediaInfos.CityPediaStatusDict.Count} " +
|
||
$"pediaLevel={root.InfoMinor.PlayerCityPediaInfos.CreditInfo.Level} " +
|
||
$"achievements={root.InfoAchievement.AchievementInfos.Count} " +
|
||
$"challenges={root.InfoAchievement.NewChallengeRecordInfo.Count} " +
|
||
$"badges={root.InfoMinor.Badges.Count} " +
|
||
$"npcCards={root.InfoMinor.InfoNpcCultivation.NpcCardInfos.Count} " +
|
||
$"computers={(root.InfoMinor.ComputerUnlockInfo.ComputerInfos?.Count ?? 0)} " +
|
||
$"factions={root.InfoAchievement.FactionInfoDic.Count} " +
|
||
// The first few "<id>:<layer>" pairs are the fast way to confirm the off-by-one below:
|
||
// CheckTalentIsLocked needs Layer > LayerNum, so a LayerNum-1 node must read ":2".
|
||
$"careerTalents={CareerTalentNodes4229938().Count}" +
|
||
$"[{string.Join(" ", CareerTalentNodes4229938().Take(3).Select(n => $"{n.Id}:{n.Layer}"))}] " +
|
||
$"seenTrees={root.InfoMinor.GameplayTalentInfos.Values.Sum(x => x.TalentTreeRecordDict.Count)} " +
|
||
$"gameplayNodes={string.Join("/", TalentTreeRepository.GameplayIds.Select(g => $"{g}:{GameplayTalentNodes4229938(g).Count}"))} " +
|
||
$"skinParts={root.InfoSpirit.AvailableSkinParts.Count}");
|
||
|
||
SeedPlayerEconomy4229938(root, state);
|
||
SeedPlayerTalents4229938(root, state);
|
||
SeedFishing4229938(root, state);
|
||
|
||
// 搜打撤的四个包(背包 / 安全箱 / 仓库 / 武器轮盘)。客户端在这一帧里就把
|
||
// PlayerExtractionShooterInfo 读成自己的 bag store,之后再改要靠 SetItemInfo 增量推 ——
|
||
// 所以这里必须是 session 里那份状态,不能现造一张空表。
|
||
// ⛑ Bags 与 SlotGroups 互斥(Type ∈ {4,5,6} 走 SlotGroups),分流在 FillLoginInfo 里做。
|
||
// ⛑ 挂载点是 InfoMinor,不是 root —— PlayerExtractionShooterInfo 在 PlayerClientInfoMinor 里。
|
||
if (extraction is not null)
|
||
ExtractionShooterRuntime4229938.FillLoginInfo(extraction, root.InfoMinor.PlayerExtractionShooterInfo);
|
||
|
||
// Poses/emotes: the client keeps ActionItems LOCKED until they appear in
|
||
// UnlockActionItemDict (or arrive via SyncUnlockInteractionActionItems), so seed
|
||
// every authored action id from ActionItemConfig.json at login.
|
||
foreach (var actionId in ClientConfigRepository.ActionItemIds())
|
||
root.InfoMinor.PlayerInteractionActionInfo.UnlockActionItemDict[actionId] =
|
||
new Auto.PlayerInteractionActionItem { CfgId = actionId, UnlockTime = 1, ShowRedPoint = false };
|
||
|
||
return root;
|
||
}
|
||
|
||
/// <summary>
|
||
/// BBChat (AppId 27 "Message") — NPC conversation history. See
|
||
/// <see cref="NpcChatCatalog4229938"/> for why this has to be part of the login frame: every
|
||
/// conversation branch already exists in the client's own NPCChatConfig, and the server only
|
||
/// says which rows are unlocked, under which dictionary key, in which order.
|
||
/// </summary>
|
||
private static void FillNpcChats(Auto.PlayerClientInfo root, long now)
|
||
{
|
||
if (!Config.Gameplay.NpcChatEnabled)
|
||
return;
|
||
|
||
// A missing catalogue is not fatal — the app simply has nothing to show, same as before this
|
||
// existed. The reason surfaces through GameRouter.NpcChat instead, which has a logger.
|
||
if (NpcChatCatalog4229938.LoadError is not null)
|
||
return;
|
||
|
||
// ChatType of the thread's head row (dump.cs:1326930). 1 Normal / 2 Invite are idle-app
|
||
// content; 3 Dialog / 4 Fake are handed over by world interactions and only ride along when
|
||
// gameplay.npcChatStoryThreads is on.
|
||
var includeStory = Config.Gameplay.NpcChatStoryThreads;
|
||
|
||
FillNpcChatList(root.InfoMinor.InfoNpcCultivation.NpcChats, NpcChatCatalog4229938.Npcs, includeStory, now);
|
||
foreach (var group in GroupsOf(NpcChatCatalog4229938.Groups, includeStory, now))
|
||
root.InfoMinor.InfoNpcCultivation.NpcGroupChats.Add(group);
|
||
}
|
||
|
||
/// subset — which rows are unlocked, under which dictionary key, in which order.
|
||
///
|
||
/// <c>InviteChatList</c> / <c>DialogChatListDict</c> stay empty on purpose: they are what the
|
||
/// player can answer right now, so filling them would auto-open every conversation on login.
|
||
/// </summary>
|
||
private static void FillNpcChatList(
|
||
List<Auto.ClientNpcChatData> destination,
|
||
IReadOnlyList<NpcChatOwner4229938> owners,
|
||
bool includeStory,
|
||
long now)
|
||
{
|
||
var threadIndex = 0;
|
||
foreach (var owner in owners)
|
||
{
|
||
Auto.ClientNpcChatData? current = null;
|
||
|
||
foreach (var thread in owner.Threads)
|
||
{
|
||
if (thread.Type >= 3 && !includeStory)
|
||
continue;
|
||
|
||
var data = current ??= EnsureOwner(destination, owner.Id);
|
||
// Key is the row's AsNpcCultivation; GetNpcChatList looks up both {0, currentNpcId},
|
||
// so a row keyed 0 is readable no matter who the player is playing as.
|
||
if (!data.NpcChatListDict.TryGetValue(thread.Key, out var list))
|
||
{
|
||
list = new Auto.ChatInfoList();
|
||
data.NpcChatListDict[thread.Key] = list;
|
||
}
|
||
|
||
AppendThread(list.ChatList, thread, owner.Id, threadIndex, now);
|
||
threadIndex++;
|
||
}
|
||
}
|
||
|
||
static Auto.ClientNpcChatData EnsureOwner(List<Auto.ClientNpcChatData> destination, uint templateId)
|
||
{
|
||
foreach (var existing in destination)
|
||
if (existing.TemplateId == templateId)
|
||
return existing;
|
||
|
||
var created = new Auto.ClientNpcChatData { TemplateId = templateId };
|
||
destination.Add(created);
|
||
return created;
|
||
}
|
||
}
|
||
|
||
private static IEnumerable<Auto.ClientNpcGroupChatData> GroupsOf(
|
||
IReadOnlyList<NpcChatOwner4229938> owners,
|
||
bool includeStory,
|
||
long now)
|
||
{
|
||
var threadIndex = 0;
|
||
foreach (var owner in owners)
|
||
{
|
||
var data = new Auto.ClientNpcGroupChatData { TemplateId = owner.Id };
|
||
foreach (var member in owner.Members)
|
||
data.Members.Add(member);
|
||
|
||
var moved = false;
|
||
foreach (var thread in owner.Threads)
|
||
{
|
||
if (thread.Type >= 3 && !includeStory)
|
||
continue;
|
||
if (!data.NpcChatListDict.TryGetValue(thread.Key, out var list))
|
||
{
|
||
list = new Auto.ChatInfoList();
|
||
data.NpcChatListDict[thread.Key] = list;
|
||
}
|
||
AppendThread(list.ChatList, thread, owner.Id, threadIndex, now);
|
||
moved = true;
|
||
threadIndex++;
|
||
}
|
||
|
||
if (moved)
|
||
yield return data;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Appends one thread as <c>NpcChatItem</c> rows. Timestamps run forward inside the thread and
|
||
/// end a few minutes ago, so a conversation reads oldest→newest and its last message is never
|
||
/// in the future; every thread additionally gets an hour-wide offset so two NPCs do not look
|
||
/// like they wrote at the same second.
|
||
/// </summary>
|
||
private static void AppendThread(
|
||
List<Auto.NpcChatItem> destination,
|
||
NpcChatThread4229938 thread,
|
||
uint belongNpc,
|
||
int threadIndex,
|
||
long now)
|
||
{
|
||
const long gapSeconds = 120;
|
||
var start = now - threadIndex * 3600L - (long)thread.Items.Count * gapSeconds - gapSeconds;
|
||
|
||
for (var i = 0; i < thread.Items.Count; i++)
|
||
{
|
||
var node = thread.Items[i];
|
||
destination.Add(new Auto.NpcChatItem
|
||
{
|
||
Timestamp = (uint)Math.Clamp(start + i * gapSeconds, 0, uint.MaxValue),
|
||
ChatId = node.Id,
|
||
NextChatId = node.NextId,
|
||
// Emojis / hyperlinks ride in here; every published row is plain text from the client
|
||
// table, so null is honest and keeps the row at 22 bytes.
|
||
ChatContext = null,
|
||
// Published history is already read — otherwise every thread lights the app's red dot.
|
||
IsRead = true,
|
||
BelongNpc = belongNpc,
|
||
ChatActionTriggeredTime = 0,
|
||
});
|
||
}
|
||
}
|
||
|
||
internal static Auto.EnterSceneInfo EnterScene(uint switchShowId)
|
||
{
|
||
var placement = ClientConfigRepository.EntryPlacement(switchShowId);
|
||
return EnterScene(
|
||
Config.World.RaidId,
|
||
Config.World.SceneInstanceId,
|
||
Config.World.UniverseId,
|
||
Config.Player.InitialUnitId,
|
||
Config.Player.InitialSpiritTemplateId,
|
||
placement.Position,
|
||
placement.Facing,
|
||
switchShowId,
|
||
isSwitchSpiritShow: true);
|
||
}
|
||
|
||
internal static Auto.EnterSceneInfo EnterScene(
|
||
uint raidId,
|
||
ulong instanceId,
|
||
uint universeId,
|
||
ulong unitId,
|
||
uint templateId,
|
||
Vec3 position,
|
||
float facing,
|
||
uint switchShowId = 0,
|
||
bool isSwitchSpiritShow = false,
|
||
uint sectorControlId = 0)
|
||
=> new()
|
||
{
|
||
PlayerSessionId = Config.Player.Pid,
|
||
RaidId = raidId,
|
||
InstanceId = instanceId,
|
||
Position = Vector(position),
|
||
Facing = facing,
|
||
SceneSpoonNames = [],
|
||
SceneSpoonMd5s = [],
|
||
Spirits =
|
||
[
|
||
new Auto.SpiritInitData
|
||
{
|
||
Id = unitId,
|
||
TemplateId = templateId,
|
||
IsActive = true,
|
||
// Proven v7 single-CreateHero entry contract: initial actor identity only.
|
||
// Weapon/fashion/runtime presentation is hydrated only after world-entry finalization.
|
||
WeaponTemplateId = 0,
|
||
WeaponSkinId = 0,
|
||
}
|
||
],
|
||
GridInfo = new Auto.ServerSimpleGridInfo
|
||
{
|
||
MinX = 0,
|
||
MinZ = 0,
|
||
MaxX = 300000,
|
||
MaxZ = 300000,
|
||
},
|
||
MatchGameId = 0,
|
||
SwitchShowId = switchShowId,
|
||
IsSwitchSpiritShow = isSwitchSpiritShow,
|
||
UniverseId = universeId,
|
||
SectorControlId = sectorControlId,
|
||
LoadingType = new Auto.LoadingTypeInfo
|
||
{
|
||
Type = 0,
|
||
Members = [],
|
||
Ripple = false,
|
||
},
|
||
LinkSimpleInfo = new Auto.LinkSimpleInfo
|
||
{
|
||
Id = 0,
|
||
Mode = 0,
|
||
DeviceLevel = 1,
|
||
Tag = 0,
|
||
PublicEventId = 0,
|
||
LastPublicEventTime = 0,
|
||
HostPid = 0,
|
||
},
|
||
DataLayerIndices = [],
|
||
Seamless = false,
|
||
TombIslandIds = [],
|
||
};
|
||
|
||
internal static Auto.RaidBattleUnitSpirit ExistingUnitProjection(uint switchShowId)
|
||
{
|
||
var placement = ClientConfigRepository.EntryPlacement(switchShowId);
|
||
return ExistingUnitProjection(
|
||
Config.Player.InitialUnitId,
|
||
Config.Player.InitialSpiritTemplateId,
|
||
placement.Position,
|
||
placement.Facing);
|
||
}
|
||
|
||
internal static Auto.RaidBattleUnitSpirit ExistingUnitProjection(
|
||
ulong unitId,
|
||
uint templateId,
|
||
Vec3 position,
|
||
float facing,
|
||
WorldEntryState? state = null)
|
||
{
|
||
// Initial world-entry uses the same BaseUnit created by native CreateHero. ManagedPid is
|
||
// presentation/remote-management metadata on this AOI projection and must stay zero; the
|
||
// actual local authority is established by SyncManagedLogicAgent(unitId, playerPid, 0).
|
||
// The worn outfit travels with the projection because this is what materializes the actor —
|
||
// sending the authored defaults here dress the model before any later sync can correct it.
|
||
var projection = CharacterUnitProjection(
|
||
unitId, templateId, position, facing, WornFashionIds4229938(templateId, state), state);
|
||
projection.ManagedPid = 0;
|
||
return projection;
|
||
}
|
||
|
||
internal static Auto.RaidBattleUnitSpirit CharacterUnitProjection(
|
||
uint templateId,
|
||
Vec3 position,
|
||
float facing,
|
||
WorldEntryState? state = null)
|
||
{
|
||
// ⛑ 必须用 ControlCharacter 而不是 Characters():科斯机甲(15022030)不在花名册里
|
||
// (隐藏可控原型),但切人主流程建单位这一步照样要能解析它 —— 2026-09-26 04:39 实测
|
||
// 就是这里抛 "Character 15022030 is not present in FightSpiritConfig roster." 把整个
|
||
// 接管打断在发 SyncRaidBattleUnitSpirit 之前。
|
||
var character = ClientConfigRepository.ControlCharacter(templateId)
|
||
?? throw new InvalidOperationException($"Character {templateId} is not present in FightSpiritConfig roster.");
|
||
return CharacterUnitProjection(
|
||
character.UnitId,
|
||
character.TemplateId,
|
||
position,
|
||
facing,
|
||
ClientConfigRepository.DefaultFashionIds(templateId),
|
||
state);
|
||
}
|
||
|
||
/// <summary>
|
||
/// The outfit one character should be wearing. When the player picked an outfit in an earlier
|
||
/// session it is replayed from the progress snapshot; otherwise the authored defaults apply.
|
||
///
|
||
/// This is the authoritative write for "what the model wears": the wardrobe panel never edits
|
||
/// the value locally, it only sends AskSetSpiritFashionsWithSource and waits for this packet.
|
||
/// Publishing defaults after a restore therefore silently undresses the character.
|
||
/// </summary>
|
||
internal static GameMethods.SyncSetSpiritFashions CharacterFashions(uint templateId, WorldEntryState? state = null)
|
||
{
|
||
var worn = state?.FashionBySpirit.GetValueOrDefault(templateId);
|
||
var wear = worn is { FashionIds.Count: > 0 }
|
||
? WearFashionsForGameMethods(
|
||
worn.FashionIds,
|
||
functionSuitId: worn.FunctionSuitId,
|
||
source: worn.Source,
|
||
sourceId: worn.SourceId,
|
||
isTryWear: worn.IsTryWear,
|
||
hiddenParts: worn.HiddenParts)
|
||
: WearFashionsForGameMethods(ClientConfigRepository.DefaultFashionIds(templateId));
|
||
|
||
return new GameMethods.SyncSetSpiritFashions
|
||
{
|
||
spiritId = templateId,
|
||
source = worn?.Source ?? 0,
|
||
spiritWearFashionsInfo = wear,
|
||
};
|
||
}
|
||
|
||
internal static GameMethods.AskAllSpiritPanelDataResult MinimalAllSpiritPanelData4229938()
|
||
=> new()
|
||
{
|
||
spirits = ClientConfigRepository.Characters().Select(character => new Auto.SpiritPanelData
|
||
{
|
||
FightSpiritId = character.TemplateId,
|
||
MaxHp = 1f,
|
||
Dam = 0f,
|
||
DefDeduct = 0f,
|
||
}).ToList()
|
||
};
|
||
|
||
/// <summary>
|
||
/// ⛑⛑⛑ 主角「换皮」:把 <b>已存在的主角单位</b>(默认 <c>Config.Player.InitialUnitId</c>)
|
||
/// 直接换成另一个 templateId,不新建单位。
|
||
///
|
||
/// 2026-09-25 02:0x 实测:客户端 <c>IsMe=True</c> 只在登录时 native <c>CreateHero</c>
|
||
/// 建出来的那个单位上出现过(<c>InitModules 100000000000 IsMe True</c> + <c>[LB] 进入场景 创建主角</c>);
|
||
/// 之后 5 次切换,无论 <c>ExistingUnitProjection</c> 还是 <c>CharacterUnitProjection</c>、
|
||
/// 无论 ManagedPid 填 0 还是 PlayerPid,客户端一律 <c>IsMe False</c> + <c>[LB] 创建角色</c>。
|
||
/// ⇒ 结论:服务端推 projection 建不出主角,只能让客户端已有的主角单位换 template。
|
||
/// </summary>
|
||
internal static Auto.RaidBattleUnitSpirit CharacterUnitProjection(
|
||
ulong unitId,
|
||
uint templateId,
|
||
Vec3 position,
|
||
float facing,
|
||
WorldEntryState? state = null)
|
||
=> CharacterUnitProjection(unitId, templateId, position, facing,
|
||
ClientConfigRepository.DefaultFashionIds(templateId), state);
|
||
|
||
internal static Auto.RaidBattleUnitSpirit CharacterUnitProjection(
|
||
ulong unitId,
|
||
uint templateId,
|
||
Vec3 position,
|
||
float facing,
|
||
IReadOnlyList<uint> fashionIds,
|
||
WorldEntryState? state = null)
|
||
{
|
||
return new Auto.RaidBattleUnitSpirit
|
||
{
|
||
SpiritWearFashionsInfo = new Auto.OtherPlayerSpiritWearFashionsInfo
|
||
{
|
||
// Applied dye per worn fashion. A spirit switch re-materializes the actor from this
|
||
// projection, so without the replay the model comes back in its authored colours.
|
||
// Empty for fashions the dye handlers never saw.
|
||
WearFashionColoringInfoDict = FashionColoringStore.GetAppliedColorings(state, fashionIds)
|
||
.ToDictionary(kv => kv.Key, kv => new Auto.FashionColoringInfo
|
||
{
|
||
ColoringType2ColorIdDict = kv.Value,
|
||
}),
|
||
WearFashionInfoList = fashionIds.Select(id => new Auto.WearFashionInfo { FashionId = id }).ToList(),
|
||
WearFashionEditInfoList = [],
|
||
HiddenParts = 0,
|
||
EditedHiddenParts = 0,
|
||
},
|
||
Begging = false,
|
||
IsBot = false,
|
||
Id = unitId,
|
||
TemplateId = templateId,
|
||
Position = Vector(position),
|
||
FacingDirection = facing,
|
||
OwnerId = Config.Player.Pid,
|
||
// Build 4229938 uses ManagedPid while constructing the BaseUnit to decide whether
|
||
// the projected spirit belongs to the local player. Leaving this at zero creates
|
||
// switch targets as IsMe=false and the stock handoff never completes.
|
||
ManagedPid = Config.Player.Pid,
|
||
MoveId = 0,
|
||
NavTags = 0,
|
||
GroundData = new Auto.MoveActionGroundData
|
||
{
|
||
MoveGroundType = 0,
|
||
MoveGroundId = 0,
|
||
MetaInfo = 0,
|
||
LocalPos = new Auto.UXVector3(),
|
||
},
|
||
};
|
||
}
|
||
|
||
private static Auto.SpiritInfo MinimalActorCharacterSpirit4229938(
|
||
CharacterCatalogEntry character,
|
||
WorldEntryState? state)
|
||
{
|
||
var loadout = CombatCatalogRepository.Loadout(character.TemplateId);
|
||
var spirit = new Auto.SpiritInfo
|
||
{
|
||
Id = character.UnitId,
|
||
TemplateId = character.TemplateId,
|
||
PossessTime = 1,
|
||
HpRate = 1f,
|
||
// ⛑️ ROLLBACK BASELINE — round 1 publishes Jobless here. See
|
||
// SpiritJobManager.lua:38 GetCurSpiritJobId() / :51 GetCurSpiritJobClassId(), which both
|
||
// read this very struct; that is what the character menu renders and what
|
||
// MainPhoneUtils.CheckJobCanShow() classifies the phone apps against.
|
||
//
|
||
// Publishing the real top job (TopUrbanJob4229938) did not unlock the hacker entry or the
|
||
// EonBug app, so it is parked. Two known obstacles if this is revisited:
|
||
// 1. CheckJobCanShow requires #JobClassIdList > 0 AND the job class to be present in
|
||
// GetCurSpiritAvailableJobIdList(), and that list only collects Jobless rows unless
|
||
// the caller passes containJobless — so route is dead independent of what we send.
|
||
// 2. The runtime top-up (GameToClientImpl.SyncSpiritJobInfo) can never land: it routes
|
||
// through SpiritManager.lua:637 `local tid = self.Id_to_Tid(self, spiritId)` and
|
||
// Id_to_Tid (SpiritManager.lua:513) returns 0 when the spirit IS found
|
||
// (`if spirit ~= nil then return 0 end`), so tid is always 0, spiritViewDatas[0] is
|
||
// nil, and the packet is dropped silently with no client-side error.
|
||
// The roster therefore stays the only source the client trusts — and stays Jobless until
|
||
// those two are solved.
|
||
SpiritJobInfo = new Auto.SpiritJobInfo { CurrentJob = JoblessUrbanJob4229938 },
|
||
EverSwitched = character.TemplateId != Config.Player.InitialSpiritTemplateId,
|
||
Blocked = false,
|
||
LastUsedWeaponInstanceId = loadout.DefaultWeapon.InstanceId,
|
||
// The phone wallpaper the character is wearing. MainPhoneUtils.GetCurrentSpiritSkinInfo()
|
||
// reads exactly this struct, and the phone panel believes whatever it finds here — a
|
||
// zeroed struct makes every "apply" look successful and change nothing.
|
||
MobileSkinInfo = WornPhoneSkin4229938(character.TemplateId, state),
|
||
// Career talents (职业天赋) published as already-activated nodes. Reusing the same
|
||
// SpiritTalentInfo shape as GameplayTalentInfos, which is the one talent dictionary the
|
||
// 4229938 login frame demonstrably parses. See TalentTreeSettings.SeedCareerTalents.
|
||
TalentInfo = CareerTalentInfo4229938(character.TemplateId),
|
||
// The persisted wheel, not the authored loadout: SeedPlayerEconomy-level armory and the
|
||
// character's own slot view must agree with the restored LastUsedWeaponInstanceId below,
|
||
// otherwise the client's preload cannot resolve the equipped weapon.
|
||
WeaponSlots = RestoredWheel4229938(character.TemplateId, state)
|
||
.Select(weapon => weapon is null ? null! : WeaponData(weapon))
|
||
.ToList(),
|
||
};
|
||
|
||
// Replay the weapon this account last equipped. PublishMinimalActorPresentation4229938
|
||
// republishes it via SyncSpiritLastUsedWeapon, but seeding the login payload keeps the
|
||
// character from briefly showing the authored default first.
|
||
var savedWeapon = state?.LastWeaponBySpirit.GetValueOrDefault(character.TemplateId) ?? 0;
|
||
if (savedWeapon != 0 &&
|
||
(CombatCatalogRepository.Weapon(character.TemplateId, savedWeapon) is not null ||
|
||
CombatCatalogRepository.Weapon(savedWeapon) is not null))
|
||
{
|
||
spirit.LastUsedWeaponInstanceId = savedWeapon;
|
||
}
|
||
|
||
// Fight-style metadata is required to interpret the equipped weapon's authored action set.
|
||
// Do not seed the old server's synthetic SpiritAbilities/urban job/combat graph here.
|
||
foreach (var (typeId, styleId) in loadout.StyleByType)
|
||
spirit.SpiritFightStyle.FightStyleInfo[typeId] = styleId;
|
||
return spirit;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 科斯机甲的 <c>SyncPlayerAddNewSpirit</c>(64691088) 载荷(移植捐赠端 7k-v0.5 RuntimePayloadFactory.cs:343-344)。
|
||
///
|
||
/// 科斯不在登录花名册里 —— FightSpiritConfig 15022030 = "特摄战原型-科斯",IsPublish=false /
|
||
/// CanJoin=false / Invisible=true,所以客户端 SpiritManager 手上没有它的 SpiritViewData。
|
||
/// 不先补这条 entry,切过去时 <c>Id_to_Tid</c> 查不到对应 tid,包会被静默丢掉(这也是
|
||
/// GameRouter.Profiles.cs 里 ROLLBACK BASELINE 注释记的同一个坑)。
|
||
///
|
||
/// ⛑ 刻意复用本服已在用的 <see cref="MinimalActorCharacterSpirit4229938"/>(每个 roster 角色都靠它
|
||
/// 下发),而不是另写一份精简结构:它的 WeaponSlots 走 <see cref="RestoredWheel4229938"/>,
|
||
/// 科斯没有存档轮盘 ⇒ 回落到 <c>CombatCatalogRepository.TryLoadout</c> 那份孤立装配(slot0 = 科斯冲锋枪)。
|
||
/// </summary>
|
||
internal static GameMethods.SyncPlayerAddNewSpirit KesiSpirit4229938(WorldEntryState? state)
|
||
=> new()
|
||
{
|
||
spirit = new() { SpiritInfo = MinimalActorCharacterSpirit4229938(ClientConfigRepository.Kesi, state) },
|
||
reason = 0,
|
||
};
|
||
|
||
/// <summary>
|
||
/// The urban-job record for one controllable character, read from
|
||
/// <c>FightSpiritConfig.DefaultUrbanJob</c>. Data-driven on purpose: no spirit id is
|
||
/// hard-coded here, so an authored job on any character shows up without touching this file.
|
||
/// </summary>
|
||
internal static Auto.SpiritJobInfo SpiritJobInfo4229938(uint templateId)
|
||
{
|
||
// Characters() only holds the playable roster; an unknown template falls back to Jobless.
|
||
// 科斯机甲(15022030)不在花名册里,走 ControlCharacter 才能拿到它的 DefaultUrbanJob=100。
|
||
var character = ClientConfigRepository.ControlCharacter(templateId);
|
||
return DefaultSpiritJobInfo4229938(templateId, character?.DefaultUrbanJob ?? 0);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Every urban job the character will actually be published with — authored rank, its promotion
|
||
/// chain, any per-character override and (in "multi") every app-gated class representative. This
|
||
/// is the server's own view of "which careers does this character own", so it is the input for
|
||
/// anything else that keys off a career (badge-derived capability buffs, chiefly) rather than a
|
||
/// second, divergent job lookup.
|
||
/// </summary>
|
||
internal static IReadOnlyList<uint> PublishedUrbanJobIds4229938(uint templateId)
|
||
=> SpiritJobInfo4229938(templateId).AvailableJobs.Keys.OrderBy(x => x).ToArray();
|
||
|
||
/// <summary>
|
||
/// <c>UrbanJobJobClassConfig</c> 11300003 "NCCA Officer" — the police career class. The client's
|
||
/// <c>PoliceJobManager.CheckIsPoliceJob</c> compares <c>UrbanJobConfig.JobClass</c> against
|
||
/// <c>UrbanJobJobClassConfig.Police</c>; the career has three ranks, 301 / 302 / 303.
|
||
/// </summary>
|
||
internal const uint PoliceJobClassId4229938 = 11_300_003;
|
||
|
||
/// <summary>
|
||
/// True when any career the character is published with belongs to the police class. Data-driven
|
||
/// on <see cref="PublishedUrbanJobIds4229938"/> (so <c>urbanJobOverrides</c> works too) rather
|
||
/// than on a hard-coded spirit or job id — Richie (15021021) is the only authored one today.
|
||
/// </summary>
|
||
internal static bool IsPoliceCharacter4229938(uint templateId)
|
||
=> PublishedUrbanJobIds4229938(templateId)
|
||
.Any(jobId => ClientConfigRepository.UrbanJob(jobId)?.JobClass == PoliceJobClassId4229938);
|
||
|
||
/// <summary>
|
||
/// 层 2a / T4-9: the payload behind <c>SyncSpiritPoliceJobInfo</c> (64349458). Everything is
|
||
/// derived from authored tables via <see cref="PoliceCatalog4229938"/> — no dispatch id, fine id
|
||
/// or suspect id is spelled out here.
|
||
///
|
||
/// The panel is a pure consumer, so "the panel is empty" always means "the payload was empty".
|
||
/// Three independent lists feed it (all read by PolicePanelManager.lua:156
|
||
/// OnSpiritPoliceJobInfoSync):
|
||
///
|
||
/// DispatchInfos -> the chips on the Duty Terminal homepage. GetPoliceDispatchItemList (:848)
|
||
/// walks dispatchInfo[spiritId] and keeps entries whose id survives
|
||
/// CheckDispatchEnable (config exists AND ShowInApp). An empty dict is a
|
||
/// blank homepage. NextAvailableTime=0 makes GetDispatchCD negative => the
|
||
/// chips render clickable instead of counting down.
|
||
///
|
||
/// CaseInfos -> the case tab. A case row is not a config table (the real client builds it
|
||
/// from people it arrests), so rows are composed from real tables:
|
||
/// NpcId <- PoliceSuspectCatalog (must exist in AgentConfig, else
|
||
/// OnRenderCaseItem returns early and draws nothing),
|
||
/// Fines <- PoliceFineCatalog (IsShow rows; GetFineList/GetResultList
|
||
/// render their Title and Factor),
|
||
/// Drops <- PoliceConfig.static.ArrestRewardDrop.
|
||
/// State=CanInterrogate(0) so the panel shows the interrogation entry rather
|
||
/// than pretending the case is settled.
|
||
///
|
||
/// ServiceData -> the homepage tally (RenderSummaryTemplate :906 reads
|
||
/// DutyBasicInfo.ServiceData / WeeklyServiceData; a missing one degrades to
|
||
/// zeros rather than crashing).
|
||
///
|
||
/// Left empty on purpose: <c>ViolationInfos</c> (a live violation arms the suspension countdown
|
||
/// and the force-quit popup — not something a private server wants to fake). It is legal as an
|
||
/// empty container.
|
||
///
|
||
/// <c>PoliceFakeFileInfo</c> used to be empty for the same "too much state machine" reason, but
|
||
/// that is why the Mimic Database archive renders 27 locked rows: nothing needs to be simulated,
|
||
/// they only need a <c>State</c> (= <see cref="PoliceFakeFileStateUnlock"/>). See step 4 below,
|
||
/// gated by <c>gameplay.policeFakeFilesUnlock</c>.
|
||
/// </summary>
|
||
internal static GameMethods.SpiritPoliceJobInfo4229938 PoliceJobInfo4229938(uint templateId)
|
||
{
|
||
var gameplay = Config.Gameplay;
|
||
var now = (uint)DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||
var result = new GameMethods.SpiritPoliceJobInfo4229938();
|
||
|
||
// Known-good skeleton fallback — byte-identical to the frame the empty wire test pins.
|
||
if (string.Equals(gameplay.PolicePayloadMode, "empty", StringComparison.OrdinalIgnoreCase))
|
||
return result;
|
||
|
||
// ---- 1. duty-terminal dispatch chips ------------------------------------------------
|
||
if (gameplay.PoliceDispatchEnabled)
|
||
{
|
||
foreach (var dispatch in PoliceCatalog4229938.InAppDispatches)
|
||
{
|
||
result.DispatchInfos[dispatch.Id] = new GameMethods.PoliceDispatchInfo4229938
|
||
{
|
||
Id = dispatch.Id,
|
||
NextAvailableTime = 0,
|
||
IsTemp = false,
|
||
TempEventId = 0,
|
||
TodayArrestSupportTimes = 0,
|
||
};
|
||
}
|
||
}
|
||
|
||
// ---- 2. fabricated case history -----------------------------------------------------
|
||
var suspects = PoliceCatalog4229938.Suspects;
|
||
var fines = PoliceCatalog4229938.RenderableFines;
|
||
var arrestDrop = PoliceCatalog4229938.ArrestRewardDrop;
|
||
var wanted = Math.Min(gameplay.PoliceCaseCount, PoliceCatalog4229938.CaseUpperLimit);
|
||
var caseCount = (uint)Math.Min(wanted, (uint)suspects.Count);
|
||
|
||
for (var i = 0u; i < caseCount; i++)
|
||
{
|
||
// Rotate through the charge table rather than repeating one row, so the list does not
|
||
// look like the same crime N times.
|
||
var charges = new List<uint>();
|
||
if (fines.Count > 0)
|
||
{
|
||
var take = 1 + (int)(i % 3);
|
||
for (var k = 0; k < take; k++)
|
||
charges.Add(fines[(int)((i * 3 + k) % fines.Count)].Id);
|
||
}
|
||
|
||
var drops = arrestDrop == 0 ? new List<uint>() : new List<uint> { arrestDrop };
|
||
|
||
result.CaseInfos.Add(new GameMethods.PoliceCaseInfo4229938
|
||
{
|
||
Id = 9_000_000ul + i,
|
||
NpcId = suspects[(int)i].Id,
|
||
Time = now - (i + 1) * 86_400u,
|
||
Fines = charges,
|
||
Sentence = 0,
|
||
Drops = drops,
|
||
BonusDrops = new List<uint>(drops),
|
||
RewardTaken = false,
|
||
IsFakePerson = false,
|
||
NpcImprisonStatus = 0,
|
||
SourceType = 0,
|
||
HasUnlockClue = false,
|
||
InterrogationInfo = new GameMethods.PoliceInterrogationInfo4229938
|
||
{
|
||
// CanInterrogate (UX.Game.PoliceCaseInterrogationState 0).
|
||
State = 0,
|
||
NpcInfo = new GameMethods.PoliceInterrogationNpcInfo4229938
|
||
{
|
||
RPSInfo = new GameMethods.PoliceInterrogationRpsInfo4229938
|
||
{
|
||
Cards = new GameMethods.PoliceInterrogationCards4229938(),
|
||
},
|
||
},
|
||
},
|
||
});
|
||
}
|
||
|
||
// ---- 3. homepage tally --------------------------------------------------------------
|
||
var tally = gameplay.PoliceServiceTally != 0
|
||
? gameplay.PoliceServiceTally
|
||
: (uint)(result.DispatchInfos.Count + result.CaseInfos.Count);
|
||
|
||
result.DutyBasicInfo = new GameMethods.PoliceDutyBasicInfo4229938
|
||
{
|
||
ServiceData = new GameMethods.PoliceServiceData4229938
|
||
{
|
||
DispatchTimes = tally,
|
||
PatrolTimes = tally,
|
||
ArrestTimes = tally,
|
||
FineCount = tally,
|
||
CarFineCount = 0,
|
||
LastUpdateTime = now,
|
||
},
|
||
WeeklyServiceData = new GameMethods.PoliceServiceData4229938
|
||
{
|
||
DispatchTimes = tally,
|
||
PatrolTimes = tally,
|
||
ArrestTimes = tally,
|
||
FineCount = tally,
|
||
CarFineCount = 0,
|
||
LastUpdateTime = now,
|
||
},
|
||
};
|
||
|
||
// ---- 4. Mimic Database archive (伪人档案) ------------------------------------------
|
||
// Every entry stays locked until the server hands it a state: PolicePanelManager only ever
|
||
// sees what SyncSpiritPoliceJobInfo carries, and an empty UnlockFileInfoDict means
|
||
// CheckFakeInfoState(id, Unlock) answers None for all 27 rows. State 8 is
|
||
// PoliceFakeFileState.Unlock — the only value the archive renders as "unlocked"; the rows
|
||
// are authored per character (all 27 belong to Richie 15021021).
|
||
if (gameplay.PoliceFakeFilesUnlock)
|
||
{
|
||
foreach (var file in PoliceCatalog4229938.FakeFilesFor(templateId))
|
||
{
|
||
result.PoliceFakeFileInfo.UnlockFileInfoDict[file.Id] =
|
||
new GameMethods.PoliceFakeFileState4229938
|
||
{
|
||
State = PoliceFakeFileStateUnlock,
|
||
InterrogationTime = 0,
|
||
};
|
||
}
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
/// <summary>PoliceFakeFileState.Unlock — the archive entry is revealed ( PoliceFakeFileState enum).</summary>
|
||
internal const byte PoliceFakeFileStateUnlock = 8;
|
||
/// Hacker chain), i.e. the rank a character stands on once its promotion chain is finished.
|
||
/// Returns <paramref name="jobId"/> itself when the id is unknown or has no authored chain.
|
||
/// </summary>
|
||
private static uint TopUrbanJob4229938(uint jobId)
|
||
{
|
||
if (jobId == 0)
|
||
return JoblessUrbanJob4229938;
|
||
|
||
var chain = ClientConfigRepository.UrbanJobChain(jobId);
|
||
return chain.Count == 0 ? jobId : chain[^1].Id;
|
||
}
|
||
|
||
/// <summary>
|
||
/// The ranks to publish for one job id: either just that rank, or — when promotion is fast-
|
||
/// forwarded (<c>urbanJobRank = "top"</c>) — every rank of the career up to the top one, which is
|
||
/// what the dictionary looks like after the player has walked the whole PreJob chain.
|
||
/// Ordered low-to-high exactly like <see cref="ClientConfigRepository.UrbanJobChain"/>, so the
|
||
/// serialized frame stays deterministic.
|
||
/// ⛑️ Only app gating and the career UI read this dictionary, and both key off the shared
|
||
/// <c>JobClass</c>, so extra ranks add nothing a single rank would not already unlock — they just
|
||
/// make "已晋升到最高段位" true.
|
||
/// </summary>
|
||
private static IReadOnlyList<uint> PublishedRanks4229938(uint jobId, bool promoteTop)
|
||
{
|
||
if (!promoteTop)
|
||
return new[] { jobId };
|
||
|
||
var chain = ClientConfigRepository.UrbanJobChain(jobId);
|
||
return chain.Count == 0 ? new[] { jobId } : chain.Select(x => x.Id).ToArray();
|
||
}
|
||
|
||
/// <summary>
|
||
/// One representative rank per job <b>class</b> that gates a phone app — the whole point of the
|
||
/// "multi" grant mode. A 2026-09-20 sweep of <c>MobileMenuSGuiConfig</c> (57 phone apps) found
|
||
/// only 9 with a non-empty <c>JobClassIdList</c>; the ids below cover all of them:
|
||
/// 4=VWT(11300002) · 301=Duty Terminal <i>and</i> Mimic Database(11300003) · 401=EonBug(11300004) ·
|
||
/// 501=Cat Express(11300005) · 801=Chaos Tide(11300009) · 1201=Top Cleaning(11300014) ·
|
||
/// 1901=茶农(11300019) · 2000=重霄食谱(11300020).
|
||
/// Sorted on purpose so the serialized frame is deterministic. The last three ship as
|
||
/// <c>StartVersion V99.0.0</c> and may be filtered by the native config loader; registering them
|
||
/// costs nothing either way.
|
||
/// </summary>
|
||
private static readonly uint[] AppGatedUrbanJobIds4229938 = { 4, 301, 401, 501, 801, 1201, 1901, 2000 };
|
||
|
||
/// <summary>
|
||
/// 单个角色的「当前职业」覆盖(<c>gameplay.urbanJobCurrentOverrides</c>,key 支持逗号分隔多个
|
||
/// template id)。返回 0 = 没配。
|
||
///
|
||
/// 用途见 <see cref="GameplaySettings.UrbanJobCurrentOverrides"/>:白靖(15021039)作者给的是
|
||
/// 100「无业」,但原生斯安威斯坦属于赛车手职业 ⇒ 需要把 CurrentJob 顶成 4(初级赛车手)。
|
||
/// </summary>
|
||
private static uint CurrentUrbanJobOverride4229938(GameplaySettings gameplay, uint templateId)
|
||
{
|
||
var map = gameplay.UrbanJobCurrentOverrides;
|
||
if (map is null || map.Count == 0)
|
||
return 0;
|
||
|
||
foreach (var (key, jobId) in map)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(key) || jobId == 0)
|
||
continue;
|
||
foreach (var part in key.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
|
||
if (uint.TryParse(part, out var id) && id == templateId)
|
||
return jobId;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
private static Auto.SpiritJobInfo DefaultSpiritJobInfo4229938(uint templateId, uint jobId)
|
||
{
|
||
// 0 is not a valid UrbanJobConfig id — never hand it to the client.
|
||
if (jobId == 0)
|
||
jobId = JoblessUrbanJob4229938;
|
||
|
||
var gameplay = PrivateServerConfigStore.Current.Gameplay;
|
||
var multiJob = string.Equals(gameplay.UrbanJobGrantMode, "multi", StringComparison.OrdinalIgnoreCase);
|
||
var promoteTop = string.Equals(gameplay.UrbanJobRank, "top", StringComparison.OrdinalIgnoreCase);
|
||
var overrides = OverriddenUrbanJobs4229938(gameplay, templateId);
|
||
|
||
// ★ per-character 当前职业覆盖:顺带把它塞进 overrides,这样下面「Jobless 且无 override
|
||
// 就早退」的那条短路不会把当前职业吃掉(白靖那条路正是 Jobless + 只配了当前职业)。
|
||
var currentOverride = CurrentUrbanJobOverride4229938(gameplay, templateId);
|
||
if (currentOverride != 0 && !overrides.Contains(currentOverride))
|
||
overrides.Add(currentOverride);
|
||
|
||
// Fast-forwarding promotion also moves the character onto the top rank, so CurrentJob agrees
|
||
// with the highest id we publish. Jobless stays Jobless — there is no chain above it.
|
||
var effectiveJob = jobId == JoblessUrbanJob4229938 || !promoteTop ? jobId : TopUrbanJob4229938(jobId);
|
||
var result = new Auto.SpiritJobInfo { CurrentJob = effectiveJob };
|
||
// Jobless carries no record of its own: the dictionary stays empty, which is also the only
|
||
// shape the login payload may use. Per-character overrides are the one thing that can still
|
||
// fill it, so the app exclusivity stays intact without them.
|
||
if (jobId == JoblessUrbanJob4229938 && !multiJob && overrides.Count == 0)
|
||
return result;
|
||
|
||
void Register(uint id)
|
||
{
|
||
foreach (var rank in PublishedRanks4229938(id, promoteTop))
|
||
{
|
||
if (result.AvailableJobs.ContainsKey(rank))
|
||
continue;
|
||
|
||
// Shape note lives on NewSpiritJobRecord4229938. Every rank carries the class's top
|
||
// job level, so promoting does not silently hand out a weaker level anywhere.
|
||
result.AvailableJobs[rank] = NewSpiritJobRecord4229938(rank);
|
||
}
|
||
}
|
||
|
||
// Multi: every app-gated class first (sorted), then the authored rank if its class is not
|
||
// already covered. MainPhoneUtils.CheckJobCanShow walks the whole dictionary, so nothing
|
||
// here is exclusive — the extra ranks simply add more apps.
|
||
if (multiJob)
|
||
foreach (var id in AppGatedUrbanJobIds4229938)
|
||
Register(id);
|
||
|
||
// Per-character grants first so an authored rank never shadows one the operator asked for.
|
||
foreach (var id in overrides)
|
||
Register(id);
|
||
|
||
if (jobId != JoblessUrbanJob4229938)
|
||
Register(jobId);
|
||
|
||
// ★ 当前职业覆盖。只在目标职业确实已注册时生效 —— 否则会发一个 AvailableJobs 里不存在的
|
||
// CurrentJob,客户端 SpiritJobManager 取不到 lastCfg(Jobless=100 那条注释同一个坑)。
|
||
if (currentOverride != 0 && result.AvailableJobs.ContainsKey(currentOverride))
|
||
{
|
||
result.CurrentJob = currentOverride;
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
/// <summary>
|
||
/// One <c>AvailableJobs</c> entry — the 7k-verified shape (donor 47-byte capture, 2026-09-20):
|
||
/// register the rank and stand on it. Career-locked phone apps gate on the job <b>class</b>, which
|
||
/// every rank of a career shares, so one rank per class carries the unlock.
|
||
/// </summary>
|
||
private static Auto.SpiritJob NewSpiritJobRecord4229938(uint id)
|
||
{
|
||
// Level is a single byte on the wire; job authoring never goes past 24, but clamp anyway.
|
||
byte level = 1;
|
||
var exp = 0u;
|
||
// Level is not free-form: SpiritJobManager.GetLevelCfg(jobClass, level) looks the pair up in
|
||
// UrbanJobLevelConfig, so only levels that table authors may be published (see the catalog).
|
||
if (string.Equals(PrivateServerConfigStore.Current.Gameplay.UrbanJobLevel,
|
||
"max", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
var jobClass = ClientConfigRepository.UrbanJob(id)?.JobClass ?? 0;
|
||
if (UrbanJobLevelCatalog4229938.TryGetTopLevel(jobClass, out var topLevel, out var topExp))
|
||
{
|
||
level = topLevel > byte.MaxValue ? byte.MaxValue : (byte)topLevel;
|
||
exp = topExp;
|
||
}
|
||
}
|
||
|
||
return new Auto.SpiritJob
|
||
{
|
||
Job = id,
|
||
Exp = exp,
|
||
Level = level,
|
||
RegisterTime = 1,
|
||
UnregisterTime = 0,
|
||
// Career talents seeded per job record too: the client's talent lock check is called with
|
||
// a job class (11300004 for Hacker), so the job dictionaries carry the same nodes as
|
||
// SpiritInfo.TalentInfo. See CareerJobTalentInfo4229938.
|
||
// every job class gates app visibility, so the talents have to follow the class too.
|
||
TalentInfo = CareerJobTalentInfo4229938(ClientConfigRepository.UrbanJob(id)?.JobClass ?? 0),
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// GM 控制台(<c>GmJob*</c>)用的职业记录 —— 与 <see cref="NewSpiritJobRecord4229938"/> 完全同一
|
||
/// 形状,只是等级由命令指定而不是配置推导。
|
||
///
|
||
/// ⛑⛑ 必须走这一条路而不是自己 new 一个 <c>Auto.SpiritJob</c>:捐赠端因为没有职业天赋表,
|
||
/// 它发的记录是 <c>RegisterTime = 0</c> + <c>TalentInfo = null</c>(全零 17 字节 + null 标记)。
|
||
/// 本服有 <see cref="CareerJobTalentInfo4229938"/>,照捐赠端那样填 null 会让玩家用 GM 命令
|
||
/// 换完职业后职业天赋整片消失 —— 那是回归,不是移植。wire 形状以本服 7k 验证过的那份为准。
|
||
/// </summary>
|
||
internal static Auto.SpiritJob GmJobRecord4229938(uint jobId, byte level)
|
||
{
|
||
var record = NewSpiritJobRecord4229938(jobId);
|
||
record.Level = Math.Max((byte)1, level);
|
||
return record;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Per-character jobs from <see cref="GameplaySettings.UrbanJobOverrides"/>, i.e. the escape hatch
|
||
/// that hands one character a career-locked app without switching the whole server to "multi".
|
||
/// A key may list several template ids (<c>"15020967,15021023"</c>); the values are urban job ids.
|
||
/// De-duplicated and sorted so the serialized frame stays deterministic.
|
||
/// </summary>
|
||
private static List<uint> OverriddenUrbanJobs4229938(GameplaySettings gameplay, uint templateId)
|
||
{
|
||
var result = new List<uint>();
|
||
var map = gameplay.UrbanJobOverrides;
|
||
if (map is null || map.Count == 0)
|
||
return result;
|
||
|
||
foreach (var (key, jobs) in map)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(key) || jobs is null || jobs.Length == 0)
|
||
continue;
|
||
|
||
var hit = false;
|
||
foreach (var part in key.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
|
||
{
|
||
if (uint.TryParse(part, out var id) && id == templateId)
|
||
{
|
||
hit = true;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (!hit)
|
||
continue;
|
||
|
||
foreach (var job in jobs)
|
||
if (job != 0 && !result.Contains(job))
|
||
result.Add(job);
|
||
}
|
||
|
||
result.Sort();
|
||
return result;
|
||
}
|
||
|
||
/// <summary>
|
||
/// The weapon wheel one character owns: the wheel restored from the progress snapshot when the
|
||
/// player ever edited it, otherwise the authored loadout. Instances that no longer resolve in
|
||
/// the catalog degrade to an empty slot rather than throwing during login payload construction.
|
||
/// </summary>
|
||
private static IReadOnlyList<CombatWeaponDefinition?> RestoredWheel4229938(
|
||
uint templateId,
|
||
WorldEntryState? state)
|
||
{
|
||
if (state is not null &&
|
||
state.WeaponSlotsBySpirit.TryGetValue(templateId, out var saved) &&
|
||
saved.Count > 0)
|
||
{
|
||
return saved
|
||
.Select(instanceId => instanceId == 0
|
||
? null
|
||
: CombatCatalogRepository.Weapon(templateId, instanceId))
|
||
.ToArray();
|
||
}
|
||
|
||
return CombatCatalogRepository.TryLoadout(templateId)?.Slots
|
||
?? Array.Empty<CombatWeaponDefinition?>();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 拨号用的通讯录条目。默认取 Configs/PhoneContacts.json 里 AutoAddFlag 的那批(49 条),
|
||
/// phoneContacts=false 时退回原来硬编码的两个叫车快拨号 —— 这两个号本来就在表里,
|
||
/// 所以开与不开都不会让叫车失效。
|
||
///
|
||
/// ⛑ 只发号码不发名字是刻意的:PhoneContact 契约只有 Remark + PhoneNumber,客户端拿号码回查
|
||
/// 本地 PhoneContactConfig 取名字头像(CallPhoneUtils.GetConfigIdByPhoneNumber),
|
||
/// 服务端填 Remark 反而会盖掉本地备注(GetContactRemark 优先用 contactInfo.Remark)。
|
||
/// </summary>
|
||
private static List<(string Remark, string Number)> BuildPhoneContacts(bool catalogueEnabled)
|
||
{
|
||
if (!catalogueEnabled)
|
||
return [(string.Empty, "142857"), (string.Empty, "206337")];
|
||
|
||
if (PhoneContactCatalog4229938.LoadError is not null)
|
||
{
|
||
Console.Error.WriteLine($"[PHONE] contact catalogue unavailable: {PhoneContactCatalog4229938.LoadError}");
|
||
return [(string.Empty, "142857"), (string.Empty, "206337")];
|
||
}
|
||
|
||
var includeHidden = PrivateServerConfigStore.Current.Gameplay.PhoneContactsIncludeHidden;
|
||
var contacts = includeHidden ? PhoneContactCatalog4229938.Contacts : PhoneContactCatalog4229938.AutoAddContacts();
|
||
if (contacts.Count == 0)
|
||
return [(string.Empty, "142857"), (string.Empty, "206337")];
|
||
|
||
var list = new List<(string, string)>(contacts.Count);
|
||
foreach (var contact in contacts)
|
||
list.Add((string.Empty, contact.PhoneNumber));
|
||
|
||
Console.WriteLine($"[LOGIN-PHONE] contacts={list.Count} hidden={includeHidden}");
|
||
return list;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 拨号解锁进度:PhoneContactUnlockConfig 的行 id 列表(module 3 的 FinishedTemplateIdList)。
|
||
/// 关掉时只留 [1, 2](叫车 / 快递车),用于二分"是号码没发还是解锁没生效"。
|
||
/// </summary>
|
||
private static List<uint> BuildPhoneUnlockIds(bool unlockAll)
|
||
{
|
||
if (!unlockAll)
|
||
return [1, 2];
|
||
|
||
if (PhoneContactCatalog4229938.LoadError is null && PhoneContactCatalog4229938.UnlockIds.Count > 0)
|
||
{
|
||
Console.WriteLine($"[LOGIN-PHONE] unlockIds={PhoneContactCatalog4229938.UnlockIds.Count}");
|
||
return PhoneContactCatalog4229938.UnlockIds.ToList();
|
||
}
|
||
|
||
return [1, 2];
|
||
}
|
||
|
||
/// <summary>
|
||
/// Phone-skin suit worn by one character (SpiritInfo.MobileSkinInfo).
|
||
///
|
||
/// Falls back to the suit MobileMenuSkinPartConfig itself declares as default
|
||
/// (DefaultWallPaperCfg/DefaultDecorationCfg/DefaultPendantCfg), which is also what the client
|
||
/// substitutes when the struct is zeroed — so seeding it explicitly keeps the login payload and
|
||
/// the panel in agreement instead of leaving MainPhoneUtils on its silent fallback path.
|
||
///
|
||
/// The stored choice is replayed by GameRouter.AskSetMobileSkinPart and persisted via
|
||
/// PlayerProgressStore, so it survives a re-login.
|
||
/// </summary>
|
||
private static Auto.SpiritMobileSkinInfo WornPhoneSkin4229938(uint templateId, WorldEntryState? state)
|
||
{
|
||
if (state is not null)
|
||
{
|
||
lock (state.SyncRoot)
|
||
{
|
||
if (state.PhoneSkinBySpirit.TryGetValue(templateId, out var worn) && worn is not null)
|
||
return new Auto.SpiritMobileSkinInfo
|
||
{
|
||
Wallpaper = worn.Wallpaper,
|
||
Decoration = worn.Decoration,
|
||
Pendant = worn.Pendant,
|
||
};
|
||
}
|
||
}
|
||
|
||
return new Auto.SpiritMobileSkinInfo
|
||
{
|
||
Wallpaper = NpcCultivationCatalog4229938.DefaultWallpaperPart,
|
||
Decoration = NpcCultivationCatalog4229938.DefaultDecorationPart,
|
||
Pendant = NpcCultivationCatalog4229938.DefaultPendantPart,
|
||
};
|
||
}
|
||
|
||
internal const uint InitialAmmoReserve4229938 = 9999u;
|
||
|
||
internal static Auto.PlayerPackItem AmmoPackItem(uint templateId, uint count = InitialAmmoReserve4229938)
|
||
=> new()
|
||
{
|
||
UniqueId = 910000000000UL + templateId,
|
||
TemplateId = templateId,
|
||
Count = count,
|
||
IsNew = false,
|
||
ExpiryTime = 0,
|
||
RemindState = 0,
|
||
Quality = 0,
|
||
Tags = 0,
|
||
IsBind = false,
|
||
Components = null,
|
||
CDFinishTime = 0,
|
||
};
|
||
|
||
/// <summary>Backpack stack for an item granted through the in-game GM console (ALT+F1).</summary>
|
||
internal static Auto.PlayerPackItem GmPackItem4229938(uint templateId, uint count, bool isBind, ulong uniqueId)
|
||
=> new()
|
||
{
|
||
UniqueId = uniqueId,
|
||
TemplateId = templateId,
|
||
Count = count,
|
||
IsNew = false,
|
||
ExpiryTime = 0,
|
||
RemindState = 0,
|
||
Quality = 0,
|
||
Tags = 0,
|
||
IsBind = isBind,
|
||
Components = null,
|
||
CDFinishTime = 0,
|
||
};
|
||
|
||
/// <summary>
|
||
/// Restores the saved wallet and backpack. Login is the only place the client adopts server
|
||
/// economy state (PlayerInfoItemData.InitPlayerInfo copies Money/Gold/BindingGold into the HUD
|
||
/// wallet and CommonItemManager_Package.SetPlayerInfoItem inserts InfoItem.PackItems), so a
|
||
/// purchase/draw survives a re-login only if it is published here.
|
||
/// </summary>
|
||
private static void SeedPlayerEconomy4229938(Auto.PlayerClientInfo root, WorldEntryState? state)
|
||
{
|
||
if (state is null)
|
||
return;
|
||
|
||
List<Auto.PlayerPackItem> items;
|
||
lock (state.SyncRoot)
|
||
{
|
||
if (!state.ProgressRestored && !state.HasSessionEconomy4229938())
|
||
return;
|
||
|
||
root.InfoItem.Money = state.WalletMoney;
|
||
root.InfoItem.Gold = state.WalletGold;
|
||
root.InfoItem.BindingGold = state.WalletBindingGold;
|
||
items = state.GmBackpackItems.Values.ToList();
|
||
}
|
||
|
||
root.InfoItem.PackItems = items;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 播种钓鱼(FishingGame)的玩家侧数据 —— <c>InfoMinor.FishingInfo</c>。
|
||
///
|
||
/// <para><b>为什么要有这一段</b>:捐赠文档 §7.1 把「登录帧播种 34 件渔具」列在已落地改动的
|
||
/// 第一条,而本项目此前<b>完全没有</b> —— Core 里连 <c>FishingInfo</c> 都没碰过,
|
||
/// 只做了 <c>AskFishingSpotFullSync</c> / <c>AskFishingSuccess</c> 两个 handler。
|
||
/// 结果就是客户端进钓鱼时初始化链三连 nil(<c>FishMgr.container</c> /
|
||
/// <c>EffectMgr.objPool</c> / <c>Rod.animator</c>),状态机停在 0,人物呆站着。</para>
|
||
///
|
||
/// <para><b>注意别被文档 §8 误导</b>:那里说 <c>GearDict</c> 在客户端 lua / C# 都无人读取、
|
||
/// 「塞数据零可见效果」。这条结论说的是<b>字典内容不被玩法消费</b>,
|
||
/// 不等于 <c>PlayerFishingInfo</c> 空着无所谓 —— 两者不是一回事。</para>
|
||
///
|
||
/// <para>⛑ <c>GearDict</c> 的 key 用 <b>UniqueId</b>(不是 TemplateId):结构里专门带了
|
||
/// <c>UniqueId</c> 字段,按"字典 of 实例"理解更合理,而且同一模板可能有多件(耐久不同)。
|
||
/// 这是推断,没有官方 reader 佐证 —— 若客户端按 TemplateId 查,改用 TemplateId 作 key 即可,
|
||
/// 播种数量不变。</para>
|
||
///
|
||
/// <para>只在 <c>GearDict</c> 为空时才播 ⇒ 已经从官方服拿过渔具的存档不会被覆盖。
|
||
/// 关掉开关 <c>gameplay.fishingSeedAllGear</c> 即回到原状。</para>
|
||
/// </summary>
|
||
private static void SeedFishing4229938(Auto.PlayerClientInfo root, WorldEntryState? state)
|
||
{
|
||
var fishing = state?.Fishing ?? new Auto.PlayerFishingInfo();
|
||
if (state is not null && fishing.GearDict.Count == 0)
|
||
{
|
||
var dur = (uint)Math.Max(0, Config.Gameplay.FishingGearDurability);
|
||
var ids = Config.Gameplay.FishingSeedAllGear
|
||
? FishingGearCatalog4229938.GearIds
|
||
: FishingGearCatalog4229938.FallbackGearIds;
|
||
if (ids.Count == 0) ids = FishingGearCatalog4229938.FallbackGearIds;
|
||
|
||
foreach (var gearConfigId in ids)
|
||
{
|
||
var uid = fishing.NextUniqueId++;
|
||
fishing.GearDict[uid] = new Auto.FishingGearInfo
|
||
{
|
||
UniqueId = uid,
|
||
TemplateId = gearConfigId,
|
||
RemainingDurability = dur,
|
||
};
|
||
}
|
||
Console.WriteLine($"[FISHING] seeded gears={ids.Count} durability={dur}");
|
||
}
|
||
root.InfoMinor.FishingInfo = fishing;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Publishes the server-owned talent trees (天赋, T1-6) into PlayerClientInfo.GameplayTalentInfos.
|
||
///
|
||
/// This dictionary is the only talent wire with a proven shape: it rides the login frame, which
|
||
/// the client already parses today. The eight talent S2Cs (SyncGameplayTalentPoint 64859579,
|
||
/// SyncActiveGameplayTalentLayer 64743495, ...) have no contract and no verifiable client reader,
|
||
/// so they are NOT used — instead GameRouter.Talent re-pushes this whole frame through
|
||
/// SyncPlayerInfo (64682159) after every accepted activation.
|
||
///
|
||
/// ⛑️ gameplayId must be a real TalentTreeGamePlayConfig id (1..5 in stock 4229938). A donor build
|
||
/// once invented 1020 and the panel then showed no points at all, because the client keys the
|
||
/// whole view by that id and never falls back.
|
||
/// </summary>
|
||
private static void SeedPlayerTalents4229938(Auto.PlayerClientInfo root, WorldEntryState? state)
|
||
{
|
||
var settings = Config.Gameplay.Talents;
|
||
if (!settings.Enabled)
|
||
return;
|
||
|
||
var gameplayIds = TalentTreeRepository.GameplayIds;
|
||
if (gameplayIds.Count == 0)
|
||
return;
|
||
|
||
if (state is null)
|
||
{
|
||
// No session state (payload built outside world entry): publish the seed only, so the
|
||
// panel at least has points to spend instead of showing an empty tree.
|
||
foreach (var gameplayId in gameplayIds)
|
||
{
|
||
if (gameplayId != 0)
|
||
root.InfoMinor.GameplayTalentInfos[gameplayId] = new Auto.SpiritTalentInfo
|
||
{
|
||
TalentPoint = settings.PointSeed,
|
||
};
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
lock (state.SyncRoot)
|
||
{
|
||
foreach (var gameplayId in gameplayIds)
|
||
{
|
||
if (gameplayId == 0)
|
||
continue;
|
||
|
||
var info = new Auto.SpiritTalentInfo();
|
||
if (state.GameplayTalents.TryGetValue(gameplayId, out var live))
|
||
{
|
||
info.Exp = live.Exp;
|
||
info.Level = live.Level;
|
||
info.TalentPoint = live.Point;
|
||
}
|
||
else
|
||
{
|
||
info.TalentPoint = settings.PointSeed;
|
||
}
|
||
|
||
// Whole-tree seeding underneath whatever the player activated themselves: the seed is
|
||
// the floor, live rows win on conflict because that is a real Ask activation.
|
||
if (settings.SeedAllTrees && settings.SeedTreesOnGameplay)
|
||
{
|
||
var layers = new Dictionary<uint, uint>();
|
||
foreach (var (id, layer, _) in GameplayTalentNodes4229938(gameplayId))
|
||
{
|
||
if (id != 0)
|
||
layers[id] = layer;
|
||
}
|
||
|
||
var owned = TalentTreeRepository.Trees
|
||
.Where(tree => tree.GameplayId == gameplayId)
|
||
.ToList();
|
||
|
||
if (live is not null)
|
||
{
|
||
foreach (var (talentId, layer) in live.Nodes)
|
||
{
|
||
if (talentId != 0 && layer != 0)
|
||
layers[talentId] = layer;
|
||
}
|
||
}
|
||
|
||
FillTalentNodes4229938(
|
||
info.UnlockTalentInfoDict,
|
||
layers.OrderBy(x => x.Key)
|
||
.Select(x => (x.Key, x.Value, 0u))
|
||
.ToList());
|
||
|
||
foreach (var (treeId, spent, count) in TalentTreeRecords4229938(owned))
|
||
{
|
||
info.TalentTreeRecordDict[treeId] = new Auto.TalentTreeRecord
|
||
{
|
||
SpentTalentPoint = spent,
|
||
ActivatedNodeCount = count,
|
||
};
|
||
}
|
||
}
|
||
else if (live is not null)
|
||
{
|
||
foreach (var (talentId, layer) in live.Nodes)
|
||
{
|
||
if (talentId == 0 || layer == 0)
|
||
continue;
|
||
|
||
info.UnlockTalentInfoDict[talentId] = new Auto.SpiritOrJobTalentNodeInfo
|
||
{
|
||
TalentId = talentId,
|
||
Layer = layer,
|
||
};
|
||
}
|
||
|
||
info.TalentTreeRecordDict = BuildTalentTreeRecords4229938(live);
|
||
}
|
||
|
||
root.InfoMinor.GameplayTalentInfos[gameplayId] = info;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// The career talent nodes (职业天赋) this server publishes as already activated.
|
||
///
|
||
/// Keyed TalentTreeTalentConfig id -> layers. Defaults to the whole <c>careerTalentTreeId</c>
|
||
/// tree (99900010 = 17 nodes in stock 4229938) because nodes have prerequisites: publishing only
|
||
/// 99906204 leaves its parents 99906100 / 99906001 unmet, and anything that checks the chain
|
||
/// would then still read the node as locked. <c>careerTalentIds</c> narrows it when some nodes
|
||
/// should stay buyable in the panel.
|
||
/// </summary>
|
||
/// <returns>
|
||
/// Nodes as <c>(Id, Layer, Charge)</c>:
|
||
/// <list type="bullet">
|
||
/// <item><c>Layer</c> — what we send. The client lock test (<c>TalentTreeMgr.lua:1031</c>) is
|
||
/// <c>return talentInfo.Layer <= talentCfg.LayerNum</c>, i.e. a node whose <c>LayerNum</c> is 1
|
||
/// needs <c>Layer</c> 2 to count as unlocked. With <c>careerTalentLayers = 0</c> we therefore
|
||
/// publish <c>LayerNum + 1</c> (2026-09-24: seeding the raw <c>LayerNum</c> left every vending
|
||
/// machine grey, which is exactly the off-by-one the client asks for). An explicit
|
||
/// <c>careerTalentLayers</c> is used verbatim — it must itself exceed the node's <c>LayerNum</c>.</item>
|
||
/// <item><c>Charge</c> — the number of layers actually paid for, for the per-tree accounting
|
||
/// records. One less than <c>Layer</c> for the same off-by-one, never below 1.</item>
|
||
/// </list>
|
||
/// </returns>
|
||
/// <summary>
|
||
/// Turns talent-table rows into the activation tuples the client reads. Shared by all three
|
||
/// containers; see <see cref="CareerTalentNodes4229938"/> for the Layer/Charge off-by-one.
|
||
/// Duplicate ids are dropped — 33 rows belong to several trees, and feeding one node twice would
|
||
/// only inflate the per-tree accounting records.
|
||
/// </summary>
|
||
private static List<(uint Id, uint Layer, uint Charge)> BuildTalentNodes4229938(
|
||
IEnumerable<TalentTreeRepository.TalentNode> nodes)
|
||
{
|
||
static (uint, uint, uint) Build(uint id, uint layerNum, uint overrideLayers)
|
||
{
|
||
// layerNum 0 happens in rows that omit it; treat it as one buyable layer.
|
||
var real = layerNum == 0 ? 1u : layerNum;
|
||
if (overrideLayers > 0)
|
||
return (id, overrideLayers, overrideLayers);
|
||
|
||
var layers = real + 1;
|
||
return (id, layers, real);
|
||
}
|
||
|
||
var result = new List<(uint, uint, uint)>();
|
||
var seen = new HashSet<uint>();
|
||
foreach (var node in nodes)
|
||
{
|
||
if (node.Id == 0 || !seen.Add(node.Id))
|
||
continue;
|
||
|
||
result.Add(Build(node.Id, node.LayerNum, Config.Gameplay.Talents.CareerTalentLayers));
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Nodes published into <c>GameplayTalentInfos[gameplayId]</c>: every tree whose
|
||
/// <c>TalentTreeConfig.GameplayId</c> is that id.
|
||
/// </summary>
|
||
internal static List<(uint Id, uint Layer, uint Charge)> GameplayTalentNodes4229938(uint gameplayId)
|
||
=> BuildTalentNodes4229938(TalentTreeRepository.NodesOfTrees(tree => tree.GameplayId == gameplayId));
|
||
|
||
/// <summary>
|
||
/// Nodes published into one career's <c>SpiritJob.TalentInfo</c>: every tree authored for that
|
||
/// <c>JobClassId</c>. A tree only ever shows up for characters that own the career, because the
|
||
/// client builds the dictionary from the AvailableJobs it received.
|
||
/// </summary>
|
||
internal static List<(uint Id, uint Layer, uint Charge)> JobClassTalentNodes4229938(uint jobClassId)
|
||
=> BuildTalentNodes4229938(TalentTreeRepository.NodesOfTrees(tree => tree.JobClassId == jobClassId));
|
||
|
||
/// <summary>
|
||
/// Nodes published into a character's <c>SpiritInfo.TalentInfo</c>: every tree keyed by neither
|
||
/// gameplay nor job class, further narrowed by the tree's own <c>SpiritId</c> list (Kingpin lists
|
||
/// the two protagonists; a few rows pin exactly one spirit). An empty list means "everybody".
|
||
/// </summary>
|
||
internal static List<(uint Id, uint Layer, uint Charge)> SpiritTalentNodes4229938(uint templateId)
|
||
{
|
||
var allowSpiritKeyed = Config.Gameplay.Talents.SeedSpiritIdTrees;
|
||
|
||
return BuildTalentNodes4229938(TalentTreeRepository.NodesOfTrees(tree =>
|
||
tree.GameplayId == 0
|
||
&& tree.JobClassId == 0
|
||
&& (allowSpiritKeyed || tree.SpiritIds.Count == 0)
|
||
&& (tree.SpiritIds.Count == 0 || tree.SpiritIds.Contains(templateId))));
|
||
}
|
||
|
||
internal static List<(uint Id, uint Layer, uint Charge)> CareerTalentNodes4229938()
|
||
{
|
||
var settings = Config.Gameplay.Talents;
|
||
if (!settings.Enabled || !settings.SeedCareerTalents)
|
||
return [];
|
||
|
||
// Explicit list wins so an operator can pin exactly one node without importing the tree.
|
||
if (settings.CareerTalentIds is { Length: > 0 } ids)
|
||
{
|
||
return BuildTalentNodes4229938(ids
|
||
.Where(id => id != 0)
|
||
.Select(id => TalentTreeRepository.TryTalent(id, out var node)
|
||
? node
|
||
: new TalentTreeRepository.TalentNode(id, 0, 1, 1)));
|
||
}
|
||
|
||
return BuildTalentNodes4229938(TalentTreeRepository.NodesOfTree(settings.CareerTalentTreeId));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Writes one activation per node. Layer/Charge already carry the client's off-by-one, see
|
||
/// <see cref="CareerTalentNodes4229938"/>.
|
||
/// </summary>
|
||
private static void FillTalentNodes4229938(
|
||
IDictionary<uint, Auto.SpiritOrJobTalentNodeInfo> target,
|
||
IReadOnlyList<(uint Id, uint Layer, uint Charge)> nodes)
|
||
{
|
||
foreach (var (id, layer, _) in nodes)
|
||
{
|
||
target[id] = new Auto.SpiritOrJobTalentNodeInfo
|
||
{
|
||
TalentId = id,
|
||
Layer = layer,
|
||
};
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Per-tree accounting records (<c>TalentTreeRecordDict</c>), computed tree by tree.
|
||
///
|
||
/// ⛑️ Never derive the grouping from <c>TalentNode.TreeId</c>: that field is only the first row of
|
||
/// a node's <c>TalentTreeid</c> array, so the 33 shared rows would all be billed to whichever tree
|
||
/// lists them first and every other tree would under-report.
|
||
/// </summary>
|
||
private static List<(uint TreeId, uint Spent, uint Count)> TalentTreeRecords4229938(
|
||
IEnumerable<TalentTreeRepository.TalentTree> trees)
|
||
{
|
||
var records = new List<(uint, uint, uint)>();
|
||
foreach (var tree in trees)
|
||
{
|
||
var spent = 0u;
|
||
var count = 0u;
|
||
foreach (var node in TalentTreeRepository.NodesOfTree(tree.Id))
|
||
{
|
||
var charge = node.LayerNum == 0 ? 1u : node.LayerNum;
|
||
spent += node.CostPoint * charge;
|
||
count += charge;
|
||
}
|
||
|
||
if (count > 0)
|
||
records.Add((tree.Id, spent, count));
|
||
}
|
||
|
||
return records;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Publishes the career talents into one character's <c>SpiritInfo.TalentInfo</c>.
|
||
///
|
||
/// This is the gate behind the greyed-out scene hack prompts — see
|
||
/// <see cref="TalentTreeSettings.SeedCareerTalents"/> for the evidence chain. The client computes
|
||
/// the lock state from this data at registration time, so it has to be in the login frame:
|
||
/// patching the lua table afterwards does nothing (verified 2026-09-24, the machines stayed grey
|
||
/// even after <c>talentLocked</c> was cleared by hand).
|
||
/// </summary>
|
||
private static Auto.SpiritTalentInfo CareerTalentInfo4229938(uint templateId)
|
||
{
|
||
var settings = Config.Gameplay.Talents;
|
||
var info = new Auto.SpiritTalentInfo { TalentPoint = settings.CareerTalentPoint };
|
||
|
||
if (!settings.Enabled || !settings.SeedCareerTalents || !settings.SeedCareerTalentsOnSpirit)
|
||
return info;
|
||
|
||
// Tree-seeding mode: publish every tree the client reads out of SpiritInfo.TalentInfo, i.e. the
|
||
// trees keyed by neither gameplay nor job class, narrowed by their own SpiritId list (Kingpin =
|
||
// the two protagonists, some rows pin exactly one spirit). Otherwise one configured tree only.
|
||
if (settings.SeedAllTrees)
|
||
{
|
||
var allowSpiritKeyed = settings.SeedSpiritIdTrees;
|
||
var owned = TalentTreeRepository.Trees
|
||
.Where(tree => tree.GameplayId == 0
|
||
&& tree.JobClassId == 0
|
||
&& (allowSpiritKeyed || tree.SpiritIds.Count == 0)
|
||
&& (tree.SpiritIds.Count == 0 || tree.SpiritIds.Contains(templateId)))
|
||
.ToList();
|
||
|
||
FillTalentNodes4229938(info.UnlockTalentInfoDict, SpiritTalentNodes4229938(templateId));
|
||
foreach (var (treeId, spent, count) in TalentTreeRecords4229938(owned))
|
||
{
|
||
info.TalentTreeRecordDict[treeId] = new Auto.TalentTreeRecord
|
||
{
|
||
SpentTalentPoint = spent,
|
||
ActivatedNodeCount = count,
|
||
};
|
||
}
|
||
|
||
return info;
|
||
}
|
||
|
||
FillTalentNodes4229938(info.UnlockTalentInfoDict, CareerTalentNodes4229938());
|
||
var legacyTreeId = settings.CareerTalentTreeId;
|
||
if (legacyTreeId != 0 && info.UnlockTalentInfoDict.Count > 0)
|
||
{
|
||
foreach (var (treeId, spent, count) in TalentTreeRecords4229938(
|
||
TalentTreeRepository.Trees.Where(tree => tree.Id == legacyTreeId)))
|
||
{
|
||
info.TalentTreeRecordDict[treeId] = new Auto.TalentTreeRecord
|
||
{
|
||
SpentTalentPoint = spent,
|
||
ActivatedNodeCount = count,
|
||
};
|
||
}
|
||
}
|
||
|
||
return info;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Same nodes as <see cref="CareerTalentInfo4229938"/>, for one <c>SpiritJob.TalentInfo</c>
|
||
/// record — the career talent view is keyed by job class, and the client method that answers
|
||
/// <c>CheckTalentIsLocked(talentId, jobClassId)</c> is passed a job class (11300004 for Hacker),
|
||
/// so the job records carry the very same activation set.
|
||
/// </summary>
|
||
private static Auto.SpiritJobTalentInfo CareerJobTalentInfo4229938(uint jobClassId)
|
||
{
|
||
var settings = Config.Gameplay.Talents;
|
||
if (!settings.Enabled || !settings.SeedCareerTalents || !settings.SeedCareerTalentsOnEveryJob)
|
||
return new Auto.SpiritJobTalentInfo();
|
||
|
||
var info = new Auto.SpiritJobTalentInfo { TalentPoint = settings.CareerTalentPoint };
|
||
|
||
// Tree-seeding mode: every tree authored for this career's JobClassId, instead of one global
|
||
// tree copy. The client's dictionary is built per AvailableJobs record, so a tree only becomes
|
||
// visible for characters that actually own the career.
|
||
if (settings.SeedAllTrees)
|
||
{
|
||
if (jobClassId == 0)
|
||
return info;
|
||
|
||
var owned = TalentTreeRepository.Trees.Where(tree => tree.JobClassId == jobClassId).ToList();
|
||
FillTalentNodes4229938(info.UnlockTalentInfoDict, JobClassTalentNodes4229938(jobClassId));
|
||
foreach (var (treeId, spent, count) in TalentTreeRecords4229938(owned))
|
||
{
|
||
info.TalentTreeRecordDict[treeId] = new Auto.TalentTreeRecord
|
||
{
|
||
SpentTalentPoint = spent,
|
||
ActivatedNodeCount = count,
|
||
};
|
||
}
|
||
|
||
return info;
|
||
}
|
||
|
||
FillTalentNodes4229938(info.UnlockTalentInfoDict, CareerTalentNodes4229938());
|
||
var legacyTreeId = settings.CareerTalentTreeId;
|
||
if (legacyTreeId != 0 && info.UnlockTalentInfoDict.Count > 0)
|
||
{
|
||
foreach (var (treeId, spent, count) in TalentTreeRecords4229938(
|
||
TalentTreeRepository.Trees.Where(tree => tree.Id == legacyTreeId)))
|
||
{
|
||
info.TalentTreeRecordDict[treeId] = new Auto.TalentTreeRecord
|
||
{
|
||
SpentTalentPoint = spent,
|
||
ActivatedNodeCount = count,
|
||
};
|
||
}
|
||
}
|
||
|
||
return info;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Per-tree "how much of this tree has been spent" record the panel shows next to each tree tab.
|
||
/// Derived from the activated nodes so it can never disagree with them.
|
||
/// </summary>
|
||
private static Dictionary<uint, Auto.TalentTreeRecord> BuildTalentTreeRecords4229938(
|
||
GameplayTalentState4229938 live)
|
||
{
|
||
var records = new Dictionary<uint, Auto.TalentTreeRecord>();
|
||
foreach (var (talentId, layer) in live.Nodes)
|
||
{
|
||
if (!TalentTreeRepository.TryTalent(talentId, out var node) || node.TreeId == 0)
|
||
continue;
|
||
|
||
if (!records.TryGetValue(node.TreeId, out var record))
|
||
{
|
||
record = new Auto.TalentTreeRecord();
|
||
records[node.TreeId] = record;
|
||
}
|
||
|
||
record.SpentTalentPoint += node.CostPoint * layer;
|
||
record.ActivatedNodeCount += layer;
|
||
}
|
||
|
||
return records;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Ensures every configured gameplay talent tree exists in the session state, seeding the point
|
||
/// balance for trees the player has never touched. Called at login before the payload is built,
|
||
/// and lazily by the talent handlers so a request for an unseen tree still works.
|
||
/// </summary>
|
||
internal static GameplayTalentState4229938 EnsureGameplayTalentTree4229938(
|
||
WorldEntryState state, uint gameplayId)
|
||
{
|
||
var settings = Config.Gameplay.Talents;
|
||
lock (state.SyncRoot)
|
||
{
|
||
if (!state.GameplayTalents.TryGetValue(gameplayId, out var tree))
|
||
{
|
||
tree = new GameplayTalentState4229938 { Point = settings.PointSeed };
|
||
state.GameplayTalents[gameplayId] = tree;
|
||
}
|
||
else if (settings.RenewSeedOnLogin && tree.Point < settings.PointSeed)
|
||
{
|
||
tree.Point = settings.PointSeed;
|
||
}
|
||
|
||
return tree;
|
||
}
|
||
}
|
||
|
||
internal static Auto.WeaponData WeaponData(CombatWeaponDefinition weapon)
|
||
=> new()
|
||
{
|
||
TemplateId = weapon.TemplateId,
|
||
Durability = weapon.InitialClientDurability,
|
||
InstanceId = weapon.InstanceId,
|
||
EventId = 0,
|
||
ReceivedTimeStamp = 0,
|
||
// Byte 1 = discardable. See CombatCodec.WeaponOperatorFlagsDiscardable — leaving this at
|
||
// 0 makes the client treat every login-payload weapon as CantDiscard.
|
||
OperatorFlags = CombatCodec.WeaponOperatorFlagsDiscardable,
|
||
SpecialLabel = null,
|
||
SceneItemHp = 1f,
|
||
StackCount = 1,
|
||
FightStyleId = weapon.Style.Id,
|
||
MagazineAmmo = weapon.InitialMagazineAmmo,
|
||
BulletDatas = new Auto.WeaponBulletDatas { BulletId = weapon.BulletId },
|
||
BindPid = Config.Player.Pid,
|
||
IsPlayerLocked = false,
|
||
};
|
||
|
||
/// <summary>
|
||
/// The outfit one character is wearing, honouring a restored choice when there is one.
|
||
/// The wardrobe authority in the login payload is PlayerFashionsInfo.SpiritFashionsInfoDict;
|
||
/// writing the authored defaults there undresses the character on every re-login.
|
||
/// </summary>
|
||
private static IReadOnlyList<uint> WornFashionIds4229938(uint templateId, WorldEntryState? state)
|
||
{
|
||
var worn = state?.FashionBySpirit.GetValueOrDefault(templateId);
|
||
return worn is { FashionIds.Count: > 0 }
|
||
? worn.FashionIds
|
||
: ClientConfigRepository.DefaultFashionIds(templateId);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 房产(House)所有权 —— 2026-09-30。
|
||
///
|
||
/// <para><b>为什么所有权只能走登录包</b>:客户端判断「这套房是不是我的」只有一条路径 ——
|
||
/// <c>gBuyHouseUtils.CheckHasBuyTheHouse(houseId)</c>
|
||
/// → <c>gPlayerManager.infoMinor.bindData.housesInfo.HouseInfoList</c>
|
||
/// → <c>PlayerInfoMinorData.lua:54</c> 的 <c>t.housesInfo = info.InfoMinor.housesInfo</c>。
|
||
/// 也就是说这份数据**完全由登录包决定**,没有任何 S2C 通道能补它
|
||
/// (<c>SyncAddHouse</c> 只能往列表里追加,读不到列表本身)。</para>
|
||
///
|
||
/// <para>以前这里一个字都没填 ⇒ 客户端认为一套房都没买。手机 app 里买完会放动画、
|
||
/// 显示「已购买」,那纯粹是 <c>BuyHouseUtils.BuyTheHouse</c> 拿到 error=0 之后的**本地乐观行为**
|
||
/// (自己 <c>SendMessage(ON_BUY_HOUSE_SUCCESS)</c> + 开 <c>HOUSE_REWARD_PANEL</c>),
|
||
/// 服务端当时既不记账也不推包,所以一走出那个面板 / 一重登 / 一开大地图,
|
||
/// 客户端重新算一次 <c>CheckHasBuyTheHouse</c> 就翻回「未购买」。</para>
|
||
///
|
||
/// <para>线格式见 <see cref="HouseOwnershipWire4229938"/>(同一份结构也用于
|
||
/// <c>SyncAddHouse</c> 推送)。</para>
|
||
/// </summary>
|
||
private static void SeedOwnedHouses4229938(Auto.PlayerClientInfo root, WorldEntryState? state)
|
||
{
|
||
if (state is null)
|
||
return;
|
||
|
||
var list = root.InfoMinor.housesInfo.HouseInfoList;
|
||
list.Clear();
|
||
|
||
// 排序只为让登录包字节稳定(便于和 packets 日志逐包对照)。
|
||
foreach (var houseId in state.Progress.OwnedHouseIds.Where(id => id != 0).OrderBy(id => id))
|
||
{
|
||
// 配置里已经没有的 houseId(手改存档 / 换 build)照样发 —— 客户端只拿它跟
|
||
// HouseConfig 对 id,多余的条目不会让它崩,但少一条就等于玩家丢一套房。
|
||
// 2026-09-30:连同该房的车库车位指派一起回填(ParkingSpaceVehicleIdDict),
|
||
// 否则重登后车库全空、按 F 驶离车库会因 ParkingVehicleUid 为空而卡死。
|
||
state.Progress.GarageParking.TryGetValue(houseId, out var parking);
|
||
list.Add(HouseOwnershipWire4229938.ToWire(houseId, parking));
|
||
}
|
||
}
|
||
|
||
private static Auto.PlayerFashionsInfo MinimalPlayerFashions4229938(WorldEntryState? state)
|
||
{
|
||
var result = new Auto.PlayerFashionsInfo
|
||
{
|
||
DefaultSpiritIsInitDefaultFashion = true,
|
||
ColoringCollectionScore = 0,
|
||
};
|
||
|
||
// Grant all selectable fashions so the wardrobe/character panel shows every skin.
|
||
// Default wear remains the character's authored defaults; players can swap in the UI.
|
||
foreach (var fashionId in ClientConfigRepository.SelectableFashionIds())
|
||
{
|
||
if (!result.FashionInfoDict.ContainsKey(fashionId))
|
||
result.FashionInfoDict[fashionId] = FashionInfo4229938(fashionId, state);
|
||
}
|
||
|
||
foreach (var character in ClientConfigRepository.Characters())
|
||
{
|
||
var worn = state?.FashionBySpirit.GetValueOrDefault(character.TemplateId);
|
||
var wornIds = WornFashionIds4229938(character.TemplateId, state);
|
||
var isRestored = worn is { FashionIds.Count: > 0 };
|
||
foreach (var fashionId in wornIds)
|
||
{
|
||
if (!result.FashionInfoDict.ContainsKey(fashionId))
|
||
result.FashionInfoDict[fashionId] = FashionInfo4229938(fashionId, state);
|
||
}
|
||
|
||
result.SpiritFashionsInfoDict[character.TemplateId] = new Auto.SpiritFashionsInfo
|
||
{
|
||
SpiritId = character.TemplateId,
|
||
SpiritWearFashionsInfo = isRestored
|
||
? WearFashions(
|
||
worn!.FashionIds,
|
||
functionSuitId: worn.FunctionSuitId,
|
||
source: worn.Source,
|
||
sourceId: worn.SourceId,
|
||
isTryWear: worn.IsTryWear,
|
||
hiddenParts: worn.HiddenParts)
|
||
: WearFashions(wornIds),
|
||
SpiritPrevWearFashionsInfo = null,
|
||
FirstGainSuitIdList = [],
|
||
ActiveClientTryWearSource = 0,
|
||
UnlockSuitSlotCount = 0,
|
||
};
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/// <summary>
|
||
/// One owned fashion as the login seed publishes it. The dye half is what unlocks the wardrobe's
|
||
/// colour page: <c>UnlockColoringSlotCount = 0</c> meant every plan slot was sealed, so the page
|
||
/// had nothing to offer and the apply button did nothing.
|
||
///
|
||
/// <c>FashionColoringStore</c> replays whatever the dye handlers remembered; a fashion nobody
|
||
/// dyed gets <see cref="FashionColoringStore.DefaultSlots"/> (all eight) and no saved scheme.
|
||
/// </summary>
|
||
private static Auto.FashionInfo FashionInfo4229938(uint fashionId, WorldEntryState? state)
|
||
{
|
||
var info = new Auto.FashionInfo
|
||
{
|
||
FashionId = fashionId,
|
||
ExpiredTime = 0,
|
||
GainTime = 1,
|
||
Status = 0,
|
||
ApplyColoringSchemeId = FashionColoringStore.GetAppliedScheme(state, fashionId),
|
||
UnlockColoringSlotCount = FashionColoringStore.GetUnlockedSlots(state, fashionId),
|
||
ColoringSchemeTopNMaxCollectionScore = 0,
|
||
};
|
||
|
||
foreach (var (planId, colors) in FashionColoringStore.GetSchemes(state, fashionId))
|
||
{
|
||
info.ColoringSchemeInfoDict[planId] = new Auto.FashionColoringInfo
|
||
{
|
||
ColoringType2ColorIdDict = new Dictionary<byte, uint>(colors),
|
||
};
|
||
}
|
||
|
||
return info;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Weapon skins (武器皮肤): seed every authored skin as owned, then replay whatever the player
|
||
/// actually equipped earlier.
|
||
///
|
||
/// <c>OwnedSkinIds</c> is the gate — the client's skin picker lists nothing without it, the same
|
||
/// way <c>UnlockColoringSlotCount = 0</c> sealed every dye slot. <c>SpiritWeaponSkinDict</c> is
|
||
/// the per-character "which skin is on which weapon" half; it starts empty for a fresh account
|
||
/// and the client renders the authored default skins until AskSetWeaponSkins assigns one.
|
||
/// </summary>
|
||
private static void SeedWeaponSkins4229938(Auto.PlayerClientInfo root, WorldEntryState? state)
|
||
{
|
||
// A missing/corrupt WeaponSkinConfig.json must not fail LoginGame — it degrades to "no skins
|
||
// owned", the same state the build has always shipped with.
|
||
if (WeaponSkinCatalog4229938.LoadError is not null)
|
||
return;
|
||
|
||
foreach (var skinId in WeaponSkinCatalog4229938.SkinIds)
|
||
root.InfoMinor.PlayerWeaponSkinInfo.OwnedSkinIds[skinId] = 1;
|
||
|
||
foreach (var (spiritId, skinBySceneItem) in WeaponSkinStore.Snapshot(state))
|
||
{
|
||
root.InfoMinor.PlayerWeaponSkinInfo.SpiritWeaponSkinDict[spiritId] = new Auto.SpiritWeaponSkinInfo
|
||
{
|
||
SkinDict = new Dictionary<uint, uint>(skinBySceneItem),
|
||
};
|
||
}
|
||
}
|
||
|
||
private static Auto.SpiritWearFashionsInfo WearFashions(
|
||
IReadOnlyList<uint> ids,
|
||
uint functionSuitId = 0,
|
||
short source = 0,
|
||
uint sourceId = 0,
|
||
bool isTryWear = false,
|
||
byte hiddenParts = 0)
|
||
=> new()
|
||
{
|
||
FunctionSuitId = functionSuitId,
|
||
WearSourceInfo = new Auto.WearSourceInfo { Source = source, SourceId = sourceId },
|
||
IsTryWear = isTryWear,
|
||
WearFashionInfoList = ids.Select(id => new Auto.WearFashionInfo { FashionId = id }).ToList(),
|
||
WearFashionEditInfoList = [],
|
||
HiddenParts = hiddenParts,
|
||
EditedHiddenParts = 0,
|
||
};
|
||
|
||
private static GameMethods.SpiritWearFashionsInfo WearFashionsForGameMethods(
|
||
IReadOnlyList<uint> ids,
|
||
uint functionSuitId = 0,
|
||
short source = 0,
|
||
uint sourceId = 0,
|
||
bool isTryWear = false,
|
||
byte hiddenParts = 0)
|
||
=> new()
|
||
{
|
||
FunctionSuitId = functionSuitId,
|
||
WearSourceInfo = new GameMethods.WearSourceInfo { Source = source, SourceId = sourceId },
|
||
IsTryWear = isTryWear,
|
||
WearFashionInfoList = ids.Select(id => new GameMethods.WearFashionInfo { FashionId = id }).ToList(),
|
||
WearFashionEditInfoList = [],
|
||
HiddenParts = hiddenParts,
|
||
EditedHiddenParts = 0,
|
||
};
|
||
|
||
private static Auto.UXVector3 Vector(Vec3 value)
|
||
=> new() { X = value.X, Y = value.Y, Z = value.Z };
|
||
}
|