fix: 解歌的 isNewItemList 按主键回读再算,不再一律发 1
排查「发 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。
This commit is contained in:
1 parent
6438fb2171
commit
80df51c239
6 files changed
+213
-4
No files matched your search
@@ -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`。钥匙**不走**
|
||||
|
||||
+16
-1
@@ -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=功能票
|
||||
|
||||
@@ -126,6 +126,12 @@ class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView>
|
||||
|
||||
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<MusicRiskFeatureView>
|
||||
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<MusicRiskFeatureView>
|
||||
_updateStep(
|
||||
RiskStep.complete,
|
||||
_isUnlock
|
||||
? AppStrings.unlockMusicSuccess
|
||||
? await _verifyUnlock(service, items)
|
||||
: AppStrings.collectiblesSuccess,
|
||||
);
|
||||
|
||||
@@ -342,6 +367,34 @@ class _MusicRiskFeatureViewState extends State<MusicRiskFeatureView>
|
||||
}
|
||||
}
|
||||
|
||||
/// 上传后回读 GetUserItemApi,把「服务器根本没存这行道具」和「存了但游戏里
|
||||
/// 还是没有这首歌」分开——后者是曲目池门禁(机台 Music.xml 没这个 ID、
|
||||
/// `eventName` 指向的活动不在开放窗口内、或落在 NG 名单里),
|
||||
/// 见 NotesListManager.cs:70,**任何用户数据包都改不动它**。
|
||||
Future<String> _verifyUnlock(
|
||||
TitleApiService service,
|
||||
List<Map<String, dynamic>> items,
|
||||
) async {
|
||||
final requested = [
|
||||
for (final item in items) '${item['itemKind']}:${item['itemId']}',
|
||||
];
|
||||
Set<String> 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<MusicRiskFeatureView>
|
||||
),
|
||||
],
|
||||
),
|
||||
// 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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<List<Map<String, dynamic>>> getUserItems(
|
||||
int userId,
|
||||
int itemKind,
|
||||
) async {
|
||||
const apiName = 'GetUserItemApi';
|
||||
final all = <Map<String, dynamic>>[];
|
||||
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<Map<String, dynamic>>());
|
||||
}
|
||||
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<Set<String>> getUserItemKeys(
|
||||
int userId,
|
||||
Iterable<int> itemKinds,
|
||||
) async {
|
||||
final keys = <String>{};
|
||||
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 {
|
||||
|
||||
@@ -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<String, dynamic> packet, {
|
||||
required List<Map<String, dynamic>> items,
|
||||
String? newFlags,
|
||||
}) {
|
||||
assert(newFlags == null || newFlags.length == items.length);
|
||||
final upsert = packet['upsertUserAll'] as Map<String, dynamic>;
|
||||
upsert['userItemList'] = items;
|
||||
upsert['isNewItemList'] = List.filled(items.length, '1').join();
|
||||
upsert['isNewItemList'] = newFlags ?? List.filled(items.length, '1').join();
|
||||
}
|
||||
|
||||
/// 解锁歌曲/谱面时补一条 musicDetail 记录并标记为新增。
|
||||
|
||||
@@ -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<AssertionError>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('总 Rating patch', () {
|
||||
|
||||
Reference in new issue
Block a user