From 9b3e76d42d8d3f5a9be8fb53b85689107e7c5139 Mon Sep 17 00:00:00 2001 From: TermiNexus Date: Fri, 23 Oct 2026 16:36:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(state):=20=E8=BF=81=E7=A7=BB=20user=20?= =?UTF-8?q?=E5=88=87=E7=89=87=EF=BC=8832=20=E5=AD=97=E6=AE=B5=20/=2028=20?= =?UTF-8?q?=E8=87=82=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 3DTank/docs/KtRebuild_Plan.md | 20 +- 3DTank/names/root_wiring.json | 29 ++- 3DTank/tools/gen_action_kt.js | 15 ++ 3DTank/tools/gen_root_wiring.js | 19 ++ .../jsMain/kotlin/App/State/ActionCatalog.kt | 26 +- .../src/jsMain/kotlin/App/State/Inventory.kt | 30 +-- .../kotlin/App/State/Reducers/UserReducer.kt | 190 ++++++++++++++ .../jsMain/kotlin/App/State/RootReducer.kt | 7 +- .../src/jsMain/kotlin/App/State/RootState.kt | 5 +- .../src/jsMain/kotlin/App/State/RootWiring.kt | 4 +- .../app/src/jsMain/kotlin/App/StateCheck.kt | 240 +++++++++++++++++- 11 files changed, 529 insertions(+), 56 deletions(-) create mode 100644 TankiOffline/app/src/jsMain/kotlin/App/State/Reducers/UserReducer.kt diff --git a/3DTank/docs/KtRebuild_Plan.md b/3DTank/docs/KtRebuild_Plan.md index 00d0090..3796330 100644 --- a/3DTank/docs/KtRebuild_Plan.md +++ b/3DTank/docs/KtRebuild_Plan.md @@ -143,7 +143,7 @@ `names/root_wiring.json` + `TankiOffline/.../App/State/RootState.kt`、`RootWiring.kt`。 - 根状态 `z6`(`TOState`)的 **89 个槽位**全部入表;字段顺序取 `toString`,槽位取构造函数 `<名>_1`。 - 根 reducer `k6` 的逐切片委派记全:reducer 引用、委派表达式、顶层 `instanceof` 动作名。 - - 已迁移切片 **12/89**: + - 已迁移切片 **13/89**: - `inviteState` → `aD` / `pD.er5`(`InviteReducer.kt`) - `loginByPassword` → `gD` / `MD.cr6`(`LoginReducer.kt`,含 `SetLogin`/`SetPassword` 把 status 打回 `NONE` 的原语义) - `twitchState` → `g9` / `y9.gta`(`TwitchReducer.kt`;`SetAccount` 是 Twitch 账号,不是 UID) @@ -186,12 +186,20 @@ `SetControlsForChina` / `SetRunningInChinaDataCenter` / `SetRunningInChinaPartners` / `SetAuthorizedByChinaSdkToken` / `SetClassic` 各置一个 true,`SetAgeWarnShow` 写入 `isAgeWarnShow`。 原 reducer 用 `ur4` 的 `cd` 参数保留其余字段;`authorizedByClassicEarlyAccessToken` 没有写入臂。 + - `user` → `t8` / `M8().qtd`(`UserReducer.kt`):32 字段,28 个 `instanceof` 臂。 + 根委派是 `M8().qtd(t, n.ypu_1)`,全部通过 `qtb`(`t8.copy`,33 参数 = 32 字段 + 回调)写回,`cd` 表示保留原字段。 + `id` / `premiumExpirationTime` / `eventPassEndTime` 是 Kotlin/JS `Long`(原构造 `Dd`), + `rankBounds` 保留原 `RankBounds` 形状(默认 `UserRankBounds(0,0)`);`disabledWithoutPremiumLocations` 收口为 `Set`。 + `SetUserPropertiesAction` 只复制 id/货币/rank/分数/rating/uid/观战权限/normalizedRank/profileUrl,不清空未触及字段; + `AddCoins`/`AddRubies` 把 `spent` 收回主余额后清零,`SpendCoins`/`SpendRubies` 同步累加 `spent`。 + `UpdateRank` 用 `min(rankNames.size, rank)` 重算 `normalizedRank`;`SetUserId(null)` 归零。 + 3 个匿名动作按效果定名:`R8`→`DisableLobbyTutorial`、`v8`→`SetInDebt`、`p8`→`ClearInDebt`。 - `launchParams` → `(pass-through)`:原根 reducer 对 `hpx_1` 没有调用,缺省是 `ag()` 空 Map; `RootState.LaunchParams` 收口为 `LaunchParamsStateSlice = Map`,不新增 reducer/动作。 - - `App/State/RootReducer.kt`:`ReduceRoot(State0, Action)` 归约上述 11 个有 reducer 的切片, - `launchParams` 与其余 **77 个字段原样透传**; - 迁移进度用 `RootReducer.Coverage`(`已迁移 12/89`)与 `RootWiring.Unmigrated` 暴露。 - - 自检:`TankiOffline/tools/RunStateTest.ps1` **294 项全绿**,含 89 槽位完整性、11 个归约切片一次归约互不串片、未迁移字段透传, + - `App/State/RootReducer.kt`:`ReduceRoot(State0, Action)` 归约上述 12 个有 reducer 的切片, + `launchParams` 与其余 **76 个字段原样透传**; + 迁移进度用 `RootReducer.Coverage`(`已迁移 13/89`)与 `RootWiring.Unmigrated` 暴露。 + - 自检:`TankiOffline/tools/RunStateTest.ps1` **318 项全绿**,含 89 槽位完整性、12 个归约切片一次归约互不串片、未迁移字段透传, 以及用 `action_trace_merged.json` 真实派发的两条商店动作(`SetCoinPaymentProviderUrl` / `ToggleShopActivity`)回放。 - 委派表里的已知形态:`shop` / `home` / `localBattleUserState` / `partner` 是内联 IIFE, 已按字段具名为 `(inline@)` 并从函数体抓到顶层动作名(69 / 1 / 2 / 11 个); @@ -201,7 +209,7 @@ 效果是把 `HomeScreen.shopDiscountNotification` 置 true,对应主菜单商店项的折扣提示); shop 的 17 个匿名类按 reducer 效果定名并登记(`Yut`→`ShopLoaded`、`F5`→`ResetShop` 等); screen 的 9 个、lootBoxes 的 6 个、garage 的 25 个、garagePreview 的 15 个、 - entrance 的 6 个匿名类同样按效果定名并登记。 + entrance 的 6 个、user 的 3 个匿名类同样按效果定名并登记。 ### S4 引擎核心 - 模块:32088(3D 数学)、92980(渲染)、61378(ECS)、55088(物理)、36659 + 71105(资源)、36734(音频)、3569(输入) diff --git a/3DTank/names/root_wiring.json b/3DTank/names/root_wiring.json index f9bf064..2b7e530 100644 --- a/3DTank/names/root_wiring.json +++ b/3DTank/names/root_wiring.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-10-23T07:37:58.358Z", + "generatedAt": "2026-10-23T08:14:22.248Z", "module": "module_10032.js", "RootState": { "Class": "z6", @@ -18,6 +18,7 @@ "garage", "garagePreview", "entrance", + "user", "launchParams" ], "AnonymousActions": [ @@ -495,6 +496,24 @@ "Name": "SetClassic", "DispatchedAt": "未在 action_trace_merged.json 中出现;按 reducer 分支效果定名", "Effect": "isClassic = true(`n.ur4(cd, cd, cd, cd, cd, cd, !0)`)" + }, + { + "Local": "R8", + "Name": "DisableLobbyTutorial", + "DispatchedAt": "未在 action_trace_merged.json 中出现;按 reducer 分支效果定名", + "Effect": "lobbyTutorialEnabled = false(`n.qtb(cd × 29, !1)`)" + }, + { + "Local": "v8", + "Name": "SetInDebt", + "DispatchedAt": "未在 action_trace_merged.json 中出现;按 reducer 分支效果定名", + "Effect": "inDebt = true(`n.qtb(cd × 30, !0)`)" + }, + { + "Local": "p8", + "Name": "ClearInDebt", + "DispatchedAt": "未在 action_trace_merged.json 中出现;按 reducer 分支效果定名", + "Effect": "inDebt = false(`n.qtb(cd × 30, !1)`)" } ], "Markers": [ @@ -537,16 +556,16 @@ "Reducer": "M8().qtd", "Delegate": "M8().qtd(t, n.ypu_1)", "Actions": [ - "?R8", - "?p8", - "?v8", "AddCoins", "AddRubies", + "ClearInDebt", + "DisableLobbyTutorial", "SetBatteryLevel", "SetClanInfo", "SetCoins", "SetDisabledWithoutPremiumLocations", "SetHasTemporaryCash", + "SetInDebt", "SetPremiumExpirationTime", "SetRankNames", "SetRubies", @@ -566,7 +585,7 @@ "UpdateUserRating" ], "Default": "new t8()", - "Migrated": false + "Migrated": true }, { "Field": "screen", diff --git a/3DTank/tools/gen_action_kt.js b/3DTank/tools/gen_action_kt.js index 514d206..b724a00 100644 --- a/3DTank/tools/gen_action_kt.js +++ b/3DTank/tools/gen_action_kt.js @@ -641,6 +641,21 @@ const FieldTypeOverrides = new Map([ ['ResetItem3dModelActionCache.itemId', 'Long'], ['SetCaptureActions.screenshotAction', '(() -> Unit)?'], ['SetCaptureActions.videoAction', '(() -> Unit)?'], + // user 切片:按状态字段/copy 调用点收口;y8 在源码里有 `new y8(null)`,userId 必须可空。 + ['SetUserId.userId', 'Long?'], + ['UpdateRank.rank', 'Int'], + ['UpdateRank.score', 'Int'], + ['UpdateUserRating.userRating', 'Int'], + ['SpendCrystals.crystal', 'Int'], + ['SpendCoins.coins', 'Int'], + ['AddCoins.coins', 'Int'], + ['SpendRubies.rubies', 'Int'], + ['AddRubies.rubies', 'Int'], + ['SetHasTemporaryCash.hasTemporaryCash', 'Boolean'], + ['UpdateUid.uid', 'String'], + ['UpdateEventPass.eventPassEndTime', 'Long'], + ['SetPremiumExpirationTime.expirationTime', 'Long'], + ['SetDisabledWithoutPremiumLocations.locations', 'Collection'], ]); const Emitted = []; diff --git a/3DTank/tools/gen_root_wiring.js b/3DTank/tools/gen_root_wiring.js index 20106fe..3c8bd7b 100644 --- a/3DTank/tools/gen_root_wiring.js +++ b/3DTank/tools/gen_root_wiring.js @@ -127,6 +127,11 @@ const AnonymousActionNames = new Map([ ['oit', 'SetRunningInChinaPartners'], ['lit', 'SetAuthorizedByChinaSdkToken'], ['fit', 'SetClassic'], + // user 切片(t8 / M8().qtd):3 个没有 toString / 不在 action_inventory 的类。 + // 括号里是原 reducer 的效果;实现见 UserReducer.kt。 + ['R8', 'DisableLobbyTutorial'], + ['v8', 'SetInDebt'], + ['p8', 'ClearInDebt'], ]); // 非动作的 `instanceof`:屏幕标识类,用于 GoTo 分支判断(不是 dispatch 的动作)。 @@ -498,6 +503,7 @@ const Migrated = { garage: { Type: 'GarageStateSlice', Reduce: 'ReduceGarage' }, garagePreview: { Type: 'GaragePreviewStateSlice', Reduce: 'ReduceGaragePreview' }, entrance: { Type: 'EntranceStateSlice', Reduce: 'ReduceEntrance' }, + user: { Type: 'UserStateSlice', Reduce: 'ReduceUser' }, // 原根 reducer 对这一片没有调用,只有默认值 `ag()`(空 Map);这里仅做类型收口。 launchParams: { Type: 'LaunchParamsStateSlice', @@ -627,6 +633,13 @@ const EntranceAnonymousEffects = { fit: 'isClassic = true(`n.ur4(cd, cd, cd, cd, cd, cd, !0)`)', }; +// user 切片(t8 / M8().qtd):没有 toString / 不在 action_inventory 的类,按 reducer 分支效果定名。 +const UserAnonymousEffects = { + R8: 'lobbyTutorialEnabled = false(`n.qtb(cd × 29, !1)`)', + v8: 'inDebt = true(`n.qtb(cd × 30, !0)`)', + p8: 'inDebt = false(`n.qtb(cd × 30, !1)`)', +}; + fs.writeFileSync( path.join(P, 'names', 'root_wiring.json'), JSON.stringify( @@ -679,6 +692,12 @@ fs.writeFileSync( DispatchedAt: '未在 action_trace_merged.json 中出现;按 reducer 分支效果定名', Effect, })), + ...Object.entries(UserAnonymousEffects).map(([Local, Effect]) => ({ + Local, + Name: AnonymousActionNames.get(Local), + DispatchedAt: '未在 action_trace_merged.json 中出现;按 reducer 分支效果定名', + Effect, + })), ], Markers: [ { diff --git a/TankiOffline/app/src/jsMain/kotlin/App/State/ActionCatalog.kt b/TankiOffline/app/src/jsMain/kotlin/App/State/ActionCatalog.kt index 805af4a..a0634b3 100644 --- a/TankiOffline/app/src/jsMain/kotlin/App/State/ActionCatalog.kt +++ b/TankiOffline/app/src/jsMain/kotlin/App/State/ActionCatalog.kt @@ -660,27 +660,27 @@ object ActionCatalog { ActionMeta("TemporaryCashSlot", TemporaryCashSlot::class, listOf(ActionFieldMeta("amount", "Any?"), ActionFieldMeta("daysToExpire", "Any?"))), ActionMeta("User", User::class, listOf(ActionFieldMeta("isUserPropertiesLoaded", "Boolean"), ActionFieldMeta("id", "Any?"), ActionFieldMeta("clan", "Any?"), ActionFieldMeta("crystals", "Any?"), ActionFieldMeta("coins", "Any?"), ActionFieldMeta("spentCoins", "Int"), ActionFieldMeta("rubies", "Any?"), ActionFieldMeta("spentRubies", "Int"), ActionFieldMeta("hasTemporaryCash", "Any?"), ActionFieldMeta("rank", "Any?"), ActionFieldMeta("rankBounds", "Any?"), ActionFieldMeta("score", "Any?"), ActionFieldMeta("gearScore", "Any?"), ActionFieldMeta("weaponGS", "Any?"), ActionFieldMeta("hullGS", "Any?"), ActionFieldMeta("droneGS", "Any?"), ActionFieldMeta("bazookaGS", "Any?"), ActionFieldMeta("resistanceGS", "Any?"), ActionFieldMeta("rating", "Any?"), ActionFieldMeta("uid", "Any?"), ActionFieldMeta("clanTag", "Any?"), ActionFieldMeta("premiumExpirationTime", "Any?"), ActionFieldMeta("disabledWithoutPremiumLocations", "Any?"), ActionFieldMeta("hasPremium", "Any?"), ActionFieldMeta("hasSpectatorPermissions", "Any?"), ActionFieldMeta("rankNames", "Any?"), ActionFieldMeta("normalizedRank", "Any?"), ActionFieldMeta("profileUrl", "Any?"), ActionFieldMeta("eventPassEndTime", "Any?"), ActionFieldMeta("lobbyTutorialEnabled", "Boolean"), ActionFieldMeta("inDebt", "Boolean"), ActionFieldMeta("batteryLevel", "Any?"))), ActionMeta("UpdateCrystals", UpdateCrystals::class, listOf(ActionFieldMeta("crystal", "Int"))), - ActionMeta("SpendCrystals", SpendCrystals::class, listOf(ActionFieldMeta("crystal", "Any?"))), - ActionMeta("AddCoins", AddCoins::class, listOf(ActionFieldMeta("coins", "Any?"))), - ActionMeta("SpendCoins", SpendCoins::class, listOf(ActionFieldMeta("coins", "Any?"))), + ActionMeta("SpendCrystals", SpendCrystals::class, listOf(ActionFieldMeta("crystal", "Int"))), + ActionMeta("AddCoins", AddCoins::class, listOf(ActionFieldMeta("coins", "Int"))), + ActionMeta("SpendCoins", SpendCoins::class, listOf(ActionFieldMeta("coins", "Int"))), ActionMeta("SetCoins", SetCoins::class, listOf(ActionFieldMeta("coins", "Int"))), - ActionMeta("AddRubies", AddRubies::class, listOf(ActionFieldMeta("rubies", "Any?"))), - ActionMeta("SpendRubies", SpendRubies::class, listOf(ActionFieldMeta("rubies", "Any?"))), + ActionMeta("AddRubies", AddRubies::class, listOf(ActionFieldMeta("rubies", "Int"))), + ActionMeta("SpendRubies", SpendRubies::class, listOf(ActionFieldMeta("rubies", "Int"))), ActionMeta("SetRubies", SetRubies::class, listOf(ActionFieldMeta("rubies", "Int"))), - ActionMeta("SetHasTemporaryCash", SetHasTemporaryCash::class, listOf(ActionFieldMeta("hasTemporaryCash", "Any?"))), + ActionMeta("SetHasTemporaryCash", SetHasTemporaryCash::class, listOf(ActionFieldMeta("hasTemporaryCash", "Boolean"))), ActionMeta("UpdateGearScore", UpdateGearScore::class, listOf(ActionFieldMeta("gearScore", "Int"), ActionFieldMeta("weaponGS", "Int"), ActionFieldMeta("hullGS", "Int"), ActionFieldMeta("droneGS", "Int"), ActionFieldMeta("bazookaGS", "Int"), ActionFieldMeta("resistanceGS", "Int"))), - ActionMeta("UpdateUserRating", UpdateUserRating::class, listOf(ActionFieldMeta("userRating", "Any?"))), - ActionMeta("UpdateUid", UpdateUid::class, listOf(ActionFieldMeta("uid", "Any?"))), + ActionMeta("UpdateUserRating", UpdateUserRating::class, listOf(ActionFieldMeta("userRating", "Int"))), + ActionMeta("UpdateUid", UpdateUid::class, listOf(ActionFieldMeta("uid", "String"))), ActionMeta("UpdateClanTag", UpdateClanTag::class, listOf(ActionFieldMeta("clanTag", "String"))), - ActionMeta("UpdateRank", UpdateRank::class, listOf(ActionFieldMeta("rank", "Any?"), ActionFieldMeta("score", "Any?"), ActionFieldMeta("bounds", "Any?"))), + ActionMeta("UpdateRank", UpdateRank::class, listOf(ActionFieldMeta("rank", "Int"), ActionFieldMeta("score", "Int"), ActionFieldMeta("bounds", "Any?"))), ActionMeta("SetRankNames", SetRankNames::class, listOf(ActionFieldMeta("rankNames", "List"))), - ActionMeta("SetUserId", SetUserId::class, listOf(ActionFieldMeta("userId", "Long"))), + ActionMeta("SetUserId", SetUserId::class, listOf(ActionFieldMeta("userId", "Long?"))), ActionMeta("SetUserPropertiesAction", SetUserPropertiesAction::class, listOf(ActionFieldMeta("user", "UserPropertiesCC"))), ActionMeta("UpdatePremium", UpdatePremium::class, listOf(ActionFieldMeta("hasPremium", "Boolean"))), - ActionMeta("UpdateEventPass", UpdateEventPass::class, listOf(ActionFieldMeta("eventPassEndTime", "Int"))), - ActionMeta("SetPremiumExpirationTime", SetPremiumExpirationTime::class, listOf(ActionFieldMeta("expirationTime", "Any?"))), + ActionMeta("UpdateEventPass", UpdateEventPass::class, listOf(ActionFieldMeta("eventPassEndTime", "Long"))), + ActionMeta("SetPremiumExpirationTime", SetPremiumExpirationTime::class, listOf(ActionFieldMeta("expirationTime", "Long"))), ActionMeta("SetPremiumRemainingSeconds", SetPremiumRemainingSeconds::class, listOf(ActionFieldMeta("seconds", "Int"))), - ActionMeta("SetDisabledWithoutPremiumLocations", SetDisabledWithoutPremiumLocations::class, listOf(ActionFieldMeta("locations", "Any?"))), + ActionMeta("SetDisabledWithoutPremiumLocations", SetDisabledWithoutPremiumLocations::class, listOf(ActionFieldMeta("locations", "Collection"))), ActionMeta("SetClanInfo", SetClanInfo::class, listOf(ActionFieldMeta("clan", "Any?"))), ActionMeta("SetAppAccountToken", SetAppAccountToken::class, listOf(ActionFieldMeta("appAccountToken", "Any?"))), ActionMeta("SetBatteryLevel", SetBatteryLevel::class, listOf(ActionFieldMeta("level", "Any?"))), diff --git a/TankiOffline/app/src/jsMain/kotlin/App/State/Inventory.kt b/TankiOffline/app/src/jsMain/kotlin/App/State/Inventory.kt index a936d94..bdfc1d7 100644 --- a/TankiOffline/app/src/jsMain/kotlin/App/State/Inventory.kt +++ b/TankiOffline/app/src/jsMain/kotlin/App/State/Inventory.kt @@ -5,7 +5,7 @@ package App.State import Core.Vector3 -// 动作/状态类共 816 个,字段 2150 个(已定类型 572 个) +// 动作/状态类共 816 个,字段 2150 个(已定类型 584 个) data class AnnouncementState( val isEnabled: Any?, // 仅 copy 转发(无类型信息) val modelLoaded: Any?, // 仅 copy 转发(无类型信息) @@ -3762,15 +3762,15 @@ data class UpdateCrystals( ) data class SpendCrystals( - val crystal: Any?, // 调用点: u + val crystal: Int, // override ) data class AddCoins( - val coins: Any?, // 仅 copy 转发(无类型信息) + val coins: Int, // override ) data class SpendCoins( - val coins: Any?, // 调用点: u + val coins: Int, // override ) data class SetCoins( @@ -3778,11 +3778,11 @@ data class SetCoins( ) data class AddRubies( - val rubies: Any?, // 仅 copy 转发(无类型信息) + val rubies: Int, // override ) data class SpendRubies( - val rubies: Any?, // 调用点: u + val rubies: Int, // override ) data class SetRubies( @@ -3790,7 +3790,7 @@ data class SetRubies( ) data class SetHasTemporaryCash( - val hasTemporaryCash: Any?, // 仅 copy 转发(无类型信息) + val hasTemporaryCash: Boolean, // override ) data class UpdateGearScore( @@ -3803,11 +3803,11 @@ data class UpdateGearScore( ) data class UpdateUserRating( - val userRating: Any?, // 仅 copy 转发(无类型信息) + val userRating: Int, // override ) data class UpdateUid( - val uid: Any?, // 仅 copy 转发(无类型信息) + val uid: String, // override ) data class UpdateClanTag( @@ -3815,8 +3815,8 @@ data class UpdateClanTag( ) data class UpdateRank( - val rank: Any?, // 调用点: t - val score: Any?, // 调用点: n + val rank: Int, // override + val score: Int, // override val bounds: Any?, // 调用点: i ) @@ -3825,7 +3825,7 @@ data class SetRankNames( ) data class SetUserId( - val userId: Long, // runtime + val userId: Long?, // override ) data class SetUserPropertiesAction( @@ -3837,11 +3837,11 @@ data class UpdatePremium( ) data class UpdateEventPass( - val eventPassEndTime: Int, // runtime + val eventPassEndTime: Long, // override ) data class SetPremiumExpirationTime( - val expirationTime: Any?, // 调用点: u + val expirationTime: Long, // override ) data class SetPremiumRemainingSeconds( @@ -3849,7 +3849,7 @@ data class SetPremiumRemainingSeconds( ) data class SetDisabledWithoutPremiumLocations( - val locations: Any?, // 调用点: this.q7k().pdh() + val locations: Collection, // override ) data class SetClanInfo( diff --git a/TankiOffline/app/src/jsMain/kotlin/App/State/Reducers/UserReducer.kt b/TankiOffline/app/src/jsMain/kotlin/App/State/Reducers/UserReducer.kt new file mode 100644 index 0000000..0a9ab73 --- /dev/null +++ b/TankiOffline/app/src/jsMain/kotlin/App/State/Reducers/UserReducer.kt @@ -0,0 +1,190 @@ +// UserReducer.kt —— 用户切片(对应 module_10032 的 t8 状态 + N8.qtd reducer) +// +// 证据链: +// 状态类 t8:`function t8(t, n, …, T)`,32 个字段,toString 名为 +// `User(isUserPropertiesLoaded=…, …, batteryLevel=…)`。 +// 根委派:`M8().qtd(t, n.ypu_1)`;qtd 有 28 个 instanceof 臂 + 默认透传, +// 全部通过 `qtb`(t8.copy,33 参数 = 32 字段 + 回调)写回,`cd` 表示保留原字段。 +// +// 字段默认值(构造): +// isUserPropertiesLoaded=false,id=Long(0,0),clan=null, +// crystals/coins/spentCoins/rubies/spentRubies=0,hasTemporaryCash=false, +// rank=1,rankBounds=RankBounds(0,0),score/gearScore/weaponGS/hullGS/droneGS/ +// bazookaGS/resistanceGS=0,rating=0,uid="",clanTag=null, +// premiumExpirationTime=Long(0,0),disabledWithoutPremiumLocations=HashSet(), +// hasPremium=false,hasSpectatorPermissions=false,rankNames=[],normalizedRank=1, +// profileUrl="",eventPassEndTime=Long(0,0),lobbyTutorialEnabled=true, +// inDebt=false,batteryLevel=null。 +// +// 3 个匿名动作没有 toString / 不在 action_inventory,按 reducer 效果定名: +// R8 → DisableLobbyTutorial(lobbyTutorialEnabled = false) +// v8 → SetInDebt(inDebt = true) +// p8 → ClearInDebt(inDebt = false) +package App.State.Reducers + +import App.State.AddCoins +import App.State.AddRubies +import App.State.SetBatteryLevel +import App.State.SetClanInfo +import App.State.SetCoins +import App.State.SetDisabledWithoutPremiumLocations +import App.State.SetHasTemporaryCash +import App.State.SetPremiumExpirationTime +import App.State.SetRankNames +import App.State.SetRubies +import App.State.SetUserPropertiesAction +import App.State.SetUserId +import App.State.SpendCoins +import App.State.SpendCrystals +import App.State.SpendRubies +import App.State.UpdateClanTag +import App.State.UpdateCrystals +import App.State.UpdateEventPass +import App.State.UpdateGearScore +import App.State.UpdatePremium +import App.State.UpdateRank +import App.State.UpdateScore +import App.State.UpdateUid +import App.State.UpdateUserRating + +/* ------------------------------------------------------------------ 匿名动作 */ + +class DisableLobbyTutorial +class SetInDebt +class ClearInDebt + +/* ------------------------------------------------------------------ 状态 */ + +/** 原 `Nx(0,0)`:`RankBounds [lowBound = 0 topBound = 0]`。 */ +data class UserRankBounds(val LowBound: Int = 0, val TopBound: Int = 0) + +data class UserStateSlice( + val IsUserPropertiesLoaded: Boolean = false, + val Id: Long = 0L, + val Clan: Any? = null, + val Crystals: Int = 0, + val Coins: Int = 0, + val SpentCoins: Int = 0, + val Rubies: Int = 0, + val SpentRubies: Int = 0, + val HasTemporaryCash: Boolean = false, + val Rank: Int = 1, + /** 原 Nx(0,0),具体模型未在 10032 内展开。 */ + val RankBounds: Any? = UserRankBounds(), + val Score: Int = 0, + val GearScore: Int = 0, + val WeaponGS: Int = 0, + val HullGS: Int = 0, + val DroneGS: Int = 0, + val BazookaGS: Int = 0, + val ResistanceGS: Int = 0, + val Rating: Int = 0, + val Uid: String = "", + val ClanTag: String? = null, + /** 原 Dd(0,0),Dd 是 Kotlin/JS 的 Long。 */ + val PremiumExpirationTime: Long = 0L, + /** 原 Yg() = HashSet,元素为地点 id。 */ + val DisabledWithoutPremiumLocations: Set = emptySet(), + val HasPremium: Boolean = false, + val HasSpectatorPermissions: Boolean = false, + val RankNames: List = emptyList(), + val NormalizedRank: Int = 1, + val ProfileUrl: String = "", + /** 原 Dd(0,0)。 */ + val EventPassEndTime: Long = 0L, + val LobbyTutorialEnabled: Boolean = true, + val InDebt: Boolean = false, + val BatteryLevel: Any? = null, +) + +/* ------------------------------------------------------------------ reducer */ + +fun ReduceUser(State0: UserStateSlice, Action: Any): UserStateSlice = when (Action) { + is SetClanInfo -> State0.copy(Clan = Action.clan) + + is UpdateCrystals -> State0.copy(Crystals = Action.crystal) + is SpendCrystals -> State0.copy(Crystals = State0.Crystals - Action.crystal) + + is AddCoins -> State0.copy( + Coins = State0.Coins + State0.SpentCoins + Action.coins, + SpentCoins = 0, + ) + is SetCoins -> State0.copy(Coins = Action.coins) + is SpendCoins -> State0.copy( + Coins = State0.Coins - Action.coins, + SpentCoins = State0.SpentCoins + Action.coins, + ) + + is SetHasTemporaryCash -> State0.copy(HasTemporaryCash = Action.hasTemporaryCash) + + is AddRubies -> State0.copy( + Rubies = State0.Rubies + State0.SpentRubies + Action.rubies, + SpentRubies = 0, + ) + is SetRubies -> State0.copy(Rubies = Action.rubies) + is SpendRubies -> State0.copy( + Rubies = State0.Rubies - Action.rubies, + SpentRubies = State0.SpentRubies + Action.rubies, + ) + + is UpdateUserRating -> State0.copy(Rating = Action.userRating) + is UpdateUid -> State0.copy(Uid = Action.uid) + is UpdateClanTag -> State0.copy(ClanTag = Action.clanTag) + is UpdateScore -> State0.copy(Score = Action.score) + + is UpdateGearScore -> State0.copy( + GearScore = Action.gearScore, + WeaponGS = Action.weaponGS, + HullGS = Action.hullGS, + DroneGS = Action.droneGS, + BazookaGS = Action.bazookaGS, + ResistanceGS = Action.resistanceGS, + ) + + is UpdateRank -> State0.copy( + Rank = Action.rank, + RankBounds = Action.bounds, + Score = Action.score, + NormalizedRank = minOf(State0.RankNames.size, Action.rank), + ) + + // SetUserPropertiesAction:只覆盖 id/货币/rank/分数/rating/uid/观战权限/normalizedRank/profileUrl。 + is SetUserPropertiesAction -> { + val User = Action.user + State0.copy( + IsUserPropertiesLoaded = true, + Id = User.id, + Crystals = User.crystals, + Rank = User.rank, + RankBounds = User.rankBounds, + Score = User.score, + GearScore = User.gearScore, + WeaponGS = User.weaponGearScore, + HullGS = User.hullGearScore, + DroneGS = User.droneGearScore, + BazookaGS = User.bazookaGearScore, + ResistanceGS = User.resistanceGearScore, + Rating = User.userRating, + Uid = User.uid, + HasSpectatorPermissions = User.hasSpectatorPermissions, + NormalizedRank = minOf(State0.RankNames.size, User.rank), + ProfileUrl = User.userProfileUrl, + ) + } + + is SetPremiumExpirationTime -> State0.copy(PremiumExpirationTime = Action.expirationTime) + is SetDisabledWithoutPremiumLocations -> + State0.copy(DisabledWithoutPremiumLocations = Action.locations.toSet()) + + is UpdatePremium -> State0.copy(HasPremium = Action.hasPremium) + is UpdateEventPass -> State0.copy(EventPassEndTime = Action.eventPassEndTime) + is SetRankNames -> State0.copy(RankNames = Action.rankNames) + is SetUserId -> State0.copy(Id = Action.userId ?: 0L) + + is DisableLobbyTutorial -> State0.copy(LobbyTutorialEnabled = false) + is SetBatteryLevel -> State0.copy(BatteryLevel = Action.level) + is SetInDebt -> State0.copy(InDebt = true) + is ClearInDebt -> State0.copy(InDebt = false) + + else -> State0 +} diff --git a/TankiOffline/app/src/jsMain/kotlin/App/State/RootReducer.kt b/TankiOffline/app/src/jsMain/kotlin/App/State/RootReducer.kt index bc941db..8c7741e 100644 --- a/TankiOffline/app/src/jsMain/kotlin/App/State/RootReducer.kt +++ b/TankiOffline/app/src/jsMain/kotlin/App/State/RootReducer.kt @@ -1,7 +1,7 @@ // RootReducer.kt —— 根 reducer 骨架(对应 module_10032 的根状态 z6 / TOState 与根 reducer k6) // // 原包 k6 对 89 个切片逐个调用各自的 reducer,再 `return n.cti(...)`(copy 出新状态)。 -// 这里采用同一形状。已迁移的 12 个切片里,11 个真正调用 reducer,launchParams 只做类型收口: +// 这里采用同一形状。已迁移的 13 个切片里,12 个真正调用 reducer,launchParams 只做类型收口: // // inviteState → ReduceInvite(aD / pD.er5) // loginByPassword → ReduceLoginByPassword(gD / MD.cr6) @@ -14,9 +14,10 @@ // garage → ReduceGarage(UB / hF,根委派 `i = hF(t, n)`) // garagePreview → ReduceGaragePreview(IG / fs.eru,根委派 `fs.eru(t, n.epv_1)`) // entrance → ReduceEntrance(hD / Bv.pth,根委派 `Bv.pth(t, n.jpx_1)`) +// user → ReduceUser(t8 / M8().qtd,根委派 `M8().qtd(t, n.ypu_1)`) // launchParams → 无 reducer(原根 reducer 原样透传;默认 `ag()` = emptyMap()) // -// 其余 77 个字段按原样透传(defaultValue 取原构造)。每个切片 reducer 对无关动作都返回原状态, +// 其余 76 个字段按原样透传(defaultValue 取原构造)。每个切片 reducer 对无关动作都返回原状态, // 所以一次 copy 里串起全部已迁移 reducer,与按动作路由再归约的结果一致。 // 迁移进度与每个字段的证据见生成物 RootWiring.kt(不要手改)。 package App.State @@ -32,6 +33,7 @@ import App.State.Reducers.ReduceLootBoxes import App.State.Reducers.ReduceScreen import App.State.Reducers.ReduceShop import App.State.Reducers.ReduceTwitch +import App.State.Reducers.ReduceUser /** 根 reducer:`k6`。 */ object RootReducer { @@ -57,6 +59,7 @@ object RootReducer { Garage = ReduceGarage(State0.Garage, Action), GaragePreview = ReduceGaragePreview(State0.GaragePreview, Action), Entrance = ReduceEntrance(State0.Entrance, Action), + User = ReduceUser(State0.User, Action), ) } diff --git a/TankiOffline/app/src/jsMain/kotlin/App/State/RootState.kt b/TankiOffline/app/src/jsMain/kotlin/App/State/RootState.kt index 7c9199d..ca64876 100644 --- a/TankiOffline/app/src/jsMain/kotlin/App/State/RootState.kt +++ b/TankiOffline/app/src/jsMain/kotlin/App/State/RootState.kt @@ -15,14 +15,15 @@ import App.State.Reducers.LootBoxesStateSlice import App.State.Reducers.ScreenStateSlice import App.State.Reducers.ShopStateSlice import App.State.Reducers.TwitchStateSlice +import App.State.Reducers.UserStateSlice /** * 原 `z6` / `TOState`:89 个切片字段,顺序与 toString 一致。 * 未迁移字段默认 null —— 原包构造时会给它们 `new ()`,这里等对应切片迁移后再补默认值。 */ data class RootState( - /** 原 user(t8 / M8().qtd)。未迁移:原样透传。 */ - val User: Any? = null, + /** 原 user(t8 / M8().qtd)。已迁移。 */ + val User: UserStateSlice = UserStateSlice(), /** 原 screen(WK / dY().ks5)。已迁移。 */ val Screen: ScreenStateSlice = ScreenStateSlice(), /** 原 modalScreen(nV / Fs.js1)。未迁移:原样透传。 */ diff --git a/TankiOffline/app/src/jsMain/kotlin/App/State/RootWiring.kt b/TankiOffline/app/src/jsMain/kotlin/App/State/RootWiring.kt index 8f83baf..d3d061d 100644 --- a/TankiOffline/app/src/jsMain/kotlin/App/State/RootWiring.kt +++ b/TankiOffline/app/src/jsMain/kotlin/App/State/RootWiring.kt @@ -14,7 +14,7 @@ data class SliceWiring( object RootWiring { val All: List = listOf( - SliceWiring("user", "ypu_1", "t8", "M8().qtd", "M8().qtd(t, n.ypu_1)", listOf("?R8", "?p8", "?v8", "AddCoins", "AddRubies", "SetBatteryLevel", "SetClanInfo", "SetCoins", "SetDisabledWithoutPremiumLocations", "SetHasTemporaryCash", "SetPremiumExpirationTime", "SetRankNames", "SetRubies", "SetUserId", "SetUserPropertiesAction", "SpendCoins", "SpendCrystals", "SpendRubies", "UpdateClanTag", "UpdateCrystals", "UpdateEventPass", "UpdateGearScore", "UpdatePremium", "UpdateRank", "UpdateScore", "UpdateUid", "UpdateUserRating")), + SliceWiring("user", "ypu_1", "t8", "M8().qtd", "M8().qtd(t, n.ypu_1)", listOf("AddCoins", "AddRubies", "ClearInDebt", "DisableLobbyTutorial", "SetBatteryLevel", "SetClanInfo", "SetCoins", "SetDisabledWithoutPremiumLocations", "SetHasTemporaryCash", "SetInDebt", "SetPremiumExpirationTime", "SetRankNames", "SetRubies", "SetUserId", "SetUserPropertiesAction", "SpendCoins", "SpendCrystals", "SpendRubies", "UpdateClanTag", "UpdateCrystals", "UpdateEventPass", "UpdateGearScore", "UpdatePremium", "UpdateRank", "UpdateScore", "UpdateUid", "UpdateUserRating")), SliceWiring("screen", "zpu_1", "WK", "dY().ks5", "dY().ks5(t, n.zpu_1)", listOf("ClearScreenStack", "DelayNextGoTo", "DisableBack", "DisableForeground", "DisplayMainEntranceScreen", "DisplayReauthorizationMessage", "EnableBack", "EnableForeground", "FilterScreenStack", "GoTo", "PopScreen", "PressedAnyButton", "ResetScreenStackOnNextGoTo")), SliceWiring("modalScreen", "apv_1", "nV", "Fs.js1", "Fs.js1(t, n.apv_1)", listOf("?XW", "AddModal", "RemoveCurrentByName", "RemoveModal")), SliceWiring("battleStatistics", "bpv_1", "jR", "rr.fqr", "rr.fqr(t, n.bpv_1)", listOf("?DR", "?HR", "?KR", "?PR", "?QR", "?UR", "?XR", "?YR", "?ZR", "?nP", "?tP", "Init", "InitBattleModeWithLevels", "SetBattleMode", "SetDisplayClientInfo", "SetFlagOrBallOwner", "SetRankRange", "SetReArmor", "SetRemainingTime", "SetRoundRestartTime", "UpdateFund", "UpdateScore")), @@ -106,7 +106,7 @@ object RootWiring { ) /** 已迁移(强类型;launchParams 为透传,不调用 reducer)的字段。 */ - val Migrated: Set = setOf("inviteState", "loginByPassword", "twitchState", "home", "localBattleUserState", "shop", "screen", "lootBoxes", "garage", "garagePreview", "entrance", "launchParams") + val Migrated: Set = setOf("inviteState", "loginByPassword", "twitchState", "home", "localBattleUserState", "shop", "screen", "lootBoxes", "garage", "garagePreview", "entrance", "user", "launchParams") /** 未迁移字段:归约时原样透传,作为后续迁移清单。 */ val Unmigrated: List get() = All.filter { it.Field !in Migrated } diff --git a/TankiOffline/app/src/jsMain/kotlin/App/StateCheck.kt b/TankiOffline/app/src/jsMain/kotlin/App/StateCheck.kt index 2024357..b57ce26 100644 --- a/TankiOffline/app/src/jsMain/kotlin/App/StateCheck.kt +++ b/TankiOffline/app/src/jsMain/kotlin/App/StateCheck.kt @@ -9,8 +9,8 @@ // ⑥ 动作目录:816 个类与 138 个真实派发名对齐 // ⑦ 回放:真实 toString 样例的类/字段/类型校验 // ⑧ 全目录动作类都能按 KClass 路由 -// ⑨~㉒ 切片 reducer:InviteState / LoginByPasswordState / TwitchState / HomeScreen / LocalBattleUserState / -// Shop(含 U7 类别表与 w3 物品表两个子 reducer)/ Screen / LootBoxes / Garage / GaragePreview / Entrance; +// ⑨~㉓ 切片 reducer:InviteState / LoginByPasswordState / TwitchState / HomeScreen / LocalBattleUserState / +// Shop(含 U7 类别表与 w3 物品表两个子 reducer)/ Screen / LootBoxes / Garage / GaragePreview / Entrance / User; // ㉑ launchParams 是纯透传切片(无 reducer/动作),只做 Map 类型收口;⑭ 根 reducer 的 89 槽位委派 // // 用法:$env:TANKIOFFLINE_STATE_TEST='1'; node …(见 TankiOffline/tools/RunStateTest.ps1) @@ -88,6 +88,31 @@ import App.State.ShopCardSold import App.State.ShopItem import App.State.ToggleShopActivity import App.State.TryToBuyOneTimePurchase +import App.State.AddCoins +import App.State.AddRubies +import App.State.SetBatteryLevel +import App.State.SetClanInfo +import App.State.SetCoins +import App.State.SetDisabledWithoutPremiumLocations +import App.State.SetHasTemporaryCash +import App.State.SetPremiumExpirationTime +import App.State.SetRankNames +import App.State.SetRubies +import App.State.SetUserPropertiesAction +import App.State.SetUserId +import App.State.SpendCoins +import App.State.SpendCrystals +import App.State.SpendRubies +import App.State.UpdateClanTag +import App.State.UpdateCrystals +import App.State.UpdateEventPass +import App.State.UpdateGearScore +import App.State.UpdatePremium +import App.State.UpdateRank +import App.State.UpdateScore +import App.State.UpdateUid +import App.State.UpdateUserRating +import App.State.UserPropertiesCC import App.State.Reducers.AddCategoriesWithBonus import App.State.Reducers.BlockBackButton import App.State.Reducers.CategoryType @@ -237,6 +262,12 @@ import App.State.Reducers.ClearDroneData import App.State.Reducers.ClearGaragePreviewParams import App.State.Reducers.ClearGrenadeData import App.State.Reducers.ClearHasGrenades +import App.State.Reducers.ClearInDebt +import App.State.Reducers.DisableLobbyTutorial +import App.State.Reducers.ReduceUser +import App.State.Reducers.SetInDebt +import App.State.Reducers.UserRankBounds +import App.State.Reducers.UserStateSlice import App.State.Reducers.ClearHullData import App.State.Reducers.ClearUpgradeItemInProcessing import App.State.Reducers.ClearWaitServerItemMount @@ -662,7 +693,7 @@ object StateCheck { Check("切片 LocalBattleUser:接 Store 派发后状态已归约", Store.State.IsReArmorTemporaryDisabled) } - // ⑭ 根 reducer:89 个槽位的委派表 + 12 个已迁移切片(11 归约 + launchParams 透传) + // ⑭ 根 reducer:89 个槽位的委派表 + 13 个已迁移切片(12 归约 + launchParams 透传) run { Check("根:根状态 89 个切片槽位", RootWiring.All.size == 89) Check( @@ -726,18 +757,18 @@ object StateCheck { LocalBattleW.Reducer == "(inline@localBattleUserState)" ) Check( - "根:迁移表 = 12 个已迁移字段,其余 77 个待迁", + "根:迁移表 = 13 个已迁移字段,其余 76 个待迁", RootReducer.Migrated == setOf( "inviteState", "loginByPassword", "twitchState", "home", "localBattleUserState", "shop", "screen", "lootBoxes", "garage", - "garagePreview", "entrance", "launchParams", + "garagePreview", "entrance", "user", "launchParams", ) && - RootWiring.Unmigrated.size == 77 + RootWiring.Unmigrated.size == 76 ) // 一次 ReduceRoot 里多个切片各归约自己的动作,互不串片 - val S0 = RootState(User = "user-1", Screen = ScreenStateSlice(Current = NamedScreen("Preloader"))) + val S0 = RootState(User = UserStateSlice(Uid = "user-1"), Screen = ScreenStateSlice(Current = NamedScreen("Preloader"))) val S1 = ReduceRoot(S0, ApplyInvite("uid-1")) Check( "根:ApplyInvite 只动 inviteState", @@ -786,10 +817,10 @@ object StateCheck { // 未迁移字段原样透传;无关动作不改变任何字段 Check( "根:未迁移字段原样透传", - S5.User == "user-1" && S5.Screen.Current == NamedScreen("Preloader") + S5.User.Uid == "user-1" && S5.Screen.Current == NamedScreen("Preloader") ) Check("根:无关动作返回等值状态", ReduceRoot(S5, AddFrags(1)) == S5) - Check("根:Coverage 文本", RootReducer.Coverage == "已迁移 12/89") + Check("根:Coverage 文本", RootReducer.Coverage == "已迁移 13/89") val Store = ReduxStore(RootState(), { S, A -> ReduceRoot(S, A) }) Store.Dispatch(SetStatus("LOGGING_IN")) @@ -1042,7 +1073,7 @@ object StateCheck { val R1 = ReduceRoot(R0, InitShopItem(1L, Item)) Check( "根:InitShopItem 只动 shop", - R1.Shop.ShopItems.Items[1L] == Item && R1.User == null && R1.Home == R0.Home + R1.Shop.ShopItems.Items[1L] == Item && R1.User == R0.User && R1.Home == R0.Home ) val R2 = ReduceRoot(R1, InitCategory(C)) Check( @@ -1189,7 +1220,7 @@ object StateCheck { val R1 = ReduceRoot(R0, GoTo(Shop, false)) Check( "根:GoTo 只动 screen", - R1.Screen.Current == Shop && R1.User == null && R1.Shop == R0.Shop && + R1.Screen.Current == Shop && R1.User == R0.User && R1.Shop == R0.Shop && R1.InviteState == R0.InviteState && R1.Home == R0.Home ) val R2 = ReduceRoot(R1, EnableForeground) @@ -2425,6 +2456,193 @@ object StateCheck { ) } + // ㉓ 切片 reducer:User(原 module_10032 的 t8 / M8().qtd,32 字段;28 个 instanceof 臂) + run { + val S0 = UserStateSlice() + Check( + "切片 User:默认值与原构造一致", + !S0.IsUserPropertiesLoaded && S0.Id == 0L && S0.Clan == null && + S0.Crystals == 0 && S0.Coins == 0 && S0.SpentCoins == 0 && + S0.Rubies == 0 && S0.SpentRubies == 0 && !S0.HasTemporaryCash && + S0.Rank == 1 && S0.RankBounds == UserRankBounds(0, 0) && + S0.Score == 0 && S0.GearScore == 0 && S0.WeaponGS == 0 && + S0.HullGS == 0 && S0.DroneGS == 0 && S0.BazookaGS == 0 && + S0.ResistanceGS == 0 && S0.Rating == 0 && S0.Uid == "" && + S0.ClanTag == null && S0.PremiumExpirationTime == 0L && + S0.DisabledWithoutPremiumLocations.isEmpty() && !S0.HasPremium && + !S0.HasSpectatorPermissions && S0.RankNames.isEmpty() && + S0.NormalizedRank == 1 && S0.ProfileUrl == "" && + S0.EventPassEndTime == 0L && S0.LobbyTutorialEnabled && + !S0.InDebt && S0.BatteryLevel == null + ) + + Check( + "切片 User:SetClanInfo 写 clan", + ReduceUser(S0, SetClanInfo("clan-1")).Clan == "clan-1" + ) + Check( + "切片 User:UpdateCrystals / SpendCrystals 写 crystal", + ReduceUser(S0, UpdateCrystals(50)).Crystals == 50 && + ReduceUser(S0.copy(Crystals = 50), SpendCrystals(20)).Crystals == 30 + ) + + val Coins = ReduceUser( + ReduceUser(ReduceUser(S0, SetCoins(100)), SpendCoins(30)), + AddCoins(10), + ) + Check( + "切片 User:SetCoins / SpendCoins / AddCoins 维护 coins 与 spentCoins", + Coins.Coins == 110 && Coins.SpentCoins == 0 + ) + + val Rubies = ReduceUser( + ReduceUser(ReduceUser(S0, SetRubies(80)), SpendRubies(20)), + AddRubies(5), + ) + Check( + "切片 User:SetRubies / SpendRubies / AddRubies 维护 rubies 与 spentRubies", + Rubies.Rubies == 85 && Rubies.SpentRubies == 0 + ) + + Check( + "切片 User:SetHasTemporaryCash 写 hasTemporaryCash", + ReduceUser(S0, SetHasTemporaryCash(true)).HasTemporaryCash + ) + Check( + "切片 User:UpdateUserRating 写 rating", + ReduceUser(S0, UpdateUserRating(12)).Rating == 12 + ) + Check( + "切片 User:UpdateUid / UpdateClanTag 写 uid / clanTag", + ReduceUser(S0, UpdateUid("uid-42")).Uid == "uid-42" && + ReduceUser(S0, UpdateClanTag("TAG")).ClanTag == "TAG" + ) + Check( + "切片 User:UpdateScore 写 score", + ReduceUser(S0, UpdateScore(team = "red", score = 123)).Score == 123 + ) + Check( + "切片 User:UpdateGearScore 写六个装备分", + ReduceUser(S0, UpdateGearScore(1, 2, 3, 4, 5, 6)).let { + it.GearScore == 1 && it.WeaponGS == 2 && it.HullGS == 3 && + it.DroneGS == 4 && it.BazookaGS == 5 && it.ResistanceGS == 6 + } + ) + + val Rank = ReduceUser( + S0.copy(RankNames = listOf("A", "B", "C")), + UpdateRank(rank = 5, score = 500, bounds = UserRankBounds(1, 10)), + ) + Check( + "切片 User:UpdateRank 写 rank/rankBounds/score 并重算 normalizedRank", + Rank.Rank == 5 && Rank.Score == 500 && + Rank.RankBounds == UserRankBounds(1, 10) && Rank.NormalizedRank == 3 + ) + + val Props = UserPropertiesCC( + bazookaGearScore = 6, + canUseGroup = true, + crystals = 321, + crystalsRating = 0, + droneGearScore = 5, + eventPassEndTime = 0, + gearScore = 21, + goldsTakenRating = 0, + hasSpectatorPermissions = true, + hullGearScore = 4, + id = 99L, + partnerUid = null, + rank = 7, + rankBounds = "1-10", + topBound = "10", + registrationTimestamp = 0, + resistanceGearScore = 3, + score = 777, + scoreRating = 0, + uid = "uid-99", + userProfileUrl = "https://example.test/u/99", + userRating = 11, + weaponGearScore = 2, + ) + val SP = ReduceUser( + S0.copy(RankNames = listOf("A", "B", "C")), + SetUserPropertiesAction(Props), + ) + Check( + "切片 User:SetUserPropertiesAction 复制服务端字段并置 isUserPropertiesLoaded", + SP.IsUserPropertiesLoaded && SP.Id == 99L && SP.Crystals == 321 && + SP.Rank == 7 && SP.RankBounds == "1-10" && SP.Score == 777 && + SP.GearScore == 21 && SP.WeaponGS == 2 && SP.HullGS == 4 && + SP.DroneGS == 5 && SP.BazookaGS == 6 && SP.ResistanceGS == 3 && + SP.Rating == 11 && SP.Uid == "uid-99" && + SP.HasSpectatorPermissions && SP.NormalizedRank == 3 && + SP.ProfileUrl == "https://example.test/u/99" + ) + + Check( + "切片 User:SetPremiumExpirationTime / UpdateEventPass 写时间戳", + ReduceUser(S0, SetPremiumExpirationTime(1_700_000_000_000L)) + .PremiumExpirationTime == 1_700_000_000_000L && + ReduceUser(S0, UpdateEventPass(1_700_000_000_001L)) + .EventPassEndTime == 1_700_000_000_001L + ) + Check( + "切片 User:SetDisabledWithoutPremiumLocations 去重成 Set", + ReduceUser(S0, SetDisabledWithoutPremiumLocations(listOf(1, 2, 2))) + .DisabledWithoutPremiumLocations == setOf(1, 2) + ) + Check( + "切片 User:UpdatePremium / UpdateEventPass 写对应字段", + ReduceUser(S0, UpdatePremium(true)).HasPremium + ) + Check( + "切片 User:SetRankNames 只写 rankNames", + ReduceUser(S0.copy(NormalizedRank = 9), SetRankNames(listOf("A", "B"))) + .let { it.RankNames == listOf("A", "B") && it.NormalizedRank == 9 } + ) + Check( + "切片 User:SetUserId 写 id,null 归零", + ReduceUser(S0, SetUserId(42L)).Id == 42L && + ReduceUser(S0, SetUserId(null)).Id == 0L + ) + Check( + "切片 User:DisableLobbyTutorial / SetBatteryLevel", + !ReduceUser(S0, DisableLobbyTutorial()).LobbyTutorialEnabled && + ReduceUser(S0, SetBatteryLevel(0.75)).BatteryLevel == 0.75 + ) + Check( + "切片 User:SetInDebt / ClearInDebt 切换 inDebt", + ReduceUser(S0, SetInDebt()).InDebt && + !ReduceUser(S0.copy(InDebt = true), ClearInDebt()).InDebt + ) + Check("切片 User:无关动作不改状态", ReduceUser(S0, AddFrags(1)) == S0) + + val Root0 = RootState() + val Root1 = ReduceRoot(Root0, UpdateCrystals(7)) + Check( + "根:UpdateCrystals 只动 user", + Root1.User.Crystals == 7 && + Root1.LoginByPassword == Root0.LoginByPassword && + Root1.Entrance == Root0.Entrance + ) + Check( + "根:user 无关动作保留前一次归约", + ReduceRoot(Root1, AddFrags(1)).User == Root1.User + ) + + val W = RootWiring.ByField("user")!! + Check( + "根:user 委派到 t8 / M8().qtd 且 27 个动作全部具名", + W.SliceClass == "t8" && W.Reducer == "M8().qtd" && + W.Actions.size == 27 && W.Actions.none { it.startsWith("?") } + ) + Check( + "根:user 的匿名动作名覆盖三个分支", + listOf("DisableLobbyTutorial", "SetInDebt", "ClearInDebt") + .all { it in W.Actions } + ) + } + val Failed = Results.filter { !it.second } println("TankiOffline 状态层自检:${Results.size} 项") for ((Name, Ok) in Results) println((if (Ok) " ✓ " else " ✗ ") + Name)