diff --git a/3DTank/docs/KtRebuild_Plan.md b/3DTank/docs/KtRebuild_Plan.md index df25b18..1e124bc 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` 动作名。 - - 已迁移切片 **86/89**: + - 已迁移切片 **87/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) @@ -435,12 +435,16 @@ `IgnoreUserMessages`/`ShowUserMessages` 增删屏蔽名单(`_C` 重载时清空); `rN`(用户资料刷新)依赖消息内的用户对象,离线重建按 no-op;`SendMessageToServer` 的时间戳留给中间件。 消息复用生成物 `ChatMessage`。 + - `itemNotifications` → `_1` / `A1().dso`(`ItemNotificationsReducer.kt`):1 字段,6 个分支。 + `AddItemNotification(s)` 加入(构造 `ItemNotificationData`);`RemoveItemNotification` 先按有效期过滤, + 再删掉匹配谓词的通知;`UpdateItemNotification` 对匹配项应用 handler; + `S1` 只做持久化(no-op);`_C` 重载时只保留有效期内的通知。 - `launchParams` → `(pass-through)`:原根 reducer 对 `hpx_1` 没有调用,缺省是 `ag()` 空 Map; `RootState.LaunchParams` 收口为 `LaunchParamsStateSlice = Map`,不新增 reducer/动作。 - - `App/State/RootReducer.kt`:`ReduceRoot(State0, Action)` 归约上述 85 个有 reducer 的切片, - `launchParams` 与其余 **3 个字段原样透传**; - 迁移进度用 `RootReducer.Coverage`(`已迁移 86/89`)与 `RootWiring.Unmigrated` 暴露。 - - 自检:`TankiOffline/tools/RunStateTest.ps1` **953 项全绿**,含 89 槽位完整性、85 个归约切片一次归约互不串片、未迁移字段透传, + - `App/State/RootReducer.kt`:`ReduceRoot(State0, Action)` 归约上述 86 个有 reducer 的切片, + `launchParams` 与其余 **2 个字段原样透传**; + 迁移进度用 `RootReducer.Coverage`(`已迁移 87/89`)与 `RootWiring.Unmigrated` 暴露。 + - 自检:`TankiOffline/tools/RunStateTest.ps1` **964 项全绿**,含 89 槽位完整性、86 个归约切片一次归约互不串片、未迁移字段透传, 以及用 `action_trace_merged.json` 真实派发的两条商店动作(`SetCoinPaymentProviderUrl` / `ToggleShopActivity`)回放。 - 委派表里的已知形态:`shop` / `home` / `localBattleUserState` / `partner` 是内联 IIFE, 已按字段具名为 `(inline@)` 并从函数体抓到顶层动作名(69 / 1 / 2 / 11 个); diff --git a/3DTank/names/root_wiring.json b/3DTank/names/root_wiring.json index 403849a..e8fd75c 100644 --- a/3DTank/names/root_wiring.json +++ b/3DTank/names/root_wiring.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-10-24T14:08:05.684Z", + "generatedAt": "2026-10-24T14:12:51.294Z", "module": "module_10032.js", "RootState": { "Class": "z6", @@ -92,7 +92,8 @@ "challenges", "clansStorage", "clan", - "chatState" + "chatState", + "itemNotifications" ], "AnonymousActions": [ { @@ -1636,6 +1637,12 @@ "Name": "SetChatConfig", "DispatchedAt": "未在 action_trace_merged.json 中出现;按 reducer 分支效果定名", "Effect": "config(原名 Configure,与匹配大厅同名类冲突,改名 SetChatConfig)" + }, + { + "Local": "S1", + "Name": "PersistItemNotifications", + "DispatchedAt": "未在 action_trace_merged.json 中出现;按 reducer 分支效果定名", + "Effect": "通知列表变化后写回存储(纯 reducer 里 no-op)" } ], "Markers": [ @@ -3218,15 +3225,15 @@ "Reducer": "A1().dso", "Delegate": "A1().dso(t, n.xpx_1)", "Actions": [ - "?S1", "AddItemNotification", "AddItemNotifications", + "PersistItemNotifications", "RemoveItemNotification", "ResetSettingsFromStorage", "UpdateItemNotification" ], "Default": "new _1()", - "Migrated": false + "Migrated": true }, { "Field": "contextTutorial", diff --git a/3DTank/tools/gen_root_wiring.js b/3DTank/tools/gen_root_wiring.js index 6e84d00..7bcd173 100644 --- a/3DTank/tools/gen_root_wiring.js +++ b/3DTank/tools/gen_root_wiring.js @@ -368,6 +368,8 @@ const AnonymousActionNames = new Map([ ['sN', 'MarkFirstChannelMessagesLoaded'], // YP 的 toString 名 Configure 与匹配大厅同名类冲突,改名避免字段错配。 ['YP', 'SetChatConfig'], + // itemNotifications 切片(_1 / I1.dso):S1 没有 toString。 + ['S1', 'PersistItemNotifications'], ]); // 非动作的 `instanceof`:屏幕标识类,用于 GoTo 分支判断(不是 dispatch 的动作)。 @@ -858,6 +860,7 @@ const Migrated = { clansStorage: { Type: 'ClanStorageStateSlice', Reduce: 'ReduceClanStorage' }, clan: { Type: 'ClanStateSlice', Reduce: 'ReduceClan' }, chatState: { Type: 'ChatStateSlice', Reduce: 'ReduceChat' }, + itemNotifications: { Type: 'ItemNotificationsStateSlice', Reduce: 'ReduceItemNotifications' }, }; for (const R of Rows) R.Migrated = Object.prototype.hasOwnProperty.call(Migrated, R.Field); @@ -1306,6 +1309,7 @@ const AppSettingsAnonymousEffects = { qN: 'isNeedScroll=true', sN: 'isFirstChannelMessagesLoad=false', YP: 'config(原名 Configure,与匹配大厅同名类冲突,改名 SetChatConfig)', + S1: '通知列表变化后写回存储(纯 reducer 里 no-op)', }; fs.writeFileSync( path.join(P, 'names', 'root_wiring.json'), diff --git a/TankiOffline/app/src/jsMain/kotlin/App/State/Reducers/ItemNotificationsReducer.kt b/TankiOffline/app/src/jsMain/kotlin/App/State/Reducers/ItemNotificationsReducer.kt new file mode 100644 index 0000000..4563200 --- /dev/null +++ b/TankiOffline/app/src/jsMain/kotlin/App/State/Reducers/ItemNotificationsReducer.kt @@ -0,0 +1,126 @@ +// ItemNotificationsReducer.kt —— 物品「新」标记切片(对应 module_10032 的 _1 状态 + I1.dso) +// +// 证据链: +// 状态类 _1:1 个字段,toString 名 "ItemNotificationsState(notifications=…)"。 +// 默认:notifications = emptySet()。 +// 根委派:`A1().dso(t, n.xpx_1)`。 +// reducer: +// q1(AddItemNotifications) → 批量加入(逐条去重) +// y1(AddItemNotification) → 用 6 个字段构造 `ItemNotificationData` 后加入 +// $1(RemoveItemNotification) → 先按有效期过滤,再删掉匹配谓词的通知 +// (section/itemId/type/parentId/excludeType;ssn_1 = 跳过有效期检查) +// j1(UpdateItemNotification) → 保留不匹配项,对匹配项应用 updateHandler +// S1(无 toString) → 只做持久化标记(纯 reducer 里 no-op) +// _C(ResetSettingsFromStorage)→ 从存储重载并过滤过期(Kotlin 侧按「只保留有效期内的」收口) +// +// 有效期判定 `msm`:`(startTime == null || startTime <= now) && (endTime == null || endTime > now)`。 +// 通知模型直接复用生成物 `ItemNotificationData`(section / itemId / type / parentId / startTime / endTime)。 +package App.State.Reducers + +import App.State.AddItemNotification +import App.State.AddItemNotifications +import App.State.ItemNotificationData +import App.State.RemoveItemNotification +import App.State.UpdateItemNotification + +/* ------------------------------------------------------------------ 匿名动作 */ + +/** 原 `S1`:通知列表变化后写回存储(纯 reducer 里 no-op)。 */ +object PersistItemNotifications + +/* ------------------------------------------------------------------ 状态 */ + +data class ItemNotificationsStateSlice( + val Notifications: Set = emptySet(), +) + +/* ------------------------------------------------------------------ reducer */ + +private fun Now(): Double = (js("Date.now()") as Number).toDouble() + +private fun TimeOf(Value: Any?): Double? = (Value as? Number)?.toDouble() + +/** 原 `msm`:有效期判定。 */ +private fun InPeriod(Item: ItemNotificationData): Boolean { + val Now = Now() + val Start = TimeOf(Item.startTime) + val End = TimeOf(Item.endTime) + return (Start == null || Start <= Now) && (End == null || End > Now) +} + +/** 原 `tsm`:删除 / 更新谓词(忽略最后一个 `skipPeriodCheck` 参数)。 */ +private fun Matches( + Item: ItemNotificationData, + Section: Any?, + ItemId: Any?, + Type: Any?, + ParentId: Any?, + ExcludeType: Any?, +): Boolean { + val WantedId = (ItemId as? Number)?.toLong() ?: ItemId + return (Section == null || Item.section == Section) && + (ItemId == null || Item.itemId == WantedId) && + (Type == null || Item.type == Type) && + (ParentId == null || Item.parentId == ParentId) && + (ExcludeType == null || Item.type != ExcludeType) +} + +private fun AsItem(Value: Any?): ItemNotificationData? = Value as? ItemNotificationData + +fun ReduceItemNotifications( + State0: ItemNotificationsStateSlice, + Action: Any, +): ItemNotificationsStateSlice = when (Action) { + is AddItemNotifications -> + State0.copy(Notifications = State0.Notifications + Action.data) + is AddItemNotification -> State0.copy( + Notifications = State0.Notifications + ItemNotificationData( + section = Action.section, + itemId = Action.itemId, + type = Action.type, + parentId = Action.parentId, + startTime = Action.startTime, + endTime = Action.endTime, + ), + ) + is RemoveItemNotification -> State0.copy( + Notifications = State0.Notifications.filterNot { + val Item = AsItem(it) ?: return@filterNot false + val Skipped = Action.skipPeriodCheck == true + (Skipped || InPeriod(Item)) && + Matches( + Item, + Action.section, + Action.itemId, + Action.type, + Action.parentId, + Action.excludeType, + ) + }.toSet(), + ) + is UpdateItemNotification -> State0.copy( + Notifications = State0.Notifications.map { + val Item = AsItem(it) ?: return@map it + if (!Matches( + Item, + Action.section, + Action.itemId, + Action.type, + Action.parentId, + Action.excludeType, + ) + ) { + it + } else { + @Suppress("UNCHECKED_CAST") + val Transform = Action.updateHandler as? Function1 + Transform?.invoke(Item) ?: it + } + }.toSet(), + ) + is PersistItemNotifications -> State0 + is ResetSettingsFromStorage -> State0.copy( + Notifications = State0.Notifications.filter { AsItem(it)?.let(::InPeriod) ?: true }.toSet(), + ) + 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 50c0a96..26e59bf 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 出新状态)。 -// 这里采用同一形状。已迁移的 86 个切片里,85 个真正调用 reducer,launchParams 只做类型收口: +// 这里采用同一形状。已迁移的 87 个切片里,86 个真正调用 reducer,launchParams 只做类型收口: // // inviteState → ReduceInvite(aD / pD.er5) // loginByPassword → ReduceLoginByPassword(gD / MD.cr6) @@ -88,9 +88,10 @@ // clansStorage → ReduceClanStorage(lO / me.vr1,根委派 `me.vr1(t, n.rpv_1)`) // clan → ReduceClan(jN / we.hr0,根委派 `we.hr0(t, n.qpv_1)`) // chatState → ReduceChat(LP / $r.wqv,根委派 `$r.wqv(t, n.opv_1)`) +// itemNotifications → ReduceItemNotifications(_1 / A1().dso,根委派 `A1().dso(t, n.xpx_1)`) // launchParams → 无 reducer(原根 reducer 原样透传;默认 `ag()` = emptyMap()) // -// 其余 3 个字段按原样透传(defaultValue 取原构造)。每个切片 reducer 对无关动作都返回原状态, +// 其余 2 个字段按原样透传(defaultValue 取原构造)。每个切片 reducer 对无关动作都返回原状态, // 所以一次 copy 里串起全部已迁移 reducer,与按动作路由再归约的结果一致。 // 迁移进度与每个字段的证据见生成物 RootWiring.kt(不要手改)。 package App.State @@ -132,6 +133,7 @@ import App.State.Reducers.ReduceGraphicsSettings import App.State.Reducers.ReduceHome import App.State.Reducers.ReduceIcons import App.State.Reducers.ReduceInvite +import App.State.Reducers.ReduceItemNotifications import App.State.Reducers.ReduceKeyboardSettings import App.State.Reducers.ReduceLanguage import App.State.Reducers.ReduceLocalBattleUser @@ -279,6 +281,7 @@ object RootReducer { ClansStorage = ReduceClanStorage(State0.ClansStorage, Action), Clan = ReduceClan(State0.Clan, Action), ChatState = ReduceChat(State0.ChatState, Action), + ItemNotifications = ReduceItemNotifications(State0.ItemNotifications, Action), ) } diff --git a/TankiOffline/app/src/jsMain/kotlin/App/State/RootState.kt b/TankiOffline/app/src/jsMain/kotlin/App/State/RootState.kt index 4fde8ab..1e7ed7d 100644 --- a/TankiOffline/app/src/jsMain/kotlin/App/State/RootState.kt +++ b/TankiOffline/app/src/jsMain/kotlin/App/State/RootState.kt @@ -42,6 +42,7 @@ import App.State.Reducers.GraphicsSettingsStateSlice import App.State.Reducers.HomeStateSlice import App.State.Reducers.IconsStateSlice import App.State.Reducers.InviteStateSlice +import App.State.Reducers.ItemNotificationsStateSlice import App.State.Reducers.KeyboardSettingsStateSlice import App.State.Reducers.LobbyLoaderStateSlice import App.State.Reducers.LocalBattleUserStateSlice @@ -248,8 +249,8 @@ data class RootState( val TwitchState: TwitchStateSlice = TwitchStateSlice(), /** 原 icons(oH / vs.kru)。已迁移。 */ val Icons: IconsStateSlice = IconsStateSlice(), - /** 原 itemNotifications(_1 / A1().dso)。未迁移:原样透传。 */ - val ItemNotifications: Any? = null, + /** 原 itemNotifications(_1 / A1().dso)。已迁移。 */ + val ItemNotifications: ItemNotificationsStateSlice = ItemNotificationsStateSlice(), /** 原 contextTutorial(Mst / Jst().tth)。已迁移。 */ val ContextTutorial: ContextTutorialStateSlice = ContextTutorialStateSlice(), /** 原 suppliesTutorial(ust / Uw.uth)。已迁移。 */ diff --git a/TankiOffline/app/src/jsMain/kotlin/App/State/RootWiring.kt b/TankiOffline/app/src/jsMain/kotlin/App/State/RootWiring.kt index 923d982..d39f428 100644 --- a/TankiOffline/app/src/jsMain/kotlin/App/State/RootWiring.kt +++ b/TankiOffline/app/src/jsMain/kotlin/App/State/RootWiring.kt @@ -91,7 +91,7 @@ object RootWiring { SliceWiring("rewardedVideosState", "upx_1", "JQ", "xh.zsl", "xh.zsl(t, n.upx_1)", listOf("SetDisplayState")), SliceWiring("twitchState", "vpx_1", "g9", "uf.gta", "uf.gta(t, n.vpx_1)", listOf("SetAccount", "SetDaysForNextLinking", "SetTwitchEnabled")), SliceWiring("icons", "wpx_1", "oH", "vs.kru", "vs.kru(t, n.wpx_1)", listOf("SetIcons")), - SliceWiring("itemNotifications", "xpx_1", "_1", "A1().dso", "A1().dso(t, n.xpx_1)", listOf("?S1", "AddItemNotification", "AddItemNotifications", "RemoveItemNotification", "ResetSettingsFromStorage", "UpdateItemNotification")), + SliceWiring("itemNotifications", "xpx_1", "_1", "A1().dso", "A1().dso(t, n.xpx_1)", listOf("AddItemNotification", "AddItemNotifications", "PersistItemNotifications", "RemoveItemNotification", "ResetSettingsFromStorage", "UpdateItemNotification")), SliceWiring("contextTutorial", "ypx_1", "Mst", "Jst().tth", "Jst().tth(t, n.ypx_1)", listOf("LoadCurrentElement", "MarkElementAsViewed", "SetLoadedElements", "SetMissionToElement", "SetUnViewedElements")), SliceWiring("suppliesTutorial", "zpx_1", "ust", "Uw.uth", "Uw.uth(t, n.zpx_1)", listOf("ActivatedInCurrentBattle", "SetLoadedElement")), SliceWiring("questCategories", "apy_1", "zZ", "th.lsh", "th.lsh(t, n.apy_1)", listOf("RemoveQuestCategory", "RequestQuestCategoriesStatistic", "SetQuestCategories", "SetQuestCategoriesStatistic")), @@ -106,7 +106,7 @@ object RootWiring { ) /** 已迁移(强类型;launchParams 为透传,不调用 reducer)的字段。 */ - val Migrated: Set = setOf("inviteState", "loginByPassword", "twitchState", "home", "localBattleUserState", "shop", "screen", "lootBoxes", "garage", "garagePreview", "entrance", "user", "settings", "accountSettings", "proBattle", "language", "announcement", "launchParams", "news", "notifications", "quests", "lobbyLoader", "purchaseDialogState", "promoCode", "supportUrl", "rulesUpdated", "notificationByCountry", "availabilityByRank", "icons", "questCategories", "mainQuest", "securitySettings", "referralEvent", "tipsState", "rankUpDialogState", "battleResult", "captchaState", "modalScreen", "droppableGold", "eventGoods", "creatorTagState", "rewardedVideosState", "passwordChangeState", "system", "contextTutorial", "battleTutorial", "spectator", "twoStepVerificationState", "antiAddiction", "moderation", "migrationState", "register", "userReportsState", "friends", "suppliesState", "clanUserInfo", "battleInviteState", "partner", "userRenameState", "battlePauseState", "battleViewState", "tankActionsLogState", "suppliesTutorial", "clanChatState", "battleSearchState", "battleStatistics", "soundSettings", "gameSettings", "keyboardSettings", "touchscreenHudSettings", "graphicsSettings", "controlSettings", "battleSettings", "contractState", "tankBuilderState", "battleMessage", "maps", "battleChat", "remoteUserDataStorage", "matchmaking", "battlesSelect", "battleUsers", "challenges", "clansStorage", "clan", "chatState") + val Migrated: Set = setOf("inviteState", "loginByPassword", "twitchState", "home", "localBattleUserState", "shop", "screen", "lootBoxes", "garage", "garagePreview", "entrance", "user", "settings", "accountSettings", "proBattle", "language", "announcement", "launchParams", "news", "notifications", "quests", "lobbyLoader", "purchaseDialogState", "promoCode", "supportUrl", "rulesUpdated", "notificationByCountry", "availabilityByRank", "icons", "questCategories", "mainQuest", "securitySettings", "referralEvent", "tipsState", "rankUpDialogState", "battleResult", "captchaState", "modalScreen", "droppableGold", "eventGoods", "creatorTagState", "rewardedVideosState", "passwordChangeState", "system", "contextTutorial", "battleTutorial", "spectator", "twoStepVerificationState", "antiAddiction", "moderation", "migrationState", "register", "userReportsState", "friends", "suppliesState", "clanUserInfo", "battleInviteState", "partner", "userRenameState", "battlePauseState", "battleViewState", "tankActionsLogState", "suppliesTutorial", "clanChatState", "battleSearchState", "battleStatistics", "soundSettings", "gameSettings", "keyboardSettings", "touchscreenHudSettings", "graphicsSettings", "controlSettings", "battleSettings", "contractState", "tankBuilderState", "battleMessage", "maps", "battleChat", "remoteUserDataStorage", "matchmaking", "battlesSelect", "battleUsers", "challenges", "clansStorage", "clan", "chatState", "itemNotifications") /** 未迁移字段:归约时原样透传,作为后续迁移清单。 */ 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 601b63b..c08fc87 100644 --- a/TankiOffline/app/src/jsMain/kotlin/App/StateCheck.kt +++ b/TankiOffline/app/src/jsMain/kotlin/App/StateCheck.kt @@ -19,7 +19,7 @@ // System / ContextTutorial / BattleTutorial / Spectator / TwoStepVerification / AntiAddiction / // Moderation / MigrationState / Register / UserReports / // Friends / Supplies / ClanUserInfo / BattleInvite / Partner / UserRename / -// BattlePause / BattleView / TankActionsLog / SuppliesTutorial / ClanChat / BattleSearch / BattleStatistics / SoundSettings / GameSettings / KeyboardSettings / TouchscreenHudSettings / GraphicsSettings / ControlSettings / BattleSettings / ContractState / TankBuilder / BattleMessage / Maps / BattleChat / RemoteUserDataStorage / Matchmaking / BattlesSelect / BattleUsers / Challenges / ClansStorage / Clan / ChatState; +// BattlePause / BattleView / TankActionsLog / SuppliesTutorial / ClanChat / BattleSearch / BattleStatistics / SoundSettings / GameSettings / KeyboardSettings / TouchscreenHudSettings / GraphicsSettings / ControlSettings / BattleSettings / ContractState / TankBuilder / BattleMessage / Maps / BattleChat / RemoteUserDataStorage / Matchmaking / BattlesSelect / BattleUsers / Challenges / ClansStorage / Clan / ChatState / ItemNotifications; // ㉑ launchParams 是纯透传切片(无 reducer/动作),只做 Map 类型收口;⑭ 根 reducer 的 89 槽位委派 // // 用法:$env:TANKIOFFLINE_STATE_TEST='1'; node …(见 TankiOffline/tools/RunStateTest.ps1) @@ -904,6 +904,14 @@ import App.State.Reducers.RequireHideChatKeyboard import App.State.Reducers.ResetChat import App.State.Reducers.SetChatConfig import App.State.Reducers.ShowChatKeyboard +import App.State.AddItemNotification +import App.State.AddItemNotifications +import App.State.ItemNotificationData +import App.State.RemoveItemNotification +import App.State.UpdateItemNotification +import App.State.Reducers.ItemNotificationsStateSlice +import App.State.Reducers.PersistItemNotifications +import App.State.Reducers.ReduceItemNotifications import App.State.Reducers.UserRankBounds import App.State.Reducers.UserStateSlice import App.State.Reducers.ClearHullData @@ -1331,7 +1339,7 @@ object StateCheck { Check("切片 LocalBattleUser:接 Store 派发后状态已归约", Store.State.IsReArmorTemporaryDisabled) } - // ⑭ 根 reducer:89 个槽位的委派表 + 86 个已迁移切片(85 归约 + launchParams 透传) + // ⑭ 根 reducer:89 个槽位的委派表 + 87 个已迁移切片(86 归约 + launchParams 透传) run { Check("根:根状态 89 个切片槽位", RootWiring.All.size == 89) Check( @@ -1395,7 +1403,7 @@ object StateCheck { LocalBattleW.Reducer == "(inline@localBattleUserState)" ) Check( - "根:迁移表 = 86 个已迁移字段,其余 3 个待迁", + "根:迁移表 = 87 个已迁移字段,其余 2 个待迁", RootReducer.Migrated == setOf( "inviteState", "loginByPassword", "twitchState", "home", @@ -1419,9 +1427,9 @@ object StateCheck { "soundSettings", "gameSettings", "keyboardSettings", "touchscreenHudSettings", "graphicsSettings", "controlSettings", "battleSettings", "contractState", "tankBuilderState", "battleMessage", "maps", "battleChat", - "remoteUserDataStorage", "matchmaking", "battlesSelect", "battleUsers", "challenges", "clansStorage", "clan", "chatState", + "remoteUserDataStorage", "matchmaking", "battlesSelect", "battleUsers", "challenges", "clansStorage", "clan", "chatState", "itemNotifications", ) && - RootWiring.Unmigrated.size == 3 + RootWiring.Unmigrated.size == 2 ) // 一次 ReduceRoot 里多个切片各归约自己的动作,互不串片 @@ -1477,7 +1485,7 @@ object StateCheck { S5.User.Uid == "user-1" && S5.Screen.Current == NamedScreen("Preloader") ) Check("根:无关动作返回等值状态", ReduceRoot(S5, AddFrags(1)) == S5) - Check("根:Coverage 文本", RootReducer.Coverage == "已迁移 86/89") + Check("根:Coverage 文本", RootReducer.Coverage == "已迁移 87/89") val Store = ReduxStore(RootState(), { S, A -> ReduceRoot(S, A) }) Store.Dispatch(SetStatus("LOGGING_IN")) @@ -7346,6 +7354,83 @@ object StateCheck { ) } + // 97 切片 reducer:ItemNotifications(_1 / A1().dso,1 字段;6 个动作) + run { + fun Item(Id: Long, End: Any? = null): ItemNotificationData = ItemNotificationData( + section = "shop", itemId = Id, type = "new", parentId = null, + startTime = null, endTime = End, + ) + + val S0 = ItemNotificationsStateSlice() + Check("切片 ItemNotifications:默认空集合", S0.Notifications.isEmpty()) + val S1 = ReduceItemNotifications(S0, AddItemNotification("shop", 1L, "new", null, null, null)) + Check( + "切片 ItemNotifications:AddItemNotification 构造并入集合", + S1.Notifications.size == 1 && + (S1.Notifications.first() as ItemNotificationData).itemId == 1L + ) + val S2 = ReduceItemNotifications(S1, AddItemNotifications(listOf(Item(2L), Item(3L)))) + Check( + "切片 ItemNotifications:AddItemNotifications 批量加入", + (S2.Notifications.map { (it as ItemNotificationData).itemId }).toSet() == + setOf(1L, 2L, 3L) + ) + Check( + "切片 ItemNotifications:RemoveItemNotification 按 section+itemId 删", + ReduceItemNotifications( + S2, + RemoveItemNotification("shop", 2L, null, null, null, true), + ).Notifications.size == 2 + ) + Check( + "切片 ItemNotifications:excludeType 命中时保留", + ReduceItemNotifications( + S2, + RemoveItemNotification("shop", null, null, null, "new", true), + ).Notifications.size == 3 + ) + Check( + "切片 ItemNotifications:UpdateItemNotification 对匹配项应用 handler", + ReduceItemNotifications( + S2, + UpdateItemNotification("shop", 1L, null, null, null, { Item: ItemNotificationData -> + Item.copy(section = "updated") + }), + ).let { Updated -> + (Updated.Notifications.first { (it as ItemNotificationData).itemId == 1L } + as ItemNotificationData).section == "updated" + } + ) + Check( + "切片 ItemNotifications:PersistItemNotifications 是 no-op", + ReduceItemNotifications(S2, PersistItemNotifications) == S2 + ) + Check( + "切片 ItemNotifications:过期项在重载时被过滤", + ReduceItemNotifications( + ReduceItemNotifications(S0, AddItemNotification("shop", 9L, "new", null, null, 1L)), + ResetSettingsFromStorage, + ).Notifications.isEmpty() + ) + Check( + "切片 ItemNotifications:无关动作不改状态", + ReduceItemNotifications(S2, AddFrags(1)) == S2 + ) + + val Root0 = RootState() + val Root1 = ReduceRoot(Root0, AddItemNotification("shop", 1L, "new", null, null, null)) + Check( + "根:AddItemNotification 只动 itemNotifications", + Root1.ItemNotifications.Notifications.size == 1 && + Root1.ChatState == Root0.ChatState + ) + val W = RootWiring.ByField("itemNotifications")!! + Check( + "根:itemNotifications 委派到 _1 / A1().dso 且 6 个动作", + W.SliceClass == "_1" && W.Reducer == "A1().dso" && W.Actions.size == 6 + ) + } + val Failed = Results.filter { !it.second } println("TankiOffline 状态层自检:${Results.size} 项") for ((Name, Ok) in Results) println((if (Ok) " ✓ " else " ✗ ") + Name)