diff --git a/client/src/jsMain/kotlin/Main.kt b/client/src/jsMain/kotlin/Main.kt index c41f00e..831f392 100644 --- a/client/src/jsMain/kotlin/Main.kt +++ b/client/src/jsMain/kotlin/Main.kt @@ -1,51 +1,121 @@ -// TankIcClient 客户端入口:用 kotlin-react 渲染一个最小但真实的页面 +// TankIcClient 客户端入口 +// +// node 分支(jsNodeDevelopmentRun):M1–M5 的协议层自检,保持原样不动。 +// browser 分支:M6 的最小「车库」只读界面 —— 把 M5 已经能拿到的语义状态(ClientState)渲染出来。 +// +// 界面刷新方式(M6 选的是「状态容器 + 手动重渲染」这条,没用 useState/useEffect): +// GarageSession 每次收到帧 / 发出上行都会回调 OnChanged,这里把根节点再 render 一次, +// React 按元素类型做协调、复用同一批 DOM 节点(所以输入框里已输入的内容不会被清掉)。 +// 为什么不走 hooks:本页面只有一份全局会话状态,用根节点重渲染语义最简单,也不引入 hook 依赖数组的心智负担。 package TankIcClient import react.FC import react.Props import react.create import react.dom.client.createRoot +import react.dom.html.ReactHTML.button import react.dom.html.ReactHTML.div import react.dom.html.ReactHTML.h1 +import react.dom.html.ReactHTML.h2 +import react.dom.html.ReactHTML.input +import react.dom.html.ReactHTML.li import react.dom.html.ReactHTML.p -import react.dom.html.ReactHTML.pre +import react.dom.html.ReactHTML.ul +import web.cssom.ClassName import web.dom.ElementId import web.dom.document import web.html.HTMLElement import kotlinx.browser.window -import Protocol.CompressionSelfTest import Protocol.ContainerDump import Protocol.GarageDriver +import Protocol.GarageSession import Protocol.IsNodeRuntime import Protocol.SelfTest -// 浏览器侧压缩帧解压自检:页面加载即自动跑一次(同步),结果同时打到 console 与页面。 -// lazy:RenderApp 会被 window.TankIcClientMain 再调一次,避免重复解压。 -private val BrowserSelfTestLines: List by lazy { - val Report = CompressionSelfTest.RunEmbedded() - val Lines = ArrayList() - Lines.add("压缩帧解压自检(fflate.unzlibSync,与 node 侧同一份实现)") - Lines.addAll(Report.Lines) - Lines.add(if (Report.Passed) "结论:✓ 通过" else "结论:✗ 未通过") - // Kotlin/JS 的 println 就是 console.log(这里直接逐行输出到 console) - for (L in Lines) println("TankIcClient | $L") - Lines -} +// 全局会话状态容器:node 分支不会用到它(构造时不碰任何浏览器 API) +private val Session = GarageSession { RenderApp() } -// 页面根组件:展示脚手架就绪信息、Kotlin/JS 版本号,以及上面那次解压自检的逐项结果 +// 页面根组件:只读渲染 Session 的快照 private val App = FC { div { - h1 { +"TankIcClient 脚手架就绪(M3:压缩帧解压)" } - p { +"当前 Kotlin/JS 版本:${KotlinVersion.CURRENT}" } - pre { +BrowserSelfTestLines.joinToString("\n") } + h1 { +"TankIcClient · M6 车库(只读)" } + + // ---------------- 1 连接区 + div { + className = ClassName("Card") + h2 { +"连接" } + div { + className = ClassName("Row") + input { + id = ElementId("ServerUrl") + className = ClassName("UrlInput") + defaultValue = Session.Url + onChange = { E -> Session.Url = E.target.value } + } + button { + onClick = { Session.Connect() } + +"连接并进车库(100073070)" + } + } + p { +"当前状态:${Session.Status}" } + if (Session.LastError.isNotEmpty()) p { +"错误原因:${Session.LastError}" } + } + + // ---------------- 2 仓库物品面板(itemsOnDepot) + div { + className = ClassName("Card") + h2 { +"仓库物品(itemsOnDepot):${Session.DepotItems.size} 件" } + val Mounted = Session.MountedItems.toHashSet() + ul { + for (Id in Session.DepotItems) { + li { +("$Id " + (if (Mounted.contains(Id)) "已挂载" else "未挂载")) } + } + } + if (Session.DepotItems.isEmpty()) p { +"(尚未收到 itemsOnDepot;挂载依据 mountedItems 集合,共 ${Session.MountedItems.size} 件)" } + } + + // ---------------- 3 挂载位面板(devicesLoaded) + div { + className = ClassName("Card") + h2 { +"挂载位(devicesLoaded):槽位 ${Session.Slots.size} 个 / 设备 ${Session.DeviceCount} 个" } + ul { + for (D in Session.Slots) { + li { +("槽位 ${D.SlotObjectId} 设备 ${D.Devices.size} 个 当前装备:" + (D.MountedDevice ?: "未装备")) } + } + } + if (Session.Slots.isEmpty()) p { +"(尚未收到 devicesLoaded)" } + } + + // ---------------- 4 市场面板(itemsOnMarket,只渲染前 20 个 id) + div { + className = ClassName("Card") + h2 { +"市场(itemsOnMarket):共 ${Session.MarketItems.size} 件(只渲染前 20 个 id)" } + ul { + className = ClassName("Plain") + for (Id in Session.MarketItems.take(20)) li { +Id } + } + } + + // ---------------- 5 日志区(最近 MaxLogLines 条收发记录) + div { + className = ClassName("Card") + h2 { +"日志:最近 ${Session.Logs.size} 条" } + ul { + className = ClassName("Plain Log") + for (Line in Session.Logs) li { +Line } + } + } } } -// 把根组件渲染到 index.html 中 id 为 root 的挂载点 +// React 根节点只建一次;之后每次刷新都是对同一个根 render 一个新元素 +private var Root: dynamic = null + private fun RenderApp() { - val container = document.getElementById(ElementId("root")) as? HTMLElement + val Container = document.getElementById(ElementId("root")) as? HTMLElement ?: error("未找到 id 为 root 的挂载节点") - createRoot(container).render(App.create()) + if (Root == null) Root = createRoot(Container) + Root.render(App.create()) } // 入口函数:Node 下跑协议层自检;浏览器下渲染页面并把入口挂到 window 上 diff --git a/client/src/jsMain/kotlin/Protocol/GarageSession.kt b/client/src/jsMain/kotlin/Protocol/GarageSession.kt new file mode 100644 index 0000000..1c9ea57 --- /dev/null +++ b/client/src/jsMain/kotlin/Protocol/GarageSession.kt @@ -0,0 +1,276 @@ +// GarageSession.kt —— M6:浏览器侧的车库会话驱动(只读,供 React 界面渲染) +// +// 与 M5 GarageDriver.kt 的分工(为什么不直接搬 GarageDriver 进浏览器): +// · GarageDriver 是 node-only 自检:读抓包文件比对、读服务端日志、ExitProcess —— 浏览器里没有 NodeRequire; +// · 本类只保留浏览器跑得动的部分:Connection(WHATWG WebSocket)+ AutoResponder + ClientState; +// · 靶 id 的推导规则本身在 ClientState(LsXblTargets / BaLcrTargets),两端同一份实现,本类不重写规则; +// · 上行节拍发送沿用 M5 的口径(一次上行最多推一帧),让每条下行的触发者唯一,便于与服务端日志对照。 +// +// 数据流:连接 → Hash Request → Hash Response → Auth/EnterSpace(100073070) +// → s2c zt(LoadDependencies, X) → AutoResponder 自动回 G.q1wd(X) +// → s2c pt(LoadObjectsData) → 推导 ls.xbl 靶(负数对象 id)→ 逐条发 → s2c lt(UnloadObjects) +// → 推导 ba.lcr 靶(挂载位)→ 逐条发 → s2c zt×28 → 自动回 G.q1wd×28 → s2c pt+devicesLoaded +// +// 界面刷新方式:本类持有一个 OnChanged 回调,每次收到帧 / 发出上行后调用一次; +// Main.kt 用「状态容器 + 手动重渲染根节点」的方式消费它(见 Main.kt 顶部说明)。 +package Protocol + +// 待发送的一条上行(队列里排队等节拍;与 M5 的 PendingUplink 同名会冲突,故另起名) +internal class QueuedUplink(val Name: String, val TargetId: String, val Bytes: ByteArray) + +internal class GarageSession(private val OnChanged: () -> Unit) { + + var Url: String = DefaultUrl + var SpaceId: Long = DefaultSpaceId + + // 界面「当前状态」:未连接 / 握手中 / 已进空间 100073070 / 错误… + var Status: String = "未连接" + private set + var LastError: String = "" + private set + + private var Conn: Connection? = null + private var Auto: AutoResponder? = null + private var StateObj: ClientState = ClientState() + + private var FrameNo = 0 + private var EnterSent = false + private var TickStarted = false + + private val LogLines = ArrayList() + private val Queue = ArrayList() + + // 靶推导的缓存(LsDerived/BaDerived 只在收到第一帧 pt 时算一次) + private var LsDerived: List = emptyList() + private var BaDerived: List = emptyList() + private var LsEnqueued = false + private var LsSent = 0 + private var BaEnqueued = false + + // ---------------------------------------------------------------- 只读快照(React 渲染直接读这些) + + val Logs: List get() = LogLines + val DepotItems: List get() = StateObj.ItemsOnDepot + val MountedItems: List get() = StateObj.MountedItems + val MarketItems: List get() = StateObj.ItemsOnMarket + val Slots: List get() = StateObj.DevicesRecords + val DeviceCount: Int get() = StateObj.DeviceCount + + // ---------------------------------------------------------------- 连接 + + fun Connect() { + Conn?.Close(1000, "重新连接") + LogLines.clear() + Queue.clear() + StateObj = ClientState() + FrameNo = 0 + EnterSent = false + LastError = "" + LsDerived = emptyList() + BaDerived = emptyList() + LsEnqueued = false + LsSent = 0 + BaEnqueued = false + Status = "握手中" + AddLog("→ 建立连接 $Url(目标空间 $SpaceId)") + + val C = Connection(Url) + Conn = C + Auto = AutoResponder(SpaceId) { Bytes -> EnqueueAuto(Bytes) } + C.OnOpen = { OnOpen() } + C.OnMessage = { OnMessage(it) } + C.OnClose = { Code -> OnClose(Code) } + C.OnError = { Msg -> + LastError = Msg + Status = "错误:$Msg" + AddLog("✗ $Msg") + OnChanged() + } + StartTickOnce() + C.Connect() + OnChanged() + } + + private fun OnOpen() { + Status = "握手中(已连接,发 Hash Request)" + val Req = Entry.BuildHashRequest(SelfTest.HashKeys, SelfTest.HashValues) + AddLog("→ c2s Hash Request ${Req.size}B") + Conn?.Send(Req) + OnChanged() + } + + private fun OnClose(Code: Int) { + AddLog("连接关闭 code=$Code") + if (!Status.startsWith("已进空间")) Status = "未连接(连接关闭 code=$Code)" + OnChanged() + } + + // ---------------------------------------------------------------- 收帧 + + private fun OnMessage(Raw: ByteArray) { + FrameNo++ + if (Frame.IsPingFrame(Raw)) { + AddLog("← s2c 保活 ping ${Raw.size}B") + OnChanged() + return + } + var Off = 0 + while (Off < Raw.size) { + val Info = Frame.PeekFrame(Raw, Off) ?: break + val Body = try { + Frame.DecodeFrame(WireFrame(Raw.copyOfRange(Off + Info.HeaderSize, Off + Info.TotalSize), Info.Compressed)) + } catch (E: ProtocolException) { + AddLog("✗ 帧#$FrameNo ${Info.TotalSize}B 解压失败:${E.message}") + OnChanged() + return + } + HandleFrame(Info.TotalSize, Body) + Off += Info.TotalSize + } + OnChanged() + } + + private fun HandleFrame(TotalBytes: Int, Body: ByteArray) { + val NmSize = Nullmap.Size(Body, 0) + if (NmSize >= Body.size) { + AddLog("← s2c 空负载帧 ${TotalBytes}B") + return + } + val MethodIdAt8 = if (Body.size >= NmSize + 16) Body.ReadI64(NmSize + 8).toString() else "" + val IsSpace = ProtocolTables.Downlink(MethodIdAt8) != null || ProtocolTables.CodecByMethod(MethodIdAt8) != null + if (!IsSpace) { + HandleEntryFrame(TotalBytes, Body) + return + } + val Parsed = SpaceFrame.Parse(Body) + // 先落状态(M5 的语义容器),再让 AutoResponder 应答(应答只是入队,由 Tick 节拍发出) + StateObj.OnSpaceFrame(Parsed, FrameNo, TotalBytes) + Auto?.OnSpaceFrame(Parsed, FrameNo) + val Names = Parsed.Records.map { RecordLabel(it) } + AddLog("← s2c ${Names.distinct().joinToString(" + ")} ${TotalBytes}B 记录${Parsed.Records.size}") + if (Parsed.Bad) AddLog(" ⚠ ${Parsed.Note}") + HandlePhases(Parsed) + } + + private fun HandleEntryFrame(TotalBytes: Int, Body: ByteArray) { + for (C in Entry.DecodeServerBody(Body)) { + AddLog("← s2c 入口 ${C.Name} ${TotalBytes}B") + if (C.Id == Entry.S2C_HASH_RESPONSE && C.Digest != null && !EnterSent) { + EnterSent = true + val Req = Entry.BuildEnterSpace(C.Digest, SpaceId) + AddLog("→ c2s Auth/EnterSpace($SpaceId) ${Req.size}B") + Conn?.Send(Req) + Status = "已进空间 $SpaceId" + } + } + } + + // 收到的帧里出现 pt(LoadObjectsData)就推导靶;推到的 ba.lcr 靶等 ls.xbl 发完再入队(顺序同真实会话) + private fun HandlePhases(Parsed: SpaceFrameBody) { + var HasPt = false + var HasDevices = false + for (R in Parsed.Records) { + if (R.MethodId == Uplink.LOAD_OBJECTS_DATA.toString()) HasPt = true + if (R.MethodId == M_DEVICES_LOADED) HasDevices = true + } + if (HasPt && !LsEnqueued) { + LsEnqueued = true + LsDerived = StateObj.LsXblTargets() + BaDerived = StateObj.BaLcrTargets() + AddLog("⟳ 由 pt 推导:ls.xbl 靶 ${LsDerived.size} 个(负数对象 id) / ba.lcr 靶 ${BaDerived.size} 个(挂载位)") + for (Id in LsDerived) EnqueueManual(Uplink.LS_XBL, Id, Uplink.BuildLsXbl(Id.toLong())) + } + if (HasPt && HasDevices) { + AddLog("✓ pt + devicesLoaded 同帧 → 车库数据到齐(槽位 ${Slots.size} / 设备 $DeviceCount)") + } + } + + // ---------------------------------------------------------------- 上行队列与节拍 + + private fun StartTickOnce() { + if (TickStarted) return + TickStarted = true + SetIntervalJs(TickMs) { Tick() } + } + + private fun Tick() { + if (Queue.isNotEmpty() && Conn?.ReadyState == 1) { + val P = Queue.removeAt(0) + Conn?.Send(P.Bytes) + AddLog("→ c2s ${P.Name} ${P.Bytes.size}B 靶=${P.TargetId}") + if (P.Name == "ls.xbl") LsSent++ + OnChanged() + } + if (LsEnqueued && !BaEnqueued && LsSent >= LsDerived.size) { + BaEnqueued = true + for (Id in BaDerived) EnqueueManual(Uplink.BA_LCR, Id, Uplink.BuildBaLcr(Id.toLong())) + AddLog("⟳ ls.xbl 已发完 → 排入 ba.lcr ${BaDerived.size} 条(靶=推导结果)") + OnChanged() + } + } + + // AutoResponder 只给字节,名字/靶/X 从这里解出来(G.q1wd 的 X 显示成 G.q1wd(X)) + private fun EnqueueAuto(Bytes: ByteArray) { + val D = DescribeUplink(Bytes) + val Base = UplinkName(D.MethodId) + Queue.add(QueuedUplink(if (D.X != null) "$Base(${D.X})" else Base, D.TargetId, Bytes)) + } + + private fun EnqueueManual(MethodId: Long, TargetId: String, Bytes: ByteArray) { + Queue.add(QueuedUplink(UplinkName(MethodId.toString()), TargetId, Bytes)) + } + + // ---------------------------------------------------------------- 日志与命名 + + private fun AddLog(Line: String) { + LogLines.add(Line) + while (LogLines.size > MaxLogLines) LogLines.removeAt(0) + // 同一份日志镜像到浏览器 console(Kotlin/JS 的 println 就是 console.log),方便运维侧留痕 + println("TankIcClient | $Line") + } + + // 上行人类可读名:UplinkByMethod 的 Name(G.q1wd / ls.xbl / ba.lcr),查不到写「未知」 + private fun UplinkName(MethodId: String): String = + ProtocolTables.Uplink(MethodId)?.Name?.ifEmpty { null } ?: "未知" + + // 下行记录人类可读名:先认 M5 语义容器那 4 条(协议表里它们的 Name 是短类名,没法读), + // 其余按 DownlinkByMethod 的 Name,查不到写「未知」 + private fun RecordLabel(R: SpaceRecord): String { + SemanticDownlink[R.MethodId]?.let { return it } + val Spec = ProtocolTables.Downlink(R.MethodId) + ?: return if (R.Kind == "container") "容器${R.Layout}" else "未知" + val Tail = if (Spec.Kind == "container") "/${Spec.Layout}" else "" + return Spec.Name.ifEmpty { "未知" } + Tail + } + + // 上行帧(整帧)→ 靶 id / methodId / G.q1wd 的 X + private class UplinkDesc(val TargetId: String, val MethodId: String, val X: Int?) + + private fun DescribeUplink(Bytes: ByteArray): UplinkDesc { + val Frames = Frame.ParseFrames(Bytes) + if (Frames.isNullOrEmpty()) return UplinkDesc("?", "?", null) + val Body = Frames[0].Body + val Nm = Nullmap.Size(Body, 0) + if (Nm + 16 > Body.size) return UplinkDesc("?", "?", null) + val Target = Body.ReadI64(Nm).toString() + val Method = Body.ReadI64(Nm + 8).toString() + val X = if (Method == Uplink.G_Q1WD.toString() && Nm + 20 <= Body.size) Body.ReadI32(Nm + 16) else null + return UplinkDesc(Target, Method, X) + } + + private companion object { + const val DefaultUrl = "ws://localhost:9091/" + const val DefaultSpaceId = 100073070L + const val TickMs = 120 + const val MaxLogLines = 50 + const val M_DEVICES_LOADED = "7615142300796401818" + + // M5 语义容器认识的那几条(协议表里 Name 是短类名,这里给人读名) + val SemanticDownlink = mapOf( + "3996477900528000389" to "itemsOnDepot", + "5236393599344825589" to "itemsOnMarket", + "3692495084100053385" to "mountedItems", + "7615142300796401818" to "devicesLoaded", + ) + } +} diff --git a/client/src/jsMain/resources/index.html b/client/src/jsMain/resources/index.html index 51bb263..d1cfbb2 100644 --- a/client/src/jsMain/resources/index.html +++ b/client/src/jsMain/resources/index.html @@ -3,15 +3,32 @@ - TankIcClient + TankIcClient · 车库 + 连接与刷新由页面上的「连接并进车库(100073070)」按钮触发,数据全部来自 WebSocket。 -->