feat: 万花筒专区可设置门的通关状态
通关态与前两个动作一样落在 userKaleidxScopeList 同一行,是 isClear。 用三态下拉(不改动 / 已通关 / 未通关)而不是勾选框,因为通关可以往回改。 - 标记通关时补 clearDate,格式按客户端 TimeManager.GetDateString 的 yyyy-MM-dd HH:mm:ss.f;已有首次通关日期不覆盖,撤销通关时一起清空 - 上行是整行替换,只叠加要改的位,playCount / best 成绩 / 日期保持原值 - 通关态只在 found && key 的前提下才显示得出来,缺钥匙时给一条短提示 - 校验放宽:三个动作任选其一即可执行
This commit is contained in:
1 parent
44c4a1daae
commit
6438fb2171
7 files changed
+237
-23
No files matched your search
@@ -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`(万能钥匙门)以外没有任何清单,
|
||||
而且还要该门绑定的活动处于开启状态才会显示。
|
||||
|
||||
@@ -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)';
|
||||
|
||||
@@ -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(
|
||||
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) {
|
||||
|
||||
@@ -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<KaleidxScopePage> createState() => _KaleidxScopePageState();
|
||||
}
|
||||
|
||||
/// 通关态可以往两个方向改,所以用三态而不是又一个勾选框。
|
||||
enum _ClearChoice { keep, cleared, uncleared }
|
||||
|
||||
class _KaleidxScopePageState extends State<KaleidxScopePage>
|
||||
with CooldownMixin<KaleidxScopePage> {
|
||||
final _gateIdController = TextEditingController();
|
||||
@@ -44,6 +47,7 @@ class _KaleidxScopePageState extends State<KaleidxScopePage>
|
||||
|
||||
bool _discover = true;
|
||||
bool _giveKey = false;
|
||||
_ClearChoice _clearChoice = _ClearChoice.keep;
|
||||
|
||||
Map<String, Map<String, dynamic>> _userAllData = const {};
|
||||
List<UserKaleidxScopeBean> _serverScopes = const [];
|
||||
@@ -88,12 +92,30 @@ class _KaleidxScopePageState extends State<KaleidxScopePage>
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 整行替换,所以已存在的门必须拿服务器原行做底,只改那两个布尔位。
|
||||
bool? get _targetClear => switch (_clearChoice) {
|
||||
_ClearChoice.keep => null,
|
||||
_ClearChoice.cleared => true,
|
||||
_ClearChoice.uncleared => false,
|
||||
};
|
||||
|
||||
/// 整行替换,所以已存在的门必须拿服务器原行做底,只改要改的位。
|
||||
List<UserKaleidxScopeBean> _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<KaleidxScopePage>
|
||||
_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<KaleidxScopePage>
|
||||
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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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'], '');
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user