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:
1 parent
7d8ceedee2
commit
a64530761b
6 files changed
+330
-1
No files matched your search
@@ -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,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
|
||||
}
|
||||
@@ -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
|
||||
Reference in new issue
Block a user