feat(state): 迁移 news/notifications/quests 切片

This commit is contained in:
TermiNexus committed 2026-10-24 13:05:13 +08:00
1 parent e66636c737
commit 792973ef5b
11 files changed
+556 -37

No files matched your search

+20 -6
View File
@@ -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<String, String>`,不新增 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@<field>)` 并从函数体抓到顶层动作名(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(输入)
+61 -13
View File
@@ -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",
+65
View File
@@ -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: [
{
@@ -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.<section>[.<id>]",
// 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.<section>[.<id>]`。 */
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"
}
@@ -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<NewsItemData> = emptyList(),
val UnreadNewsCounter: Int = 0,
val P: Boolean = false,
)
/* ------------------------------------------------------------------ reducer */
/**
* 原包的排序:`compareBy { -dateInSeconds }`。
* 用取负而不是 `compareByDescending`,是为了和原包 32 位取负(`0 | -x`)的溢出行为一致。
*/
private fun SortNews(News: List<NewsItemData>): List<NewsItemData> =
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
}
@@ -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<Any?> = 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
}
@@ -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.<section>[.<id>])→ 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
}
@@ -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),
)
}
@@ -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)。未迁移:原样透传。 */
@@ -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<String> = setOf("inviteState", "loginByPassword", "twitchState", "home", "localBattleUserState", "shop", "screen", "lootBoxes", "garage", "garagePreview", "entrance", "user", "settings", "accountSettings", "proBattle", "language", "announcement", "launchParams")
val Migrated: Set<String> = 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<SliceWiring> get() = All.filter { it.Field !in Migrated }
@@ -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<Any?>("n1", "n2")
)
Check(
"切片 Notifications:RemoveNotifications 按谓词过滤",
ReduceNotifications(S2, RemoveNotifications { it == "n1" }).Notifications ==
listOf<Any?>("n2")
)
Check(
"切片 Notifications:RemoveFirstNotification 丢掉第一条",
ReduceNotifications(S2, RemoveFirstNotification).Notifications ==
listOf<Any?>("n2")
)
Check("切片 Notifications:无关动作不改状态", ReduceNotifications(S2, AddFrags(1)) == S2)
val Root0 = RootState()
val Root1 = ReduceRoot(Root0, AddNotification("x"))
Check(
"根:AddNotification 只动 notifications",
Root1.Notifications.Notifications == listOf<Any?>("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)