From 80df51c239473c60a833f47eb8b1313e7da63481 Mon Sep 17 00:00:00 2001 From: chuxuehaocai Date: Thu, 1 Oct 2026 22:14:50 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E8=A7=A3=E6=AD=8C=E7=9A=84=20isNewItemL?= =?UTF-8?q?ist=20=E6=8C=89=E4=B8=BB=E9=94=AE=E5=9B=9E=E8=AF=BB=E5=86=8D?= =?UTF-8?q?=E7=AE=97=EF=BC=8C=E4=B8=8D=E5=86=8D=E4=B8=80=E5=BE=8B=E5=8F=91?= =?UTF-8?q?=201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 排查「发 11820 解歌不显示」时发现两处问题: - applyItemListPatch 的 isNewItemList 一直硬写全 '1'(新增),从没读服务器 已有行。真机 BuildListData 是按 (itemKind, itemId) 比对快照给 0/1 的, 重复解锁一首已存在的歌时服务器按插入冲突丢行,表现就是上传成功但没生效。 这和角色/地图/万花筒三处的处理不一致,现补齐。 - 新增 TitleApiService.getUserItems/getUserItemKeys。GetUserItemApi 是按 itemKind 分页的,首次 nextIndex 要按 itemKind * 1e10 编码 (PacketGetUserItem.cs:19),发 0 只会拿到空页。 - 解锁上传后回读复查,把「服务器没存这行道具」与「存了但曲目池不给进」分开报。 - 待解锁列表含 10000~19999 的 DX 曲时提示:Master/Re:Master 不像老曲那样 白送(IsUnlockMaster 里 id<10000 直接放过),必须显式带 itemKind 6/7。 需要说明:一首歌进不进选曲池由 NotesListManager.cs:70 决定 (IsOpenEvent(eventName) && !IsGameNgMusicId),事件窗口来自 GetGameEventApi、 曲目本体来自机台 Music.xml。11820 若卡在这一层,任何用户包都改不动, 复查那一步就是为了把这种情况和包的问题区分开。 README 补 isNew*List 语义、曲目可见性链路与 DX 段解锁规则三条说明。 删除因此不再被引用的 unlockMusicSuccess。 --- README.md | 28 +++++++++ lib/config/strings.dart | 17 +++++- lib/pages/music_risk_feature_view.dart | 69 +++++++++++++++++++++- lib/services/title_api_service.dart | 56 ++++++++++++++++++ lib/services/user_all_payload_builder.dart | 9 ++- test/user_all_patch_test.dart | 38 ++++++++++++ 6 files changed, 213 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index c4628ad..a736147 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,34 @@ lib/ `0 ~ 99999`。`ratingList` / `newRatingList` 里每首歌各自的 Rating 原样带回, 不重排也不改值。占位 playlog 的 `before/afterRating`(及 `DeluxRating`)一起对齐成 新写的值,否则存档里那条记录与 `playerRating` 自相矛盾。 +- **`isNew*List` 每一位都是插入/更新标志,不能一律发 '1'**:真机 + `VOExtensions.BuildListData`(:335-355)拿本地新列表与服务器快照做 + `Except()` 增量,再按「服务器是否已有同主键行」给每位填 '0'(更新)或 + '1'(新增)。主键分别是 characterId / mapId / gateId / + **(itemKind, itemId)** / **(musicId, level)**。所以我们所有 patch 都要先回读 + 服务器状态再算这一串——`userItemList` 那处原先硬写全 '1',重复解锁一首 + 已存在的歌时会被服务器判成插入冲突丢掉,表现为「上传成功但没生效」, + 现在改成先 `GetUserItemApi` 回读再算标志位。 + `GetUserItemApi` 是**按 itemKind 分页**的,首次请求的 `nextIndex` 要按 + `itemKind * 1e10` 编码(`PacketGetUserItem.cs:19`),发 0 只会拿到空页。 +- **一首歌能不能出现在选曲界面,和用户包无关**:`NotesListManager.cs:70` + 只在 `IsOpenEvent(musicData.eventName.id) && !IsGameNgMusicId(id)` 时才把这首歌 + 放进全局曲池 `_notesList`。事件窗口来自服务器 `GetGameEventApi` + (`EventManager.cs:36` 要求 `startDate < 本机时间 < endDate`,只有 eventId=1 + 是硬编码常驻),曲目本体来自机台 `music/Music.xml`(`disable` 条目在加载时就被 + Remove)。所以「解歌上传成功但游戏里没有」有三种包根本治不了的原因: + 表里没这个 musicId、它绑的事件没在开、或它在 NG 名单里。页面上的复查会把 + 「服务器没存这行道具」和「存了但曲目池不给进」分开报。 +- **DX 段(10000~19999)的 Master / Re:Master 不白送**:`IsUnlockMaster` + 里 `id < 10000` 直接 `return true`,DX 段必须显式带 itemKind 6; + `IsUnlockReMaster` 还要 `subLockType == Unlock` 且 + `IsOpenEvent(subEventName.id)`,缺这一条会早退,压过 `id < 10000` 的捷径。 + 难度解锁查的是 `userItemList` 的 itemKind 5/6/7/8,**不是** + `userMusicDetailList`——后者只喂成绩字典,且它的 `level` 是难度序号 + (`MusicDifficultyID`,0..5),发到 6..9 会让下载侧 + `ScoreDic` 那个长度固定 6 的数组越界。 + `musicId >= 100000` 才有 `level → 10` 的重写(宴曲, + `ConstParameter.UtageDifficultyId`),与 DX 段无关。 - **万花筒的门、钥匙、通关在同一行**:`upsertUserAll.userKaleidxScopeList`, `UserKaleidxScope` 共 15 个字段、主键 `gateId`,「发现门」是 `isGateFound`、 「获取钥匙」是 `isKeyFound`、「已通关」是 `isClear`。钥匙**不走** diff --git a/lib/config/strings.dart b/lib/config/strings.dart index 3c96ba8..0aa034f 100644 --- a/lib/config/strings.dart +++ b/lib/config/strings.dart @@ -219,7 +219,22 @@ class AppStrings { static const unlockRunning = '执行中...'; static const unlockStepFetch = '拉取用户数据'; static const unlockStepUpload = '上传解锁数据'; - static const unlockMusicSuccess = '解锁数据上传完成'; + + static String unlockVerified(int count) => + '解锁数据上传完成,复查 GetUserItemApi 已确认 $count 行道具被服务器保存。' + '若游戏里还是没有这首歌,卡点在曲目池而不是用户包:机台 Music.xml 没有这个 ' + 'musicId、它绑定的 eventName 活动不在开放窗口内(GetGameEventApi)、' + '或者它落在 NG 名单里。这三种情况上传什么都不会显示。'; + static String unlockNotSaved(String list) => + '数据包已被接受,但复查时服务器上没有 $list 这些行 —— ' + '服务器没有写入 upsertUserAll.userItemList。'; + static String unlockVerifyFailed(String error) => + '解锁数据上传完成,但复查道具列表失败,无法确认是否写入:$error'; + static const unlockDxRangeNotice = + '10000 ~ 19999 这段 DX 曲:Master / Re:Master 不像老曲那样白送 —— ' + '客户端 IsUnlockMaster 里 id < 10000 直接返回 true,DX 段必须显式带上 ' + 'itemKind 6 / 7 的行。Re:Master 还要该曲 subEventName 指向的事件处于开放 ' + '窗口内,那一条用户包改不动。'; // Collectibles (收藏品获取 / UpsertUserAll 上传收藏品道具) // itemKind: 1=姓名框 2=称号 3=头像 10=搭档 11=背景板 12=功能票 diff --git a/lib/pages/music_risk_feature_view.dart b/lib/pages/music_risk_feature_view.dart index 467bb4a..ebf5ca0 100644 --- a/lib/pages/music_risk_feature_view.dart +++ b/lib/pages/music_risk_feature_view.dart @@ -126,6 +126,12 @@ class _MusicRiskFeatureViewState extends State bool get _isUnlock => widget.mode == MusicRiskFeatureMode.unlockMusic; + /// ConstParameter.DxScoreBaseID=10000 / StrongScoreBaseID=20000 + /// (`MAI2System/ConstParameter.cs:168,171`)。这段里的歌 Master 需要 + /// 显式 itemKind 6,不像 id < 10000 那样在 IsUnlockMaster 里直接放过。 + bool _isDeluxeRange(_PendingMusic music) => + music.musicId >= 10000 && music.musicId < 20000; + @override int? get cooldownLoginDateTime => widget.loginDateTime; @@ -310,7 +316,26 @@ class _MusicRiskFeatureViewState extends State generalUserInfo: data, ); - builder.applyItemListPatch(packet, items: _buildUserItemList()); + final items = _buildUserItemList(); + // isNewItemList 得按 (itemKind, itemId) 主键比对服务器快照来给 0/1, + // 不能一律发 '1':重复解锁一首已经存在的歌时,服务器会把插入冲突的 + // 行整行丢掉,表现就是「上传成功但没生效」。 + final existingKeys = await service.getUserItemKeys( + widget.userId, + items.map((e) => e['itemKind'] as int), + ); + builder.applyItemListPatch( + packet, + items: items, + newFlags: [ + for (final item in items) + existingKeys.contains( + '${item['itemKind']}:${item['itemId']}', + ) + ? '0' + : '1', + ].join(), + ); if (_isUnlock) { builder.applyMusicDetailPatch(packet, musicData: musicData); } @@ -320,7 +345,7 @@ class _MusicRiskFeatureViewState extends State _updateStep( RiskStep.complete, _isUnlock - ? AppStrings.unlockMusicSuccess + ? await _verifyUnlock(service, items) : AppStrings.collectiblesSuccess, ); @@ -342,6 +367,34 @@ class _MusicRiskFeatureViewState extends State } } + /// 上传后回读 GetUserItemApi,把「服务器根本没存这行道具」和「存了但游戏里 + /// 还是没有这首歌」分开——后者是曲目池门禁(机台 Music.xml 没这个 ID、 + /// `eventName` 指向的活动不在开放窗口内、或落在 NG 名单里), + /// 见 NotesListManager.cs:70,**任何用户数据包都改不动它**。 + Future _verifyUnlock( + TitleApiService service, + List> items, + ) async { + final requested = [ + for (final item in items) '${item['itemKind']}:${item['itemId']}', + ]; + Set saved; + try { + saved = await service.getUserItemKeys( + widget.userId, + items.map((e) => e['itemKind'] as int), + ); + } catch (e) { + return AppStrings.unlockVerifyFailed('$e'); + } + + final missing = requested.where((k) => !saved.contains(k)).toList(); + if (missing.isNotEmpty) { + return AppStrings.unlockNotSaved(missing.join(' ')); + } + return AppStrings.unlockVerified(requested.length); + } + String get _notLoggedInMessage => _isUnlock ? AppStrings.unlockNotLoggedIn : AppStrings.collectiblesNotLoggedIn; @@ -539,6 +592,18 @@ class _MusicRiskFeatureViewState extends State ), ], ), + // DX 段的 Master / Re:Master 不白送,勾选项里漏一个就开不出来, + // 这是这段 ID 最容易踩的坑,只在列表真含 DX 曲时才说明。 + if (_pendingMusics.any(_isDeluxeRange)) ...[ + const SizedBox(height: 10), + Text( + AppStrings.unlockDxRangeNotice, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.error, + height: 1.5, + ), + ), + ], ], ), ), diff --git a/lib/services/title_api_service.dart b/lib/services/title_api_service.dart index 8bb05fc..d743494 100644 --- a/lib/services/title_api_service.dart +++ b/lib/services/title_api_service.dart @@ -530,6 +530,62 @@ class TitleApiService { ); return UserKaleidxScopeBean.listFromResponse(json); } + + /// 某一类道具的持有情况,对应 UserItemResponseVO。 + /// + /// `GetUserItemApi` 是**按 itemKind 分页**的:客户端 + /// `PacketGetUserItem.cs:19` 首次请求把 `nextIndex` 塞成 + /// `itemKind * 10000000000`(`ConstParameter.ItemKindConvert = 1e10`), + /// 之后拿响应里的 `nextIndex` 续拉,直到它为 0。 + /// 直接发 `nextIndex: 0` 只会拿到空页,所以要照这个编码来。 + Future>> getUserItems( + int userId, + int itemKind, + ) async { + const apiName = 'GetUserItemApi'; + final all = >[]; + var nextIndex = itemKind * 10000000000; + var pages = 0; + + do { + final json = await _callApi(apiName, { + 'userId': userId, + 'nextIndex': nextIndex, + }, userId); + + final list = json['userItemList']; + if (list is List) { + all.addAll(list.whereType>()); + } + nextIndex = (json['nextIndex'] as num?)?.toInt() ?? 0; + pages++; + } while (nextIndex != 0 && pages < 40); + + return all; + } + + /// 一次读多个 itemKind,返回 `'kind:itemId'` 的键集合。 + /// + /// 用于给 `isNewItemList` 判每一位是 '1'(服务器没有这行、插入) + /// 还是 '0'(已有、更新)——`VOExtensions.BuildListData:274` 的 + /// userItemList 主键是 (itemKind, itemId) 二元组。 + Future> getUserItemKeys( + int userId, + Iterable itemKinds, + ) async { + final keys = {}; + await Future.wait([ + for (final kind in itemKinds.toSet()) + getUserItems(userId, kind).then((rows) { + for (final row in rows) { + final id = (row['itemId'] as num?)?.toInt(); + if (id == null) continue; + keys.add('$kind:$id'); + } + }), + ]); + return keys; + } } class _RandomHelper { diff --git a/lib/services/user_all_payload_builder.dart b/lib/services/user_all_payload_builder.dart index 1b72ebb..0383a0e 100644 --- a/lib/services/user_all_payload_builder.dart +++ b/lib/services/user_all_payload_builder.dart @@ -398,13 +398,20 @@ class UserAllPayloadBuilder { /// Generic item patch: replace `userItemList` / `isNewItemList` in the packet. /// Used by UnlockMusic (itemKind 5/6/7) and 收藏品获取 (itemKind 1/2/3/10/11/12). + /// + /// [newFlags] 每一位对应一行:'1' 服务器没有这行(插入)、'0' 已有(更新)。 + /// 省略时全按 '1' 发。真机 `BuildListData` 是按 (itemKind, itemId) 主键比对 + /// 服务器快照算出这一位的,所以重复解锁同一首歌时必须发 '0',否则服务器 + /// 可能当成插入冲突直接丢掉这一行。 void applyItemListPatch( Map packet, { required List> items, + String? newFlags, }) { + assert(newFlags == null || newFlags.length == items.length); final upsert = packet['upsertUserAll'] as Map; upsert['userItemList'] = items; - upsert['isNewItemList'] = List.filled(items.length, '1').join(); + upsert['isNewItemList'] = newFlags ?? List.filled(items.length, '1').join(); } /// 解锁歌曲/谱面时补一条 musicDetail 记录并标记为新增。 diff --git a/test/user_all_patch_test.dart b/test/user_all_patch_test.dart index 795b75c..fa17762 100644 --- a/test/user_all_patch_test.dart +++ b/test/user_all_patch_test.dart @@ -114,6 +114,44 @@ void main() { expect(upsert['userItemList'], hasLength(2)); expect(upsert['isNewItemList'], '11'); }); + + test('isNewItemList 可按主键比对给出 0/1,而不是一律 1', () { + final builder = UserAllPayloadBuilder(_config); + final packet = _buildPacket(builder); + + // 11820 的 Master 行服务器已有 -> '0'(更新),5 行是新的 -> '1'。 + builder.applyItemListPatch( + packet, + items: [ + {'itemKind': 6, 'itemId': 11820, 'stock': 1, 'isValid': true}, + {'itemKind': 5, 'itemId': 11820, 'stock': 1, 'isValid': true}, + ], + newFlags: '01', + ); + + final upsert = _upsert(packet); + expect(upsert['isNewItemList'], '01'); + expect( + (upsert['isNewItemList'] as String).length, + (upsert['userItemList'] as List).length, + ); + }); + + test('flags 与行数不一致时断言拦住', () { + final builder = UserAllPayloadBuilder(_config); + final packet = _buildPacket(builder); + + expect( + () => builder.applyItemListPatch( + packet, + items: [ + {'itemKind': 5, 'itemId': 11820, 'stock': 1, 'isValid': true}, + ], + newFlags: '11', + ), + throwsA(isA()), + ); + }); }); group('总 Rating patch', () {