using Ananta.SDK.Rpc; using Ananta.Server.ClientData.Client4229938; using Ananta.Server.Protocol.Client4229938; using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game; namespace Ananta.Server.Handlers.Game; /// /// Profession phone-app DATA handlers (build 4229938) — 清洁工 Washer / 赛车手 Racer. /// Ported from the Lyco donor build on 2026-09-26. /// /// Diagnosis (donor, 2026-09-20 — holds for our tree too): these apps are not blocked at the /// entry or at the switch gate. `MainPhoneUtils.OnAppItemClick` has a branch for every job and the /// fastpatch ClientGameSwitch module already sets EnableJanitor / EnableCatExpress / /// EnableDutyTerminal / EnableEonBug, so `SwitchFunctionManager.CheckEnable` passes. What was /// missing is the DATA: with no handler the client fell through to the typed-default (all-zero) /// body and each panel rendered an empty shell. /// /// ⛑⛑ This is a STATIC SNAPSHOT, not a simulation. There is no server-side mission generation, /// no persisted racing result and no WasherConfig table on our side, so: /// * 清洁工 shows one in-flight mission and a fixed history — progress never advances by itself; /// * 赛车手 reports zero points / zero stars, so only the group whose authored gate is 0/0 /// (group 1, C级赛) is unlocked and every league the player has not earned renders 未解锁. /// Accepting / finishing a mission is acked and nothing else happens. /// /// ⛑ Do NOT "improve" 赛车手 by returning every group with a synthetic record. The donor measured /// that on 2026-09-22: `RacerHistoryPageStore.BuildHistoryList` turns each group's track rows into a /// HISTORY entry (so the app claimed "C级赛事 完成" for a player who never raced) and /// `RacerCareerPageStore.BuildDisplayLists` treats the KEYS of CompetitionGroupInfos as the unlocked /// set, which pushes the frontier to the last league and renders B级赛 进行中 although it is /// authored locked. Presence means "unlocked"; the records stay empty until a race is finished. /// internal sealed partial class GameRouter { /// /// Washer mission ids. ⛑ Hard-coded: this server ships no WasherConfig.json, so these are the /// donor's literal rows from ConfigDump_v3. Replace with a repository lookup if the table is /// ever copied into ClientData/4229938/Configs. /// private static readonly uint[] WasherMissionIds = [2u, 3u, 6u]; [Handler(MethodId.AskGetWasherMissionInfo, HandlerPacketKind.Invoke)] private Task AskGetWasherMissionInfo(Connection conn, UxRpcMessage msg) => conn.ReturnAsync(msg, BuildWasherMissionInfo()); /// /// Accept / finish (63297472 / 63889979). Both return void per DefaultReturnCatalog, so an empty /// OK is the whole contract — they are INVOKEs, and leaving them unrouted makes the button await /// a reply that never comes. /// [Handler(MethodId.AskAcceptWasherMission, HandlerPacketKind.Invoke)] [Handler(MethodId.AskFinishWasherMission, HandlerPacketKind.Invoke)] private async Task WasherMissionAction(Connection conn, UxRpcMessage msg) { conn.Log.Info( $"[WASHER] {(msg.MethodId == MethodId.AskAcceptWasherMission ? "accept" : "finish")} " + $"bytes={msg.Body.Length}"); await conn.ReturnEmptyOkAsync(msg); } [Handler(MethodId.AskRacingCompetitionGroupInfo, HandlerPacketKind.Invoke)] private async Task AskRacingCompetitionGroupInfo(Connection conn, UxRpcMessage msg) { var info = BuildRacingCompetitionGroupInfo(); conn.Log.Info( $"[RACER] group info leagues=[{string.Join(',', info.UnlockCompetitionTypes)}] " + $"groups=[{string.Join(',', info.CompetitionGroupInfos.Keys)}] " + $"point={info.CompetitionPoint} star={info.CompetitionStar}"); await conn.ReturnAsync(msg, info); } // ------------------------------------------------------------------ builders private static GameMethods.WasherMissionInfoResult BuildWasherMissionInfo() { var now = (uint)DateTimeOffset.UtcNow.ToUnixTimeSeconds(); var result = new GameMethods.WasherMissionInfoResult { LastRefreshMissionTime = now, CurMissionIndex = 1, CurMissionId = WasherMissionIds[0], CurMissionEventId = 1, // A non-zero progress is what makes the panel's progress bar render at all. CurMissionProgress = 0.35f, CurMissionStartTime = now, CurMissionResult = new GameMethods.WasherMissionResult { MissionId = WasherMissionIds[0], EventId = 1, Progress = 0.35f, RewardRate = 1f, ProficiencyRate = 1f, UsingTime = 0, AddMoney = 0, TalentPointRate = 1f, RandomCfgId = 1, }, HistoryMissionCnt = (uint)WasherMissionIds.Length, HistoryMissionMoney = 0, TodayMissionMoney = 0, CurRandomCfgId = 1, }; // MissionDic / RandomMissionHistory carry the same rows: the panel iterates MissionDic to // list today's jobs, so leaving it empty reproduces the empty shell exactly. foreach (var id in WasherMissionIds) { result.MissionDic[id] = new GameMethods.WasherRandomMissionId { Id = id, RandomCfgId = 1 }; result.RandomMissionHistory.Add(new GameMethods.WasherRandomMissionId { Id = id, RandomCfgId = 1 }); } result.Spirit2HistoryMissionInfo[0] = new GameMethods.WasherSpiritHistoryMissionInfo { HistoryMissionCnt = (uint)WasherMissionIds.Length, HistoryMissionMoney = 0, TodayMissionMoney = 0, }; return result; } /// /// 赛车手 league snapshot, gated by the authored thresholds in /// RacingDriverCompetitionGroupConfig: a group opens when it is not hard-locked and the player /// has reached both point/star gates. Player progress is zero (nothing is persisted yet), so /// only group 1 (C级赛, 0/0) opens and B级赛 renders 未解锁 — which is the truthful state for a /// player who has never raced. /// private static GameMethods.RacingCompetitionGroupInfoResult BuildRacingCompetitionGroupInfo() { var result = new GameMethods.RacingCompetitionGroupInfoResult { CompetitionPoint = 0, CompetitionStar = 0, }; if (RacingCatalog4229938.LoadError is not null) return result; const uint playerPoint = 0; const uint playerStar = 0; foreach (var competition in RacingCatalog4229938.Competitions) { var anyUnlockedGroup = false; foreach (var groupId in competition.GroupIds) { if (!RacingCatalog4229938.Groups.TryGetValue(groupId, out var group)) continue; if (group.IsLocked || playerPoint < group.UnlockPoint || playerStar < group.UnlockStar) continue; // Present = "this group is unlocked". The track records stay EMPTY: a record here is // read as a finished race, and no race has been finished. if (!result.CompetitionGroupInfos.ContainsKey(groupId)) result.CompetitionGroupInfos[groupId] = new GameMethods.RacingCompetitionGroupInfo(); anyUnlockedGroup = true; } // A league type is unlocked only while one of its groups is reachable. if (anyUnlockedGroup) result.UnlockCompetitionTypes.Add(competition.Id); } return result; } }