import '../config/title_server_config.dart'; import '../models/user_character.dart'; import 'title_api_service.dart'; /// Dart port of `eaquira/src/sdgb/payload.py::UserAll_payload` and /// `eaquira/action/UnlockMusic.py::music_user_all_patcher`. /// /// Builds the UpsertUserAllApi request packet used by the /// UpsertMusic (上传成绩) / UnlockMusic (解锁歌曲) workflows. class UserAllPayloadBuilder { final TitleServerConfig config; const UserAllPayloadBuilder(this.config); static DateTime _shanghaiNow() => DateTime.now().toUtc().add(const Duration(hours: 8)); static String _two(int v) => v.toString().padLeft(2, '0'); static String _formatPlayDate(DateTime dt) => '${dt.year}-${_two(dt.month)}-${_two(dt.day)}'; static String _formatPlayDateTime(DateTime dt) => '${dt.year}-${_two(dt.month)}-${_two(dt.day)} ' '${_two(dt.hour)}:${_two(dt.minute)}:${_two(dt.second)}.0'; static int _toInt(dynamic v) { if (v is int) return v; if (v is num) return v.toInt(); return 0; } /// "1.56.00" -> 1056000 (Lionheart convertVersionNumber). static int _convertVersionNumber(String version) { final parts = version.split('.'); if (parts.length != 3) return 1053000; final major = int.tryParse(parts[0]); final minor = int.tryParse(parts[1]); final patch = int.tryParse(parts[2]); if (major == null || minor == null || patch == null) return 1053000; return major * 1000000 + minor * 1000 + patch; } /// Build the UpsertUserAllApi packet. /// /// [generalUserInfo] is the result of `TitleApiService.fetchUserAllData`, /// keyed by the raw API names. Map build({ required int userId, required int loginId, required int loginDateTime, required Map musicData, required Map> generalUserInfo, }) { final userDataResp = generalUserInfo['GetUserDataApi'] ?? const {}; final userExtendResp = generalUserInfo['GetUserExtendApi'] ?? const {}; final userOptionResp = generalUserInfo['GetUserOptionApi'] ?? const {}; final userRatingResp = generalUserInfo['GetUserRatingApi'] ?? const {}; final userChargeResp = generalUserInfo['GetUserChargeApi'] ?? const {}; final userActivityResp = generalUserInfo['GetUserActivityApi'] ?? const {}; final userMissionResp = generalUserInfo['GetUserMissionDataApi'] ?? const {}; final userData = userDataResp['userData'] as Map? ?? const {}; final playerRating = _toInt(userData['playerRating']); final charaSlot = (userData['charaSlot'] as List? ?? const []) .map((e) => _toInt(e)) .toList(); final paddedCharaSlot = List.generate( 5, (i) => i < charaSlot.length ? charaSlot[i] : 0, ); final now = _shanghaiNow(); final playDate = _formatPlayDate(now); final playDateTime = _formatPlayDateTime(now); final loginDate = _formatPlayDateTime(DateTime.fromMillisecondsSinceEpoch(loginDateTime * 1000).toUtc().add(const Duration(hours: 8))); // Lionheart: GetUserDataApi 返回的部分字段不能出现在 UpsertUserAll 的 // userData 中,否则服务器会静默丢弃整包处理。 final newUserData = Map.from(userData) ..remove('friendCode') ..remove('nameplateId') ..remove('trophyId') ..remove('cmLastEmoneyBrand') ..remove('cmLastEmoneyCredit'); newUserData ..['accessCode'] = '' ..['isNetMember'] = 1 ..['playCount'] = _toInt(userData['playCount']) + 1 ..['currentPlayCount'] = _toInt(userData['currentPlayCount']) + 1 ..['lastGameId'] = 'SDGB' ..['lastLoginDate'] = loginDate ..['lastPlayDate'] = playDateTime ..['lastPlayCredit'] = 1 ..['lastPlayMode'] = 0 ..['lastPlaceId'] = config.placeId ..['lastPlaceName'] = config.placeName ..['lastAllNetId'] = 0 ..['lastRegionId'] = config.regionId ..['lastRegionName'] = config.regionName ..['lastClientId'] = config.clientId ..['lastCountryCode'] = 'CHN' ..['banState'] = userDataResp['banState'] ..['dateTime'] = loginDateTime; // Lionheart: userOption 需去掉 tempoVolume。 final userOption = userOptionResp['userOption'] as Map? ?? const {}; final newUserOption = Map.from(userOption) ..remove('tempoVolume'); // Lionheart: userRating.udemae 的大写重复字段需去掉。 final userRating = userRatingResp['userRating'] as Map? ?? const {}; final udemae = userRating['udemae'] as Map?; if (udemae != null) { for (final key in [ 'MaxLoseNum', 'NpcLoseNum', 'NpcMaxLoseNum', 'NpcMaxWinNum', 'NpcTotalLoseNum', 'NpcTotalWinNum', 'NpcWinNum', ]) { udemae.remove(key); } } // Lionheart: userChargeList 只保留 4 个字段 (无 extNum1)。 final userChargeList = [ for (final c in (userChargeResp['userChargeList'] as List? ?? const [])) if (c is Map) { 'chargeId': c['chargeId'], 'stock': c['stock'], 'purchaseDate': c['purchaseDate'], 'validDate': c['validDate'], }, ]; final missionList = userMissionResp['userMissionDataList'] as List? ?? const []; final missions = >[]; for (final raw in missionList.take(6)) { final m = raw as Map; missions.add({ 'type': m['type'], 'difficulty': m['difficulty'], 'targetGenreId': m['targetGenreId'], 'targetGenreTableId': m['targetGenreTableId'], 'conditionGenreId': m['conditionGenreId'], 'conditionGenreTableId': m['conditionGenreTableId'], 'clearFlag': m['clearFlag'], }); } final weekly = userMissionResp['userWeeklyData'] as Map? ?? const {}; final achievement = _toInt(musicData['achievement']); final gradeRating = _toInt(userData['gradeRating']); final playlog = { 'userId': 0, 'orderId': 0, 'playlogId': loginId, 'version': _convertVersionNumber( userData['lastRomVersion'] as String? ?? '', ), 'placeId': config.placeId, 'placeName': config.placeName, 'loginDate': loginDateTime, 'playDate': playDate, 'userPlayDate': playDateTime, 'type': 0, 'musicId': musicData['musicId'], 'level': musicData['level'], 'trackNo': 1, 'vsMode': 0, 'vsUserName': '', 'vsStatus': 0, 'vsUserRating': 0, 'vsUserAchievement': 0, 'vsUserGradeRank': 0, 'vsRank': 0, 'playerNum': 1, 'playedUserId1': 0, 'playedUserName1': '', 'playedMusicLevel1': 0, 'playedUserId2': 0, 'playedUserName2': '', 'playedMusicLevel2': 0, 'playedUserId3': 0, 'playedUserName3': '', 'playedMusicLevel3': 0, 'achievement': achievement, 'deluxscore': musicData['deluxscoreMax'], 'scoreRank': musicData['scoreRank'], 'maxCombo': 0, 'totalCombo': 1, 'maxSync': 1, 'totalSync': 0, 'tapCriticalPerfect': 0, 'tapPerfect': 0, 'tapGreat': 0, 'tapGood': 0, 'tapMiss': 1, 'holdCriticalPerfect': 0, 'holdPerfect': 0, 'holdGreat': 0, 'holdGood': 0, 'holdMiss': 0, 'slideCriticalPerfect': 0, 'slidePerfect': 0, 'slideGreat': 0, 'slideGood': 0, 'slideMiss': 0, 'touchCriticalPerfect': 0, 'touchPerfect': 0, 'touchGreat': 0, 'touchGood': 0, 'touchMiss': 0, 'breakCriticalPerfect': 0, 'breakPerfect': 0, 'breakGreat': 0, 'breakGood': 0, 'breakMiss': 0, 'isTap': true, 'isHold': false, 'isSlide': false, 'isTouch': false, 'isBreak': false, 'isCriticalDisp': false, 'isFastLateDisp': true, 'fastCount': 0, 'lateCount': 0, 'isAchieveNewRecord': false, 'isDeluxscoreNewRecord': false, 'comboStatus': musicData['comboStatus'], 'syncStatus': musicData['syncStatus'], 'isClear': achievement >= 500000, 'beforeRating': playerRating, 'afterRating': playerRating, 'beforeGrade': gradeRating, 'afterGrade': gradeRating, 'afterGradeRank': _toInt(userData['gradeRank']), 'beforeDeluxRating': playerRating, 'afterDeluxRating': playerRating, 'isPlayTutorial': false, 'isEventMode': false, 'isFreedomMode': false, 'playMode': 0, 'isNewFree': false, 'trialPlayAchievement': -1, 'extNum1': 0, 'extNum2': 0, 'extNum4': 0, 'extBool1': false, 'extBool2': false, 'extBool3': false, }; for (var i = 0; i < 5; i++) { playlog['characterId${i + 1}'] = paddedCharaSlot[i]; playlog['characterLevel${i + 1}'] = 1; playlog['characterAwakening${i + 1}'] = 0; } return { 'userId': userId, 'playlogId': loginId, 'isEventMode': false, 'isFreePlay': false, 'loginDateTime': loginDateTime, 'userPlaylogList': [playlog], 'upsertUserAll': { 'userData': [newUserData], 'userExtend': [userExtendResp['userExtend']], 'userOption': [newUserOption], 'userCharacterList': [], 'userGhost': [], 'userMapList': [], 'userLoginBonusList': [], 'userRatingList': [userRating], 'userItemList': [], 'userMusicDetailList': [musicData], 'userCourseList': [], 'userFriendSeasonRankingList': [], 'userChargeList': userChargeList, 'userFavoriteList': [], 'userActivityList': [userActivityResp['userActivity']], 'userMissionDataList': missions, 'userWeeklyData': { 'lastLoginWeek': weekly['lastLoginWeek'], 'beforeLoginWeek': weekly['beforeLoginWeek'], 'friendBonusFlag': weekly['friendBonusFlag'], }, 'userGamePlaylogList': [ { 'playlogId': loginId, 'version': userData['lastRomVersion'], 'playDate': playDateTime, 'playMode': 0, 'useTicketId': -1, 'playCredit': 1, 'playTrack': 1, 'clientId': config.clientId, 'isPlayTutorial': false, 'isEventMode': false, 'isNewFree': false, 'playCount': 0, 'playSpecial': TitleApiService.calcRandom(), 'playOtherUserId': 0, }, ], 'user2pPlaylog': { 'userId1': 0, 'userId2': 0, 'userName1': '', 'userName2': '', 'regionId': 0, 'placeId': 0, 'user2pPlaylogDetailList': [], }, 'userIntimateList': [], 'userShopItemStockList': [], 'userGetPointList': [], 'userTradeItemList': [], 'userFavoritemusicList': [], 'userKaleidxScopeList': [], 'isNewCharacterList': '', 'isNewMapList': '', 'isNewLoginBonusList': '', 'isNewItemList': '', 'isNewMusicDetailList': '0', 'isNewCourseList': '', 'isNewFavoriteList': '', 'isNewFriendSeasonRankingList': '', 'isNewUserIntimateList': '', 'isNewFavoritemusicList': '', 'isNewKaleidxScopeList': '', }, }; } /// Generic item patch: replace `userItemList` / `isNewItemList` in the packet. /// Used by UnlockMusic (itemKind 5/6/7) and 收藏品获取 (itemKind 1/2/3/10/11/12). void applyItemListPatch( Map packet, { required List> items, }) { final upsert = packet['upsertUserAll'] as Map; upsert['userItemList'] = items; upsert['isNewItemList'] = List.filled(items.length, '1').join(); } /// 解锁歌曲/谱面时补一条 musicDetail 记录并标记为新增。 /// 难度本身由 userItemList 的 itemKind 5/6/7 行表达(见客户端 /// VOExtensions.ExportUserItems),不写在 musicDetail 上。 void applyMusicDetailPatch( Map packet, { required Map musicData, }) { final upsert = packet['upsertUserAll'] as Map; upsert['userMusicDetailList'] = [musicData]; upsert['isNewMusicDetailList'] = '1'; } /// 发放/更新旅行伙伴:写 `userCharacterList` 并按行数生成 `isNewCharacterList`。 /// 客户端 BuildListData 只发增量、每位一个字符,所以这里只放要新增的行。 void applyCharacterListPatch( Map packet, { required List> characters, }) { final upsert = packet['upsertUserAll'] as Map; upsert['userCharacterList'] = characters; upsert['isNewCharacterList'] = List.filled(characters.length, '1').join(); } /// 编组旅行伙伴:改 `userData[0].charaSlot`(int[5],槽 0 为队长)。 /// 客户端 ExportUserPlaylog 用同一份 CharaSlot 填 playlog 的 /// characterId1..5,这里一起同步,避免 playlog 与 userData 互相矛盾。 void applyCharaSlotPatch( Map packet, { required List charaSlot, }) { final slots = normalizeCharaSlot(charaSlot); final upsert = packet['upsertUserAll'] as Map; final userDataList = upsert['userData'] as List; (userDataList.first as Map)['charaSlot'] = slots; for (final raw in (packet['userPlaylogList'] as List)) { final playlog = raw as Map; for (var i = 0; i < 5; i++) { playlog['characterId${i + 1}'] = slots[i]; } } } }