From a64530761b2dfeb482ab370430c4605e98ecb2bf Mon Sep 17 00:00:00 2001 From: TermiNexus Date: Thu, 22 Oct 2026 12:47:51 +0800 Subject: [PATCH] =?UTF-8?q?feat(state):=20=E7=A7=BB=E6=A4=8D=20Redux=20?= =?UTF-8?q?=E5=86=85=E6=A0=B8=EF=BC=88Store/=E4=B8=AD=E9=97=B4=E4=BB=B6/?= =?UTF-8?q?=E7=BD=91=E5=85=B3=EF=BC=89=E5=B9=B6=E5=8A=A0=2010=20=E9=A1=B9?= =?UTF-8?q?=E8=87=AA=E6=A3=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对照 module_10849 的 toString 证据实现:vt=ReduxStore(归约+监听器+中间件链)、yt=ReduxToModelGateway(按动作类先通知、再交给 Store;模型/键/游戏对象三级订阅,重复订阅 fail-fast)。 自检 TANKIOFFLINE_STATE_TEST=1:归约累加、通知次数与新状态可见性、中间件由外向内顺序、动作监听器先于归约、模型订阅/退订、重复订阅抛错、派发经过调度器,共 10 项全通过。 state 模块补 js(IR){browser();nodejs()} 使 :app 可依赖;新增 tools/RunStateTest.ps1。 --- TankiOffline/app/build.gradle.kts | 1 + .../app/src/jsMain/kotlin/App/Main.kt | 4 + .../app/src/jsMain/kotlin/App/StateCheck.kt | 120 +++++++++++ TankiOffline/state/build.gradle.kts | 5 +- .../state/src/jsMain/kotlin/State/Redux.kt | 189 ++++++++++++++++++ TankiOffline/tools/RunStateTest.ps1 | 12 ++ 6 files changed, 330 insertions(+), 1 deletion(-) create mode 100644 TankiOffline/app/src/jsMain/kotlin/App/StateCheck.kt create mode 100644 TankiOffline/state/src/jsMain/kotlin/State/Redux.kt create mode 100644 TankiOffline/tools/RunStateTest.ps1 diff --git a/TankiOffline/app/build.gradle.kts b/TankiOffline/app/build.gradle.kts index c7c055d..59c68c4 100644 --- a/TankiOffline/app/build.gradle.kts +++ b/TankiOffline/app/build.gradle.kts @@ -14,6 +14,7 @@ kotlin { dependencies { implementation(project(":core")) implementation(project(":net")) + implementation(project(":state")) // 自动测试用 ws 客户端:需要自定义 User-Agent(真实服务器会拒绝 Node 默认 UA) implementation(npm("ws", "8.18.0")) } diff --git a/TankiOffline/app/src/jsMain/kotlin/App/Main.kt b/TankiOffline/app/src/jsMain/kotlin/App/Main.kt index 4542125..c6a6769 100644 --- a/TankiOffline/app/src/jsMain/kotlin/App/Main.kt +++ b/TankiOffline/app/src/jsMain/kotlin/App/Main.kt @@ -234,6 +234,10 @@ object Replay { fun main() { if (!IsNode()) return + if (StateCheck.Wanted()) { + StateCheck.Run() + return + } if (Replay.Wanted()) { Replay.Run() return diff --git a/TankiOffline/app/src/jsMain/kotlin/App/StateCheck.kt b/TankiOffline/app/src/jsMain/kotlin/App/StateCheck.kt new file mode 100644 index 0000000..561a21b --- /dev/null +++ b/TankiOffline/app/src/jsMain/kotlin/App/StateCheck.kt @@ -0,0 +1,120 @@ +// StateCheck.kt —— 状态层内核自检(Node 下运行,对应 S3 的 Redux 胶水) +// +// 覆盖点(都是原 module_10849 的实际语义): +// ① Store:派发 → 归约 → 通知监听器,监听器看到的是新状态 +// ② 中间件:列表靠后的包在更外层(调用顺序先外后内,返回顺序反过来) +// ③ 网关:按动作类的监听器在状态更新之前触发 +// ④ 网关:模型订阅可退订;重复订阅直接失败 +// ⑤ 网关:调度器负责把派发排进引擎循环(这里用同步调度器验证) +// +// 用法:$env:TANKIOFFLINE_STATE_TEST='1'; node …(见 TankiOffline/tools/RunStateTest.ps1) +package App + +import State.Action +import State.Middleware +import State.ReduxGateway +import State.ReduxStore +import State.StoreListener + +private data class AddFrags(val fragsCount: Int) : Action +private data class SetScreen(val screen: String) : Action + +private data class UiState(val Frags: Int = 0, val Screen: String = "Preloader") + +private val ExitWith: (Int) -> Unit = js("(function (C) { process.exit(C); })") + +object StateCheck { + + fun Wanted(): Boolean = js("process.env.TANKIOFFLINE_STATE_TEST === '1'") as Boolean + + fun Run() { + val Results = ArrayList>() + fun Check(Name: String, Condition: Boolean) { Results.add(Name to Condition) } + + // ① Store:归约 + 监听器 + run { + val Store = ReduxStore(UiState(), { S, A -> + when (A) { + is AddFrags -> S.copy(Frags = S.Frags + A.fragsCount) + is SetScreen -> S.copy(Screen = A.screen) + else -> S + } + }) + var Notified = 0 + var Seen = -1 + Store.Subscribe(StoreListener { Notified++; Seen = Store.State.Frags }) + Store.Dispatch(AddFrags(3)) + Store.Dispatch(AddFrags(4)) + Check("Store 归约:两次派发累加", Store.State.Frags == 7) + Check("Store 通知:每次状态变化通知一次", Notified == 2) + Check("Store 通知:监听器看到的是新状态", Seen == 7) + } + + // ② 中间件顺序 + run { + val Log = ArrayList() + val First = Middleware { _, Next -> + { A -> Log.add("First.In"); val R = Next(A); Log.add("First.Out"); R } + } + val Second = Middleware { _, Next -> + { A -> Log.add("Second.In"); val R = Next(A); Log.add("Second.Out"); R } + } + val Store = ReduxStore(UiState(), { S, A -> if (A is AddFrags) S.copy(Frags = S.Frags + A.fragsCount) else S }, listOf(First, Second)) + Store.Dispatch(AddFrags(1)) + Check("中间件:靠后的包在更外层", Log == listOf("Second.In", "First.In", "First.Out", "Second.Out")) + } + + // ③ 网关:动作监听器先于状态更新 + run { + val Store = ReduxStore(UiState(), { S, A -> + if (A is AddFrags) S.copy(Frags = S.Frags + A.fragsCount) else S + }) + val Gateway = ReduxGateway(Store) { Task -> Task() } + var FragsAtListener = -1 + Gateway.OnAction(AddFrags::class) { FragsAtListener = Gateway.StoreState.Frags } + Gateway.Dispatch(AddFrags(5)) + Check("网关:动作监听器在归约之前触发", FragsAtListener == 0) + Check("网关:派发后状态已更新", Gateway.StoreState.Frags == 5) + } + + // ④ 模型订阅 / 退订 / 重复订阅 + run { + val Store = ReduxStore(UiState(), { S, A -> S }) + val Gateway = ReduxGateway(Store) { Task -> Task() } + val Model = Any() + var Hits = 0 + Gateway.SubscribeModel(Model) { Scope -> Scope.Listen(SetScreen::class) { Hits++ } } + Gateway.Dispatch(SetScreen("Lobby")) + val AfterFirst = Hits + Gateway.UnsubscribeModel(Model) + Gateway.Dispatch(SetScreen("Garage")) + Check("网关:模型订阅收到动作", AfterFirst == 1) + Check("网关:退订后不再收到动作", Hits == 1) + + val Twin = Any() + Gateway.SubscribeModel(Twin) { } + val DuplicateThrew = try { + Gateway.SubscribeModel(Twin) { } + false + } catch (E: IllegalArgumentException) { + true + } + Check("网关:重复订阅模型直接失败", DuplicateThrew) + } + + // ⑤ 调度器:派发确实经过调度器 + run { + val Store = ReduxStore(UiState(), { S, A -> if (A is AddFrags) S.copy(Frags = S.Frags + A.fragsCount) else S }) + var Scheduled = 0 + val Gateway = ReduxGateway(Store) { Task -> Scheduled++; Task() } + Gateway.Dispatch(AddFrags(2)) + Check("网关:派发经过调度器", Scheduled == 1 && Store.State.Frags == 2) + } + + val Failed = Results.filter { !it.second } + println("TankiOffline 状态层自检:${Results.size} 项") + for ((Name, Ok) in Results) println((if (Ok) " ✓ " else " ✗ ") + Name) + println(if (Failed.isEmpty()) "结果:通过" else "结果:失败 ${Failed.size} 项") + ExitWith(if (Failed.isEmpty()) 0 else 1) + } +} diff --git a/TankiOffline/state/build.gradle.kts b/TankiOffline/state/build.gradle.kts index 0690cc9..629d92e 100644 --- a/TankiOffline/state/build.gradle.kts +++ b/TankiOffline/state/build.gradle.kts @@ -4,5 +4,8 @@ plugins { } kotlin { - js(IR) {} + js(IR) { + browser() + nodejs() + } } diff --git a/TankiOffline/state/src/jsMain/kotlin/State/Redux.kt b/TankiOffline/state/src/jsMain/kotlin/State/Redux.kt new file mode 100644 index 0000000..65ef1ff --- /dev/null +++ b/TankiOffline/state/src/jsMain/kotlin/State/Redux.kt @@ -0,0 +1,189 @@ +// Redux.kt —— 状态层内核(对应原 module_10849 的 Redux 引擎) +// +// 原包结构(已按 toString 证据核实): +// vt = Redux Store:`(state, reducer, middlewares)`,reduxState 由 laq_1(action, state) 归约, +// qaq(action) = 走中间件链派发,raq/saq = 订阅/退订 +// yt = ReduxToModelGateway:uar(action) 把派发排进调度器; +// 派发前先按「动作类」通知监听器,再交给 Store; +// 另外维护 模型/键/游戏对象 三级订阅表(nar/oar/lar) +// ft/Ct = 派生值(selector)与组件订阅,S3 暂不移植(UI 层才需要) +// +// 这里的实现保持同样语义: +// · 动作以 Kotlin 类为键(等价原包的构造函数作 Map key) +// · 中间件顺序与原包一致:列表里靠后的中间件包在更外层 +// · 监听器在状态更新之后被通知;网关的按动作监听器在状态更新之前被通知 +package State + +import kotlin.reflect.KClass + +/** 动作基类:与原包一致,动作是不可变数据类(Kotlin data class)。 */ +interface Action + +/** Store 订阅者:状态变化后回调(对应原 `vt.maq_1` 里那些对象的 `kaq`)。 */ +fun interface StoreListener { + fun OnStoreChanged() +} + +/** 中间件:签名等价原包的 `o(store, next) -> dispatch`。 */ +fun interface Middleware { + fun Invoke(Store: ReduxStore, Next: (Action) -> Action): (Action) -> Action +} + +/** + * Redux Store(原 `vt`)。 + * + * @param InitialState 初始状态 + * @param Reducer 归约函数,签名与原包 `laq_1(action, state)` 一致 + * @param Middlewares 中间件列表;实现里按列表顺序依次包裹,靠后的在更外层 + */ +class ReduxStore( + InitialState: S, + private val Reducer: (S, Action) -> S, + Middlewares: List> = emptyList(), +) { + var State: S = InitialState + private set + + private val Listeners = ArrayList() + private val DispatchChain: (Action) -> Action + + init { + // 原 `vt` 构造里的 invokeReducer:先归约,再逐个通知监听器,最后把动作原样返回 + var Chain: (Action) -> Action = { A -> + State = Reducer(State, A) + for (L in Listeners.toList()) L.OnStoreChanged() + A + } + for (M in Middlewares) Chain = M.Invoke(this, Chain) + DispatchChain = Chain + } + + fun Dispatch(A: Action): Action = DispatchChain(A) + + fun Subscribe(L: StoreListener) { + Listeners.add(L) + } + + fun Unsubscribe(L: StoreListener) { + Listeners.remove(L) + } + + val ListenerCount: Int get() = Listeners.size +} + +/** 网关里「按模型订阅」的作用域:收集该模型的按动作监听器,退订时一起摘掉。 */ +class ModelActionScope internal constructor(private val Gateway: ReduxGateway<*>) { + internal val Removers = ArrayList<() -> Unit>() + + fun Listen(Type: KClass, Listener: (Action) -> Unit) { + Removers.add(Gateway.OnAction(Type, Listener)) + } +} + +/** + * Redux 到模型的网关(原 `yt`)。 + * + * 派发路径:`Dispatch` → 调度器 → `StoreDispatch`(按动作类通知监听器)→ Store 归约 → Store 监听器。 + * 这与原包一致:动作监听器先于状态更新被调用。 + * + * @param Scheduler 调度器,把任务排进引擎循环;离线自检里可以直接同步执行 + */ +class ReduxGateway( + private val Store: ReduxStore, + private val Scheduler: (() -> Unit) -> Unit, +) { + private val ActionListeners = LinkedHashMap, MutableList<(Action) -> Unit>>() + private val ModelScopes = LinkedHashMap Unit>>() + private val KeyScopes = LinkedHashMap Unit>>() + private val GameObjectScopes = LinkedHashMap Unit>>() + private val OnInitCallbacks = ArrayList<(ReduxGateway) -> Unit>() + private var Initialized = false + + val StoreState: S get() = Store.State + + /** 原 `uar`:把派发排进调度器(调度器负责错误隔离)。 */ + fun Dispatch(A: Action) { + Scheduler { StoreDispatch(A) } + } + + /** 原 `gas` 返回的派发包装:先按动作类通知,再交给 Store。 */ + fun StoreDispatch(A: Action) { + val List = ActionListeners[A::class] + if (List != null) for (L in List.toList()) L(A) + Store.Dispatch(A) + } + + /** 注册按动作类的监听器,返回摘除函数(原 `sar` / `tar`)。 */ + fun OnAction(Type: KClass, Listener: (Action) -> Unit): () -> Unit { + ActionListeners.getOrPut(Type) { ArrayList() }.add(Listener) + return { ActionListeners[Type]?.remove(Listener) } + } + + /** 原 `nar`:按模型订阅,重复订阅直接失败。 */ + fun SubscribeModel(Model: Any, Body: (ModelActionScope) -> Unit) { + require(!ModelScopes.containsKey(Model)) { "Model already subscribed! $Model" } + val Scope = ModelActionScope(this) + Body(Scope) + ModelScopes[Model] = Scope.Removers + } + + /** 原 `rar`。 */ + fun UnsubscribeModel(Model: Any) { + ModelScopes.remove(Model)?.forEach { it() } + } + + /** 原 `oar`。 */ + fun SubscribeKey(Key: Any, Body: (ModelActionScope) -> Unit) { + require(!KeyScopes.containsKey(Key)) { "Key already subscribed! $Key" } + val Scope = ModelActionScope(this) + Body(Scope) + KeyScopes[Key] = Scope.Removers + } + + /** 原 `par`。 */ + fun UnsubscribeKey(Key: Any) { + KeyScopes.remove(Key)?.forEach { it() } + } + + /** 原 `lar`:按游戏对象订阅。 */ + fun SubscribeGameObject(GameObjectId: Long, Body: (ModelActionScope) -> Unit) { + require(!GameObjectScopes.containsKey(GameObjectId)) { "GameObject already subscribed! modelId=$GameObjectId" } + val Scope = ModelActionScope(this) + Body(Scope) + GameObjectScopes[GameObjectId] = Scope.Removers + } + + /** 原 `qar`。 */ + fun UnsubscribeGameObject(GameObjectId: Long) { + GameObjectScopes.remove(GameObjectId)?.forEach { it() } + } + + /** 原 `mar`。 */ + fun HasModel(Model: Any): Boolean = ModelScopes.containsKey(Model) + + /** 原 `var`:注册「网关初始化完成」回调,若已初始化则立即执行。 */ + fun OnInit(Callback: (ReduxGateway) -> Unit) { + if (Initialized) Callback(this) else OnInitCallbacks.add(Callback) + } + + /** 原 `o7j`:初始化完成,清空订阅表并回调等待者。 */ + fun Initialize() { + // 与原包一致:初始化之前就有订阅(动作/模型/键/游戏对象)视为用法错误 + if (!Initialized && + (ActionListeners.isNotEmpty() || ModelScopes.isNotEmpty() || + KeyScopes.isNotEmpty() || GameObjectScopes.isNotEmpty()) + ) { + throw IllegalStateException("ReduxToModelGateway has subscriptions before it initialized") + } + ActionListeners.clear() + ModelScopes.clear() + KeyScopes.clear() + GameObjectScopes.clear() + Initialized = true + val Waiters = OnInitCallbacks.toList() + OnInitCallbacks.clear() + for (C in Waiters) C(this) + } + + val IsInitialized: Boolean get() = Initialized +} diff --git a/TankiOffline/tools/RunStateTest.ps1 b/TankiOffline/tools/RunStateTest.ps1 new file mode 100644 index 0000000..1793999 --- /dev/null +++ b/TankiOffline/tools/RunStateTest.ps1 @@ -0,0 +1,12 @@ +# RunStateTest.ps1 —— 跑状态层内核自检(Store / 中间件 / 网关) +# +# 覆盖:归约、监听器通知、中间件顺序、动作监听器时序、模型订阅退订、调度器。 +# 用法:powershell -ExecutionPolicy Bypass -File .\tools\RunStateTest.ps1 +$ErrorActionPreference = 'Stop' + +$Root = Split-Path -Parent $PSScriptRoot +$env:TANKIOFFLINE_STATE_TEST = '1' + +Write-Host '运行状态层自检(TANKIOFFLINE_STATE_TEST=1)' +& (Join-Path $Root 'gradlew.bat') ':app:jsNodeDevelopmentRun' '--console=plain' +exit $LASTEXITCODE