diff --git a/README.md b/README.md index edf48de..c4628ad 100644 --- a/README.md +++ b/README.md @@ -98,16 +98,21 @@ lib/ `0 ~ 99999`。`ratingList` / `newRatingList` 里每首歌各自的 Rating 原样带回, 不重排也不改值。占位 playlog 的 `before/afterRating`(及 `DeluxRating`)一起对齐成 新写的值,否则存档里那条记录与 `playerRating` 自相矛盾。 -- **万花筒的门和钥匙在同一行**:`upsertUserAll.userKaleidxScopeList`, +- **万花筒的门、钥匙、通关在同一行**:`upsertUserAll.userKaleidxScopeList`, `UserKaleidxScope` 共 15 个字段、主键 `gateId`,「发现门」是 `isGateFound`、 - 「获取钥匙」是 `isKeyFound`。钥匙**不走** `userItemList`——`ExportUserItems` - 从不输出 itemKind 15,下行也没有读它的路径;`15000000 + keyId` 只是 present - ID 的编码段位。 + 「获取钥匙」是 `isKeyFound`、「已通关」是 `isClear`。钥匙**不走** + `userItemList`——`ExportUserItems` 从不输出 itemKind 15,下行也没有读它的路径; + `15000000 + keyId` 只是 present ID 的编码段位。 客户端状态机(`KaleidxScopeGateListController.cs:144-159`)里 `!isGateFound && isKeyFound` 判的是 **AnimState.None(隐形)**,不是「锁着的门」, - 所以给钥匙必须连带把门标为已发现;`found && !key` 才是可见未解锁。 + 所以给钥匙必须连带把门标为已发现;`found && !key` 才是可见未解锁; + 通关态只在 `found && key` 的前提下才显示得出来。 + 标记通关时 `clearDate` 落当前时间戳(客户端就是这么写的: + `clearDate = TimeManager.GetNowDateString()`,格式 + `yyyy-MM-dd HH:mm:ss.f`),已有首次通关日期的不覆盖;撤销通关则连 `clearDate` + 一起清空。 又因为上行是**整行替换**,页面上会先 `GetUserKaleidxScopeApi` 读回原行、 - 只叠加那两个布尔位再发出去,否则 best 成绩 / `playCount` / 日期会被清零。 + 只叠加要改的位再发出去,否则 best 成绩 / `playCount` / 日期会被清零。 合法 `gateId` 只存在于机台的 `KaleidxScopeGate.xml`,代码里除了 `ForceAddMasterKey` 写死的 `gateId = 7`(万能钥匙门)以外没有任何清单, 而且还要该门绑定的活动处于开启状态才会显示。 diff --git a/lib/config/strings.dart b/lib/config/strings.dart index c211de2..3c96ba8 100644 --- a/lib/config/strings.dart +++ b/lib/config/strings.dart @@ -361,7 +361,14 @@ class AppStrings { static const kaleidxGateIdLabel = '门 ID (gateId)'; static const kaleidxGateIdHint = '例如 3'; static const kaleidxNeedGateId = '请填写门 ID。'; - static const kaleidxNeedAction = '请至少勾选一项:发现门或获取钥匙。'; + static const kaleidxNeedAction = '请至少选一项:发现门、获取钥匙或通关状态。'; + static const kaleidxClearLabel = '通关状态'; + static const kaleidxClearKeep = '不改动'; + static const kaleidxClearCleared = '标记为已通关'; + static const kaleidxClearUncleared = '标记为未通关'; + static const kaleidxClearNeedsKey = + '通关态只在「已发现 + 有钥匙」的门上才显示得出来;缺钥匙时它仍算未解锁。' + '要一并补钥匙就勾上「获取钥匙」。标记未通关会连首次通关日期一起清空。'; static const kaleidxPendingEmpty = '列表为空,可在上方填写门 ID 后添加。'; static const kaleidxActionDiscover = '发现门 (isGateFound)'; static const kaleidxActionKey = '获取钥匙 (isKeyFound)'; diff --git a/lib/models/user_kaleidx_scope.dart b/lib/models/user_kaleidx_scope.dart index 193a9b5..5493a3b 100644 --- a/lib/models/user_kaleidx_scope.dart +++ b/lib/models/user_kaleidx_scope.dart @@ -72,6 +72,7 @@ class UserKaleidxScopeBean { bool? isGateFound, bool? isKeyFound, bool? isClear, + String? clearDate, }) => UserKaleidxScopeBean( gateId: gateId, isGateFound: isGateFound ?? this.isGateFound, @@ -85,29 +86,48 @@ class UserKaleidxScopeBean { bestAchievementDate: bestAchievementDate, bestDeluxscoreDate: bestDeluxscoreDate, playCount: playCount, - clearDate: clearDate, + clearDate: clearDate ?? this.clearDate, lastPlayDate: lastPlayDate, isInfoWatched: isInfoWatched, ); /// 服务器上还没有这扇门时的新行。`isGateFound` 恒为 true——不存在「只给钥匙 /// 不给门」这种可用状态(见类注释的状态机),新行其余字段取 struct 默认值。 - static UserKaleidxScopeBean discovered(int gateId, {required bool giveKey}) => - UserKaleidxScopeBean( - gateId: gateId, - isGateFound: true, - isKeyFound: giveKey, - ); + static UserKaleidxScopeBean discovered( + int gateId, { + required bool giveKey, + bool? clear, + String timestamp = '', + }) => UserKaleidxScopeBean( + gateId: gateId, + isGateFound: true, + isKeyFound: giveKey, + isClear: clear ?? false, + clearDate: (clear ?? false) ? timestamp : '', + ); /// 在服务器原行上叠加本次动作。**必须**走这里而不是新建一行:整行替换会把 /// best 成绩、playCount、clearDate 这些字段清零。 + /// + /// [clear] 传 null 表示不改动通关状态。给钥匙必须连带发现门,否则这行在 + /// 客户端是隐形的。 UserKaleidxScopeBean withActions({ required bool discover, required bool giveKey, + bool? clear, + String timestamp = '', }) => copyWith( - // 给钥匙连带发现门,否则这行在客户端是隐形的。 isGateFound: isGateFound || discover || giveKey, isKeyFound: isKeyFound || giveKey, + isClear: clear, + // 首次通关日期只在刚转成通关时落一个时间戳(客户端就是这么写的: + // `clearDate = TimeManager.GetNowDateString()`);撤销通关时一起清空, + // 留着一个日期比缺日期更容易让人误读。 + clearDate: clear == null + ? null + : (clear + ? (clearDate.isNotEmpty ? clearDate : timestamp) + : ''), ); static int _toInt(dynamic v) { diff --git a/lib/pages/kaleidx_scope_page.dart b/lib/pages/kaleidx_scope_page.dart index 265a523..2f69ecb 100644 --- a/lib/pages/kaleidx_scope_page.dart +++ b/lib/pages/kaleidx_scope_page.dart @@ -12,10 +12,10 @@ import '../widgets/app_notice.dart'; import '../widgets/cooldown_mixin.dart'; import '../widgets/step_progress.dart'; -/// 万花筒专区:发现新的宿命之门 + 获取门的钥匙。 +/// 万花筒专区:发现新的宿命之门、获取门的钥匙、设置门的通关状态。 /// -/// 两个动作落在 `upsertUserAll.userKaleidxScopeList` 同一行的两个布尔位上 -/// (`isGateFound` / `isKeyFound`)。钥匙不走 `userItemList`: +/// 三个动作都落在 `upsertUserAll.userKaleidxScopeList` 同一行的三个布尔位上 +/// (`isGateFound` / `isKeyFound` / `isClear`)。钥匙不走 `userItemList`: /// `ExportUserItems` 从不输出 itemKind 15,下行也没有读它的路径。 class KaleidxScopePage extends StatefulWidget { final int userId; @@ -37,6 +37,9 @@ class KaleidxScopePage extends StatefulWidget { State createState() => _KaleidxScopePageState(); } +/// 通关态可以往两个方向改,所以用三态而不是又一个勾选框。 +enum _ClearChoice { keep, cleared, uncleared } + class _KaleidxScopePageState extends State with CooldownMixin { final _gateIdController = TextEditingController(); @@ -44,6 +47,7 @@ class _KaleidxScopePageState extends State bool _discover = true; bool _giveKey = false; + _ClearChoice _clearChoice = _ClearChoice.keep; Map> _userAllData = const {}; List _serverScopes = const []; @@ -88,12 +92,30 @@ class _KaleidxScopePageState extends State return null; } - /// 整行替换,所以已存在的门必须拿服务器原行做底,只改那两个布尔位。 + bool? get _targetClear => switch (_clearChoice) { + _ClearChoice.keep => null, + _ClearChoice.cleared => true, + _ClearChoice.uncleared => false, + }; + + /// 整行替换,所以已存在的门必须拿服务器原行做底,只改要改的位。 List _buildRows() { + final clear = _targetClear; + final timestamp = UserAllPayloadBuilder.nowTimestamp(); return [ for (final id in _pending) - _serverRow(id)?.withActions(discover: _discover, giveKey: _giveKey) ?? - UserKaleidxScopeBean.discovered(id, giveKey: _giveKey), + _serverRow(id)?.withActions( + discover: _discover, + giveKey: _giveKey, + clear: clear, + timestamp: timestamp, + ) ?? + UserKaleidxScopeBean.discovered( + id, + giveKey: _giveKey, + clear: clear, + timestamp: timestamp, + ), ]; } @@ -139,7 +161,7 @@ class _KaleidxScopePageState extends State _snack(AppStrings.kaleidxNeedGateId); return; } - if (!_discover && !_giveKey) { + if (!_discover && !_giveKey && _clearChoice == _ClearChoice.keep) { _snack(AppStrings.kaleidxNeedAction); return; } @@ -366,10 +388,42 @@ class _KaleidxScopePageState extends State enabled: enabled, onChanged: (v) => setState(() => _giveKey = v), ), - if (_giveKey) ...[ - const SizedBox(height: 4), + const SizedBox(height: 10), + DropdownButtonFormField<_ClearChoice>( + initialValue: _clearChoice, + decoration: InputDecoration( + labelText: AppStrings.kaleidxClearLabel, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + ), + contentPadding: const EdgeInsets.all(14), + ), + items: const [ + DropdownMenuItem( + value: _ClearChoice.keep, + child: Text(AppStrings.kaleidxClearKeep), + ), + DropdownMenuItem( + value: _ClearChoice.cleared, + child: Text(AppStrings.kaleidxClearCleared), + ), + DropdownMenuItem( + value: _ClearChoice.uncleared, + child: Text(AppStrings.kaleidxClearUncleared), + ), + ], + onChanged: enabled + ? (v) => setState( + () => _clearChoice = v ?? _ClearChoice.keep, + ) + : null, + ), + if (_giveKey || _clearChoice == _ClearChoice.cleared) ...[ + const SizedBox(height: 6), Text( - AppStrings.kaleidxKeyNeedsGate, + _giveKey + ? AppStrings.kaleidxKeyNeedsGate + : AppStrings.kaleidxClearNeedsKey, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, height: 1.5, diff --git a/lib/services/user_all_payload_builder.dart b/lib/services/user_all_payload_builder.dart index 135cf02..1b72ebb 100644 --- a/lib/services/user_all_payload_builder.dart +++ b/lib/services/user_all_payload_builder.dart @@ -55,6 +55,11 @@ class UserAllPayloadBuilder { '${dt.year}-${_two(dt.month)}-${_two(dt.day)} ' '${_two(dt.hour)}:${_two(dt.minute)}:${_two(dt.second)}.0'; + /// 客户端 `TimeManager.GetDateString` 的格式是 + /// `ToString("yyyy-MM-dd HH:mm:ss.f")`,也就是带一位小数的秒。 + /// 万花筒的 `clearDate` 之类字段要按这个形状给。 + static String nowTimestamp() => _formatPlayDateTime(_shanghaiNow()); + static int _toInt(dynamic v) { if (v is int) return v; if (v is num) return v.toInt(); diff --git a/test/kaleidx_scope_page_test.dart b/test/kaleidx_scope_page_test.dart index 1a1754e..c46f7c7 100644 --- a/test/kaleidx_scope_page_test.dart +++ b/test/kaleidx_scope_page_test.dart @@ -40,7 +40,7 @@ Finder _runFinder(WidgetTester tester) => find.ancestor( ); void main() { - testWidgets('渲染门 ID 输入与两个动作勾选,且无溢出', (tester) async { + testWidgets('渲染门 ID 输入、两个勾选与通关下拉,且无溢出', (tester) async { await _pumpPage(tester, loggedIn: true); expect(tester.takeException(), isNull); @@ -71,7 +71,7 @@ void main() { expect(find.text(AppStrings.kaleidxPendingEmpty), findsNothing); }); - testWidgets('两个动作都不勾时被拦下', (tester) async { + testWidgets('三个动作都不选时被拦下', (tester) async { await _pumpPage(tester, loggedIn: true); await tester.enterText(find.byType(TextField), '3'); @@ -86,6 +86,37 @@ void main() { expect(find.text(AppStrings.kaleidxNeedAction), findsOneWidget); }); + testWidgets('通关状态下拉默认「不改动」,选已通关才提示需要钥匙', (tester) async { + await _pumpPage(tester, loggedIn: true); + + expect(find.text(AppStrings.kaleidxClearKeep), findsOneWidget); + expect(find.text(AppStrings.kaleidxClearNeedsKey), findsNothing); + + await tester.tap(find.text(AppStrings.kaleidxClearKeep)); + await tester.pumpAndSettle(); + await tester.tap(find.text(AppStrings.kaleidxClearCleared).last); + await tester.pumpAndSettle(); + + expect(find.text(AppStrings.kaleidxClearCleared), findsOneWidget); + expect(find.text(AppStrings.kaleidxClearNeedsKey), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('勾了钥匙时优先解释「钥匙离不开门」', (tester) async { + await _pumpPage(tester, loggedIn: true); + + await tester.tap(find.text(AppStrings.kaleidxActionKey)); + await tester.pump(); + await tester.tap(find.text(AppStrings.kaleidxClearKeep)); + await tester.pumpAndSettle(); + await tester.tap(find.text(AppStrings.kaleidxClearCleared).last); + await tester.pumpAndSettle(); + + expect(find.text(AppStrings.kaleidxKeyNeedsGate), findsOneWidget); + // 两条提示不叠着刷屏。 + expect(find.text(AppStrings.kaleidxClearNeedsKey), findsNothing); + }); + testWidgets('列表为空时执行被拦下', (tester) async { await _pumpPage(tester, loggedIn: true); diff --git a/test/user_all_patch_test.dart b/test/user_all_patch_test.dart index 9c74d20..795b75c 100644 --- a/test/user_all_patch_test.dart +++ b/test/user_all_patch_test.dart @@ -506,5 +506,97 @@ void main() { contains('隐形'), ); }); + + test('标记通关时落一个首次通关日期,已有日期不被覆盖', () { + const noDate = UserKaleidxScopeBean(gateId: 5); + final marked = noDate.withActions( + discover: true, + giveKey: true, + clear: true, + timestamp: '2026-10-01 12:00:00.0', + ); + expect(marked.isClear, isTrue); + expect(marked.clearDate, '2026-10-01 12:00:00.0'); + + const already = UserKaleidxScopeBean( + gateId: 5, + isGateFound: true, + isKeyFound: true, + isClear: true, + clearDate: '2026-01-01 00:00:00.0', + ); + final reMarked = already.withActions( + discover: false, + giveKey: false, + clear: true, + timestamp: '2026-10-01 12:00:00.0', + ); + // clearDate 是「首次」通关日期,不能被后来的一次操作改掉。 + expect(reMarked.clearDate, '2026-01-01 00:00:00.0'); + }); + + test('撤销通关把 clearDate 一起清掉', () { + const cleared = UserKaleidxScopeBean( + gateId: 6, + isGateFound: true, + isKeyFound: true, + isClear: true, + clearDate: '2026-01-01 00:00:00.0', + playCount: 3, + ); + + final undone = cleared.withActions( + discover: false, + giveKey: false, + clear: false, + ); + + expect(undone.isClear, isFalse); + expect(undone.clearDate, ''); + // 只动通关态,成绩与次数要原样留着。 + expect(undone.playCount, 3); + expect(undone.isKeyFound, isTrue); + }); + + test('通关状态选「不改动」时 null 不覆写原值', () { + const cleared = UserKaleidxScopeBean( + gateId: 8, + isGateFound: true, + isKeyFound: true, + isClear: true, + clearDate: '2026-01-01 00:00:00.0', + ); + + final kept = cleared.withActions(discover: true, giveKey: false); + + expect(kept.isClear, isTrue); + expect(kept.clearDate, '2026-01-01 00:00:00.0'); + }); + + test('新行也能直接建成已通关', () { + final row = UserKaleidxScopeBean.discovered( + 11, + giveKey: true, + clear: true, + timestamp: '2026-10-01 12:00:00.0', + ).toWireJson(); + + expect(row['isGateFound'], isTrue); + expect(row['isKeyFound'], isTrue); + expect(row['isClear'], isTrue); + expect(row['clearDate'], '2026-10-01 12:00:00.0'); + }); + + test('新行不选通关时 clearDate 留空', () { + final row = UserKaleidxScopeBean.discovered( + 11, + giveKey: false, + clear: false, + timestamp: '2026-10-01 12:00:00.0', + ).toWireJson(); + + expect(row['isClear'], isFalse); + expect(row['clearDate'], ''); + }); }); }