diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..eaecae5 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md index 49746b6..df82cd1 100644 --- a/README.md +++ b/README.md @@ -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 没有这份表,页面里也明写了这个限制。 ## 构建环境注意事项 diff --git a/lib/config/strings.dart b/lib/config/strings.dart index 8c37f58..474b6e2 100644 --- a/lib/config/strings.dart +++ b/lib/config/strings.dart @@ -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 = '以下功能会向服务器发送伪造数据,属于高风险操作,请谨慎使用。'; diff --git a/lib/models/user_kaleidx_scope.dart b/lib/models/user_kaleidx_scope.dart new file mode 100644 index 0000000..193a9b5 --- /dev/null +++ b/lib/models/user_kaleidx_scope.dart @@ -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 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 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 listFromResponse( + Map json, + ) { + final raw = json['userKaleidxScopeList']; + if (raw is! List) return const []; + return raw + .whereType>() + .map(UserKaleidxScopeBean.fromJson) + .toList(); + } +} diff --git a/lib/pages/high_risk_feature_page.dart b/lib/pages/high_risk_feature_page.dart index 855e268..ad96b71 100644 --- a/lib/pages/high_risk_feature_page.dart +++ b/lib/pages/high_risk_feature_page.dart @@ -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 { ); } + 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 { 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, + ), ], ), ), diff --git a/lib/pages/kaleidx_scope_page.dart b/lib/pages/kaleidx_scope_page.dart new file mode 100644 index 0000000..9100330 --- /dev/null +++ b/lib/pages/kaleidx_scope_page.dart @@ -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 Function()? onExitToTitle; + + const KaleidxScopePage({ + super.key, + required this.userId, + this.cookies, + this.loginDateTime, + this.loginId, + this.onExitToTitle, + }); + + @override + State createState() => _KaleidxScopePageState(); +} + +enum _Step { idle, fetchData, upload, logout, complete, failed } + +class _KaleidxScopePageState extends State { + /// 与其它高危功能一致:不写真实成绩,playlog 用同一条占位记录。 + static const int _placeholderMusicId = 11538; + + final _gateIdController = TextEditingController(); + final List _pending = []; + + bool _discover = true; + bool _giveKey = false; + + Map> _userAllData = const {}; + List _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 _buildRows() { + return [ + for (final id in _pending) + _serverRow(id)?.withActions(discover: _discover, giveKey: _giveKey) ?? + UserKaleidxScopeBean.discovered(id, giveKey: _giveKey), + ]; + } + + Future _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>; + _serverScopes = results[1] as List; + _loading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() => _loading = false); + _snack('${AppStrings.loadFailed}: $e'); + } + } + + Future _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>; + if (mounted) { + setState(() { + _userAllData = data; + _serverScopes = results[1] as List; + }); + } + } + + _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 _verify(TitleApiService service) async { + final requested = List.from(_pending); + List 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 _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 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)), + ], + ), + ); + } +} diff --git a/lib/pages/map_traverse_page.dart b/lib/pages/map_traverse_page.dart new file mode 100644 index 0000000..5f5e133 --- /dev/null +++ b/lib/pages/map_traverse_page.dart @@ -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 Function()? onExitToTitle; + + const MapTraversePage({ + super.key, + required this.userId, + this.cookies, + this.loginDateTime, + this.loginId, + this.onExitToTitle, + }); + + @override + State createState() => _MapTraversePageState(); +} + +enum _Step { idle, fetchData, upload, logout, complete, failed } + +class _MapTraversePageState extends State { + /// 与其它高危功能一致:不写真实成绩,playlog 用同一条占位记录。 + static const int _placeholderMusicId = 11538; + + final _mapIdController = TextEditingController(); + final List _pending = []; + + Map> _userAllData = const {}; + Set? _serverMapIds; + List> _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> rows) { + _serverMaps = rows; + _serverMapIds = rows + .map((e) => (e['mapId'] as num?)?.toInt() ?? 0) + .where((e) => e != 0) + .toSet(); + } + + Future _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>; + _applyServerMaps(results[1] as List>); + _loading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() => _loading = false); + _snack('${AppStrings.loadFailed}: $e'); + } + } + + Future _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>; + if (mounted) setState(() => _userAllData = data); + _applyServerMaps(results[1] as List>); + 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 _verify(TitleApiService service) async { + final requested = List.from(_pending); + List> rows; + try { + rows = await service.getUserMaps(widget.userId); + } catch (e) { + return AppStrings.mapVerifyFailed('$e'); + } + if (mounted) setState(() => _applyServerMaps(rows)); + + final saved = _serverMapIds ?? {}; + final missing = requested.where((id) => !saved.contains(id)).toList(); + if (missing.isNotEmpty) { + return AppStrings.mapNotSaved(missing.join(' / ')); + } + return AppStrings.mapVerified(requested.length); + } + + Map _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)), + ], + ), + ); + } +} diff --git a/lib/pages/number_patch_page.dart b/lib/pages/number_patch_page.dart new file mode 100644 index 0000000..69f72d1 --- /dev/null +++ b/lib/pages/number_patch_page.dart @@ -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 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 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 createState() => _NumberPatchViewState(); +} + +class _NumberPatchViewState extends State { + /// 与解锁/收藏品/旅行伙伴一致:这些操作不写真实成绩, + /// playlog 沿用同一条占位记录,Rating 修改需要它来带 before/after。 + static const int _placeholderMusicId = 11538; + + final _valueController = TextEditingController(); + + Map> _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> 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 _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 _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 _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( + 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)), + ], + ), + ); + } +} diff --git a/lib/pages/travel_partner_page.dart b/lib/pages/travel_partner_page.dart index 1fd9f98..3219995 100644 --- a/lib/pages/travel_partner_page.dart +++ b/lib/pages/travel_partner_page.dart @@ -48,6 +48,12 @@ class _TravelPartnerPageState extends State { final _grantIdController = TextEditingController(); + /// 每个出战槽位一个等级输入框,留空表示不改该角色的等级。 + final _slotLevelControllers = List.generate( + 5, + (_) => TextEditingController(), + ); + List _owned = const []; Map> _userAllData = const {}; final List _pendingGrant = []; @@ -82,6 +88,9 @@ class _TravelPartnerPageState extends State { void dispose() { _cooldownTimer?.cancel(); _grantIdController.dispose(); + for (final c in _slotLevelControllers) { + c.dispose(); + } super.dispose(); } @@ -192,9 +201,28 @@ class _TravelPartnerPageState extends State { } 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 _slotLevelsById() { + final result = {}; + 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 { 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 { 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 = >[]; + 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 { } } + Future _verifyGranted(TitleApiService service) async { + final requested = List.from(_pendingGrant); + List 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 _placeholderMusicData() => { 'musicId': _placeholderMusicId, 'level': 0, @@ -527,10 +606,48 @@ class _TravelPartnerPageState extends State { : 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 _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 = { @@ -548,37 +665,64 @@ class _TravelPartnerPageState extends State { const SizedBox(height: 14), for (var i = 0; i < 5; i++) Padding( - padding: const EdgeInsets.only(bottom: 8), - child: DropdownButtonFormField( - 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( + 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 { ); } + /// 真实等级和界面等级不是一回事(`UserChara`:显示 = level % 10000、 + /// 转生 = level ~/ 10000),只在会混淆时才说明。 + List _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, diff --git a/lib/services/title_api_service.dart b/lib/services/title_api_service.dart index 29c2f7b..7fc7815 100644 --- a/lib/services/title_api_service.dart +++ b/lib/services/title_api_service.dart @@ -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>> getUserMaps(int userId) async { + const apiName = 'GetUserMapApi'; + final all = >[]; + 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>()); + } + nextIndex = (json['nextIndex'] as num?)?.toInt() ?? 0; + pages++; + } while (nextIndex != 0 && pages < 20); + + return all; + } + + /// 万花宿命之门进度,对应 UserKaleidxScopeResponseVO。 + /// + /// 与 `GetUserMapApi` 不同,这个接口不分页(`UserKaleidxScopeRequestVO` + /// 只有 `userId` 一个字段),一次拿全。 + Future> getUserKaleidxScopes(int userId) async { + final json = await _callApi( + 'GetUserKaleidxScopeApi', + {'userId': userId}, + userId, + ); + return UserKaleidxScopeBean.listFromResponse(json); + } } class _RandomHelper { diff --git a/lib/services/user_all_payload_builder.dart b/lib/services/user_all_payload_builder.dart index c93008c..216ab89 100644 --- a/lib/services/user_all_payload_builder.dart +++ b/lib/services/user_all_payload_builder.dart @@ -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 _userDataOf(Map packet) => + ((packet['upsertUserAll'] as Map)['userData'] as List) + .first + as Map; + /// "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 packet, { required List> characters, + String? newFlags, }) { final upsert = packet['upsertUserAll'] as Map; 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 packet, { + required List> maps, + required String newFlags, + }) { + assert(maps.length == newFlags.length); + final upsert = packet['upsertUserAll'] as Map; + upsert['userMapList'] = maps; + upsert['isNewMapList'] = newFlags; + } + + /// 一行「已完成」的区域记录,字段与 `Net.VO.Mai2.UserMap` 一一对应。 + static Map 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 packet, { + required List> scopes, + required String newFlags, + }) { + assert(scopes.isNotEmpty); + assert(scopes.length == newFlags.length); + final upsert = packet['upsertUserAll'] as Map; + 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 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)['userRatingList'] + as List; + // 整行换掉而不是原地写:userRating 在缺数据时是 `const {}`,改不了。 + if (userRatingList.first is Map) { + userRatingList[0] = { + ...(userRatingList.first as Map), + 'rating': value, + }; + } + + for (final raw in (packet['userPlaylogList'] as List)) { + final playlog = raw as Map; + 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 packet, { + required int point, + int? totalPoint, + }) { + final userData = _userDataOf(packet); + userData['point'] = clampValue(point); + if (totalPoint != null) { + userData['totalPoint'] = clampValue(totalPoint); + } + } } diff --git a/test/kaleidx_scope_page_test.dart b/test/kaleidx_scope_page_test.dart new file mode 100644 index 0000000..1a1754e --- /dev/null +++ b/test/kaleidx_scope_page_test.dart @@ -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 _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(_runFinder(tester)); + expect(run.onPressed, isNull); + }); +} diff --git a/test/map_traverse_page_test.dart b/test/map_traverse_page_test.dart new file mode 100644 index 0000000..90cb13c --- /dev/null +++ b/test/map_traverse_page_test.dart @@ -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 _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(_runFinder(tester)); + expect(run.onPressed, isNull); + }); +} diff --git a/test/number_patch_page_test.dart b/test/number_patch_page_test.dart new file mode 100644 index 0000000..e9c6554 --- /dev/null +++ b/test/number_patch_page_test.dart @@ -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 _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(_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); + }); +} diff --git a/test/user_all_patch_test.dart b/test/user_all_patch_test.dart index cf2c77e..9c74d20 100644 --- a/test/user_all_patch_test.dart +++ b/test/user_all_patch_test.dart @@ -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; + final userRating = (upsert['userRatingList'] as List).first + as Map; + 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; + 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; + expect(userData['playerRating'], UserAllPayloadBuilder.maxRating); + + builder.applyRatingPatch(packet, rating: -5); + final clamped = (_upsert(packet)['userData'] as List).first + as Map; + 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; + 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; + 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; + 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('隐形'), + ); + }); + }); }