feat(client): M6 车库最小界面(浏览器只读),实测显示 15/28/515/992

browser 分支改为单页界面:连接区(URL 可改)→ 仓库物品(15 件,标注已挂载/未挂载) / 挂载位(28 槽位 + 515 设备,当前装备全 null) / 市场(992 件,只渲染前 20) / 日志区(最近 50 条,人类可读名)。状态更新用「状态容器 + 手动重渲染根节点」(未用 hooks)——全页只有一份全局会话状态。新增 GarageSession.kt(浏览器侧驱动,复用 AutoResponder 与 ClientState 的靶推导);M5 GarageDriver 的 node-only 逻辑(读抓包/日志/NodeRequire)没有拖进浏览器路径。验收:三构建成功(注意 dev 与 prod 系列要分开跑,否则 Gradle 报隐式依赖);浏览器实测已进空间 100073070、面板显示 15/28/515/992、日志含 G.q1wd/LoadObjectsData/ls.xbl/ba.lcr/devicesLoaded;服务端 session_069 佐证浏览器的上行被逐条收到;浏览器 Origin(http://127.0.0.1:8123) 被接受——服务端只记录不校验 Origin(index.js:484-489),当前无需改动。
This commit is contained in:
TermiNexus committed 2026-09-18 11:50:19 +08:00
1 parent c0360810b7
commit 8964ea28cd
3 files changed
+389 -26

No files matched your search

+92 -22
View File
@@ -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<String> by lazy {
val Report = CompressionSelfTest.RunEmbedded()
val Lines = ArrayList<String>()
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<Props> {
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 }
}
}
// 把根组件渲染到 index.html 中 id 为 root 的挂载点
// ---------------- 5 日志区(最近 MaxLogLines 条收发记录)
div {
className = ClassName("Card")
h2 { +"日志:最近 ${Session.Logs.size} 条" }
ul {
className = ClassName("Plain Log")
for (Line in Session.Logs) li { +Line }
}
}
}
}
// 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 上
@@ -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<String>()
private val Queue = ArrayList<QueuedUplink>()
// 靶推导的缓存(LsDerived/BaDerived 只在收到第一帧 pt 时算一次)
private var LsDerived: List<String> = emptyList()
private var BaDerived: List<String> = emptyList()
private var LsEnqueued = false
private var LsSent = 0
private var BaEnqueued = false
// ---------------------------------------------------------------- 只读快照(React 渲染直接读这些)
val Logs: List<String> get() = LogLines
val DepotItems: List<String> get() = StateObj.ItemsOnDepot
val MountedItems: List<String> get() = StateObj.MountedItems
val MarketItems: List<String> get() = StateObj.ItemsOnMarket
val Slots: List<DevicesLoadedEntry> 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",
)
}
}
+21 -4
View File
@@ -3,15 +3,32 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TankIcClient</title>
<title>TankIcClient · 车库</title>
<style>
/* 自检结果用 <pre> 展示,允许折行以免长行溢出 */
pre { white-space: pre-wrap; word-break: break-all; }
body { margin: 0; padding: 16px; background: #14181f; color: #d7dde6;
font-family: "Cascadia Mono", Consolas, "Microsoft YaHei", monospace; }
h1 { font-size: 20px; margin: 0 0 12px; }
h2 { font-size: 14px; margin: 0 0 8px; color: #8fb7ff; }
p { margin: 4px 0; }
/* 面板:每块一个卡片,避免整页挤成一坨 */
.Card { border: 1px solid #2b3442; border-radius: 6px; padding: 10px 12px; margin-bottom: 12px; }
.Row { display: flex; gap: 8px; align-items: center; margin-bottom: 6px; }
.UrlInput { flex: 0 0 360px; background: #0f1319; color: #d7dde6;
border: 1px solid #2b3442; border-radius: 4px; padding: 4px 6px; }
button { background: #24405f; color: #d7dde6; border: 1px solid #3b6ea5;
border-radius: 4px; padding: 5px 10px; cursor: pointer; }
button:hover { background: #2d5078; }
ul { margin: 0; padding-left: 18px; }
li { line-height: 1.5; }
/* 市场 id 列表与日志:去掉项目符号、等宽排版,长行折行 */
ul.Plain { list-style: none; padding-left: 0; }
ul.Plain li { white-space: pre-wrap; word-break: break-all; font-size: 12px; color: #b9c4d2; }
ul.Log { max-height: 420px; overflow: auto; }
</style>
</head>
<body>
<!-- React 挂载点,与 Main.kt 中的 id 保持一致。
页面加载即自动跑一次「压缩帧解压自检」(内嵌真实压缩帧),结果同时渲染到这里与 console。 -->
连接与刷新由页面上的「连接并进车库(100073070)」按钮触发,数据全部来自 WebSocket。 -->
<div id="root"></div>
<!-- webpack 产物名由 build.gradle.kts 的 outputFileName 决定 -->
<script src="TankIcClient.js"></script>