From 0ca28ff9b2b0821358f6a7fbdcf4e9b7a6d99963 Mon Sep 17 00:00:00 2001 From: TermiNexus Date: Wed, 21 Oct 2026 00:38:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(autotest):=20=E6=96=B0=E5=A2=9E=E5=8F=8C?= =?UTF-8?q?=E8=BF=9E=E6=8E=A5=E6=8E=A5=E5=85=A5=E6=B5=8B=E8=AF=95=E4=B8=8E?= =?UTF-8?q?=E9=93=BE=E8=B7=AF=E5=AF=86=E9=92=A5=E6=8E=A2=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AutoTest:大厅 Hash Request → 取 digest/空间 id → 空间连接 EnterSpace → 收空间帧 - StreamDecoder:首条密文即穷举单字节链路密钥(K),之后 s2c 自动解密 - 首条空间帧到达后再加密发送 G.q1wd,服务端才会继续下发容器数据 - RunAutoTest.ps1:自动拉起大厅/空间两条中继,跑完自动回收 - 实测通过:握手成功、K 唯一命中、容器 1 条、回编码一致 --- TankiOffline/app/build.gradle.kts | 2 + .../app/src/jsMain/kotlin/App/AutoTest.kt | 230 ++++++++++++++++++ .../app/src/jsMain/kotlin/App/Main.kt | 4 + TankiOffline/deps.lock.json | 5 + .../src/jsMain/kotlin/Net/Protocol/Entry.kt | 117 +++++++++ .../kotlin/Net/Protocol/StreamDecoder.kt | 121 +++++++++ TankiOffline/tools/RunAutoTest.ps1 | 87 +++++++ 7 files changed, 566 insertions(+) create mode 100644 TankiOffline/app/src/jsMain/kotlin/App/AutoTest.kt create mode 100644 TankiOffline/net/src/jsMain/kotlin/Net/Protocol/Entry.kt create mode 100644 TankiOffline/net/src/jsMain/kotlin/Net/Protocol/StreamDecoder.kt create mode 100644 TankiOffline/tools/RunAutoTest.ps1 diff --git a/TankiOffline/app/build.gradle.kts b/TankiOffline/app/build.gradle.kts index dce0f6e..c7c055d 100644 --- a/TankiOffline/app/build.gradle.kts +++ b/TankiOffline/app/build.gradle.kts @@ -14,6 +14,8 @@ kotlin { dependencies { implementation(project(":core")) implementation(project(":net")) + // 自动测试用 ws 客户端:需要自定义 User-Agent(真实服务器会拒绝 Node 默认 UA) + implementation(npm("ws", "8.18.0")) } } } diff --git a/TankiOffline/app/src/jsMain/kotlin/App/AutoTest.kt b/TankiOffline/app/src/jsMain/kotlin/App/AutoTest.kt new file mode 100644 index 0000000..518edf8 --- /dev/null +++ b/TankiOffline/app/src/jsMain/kotlin/App/AutoTest.kt @@ -0,0 +1,230 @@ +// AutoTest.kt —— 接服务器自动化测试(Node 下运行) +// +// 真实链路是**两段连接**(与抓包 plain/space-* 的 c2s_0000 = EnterSpace 一致): +// ① 大厅连接(c2):Hash Request → Hash Response(digest + 分配的空间 id) +// ② 空间连接(c1):首帧直接 Auth/EnterSpace(digest, spaceId) → 开始收空间帧 +// +// 环境变量: +// TANKIOFFLINE_AUTOTEST=1 +// TANKIOFFLINE_AUTOTEST_URL 大厅中继,默认 ws://localhost:9091/ +// TANKIOFFLINE_AUTOTEST_SPACE_URL 空间中继,默认 ws://localhost:9092/ +// TANKIOFFLINE_AUTOTEST_SPACE 期望空间 id,默认 100073070(服务端会回实际分配值) +// TANKIOFFLINE_AUTOTEST_TIMEOUT 默认 20000(毫秒) +package App + +import Core.ByteReader +import Core.Nullmap +import Net.Generated.DownlinkRegistry +import Net.Protocol.Entry +import Net.Protocol.EntryEvent +import Net.Protocol.S2cDecoder +import Net.Protocol.SpaceFrame +import Net.Protocol.XorCipher + +// ws 包的客户端(可自定义握手头;Node 内置 WebSocket 不能改 User-Agent) +// 用 module.require 动态取构造器,避开 CommonJS/UMD 的 import 形态差异 +private val CreateWs: (String, dynamic) -> dynamic = + js("(function (U, O) { var W = module.require('ws'); return new W(U, O); })") + +private val MakeWsOptions: (String) -> dynamic = js( + "(function (Ua) { return { headers: { 'User-Agent': Ua }, perMessageDeflate: false }; })" +) + +private val SetTimeout: (() -> Unit, Int) -> Unit = js("(function (Fn, Ms) { setTimeout(Fn, Ms); })") + +private const val BrowserUserAgent = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36 Edg/153.0.0.0" + +private fun Exit(Code: Int) { + js("process.exit(Code)") +} + +object AutoTest { + + fun Wanted(): Boolean = js("process.env.TANKIOFFLINE_AUTOTEST === '1'") as Boolean + + private class Stats { + var Messages = 0 + var Frames = 0 + var Pings = 0 + var Records = 0 + var RoundTripOk = 0 + var RoundTripBad = 0 + var HeaderErrors = 0 + var DecodeErrors = 0 + var ShortCommands = 0 + var UnknownMethods = 0 + var Containers = 0 + var HashReceived = false + var EnterSent = false + var OpenSpace = false + var SpaceConnected = false + } + + private val Containers = setOf( + 3216143066888387731L, + 7640916300855664666L, + 9038756763225879447L, + 8179162314198150518L, + ) + + fun Run() { + val LobbyUrl = Env("TANKIOFFLINE_AUTOTEST_URL", "ws://localhost:9091/") + val SpaceUrl = Env("TANKIOFFLINE_AUTOTEST_SPACE_URL", "ws://localhost:9092/") + val WantSpace = Env("TANKIOFFLINE_AUTOTEST_SPACE", "100073070").toLong() + val TimeoutMs = Env("TANKIOFFLINE_AUTOTEST_TIMEOUT", "20000").toInt() + + val S = Stats() + val LobbyDecoder = S2cDecoder() + var SpaceDecoder: S2cDecoder? = null + var Socket: dynamic = null + var PendingDigest: ByteArray? = null + var AssignedSpace: Long? = null + var SpaceTarget = 0L + var C2sCipher: XorCipher? = null + var ObjectDataSent = false + val SpaceAliases = HashMap>() + + println("TankiOffline 自动测试:大厅=$LobbyUrl 空间=$SpaceUrl(请求空间 $WantSpace,超时 ${TimeoutMs}ms)") + + fun SendBytes(Bytes: ByteArray) { + Socket.send(Bytes) + } + + fun HandleSpaceBody(Body: ByteArray) { + val Nm = Nullmap.Size(Body, 0) + if (Nm >= Body.size) { + S.Pings++ + return + } + val R = SpaceFrame.DecodeBody(Body, DownlinkRegistry.ByMethodId, SpaceAliases) + S.Records += R.Records.size + S.ShortCommands += R.ShortCommandCount + S.UnknownMethods += R.UnknownMethodIds.size + val Err = R.Error + if (Err != null) { + if (Err.startsWith("记录头截断")) S.HeaderErrors++ else S.DecodeErrors++ + println(" ⚠ $Err") + } + for (Rec in R.Records) { + if (Rec.Payload.contentEquals(Rec.Reencoded)) S.RoundTripOk++ else { + S.RoundTripBad++ + println(" ⚠ methodId=${Rec.MethodId} 回编码不一致") + } + if (Rec.MethodId in Containers) S.Containers++ + } + } + + fun Finish() { + val Ok = S.HashReceived && S.EnterSent && S.Records > 0 && S.RoundTripBad == 0 + println("—— 自动测试结果 ——") + println("消息 ${S.Messages}|帧 ${S.Frames}|保活 ${S.Pings}|空间记录 ${S.Records}") + println("回编码一致 ${S.RoundTripOk}|不一致 ${S.RoundTripBad}") + println("容器记录 ${S.Containers}|短命令 ${S.ShortCommands}|未知 methodId ${S.UnknownMethods}") + println("记录头截断 ${S.HeaderErrors}|其它解码错误 ${S.DecodeErrors}") + println("握手:Hash=${S.HashReceived} Enter=${S.EnterSent} OpenSpace=${S.OpenSpace} 空间连接=${S.SpaceConnected} 密钥=${SpaceDecoder?.Key}") + println(if (Ok) "结果:通过" else "结果:失败") + try { + Socket.close() + } catch (_: Throwable) { + } + Exit(if (Ok) 0 else 1) + } + + // 打开一条连接;IsSpace=true 时首帧发 EnterSpace + fun Open(Url: String, IsSpace: Boolean) { + val Decoder = if (IsSpace) S2cDecoder().also { SpaceDecoder = it } else LobbyDecoder + val Ws: dynamic = CreateWs(Url, MakeWsOptions(BrowserUserAgent)) + Socket = Ws + if (IsSpace) S.SpaceConnected = true + + val OnOpen: (dynamic, dynamic) -> Unit = { _, _ -> + if (IsSpace) { + val Digest = PendingDigest + val Target = AssignedSpace ?: WantSpace + if (Digest == null) { + println("✗ 空间连接缺少 digest") + Exit(1) + } + S.EnterSent = true + SpaceTarget = Target + SendBytes(Entry.BuildEnterSpace(Digest!!, Target)) + println("→ [空间] Auth/EnterSpace($Target)") + } else { + println("→ [大厅] 已连接,发 Hash Request") + SendBytes(Entry.BuildHashRequest(Entry.HashKeys, Entry.HashValues)) + } + } + + val OnMessage: (dynamic, dynamic) -> Unit = { Data, _ -> + S.Messages++ + val Bytes = js("new Int8Array(Data)") as ByteArray + val Bodies = Decoder.Feed(Bytes) { Line -> println(" $Line") } + // 第一条空间帧到达(此时密钥已知)后再发 G.q1wd;太早发服务端会直接关连接。 + // 之后的上行必须用 c2s 方向的同一密钥(密钥表再 ^87)。 + val K = Decoder.Key + if (IsSpace && !ObjectDataSent && K != null) { + val C = XorCipher(K, true) + C2sCipher = C + val Req = C.Apply(Entry.BuildObjectDataRequest(SpaceTarget)) + SendBytes(Req) + ObjectDataSent = true + println("→ [空间] G.q1wd(1)(已加密,K=$K)") + } + for (B in Bodies) { + S.Frames++ + if (IsSpace) { + HandleSpaceBody(B) + continue + } + // 大厅:只处理入口命令 + val Nm = Nullmap.Size(B, 0) + if (Nm >= B.size) { + S.Pings++ + continue + } + var Reconnect = false + for (E in Entry.ParseServerBody(B)) { + when (E) { + is EntryEvent.HashResponse -> { + S.HashReceived = true + PendingDigest = E.Digest + println("← [大厅] Hash Response(加密标志=${E.Encrypted})") + } + is EntryEvent.OpenSpace -> { + S.OpenSpace = true + AssignedSpace = E.SpaceId + println("← [大厅] Open Space spaceId=${E.SpaceId}") + Reconnect = true + } + is EntryEvent.Unknown -> println("← [大厅] 未识别入口命令 id=${E.Id}") + } + } + if (Reconnect && PendingDigest != null) { + println("→ 关闭大厅连接,改连空间服务器") + try { + Ws.close() + } catch (_: Throwable) { + } + Open(SpaceUrl, true) + } + } + } + + val OnError: (dynamic, dynamic) -> Unit = { _, _ -> println("✗ WebSocket 错误($Url)") } + val OnClose: (dynamic, dynamic) -> Unit = { Code, _ -> println("连接关闭 code=$Code($Url)") } + Ws.on("open", OnOpen) + Ws.on("message", OnMessage) + Ws.on("error", OnError) + Ws.on("close", OnClose) + } + + Open(LobbyUrl, false) + SetTimeout({ Finish() }, TimeoutMs) + } + + private val EnvImpl: (String, String) -> String = + js("(function (N, D) { return process.env[N] || D; })") + + private fun Env(Name: String, Def: String): String = EnvImpl(Name, Def) +} diff --git a/TankiOffline/app/src/jsMain/kotlin/App/Main.kt b/TankiOffline/app/src/jsMain/kotlin/App/Main.kt index b2c17d6..863a061 100644 --- a/TankiOffline/app/src/jsMain/kotlin/App/Main.kt +++ b/TankiOffline/app/src/jsMain/kotlin/App/Main.kt @@ -122,6 +122,10 @@ private fun RunOne(Path: String, Registry: Map, S: Stats) { fun main() { if (!IsNode()) return + if (AutoTest.Wanted()) { + AutoTest.Run() + return + } // 默认抓包目录:从 cwd 逐级向上找 3DTank/packets(Gradle 运行 node 的 cwd 在 build/js/packages 下) val Root = js( diff --git a/TankiOffline/deps.lock.json b/TankiOffline/deps.lock.json index d68f8e9..b1801b1 100644 --- a/TankiOffline/deps.lock.json +++ b/TankiOffline/deps.lock.json @@ -42,6 +42,11 @@ "Module": "94965", "Evidence": "module_94965.js:305 var e = \"1.13.6\"", "Confidence": "已证实" + }, + "ws": { + "Version": "8.18.0", + "Evidence": "仅自动测试客户端使用:Node 内置 WebSocket 无法自定义 User-Agent,真实服务器会拒绝 node UA", + "Confidence": "测试依赖" } }, "KotlinEcosystem": { diff --git a/TankiOffline/net/src/jsMain/kotlin/Net/Protocol/Entry.kt b/TankiOffline/net/src/jsMain/kotlin/Net/Protocol/Entry.kt new file mode 100644 index 0000000..fc5e6af --- /dev/null +++ b/TankiOffline/net/src/jsMain/kotlin/Net/Protocol/Entry.kt @@ -0,0 +1,117 @@ +// Entry.kt —— 入口层(握手)命令 +// +// 依据:3DTank/tools/server/entry.js 与 module_61860 的入口帧处理。 +// c2s 1 Hash Request 体 = [1][varint N][N × 字符串][varint M][M × 字符串] +// s2c 2 Hash Response 体 = [2][32 字节 hash][1 字节 是否启用链路加密] +// c2s 3 Auth/EnterSpace 体 = [3][32 字节 digest][i64 spaceId] +// s2c 32 Open Space 体 = [32][i64 spaceId] +package Net.Protocol + +import Core.ByteReader +import Core.ByteWriter +import Core.Frame +import Core.Nullmap +import Core.ProtocolException + +sealed class EntryEvent { + class HashResponse(val Digest: ByteArray, val Encrypted: Boolean) : EntryEvent() + class OpenSpace(val SpaceId: Long) : EntryEvent() + class Unknown(val Id: Int, val Remaining: ByteArray) : EntryEvent() +} + +object Entry { + + const val C2S_HASH_REQUEST = 1 + const val S2C_HASH_RESPONSE = 2 + const val C2S_ENTER_SPACE = 3 + const val S2C_OPEN_SPACE = 32 + + fun BuildHashRequest(Keys: List, Values: List): ByteArray { + val W = ByteWriter() + W.WriteByte(C2S_HASH_REQUEST) + W.WriteVarint(Keys.size) + for (K in Keys) W.WriteString(K) + W.WriteVarint(Values.size) + for (V in Values) W.WriteString(V) + return Frame.EncodeFrame(W.ToByteArray()) + } + + fun BuildEnterSpace(Digest: ByteArray, SpaceId: Long): ByteArray { + if (Digest.size != 32) throw ProtocolException("EnterSpace 的 digest 必须是 32 字节,实际 ${Digest.size}") + val W = ByteWriter() + W.WriteByte(C2S_ENTER_SPACE) + W.WriteRaw(Digest) + W.WriteLong(SpaceId) + return Frame.EncodeFrame(W.ToByteArray()) + } + + // 空间连接建立后客户端要立即发的一条请求(G.q1wd): + // 抓包 space-100045260/c2s_0001 = [nullmap 00][objectId i64][methodId i64][i32 1] + // 不发这条,服务端只推一帧就正常关闭(code=1000)。 + fun BuildObjectDataRequest(SpaceId: Long, Index: Int = 1): ByteArray { + val W = ByteWriter() + W.WriteLong(SpaceId) + W.WriteLong(1816792453857564692L) + W.WriteInt(Index) + return Frame.EncodeFrame(W.ToByteArray()) + } + + // 一个入口帧体(nullmap + 若干入口命令)→ 事件列表 + fun ParseServerBody(Body: ByteArray): List { + val Out = ArrayList() + val Start = Nullmap.Size(Body, 0) + if (Start <= 0 || Start >= Body.size) return Out + val R = ByteReader(Body, Start) + while (R.Remaining > 0) { + val Id = R.ReadByte().toInt() and 0xff + when (Id) { + S2C_HASH_RESPONSE -> { + if (R.Remaining < 33) { + Out.add(EntryEvent.Unknown(Id, ByteArray(0))) + break + } + val Digest = R.ReadRaw(32) + val Flag = R.ReadByte().toInt() and 0xff + Out.add(EntryEvent.HashResponse(Digest, Flag != 0)) + } + S2C_OPEN_SPACE -> { + if (R.Remaining < 8) { + Out.add(EntryEvent.Unknown(Id, ByteArray(0))) + break + } + Out.add(EntryEvent.OpenSpace(R.ReadLong())) + } + else -> { + Out.add(EntryEvent.Unknown(Id, ByteArray(0))) + break + } + } + } + return Out + } + + // 与真实抓包一致的握手键/值;client_version 用当前基线 e98d0216 + val HashKeys = listOf( + "relay", "resources", "config-template", "lang", "browser_user_agent", "os", "device_type", + "client_type", "hardware_concurrency", "time_zone", "video_card", "mobile_device", + "touchscreen_support", "client_version", "ram_size", + ) + + val HashValues = listOf( + "0", + "http://localhost:8080/resources", + "http://localhost:8080/config.xml", + "cn", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36 Edg/153.0.0.0", + "Win32", + "Desktop", + "BROWSER", + "16", + "Asia/Shanghai", + "ANGLE (AMD, AMD Radeon(TM) 860M Graphics (0x00001114) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "false", + "false", + "e98d02161a563e043eabb96c6e4db5fedcf165e7", + "32768", + ) +} diff --git a/TankiOffline/net/src/jsMain/kotlin/Net/Protocol/StreamDecoder.kt b/TankiOffline/net/src/jsMain/kotlin/Net/Protocol/StreamDecoder.kt new file mode 100644 index 0000000..51f845c --- /dev/null +++ b/TankiOffline/net/src/jsMain/kotlin/Net/Protocol/StreamDecoder.kt @@ -0,0 +1,121 @@ +// StreamDecoder.kt —— s2c 方向的链路解码:明文段 → 密钥探测 → 自变体 XOR 流密码 +// +// 依据:3DTank/tools/wss_server.js 的 StreamDecoder/Cipher 与 module_61860 的链路编码器: +// 解密:p = c ^ key[idx];key[idx] = p;idx ^= (p & 7) +// key[i] = K ^ (i << 3)(s2c);c2s 再 ^ 87 +// Open Space 之后服务端切加密;密钥是单字节,用「解密后每条消息都必须是完整帧」来穷举。 +package Net.Protocol + +import Core.Frame + +class XorCipher(K: Int, Encoded: Boolean) { + + private val Slot = IntArray(8) { I -> ((K xor (I shl 3)) xor (if (Encoded) 87 else 0)) and 0xff } + private var Index = 0 + + fun Apply(Buf: ByteArray): ByteArray { + val Out = ByteArray(Buf.size) + for (I in Buf.indices) { + val Plain = (Buf[I].toInt() xor Slot[Index]) and 0xff + Out[I] = Plain.toByte() + Slot[Index] = Plain + Index = Index xor (Plain and 7) + } + return Out + } +} + +class S2cDecoder { + + private enum class Stage { Plain, Probing, Cipher } + + private var Current: Stage = Stage.Plain + private var Cipher: XorCipher? = null + private val Window = ArrayList() + + val Key: Int? get() = if (Current == Stage.Cipher) LastKey else null + private var LastKey: Int? = null + + // 返回解压后的帧体;处于密钥探测窗口内时返回空 + fun Feed(Buf: ByteArray, Log: (String) -> Unit): List { + when (Current) { + Stage.Plain -> { + val Frames = Frame.ParseFrames(Buf) + if (Frames != null) return DecodeFrames(Frames, Log) + Current = Stage.Probing + Window.clear() + Window.add(Buf) + Log("明文段结束,开始探测链路密钥(首条密文 ${Buf.size}B)") + // 关键:首条密文就要立刻穷举,否则服务端在等回复时会直接关连接 + return Probe(Log) + } + Stage.Probing -> { + Window.add(Buf) + return Probe(Log) + } + Stage.Cipher -> { + val Plain = Cipher!!.Apply(Buf) + val Frames = Frame.ParseFrames(Plain) + if (Frames == null) { + Log("密文帧解析失败(${Buf.size}B)") + return emptyList() + } + return DecodeFrames(Frames, Log) + } + } + return emptyList() + } + + // 在探测窗口上穷举单字节密钥;唯一命中即切到密文态并吐出窗口内所有帧 + private fun Probe(Log: (String) -> Unit): List { + val Hits = (0..255).filter { SeriesOk(Window, it) } + if (Hits.size == 1) { + val K = Hits[0] + LastKey = K + Cipher = XorCipher(K, false) + Current = Stage.Cipher + Log("链路密钥 K=$K(窗口 ${Window.size} 条)") + val Buffered = ArrayList(Window) + Window.clear() + val Out = ArrayList() + for (B in Buffered) Out.addAll(DecodeFrames(Frame.ParseFrames(Cipher!!.Apply(B)) ?: emptyList(), Log)) + return Out + } + val Bytes = Window.sumOf { it.size } + if (Window.size >= 8 || Bytes > 262144) { + Log("密钥探测失败(候选 ${Hits.size} 个),后续按非帧处理") + Current = Stage.Plain + val Buffered = ArrayList(Window) + Window.clear() + val Out = ArrayList() + for (B in Buffered) { + val F = Frame.ParseFrames(B) ?: continue + Out.addAll(DecodeFrames(F, Log)) + } + return Out + } + Log("密钥候选 ${Hits.size} 个,继续等待下一条") + return emptyList() + } + + // 同一密钥连续作用于窗口内所有消息(流密码状态连续) + private fun SeriesOk(Window: List, K: Int): Boolean { + val C = XorCipher(K, false) + for (B in Window) { + if (Frame.ParseFrames(C.Apply(B)) == null) return false + } + return true + } + + private fun DecodeFrames(Frames: List, Log: (String) -> Unit): List { + val Out = ArrayList(Frames.size) + for (F in Frames) { + try { + Out.add(Frame.DecodeFrame(F)) + } catch (E: Throwable) { + Log("帧解压失败:${E.message}") + } + } + return Out + } +} diff --git a/TankiOffline/tools/RunAutoTest.ps1 b/TankiOffline/tools/RunAutoTest.ps1 new file mode 100644 index 0000000..9ab906f --- /dev/null +++ b/TankiOffline/tools/RunAutoTest.ps1 @@ -0,0 +1,87 @@ +# RunAutoTest.ps1 —— 启动两条中继并跑 TankiOffline 接入测试 +# +# 真实链路是两段连接: +# 大厅:9091 → wss://c2.3dtank.com:9090/(Hash Request / Hash Response) +# 空间:9092 → wss://c2.3dtank.com:9090/(首帧 EnterSpace → 空间帧;实测 c2 才会回空间数据) +# +# 用法: +# powershell -ExecutionPolicy Bypass -File .\tools\RunAutoTest.ps1 +# powershell -ExecutionPolicy Bypass -File .\tools\RunAutoTest.ps1 -TimeoutSec 30 -SpaceId 100073070 +param( + [long]$SpaceId = 100073070, + [int]$TimeoutSec = 25, + [int]$LobbyPort = 9091, + [int]$SpacePort = 9092, + [string]$LobbyUpstream = 'wss://c2.3dtank.com:9090/', + [string]$SpaceUpstream = 'wss://c2.3dtank.com:9090/' +) + +$ErrorActionPreference = 'Stop' + +$Root = Split-Path -Parent $PSScriptRoot +$Tools = Join-Path (Split-Path -Parent $Root) '3DTank\tools' +$RelayLobbyOut = Join-Path $env:TEMP 'tankioffline-relay-lobby.out.log' +$RelayLobbyErr = Join-Path $env:TEMP 'tankioffline-relay-lobby.err.log' +$RelaySpaceOut = Join-Path $env:TEMP 'tankioffline-relay-space.out.log' +$RelaySpaceErr = Join-Path $env:TEMP 'tankioffline-relay-space.err.log' + +if (-not (Test-Path (Join-Path $Tools 'wss_server.js'))) { + throw "找不到中继脚本:$Tools\wss_server.js" +} + +function Start-Relay([int]$Port, [string]$Upstream, [string]$Out, [string]$Err, [string]$Label) { + $Args = @( + 'wss_server.js', + '--port', "$Port", + '--upstream', $Upstream, + '--dump-every', '0', + '--log', '0', + '--quiet', '1', + '--tunnel-reconnect', '0', + '--label', $Label + ) + Write-Host "启动中继 $Label:$Upstream → localhost:$Port" + return Start-Process -FilePath 'node' -ArgumentList $Args -WorkingDirectory $Tools -PassThru -WindowStyle Hidden ` + -RedirectStandardOutput $Out -RedirectStandardError $Err +} + +function Wait-Port([int]$Port, [string]$Name) { + for ($I = 0; $I -lt 50; $I++) { + Start-Sleep -Milliseconds 200 + try { + $Client = New-Object System.Net.Sockets.TcpClient + $Client.Connect('127.0.0.1', $Port) + $Client.Close() + return + } catch { + } + } + throw "$Name 端口 $Port 未启动" +} + +$RelayLobby = Start-Relay $LobbyPort $LobbyUpstream $RelayLobbyOut $RelayLobbyErr 'autotest-lobby' +$RelaySpace = Start-Relay $SpacePort $SpaceUpstream $RelaySpaceOut $RelaySpaceErr 'autotest-space' + +try { + Wait-Port $LobbyPort '大厅中继' + Wait-Port $SpacePort '空间中继' + + $env:TANKIOFFLINE_AUTOTEST = '1' + $env:TANKIOFFLINE_AUTOTEST_URL = "ws://localhost:$LobbyPort/" + $env:TANKIOFFLINE_AUTOTEST_SPACE_URL = "ws://localhost:$SpacePort/" + $env:TANKIOFFLINE_AUTOTEST_SPACE = "$SpaceId" + $env:TANKIOFFLINE_AUTOTEST_TIMEOUT = "$($TimeoutSec * 1000)" + + Write-Host "运行客户端:大厅 $($env:TANKIOFFLINE_AUTOTEST_URL) / 空间 $($env:TANKIOFFLINE_AUTOTEST_SPACE_URL)" + & (Join-Path $Root 'gradlew.bat') ':app:jsNodeDevelopmentRun' '--console=plain' + $Code = $LASTEXITCODE + Write-Host "客户端退出码:$Code" + exit $Code +} finally { + foreach ($P in @($RelayLobby, $RelaySpace)) { + if ($P -and -not $P.HasExited) { + Stop-Process -Id $P.Id -Force -ErrorAction SilentlyContinue + } + } + Write-Host '中继已停止' +}