feat: 万花筒专区可设置门的通关状态

通关态与前两个动作一样落在 userKaleidxScopeList 同一行,是 isClear。
用三态下拉(不改动 / 已通关 / 未通关)而不是勾选框,因为通关可以往回改。

- 标记通关时补 clearDate,格式按客户端 TimeManager.GetDateString 的
  yyyy-MM-dd HH:mm:ss.f;已有首次通关日期不覆盖,撤销通关时一起清空
- 上行是整行替换,只叠加要改的位,playCount / best 成绩 / 日期保持原值
- 通关态只在 found && key 的前提下才显示得出来,缺钥匙时给一条短提示
- 校验放宽:三个动作任选其一即可执行
This commit is contained in:
Cookiues committed 2026-10-01 11:04:18 +08:00
1 parent 44c4a1daae
commit 6438fb2171
7 files changed
+237 -23

No files matched your search

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