feat(state): 移植 Redux 内核(Store/中间件/网关)并加 10 项自检

对照 module_10849 的 toString 证据实现:vt=ReduxStore(归约+监听器+中间件链)、yt=ReduxToModelGateway(按动作类先通知、再交给 Store;模型/键/游戏对象三级订阅,重复订阅 fail-fast)。

自检 TANKIOFFLINE_STATE_TEST=1:归约累加、通知次数与新状态可见性、中间件由外向内顺序、动作监听器先于归约、模型订阅/退订、重复订阅抛错、派发经过调度器,共 10 项全通过。

state 模块补 js(IR){browser();nodejs()} 使 :app 可依赖;新增 tools/RunStateTest.ps1。
This commit is contained in:
TermiNexus committed 2026-10-22 12:47:51 +08:00
1 parent 7d8ceedee2
commit a64530761b
6 files changed
+330 -1

No files matched your search

+1
View File
@@ -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"))
}
@@ -234,6 +234,10 @@ object Replay {
fun main() {
if (!IsNode()) return
if (StateCheck.Wanted()) {
StateCheck.Run()
return
}
if (Replay.Wanted()) {
Replay.Run()
return
@@ -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<Pair<String, Boolean>>()
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<String>()
val First = Middleware<UiState> { _, Next ->
{ A -> Log.add("First.In"); val R = Next(A); Log.add("First.Out"); R }
}
val Second = Middleware<UiState> { _, 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)
}
}
+4 -1
View File
@@ -4,5 +4,8 @@ plugins {
}
kotlin {
js(IR) {}
js(IR) {
browser()
nodejs()
}
}
@@ -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<S> {
fun Invoke(Store: ReduxStore<S>, Next: (Action) -> Action): (Action) -> Action
}
/**
* Redux Store(原 `vt`)。
*
* @param InitialState 初始状态
* @param Reducer 归约函数,签名与原包 `laq_1(action, state)` 一致
* @param Middlewares 中间件列表;实现里按列表顺序依次包裹,靠后的在更外层
*/
class ReduxStore<S>(
InitialState: S,
private val Reducer: (S, Action) -> S,
Middlewares: List<Middleware<S>> = emptyList(),
) {
var State: S = InitialState
private set
private val Listeners = ArrayList<StoreListener>()
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<out Action>, Listener: (Action) -> Unit) {
Removers.add(Gateway.OnAction(Type, Listener))
}
}
/**
* Redux 到模型的网关(原 `yt`)。
*
* 派发路径:`Dispatch` → 调度器 → `StoreDispatch`(按动作类通知监听器)→ Store 归约 → Store 监听器。
* 这与原包一致:动作监听器先于状态更新被调用。
*
* @param Scheduler 调度器,把任务排进引擎循环;离线自检里可以直接同步执行
*/
class ReduxGateway<S>(
private val Store: ReduxStore<S>,
private val Scheduler: (() -> Unit) -> Unit,
) {
private val ActionListeners = LinkedHashMap<KClass<out Action>, MutableList<(Action) -> Unit>>()
private val ModelScopes = LinkedHashMap<Any, MutableList<() -> Unit>>()
private val KeyScopes = LinkedHashMap<Any, MutableList<() -> Unit>>()
private val GameObjectScopes = LinkedHashMap<Long, MutableList<() -> Unit>>()
private val OnInitCallbacks = ArrayList<(ReduxGateway<S>) -> 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<out Action>, 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<S>) -> 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
}
+12
View File
@@ -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