diff --git a/3DTank/docs/KtRebuild_Plan.md b/3DTank/docs/KtRebuild_Plan.md index 3aa3561..f846bc2 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` 动作名。 - - 已迁移切片 **18/89**: + - 已迁移切片 **21/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) @@ -210,12 +210,25 @@ - `announcement` → `lC` / `PC().rq5`(`AnnouncementReducer.kt`):5 字段,4 个 `instanceof` 臂。 `isEnabled` 缺省 true,其余 false/空列表;`bC`→`DisableAnnouncement`、`qC`→`SetAnnouncementModelLoaded`、 `CC`→`SetAnnouncementItems`(生成物 `Init` 属于另一个切片,已改名)、`$C`→`SetSkipNextPromotedAnnounce`。 + - `news` → `ZX` / `E_.asd`(`NewsReducer.kt`):4 字段,7 个分支(4 个 `instanceof` + 2 个单例 + `hJ`)。 + `SetNewsItemAction` / `AddNewsItemAction` 都按 `compareBy { -dateInSeconds }` 排序(与原包 32 位取负一致), + 后者是「追加再排序」;`RemoveNewsItemAction` 按 `id` 过滤,`SetUnreadNewsCounter` 写计数。 + 单例 `x_`/`j_` 定名 `OpenNews` / `CloseNews`;`hJ` 无字段、只把语义未定的 `p` 置 true,暂名 `SetNewsFlag`。 + - `notifications` → `jJ` / `B_.ise`(`NotificationsReducer.kt`):2 字段,4 个 `instanceof` 臂。 + 缺省 `notifications=emptyList()`、`notificationSound=null`(根状态是 `new jJ()`,生成器认不出类名,迁移表里显式补 `jJ`)。 + `AddNotification` 追加、`PJ`→`RemoveNotifications(predicate)` 按谓词过滤、`RJ`→`RemoveFirstNotification` 丢第一条。 + - `quests` → `Frt` / `Yrt.otg`(`QuestsReducer.kt` + `QuestModels.kt`):3 字段,4 个分支。 + 缺省 `currentSection=SPECIAL`、`currentCategory=null`、`isNotCompletedQuests=false`。 + `GoTo` 到 `tX`(`QuestCategoryScreen(section, categoryId)`)时切到目标分区,到 `nX`(`ChallengeScreen`)时切 `CHALLENGE`; + 原包那条分支还会发 `Wrt(QuestsChangeCategory)` 命令,属于副作用,留给中间件层。 + `Hrt` 类体为空、reducer 读的 `t.iu5_1` 是 `undefined`,Kotlin 侧按 `SetNotCompletedQuests` 收口为 false。 + `tX` / `nX` 是屏幕标识类,已从动作表剔除并记入 `Markers`(`screen-id-class`)。 - `launchParams` → `(pass-through)`:原根 reducer 对 `hpx_1` 没有调用,缺省是 `ag()` 空 Map; `RootState.LaunchParams` 收口为 `LaunchParamsStateSlice = Map`,不新增 reducer/动作。 - - `App/State/RootReducer.kt`:`ReduceRoot(State0, Action)` 归约上述 17 个有 reducer 的切片, - `launchParams` 与其余 **71 个字段原样透传**; - 迁移进度用 `RootReducer.Coverage`(`已迁移 18/89`)与 `RootWiring.Unmigrated` 暴露。 - - 自检:`TankiOffline/tools/RunStateTest.ps1` **350 项全绿**,含 89 槽位完整性、17 个归约切片一次归约互不串片、未迁移字段透传, + - `App/State/RootReducer.kt`:`ReduceRoot(State0, Action)` 归约上述 20 个有 reducer 的切片, + `launchParams` 与其余 **68 个字段原样透传**; + 迁移进度用 `RootReducer.Coverage`(`已迁移 21/89`)与 `RootWiring.Unmigrated` 暴露。 + - 自检:`TankiOffline/tools/RunStateTest.ps1` **376 项全绿**,含 89 槽位完整性、20 个归约切片一次归约互不串片、未迁移字段透传, 以及用 `action_trace_merged.json` 真实派发的两条商店动作(`SetCoinPaymentProviderUrl` / `ToggleShopActivity`)回放。 - 委派表里的已知形态:`shop` / `home` / `localBattleUserState` / `partner` 是内联 IIFE, 已按字段具名为 `(inline@)` 并从函数体抓到顶层动作名(69 / 1 / 2 / 11 个); @@ -226,7 +239,8 @@ shop 的 17 个匿名类按 reducer 效果定名并登记(`Yut`→`ShopLoaded`、`F5`→`ResetShop` 等); screen 的 9 个、lootBoxes 的 6 个、garage 的 25 个、garagePreview 的 15 个、 entrance 的 6 个、user 的 3 个、settings 的 2 个、accountSettings 的 11 个、 - language 的 1 个、announcement 的 3 个匿名类同样按效果定名并登记。 + language 的 1 个、announcement 的 3 个、news 的 3 个(含两个单例)、notifications 的 2 个、 + quests 的 1 个匿名类 / 单例同样按效果定名并登记。 ### 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 3a956d4..06f0cd0 100644 --- a/3DTank/names/root_wiring.json +++ b/3DTank/names/root_wiring.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-10-23T09:52:15.365Z", + "generatedAt": "2026-10-24T04:49:01.300Z", "module": "module_10032.js", "RootState": { "Class": "z6", @@ -24,7 +24,10 @@ "proBattle", "language", "announcement", - "launchParams" + "launchParams", + "news", + "notifications", + "quests" ], "AnonymousActions": [ { @@ -621,6 +624,42 @@ "Name": "SetAnnouncementItems", "DispatchedAt": "未在 action_trace_merged.json 中出现;按 reducer 分支效果定名", "Effect": "items = action.items" + }, + { + "Local": "x_", + "Name": "OpenNews", + "DispatchedAt": "未在 action_trace_merged.json 中出现;按 reducer 分支效果定名", + "Effect": "isOpen = true(`yd(t, x_)` → `n.psc(!0)`;x_ = new QX() 单例)" + }, + { + "Local": "j_", + "Name": "CloseNews", + "DispatchedAt": "未在 action_trace_merged.json 中出现;按 reducer 分支效果定名", + "Effect": "isOpen = false(`yd(t, j_)` → `n.psc(!1)`;j_ = new tJ() 单例)" + }, + { + "Local": "hJ", + "Name": "SetNewsFlag", + "DispatchedAt": "未在 action_trace_merged.json 中出现;按 reducer 分支效果定名", + "Effect": "p = true(`n.psc(cd, cd, cd, !0)`;动作类无字段,字段语义未定)" + }, + { + "Local": "PJ", + "Name": "RemoveNotifications", + "DispatchedAt": "未在 action_trace_merged.json 中出现;按 reducer 分支效果定名", + "Effect": "notifications = notifications.filter {!action.predicate(it)}(`t.hse_1(s) || e.y(s)`)" + }, + { + "Local": "RJ", + "Name": "RemoveFirstNotification", + "DispatchedAt": "未在 action_trace_merged.json 中出现;按 reducer 分支效果定名", + "Effect": "notifications = notifications.drop(1)(`w$(n.tsd_1, 1)`)" + }, + { + "Local": "Hrt", + "Name": "SetNotCompletedQuests", + "DispatchedAt": "未在 action_trace_merged.json 中出现;按 reducer 分支效果定名", + "Effect": "isNotCompletedQuests = action.isNotCompletedQuests(`n.au5(cd, cd, t.iu5_1)`;动作类无字段,原包写入 undefined,Kotlin 侧收口为 false)" } ], "Markers": [ @@ -653,6 +692,16 @@ "Local": "UB", "Kind": "state-class", "Evidence": "garage 切片状态类:`function UB(…31 字段)`,toString 名 \"Garage(...)\";InitState 分支用 `h instanceof UB` 校验灌入状态,不是动作。" + }, + { + "Local": "tX", + "Kind": "screen-id-class", + "Evidence": "屏幕标识类 Lobby.Quests.QuestCategory:`function tX(categoryType, id){ … this.gs8_1 = \"Lobby.Quests.QuestCategory.\" + … }`;quests 的 GoTo 分支用它判断 section,不是 dispatch 的动作。" + }, + { + "Local": "nX", + "Kind": "screen-id-class", + "Evidence": "屏幕标识类 Lobby.Quests.Challenge:`function nX(t){ … this.js8_1 = \"Lobby.Quests.Challenge\" }`;quests 的 GoTo 分支命中它时把 section 置 CHALLENGE,不是 dispatch 的动作。" } ], "Rows": [ @@ -1040,13 +1089,12 @@ "Reducer": "Yrt.otg", "Delegate": "(null == zp && new Yrt(), zp).otg(t, n.npv_1)", "Actions": [ - "?Hrt", "GoTo", - "QuestCategory", - "SetCurrentCategory" + "SetCurrentCategory", + "SetNotCompletedQuests" ], "Default": "new Frt()", - "Migrated": false + "Migrated": true }, { "Field": "chatState", @@ -1196,14 +1244,14 @@ "Reducer": "E_.asd", "Delegate": "E_.asd(t, n.tpv_1)", "Actions": [ - "?hJ", "AddNewsItemAction", "RemoveNewsItemAction", + "SetNewsFlag", "SetNewsItemAction", "SetUnreadNewsCounter" ], "Default": "new ZX()", - "Migrated": false + "Migrated": true }, { "Field": "settings", @@ -1274,17 +1322,17 @@ { "Field": "notifications", "Slot": "xpv_1", - "SliceClass": null, + "SliceClass": "jJ", "Reducer": "B_.ise", "Delegate": "B_.ise(t, n.xpv_1)", "Actions": [ - "?PJ", - "?RJ", "AddNotification", - "InitNotificationSound" + "InitNotificationSound", + "RemoveFirstNotification", + "RemoveNotifications" ], "Default": "", - "Migrated": false + "Migrated": true }, { "Field": "friends", diff --git a/3DTank/tools/gen_root_wiring.js b/3DTank/tools/gen_root_wiring.js index 0868871..c36b66f 100644 --- a/3DTank/tools/gen_root_wiring.js +++ b/3DTank/tools/gen_root_wiring.js @@ -158,6 +158,18 @@ const AnonymousActionNames = new Map([ ['bC', 'DisableAnnouncement'], ['qC', 'SetAnnouncementModelLoaded'], ['CC', 'SetAnnouncementItems'], + // notifications 切片(jJ / B_.ise):2 个没有 toString / 不在 action_inventory 的类。 + // PJ 是带谓词的移除,RJ 是丢掉第一条;实现见 NotificationsReducer.kt。 + ['PJ', 'RemoveNotifications'], + ['RJ', 'RemoveFirstNotification'], + // quests 切片(Frt / Yrt.otg):1 个没有 toString / 不在 action_inventory 的类。 + // 实现见 QuestsReducer.kt。 + ['Hrt', 'SetNotCompletedQuests'], + // news 切片(ZX / E_.asd):hJ 没有 toString;两个单例 x_/j_ 按效果定名。 + // 实现见 NewsReducer.kt。 + ['hJ', 'SetNewsFlag'], + ['x_', 'OpenNews'], + ['j_', 'CloseNews'], ]); // 非动作的 `instanceof`:屏幕标识类,用于 GoTo 分支判断(不是 dispatch 的动作)。 @@ -167,6 +179,8 @@ const NonActionInstanceof = new Map([ ['RX', { Kind: 'screen-id-class', Evidence: '屏幕标识类 System.Loader:`function RX(){ this.ysb_1="System.Loader" }`,`PX()` 返回该单例,也是 WK.current 的默认值;GoTo 到它时不发 screen_show。' }], ['FX', { Kind: 'screen-id-class', Evidence: '屏幕标识类 Garage:`function FX(t){ … this.irm_1 = t; this.jrm_1 = "Garage" }`,默认分类 `BB().sq5_1`;garage 的 GoTo 分支用它把 currentCategory 复位。' }], ['UB', { Kind: 'state-class', Evidence: 'garage 切片状态类:`function UB(…31 字段)`,toString 名 "Garage(...)";InitState 分支用 `h instanceof UB` 校验灌入状态,不是动作。' }], + ['tX', { Kind: 'screen-id-class', Evidence: '屏幕标识类 Lobby.Quests.QuestCategory:`function tX(categoryType, id){ … this.gs8_1 = "Lobby.Quests.QuestCategory." + … }`;quests 的 GoTo 分支用它判断 section,不是 dispatch 的动作。' }], + ['nX', { Kind: 'screen-id-class', Evidence: '屏幕标识类 Lobby.Quests.Challenge:`function nX(t){ … this.js8_1 = "Lobby.Quests.Challenge" }`;quests 的 GoTo 分支命中它时把 section 置 CHALLENGE,不是 dispatch 的动作。' }], ]); /** 动作局部类名 → 动作名;未收录的返回 null。 */ @@ -550,9 +564,24 @@ const Migrated = { Import: false, PassThrough: true, }, + news: { Type: 'NewsStateSlice', Reduce: 'ReduceNews' }, + // 原根状态的这一片是 `new jJ()`,生成器没认出切片类,默认值在这里显式补。 + notifications: { + Type: 'NotificationsStateSlice', + Default: 'NotificationsStateSlice()', + SliceClass: 'jJ', + Reduce: 'ReduceNotifications', + }, + quests: { Type: 'QuestsStateSlice', Reduce: 'ReduceQuests' }, }; for (const R of Rows) R.Migrated = Object.prototype.hasOwnProperty.call(Migrated, R.Field); +// 少数切片的类名生成器认不出来(例如根构造里是 `new jJ()` 但字段默认值挡住了推断), +// 迁移表里可以显式补一个 SliceClass。 +for (const R of Rows) { + const M = Migrated[R.Field]; + if (M && M.SliceClass) R.SliceClass = M.SliceClass; +} // 字段名 → Kotlin 属性名(首字母大写,保留原有大小写与数字) function KotlinName(Field) { return Field.charAt(0).toUpperCase() + Field.slice(1); } @@ -712,6 +741,24 @@ const AnnouncementAnonymousEffects = { CC: 'items = action.items', }; +// news 切片(ZX / E_.asd):没有 toString / 不在 action_inventory 的类与单例,按 reducer 分支效果定名。 +const NewsAnonymousEffects = { + x_: 'isOpen = true(`yd(t, x_)` → `n.psc(!0)`;x_ = new QX() 单例)', + j_: 'isOpen = false(`yd(t, j_)` → `n.psc(!1)`;j_ = new tJ() 单例)', + hJ: 'p = true(`n.psc(cd, cd, cd, !0)`;动作类无字段,字段语义未定)', +}; + +// notifications 切片(jJ / NJ.ise):没有 toString / 不在 action_inventory 的类,按 reducer 分支效果定名。 +const NotificationsAnonymousEffects = { + PJ: 'notifications = notifications.filter {!action.predicate(it)}(`t.hse_1(s) || e.y(s)`)', + RJ: 'notifications = notifications.drop(1)(`w$(n.tsd_1, 1)`)', +}; + +// quests 切片(Frt / Yrt.otg):没有 toString / 不在 action_inventory 的类,按 reducer 分支效果定名。 +const QuestsAnonymousEffects = { + Hrt: 'isNotCompletedQuests = action.isNotCompletedQuests(`n.au5(cd, cd, t.iu5_1)`;动作类无字段,原包写入 undefined,Kotlin 侧收口为 false)', +}; + fs.writeFileSync( path.join(P, 'names', 'root_wiring.json'), JSON.stringify( @@ -794,6 +841,24 @@ fs.writeFileSync( DispatchedAt: '未在 action_trace_merged.json 中出现;按 reducer 分支效果定名', Effect, })), + ...Object.entries(NewsAnonymousEffects).map(([Local, Effect]) => ({ + Local, + Name: AnonymousActionNames.get(Local), + DispatchedAt: '未在 action_trace_merged.json 中出现;按 reducer 分支效果定名', + Effect, + })), + ...Object.entries(NotificationsAnonymousEffects).map(([Local, Effect]) => ({ + Local, + Name: AnonymousActionNames.get(Local), + DispatchedAt: '未在 action_trace_merged.json 中出现;按 reducer 分支效果定名', + Effect, + })), + ...Object.entries(QuestsAnonymousEffects).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/QuestModels.kt b/TankiOffline/app/src/jsMain/kotlin/App/State/QuestModels.kt new file mode 100644 index 0000000..589073a --- /dev/null +++ b/TankiOffline/app/src/jsMain/kotlin/App/State/QuestModels.kt @@ -0,0 +1,29 @@ +// QuestModels.kt —— 任务切片的枚举与屏幕标识 +// +// 证据链: +// · 分区枚举 `kk`:`(Nn = new kk("SPECIAL", 0), Mn = new kk("CHALLENGE", 1), On = new kk("CONTRACT", 2))`, +// `vg()` = SPECIAL、`bk()` = CHALLENGE、`qk()` = CONTRACT(module_47243)。 +// · 原 `tX` 是屏幕标识(不是动作):`m()` 返回 "Lobby.Quests.QuestCategory.
[.]", +// toString 名 "QuestCategory(categoryType=…, id=…)";quests reducer 用它的 `es8_1` 当新分区。 +// · 原 `nX`:`m()` 返回 "Lobby.Quests.Challenge",toString 名 "Challenge(battlePassFlavor=…)"。 +// Sealed interface 要求实现与声明同包,所以这两个屏幕类放在 App.State 而不是 Reducers 子包。 +package App.State + +/** 原 `kk` 的三个枚举值。 */ +enum class QuestSection { SPECIAL, CHALLENGE, CONTRACT } + +/** 原 `tX`:`Lobby.Quests.QuestCategory.
[.]`。 */ +data class QuestCategoryScreen( + val section: QuestSection, + val categoryId: Any? = null, +) : ScreenId { + override val name: String = + "Lobby.Quests.QuestCategory." + section.name + (categoryId?.let { ".$it" } ?: "") +} + +/** 原 `nX`:`Lobby.Quests.Challenge`。 */ +data class ChallengeScreen( + val battlePassFlavor: Any?, +) : ScreenId { + override val name: String = "Lobby.Quests.Challenge" +} diff --git a/TankiOffline/app/src/jsMain/kotlin/App/State/Reducers/NewsReducer.kt b/TankiOffline/app/src/jsMain/kotlin/App/State/Reducers/NewsReducer.kt new file mode 100644 index 0000000..885635a --- /dev/null +++ b/TankiOffline/app/src/jsMain/kotlin/App/State/Reducers/NewsReducer.kt @@ -0,0 +1,71 @@ +// NewsReducer.kt —— 新闻切片(对应 module_10032 的 ZX 状态 + E_.asd) +// +// 证据链: +// 状态类 ZX:4 个字段,toString 名 "NewsState(...)";copy 是 psc(4 字段 + 回调)。 +// 默认:isOpen=false、news=emptyList()、unreadNewsCounter=0、p=false。 +// 根委派:`E_.asd(t, n.tpv_1)`。 +// reducer 七个分支: +// yd(t, x_) → isOpen=true(x_ = new QX() 单例,无字段) +// yd(t, j_) → isOpen=false(j_ = new tJ() 单例,无字段) +// t instanceof sJ → news = action.news.sortedWith(compareBy { -dateInSeconds }) +// t instanceof oJ → news = (news + action.news).sortedWith(同上) +// t instanceof _J → news = news.filterNot { it.id == action.newsId } +// t instanceof lJ → unreadNewsCounter = action.count +// t instanceof hJ → p=true +// 排序证据:`wm(r, new pJ((t, n) -> bk(fJ(0, t), fJ(0, n))))`, +// fJ(0, t) 是 `0 | -t.li5_1`(li5_1 = NewsItemData.dateInSeconds),bk 是 compareValues。 +// `p` 的语义不明(只有一支写 true,从不复位),按字段名保留、动作名先叫 SetNewsFlag。 +package App.State.Reducers + +import App.State.AddNewsItemAction +import App.State.NewsItemData +import App.State.RemoveNewsItemAction +import App.State.SetNewsItemAction +import App.State.SetUnreadNewsCounter + +/* ------------------------------------------------------------------ 匿名动作 */ + +/** 原 `x_`(`new QX()`):打开新闻面板。 */ +object OpenNews + +/** 原 `j_`(`new tJ()`):关闭新闻面板。 */ +object CloseNews + +/** 原 `hJ`:把 `p` 置 true;字段语义未定。 */ +object SetNewsFlag + +/* ------------------------------------------------------------------ 状态 */ + +data class NewsStateSlice( + val IsOpen: Boolean = false, + val News: List = emptyList(), + val UnreadNewsCounter: Int = 0, + val P: Boolean = false, +) + +/* ------------------------------------------------------------------ reducer */ + +/** + * 原包的排序:`compareBy { -dateInSeconds }`。 + * 用取负而不是 `compareByDescending`,是为了和原包 32 位取负(`0 | -x`)的溢出行为一致。 + */ +private fun SortNews(News: List): List = + News.sortedWith(compareBy { -it.dateInSeconds }) + +fun ReduceNews( + State0: NewsStateSlice, + Action: Any, +): NewsStateSlice = when (Action) { + is OpenNews -> State0.copy(IsOpen = true) + is CloseNews -> State0.copy(IsOpen = false) + is SetNewsItemAction -> State0.copy(News = SortNews(Action.news)) + is AddNewsItemAction -> + State0.copy(News = SortNews(State0.News + (Action.news as NewsItemData))) + is RemoveNewsItemAction -> { + val NewsId = Action.newsId as Long + State0.copy(News = State0.News.filterNot { it.id == NewsId }) + } + is SetUnreadNewsCounter -> State0.copy(UnreadNewsCounter = Action.count) + is SetNewsFlag -> State0.copy(P = true) + else -> State0 +} diff --git a/TankiOffline/app/src/jsMain/kotlin/App/State/Reducers/NotificationsReducer.kt b/TankiOffline/app/src/jsMain/kotlin/App/State/Reducers/NotificationsReducer.kt new file mode 100644 index 0000000..9432280 --- /dev/null +++ b/TankiOffline/app/src/jsMain/kotlin/App/State/Reducers/NotificationsReducer.kt @@ -0,0 +1,45 @@ +// NotificationsReducer.kt —— 通知切片(对应 module_10032 的 jJ 状态 + NJ.ise) +// +// 证据链: +// 状态类 jJ:2 个字段,toString 名 "Notifications(...)";copy 是 wsd(2 字段 + 回调)。 +// 默认:notifications=emptyList()、notificationSound=null。根状态里就是 `new jJ()`。 +// 根委派:`B_.ise(t, n.xpv_1)`。 +// reducer 四个 instanceof 臂: +// IJ → notificationSound = action.sound +// AJ → notifications = notifications + action.notificationItem(`Hg(list, item)`) +// RJ → notifications = notifications.drop(1)(`w$(list, 1)`) +// PJ → notifications = notifications.filterNot { action.predicate(it) }(`t.hse_1(s) || e.y(s)`) +// 通知项的类型在协议层是 `CJ`(带存活时长的通知包装),还没迁移,先用 Any? 收口。 +package App.State.Reducers + +import App.State.AddNotification +import App.State.InitNotificationSound + +/* ------------------------------------------------------------------ 匿名动作 */ + +/** 原 `PJ`:带谓词地移除通知;谓词来自调用点。 */ +class RemoveNotifications(val predicate: (Any?) -> Boolean) + +/** 原 `RJ`:丢掉第一条通知。 */ +object RemoveFirstNotification + +/* ------------------------------------------------------------------ 状态 */ + +data class NotificationsStateSlice( + val Notifications: List = emptyList(), + val NotificationSound: Any? = null, +) + +/* ------------------------------------------------------------------ reducer */ + +fun ReduceNotifications( + State0: NotificationsStateSlice, + Action: Any, +): NotificationsStateSlice = when (Action) { + is InitNotificationSound -> State0.copy(NotificationSound = Action.sound) + is AddNotification -> State0.copy(Notifications = State0.Notifications + Action.notificationItem) + is RemoveNotifications -> + State0.copy(Notifications = State0.Notifications.filterNot(Action.predicate)) + is RemoveFirstNotification -> State0.copy(Notifications = State0.Notifications.drop(1)) + else -> State0 +} diff --git a/TankiOffline/app/src/jsMain/kotlin/App/State/Reducers/QuestsReducer.kt b/TankiOffline/app/src/jsMain/kotlin/App/State/Reducers/QuestsReducer.kt new file mode 100644 index 0000000..ee0098b --- /dev/null +++ b/TankiOffline/app/src/jsMain/kotlin/App/State/Reducers/QuestsReducer.kt @@ -0,0 +1,57 @@ +// QuestsReducer.kt —— 任务切片(对应 module_10032 的 Frt 状态 + Yrt.otg) +// +// 证据链: +// 状态类 Frt:3 个字段,toString 名 "Quests(...)";copy 是 au5(3 字段 + 回调)。 +// 默认:currentSection=vg()=SPECIAL、currentCategory=null、isNotCompletedQuests=false。 +// 根委派:`(null == zp && new Yrt(), zp).otg(t, n.npv_1)`。 +// reducer: +// t instanceof Vrt → currentCategory = action.currentCategory +// t instanceof Hrt → isNotCompletedQuests = action.isNotCompletedQuests +// (Hrt 类体为空、没有字段,`t.iu5_1` 是 undefined;Kotlin 侧收口为 false) +// t instanceof QK(GoTo) +// · 目标是 tX(Lobby.Quests.QuestCategory.
[.])→ currentSection = 目标里的 section +// 并额外 dispatch `Wrt(previousScreen, previousCategoryId, section)`(见下方注释) +// · 目标是 nX(Lobby.Quests.Challenge)→ currentSection = CHALLENGE(`pg()`) +// · 其他目标 → 状态不变 +// `currentCategory` 的真实类型是任务分类对象(QuestCategoryData),切片里只当不透明值保存。 +package App.State.Reducers + +import App.State.ChallengeScreen +import App.State.GoTo +import App.State.QuestCategoryScreen +import App.State.QuestSection +import App.State.SetCurrentCategory + +/* ------------------------------------------------------------------ 匿名动作 */ + +/** + * 原 `Hrt`:类体为空,reducer 读的 `t.iu5_1` 恒为 undefined。 + * 原包的实际效果是把 isNotCompletedQuests 置成假值,这里按 false 收口。 + */ +object SetNotCompletedQuests + +/* ------------------------------------------------------------------ 状态 */ + +data class QuestsStateSlice( + val CurrentSection: QuestSection = QuestSection.SPECIAL, + val CurrentCategory: Any? = null, + val IsNotCompletedQuests: Boolean = false, +) + +/* ------------------------------------------------------------------ reducer */ + +fun ReduceQuests( + State0: QuestsStateSlice, + Action: Any, +): QuestsStateSlice = when (Action) { + is SetCurrentCategory -> State0.copy(CurrentCategory = Action.currentCategory) + is SetNotCompletedQuests -> State0.copy(IsNotCompletedQuests = false) + // 原包的 QuestCategory 分支还会 `u.uar(new Wrt(currentScreen, currentCategoryId, section))` + // 发一条 QuestsChangeCategory 命令;那是副作用,不属于纯状态归约,等中间件层再补。 + is GoTo -> when (val Screen = Action.screen) { + is QuestCategoryScreen -> State0.copy(CurrentSection = Screen.section) + is ChallengeScreen -> State0.copy(CurrentSection = QuestSection.CHALLENGE) + else -> State0 + } + 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 7478e6a..58b81c9 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 出新状态)。 -// 这里采用同一形状。已迁移的 18 个切片里,17 个真正调用 reducer,launchParams 只做类型收口: +// 这里采用同一形状。已迁移的 21 个切片里,20 个真正调用 reducer,launchParams 只做类型收口: // // inviteState → ReduceInvite(aD / pD.er5) // loginByPassword → ReduceLoginByPassword(gD / MD.cr6) @@ -20,9 +20,12 @@ // proBattle → ReduceProBattle(Wtt / uv.wth,根委派 `uv.wth(t, n.bpy_1)`) // language → ReduceLanguage(枚举值 / m6().jtg,根委派 `m6().jtg(t, n.gpx_1)`) // announcement → ReduceAnnouncement(lC / PC().rq5,根委派 `PC().rq5(t, n.opx_1)`) +// news → ReduceNews(ZX / E_.asd,根委派 `E_.asd(t, n.tpv_1)`) +// notifications → ReduceNotifications(jJ / B_.ise,根委派 `B_.ise(t, n.xpv_1)`) +// quests → ReduceQuests(Frt / Yrt.otg,根委派 `(null == zp && new Yrt(), zp).otg(t, n.npv_1)`) // launchParams → 无 reducer(原根 reducer 原样透传;默认 `ag()` = emptyMap()) // -// 其余 71 个字段按原样透传(defaultValue 取原构造)。每个切片 reducer 对无关动作都返回原状态, +// 其余 68 个字段按原样透传(defaultValue 取原构造)。每个切片 reducer 对无关动作都返回原状态, // 所以一次 copy 里串起全部已迁移 reducer,与按动作路由再归约的结果一致。 // 迁移进度与每个字段的证据见生成物 RootWiring.kt(不要手改)。 package App.State @@ -38,7 +41,10 @@ import App.State.Reducers.ReduceLanguage import App.State.Reducers.ReduceLocalBattleUser import App.State.Reducers.ReduceLoginByPassword import App.State.Reducers.ReduceLootBoxes +import App.State.Reducers.ReduceNews +import App.State.Reducers.ReduceNotifications import App.State.Reducers.ReduceProBattle +import App.State.Reducers.ReduceQuests import App.State.Reducers.ReduceScreen import App.State.Reducers.ReduceSettings import App.State.Reducers.ReduceShop @@ -75,6 +81,9 @@ object RootReducer { ProBattle = ReduceProBattle(State0.ProBattle, Action), Language = ReduceLanguage(State0.Language, Action), Announcement = ReduceAnnouncement(State0.Announcement, Action), + News = ReduceNews(State0.News, Action), + Notifications = ReduceNotifications(State0.Notifications, Action), + Quests = ReduceQuests(State0.Quests, Action), ) } diff --git a/TankiOffline/app/src/jsMain/kotlin/App/State/RootState.kt b/TankiOffline/app/src/jsMain/kotlin/App/State/RootState.kt index 9438809..79a1a22 100644 --- a/TankiOffline/app/src/jsMain/kotlin/App/State/RootState.kt +++ b/TankiOffline/app/src/jsMain/kotlin/App/State/RootState.kt @@ -14,7 +14,10 @@ import App.State.Reducers.InviteStateSlice import App.State.Reducers.LocalBattleUserStateSlice import App.State.Reducers.LoginByPasswordStateSlice import App.State.Reducers.LootBoxesStateSlice +import App.State.Reducers.NewsStateSlice +import App.State.Reducers.NotificationsStateSlice import App.State.Reducers.ProBattleStateSlice +import App.State.Reducers.QuestsStateSlice import App.State.Reducers.ScreenStateSlice import App.State.Reducers.SettingsStateSlice import App.State.Reducers.ShopStateSlice @@ -56,8 +59,8 @@ data class RootState( val PasswordChangeState: Any? = null, /** 原 system(_9 / w9().xt9)。未迁移:原样透传。 */ val System: Any? = null, - /** 原 quests(Frt / Yrt.otg)。未迁移:原样透传。 */ - val Quests: Any? = null, + /** 原 quests(Frt / Yrt.otg)。已迁移。 */ + val Quests: QuestsStateSlice = QuestsStateSlice(), /** 原 chatState(LP / $r.wqv)。未迁移:原样透传。 */ val ChatState: Any? = null, /** 原 clanChatState(qnt / yv.qtg)。未迁移:原样透传。 */ @@ -68,16 +71,16 @@ data class RootState( val ClansStorage: Any? = null, /** 原 tipsState(dst / Hw.vth)。未迁移:原样透传。 */ val TipsState: Any? = null, - /** 原 news(ZX / E_.asd)。未迁移:原样透传。 */ - val News: Any? = null, + /** 原 news(ZX / E_.asd)。已迁移。 */ + val News: NewsStateSlice = NewsStateSlice(), /** 原 settings(Uut / xw.ltg)。已迁移。 */ val Settings: SettingsStateSlice = SettingsStateSlice(), /** 原 accountSettings(oet / Lp.mtg)。已迁移。 */ val AccountSettings: AccountSettingsStateSlice = AccountSettingsStateSlice(), /** 原 lootBoxes(aH / ws.wrv)。已迁移。 */ val LootBoxes: LootBoxesStateSlice = LootBoxesStateSlice(), - /** 原 notifications(? / B_.ise)。未迁移:原样透传。 */ - val Notifications: Any? = null, + /** 原 notifications(jJ / B_.ise)。已迁移。 */ + val Notifications: NotificationsStateSlice = NotificationsStateSlice(), /** 原 friends(KD / NL().cr9)。未迁移:原样透传。 */ val Friends: Any? = null, /** 原 remoteUserDataStorage($Q / vh.isl)。未迁移:原样透传。 */ diff --git a/TankiOffline/app/src/jsMain/kotlin/App/State/RootWiring.kt b/TankiOffline/app/src/jsMain/kotlin/App/State/RootWiring.kt index 3d15adf..750b21b 100644 --- a/TankiOffline/app/src/jsMain/kotlin/App/State/RootWiring.kt +++ b/TankiOffline/app/src/jsMain/kotlin/App/State/RootWiring.kt @@ -29,17 +29,17 @@ object RootWiring { SliceWiring("captchaState", "kpv_1", "jP", "ar.iqt", "ar.iqt(t, n.kpv_1)", listOf("?AP", "?IP", "?PP", "?RP", "?SP", "LocationsUpdated", "SetLocations")), SliceWiring("passwordChangeState", "lpv_1", "VD", "uu.pr6", "uu.pr6(t, n.lpv_1)", listOf("SetPasswordChangeHash", "ShowEmailAndPasswordChangeDialog")), SliceWiring("system", "mpv_1", "_9", "w9().xt9", "w9().xt9(t, n.mpv_1)", listOf("DoShowMessage", "SetFatalError")), - SliceWiring("quests", "npv_1", "Frt", "Yrt.otg", "(null == zp && new Yrt(), zp).otg(t, n.npv_1)", listOf("?Hrt", "GoTo", "QuestCategory", "SetCurrentCategory")), + SliceWiring("quests", "npv_1", "Frt", "Yrt.otg", "(null == zp && new Yrt(), zp).otg(t, n.npv_1)", listOf("GoTo", "SetCurrentCategory", "SetNotCompletedQuests")), SliceWiring("chatState", "opv_1", "LP", "\$r.wqv", "\$r.wqv(t, n.opv_1)", listOf("?QP", "?XP", "?_C", "?bN", "?fN", "?lN", "?nN", "?pN", "?qN", "?sN", "?vN", "?wN", "ChangeChannel", "CleanUsersMessage", "Configure", "IgnoreUserMessages", "SaveChatMessage", "SendMessageToServer", "SetMessages", "SetSelectedUid", "ShowContextMenu", "ShowUserMessages", "UpdateTypingSpeedAntifloodParams", "UpdateUsers")), SliceWiring("clanChatState", "ppv_1", "qnt", "yv.qtg", "yv.qtg(t, n.ppv_1)", listOf("?\$nt", "?Tnt", "?xnt", "Configure", "DoAddMessages")), SliceWiring("clan", "qpv_1", "jN", "we.hr0", "we.hr0(t, n.qpv_1)", listOf("?FM", "?IM", "?KM", "?PM", "?RM", "?VM", "?YM", "?iM", "AcceptedUserViewed", "AddIncomingClan", "AddNewIncoming", "AddNewIncomingClan", "AddOutgoingClan", "AddUsersToAccepted", "AddUsersToNewAccepted", "ClanBanned", "ClanInfoLoaded", "ClanLoaded", "ClanModalVisible", "IncomingAdded", "IncomingRemoved", "IncomingUserViewed", "MembersFetched", "NewAcceptedUserNotification", "NewClanInviteNotification", "NewIncomingUserNotification", "OutgoingAdded", "OutgoingRemoved", "RemoveIncomingClan", "RemoveNewIncoming", "RemoveNewIncomingClan", "RemoveOutgoingClan", "RemoveUserFromAccepted", "RemoveUsersFromNewAccepted", "SetClanEditPrice", "SetMinRankForCreate", "UpdateClanLicense", "UpdateClanNameValidation", "UpdateClanSearchValidation", "UpdateClanTagValidation", "UpdateHasClan", "UpdateTimeToJoinRestriction", "UpdateValidation")), SliceWiring("clansStorage", "rpv_1", "lO", "me.vr1", "me.vr1(t, n.rpv_1)", listOf("?mO", "AddUser", "AddUsersToNewAccepted", "BuyAdditionalPlace", "ClanLoaded", "ClanUnloaded", "ForeignClanLoaded", "MinimalRankChanged", "RecruitmentChanged", "RemoveUser", "RemoveUsersFromNewAccepted", "ShowForeignClan", "UpdateClan")), SliceWiring("tipsState", "spv_1", "dst", "Hw.vth", "Hw.vth(t, n.spv_1)", listOf("UpdateTips")), - SliceWiring("news", "tpv_1", "ZX", "E_.asd", "E_.asd(t, n.tpv_1)", listOf("?hJ", "AddNewsItemAction", "RemoveNewsItemAction", "SetNewsItemAction", "SetUnreadNewsCounter")), + SliceWiring("news", "tpv_1", "ZX", "E_.asd", "E_.asd(t, n.tpv_1)", listOf("AddNewsItemAction", "RemoveNewsItemAction", "SetNewsFlag", "SetNewsItemAction", "SetUnreadNewsCounter")), SliceWiring("settings", "upv_1", "Uut", "xw.ltg", "xw.ltg(t, n.upv_1)", listOf("DisableAccountSettings", "SetSettingsCategory")), SliceWiring("accountSettings", "vpv_1", "oet", "Lp.mtg", "Lp.mtg(t, n.vpv_1)", listOf("ClearAccountRemovalTimer", "ClearInputUid", "ResetPasswordFields", "SetAccountRemovalDays", "SetCountry", "SetCurrentPassword", "SetCurrentPasswordMandatory", "SetEmail", "SetEmailConfirmed", "SetNewPassword", "SetPasswordStatus", "SetRepeatNewPassword", "SetUnlockButtonForRenameUid")), SliceWiring("lootBoxes", "wpv_1", "aH", "ws.wrv", "ws.wrv(t, n.wpv_1)", listOf("AddKeys", "ClearRewards", "LootBoxOpeningFailed", "SelectCategory", "SetContainerData", "SetCurrentContainer", "SetGoToGarage", "SetIsAccelerated", "SetIsBlockOpenButtons", "SetIsOpening", "SetIsReopening", "SetIsShopVisited", "SetKeys", "SetPeriodOfGiveLootBox", "SetPossibleRewards", "SetRewards", "SetTimeToGiveLootBox", "ShownRewards")), - SliceWiring("notifications", "xpv_1", "?", "B_.ise", "B_.ise(t, n.xpv_1)", listOf("?PJ", "?RJ", "AddNotification", "InitNotificationSound")), + SliceWiring("notifications", "xpv_1", "jJ", "B_.ise", "B_.ise(t, n.xpv_1)", listOf("AddNotification", "InitNotificationSound", "RemoveFirstNotification", "RemoveNotifications")), SliceWiring("friends", "ypv_1", "KD", "NL().cr9", "NL().cr9(t, n.ypv_1)", listOf("?_L", "?hL", "AddUsersToAccepted", "AddUsersToIncoming", "AddUsersToNewAccepted", "AddUsersToNewIncoming", "ChangeFriendAddFindState", "RemoveUserFromAccepted", "RemoveUserFromIncoming", "RemoveUserFromNewAccepted", "RemoveUsersFromNewIncoming", "SetLimits", "SetValidationStatus")), SliceWiring("remoteUserDataStorage", "zpv_1", "\$Q", "vh.isl", "vh.isl(t, n.zpv_1)", listOf("UpdateClanTags", "UpdateOnlineStatus", "UpdatePremiumStatus", "UpdateRanks", "UpdateUids", "UserEnterBattle", "UserLeaveBattle", "UserLeaveGroup")), SliceWiring("battlesSelect", "apw_1", "fA", "Mi.vqk", "Mi.vqk(t, n.apw_1)", listOf("?dA", "?gA", "AddBattle", "AddUser", "AddUsers", "ChangeName", "EnterToBattleInfo", "InitProBattleRewardLimit", "LimitReached", "RemoveBattle", "RemoveUser", "RoundFinish", "RoundStarted", "SetReEnterProtectBattleId", "SwapTeam", "UpdateFund", "UpdateSuspicion", "UpdateTeamScore", "UpdateUserScore", "UpdateUserSuspicion")), @@ -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") + 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") /** 未迁移字段:归约时原样透传,作为后续迁移清单。 */ 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 1f2b31e..2bcbfb0 100644 --- a/TankiOffline/app/src/jsMain/kotlin/App/StateCheck.kt +++ b/TankiOffline/app/src/jsMain/kotlin/App/StateCheck.kt @@ -9,9 +9,9 @@ // ⑥ 动作目录:816 个类与 138 个真实派发名对齐 // ⑦ 回放:真实 toString 样例的类/字段/类型校验 // ⑧ 全目录动作类都能按 KClass 路由 -// ⑨~㉘ 切片 reducer:InviteState / LoginByPasswordState / TwitchState / HomeScreen / LocalBattleUserState / +// ⑨~㉛ 切片 reducer:InviteState / LoginByPasswordState / TwitchState / HomeScreen / LocalBattleUserState / // Shop(含 U7 类别表与 w3 物品表两个子 reducer)/ Screen / LootBoxes / Garage / GaragePreview / Entrance / User / Settings / -// AccountSettings / ProBattle / Language / Announcement; +// AccountSettings / ProBattle / Language / Announcement / News / Notifications / Quests; // ㉑ launchParams 是纯透传切片(无 reducer/动作),只做 Map 类型收口;⑭ 根 reducer 的 89 槽位委派 // // 用法:$env:TANKIOFFLINE_STATE_TEST='1'; node …(见 TankiOffline/tools/RunStateTest.ps1) @@ -165,6 +165,14 @@ import App.State.DisplayMainEntranceScreen import App.State.DisplayReauthorizationMessage import App.State.EntranceScreen import App.State.GoTo +import App.State.AddNewsItemAction +import App.State.AddNotification +import App.State.InitNotificationSound +import App.State.NewsItemData +import App.State.RemoveNewsItemAction +import App.State.SetCurrentCategory +import App.State.SetNewsItemAction +import App.State.SetUnreadNewsCounter import App.State.LoaderScreen import App.State.NamedScreen import App.State.PressedAnyButton @@ -302,6 +310,21 @@ import App.State.Reducers.SetRepeatNewPassword import App.State.Reducers.SetSettingsCategory import App.State.Reducers.SetUnlockButtonForRenameUid import App.State.Reducers.SettingsStateSlice +import App.State.ChallengeScreen +import App.State.Reducers.CloseNews +import App.State.Reducers.NewsStateSlice +import App.State.Reducers.NotificationsStateSlice +import App.State.Reducers.OpenNews +import App.State.QuestCategoryScreen +import App.State.QuestSection +import App.State.Reducers.QuestsStateSlice +import App.State.Reducers.ReduceNews +import App.State.Reducers.ReduceNotifications +import App.State.Reducers.ReduceQuests +import App.State.Reducers.RemoveFirstNotification +import App.State.Reducers.RemoveNotifications +import App.State.Reducers.SetNewsFlag +import App.State.Reducers.SetNotCompletedQuests import App.State.Reducers.UserRankBounds import App.State.Reducers.UserStateSlice import App.State.Reducers.ClearHullData @@ -729,7 +752,7 @@ object StateCheck { Check("切片 LocalBattleUser:接 Store 派发后状态已归约", Store.State.IsReArmorTemporaryDisabled) } - // ⑭ 根 reducer:89 个槽位的委派表 + 18 个已迁移切片(17 归约 + launchParams 透传) + // ⑭ 根 reducer:89 个槽位的委派表 + 21 个已迁移切片(20 归约 + launchParams 透传) run { Check("根:根状态 89 个切片槽位", RootWiring.All.size == 89) Check( @@ -793,15 +816,16 @@ object StateCheck { LocalBattleW.Reducer == "(inline@localBattleUserState)" ) Check( - "根:迁移表 = 18 个已迁移字段,其余 71 个待迁", + "根:迁移表 = 21 个已迁移字段,其余 68 个待迁", RootReducer.Migrated == setOf( "inviteState", "loginByPassword", "twitchState", "home", "localBattleUserState", "shop", "screen", "lootBoxes", "garage", "garagePreview", "entrance", "user", "settings", "accountSettings", "proBattle", "language", "announcement", "launchParams", + "news", "notifications", "quests", ) && - RootWiring.Unmigrated.size == 71 + RootWiring.Unmigrated.size == 68 ) // 一次 ReduceRoot 里多个切片各归约自己的动作,互不串片 @@ -857,7 +881,7 @@ object StateCheck { S5.User.Uid == "user-1" && S5.Screen.Current == NamedScreen("Preloader") ) Check("根:无关动作返回等值状态", ReduceRoot(S5, AddFrags(1)) == S5) - Check("根:Coverage 文本", RootReducer.Coverage == "已迁移 18/89") + Check("根:Coverage 文本", RootReducer.Coverage == "已迁移 21/89") val Store = ReduxStore(RootState(), { S, A -> ReduceRoot(S, A) }) Store.Dispatch(SetStatus("LOGGING_IN")) @@ -2925,6 +2949,160 @@ object StateCheck { ) } + // ㉙ 切片 reducer:News(ZX / E_.asd,4 字段;7 个分支) + run { + val S0 = NewsStateSlice() + Check( + "切片 News:默认值完整", + !S0.IsOpen && S0.News.isEmpty() && S0.UnreadNewsCounter == 0 && !S0.P + ) + val N1 = NewsItemData(10, "d1", 0, "h1", 1L, "") + val N2 = NewsItemData(30, "d2", 0, "h2", 2L, "") + val N3 = NewsItemData(20, "d3", 0, "h3", 3L, "") + Check( + "切片 News:SetNewsItemAction 按 dateInSeconds 降序", + ReduceNews(S0, SetNewsItemAction(listOf(N1, N2, N3))).News.map { it.id } == + listOf(2L, 3L, 1L) + ) + val S1 = ReduceNews(S0, SetNewsItemAction(listOf(N1, N2))) + Check( + "切片 News:AddNewsItemAction 追加后重新排序", + ReduceNews(S1, AddNewsItemAction(N3)).News.map { it.id } == listOf(2L, 3L, 1L) + ) + Check( + "切片 News:RemoveNewsItemAction 按 id 删除", + ReduceNews(S1, RemoveNewsItemAction(2L)).News.map { it.id } == listOf(1L) + ) + Check( + "切片 News:SetUnreadNewsCounter 写计数", + ReduceNews(S0, SetUnreadNewsCounter(5)).UnreadNewsCounter == 5 + ) + Check( + "切片 News:OpenNews/CloseNews 开关面板、SetNewsFlag 置 p", + ReduceNews(ReduceNews(ReduceNews(S0, OpenNews), CloseNews), SetNewsFlag) == + NewsStateSlice(IsOpen = false, P = true) + ) + Check("切片 News:无关动作不改状态", ReduceNews(S0, AddFrags(1)) == S0) + + val Root0 = RootState() + val Root1 = ReduceRoot(Root0, OpenNews) + Check( + "根:OpenNews 只动 news", + Root1.News.IsOpen && Root1.Notifications == Root0.Notifications + ) + val W = RootWiring.ByField("news")!! + Check( + "根:news 委派到 ZX / E_.asd 且 5 个动作全部具名", + W.SliceClass == "ZX" && W.Reducer == "E_.asd" && + W.Actions.toSet() == setOf( + "AddNewsItemAction", "RemoveNewsItemAction", "SetNewsFlag", + "SetNewsItemAction", "SetUnreadNewsCounter", + ) + ) + } + + // ㉚ 切片 reducer:Notifications(jJ / B_.ise,2 字段;4 个 instanceof 臂) + run { + val S0 = NotificationsStateSlice() + Check( + "切片 Notifications:默认值完整", + S0.Notifications.isEmpty() && S0.NotificationSound == null + ) + val S1 = ReduceNotifications(S0, InitNotificationSound("ding")) + Check( + "切片 Notifications:InitNotificationSound 写音效", + S1.NotificationSound == "ding" && S1.Notifications.isEmpty() + ) + val S2 = ReduceNotifications( + ReduceNotifications(S1, AddNotification("n1")), + AddNotification("n2"), + ) + Check( + "切片 Notifications:AddNotification 追加到末尾", + S2.Notifications == listOf("n1", "n2") + ) + Check( + "切片 Notifications:RemoveNotifications 按谓词过滤", + ReduceNotifications(S2, RemoveNotifications { it == "n1" }).Notifications == + listOf("n2") + ) + Check( + "切片 Notifications:RemoveFirstNotification 丢掉第一条", + ReduceNotifications(S2, RemoveFirstNotification).Notifications == + listOf("n2") + ) + Check("切片 Notifications:无关动作不改状态", ReduceNotifications(S2, AddFrags(1)) == S2) + + val Root0 = RootState() + val Root1 = ReduceRoot(Root0, AddNotification("x")) + Check( + "根:AddNotification 只动 notifications", + Root1.Notifications.Notifications == listOf("x") && Root1.News == Root0.News + ) + val W = RootWiring.ByField("notifications")!! + Check( + "根:notifications 委派到 jJ / B_.ise 且 4 个动作全部具名", + W.SliceClass == "jJ" && W.Reducer == "B_.ise" && + W.Actions.toSet() == setOf( + "AddNotification", "InitNotificationSound", + "RemoveFirstNotification", "RemoveNotifications", + ) + ) + } + + // ㉛ 切片 reducer:Quests(Frt / Yrt.otg,3 字段;GoTo 按目标屏幕切分区) + run { + val S0 = QuestsStateSlice() + Check( + "切片 Quests:默认值完整", + S0.CurrentSection == QuestSection.SPECIAL && S0.CurrentCategory == null && + !S0.IsNotCompletedQuests + ) + Check( + "切片 Quests:SetCurrentCategory 写分类", + ReduceQuests(S0, SetCurrentCategory("cat-1")).CurrentCategory == "cat-1" + ) + Check( + "切片 Quests:SetNotCompletedQuests 收口为 false", + !ReduceQuests(QuestsStateSlice(IsNotCompletedQuests = true), SetNotCompletedQuests) + .IsNotCompletedQuests + ) + Check( + "切片 Quests:屏幕名按原包拼接", + QuestCategoryScreen(QuestSection.SPECIAL, 7L).name == + "Lobby.Quests.QuestCategory.SPECIAL.7" && + ChallengeScreen(null).name == "Lobby.Quests.Challenge" + ) + val S1 = ReduceQuests(S0, GoTo(QuestCategoryScreen(QuestSection.CONTRACT, 42L), false)) + Check( + "切片 Quests:GoTo(QuestCategory) 切到目标分区", + S1.CurrentSection == QuestSection.CONTRACT + ) + val S2 = ReduceQuests(S1, GoTo(ChallengeScreen(null), false)) + Check( + "切片 Quests:GoTo(Challenge) 切到 CHALLENGE", + S2.CurrentSection == QuestSection.CHALLENGE + ) + Check( + "切片 Quests:其他屏幕的 GoTo 不改状态", + ReduceQuests(S2, GoTo(NamedScreen("Lobby.Home"), true)) == S2 + ) + + val Root0 = RootState() + val Root1 = ReduceRoot(Root0, GoTo(QuestCategoryScreen(QuestSection.CHALLENGE, null), false)) + Check( + "根:GoTo 只动 quests 的分区", + Root1.Quests.CurrentSection == QuestSection.CHALLENGE && + Root1.Notifications == Root0.Notifications + ) + val W = RootWiring.ByField("quests")!! + Check( + "根:quests 委派到 Frt / Yrt.otg,屏幕标识类不算动作", + W.SliceClass == "Frt" && W.Reducer == "Yrt.otg" && + W.Actions == listOf("GoTo", "SetCurrentCategory", "SetNotCompletedQuests") + ) + } + val Failed = Results.filter { !it.second } println("TankiOffline 状态层自检:${Results.size} 项") for ((Name, Ok) in Results) println((if (Ok) " ✓ " else " ✗ ") + Name)