feat: 风险页新增数值修改、一键跑图与万花筒专区

- 修改总 Rating:只写 playerRating 与 userRating.rating,钳 0~99999,
  每首歌各自的 ratingList 原样带回
- 添加舞里程(UserDetail.point):完整 int32 区间,支持累加与直接设定,
  允许负值即扣减
- 一键跑图:isClear/isComplete 在客户端是从 distance 派生的,单发 flag 会被
  冲掉,故把 distance 推过 End 里程针;unlockFlag 语义是反的
- 万花筒专区:发现门与获取钥匙落在 userKaleidxScopeList 同一行的两个布尔位,
  钥匙不走 userItemList;上行是整行替换,所以先读回原行再叠加
- 旅行伙伴:修正误导性的示例 ID 1001(合法是三位数,表里没有的 ID 服务器照收
  但客户端静默跳过),编组槽位可设定等级,上传后回读复查以区分失败原因
- 补 MIT 许可证
This commit is contained in:
Cookiues committed 2026-09-27 01:49:55 +08:00
1 parent 329845f228
commit 0808e367f7
15 files changed
+3597 -42

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 chuxuehaocai
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+46 -1
View File
@@ -15,7 +15,7 @@ Android / iOS / Windows / macOS / Linux。
| 主页 | Aime 登录(QR / 令牌)、Rating 详情、入坑信息、游玩统计、状态 |
| 票据 | 功能票查询与使用 |
| B50 | Best 50 成绩图,可导出 PNG(移植自 Empurple 的 `Best50ImageRenderer`) |
| 风险 | 歌曲解锁、收藏品获取、旅行伙伴发放与编组 |
| 风险 | 歌曲解锁、收藏品获取、旅行伙伴发放与编组(含等级)、修改总 Rating、添加舞里程、一键跑图、万花筒专区 |
| 设置 | Title Server / Auth Server / 机器参数,支持配置导入导出 |
## 开发
@@ -65,6 +65,51 @@ lib/
`upsertUserAll.userCharacterList`(`{characterId, level, awakening, useCount}`);
搭档是 `ItemKind.Partner = 10`,走 `userItemList`。出战编组是
`UserDetail.charaSlot`(`int[5]`,槽 0 为队长)。
- **角色 ID 必须在机台的 Chara 表里**:合法 ID 是三位数(101-105 / 201-205 /
301-306 / 392-395 / 401-405 / 501-505 / 601-605,取自客户端
`PlInformationProcess.AddDefaultCharacter`),不是四位数的 `1001`。填了表里
没有的 ID,服务器照样返回 `returnCode: 1`,但客户端 `CharacterSelectProces`
会静默跳过,游戏里看起来就是「加不上」。因此上传后会复查
`GetUserCharacterApi`,用来区分「服务器没写入」与「写入了但机台不认这个 ID」。
角色的 `awakening` 由客户端从 `level` 反推(`UserChara.CalcLevelToAwake`),
读回时并不取服务端值。
- **舞里程 = `UserDetail.point`**(余额),`totalPoint` 是累计获得量。客户端只在
「获得」时钳到 99999(`UserDetail.AddMile`),商店扣款走不带钳位的
`Point -= cost`,展示是裸的 `num.ToString()`,所以直接写更大的值不会被覆回。
取值是整个 int32 区间 `-2147483648 ~ 2147483647`——同 `playSpecial` 那次一样,
超出 C# `int` 会让服务器反序列化失败并回 500。允许负值,所以累加模式填负数
就是扣里程。
- **总 Rating 只改两处**:`userData.playerRating` 与 `userRating.rating`,取值钳在
`0 ~ 99999`。`ratingList` / `newRatingList` 里每首歌各自的 Rating 原样带回,
不重排也不改值。占位 playlog 的 `before/afterRating`(及 `DeluxRating`)一起对齐成
新写的值,否则存档里那条记录与 `playerRating` 自相矛盾。
- **万花筒的门和钥匙在同一行**:`upsertUserAll.userKaleidxScopeList`,
`UserKaleidxScope` 共 15 个字段、主键 `gateId`,「发现门」是 `isGateFound`、
「获取钥匙」是 `isKeyFound`。钥匙**不走** `userItemList`——`ExportUserItems`
从不输出 itemKind 15,下行也没有读它的路径;`15000000 + keyId` 只是 present
ID 的编码段位。
客户端状态机(`KaleidxScopeGateListController.cs:144-159`)里
`!isGateFound && isKeyFound` 判的是 **AnimState.None(隐形)**,不是「锁着的门」,
所以给钥匙必须连带把门标为已发现;`found && !key` 才是可见未解锁。
又因为上行是**整行替换**,页面上会先 `GetUserKaleidxScopeApi` 读回原行、
只叠加那两个布尔位再发出去,否则 best 成绩 / `playCount` / 日期会被清零。
合法 `gateId` 只存在于机台的 `KaleidxScopeGate.xml`,代码里除了
`ForceAddMasterKey` 写死的 `gateId = 7`(万能钥匙门)以外没有任何清单,
而且还要该门绑定的活动处于开启状态才会显示。
- **旅行伙伴等级**:真实等级区间 `1 ~ 999999`,界面显示的等级是 `level % 10000`、
转生次数是 `level ~/ 10000`,所以 `999999` = 99 转生 + 9999 级。给已有角色改等级要发
`isNewCharacterList` 对应位为 `'0'` 的更新行(`'1'` 是插入),且必须带上原有
`useCount`,否则整行覆盖会把使用次数清零。
- **一键跑图只发得动「完成态」**:区域进度是 `upsertUserAll.userMapList`,行结构
`{mapId, distance, isLock, isClear, isComplete, unlockFlag}`,主键 mapId。
`isClear` / `isComplete` 在客户端是从 `distance` 派生的
(`MapMaster.CreateUserDataMapList` 拿 distance 与该区域的 ReleaseFlag / End
里程针比较,没有对应针时甚至强制写回 false),所以单发 flag 下次进区域选择页就被冲掉;
这里把 `distance` 直接推到 `UserMapData.MaxDistance = 999999999`。`unlockFlag`
语义是反的(`IsFinishedOpening ? 0 : 1`,取 0 才是已开启),`isLock` 客户端根本不回读。
**该区域的收藏品发不了**:`区域 → 宝藏 → 物品` 的对应关系在机台的 `Map.xml` /
`MapTreasure.xml` 里(`DataManager.LoadMaps` / `LoadMapTresures` 从本地数据目录读,
不经 title server),App 没有这份表,页面里也明写了这个限制。
## 构建环境注意事项
+144 -1
View File
@@ -254,13 +254,38 @@ class AppStrings {
static const travelPartnerGrantTitle = '发放新的旅行伙伴';
static const travelPartnerGrantEmpty = '未添加待发放角色。';
static const travelPartnerCharacterIdLabel = '旅行伙伴 ID';
static const travelPartnerCharacterIdHint = '例如 1001';
static const travelPartnerCharacterIdHint = '例如 101';
static const travelPartnerNeedCharacterId = '请填写旅行伙伴 ID。';
static const travelPartnerAlreadyOwned = '该旅行伙伴已拥有,无需重复发放。';
static const travelPartnerIdRangeTitle = '角色 ID 对照';
static const travelPartnerIdRangeBody =
'101-105 · 201-205 · 301-306 · 392-395 · 401-405 · 501-505 · 601-605\n'
'编号按颜色分段,每段内递增。这个表取自客户端内置的默认发放清单,机台的 '
'Chara.xml 可能更多。\n\n'
'ID 不在机台表里时服务器照样保存,但客户端 CharacterSelectProces 会静默跳过,'
'游戏里就是「加不上」。';
static const travelPartnerIdUnknown = '该 ID 不在已知的默认角色清单里,机台表里也可能有,注意确认。';
// 上传后复查 GetUserCharacterApi,用来区分「服务器没存」和「存了但游戏不显示」
static String travelPartnerVerified(int count) =>
'旅行伙伴数据上传完成,复查 GetUserCharacterApi 已确认服务器保存了 $count 个角色。'
'若游戏里仍看不到,说明这些 ID 不在机台的 Chara 表里。';
static String travelPartnerNotSaved(String ids) =>
'数据包已被接受,但复查时服务器上没有 #$ids —— '
'服务器没有写入 upsertUserAll.userCharacterList。';
static String travelPartnerVerifyFailed(String error) =>
'旅行伙伴数据上传完成,但复查角色列表失败,无法确认是否写入:$error';
static const travelPartnerSlotTitle = '编组出战槽位';
static const travelPartnerSlotNone = '空';
static String travelPartnerSlotLabel(int index) =>
index == 0 ? '槽 0(队长)' : '槽 $index';
static const travelPartnerLevelLabel = '等级(留空不改)';
static const travelPartnerLevelHint = '1 ~ 999999';
static const travelPartnerLevelInvalid = '旅行伙伴等级需要在 1 ~ 999999 之间。';
static String travelPartnerLevelConverted(int realLevel) =>
'真实等级 $realLevel = ${realLevel ~/ 10000} 转生 + ${realLevel % 10000} 级';
static const travelPartnerCopySlot0 = '把槽 0 复制到其他槽位';
static const travelPartnerCopyNeedSlot0 = '槽 0 还没有选择旅行伙伴。';
static const travelPartnerNotOwnedHint = '编组失败:你并不拥有该旅行伙伴。';
@@ -270,6 +295,124 @@ class AppStrings {
static const travelPartnerStepFetch = '拉取角色与用户数据';
static const travelPartnerStepUpload = '上传旅行伙伴数据';
// MapTraveller 一键跑图(UserMap 走 userMapList,收藏品另走 userItemList)
static const mapFeatureTitle = '一键跑图';
static const mapFeatureDesc =
'把区域标记为已完成。isClear / isComplete 在客户端是从 distance 派生的,'
'所以这里把 distance 直接推到 999999999(UserMapData.MaxDistance),'
'超过任何一张图的 End 里程针,状态才不会被下一次进区域选择页冲掉。';
static const mapNotLoggedIn = '尚未登录游戏服务器,无法操作区域进度。';
static String mapCooldownNotice(int remaining) =>
'登录后需冷却 $ticketCooldownSeconds 秒,剩余 $remaining 秒后可操作区域进度。';
static const mapIdLabel = '区域 ID';
static const mapIdHint = '例如 550001';
static const mapNeedMapId = '请填写要标记完成的区域 ID。';
static const mapPendingEmpty = '列表为空,可在上方填写区域 ID 后添加。';
static const mapRun = '标记区域已完成';
static const mapRunning = '执行中...';
static const mapStepFetch = '拉取区域进度';
static const mapStepUpload = '上传区域进度';
static const mapFetchData = '拉取区域数据';
static const mapFetched = '区域数据已拉取';
static const mapNoData = '⚠️ 尚未拉取区域进度。';
static String mapCompleted(int mapId) => '区域 #$mapId 已标记为完成';
static String mapVerified(int count) =>
'上传完成,复查 GetUserMapApi 已确认 $count 个区域的进度被保存。'
'若游戏里区域仍显示未解锁,多半是 distance 还不够或该 mapId 不在机台表里。';
static String mapNotSaved(String ids) =>
'数据包已被接受,但复查时服务器上没有 #$ids 的完成记录 —— '
'服务器没有写入 upsertUserAll.userMapList。';
static String mapVerifyFailed(String error) =>
'上传完成,但复查区域进度失败,无法确认是否写入:$error';
static const mapCollectiblesNotice =
'只标记完成,不发放该区域的收藏品——对应关系在机台的 Map.xml / '
'MapTreasure.xml 里,App 拿不到。要收藏品请用「收藏品获取」。';
// Kaleidoscope 万花筒专区(UserKaleidxScope 走 userKaleidxScopeList)
static const kaleidxFeatureTitle = '万花筒专区';
static const kaleidxFeatureDesc =
'发现新的宿命之门、获取门的钥匙。两者都是 userKaleidxScopeList 里同一行的 '
'isGateFound / isKeyFound,钥匙不走 userItemList。每行是整行替换,'
'所以上传前会先读回原行的成绩与日期再合并。';
static const kaleidxNotLoggedIn = '尚未登录游戏服务器,无法操作万花筒。';
static String kaleidxCooldownNotice(int remaining) =>
'登录后需冷却 $ticketCooldownSeconds 秒,剩余 $remaining 秒后可操作万花筒。';
static const kaleidxGateIdLabel = '门 ID (gateId)';
static const kaleidxGateIdHint = '例如 3';
static const kaleidxNeedGateId = '请填写门 ID。';
static const kaleidxNeedAction = '请至少勾选一项:发现门或获取钥匙。';
static const kaleidxPendingEmpty = '列表为空,可在上方填写门 ID 后添加。';
static const kaleidxActionDiscover = '发现门 (isGateFound)';
static const kaleidxActionKey = '获取钥匙 (isKeyFound)';
static const kaleidxKeyNeedsGate =
'只给钥匙不给门的话这扇门是隐形的(客户端状态机把 !found && key 判为 None),'
'所以勾了钥匙会连带把门标为已发现。';
static const kaleidxRun = '执行万花筒操作';
static const kaleidxRunning = '执行中...';
static const kaleidxStepFetch = '拉取万花筒进度';
static const kaleidxStepUpload = '上传万花筒进度';
static const kaleidxFetchData = '拉取门进度';
static const kaleidxFetched = '门进度已拉取';
static const kaleidxNoData = '⚠️ 尚未拉取门进度,无法合并原行字段。';
static const kaleidxGateIdDuplicated = '同一扇门只能出现一次。';
static String kaleidxVerified(int count) =>
'上传完成,复查 GetUserKaleidxScopeApi 已确认 $count 扇门的状态被保存。'
'若游戏里仍看不到,说明这个 gateId 不在机台的 KaleidxScopeGate.xml 里,'
'或它绑定的活动没在开。';
static String kaleidxNotSaved(String ids) =>
'数据包已被接受,但复查时服务器上没有 #$ids 的行 —— '
'服务器没有写入 upsertUserAll.userKaleidxScopeList。';
static String kaleidxVerifyFailed(String error) =>
'上传完成,但复查万花筒进度失败,无法确认是否写入:$error';
static String kaleidxGateState(bool found, bool key, bool clear) {
if (!found && key) return '隐形(有钥匙但没发现门)';
if (!found) return '未见过';
if (!key) return '已发现 · 未解锁';
if (!clear) return '可挑战';
return '已通关';
}
// NumberPatch 数值修改(修改 Rating / 添加舞里程),都走 UpsertUserAllApi
static const numberPatchNotLoggedIn = '尚未登录游戏服务器,无法执行修改。';
static const numberPatchRun = '执行修改';
static const numberPatchStepUpload = '上传修改后的数据';
static String numberPatchCooldownNotice(int remaining) =>
'登录后需冷却 $ticketCooldownSeconds 秒,剩余 $remaining 秒后可执行修改。';
static String numberPatchOutOfRange(int min, int max) =>
'数值超出范围($min ~ $max)。';
static String numberPatchRangeHint(int min, int max) => '可填 $min ~ $max';
static String numberPatchPreview(String label, int current, int target) =>
'$label: $current → $target';
// Rating 修改(只改总 Rating,不动每首歌的 Rating)
static const ratingFeatureTitle = '修改 Rating';
static const ratingFeatureDesc =
'只写总 Rating(userData.playerRating 与 userRating.rating 两处),取值 0 ~ 99999。'
'ratingList / newRatingList 里每首歌各自的 Rating 原样带回不动。'
'包里带一条占位 playlog,before/after Rating 一并改成新旧值。';
static const ratingValueLabel = '目标总 Rating';
static const ratingValueHint = '例如 15000';
static const ratingNeedValue = '请填写目标 Rating。';
static const ratingSuccess = 'Rating 上传完成';
static const ratingCurrentValue = '当前总 Rating';
// MaiMile 舞里程(UserDetail.point 余额 / totalPoint 累计)
static const maiMileFeatureTitle = '添加舞里程';
static const maiMileFeatureDesc =
'舞里程就是 UserDetail.point(余额),totalPoint 为累计获得量。'
'客户端只在「获得」时把余额钳到 99999(UserDetail.AddMile),'
'商店扣款走不带钳位的 Point -= cost,所以写更大的值不会被覆回。'
'取值是整个 int32 区间,累加模式填负数即为扣减。';
static const maiMileValueLabel = '舞里程数值';
static const maiMileValueHint = '例如 10000';
static const maiMileNeedValue = '请填写舞里程数值。';
static const maiMileModeLabel = '写入方式';
static const maiMileModeAdd = '累加(当前值 + N)';
static const maiMileModeSet = '直接设定为 N';
static const maiMileCurrentBalance = '当前舞里程';
static const maiMileSuccess = '舞里程上传完成';
// HighRiskFeature hub (中转页)
static const musicRiskHubTitle = '高危功能';
static const musicRiskHubDesc = '以下功能会向服务器发送伪造数据,属于高风险操作,请谨慎使用。';
+156
View File
@@ -0,0 +1,156 @@
/// `Net.VO.Mai2.UserKaleidxScope`(万花筒 / 宿命之门),走
/// `upsertUserAll.userKaleidxScopeList`,主键是 `gateId`。
///
/// 依据反编译客户端:
/// - 「发现门」与「获取钥匙」都在这同一张表里,分别是 `isGateFound` 与
/// `isKeyFound`。钥匙**不**走 `userItemList`——`ExportUserItems` 从不输出
/// itemKind 15,下行也没有 `GetUserItem(KaleidxScopeKey)` 这一步。
/// - 状态机在 `KaleidxScopeGateListController.cs:144-159`,四个组合里:
/// `!found && key` -> AnimState.None(**隐形**,不是锁着的门)
/// ` found && !key` -> Close(可见、未解锁)
/// ` found && key && !clear` -> Open(可挑战)
/// ` found && key && clear` -> Clear
/// 所以只给钥匙不给门等于什么都没给。
class UserKaleidxScopeBean {
final int gateId;
final bool isGateFound;
final bool isKeyFound;
final bool isClear;
final int totalRestLife;
final int totalAchievement;
/// 原样拼写是 `Deluxscore`(不是 `DeluxeScore`),改名服务器就认不出了。
final int totalDeluxscore;
final int bestAchievement;
final int bestDeluxscore;
final String bestAchievementDate;
final String bestDeluxscoreDate;
final int playCount;
final String clearDate;
final String lastPlayDate;
final bool isInfoWatched;
const UserKaleidxScopeBean({
required this.gateId,
this.isGateFound = false,
this.isKeyFound = false,
this.isClear = false,
this.totalRestLife = 0,
this.totalAchievement = 0,
this.totalDeluxscore = 0,
this.bestAchievement = 0,
this.bestDeluxscore = 0,
this.bestAchievementDate = '',
this.bestDeluxscoreDate = '',
this.playCount = 0,
this.clearDate = '',
this.lastPlayDate = '',
this.isInfoWatched = false,
});
/// wire 上的完整 15 个字段。发出去的是整行替换,所以没列在这里的字段
/// 必须从服务器原值带回来,否则会被清零。
Map<String, dynamic> toWireJson() => {
'gateId': gateId,
'isGateFound': isGateFound,
'isKeyFound': isKeyFound,
'isClear': isClear,
'totalRestLife': totalRestLife,
'totalAchievement': totalAchievement,
'totalDeluxscore': totalDeluxscore,
'bestAchievement': bestAchievement,
'bestDeluxscore': bestDeluxscore,
'bestAchievementDate': bestAchievementDate,
'bestDeluxscoreDate': bestDeluxscoreDate,
'playCount': playCount,
'clearDate': clearDate,
'lastPlayDate': lastPlayDate,
'isInfoWatched': isInfoWatched,
};
UserKaleidxScopeBean copyWith({
bool? isGateFound,
bool? isKeyFound,
bool? isClear,
}) => UserKaleidxScopeBean(
gateId: gateId,
isGateFound: isGateFound ?? this.isGateFound,
isKeyFound: isKeyFound ?? this.isKeyFound,
isClear: isClear ?? this.isClear,
totalRestLife: totalRestLife,
totalAchievement: totalAchievement,
totalDeluxscore: totalDeluxscore,
bestAchievement: bestAchievement,
bestDeluxscore: bestDeluxscore,
bestAchievementDate: bestAchievementDate,
bestDeluxscoreDate: bestDeluxscoreDate,
playCount: playCount,
clearDate: clearDate,
lastPlayDate: lastPlayDate,
isInfoWatched: isInfoWatched,
);
/// 服务器上还没有这扇门时的新行。`isGateFound` 恒为 true——不存在「只给钥匙
/// 不给门」这种可用状态(见类注释的状态机),新行其余字段取 struct 默认值。
static UserKaleidxScopeBean discovered(int gateId, {required bool giveKey}) =>
UserKaleidxScopeBean(
gateId: gateId,
isGateFound: true,
isKeyFound: giveKey,
);
/// 在服务器原行上叠加本次动作。**必须**走这里而不是新建一行:整行替换会把
/// best 成绩、playCount、clearDate 这些字段清零。
UserKaleidxScopeBean withActions({
required bool discover,
required bool giveKey,
}) => copyWith(
// 给钥匙连带发现门,否则这行在客户端是隐形的。
isGateFound: isGateFound || discover || giveKey,
isKeyFound: isKeyFound || giveKey,
);
static int _toInt(dynamic v) {
if (v is int) return v;
if (v is num) return v.toInt();
if (v is String) return int.tryParse(v) ?? 0;
return 0;
}
static bool _toBool(dynamic v) {
if (v is bool) return v;
if (v is num) return v != 0;
if (v is String) return v == '1' || v.toLowerCase() == 'true';
return false;
}
factory UserKaleidxScopeBean.fromJson(Map<String, dynamic> json) =>
UserKaleidxScopeBean(
gateId: _toInt(json['gateId']),
isGateFound: _toBool(json['isGateFound']),
isKeyFound: _toBool(json['isKeyFound']),
isClear: _toBool(json['isClear']),
totalRestLife: _toInt(json['totalRestLife']),
totalAchievement: _toInt(json['totalAchievement']),
totalDeluxscore: _toInt(json['totalDeluxscore']),
bestAchievement: _toInt(json['bestAchievement']),
bestDeluxscore: _toInt(json['bestDeluxscore']),
bestAchievementDate: json['bestAchievementDate']?.toString() ?? '',
bestDeluxscoreDate: json['bestDeluxscoreDate']?.toString() ?? '',
playCount: _toInt(json['playCount']),
clearDate: json['clearDate']?.toString() ?? '',
lastPlayDate: json['lastPlayDate']?.toString() ?? '',
isInfoWatched: _toBool(json['isInfoWatched']),
);
static List<UserKaleidxScopeBean> listFromResponse(
Map<String, dynamic> json,
) {
final raw = json['userKaleidxScopeList'];
if (raw is! List) return const [];
return raw
.whereType<Map<String, dynamic>>()
.map(UserKaleidxScopeBean.fromJson)
.toList();
}
}
+80 -2
View File
@@ -6,11 +6,14 @@ import '../config/responsive.dart';
import '../config/strings.dart';
import '../config/title_server_config.dart';
import 'collectibles_page.dart';
import 'kaleidx_scope_page.dart';
import 'map_traverse_page.dart';
import 'number_patch_page.dart';
import 'travel_partner_page.dart';
import 'unlock_music_page.dart';
/// 高危功能中转页:导航到 UnlockMusic (解锁歌曲) / 收藏品获取。
/// 两个 feature 互相独立,各自的执行流程互不影响。
/// 高危功能中转页:导航到解锁歌曲 / 收藏品获取 / 旅行伙伴 / 数值修改。
/// 各 feature 互相独立,执行流程互不影响。
class HighRiskFeaturePage extends StatefulWidget {
final int userId;
final String? cookies;
@@ -135,6 +138,49 @@ class _HighRiskFeaturePageState extends State<HighRiskFeaturePage> {
);
}
void _openNumberPatch(NumberPatchMode mode) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => NumberPatchPage(
userId: widget.userId,
cookies: widget.cookies,
loginDateTime: widget.loginDateTime,
loginId: widget.loginId,
onExitToTitle: widget.onExitToTitle,
mode: mode,
),
),
);
}
void _openMapTraverse() {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => MapTraversePage(
userId: widget.userId,
cookies: widget.cookies,
loginDateTime: widget.loginDateTime,
loginId: widget.loginId,
onExitToTitle: widget.onExitToTitle,
),
),
);
}
void _openKaleidxScope() {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => KaleidxScopePage(
userId: widget.userId,
cookies: widget.cookies,
loginDateTime: widget.loginDateTime,
loginId: widget.loginId,
onExitToTitle: widget.onExitToTitle,
),
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
@@ -191,6 +237,38 @@ class _HighRiskFeaturePageState extends State<HighRiskFeaturePage> {
desc: AppStrings.travelPartnerFeatureDesc,
onTap: _openTravelPartner,
),
const SizedBox(height: 12),
_buildNavCard(
theme,
icon: Icons.speed,
title: AppStrings.ratingFeatureTitle,
desc: AppStrings.ratingFeatureDesc,
onTap: () => _openNumberPatch(NumberPatchMode.rating),
),
const SizedBox(height: 12),
_buildNavCard(
theme,
icon: Icons.commute,
title: AppStrings.maiMileFeatureTitle,
desc: AppStrings.maiMileFeatureDesc,
onTap: () => _openNumberPatch(NumberPatchMode.maiMile),
),
const SizedBox(height: 12),
_buildNavCard(
theme,
icon: Icons.map_outlined,
title: AppStrings.mapFeatureTitle,
desc: AppStrings.mapFeatureDesc,
onTap: _openMapTraverse,
),
const SizedBox(height: 12),
_buildNavCard(
theme,
icon: Icons.science_outlined,
title: AppStrings.kaleidxFeatureTitle,
desc: AppStrings.kaleidxFeatureDesc,
onTap: _openKaleidxScope,
),
],
),
),
+695
View File
@@ -0,0 +1,695 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../config/responsive.dart';
import '../config/strings.dart';
import '../config/title_server_config.dart';
import '../models/user_kaleidx_scope.dart';
import '../services/title_api_service.dart';
import '../services/user_all_payload_builder.dart';
/// 万花筒专区:发现新的宿命之门 + 获取门的钥匙。
///
/// 两个动作落在 `upsertUserAll.userKaleidxScopeList` 同一行的两个布尔位上
/// (`isGateFound` / `isKeyFound`)。钥匙不走 `userItemList`:
/// `ExportUserItems` 从不输出 itemKind 15,下行也没有读它的路径。
class KaleidxScopePage extends StatefulWidget {
final int userId;
final String? cookies;
final int? loginDateTime;
final int? loginId;
final Future<void> Function()? onExitToTitle;
const KaleidxScopePage({
super.key,
required this.userId,
this.cookies,
this.loginDateTime,
this.loginId,
this.onExitToTitle,
});
@override
State<KaleidxScopePage> createState() => _KaleidxScopePageState();
}
enum _Step { idle, fetchData, upload, logout, complete, failed }
class _KaleidxScopePageState extends State<KaleidxScopePage> {
/// 与其它高危功能一致:不写真实成绩,playlog 用同一条占位记录。
static const int _placeholderMusicId = 11538;
final _gateIdController = TextEditingController();
final List<int> _pending = [];
bool _discover = true;
bool _giveKey = false;
Map<String, Map<String, dynamic>> _userAllData = const {};
List<UserKaleidxScopeBean> _serverScopes = const [];
bool _loading = false;
bool _running = false;
bool _autoLogout = true;
_Step _step = _Step.idle;
String _stepMessage = '';
Timer? _cooldownTimer;
int _cooldownRemaining = 0;
bool get _loggedIn => widget.loginDateTime != null;
@override
void initState() {
super.initState();
_syncCooldown();
}
@override
void didUpdateWidget(covariant KaleidxScopePage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.loginDateTime != widget.loginDateTime) {
_syncCooldown();
}
}
@override
void dispose() {
_cooldownTimer?.cancel();
_gateIdController.dispose();
super.dispose();
}
void _syncCooldown() {
_cooldownTimer?.cancel();
final loginDateTime = widget.loginDateTime;
if (loginDateTime == null) {
setState(() => _cooldownRemaining = 0);
return;
}
setState(() => _cooldownRemaining = _computeRemaining(loginDateTime));
if (_cooldownRemaining <= 0) return;
_cooldownTimer = Timer.periodic(const Duration(seconds: 1), (_) {
if (!mounted) return;
final remaining = _computeRemaining(loginDateTime);
setState(() => _cooldownRemaining = remaining);
if (remaining <= 0) {
_cooldownTimer?.cancel();
_cooldownTimer = null;
}
});
}
int _computeRemaining(int loginDateTime) {
final nowSec = DateTime.now().millisecondsSinceEpoch ~/ 1000;
final remaining =
AppStrings.ticketCooldownSeconds - (nowSec - loginDateTime);
return remaining < 0 ? 0 : remaining;
}
void _snack(String message) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
}
void _addPending() {
final id = int.tryParse(_gateIdController.text.trim()) ?? -1;
if (id <= 0) {
_snack(AppStrings.kaleidxNeedGateId);
return;
}
if (_pending.contains(id)) {
_snack(AppStrings.kaleidxGateIdDuplicated);
return;
}
setState(() {
_pending.add(id);
_gateIdController.clear();
});
}
UserKaleidxScopeBean? _serverRow(int gateId) {
for (final row in _serverScopes) {
if (row.gateId == gateId) return row;
}
return null;
}
/// 整行替换,所以已存在的门必须拿服务器原行做底,只改那两个布尔位。
List<UserKaleidxScopeBean> _buildRows() {
return [
for (final id in _pending)
_serverRow(id)?.withActions(discover: _discover, giveKey: _giveKey) ??
UserKaleidxScopeBean.discovered(id, giveKey: _giveKey),
];
}
Future<void> _fetchData() async {
if (!TitleServerConfigHolder().isConfigured) return;
setState(() => _loading = true);
try {
final config = TitleServerConfigHolder().config!;
final service = TitleApiService(config, cookies: widget.cookies);
final results = await Future.wait([
service.fetchUserAllData(widget.userId),
service.getUserKaleidxScopes(widget.userId),
]);
if (!mounted) return;
setState(() {
_userAllData = results[0] as Map<String, Map<String, dynamic>>;
_serverScopes = results[1] as List<UserKaleidxScopeBean>;
_loading = false;
});
} catch (e) {
if (!mounted) return;
setState(() => _loading = false);
_snack('${AppStrings.loadFailed}: $e');
}
}
Future<void> _run() async {
if (!TitleServerConfigHolder().isConfigured) {
_snack(AppStrings.ticketNotConfigured);
return;
}
final loginDateTime = widget.loginDateTime;
final loginId = widget.loginId;
if (loginDateTime == null || loginId == null) {
_snack(AppStrings.kaleidxNotLoggedIn);
return;
}
if (_cooldownRemaining > 0) {
_snack(AppStrings.kaleidxCooldownNotice(_cooldownRemaining));
return;
}
if (_pending.isEmpty) {
_snack(AppStrings.kaleidxNeedGateId);
return;
}
if (!_discover && !_giveKey) {
_snack(AppStrings.kaleidxNeedAction);
return;
}
setState(() => _running = true);
try {
final config = TitleServerConfigHolder().config!;
final service = TitleApiService(config, cookies: widget.cookies);
final builder = UserAllPayloadBuilder(config);
var data = _userAllData;
if (data.isEmpty || _serverScopes.isEmpty) {
_updateStep(_Step.fetchData);
final results = await Future.wait([
service.fetchUserAllData(widget.userId),
service.getUserKaleidxScopes(widget.userId),
]);
data = results[0] as Map<String, Map<String, dynamic>>;
if (mounted) {
setState(() {
_userAllData = data;
_serverScopes = results[1] as List<UserKaleidxScopeBean>;
});
}
}
_updateStep(_Step.upload);
final packet = builder.build(
userId: widget.userId,
loginId: loginId,
loginDateTime: loginDateTime,
musicData: _placeholderMusicData(),
generalUserInfo: data,
);
final rows = _buildRows();
// 每一位 '1' 插入 / '0' 更新,判据是服务器有没有同 gateId 的行。
final flags = [
for (final row in rows) _serverRow(row.gateId) == null ? '1' : '0',
].join();
builder.applyKaleidxScopePatch(
packet,
scopes: [for (final row in rows) row.toWireJson()],
newFlags: flags,
);
await service.upsertUserAll(packet, widget.userId);
_updateStep(_Step.complete, await _verify(service));
if (_autoLogout && widget.onExitToTitle != null) {
await Future.delayed(const Duration(seconds: 2));
if (!mounted) return;
_updateStep(_Step.logout, AppStrings.exitingToTitle);
await widget.onExitToTitle!();
}
} on TitleApiException catch (e) {
_updateStep(_Step.failed, e.message);
} catch (e) {
_updateStep(_Step.failed, '$e');
} finally {
if (mounted) setState(() => _running = false);
}
}
Future<String> _verify(TitleApiService service) async {
final requested = List<int>.from(_pending);
List<UserKaleidxScopeBean> rows;
try {
rows = await service.getUserKaleidxScopes(widget.userId);
} catch (e) {
return AppStrings.kaleidxVerifyFailed('$e');
}
if (mounted) setState(() => _serverScopes = rows);
final saved = rows.where((r) => requested.contains(r.gateId));
final missing = requested
.where((id) => !rows.any((r) => r.gateId == id))
.toList();
if (missing.isNotEmpty) {
return AppStrings.kaleidxNotSaved(missing.join(' / '));
}
return AppStrings.kaleidxVerified(saved.length);
}
Map<String, dynamic> _placeholderMusicData() => {
'musicId': _placeholderMusicId,
'level': 0,
'playCount': 1,
'achievement': 0,
'comboStatus': 0,
'syncStatus': 0,
'deluxscoreMax': 0,
'scoreRank': 0,
'extNum1': 0,
};
void _updateStep(_Step step, [String? message]) {
if (!mounted) return;
setState(() {
_step = step;
_stepMessage = message ?? '';
});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(title: const Text(AppStrings.kaleidxFeatureTitle)),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: responsiveBody(
context,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (!_loggedIn)
_notice(theme, AppStrings.kaleidxNotLoggedIn, error: true),
if (_loggedIn && _cooldownRemaining > 0)
_notice(
theme,
AppStrings.kaleidxCooldownNotice(_cooldownRemaining),
),
_card(
theme,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_titleRow(
theme,
Icons.science_outlined,
AppStrings.kaleidxFeatureTitle,
),
const SizedBox(height: 12),
Text(
AppStrings.kaleidxFeatureDesc,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
height: 1.5,
),
),
],
),
),
const SizedBox(height: 12),
_inputCard(theme),
const SizedBox(height: 12),
_fetchCard(theme),
const SizedBox(height: 12),
_autoLogoutToggle(theme),
const SizedBox(height: 12),
_runButton(theme),
if (_step != _Step.idle) ...[
const SizedBox(height: 16),
_progressCard(theme),
],
],
),
),
),
);
}
Widget _notice(ThemeData theme, String text, {bool error = false}) {
return Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: error
? theme.colorScheme.errorContainer
: theme.colorScheme.tertiaryContainer.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(10),
),
child: Text(
text,
style: theme.textTheme.bodySmall?.copyWith(
color: error
? theme.colorScheme.onErrorContainer
: theme.colorScheme.onTertiaryContainer,
),
),
);
}
Widget _card(ThemeData theme, {required Widget child}) {
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: theme.colorScheme.outline.withValues(alpha: 0.3),
),
),
child: Padding(padding: const EdgeInsets.all(20), child: child),
);
}
Widget _titleRow(ThemeData theme, IconData icon, String title) {
return Row(
children: [
Icon(icon, size: 18, color: theme.colorScheme.primary),
const SizedBox(width: 8),
Expanded(
child: Text(
title,
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
),
],
);
}
Widget _checkRow({
required String label,
required bool value,
required bool enabled,
required ValueChanged<bool> onChanged,
}) {
return InkWell(
borderRadius: BorderRadius.circular(10),
onTap: enabled ? () => onChanged(!value) : null,
child: Row(
children: [
Checkbox(
value: value,
onChanged: enabled ? (v) => onChanged(v ?? false) : null,
),
Expanded(child: Text(label)),
],
),
);
}
Widget _inputCard(ThemeData theme) {
final enabled = !_running;
return _card(
theme,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: _gateIdController,
enabled: enabled,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: AppStrings.kaleidxGateIdLabel,
hintText: AppStrings.kaleidxGateIdHint,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
),
contentPadding: const EdgeInsets.all(14),
),
),
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: enabled ? _addPending : null,
icon: const Icon(Icons.add, size: 18),
label: const Text(AppStrings.listAddButton),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
),
const SizedBox(height: 4),
_checkRow(
label: AppStrings.kaleidxActionDiscover,
value: _discover,
enabled: enabled,
onChanged: (v) => setState(() => _discover = v),
),
_checkRow(
label: AppStrings.kaleidxActionKey,
value: _giveKey,
enabled: enabled,
onChanged: (v) => setState(() => _giveKey = v),
),
if (_giveKey) ...[
const SizedBox(height: 4),
Text(
AppStrings.kaleidxKeyNeedsGate,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
height: 1.5,
),
),
],
const SizedBox(height: 10),
for (final id in _pending)
Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Text(
'#$id ${_stateLabelFor(id)}',
style: theme.textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
),
),
),
if (_pending.isEmpty)
Text(
AppStrings.kaleidxPendingEmpty,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
);
}
String _stateLabelFor(int gateId) {
final row = _serverRow(gateId);
if (row == null) {
return _userAllData.isEmpty
? ''
: AppStrings.kaleidxGateState(false, false, false);
}
return AppStrings.kaleidxGateState(
row.isGateFound,
row.isKeyFound,
row.isClear,
);
}
Widget _fetchCard(ThemeData theme) {
final hasData = _userAllData.isNotEmpty;
return _card(
theme,
child: Row(
children: [
Icon(
hasData ? Icons.check_circle : Icons.download,
size: 18,
color: hasData ? Colors.green : theme.colorScheme.primary,
),
const SizedBox(width: 8),
Expanded(
child: Text(
hasData
? '${AppStrings.kaleidxFetched} (${_serverScopes.length})'
: AppStrings.kaleidxNoData,
style: theme.textTheme.bodySmall?.copyWith(
color: hasData
? Colors.green
: theme.colorScheme.onSurfaceVariant,
),
),
),
SizedBox(
height: 32,
child: OutlinedButton.icon(
onPressed: _loading || _running ? null : _fetchData,
icon: _loading
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh, size: 16),
label: Text(
_loading
? AppStrings.travelPartnerFetching
: AppStrings.kaleidxFetchData,
style: theme.textTheme.labelSmall,
),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
],
),
);
}
Widget _autoLogoutToggle(ThemeData theme) {
final enabled = widget.onExitToTitle != null && !_running;
return _card(
theme,
child: InkWell(
borderRadius: BorderRadius.circular(10),
onTap: enabled ? () => setState(() => _autoLogout = !_autoLogout) : null,
child: Row(
children: [
Checkbox(
value: _autoLogout,
onChanged: enabled
? (v) => setState(() => _autoLogout = v ?? false)
: null,
),
Expanded(
child: Text(
AppStrings.autoLogoutAndExit,
style: theme.textTheme.bodyMedium,
),
),
],
),
),
);
}
Widget _runButton(ThemeData theme) {
return SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _running || !_loggedIn ? null : _run,
icon: _running
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.lock_open_outlined, size: 20),
label: Text(
_running ? AppStrings.kaleidxRunning : AppStrings.kaleidxRun,
),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
);
}
Widget _progressCard(ThemeData theme) {
final isFailed = _step == _Step.failed;
return _card(
theme,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_stepRow(theme, _Step.fetchData, AppStrings.kaleidxStepFetch),
_stepRow(theme, _Step.upload, AppStrings.kaleidxStepUpload),
if (_autoLogout) _stepRow(theme, _Step.logout, AppStrings.stepLogout),
if (_stepMessage.isNotEmpty) ...[
const SizedBox(height: 12),
Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: isFailed
? theme.colorScheme.errorContainer
: theme.colorScheme.surfaceContainerHighest
.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(8),
),
child: Text(
_stepMessage,
style: theme.textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
color: isFailed
? theme.colorScheme.onErrorContainer
: theme.colorScheme.onSurface,
),
),
),
],
],
),
);
}
Widget _stepRow(ThemeData theme, _Step step, String label) {
final order = [_Step.fetchData, _Step.upload, _Step.logout, _Step.complete];
final current = _step == _Step.failed ? _Step.upload : _step;
final done = order.indexOf(current) > order.indexOf(step);
final active = _step == step;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Icon(
done
? Icons.check_circle
: active
? Icons.radio_button_checked
: Icons.radio_button_unchecked,
size: 18,
color: done
? Colors.green
: active
? theme.colorScheme.primary
: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 10),
Expanded(child: Text(label, style: theme.textTheme.bodySmall)),
],
),
);
}
}
+623
View File
@@ -0,0 +1,623 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../config/responsive.dart';
import '../config/strings.dart';
import '../config/title_server_config.dart';
import '../services/title_api_service.dart';
import '../services/user_all_payload_builder.dart';
/// 一键跑图:把选中的区域标记为已完成。
///
/// 依据反编译客户端:
/// - 区域进度是 `upsertUserAll.userMapList`,行结构见 `Net/VO/Mai2/UserMap.cs`
/// `{mapId, distance, isLock, isClear, isComplete, unlockFlag}`,主键 mapId。
/// - `MapMaster.CreateUserDataMapList`(Util/MapMaster.cs:509-543)用 `distance`
/// 与该区域的 ReleaseFlag / End 里程针比较,**重算** isClear / isComplete,
/// 没有对应针时甚至强制写回 false。所以只发 flag 会被冲掉,必须把
/// `distance` 推过 End 针——这里直接给 `UserMapData.MaxDistance`。
/// - `unlockFlag` 语义是反的:`IsFinishedOpening ? 0 : 1`。
/// - `isLock` 客户端不回读(`ConvertUserMap` 从不读它)。
class MapTraversePage extends StatefulWidget {
final int userId;
final String? cookies;
final int? loginDateTime;
final int? loginId;
final Future<void> Function()? onExitToTitle;
const MapTraversePage({
super.key,
required this.userId,
this.cookies,
this.loginDateTime,
this.loginId,
this.onExitToTitle,
});
@override
State<MapTraversePage> createState() => _MapTraversePageState();
}
enum _Step { idle, fetchData, upload, logout, complete, failed }
class _MapTraversePageState extends State<MapTraversePage> {
/// 与其它高危功能一致:不写真实成绩,playlog 用同一条占位记录。
static const int _placeholderMusicId = 11538;
final _mapIdController = TextEditingController();
final List<int> _pending = [];
Map<String, Map<String, dynamic>> _userAllData = const {};
Set<int>? _serverMapIds;
List<Map<String, dynamic>> _serverMaps = const [];
bool _loading = false;
bool _running = false;
bool _autoLogout = true;
_Step _step = _Step.idle;
String _stepMessage = '';
Timer? _cooldownTimer;
int _cooldownRemaining = 0;
bool get _loggedIn => widget.loginDateTime != null;
@override
void initState() {
super.initState();
_syncCooldown();
}
@override
void didUpdateWidget(covariant MapTraversePage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.loginDateTime != widget.loginDateTime) {
_syncCooldown();
}
}
@override
void dispose() {
_cooldownTimer?.cancel();
_mapIdController.dispose();
super.dispose();
}
void _syncCooldown() {
_cooldownTimer?.cancel();
final loginDateTime = widget.loginDateTime;
if (loginDateTime == null) {
setState(() => _cooldownRemaining = 0);
return;
}
setState(() => _cooldownRemaining = _computeRemaining(loginDateTime));
if (_cooldownRemaining <= 0) return;
_cooldownTimer = Timer.periodic(const Duration(seconds: 1), (_) {
if (!mounted) return;
final remaining = _computeRemaining(loginDateTime);
setState(() => _cooldownRemaining = remaining);
if (remaining <= 0) {
_cooldownTimer?.cancel();
_cooldownTimer = null;
}
});
}
int _computeRemaining(int loginDateTime) {
final nowSec = DateTime.now().millisecondsSinceEpoch ~/ 1000;
final remaining =
AppStrings.ticketCooldownSeconds - (nowSec - loginDateTime);
return remaining < 0 ? 0 : remaining;
}
void _snack(String message) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
}
void _addPending() {
final id = int.tryParse(_mapIdController.text.trim()) ?? -1;
if (id <= 0) {
_snack(AppStrings.mapNeedMapId);
return;
}
if (_pending.contains(id)) {
_snack(AppStrings.listDuplicate);
return;
}
setState(() {
_pending.add(id);
_mapIdController.clear();
});
}
void _applyServerMaps(List<Map<String, dynamic>> rows) {
_serverMaps = rows;
_serverMapIds = rows
.map((e) => (e['mapId'] as num?)?.toInt() ?? 0)
.where((e) => e != 0)
.toSet();
}
Future<void> _fetchData() async {
if (!TitleServerConfigHolder().isConfigured) return;
setState(() => _loading = true);
try {
final config = TitleServerConfigHolder().config!;
final service = TitleApiService(config, cookies: widget.cookies);
final results = await Future.wait([
service.fetchUserAllData(widget.userId),
service.getUserMaps(widget.userId),
]);
if (!mounted) return;
setState(() {
_userAllData = results[0] as Map<String, Map<String, dynamic>>;
_applyServerMaps(results[1] as List<Map<String, dynamic>>);
_loading = false;
});
} catch (e) {
if (!mounted) return;
setState(() => _loading = false);
_snack('${AppStrings.loadFailed}: $e');
}
}
Future<void> _run() async {
if (!TitleServerConfigHolder().isConfigured) {
_snack(AppStrings.ticketNotConfigured);
return;
}
final loginDateTime = widget.loginDateTime;
final loginId = widget.loginId;
if (loginDateTime == null || loginId == null) {
_snack(AppStrings.mapNotLoggedIn);
return;
}
if (_cooldownRemaining > 0) {
_snack(AppStrings.mapCooldownNotice(_cooldownRemaining));
return;
}
if (_pending.isEmpty) {
_snack(AppStrings.mapNeedMapId);
return;
}
setState(() => _running = true);
try {
final config = TitleServerConfigHolder().config!;
final service = TitleApiService(config, cookies: widget.cookies);
final builder = UserAllPayloadBuilder(config);
var data = _userAllData;
var serverIds = _serverMapIds;
if (data.isEmpty || serverIds == null) {
_updateStep(_Step.fetchData);
final results = await Future.wait([
service.fetchUserAllData(widget.userId),
service.getUserMaps(widget.userId),
]);
data = results[0] as Map<String, Map<String, dynamic>>;
if (mounted) setState(() => _userAllData = data);
_applyServerMaps(results[1] as List<Map<String, dynamic>>);
serverIds = _serverMapIds!;
}
_updateStep(_Step.upload);
final packet = builder.build(
userId: widget.userId,
loginId: loginId,
loginDateTime: loginDateTime,
musicData: _placeholderMusicData(),
generalUserInfo: data,
);
// isNewMapList 与 userMapList 一一对齐,每位 '1' 新增 / '0' 更新,
// 判据是服务器是否已有同 mapId 的行(VOExtensions.BuildListData)。
final maps = [
for (final id in _pending) UserAllPayloadBuilder.completedMap(id),
];
final flags = [
for (final id in _pending) serverIds.contains(id) ? '0' : '1',
].join();
builder.applyMapPatch(packet, maps: maps, newFlags: flags);
await service.upsertUserAll(packet, widget.userId);
_updateStep(_Step.complete, await _verify(service));
if (_autoLogout && widget.onExitToTitle != null) {
await Future.delayed(const Duration(seconds: 2));
if (!mounted) return;
_updateStep(_Step.logout, AppStrings.exitingToTitle);
await widget.onExitToTitle!();
}
} on TitleApiException catch (e) {
_updateStep(_Step.failed, e.message);
} catch (e) {
_updateStep(_Step.failed, '$e');
} finally {
if (mounted) setState(() => _running = false);
}
}
/// 回读区分「服务器没写入 userMapList」和「写入了但游戏里仍显示未完成」。
Future<String> _verify(TitleApiService service) async {
final requested = List<int>.from(_pending);
List<Map<String, dynamic>> rows;
try {
rows = await service.getUserMaps(widget.userId);
} catch (e) {
return AppStrings.mapVerifyFailed('$e');
}
if (mounted) setState(() => _applyServerMaps(rows));
final saved = _serverMapIds ?? <int>{};
final missing = requested.where((id) => !saved.contains(id)).toList();
if (missing.isNotEmpty) {
return AppStrings.mapNotSaved(missing.join(' / '));
}
return AppStrings.mapVerified(requested.length);
}
Map<String, dynamic> _placeholderMusicData() => {
'musicId': _placeholderMusicId,
'level': 0,
'playCount': 1,
'achievement': 0,
'comboStatus': 0,
'syncStatus': 0,
'deluxscoreMax': 0,
'scoreRank': 0,
'extNum1': 0,
};
void _updateStep(_Step step, [String? message]) {
if (!mounted) return;
setState(() {
_step = step;
_stepMessage = message ?? '';
});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(title: const Text(AppStrings.mapFeatureTitle)),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: responsiveBody(
context,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (!_loggedIn)
_notice(theme, AppStrings.mapNotLoggedIn, error: true),
if (_loggedIn && _cooldownRemaining > 0)
_notice(theme, AppStrings.mapCooldownNotice(_cooldownRemaining)),
_card(
theme,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_titleRow(
theme,
Icons.map_outlined,
AppStrings.mapFeatureTitle,
),
const SizedBox(height: 12),
Text(
AppStrings.mapFeatureDesc,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
height: 1.5,
),
),
const SizedBox(height: 10),
Text(
AppStrings.mapCollectiblesNotice,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
height: 1.5,
),
),
],
),
),
const SizedBox(height: 12),
_inputCard(theme),
const SizedBox(height: 12),
_fetchCard(theme),
const SizedBox(height: 12),
_autoLogoutToggle(theme),
const SizedBox(height: 12),
_runButton(theme),
if (_step != _Step.idle) ...[
const SizedBox(height: 16),
_progressCard(theme),
],
],
),
),
),
);
}
Widget _notice(ThemeData theme, String text, {bool error = false}) {
return Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: error
? theme.colorScheme.errorContainer
: theme.colorScheme.tertiaryContainer.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(10),
),
child: Text(
text,
style: theme.textTheme.bodySmall?.copyWith(
color: error
? theme.colorScheme.onErrorContainer
: theme.colorScheme.onTertiaryContainer,
),
),
);
}
Widget _card(ThemeData theme, {required Widget child}) {
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: theme.colorScheme.outline.withValues(alpha: 0.3),
),
),
child: Padding(padding: const EdgeInsets.all(20), child: child),
);
}
Widget _titleRow(ThemeData theme, IconData icon, String title) {
return Row(
children: [
Icon(icon, size: 18, color: theme.colorScheme.primary),
const SizedBox(width: 8),
Expanded(
child: Text(
title,
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
),
],
);
}
Widget _inputCard(ThemeData theme) {
final enabled = !_running;
return _card(
theme,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: _mapIdController,
enabled: enabled,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: AppStrings.mapIdLabel,
hintText: AppStrings.mapIdHint,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
),
contentPadding: const EdgeInsets.all(14),
),
),
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: enabled ? _addPending : null,
icon: const Icon(Icons.add, size: 18),
label: const Text(AppStrings.listAddButton),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
),
const SizedBox(height: 10),
Text(
_pending.isEmpty
? AppStrings.mapPendingEmpty
: _pending.map((e) => '#$e').join(' '),
style: theme.textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
color: _pending.isEmpty
? theme.colorScheme.onSurfaceVariant
: theme.colorScheme.onSurface,
),
),
],
),
);
}
Widget _fetchCard(ThemeData theme) {
final hasData = _serverMapIds != null;
return _card(
theme,
child: Row(
children: [
Icon(
hasData ? Icons.check_circle : Icons.download,
size: 18,
color: hasData ? Colors.green : theme.colorScheme.primary,
),
const SizedBox(width: 8),
Expanded(
child: Text(
hasData
? '${AppStrings.mapFetched} (${_serverMaps.length})'
: AppStrings.mapNoData,
style: theme.textTheme.bodySmall?.copyWith(
color: hasData
? Colors.green
: theme.colorScheme.onSurfaceVariant,
),
),
),
SizedBox(
height: 32,
child: OutlinedButton.icon(
onPressed: _loading || _running ? null : _fetchData,
icon: _loading
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh, size: 16),
label: Text(
_loading
? AppStrings.travelPartnerFetching
: AppStrings.mapFetchData,
style: theme.textTheme.labelSmall,
),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
],
),
);
}
Widget _autoLogoutToggle(ThemeData theme) {
final enabled = widget.onExitToTitle != null && !_running;
return _card(
theme,
child: InkWell(
borderRadius: BorderRadius.circular(10),
onTap: enabled ? () => setState(() => _autoLogout = !_autoLogout) : null,
child: Row(
children: [
Checkbox(
value: _autoLogout,
onChanged: enabled
? (v) => setState(() => _autoLogout = v ?? false)
: null,
),
Expanded(
child: Text(
AppStrings.autoLogoutAndExit,
style: theme.textTheme.bodyMedium,
),
),
],
),
),
);
}
Widget _runButton(ThemeData theme) {
return SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _running || !_loggedIn ? null : _run,
icon: _running
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.flag_outlined, size: 20),
label: Text(
_running ? AppStrings.mapRunning : AppStrings.mapRun,
),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
);
}
Widget _progressCard(ThemeData theme) {
final isFailed = _step == _Step.failed;
return _card(
theme,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_stepRow(theme, _Step.fetchData, AppStrings.mapStepFetch),
_stepRow(theme, _Step.upload, AppStrings.mapStepUpload),
if (_autoLogout) _stepRow(theme, _Step.logout, AppStrings.stepLogout),
if (_stepMessage.isNotEmpty) ...[
const SizedBox(height: 12),
Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: isFailed
? theme.colorScheme.errorContainer
: theme.colorScheme.surfaceContainerHighest
.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(8),
),
child: Text(
_stepMessage,
style: theme.textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
color: isFailed
? theme.colorScheme.onErrorContainer
: theme.colorScheme.onSurface,
),
),
),
],
],
),
);
}
Widget _stepRow(ThemeData theme, _Step step, String label) {
final order = [_Step.fetchData, _Step.upload, _Step.logout, _Step.complete];
final current = _step == _Step.failed ? _Step.upload : _step;
final done = order.indexOf(current) > order.indexOf(step);
final active = _step == step;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Icon(
done
? Icons.check_circle
: active
? Icons.radio_button_checked
: Icons.radio_button_unchecked,
size: 18,
color: done
? Colors.green
: active
? theme.colorScheme.primary
: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 10),
Expanded(child: Text(label, style: theme.textTheme.bodySmall)),
],
),
);
}
}
+719
View File
@@ -0,0 +1,719 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../config/responsive.dart';
import '../config/strings.dart';
import '../config/title_server_config.dart';
import '../services/title_api_service.dart';
import '../services/user_all_payload_builder.dart';
/// 数值修改:Rating 与舞里程。两者的流程一模一样(拉数据 → 改一个数 →
/// UpsertUserAll),差别只在改哪个字段,所以合成一个带模式的视图。
enum NumberPatchMode {
/// 总 Rating:`userData.playerRating` + `userRating.rating`
rating,
/// 舞里程:`userData.point`(余额),可带上 `totalPoint`(累计)
maiMile,
}
class NumberPatchPage extends StatelessWidget {
final int userId;
final String? cookies;
final int? loginDateTime;
final int? loginId;
final Future<void> Function()? onExitToTitle;
final NumberPatchMode mode;
const NumberPatchPage({
super.key,
required this.userId,
this.cookies,
this.loginDateTime,
this.loginId,
this.onExitToTitle,
required this.mode,
});
@override
Widget build(BuildContext context) {
final isRating = mode == NumberPatchMode.rating;
return Scaffold(
appBar: AppBar(
title: Text(
isRating
? AppStrings.ratingFeatureTitle
: AppStrings.maiMileFeatureTitle,
),
),
body: NumberPatchView(
userId: userId,
cookies: cookies,
loginDateTime: loginDateTime,
loginId: loginId,
onExitToTitle: onExitToTitle,
mode: mode,
featureTitle: isRating
? AppStrings.ratingFeatureTitle
: AppStrings.maiMileFeatureTitle,
featureDesc: isRating
? AppStrings.ratingFeatureDesc
: AppStrings.maiMileFeatureDesc,
),
);
}
}
enum _Step { idle, fetchData, upload, logout, complete, failed }
class NumberPatchView extends StatefulWidget {
final int userId;
final String? cookies;
/// 来自 HomePage 启动时的 UserLoginApi,为 `null` 表示没有有效登录态。
final int? loginDateTime;
final int? loginId;
final Future<void> Function()? onExitToTitle;
final NumberPatchMode mode;
final String featureTitle;
final String featureDesc;
const NumberPatchView({
super.key,
required this.userId,
this.cookies,
this.loginDateTime,
this.loginId,
this.onExitToTitle,
required this.mode,
required this.featureTitle,
required this.featureDesc,
});
@override
State<NumberPatchView> createState() => _NumberPatchViewState();
}
class _NumberPatchViewState extends State<NumberPatchView> {
/// 与解锁/收藏品/旅行伙伴一致:这些操作不写真实成绩,
/// playlog 沿用同一条占位记录,Rating 修改需要它来带 before/after。
static const int _placeholderMusicId = 11538;
final _valueController = TextEditingController();
Map<String, Map<String, dynamic>> _userAllData = const {};
bool _maiMileAdd = true;
bool _loading = false;
bool _running = false;
bool _autoLogout = true;
_Step _step = _Step.idle;
String _stepMessage = '';
Timer? _cooldownTimer;
int _cooldownRemaining = 0;
bool get _isRating => widget.mode == NumberPatchMode.rating;
bool get _loggedIn => widget.loginDateTime != null;
@override
void initState() {
super.initState();
_syncCooldown();
}
@override
void didUpdateWidget(covariant NumberPatchView oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.loginDateTime != widget.loginDateTime) {
_syncCooldown();
}
}
@override
void dispose() {
_cooldownTimer?.cancel();
_valueController.dispose();
super.dispose();
}
void _syncCooldown() {
_cooldownTimer?.cancel();
final loginDateTime = widget.loginDateTime;
if (loginDateTime == null) {
setState(() => _cooldownRemaining = 0);
return;
}
setState(() => _cooldownRemaining = _computeRemaining(loginDateTime));
if (_cooldownRemaining <= 0) return;
_cooldownTimer = Timer.periodic(const Duration(seconds: 1), (_) {
if (!mounted) return;
final remaining = _computeRemaining(loginDateTime);
setState(() => _cooldownRemaining = remaining);
if (remaining <= 0) {
_cooldownTimer?.cancel();
_cooldownTimer = null;
}
});
}
int _computeRemaining(int loginDateTime) {
final nowSec = DateTime.now().millisecondsSinceEpoch ~/ 1000;
final remaining =
AppStrings.ticketCooldownSeconds - (nowSec - loginDateTime);
return remaining < 0 ? 0 : remaining;
}
void _snack(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
}
static int _toInt(dynamic v) {
if (v is int) return v;
if (v is num) return v.toInt();
if (v is String) return int.tryParse(v) ?? 0;
return 0;
}
/// 从已拉取的 GetUserDataApi 里读当前值,供「累加」和界面上的当前值展示。
int _currentOf(Map<String, Map<String, dynamic>> data, String field) {
final userData = data['GetUserDataApi']?['userData'];
if (userData is! Map) return 0;
return _toInt(userData[field]);
}
int get _currentRating => _currentOf(_userAllData, 'playerRating');
int get _currentPoint => _currentOf(_userAllData, 'point');
/// Rating 的区间是游戏内的 0~99999;舞里程是整个 int32,允许负值
/// (累加模式填负数就是扣里程)。
int get _min => _isRating ? 0 : UserAllPayloadBuilder.minValue;
int get _max => _isRating
? UserAllPayloadBuilder.maxRating
: UserAllPayloadBuilder.maxValue;
int _clamp(int v) => v < _min ? _min : (v > _max ? _max : v);
/// 界面上「将写入」的预览值。未拉取数据时按 0 起算,仅用于提示。
int get _previewTarget {
final input = int.tryParse(_valueController.text.trim());
if (input == null) return 0;
if (_isRating) return _clamp(input);
return _maiMileAdd ? _clamp(_currentPoint + input) : _clamp(input);
}
String? _checkInput() {
final text = _valueController.text.trim();
final input = int.tryParse(text);
if (input == null) {
return _isRating ? AppStrings.ratingNeedValue : AppStrings.maiMileNeedValue;
}
if (input < _min || input > _max) {
return AppStrings.numberPatchOutOfRange(_min, _max);
}
if (_isRating) return null;
if (_maiMileAdd && input == 0) return AppStrings.maiMileNeedValue;
return null;
}
Future<void> _fetchData() async {
if (!TitleServerConfigHolder().isConfigured) return;
setState(() => _loading = true);
try {
final config = TitleServerConfigHolder().config!;
final service = TitleApiService(config, cookies: widget.cookies);
final data = await service.fetchUserAllData(widget.userId);
if (!mounted) return;
setState(() {
_userAllData = data;
_loading = false;
});
} catch (e) {
if (!mounted) return;
setState(() => _loading = false);
_snack('${AppStrings.loadFailed}: $e');
}
}
Future<void> _run() async {
if (!TitleServerConfigHolder().isConfigured) {
_snack(AppStrings.ticketNotConfigured);
return;
}
final loginDateTime = widget.loginDateTime;
final loginId = widget.loginId;
if (loginDateTime == null || loginId == null) {
_snack(AppStrings.numberPatchNotLoggedIn);
return;
}
if (_cooldownRemaining > 0) {
_snack(AppStrings.numberPatchCooldownNotice(_cooldownRemaining));
return;
}
final inputError = _checkInput();
if (inputError != null) {
_snack(inputError);
return;
}
final input = int.parse(_valueController.text.trim());
setState(() => _running = true);
try {
final config = TitleServerConfigHolder().config!;
final service = TitleApiService(config, cookies: widget.cookies);
final builder = UserAllPayloadBuilder(config);
var data = _userAllData;
if (data.isEmpty) {
_updateStep(_Step.fetchData);
data = await service.fetchUserAllData(widget.userId);
if (mounted) setState(() => _userAllData = data);
}
_updateStep(_Step.upload);
final packet = builder.build(
userId: widget.userId,
loginId: loginId,
loginDateTime: loginDateTime,
musicData: _placeholderMusicData(),
generalUserInfo: data,
);
final String resultMessage;
if (_isRating) {
builder.applyRatingPatch(packet, rating: input);
resultMessage =
'${AppStrings.ratingSuccess} → ${_clamp(input)}';
} else {
final current = _currentOf(data, 'point');
final total = _currentOf(data, 'totalPoint');
final point = _maiMileAdd ? _clamp(current + input) : _clamp(input);
builder.applyMaiMilePatch(
packet,
point: point,
// 累加才动累计值;「直接设定」只覆写余额,累计是历史事实。
totalPoint: _maiMileAdd ? _clamp(total + input) : null,
);
resultMessage = '${AppStrings.maiMileSuccess} → $point';
}
await service.upsertUserAll(packet, widget.userId);
_updateStep(_Step.complete, resultMessage);
if (_autoLogout && widget.onExitToTitle != null) {
await Future.delayed(const Duration(seconds: 2));
if (!mounted) return;
_updateStep(_Step.logout, AppStrings.exitingToTitle);
await widget.onExitToTitle!();
}
} on TitleApiException catch (e) {
_updateStep(_Step.failed, e.message);
} catch (e) {
_updateStep(_Step.failed, '$e');
} finally {
if (mounted) setState(() => _running = false);
}
}
Map<String, dynamic> _placeholderMusicData() => {
'musicId': _placeholderMusicId,
'level': 0,
'playCount': 1,
'achievement': 0,
'comboStatus': 0,
'syncStatus': 0,
'deluxscoreMax': 0,
'scoreRank': 0,
'extNum1': 0,
};
void _updateStep(_Step step, [String? message]) {
if (!mounted) return;
setState(() {
_step = step;
_stepMessage = message ?? '';
});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: responsiveBody(
context,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (!_loggedIn)
_notice(theme, AppStrings.numberPatchNotLoggedIn, error: true),
if (_loggedIn && _cooldownRemaining > 0)
_notice(
theme,
AppStrings.numberPatchCooldownNotice(_cooldownRemaining),
),
_descCard(theme),
const SizedBox(height: 12),
_inputCard(theme),
const SizedBox(height: 12),
_fetchCard(theme),
const SizedBox(height: 12),
_autoLogoutToggle(theme),
const SizedBox(height: 12),
_runButton(theme),
if (_step != _Step.idle) ...[
const SizedBox(height: 16),
_progressCard(theme),
],
],
),
),
);
}
Widget _notice(ThemeData theme, String text, {bool error = false}) {
return Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: error
? theme.colorScheme.errorContainer
: theme.colorScheme.tertiaryContainer.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(10),
),
child: Text(
text,
style: theme.textTheme.bodySmall?.copyWith(
color: error
? theme.colorScheme.onErrorContainer
: theme.colorScheme.onTertiaryContainer,
),
),
);
}
Widget _card(ThemeData theme, {required Widget child}) {
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: theme.colorScheme.outline.withValues(alpha: 0.3),
),
),
child: Padding(padding: const EdgeInsets.all(20), child: child),
);
}
Widget _descCard(ThemeData theme) {
return _card(
theme,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
_isRating ? Icons.speed : Icons.commute,
size: 18,
color: theme.colorScheme.primary,
),
const SizedBox(width: 8),
Expanded(
child: Text(
widget.featureTitle,
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
),
],
),
const SizedBox(height: 12),
Text(
widget.featureDesc,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
height: 1.5,
),
),
],
),
);
}
Widget _inputCard(ThemeData theme) {
final enabled = !_running;
final hasInput = int.tryParse(_valueController.text.trim()) != null;
return _card(
theme,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: _valueController,
enabled: enabled,
keyboardType: TextInputType.number,
onChanged: (_) => setState(() {}),
decoration: InputDecoration(
labelText: _isRating
? AppStrings.ratingValueLabel
: AppStrings.maiMileValueLabel,
hintText: _isRating
? AppStrings.ratingValueHint
: AppStrings.maiMileValueHint,
helperText: AppStrings.numberPatchRangeHint(_min, _max),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
),
contentPadding: const EdgeInsets.all(14),
),
),
if (!_isRating) ...[
const SizedBox(height: 12),
DropdownButtonFormField<int>(
initialValue: _maiMileAdd ? 1 : 0,
decoration: InputDecoration(
labelText: AppStrings.maiMileModeLabel,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
),
contentPadding: const EdgeInsets.all(14),
),
items: [
DropdownMenuItem(
value: 1,
child: Text(
AppStrings.maiMileModeAdd,
style: theme.textTheme.bodyMedium,
),
),
DropdownMenuItem(
value: 0,
child: Text(
AppStrings.maiMileModeSet,
style: theme.textTheme.bodyMedium,
),
),
],
onChanged: enabled
? (v) => setState(() => _maiMileAdd = (v ?? 1) == 1)
: null,
),
],
// 累加要基于服务器上的当前余额,没拉取数据前预览不了,就别猜一个 0。
if (hasInput && (_isRating || _userAllData.isNotEmpty)) ...[
const SizedBox(height: 12),
Text(
_previewLine,
style: theme.textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
),
),
],
],
),
);
}
String get _previewLine {
final label = _isRating
? AppStrings.ratingCurrentValue
: AppStrings.maiMileCurrentBalance;
final current = _isRating ? _currentRating : _currentPoint;
return AppStrings.numberPatchPreview(label, current, _previewTarget);
}
Widget _fetchCard(ThemeData theme) {
final hasData = _userAllData.isNotEmpty;
final current = _isRating ? _currentRating : _currentPoint;
final label = _isRating
? AppStrings.ratingCurrentValue
: AppStrings.maiMileCurrentBalance;
return _card(
theme,
child: Row(
children: [
Icon(
hasData ? Icons.check_circle : Icons.download,
size: 18,
color: hasData ? Colors.green : theme.colorScheme.primary,
),
const SizedBox(width: 8),
Expanded(
child: Text(
hasData
? '$label: $current'
: AppStrings.unlockNoData,
style: theme.textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
color: hasData
? Colors.green
: theme.colorScheme.onSurfaceVariant,
),
),
),
SizedBox(
height: 32,
child: OutlinedButton.icon(
onPressed: _loading || _running ? null : _fetchData,
icon: _loading
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh, size: 16),
label: Text(
_loading
? AppStrings.unlockFetching
: hasData
? AppStrings.unlockRefetch
: AppStrings.unlockFetchData,
style: theme.textTheme.labelSmall,
),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
],
),
);
}
Widget _autoLogoutToggle(ThemeData theme) {
final enabled = widget.onExitToTitle != null && !_running;
return _card(
theme,
child: InkWell(
borderRadius: BorderRadius.circular(10),
onTap: enabled ? () => setState(() => _autoLogout = !_autoLogout) : null,
child: Row(
children: [
Checkbox(
value: _autoLogout,
onChanged: enabled
? (v) => setState(() => _autoLogout = v ?? false)
: null,
),
Expanded(
child: Text(
AppStrings.autoLogoutAndExit,
style: theme.textTheme.bodyMedium,
),
),
],
),
),
);
}
Widget _runButton(ThemeData theme) {
return SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _running || !_loggedIn ? null : _run,
icon: _running
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.send, size: 20),
label: Text(
_running ? AppStrings.unlockRunning : AppStrings.numberPatchRun,
),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
);
}
Widget _progressCard(ThemeData theme) {
final isFailed = _step == _Step.failed;
return _card(
theme,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_stepRow(theme, _Step.fetchData, AppStrings.unlockStepFetch),
_stepRow(theme, _Step.upload, AppStrings.numberPatchStepUpload),
if (_autoLogout)
_stepRow(theme, _Step.logout, AppStrings.stepLogout),
if (_stepMessage.isNotEmpty) ...[
const SizedBox(height: 12),
Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: isFailed
? theme.colorScheme.errorContainer
: theme.colorScheme.surfaceContainerHighest
.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(8),
),
child: Text(
_stepMessage,
style: theme.textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
color: isFailed
? theme.colorScheme.onErrorContainer
: theme.colorScheme.onSurface,
),
),
),
],
],
),
);
}
Widget _stepRow(ThemeData theme, _Step step, String label) {
final order = [_Step.fetchData, _Step.upload, _Step.logout, _Step.complete];
final current = _step == _Step.failed ? _Step.upload : _step;
final done = order.indexOf(current) > order.indexOf(step);
final active = _step == step;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Icon(
done
? Icons.check_circle
: active
? Icons.radio_button_checked
: Icons.radio_button_unchecked,
size: 18,
color: done
? Colors.green
: active
? theme.colorScheme.primary
: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 10),
Expanded(child: Text(label, style: theme.textTheme.bodySmall)),
],
),
);
}
}
+198 -35
View File
@@ -48,6 +48,12 @@ class _TravelPartnerPageState extends State<TravelPartnerPage> {
final _grantIdController = TextEditingController();
/// 每个出战槽位一个等级输入框,留空表示不改该角色的等级。
final _slotLevelControllers = List<TextEditingController>.generate(
5,
(_) => TextEditingController(),
);
List<UserCharacterBean> _owned = const [];
Map<String, Map<String, dynamic>> _userAllData = const {};
final List<int> _pendingGrant = [];
@@ -82,6 +88,9 @@ class _TravelPartnerPageState extends State<TravelPartnerPage> {
void dispose() {
_cooldownTimer?.cancel();
_grantIdController.dispose();
for (final c in _slotLevelControllers) {
c.dispose();
}
super.dispose();
}
@@ -192,9 +201,28 @@ class _TravelPartnerPageState extends State<TravelPartnerPage> {
}
setState(() {
_slots = normalizeCharaSlot([for (var i = 0; i < 5; i++) leader]);
// 五个槽位这时都是同一个角色,等级跟着一起复制才对得上。
final leaderLevel = _slotLevelControllers[0].text;
for (var i = 1; i < 5; i++) {
_slotLevelControllers[i].text = leaderLevel;
}
});
}
/// 槽位上填了的等级 -> characterId -> level。同一角色占多个槽位时取最高的,
/// 否则两个槽位填了不同等级就没有确定答案。
Map<int, int> _slotLevelsById() {
final result = <int, int>{};
for (var i = 0; i < 5; i++) {
final id = _slots[i];
final level = int.tryParse(_slotLevelControllers[i].text.trim());
if (id == 0 || level == null) continue;
final existing = result[id];
if (existing == null || level > existing) result[id] = level;
}
return result;
}
String? _validate() {
if (_pendingGrant.isEmpty && _userAllData.isEmpty) {
return AppStrings.travelPartnerNoData;
@@ -205,6 +233,11 @@ class _TravelPartnerPageState extends State<TravelPartnerPage> {
return AppStrings.travelPartnerNotOwnedHint;
}
}
const min = UserAllPayloadBuilder.minCharacterLevel;
const max = UserAllPayloadBuilder.maxCharacterLevel;
for (final level in _slotLevelsById().values) {
if (level < min || level > max) return AppStrings.travelPartnerLevelInvalid;
}
return null;
}
@@ -252,21 +285,51 @@ class _TravelPartnerPageState extends State<TravelPartnerPage> {
generalUserInfo: data,
);
if (_pendingGrant.isNotEmpty) {
// 新角色给最小可用值;已拥有的不重发,避免把 level/awakening 打回。
final levels = _slotLevelsById();
final grantRows = [
for (final id in _pendingGrant)
{
// 新角色默认最小可用值;槽位上填了等级就顺手带上,省一次操作。
'characterId': id,
'level': UserAllPayloadBuilder.clampCharacterLevel(
levels[id] ?? 1,
),
'awakening': 0,
'useCount': 0,
},
];
// 已拥有且槽位要求改等级的,发 isNew='0' 的更新行。必须带上原有
// useCount / awakening,否则整行被覆盖会把使用次数清零。
final ownedById = {for (final c in _owned) c.characterId: c};
final levelRows = <Map<String, dynamic>>[];
for (final entry in levels.entries) {
if (_pendingGrant.contains(entry.key)) continue;
final owned = ownedById[entry.key];
if (owned == null) continue;
final level = UserAllPayloadBuilder.clampCharacterLevel(entry.value);
if (level == owned.level) continue;
levelRows.add(owned.copyWith(level: level).toWireJson());
}
if (grantRows.isNotEmpty || levelRows.isNotEmpty) {
builder.applyCharacterListPatch(
packet,
characters: [
for (final id in _pendingGrant)
{'characterId': id, 'level': 1, 'awakening': 0, 'useCount': 0},
],
characters: [...grantRows, ...levelRows],
newFlags: '${'1' * grantRows.length}${'0' * levelRows.length}',
);
}
builder.applyCharaSlotPatch(packet, charaSlot: _slots);
await service.upsertUserAll(packet, widget.userId);
_updateStep(_Step.complete, AppStrings.travelPartnerSuccess);
// 两种失败在返回值上完全一样(returnCode 都是 1),只能靠回读区分:
// 服务器没写入 userCharacterList,还是写入了但 ID 不在机台 Chara 表里
// 被客户端 CharacterSelectProces 静默跳过。
_updateStep(
_Step.complete,
_pendingGrant.isEmpty
? AppStrings.travelPartnerSuccess
: await _verifyGranted(service),
);
if (_autoLogout && widget.onExitToTitle != null) {
await Future.delayed(const Duration(seconds: 2));
@@ -283,6 +346,22 @@ class _TravelPartnerPageState extends State<TravelPartnerPage> {
}
}
Future<String> _verifyGranted(TitleApiService service) async {
final requested = List<int>.from(_pendingGrant);
List<UserCharacterBean> owned;
try {
owned = await service.getUserCharacters(widget.userId);
} catch (e) {
return AppStrings.travelPartnerVerifyFailed('$e');
}
if (mounted) setState(() => _owned = owned);
final saved = owned.map((c) => c.characterId).toSet();
final missing = requested.where((id) => !saved.contains(id)).toList();
if (missing.isEmpty) return AppStrings.travelPartnerVerified(requested.length);
return AppStrings.travelPartnerNotSaved(missing.join(' / '));
}
Map<String, dynamic> _placeholderMusicData() => {
'musicId': _placeholderMusicId,
'level': 0,
@@ -527,10 +606,48 @@ class _TravelPartnerPageState extends State<TravelPartnerPage> {
: theme.colorScheme.onSurface,
),
),
const SizedBox(height: 12),
Text(
AppStrings.travelPartnerIdRangeTitle,
style: theme.textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
AppStrings.travelPartnerIdRangeBody,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
height: 1.5,
),
),
if (_pendingGrant.any(_isSuspectId)) ...[
const SizedBox(height: 8),
Text(
AppStrings.travelPartnerIdUnknown,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
),
],
],
);
}
/// 客户端 `PlInformationProcess.AddDefaultCharacter` 里的默认发放清单,
/// 是已知一定存在于机台 Chara 表的那批 ID。
static const List<int> _knownCharacterIds = [
101, 102, 103, 104, 105,
201, 202, 203, 204, 205,
301, 302, 303, 304, 305, 306,
392, 393, 394, 395,
401, 402, 403, 404, 405,
501, 502, 503, 504, 505,
601, 602, 603, 604, 605,
];
bool _isSuspectId(int id) => !_knownCharacterIds.contains(id);
Widget _slotCard(ThemeData theme) {
// 下拉选项 = 已拥有 ∪ 待发放 ∪ 当前槽位已有值。
final optionIds = <int>{
@@ -548,37 +665,64 @@ class _TravelPartnerPageState extends State<TravelPartnerPage> {
const SizedBox(height: 14),
for (var i = 0; i < 5; i++)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: DropdownButtonFormField<int>(
initialValue: _slots[i],
decoration: InputDecoration(
labelText: AppStrings.travelPartnerSlotLabel(i),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 12,
),
),
items: [
DropdownMenuItem(
value: 0,
child: Text(
AppStrings.travelPartnerSlotNone,
style: theme.textTheme.bodyMedium,
padding: const EdgeInsets.only(bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DropdownButtonFormField<int>(
initialValue: _slots[i],
decoration: InputDecoration(
labelText: AppStrings.travelPartnerSlotLabel(i),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 12,
),
),
items: [
DropdownMenuItem(
value: 0,
child: Text(
AppStrings.travelPartnerSlotNone,
style: theme.textTheme.bodyMedium,
),
),
for (final id in optionIds)
DropdownMenuItem(value: id, child: Text('#$id')),
],
onChanged: _running
? null
: (v) => setState(() {
final next = [..._slots];
next[i] = v ?? 0;
_slots = next;
}),
),
for (final id in optionIds)
DropdownMenuItem(value: id, child: Text('#$id')),
if (_slots[i] != 0) ...[
const SizedBox(height: 6),
TextField(
controller: _slotLevelControllers[i],
enabled: !_running,
keyboardType: TextInputType.number,
onChanged: (_) => setState(() {}),
decoration: InputDecoration(
isDense: true,
labelText: AppStrings.travelPartnerLevelLabel,
hintText: AppStrings.travelPartnerLevelHint,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 12,
),
),
),
..._levelHintLines(theme, _slotLevelControllers[i].text),
],
],
onChanged: _running
? null
: (v) => setState(() {
final next = [..._slots];
next[i] = v ?? 0;
_slots = next;
}),
),
),
SizedBox(
@@ -599,6 +743,25 @@ class _TravelPartnerPageState extends State<TravelPartnerPage> {
);
}
/// 真实等级和界面等级不是一回事(`UserChara`:显示 = level % 10000、
/// 转生 = level ~/ 10000),只在会混淆时才说明。
List<Widget> _levelHintLines(ThemeData theme, String raw) {
final level = int.tryParse(raw.trim());
if (level == null || level < UserAllPayloadBuilder.minCharacterLevel) {
return const [];
}
if (level <= 9999) return const [];
return [
const SizedBox(height: 4),
Text(
AppStrings.travelPartnerLevelConverted(level),
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
];
}
Widget _ownedCard(ThemeData theme) {
return _sectionCard(
theme,
+41
View File
@@ -10,6 +10,7 @@ import 'package:pointycastle/export.dart';
import '../config/title_server_config.dart';
import '../models/user_data.dart';
import '../models/user_character.dart';
import '../models/user_kaleidx_scope.dart';
import '../models/user_preview.dart';
import '../models/user_rating.dart';
@@ -568,6 +569,46 @@ class TitleApiService {
);
return UserCharacterBean.listFromResponse(json);
}
/// 区域进度列表,对应 UserMapResponseVO。
///
/// 客户端 `PacketGetUserMap.cs:51-57` 是分页拉的:`nextIndex != 0` 就用它续拉,
/// 把所有页的 `userMapList` 拼起来。这里照做,否则只能看到第一页的区域。
Future<List<Map<String, dynamic>>> getUserMaps(int userId) async {
const apiName = 'GetUserMapApi';
final all = <Map<String, dynamic>>[];
var nextIndex = 0;
var pages = 0;
do {
final json = await _callApi(apiName, {
'userId': userId,
'nextIndex': nextIndex,
}, userId);
final list = json['userMapList'];
if (list is List) {
all.addAll(list.whereType<Map<String, dynamic>>());
}
nextIndex = (json['nextIndex'] as num?)?.toInt() ?? 0;
pages++;
} while (nextIndex != 0 && pages < 20);
return all;
}
/// 万花宿命之门进度,对应 UserKaleidxScopeResponseVO。
///
/// 与 `GetUserMapApi` 不同,这个接口不分页(`UserKaleidxScopeRequestVO`
/// 只有 `userId` 一个字段),一次拿全。
Future<List<UserKaleidxScopeBean>> getUserKaleidxScopes(int userId) async {
final json = await _callApi(
'GetUserKaleidxScopeApi',
{'userId': userId},
userId,
);
return UserKaleidxScopeBean.listFromResponse(json);
}
}
class _RandomHelper {
+143 -3
View File
@@ -8,6 +8,20 @@ import 'title_api_service.dart';
/// Builds the UpsertUserAllApi request packet used by the
/// UpsertMusic (上传成绩) / UnlockMusic (解锁歌曲) workflows.
class UserAllPayloadBuilder {
/// `UserDetail` 里这些数值字段在 C# 侧都是 `int`,超出 int32 会让服务器
/// 反序列化失败并回 500(同 `playSpecial` 那次)。舞里程直接用整个 int32 区间,
/// 允许负值意味着「累加模式填负数就是扣里程」。
static const int minValue = -2147483648;
static const int maxValue = 2147483647;
/// 总 Rating 的显示区间,游戏内没有超过 5 位的段位。
static const int maxRating = 99999;
/// `UserChara` 的真实等级上限(`RealLevelMax`)。界面上显示的等级是
/// `level % 10000`、转生次数是 `level ~/ 10000`,所以 999999 = 99 转生 + 9999 级。
static const int minCharacterLevel = 1;
static const int maxCharacterLevel = 999999;
final TitleServerConfig config;
const UserAllPayloadBuilder(this.config);
@@ -30,6 +44,20 @@ class UserAllPayloadBuilder {
return 0;
}
static int clampValue(int v) =>
v < minValue ? minValue : (v > maxValue ? maxValue : v);
/// 旅行伙伴等级不允许 0,`UserChara.Clear()` 的初值就是 1。
static int clampCharacterLevel(int v) =>
v < minCharacterLevel
? minCharacterLevel
: (v > maxCharacterLevel ? maxCharacterLevel : v);
static Map<String, dynamic> _userDataOf(Map<String, dynamic> packet) =>
((packet['upsertUserAll'] as Map<String, dynamic>)['userData'] as List)
.first
as Map<String, dynamic>;
/// "1.56.00" -> 1056000 (Lionheart convertVersionNumber).
static int _convertVersionNumber(String version) {
final parts = version.split('.');
@@ -369,15 +397,76 @@ class UserAllPayloadBuilder {
upsert['isNewMusicDetailList'] = '1';
}
/// 发放/更新旅行伙伴:写 `userCharacterList` 并按行数生成 `isNewCharacterList`。
/// 客户端 BuildListData 只发增量、每位一个字符,所以这里只放要新增的行。
/// 发放/更新旅行伙伴:写 `userCharacterList` 并给出逐行 `isNewCharacterList`。
///
/// [newFlags] 省略时全部按「新增行」发(每位一个 '1')。给已有角色改等级时
/// 必须传 '0',否则服务器按插入处理。客户端 `BuildListData` 就是按
/// 「服务端是否已有同 characterId 的行」决定这一位是 0 还是 1 的。
void applyCharacterListPatch(
Map<String, dynamic> packet, {
required List<Map<String, dynamic>> characters,
String? newFlags,
}) {
final upsert = packet['upsertUserAll'] as Map<String, dynamic>;
upsert['userCharacterList'] = characters;
upsert['isNewCharacterList'] = List.filled(characters.length, '1').join();
upsert['isNewCharacterList'] =
newFlags ?? List.filled(characters.length, '1').join();
}
/// 一键跑图:把区域标记为已完成,并让客户端自己把状态推回「已完成」。
///
/// `UserMap.isClear` / `isComplete` 在客户端是从 `distance` 派生的
/// (`MapMaster.CreateUserDataMapList` 拿 `distance` 与该区域的
/// ReleaseFlag / End 里程针比较,没有对应针时甚至强制写回 false),
/// 单发 flag 下次进区域选择页就会被冲掉。所以这里把 `distance` 直接推到
/// `UserMapData.MaxDistance`,超过任何一张图的 End 针,flag 才站得住。
///
/// `unlockFlag` 语义是反的:`IsFinishedOpening ? 0 : 1`,取 0 表示已开启。
/// `isLock` 客户端不回读(`ConvertUserMap` 从不读它),给 false 只是保持上行一致。
///
/// [newFlags] 每一位对应 [maps] 里的一行:'1' 新增、'0' 更新,
/// 长度必须与 [maps] 一致(`BuildListData` 是一一对齐生成的)。
void applyMapPatch(
Map<String, dynamic> packet, {
required List<Map<String, dynamic>> maps,
required String newFlags,
}) {
assert(maps.length == newFlags.length);
final upsert = packet['upsertUserAll'] as Map<String, dynamic>;
upsert['userMapList'] = maps;
upsert['isNewMapList'] = newFlags;
}
/// 一行「已完成」的区域记录,字段与 `Net.VO.Mai2.UserMap` 一一对应。
static Map<String, dynamic> completedMap(int mapId, {int? distance}) => {
'mapId': mapId,
'distance': distance ?? maxMapDistance,
'isLock': false,
'isClear': true,
'isComplete': true,
'unlockFlag': 0,
};
/// `UserMapData.MaxDistance = 999999999`,客户端认的最大步数。
static const int maxMapDistance = 999999999;
/// 万花筒:写 `userKaleidxScopeList` + `isNewKaleidxScopeList`。
///
/// 每行是**整行替换**(15 个字段的 `UserKaleidxScope`),所以调用方必须先把
/// 服务器上原行的成绩/日期字段读回来合并,不然 best 记录与 playCount 会被清零。
///
/// 客户端 `ExportUserAll`(VOExtensions.cs:323)只在数组非空时才给这两个字段赋值,
/// 空数组等于什么都没发;[newFlags] 长度必须等于 [scopes] 行数。
void applyKaleidxScopePatch(
Map<String, dynamic> packet, {
required List<Map<String, dynamic>> scopes,
required String newFlags,
}) {
assert(scopes.isNotEmpty);
assert(scopes.length == newFlags.length);
final upsert = packet['upsertUserAll'] as Map<String, dynamic>;
upsert['userKaleidxScopeList'] = scopes;
upsert['isNewKaleidxScopeList'] = newFlags;
}
/// 编组旅行伙伴:改 `userData[0].charaSlot`(int[5],槽 0 为队长)。
@@ -399,4 +488,55 @@ class UserAllPayloadBuilder {
}
}
}
/// 改总 Rating:只动 `userData.playerRating` 与 `userRating.rating`。
///
/// `userRating.ratingList` / `newRatingList` 里每首歌各自的 Rating 原样带回,
/// 不重排也不改值——服务器就是按这两处的和来出「乐曲 Rating」的。
/// playlog 的 before/after 一起对齐,否则存档里那条记录会自相矛盾。
void applyRatingPatch(
Map<String, dynamic> packet, {
required int rating,
}) {
final userData = _userDataOf(packet);
final before = _toInt(userData['playerRating']);
final value = rating.clamp(0, maxRating);
userData['playerRating'] = value;
final userRatingList =
(packet['upsertUserAll'] as Map<String, dynamic>)['userRatingList']
as List;
// 整行换掉而不是原地写:userRating 在缺数据时是 `const {}`,改不了。
if (userRatingList.first is Map<String, dynamic>) {
userRatingList[0] = {
...(userRatingList.first as Map<String, dynamic>),
'rating': value,
};
}
for (final raw in (packet['userPlaylogList'] as List)) {
final playlog = raw as Map<String, dynamic>;
playlog['beforeRating'] = before;
playlog['afterRating'] = value;
playlog['beforeDeluxRating'] = before;
playlog['afterDeluxRating'] = value;
}
}
/// 改舞里程:`UserDetail.point` 是余额,`totalPoint` 是累计获得量。
///
/// 客户端只在「获得」时把 point 钳到 99999(`UserDetail.AddMile`),
/// 商店扣款走的是不带钳位的 `Point -= cost`,所以直接写更大的值不会被覆回;
/// 展示就是一句 `num.ToString()`,没有位数上限。
void applyMaiMilePatch(
Map<String, dynamic> packet, {
required int point,
int? totalPoint,
}) {
final userData = _userDataOf(packet);
userData['point'] = clampValue(point);
if (totalPoint != null) {
userData['totalPoint'] = clampValue(totalPoint);
}
}
}
+107
View File
@@ -0,0 +1,107 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:ichino/config/strings.dart';
import 'package:ichino/config/title_server_config.dart';
import 'package:ichino/pages/kaleidx_scope_page.dart';
import 'package:shared_preferences/shared_preferences.dart';
Future<void> _pumpPage(WidgetTester tester, {required bool loggedIn}) async {
SharedPreferences.setMockInitialValues({});
await TitleServerConfigHolder().update(
const TitleServerConfig(
titleServerUrl: 'http://127.0.0.1:9999',
aesKey: '0123456789abcdef',
aesIv: '0123456789abcdef',
clientId: 'A63E01C2805',
),
);
tester.view.physicalSize = const Size(420, 1700);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(
MaterialApp(
home: KaleidxScopePage(
userId: 42,
cookies: 'JSESSIONID=X',
loginDateTime: loggedIn ? 1 : null,
loginId: loggedIn ? 7 : null,
onExitToTitle: () async {},
),
),
);
await tester.pump();
}
Finder _runFinder(WidgetTester tester) => find.ancestor(
of: find.text(AppStrings.kaleidxRun),
matching: find.byType(FilledButton),
);
void main() {
testWidgets('渲染门 ID 输入与两个动作勾选,且无溢出', (tester) async {
await _pumpPage(tester, loggedIn: true);
expect(tester.takeException(), isNull);
expect(find.text(AppStrings.kaleidxGateIdLabel), findsOneWidget);
expect(find.text(AppStrings.kaleidxActionDiscover), findsOneWidget);
expect(find.text(AppStrings.kaleidxActionKey), findsOneWidget);
// 没勾钥匙时不占版面。
expect(find.text(AppStrings.kaleidxKeyNeedsGate), findsNothing);
});
testWidgets('勾了钥匙才解释「钥匙离不开门」', (tester) async {
await _pumpPage(tester, loggedIn: true);
await tester.tap(find.text(AppStrings.kaleidxActionKey));
await tester.pump();
expect(find.text(AppStrings.kaleidxKeyNeedsGate), findsOneWidget);
});
testWidgets('添加门 ID 后出现在列表并带状态', (tester) async {
await _pumpPage(tester, loggedIn: true);
await tester.enterText(find.byType(TextField), '3');
await tester.tap(find.text(AppStrings.listAddButton));
await tester.pump();
expect(find.textContaining('#3'), findsOneWidget);
expect(find.text(AppStrings.kaleidxPendingEmpty), findsNothing);
});
testWidgets('两个动作都不勾时被拦下', (tester) async {
await _pumpPage(tester, loggedIn: true);
await tester.enterText(find.byType(TextField), '3');
await tester.tap(find.text(AppStrings.listAddButton));
await tester.pump();
await tester.tap(find.text(AppStrings.kaleidxActionDiscover));
await tester.pump();
await tester.tap(_runFinder(tester));
await tester.pump();
expect(find.text(AppStrings.kaleidxNeedAction), findsOneWidget);
});
testWidgets('列表为空时执行被拦下', (tester) async {
await _pumpPage(tester, loggedIn: true);
await tester.tap(_runFinder(tester));
await tester.pump();
expect(find.text(AppStrings.kaleidxNeedGateId), findsOneWidget);
});
testWidgets('未登录时禁用执行', (tester) async {
await _pumpPage(tester, loggedIn: false);
expect(tester.takeException(), isNull);
expect(find.text(AppStrings.kaleidxNotLoggedIn), findsOneWidget);
final run = tester.widget<FilledButton>(_runFinder(tester));
expect(run.onPressed, isNull);
});
}
+88
View File
@@ -0,0 +1,88 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:ichino/config/strings.dart';
import 'package:ichino/config/title_server_config.dart';
import 'package:ichino/pages/map_traverse_page.dart';
import 'package:shared_preferences/shared_preferences.dart';
Future<void> _pumpPage(WidgetTester tester, {required bool loggedIn}) async {
SharedPreferences.setMockInitialValues({});
await TitleServerConfigHolder().update(
const TitleServerConfig(
titleServerUrl: 'http://127.0.0.1:9999',
aesKey: '0123456789abcdef',
aesIv: '0123456789abcdef',
clientId: 'A63E01C2805',
),
);
tester.view.physicalSize = const Size(420, 1600);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(
MaterialApp(
home: MapTraversePage(
userId: 42,
cookies: 'JSESSIONID=X',
loginDateTime: loggedIn ? 1 : null,
loginId: loggedIn ? 7 : null,
onExitToTitle: () async {},
),
),
);
await tester.pump();
}
Finder _runFinder(WidgetTester tester) => find.ancestor(
of: find.text(AppStrings.mapRun),
matching: find.byType(FilledButton),
);
void main() {
testWidgets('渲染区域 ID 输入与收藏品限制说明,且无溢出', (tester) async {
await _pumpPage(tester, loggedIn: true);
expect(tester.takeException(), isNull);
expect(find.text(AppStrings.mapIdLabel), findsOneWidget);
expect(find.text(AppStrings.mapCollectiblesNotice), findsOneWidget);
expect(find.text(AppStrings.mapNoData), findsOneWidget);
});
testWidgets('添加区域 ID 后进入待处理列表,重复添加被拒', (tester) async {
await _pumpPage(tester, loggedIn: true);
await tester.enterText(find.byType(TextField), '550001');
await tester.tap(find.text(AppStrings.listAddButton));
await tester.pump();
expect(find.text('#550001'), findsOneWidget);
expect(find.text(AppStrings.mapNeedMapId), findsNothing);
await tester.enterText(find.byType(TextField), '550001');
await tester.tap(find.text(AppStrings.listAddButton));
await tester.pump();
expect(find.text(AppStrings.listDuplicate), findsOneWidget);
expect(find.text('#550001'), findsOneWidget);
});
testWidgets('列表为空时执行被拦下', (tester) async {
await _pumpPage(tester, loggedIn: true);
await tester.tap(_runFinder(tester));
await tester.pump();
expect(find.text(AppStrings.mapNeedMapId), findsOneWidget);
});
testWidgets('未登录时禁用执行', (tester) async {
await _pumpPage(tester, loggedIn: false);
expect(tester.takeException(), isNull);
expect(find.text(AppStrings.mapNotLoggedIn), findsOneWidget);
final run = tester.widget<FilledButton>(_runFinder(tester));
expect(run.onPressed, isNull);
});
}
+142
View File
@@ -0,0 +1,142 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:ichino/config/strings.dart';
import 'package:ichino/config/title_server_config.dart';
import 'package:ichino/pages/number_patch_page.dart';
import 'package:ichino/services/user_all_payload_builder.dart';
import 'package:shared_preferences/shared_preferences.dart';
Future<void> _pumpPage(
WidgetTester tester,
NumberPatchMode mode, {
bool loggedIn = true,
}) async {
SharedPreferences.setMockInitialValues({});
await TitleServerConfigHolder().update(
const TitleServerConfig(
titleServerUrl: 'http://127.0.0.1:9999',
aesKey: '0123456789abcdef',
aesIv: '0123456789abcdef',
clientId: 'A63E01C2805',
),
);
tester.view.physicalSize = const Size(420, 1500);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(
MaterialApp(
home: NumberPatchPage(
userId: 42,
cookies: 'JSESSIONID=X',
loginDateTime: loggedIn ? 1 : null,
loginId: loggedIn ? 7 : null,
onExitToTitle: () async {},
mode: mode,
),
),
);
await tester.pump();
}
Finder _runFinder(WidgetTester tester) => find.ancestor(
of: find.text(AppStrings.numberPatchRun),
matching: find.byType(FilledButton),
);
FilledButton _runButton(WidgetTester tester) =>
tester.widget<FilledButton>(_runFinder(tester));
void main() {
testWidgets('Rating 模式渲染总 Rating 输入框且可执行,无溢出', (tester) async {
await _pumpPage(tester, NumberPatchMode.rating);
expect(tester.takeException(), isNull);
// 标题在 AppBar 与说明卡各出现一次。
expect(find.text(AppStrings.ratingFeatureTitle), findsNWidgets(2));
expect(find.text(AppStrings.ratingFeatureDesc), findsOneWidget);
expect(find.text(AppStrings.ratingValueLabel), findsOneWidget);
// 舞里程才有的「累加 / 直接设定」选择器不该出现。
expect(find.text(AppStrings.maiMileModeLabel), findsNothing);
expect(_runButton(tester).onPressed, isNotNull);
});
testWidgets('舞里程模式提供累加与设定两种方式', (tester) async {
await _pumpPage(tester, NumberPatchMode.maiMile);
expect(tester.takeException(), isNull);
expect(find.text(AppStrings.maiMileModeAdd), findsOneWidget);
expect(find.text(AppStrings.maiMileValueLabel), findsOneWidget);
// 累加要基于服务器上的当前余额,没拉取数据前不猜、不预览。
await tester.enterText(find.byType(TextField), '1000');
await tester.pump();
expect(find.textContaining('→'), findsNothing);
});
testWidgets('未登录时禁用执行', (tester) async {
await _pumpPage(tester, NumberPatchMode.rating, loggedIn: false);
expect(tester.takeException(), isNull);
expect(find.text(AppStrings.numberPatchNotLoggedIn), findsOneWidget);
expect(_runButton(tester).onPressed, isNull);
});
testWidgets('Rating 超过 99999 时被拦下', (tester) async {
await _pumpPage(tester, NumberPatchMode.rating);
await tester.enterText(
find.byType(TextField),
'${UserAllPayloadBuilder.maxRating + 1}',
);
await tester.pump();
await tester.tap(_runFinder(tester));
await tester.pump();
expect(
find.text(
AppStrings.numberPatchOutOfRange(
0,
UserAllPayloadBuilder.maxRating,
),
),
findsOneWidget,
);
});
testWidgets('舞里程模式接受 int32 两端的大数', (tester) async {
await _pumpPage(tester, NumberPatchMode.maiMile);
for (final value in [
UserAllPayloadBuilder.maxValue,
UserAllPayloadBuilder.minValue,
]) {
await tester.enterText(find.byType(TextField), '$value');
await tester.pump();
await tester.tap(_runFinder(tester));
await tester.pump();
// 不该报越界;会走到拉数据/发包那步,这里只断言没有越界提示。
expect(
find.text(
AppStrings.numberPatchOutOfRange(
UserAllPayloadBuilder.minValue,
UserAllPayloadBuilder.maxValue,
),
),
findsNothing,
reason: '$value 在 int32 区间内,不该被拦下',
);
}
});
testWidgets('空输入时给出填写提示', (tester) async {
await _pumpPage(tester, NumberPatchMode.maiMile);
await tester.tap(_runFinder(tester));
await tester.pump();
expect(find.text(AppStrings.maiMileNeedValue), findsOneWidget);
});
}
+394
View File
@@ -1,6 +1,8 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:ichino/config/strings.dart';
import 'package:ichino/config/title_server_config.dart';
import 'package:ichino/models/user_character.dart';
import 'package:ichino/models/user_kaleidx_scope.dart';
import 'package:ichino/services/user_all_payload_builder.dart';
const _config = TitleServerConfig(
@@ -113,4 +115,396 @@ void main() {
expect(upsert['isNewItemList'], '11');
});
});
group('总 Rating patch', () {
test('只改 playerRating 与 userRating.rating,每首歌的 ratingList 不动', () {
final builder = UserAllPayloadBuilder(_config);
final packet = builder.build(
userId: 42,
loginId: 7,
loginDateTime: 1700000000,
musicData: const {'musicId': 11538, 'level': 0, 'achievement': 0},
generalUserInfo: const {
'GetUserDataApi': {
'userData': {
'playerRating': 12345,
'highestRating': 13000,
'gradeRating': 5000,
'charaSlot': [101],
},
},
'GetUserRatingApi': {
'userRating': {
'rating': 12345,
'ratingList': [
{'musicId': 834, 'level': 4, 'rating': 100},
],
'newRatingList': [
{'musicId': 11538, 'level': 3, 'rating': 200},
],
},
},
},
);
builder.applyRatingPatch(packet, rating: 99999);
final upsert = _upsert(packet);
final userData = (upsert['userData'] as List).first
as Map<String, dynamic>;
final userRating = (upsert['userRatingList'] as List).first
as Map<String, dynamic>;
expect(userData['playerRating'], 99999);
expect(userRating['rating'], 99999);
// 每首歌各自的 Rating 必须原样带回。
expect(userRating['ratingList'], [
{'musicId': 834, 'level': 4, 'rating': 100},
]);
expect(userRating['newRatingList'], [
{'musicId': 11538, 'level': 3, 'rating': 200},
]);
// 历史最高、段位 Rating 这些不属于「总 Rating」。
expect(userData['highestRating'], 13000);
expect(userData['gradeRating'], 5000);
final playlog = (packet['userPlaylogList'] as List).first
as Map<String, dynamic>;
expect(playlog['beforeRating'], 12345);
expect(playlog['afterRating'], 99999);
expect(playlog['beforeDeluxRating'], 12345);
expect(playlog['afterDeluxRating'], 99999);
});
test('Rating 超过 99999 被钳到上限,负数钳到 0', () {
final builder = UserAllPayloadBuilder(_config);
final packet = _buildPacket(builder);
builder.applyRatingPatch(
packet,
rating: UserAllPayloadBuilder.maxRating + 500,
);
final userData =
((_upsert(packet))['userData'] as List).first as Map<String, dynamic>;
expect(userData['playerRating'], UserAllPayloadBuilder.maxRating);
builder.applyRatingPatch(packet, rating: -5);
final clamped = (_upsert(packet)['userData'] as List).first
as Map<String, dynamic>;
expect(clamped['playerRating'], 0);
});
test('GetUserRatingApi 缺数据时不会写到 const 空 map 上崩掉', () {
final builder = UserAllPayloadBuilder(_config);
final packet = _buildPacket(builder);
expect(() => builder.applyRatingPatch(packet, rating: 5000), returnsNormally);
final userRating = (_upsert(packet))['userRatingList'] as List;
expect(userRating.first, {'rating': 5000});
});
});
group('舞里程 patch', () {
test('point 与 totalPoint 分别写入,totalPoint 省略时保持原值', () {
final builder = UserAllPayloadBuilder(_config);
final packet = builder.build(
userId: 42,
loginId: 7,
loginDateTime: 1700000000,
musicData: const {'musicId': 11538, 'level': 0, 'achievement': 0},
generalUserInfo: const {
'GetUserDataApi': {
'userData': {'point': 8000, 'totalPoint': 120000},
},
},
);
builder.applyMaiMilePatch(packet, point: 99999);
final userData =
(_upsert(packet))['userData'] as List;
final row = userData.first as Map<String, dynamic>;
expect(row['point'], 99999);
expect(row['totalPoint'], 120000);
});
test('累加越过 int32 上界时被钳住', () {
const current = 2147483000;
final builder = UserAllPayloadBuilder(_config);
final packet = builder.build(
userId: 42,
loginId: 7,
loginDateTime: 1700000000,
musicData: const {'musicId': 11538, 'level': 0, 'achievement': 0},
generalUserInfo: const {
'GetUserDataApi': {
'userData': {'point': current, 'totalPoint': current},
},
},
);
// 累加 2000 会越过 int32 上界,应被钳住。
builder.applyMaiMilePatch(
packet,
point: UserAllPayloadBuilder.clampValue(current + 2000),
totalPoint: UserAllPayloadBuilder.clampValue(current + 2000),
);
final row = ((_upsert(packet))['userData'] as List).first
as Map<String, dynamic>;
expect(row['point'], UserAllPayloadBuilder.maxValue);
expect(row['totalPoint'], UserAllPayloadBuilder.maxValue);
expect(UserAllPayloadBuilder.maxValue, 2147483647);
expect(UserAllPayloadBuilder.minValue, -2147483648);
});
test('负数舞里程原样写入,只有越过 int32 下界才钳', () {
final builder = UserAllPayloadBuilder(_config);
final packet = builder.build(
userId: 42,
loginId: 7,
loginDateTime: 1700000000,
musicData: const {'musicId': 11538, 'level': 0, 'achievement': 0},
generalUserInfo: const {
'GetUserDataApi': {
'userData': {'point': 5000, 'totalPoint': 5000},
},
},
);
// 累加模式下填负数就是扣里程,不该被当成非法值抬回 0。
builder.applyMaiMilePatch(packet, point: -7);
final row = (_upsert(packet)['userData'] as List).first
as Map<String, dynamic>;
expect(row['point'], -7);
});
test('clampValue 的两侧边界', () {
expect(UserAllPayloadBuilder.clampValue(0), 0);
expect(UserAllPayloadBuilder.clampValue(-7), -7);
expect(
UserAllPayloadBuilder.clampValue(UserAllPayloadBuilder.maxValue + 1),
UserAllPayloadBuilder.maxValue,
);
expect(
UserAllPayloadBuilder.clampValue(UserAllPayloadBuilder.minValue - 1),
UserAllPayloadBuilder.minValue,
);
});
});
group('旅行伙伴等级 patch', () {
test('新增行标 1、改等级行标 0,两段拼成 isNewCharacterList', () {
final builder = UserAllPayloadBuilder(_config);
final packet = _buildPacket(builder);
builder.applyCharacterListPatch(
packet,
characters: [
{'characterId': 101, 'level': 999999, 'awakening': 0, 'useCount': 0},
{'characterId': 102, 'level': 5000, 'awakening': 3, 'useCount': 77},
],
newFlags: '10',
);
final upsert = _upsert(packet);
expect(upsert['isNewCharacterList'], '10');
expect(upsert['userCharacterList'], hasLength(2));
});
test('省略 newFlags 时沿用全 1 的新增语义', () {
final builder = UserAllPayloadBuilder(_config);
final packet = _buildPacket(builder);
builder.applyCharacterListPatch(packet, characters: [
{'characterId': 101, 'level': 1, 'awakening': 0, 'useCount': 0},
]);
expect(_upsert(packet)['isNewCharacterList'], '1');
});
test('等级钳位:0 以下抬到 1,超过真实等级上限压到 999999', () {
expect(UserAllPayloadBuilder.clampCharacterLevel(0), 1);
expect(UserAllPayloadBuilder.clampCharacterLevel(-9), 1);
expect(UserAllPayloadBuilder.clampCharacterLevel(10000000), 999999);
expect(UserAllPayloadBuilder.clampCharacterLevel(999999), 999999);
});
});
group('一键跑图 userMapList patch', () {
test('完成行的字段与 Net.VO.Mai2.UserMap 一一对应', () {
final row = UserAllPayloadBuilder.completedMap(550001);
// unlockFlag 语义是反的:0 才表示已开启(IsFinishedOpening)。
expect(row.keys.toSet(), {
'mapId',
'distance',
'isLock',
'isClear',
'isComplete',
'unlockFlag',
});
expect(row['mapId'], 550001);
expect(row['isClear'], isTrue);
expect(row['isComplete'], isTrue);
expect(row['unlockFlag'], 0);
// distance 必须推过 End 针,否则客户端会用 distance 把 flag 冲回 false。
expect(row['distance'], UserAllPayloadBuilder.maxMapDistance);
});
test('isNewMapList 每位对应一行,服务器已有的行标 0', () {
final builder = UserAllPayloadBuilder(_config);
final packet = _buildPacket(builder);
builder.applyMapPatch(
packet,
maps: [
UserAllPayloadBuilder.completedMap(1),
UserAllPayloadBuilder.completedMap(2),
UserAllPayloadBuilder.completedMap(3),
],
newFlags: '010',
);
final upsert = _upsert(packet);
expect(upsert['userMapList'], hasLength(3));
expect(upsert['isNewMapList'], '010');
expect(upsert['isNewMapList'].length,
(upsert['userMapList'] as List).length);
});
test('可以单独抬高 distance 而不动其它字段', () {
final row = UserAllPayloadBuilder.completedMap(9, distance: 12345);
expect(row['distance'], 12345);
});
});
group('万花筒 userKaleidxScopeList patch', () {
test('wire 行的 15 个字段与 UserKaleidxScope 结构一致', () {
final row = const UserKaleidxScopeBean(gateId: 3).toWireJson();
// totalDeluxscore 的拼写是原样的 Deluxscore,写错服务器认不出。
expect(row.keys.toList(), [
'gateId',
'isGateFound',
'isKeyFound',
'isClear',
'totalRestLife',
'totalAchievement',
'totalDeluxscore',
'bestAchievement',
'bestDeluxscore',
'bestAchievementDate',
'bestDeluxscoreDate',
'playCount',
'clearDate',
'lastPlayDate',
'isInfoWatched',
]);
// 日期字段用 "" 而不是 null,对齐客户端 class 构造器的默认值。
expect(row['bestAchievementDate'], '');
expect(row['clearDate'], '');
});
test('isNewKaleidxScopeList 位数与行数一致,已有行标 0', () {
final builder = UserAllPayloadBuilder(_config);
final packet = _buildPacket(builder);
final scopes = [
const UserKaleidxScopeBean(gateId: 1, isGateFound: true),
const UserKaleidxScopeBean(gateId: 2, isGateFound: true),
];
builder.applyKaleidxScopePatch(
packet,
scopes: [for (final s in scopes) s.toWireJson()],
newFlags: '01',
);
final upsert = _upsert(packet);
expect(upsert['userKaleidxScopeList'], hasLength(2));
expect(upsert['isNewKaleidxScopeList'], '01');
expect(
(upsert['isNewKaleidxScopeList'] as String).length,
(upsert['userKaleidxScopeList'] as List).length,
);
});
test('新发现门:isGateFound=true,给钥匙时连带把门标为已发现', () {
final foundOnly = UserKaleidxScopeBean.discovered(
7,
giveKey: false,
).toWireJson();
expect(foundOnly['isGateFound'], isTrue);
expect(foundOnly['isKeyFound'], isFalse);
final withKey = UserKaleidxScopeBean.discovered(
7,
giveKey: true,
).toWireJson();
expect(withKey['isKeyFound'], isTrue);
expect(withKey['isGateFound'], isTrue);
});
test('已有行只叠加布尔位,成绩与日期不被清零', () {
final server = UserKaleidxScopeBean.fromJson(const {
'gateId': 4,
'isGateFound': true,
'isKeyFound': false,
'isClear': false,
'totalRestLife': 5,
'totalAchievement': 612345,
'totalDeluxscore': 987,
'bestAchievement': 1012345,
'bestDeluxscore': 55,
'bestAchievementDate': '2026-01-02 03:04:05.0',
'bestDeluxscoreDate': '2026-01-02 03:04:05.0',
'playCount': 12,
'clearDate': '',
'lastPlayDate': '2026-09-01 00:00:00.0',
'isInfoWatched': true,
});
final updated = server
.withActions(discover: false, giveKey: true)
.toWireJson();
expect(updated['isKeyFound'], isTrue);
expect(updated['isGateFound'], isTrue);
expect(updated['bestAchievement'], 1012345);
expect(updated['bestAchievementDate'], '2026-01-02 03:04:05.0');
expect(updated['playCount'], 12);
expect(updated['totalRestLife'], 5);
expect(updated['isInfoWatched'], isTrue);
expect(updated['lastPlayDate'], '2026-09-01 00:00:00.0');
});
test('withActions 不会把已经为 true 的位改回 false', () {
const cleared = UserKaleidxScopeBean(
gateId: 9,
isGateFound: true,
isKeyFound: true,
isClear: true,
);
final again = cleared.withActions(discover: true, giveKey: false);
expect(again.isGateFound, isTrue);
expect(again.isKeyFound, isTrue);
expect(again.isClear, isTrue);
});
test('状态文案覆盖四种组合,隐形态单独说明', () {
expect(AppStrings.kaleidxGateState(false, false, false), '未见过');
expect(AppStrings.kaleidxGateState(true, false, false), '已发现 · 未解锁');
expect(AppStrings.kaleidxGateState(true, true, false), '可挑战');
expect(AppStrings.kaleidxGateState(true, true, true), '已通关');
// !found && key 是隐形,不是「锁着的门」。
expect(
AppStrings.kaleidxGateState(false, true, false),
contains('隐形'),
);
});
});
}