Ship the WeChat hot-update catalogs and the camp, TideWhale T2, UI atlas, and animation-lab work.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
# AIServer
|
||||
|
||||
Windows Server 可上传 ZIP、前台运行、开机自启、反向代理与验证方法见 [部署说明](deploy/windows/README.md)。发布包使用独立端口 15888,不覆盖现有 Kestrel 服务;旧服务的账号和存档需单独迁移。
|
||||
|
||||
C++ 登录、WebSocket 网关与 G1 教学玩法服务(SQLite),协议兼容客户端 `HotUpdate.Net` / `PrototypeApi`。
|
||||
|
||||
## 接口
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Generate an actual snippet with New-CaddySite.ps1, or replace SERVER_NIC_IPV4.
|
||||
# Binding to a non-loopback NIC is required when the backend uses the same port.
|
||||
https://BuddyDefense.xworld.ren:15888 {
|
||||
bind SERVER_NIC_IPV4
|
||||
reverse_proxy 127.0.0.1:15888 {
|
||||
transport http {
|
||||
read_timeout 0
|
||||
write_timeout 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
[CmdletBinding(SupportsShouldProcess)]
|
||||
param(
|
||||
[ValidateRange(1,65535)][int]$Port = 15888,
|
||||
[string]$TaskName = 'AIGame-AIServer',
|
||||
[switch]$NoStart
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$serverRoot = $PSScriptRoot
|
||||
$executable = Join-Path $serverRoot 'aiserver.exe'
|
||||
if (-not (Test-Path -LiteralPath $executable)) { throw 'Unzip the complete release before installing.' }
|
||||
# Caddy may use this port on a NIC, but must not occupy wildcard/loopback addresses.
|
||||
$listeners = @(Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue)
|
||||
foreach ($listener in $listeners) {
|
||||
if ($listener.LocalAddress -notin @('0.0.0.0','::','127.0.0.1')) { continue }
|
||||
$owner = Get-CimInstance Win32_Process -Filter ("ProcessId={0}" -f $listener.OwningProcess)
|
||||
if ($listener.LocalAddress -eq '127.0.0.1' -and $owner.ExecutablePath -and
|
||||
[IO.Path]::GetFullPath($owner.ExecutablePath) -eq [IO.Path]::GetFullPath($executable)) { continue }
|
||||
throw "Port $Port at $($listener.LocalAddress) is occupied by $($owner.Name) (PID $($listener.OwningProcess)). Configure Caddy bind to the NIC IPv4, validate/reload Caddy, then retry."
|
||||
}
|
||||
$powershell = Join-Path $env:SystemRoot 'System32/WindowsPowerShell/v1.0/powershell.exe'
|
||||
$runner = Join-Path $serverRoot 'Run-Server.ps1'
|
||||
$arguments = '-NoProfile -NonInteractive -ExecutionPolicy Bypass -File "{0}" -Port {1}' -f $runner,$Port
|
||||
$action = New-ScheduledTaskAction -Execute $powershell -Argument $arguments -WorkingDirectory $serverRoot
|
||||
$trigger = New-ScheduledTaskTrigger -AtStartup
|
||||
$principal = New-ScheduledTaskPrincipal -UserId 'S-1-5-19' -LogonType ServiceAccount -RunLevel Limited
|
||||
$settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit ([TimeSpan]::Zero) `
|
||||
-RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -MultipleInstances IgnoreNew `
|
||||
-StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
|
||||
if ($PSCmdlet.ShouldProcess($TaskName, "Register AIServer startup task on loopback port $Port")) {
|
||||
$data = Join-Path $serverRoot 'data'; $logs = Join-Path $serverRoot 'logs'
|
||||
New-Item -ItemType Directory -Path $data,$logs -Force | Out-Null
|
||||
& icacls.exe $serverRoot /grant '*S-1-5-19:(OI)(CI)RX' | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Cannot grant executable read permission to Local Service.' }
|
||||
foreach ($directory in @($data,$logs)) {
|
||||
& icacls.exe $directory /grant '*S-1-5-19:(OI)(CI)M' | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { throw "Cannot grant write permission: $directory" }
|
||||
}
|
||||
Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger -Principal $principal `
|
||||
-Settings $settings -Description 'AIGame HTTP, gameplay and WebSocket server' -Force | Out-Null
|
||||
if ($NoStart) { Write-Output "Installed task $TaskName; not started (-NoStart)."; return }
|
||||
Start-ScheduledTask -TaskName $TaskName
|
||||
$healthy = $false
|
||||
for ($attempt=0; $attempt -lt 25; $attempt++) {
|
||||
try {
|
||||
$health = Invoke-RestMethod -Uri "http://127.0.0.1:$Port/health" -TimeoutSec 1
|
||||
if ($health.ok -and $health.service -eq 'aiserver' -and $health.gameProtocol -eq 1) { $healthy=$true; break }
|
||||
} catch { }
|
||||
Start-Sleep -Milliseconds 200
|
||||
}
|
||||
if (-not $healthy) {
|
||||
throw "Task $TaskName was registered but AIServer is not healthy on 127.0.0.1:$Port. Check logs/ and Get-ScheduledTaskInfo -TaskName $TaskName. An HTTP 400 may mean this port is still handled by Caddy HTTPS."
|
||||
}
|
||||
Write-Output "AIServer healthy at http://127.0.0.1:$Port/health; startup task $TaskName installed."
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
[CmdletBinding()]
|
||||
param([string]$ListenIp = '', [string]$OutputPath = '')
|
||||
$ErrorActionPreference = 'Stop'
|
||||
if (-not $ListenIp) {
|
||||
$interfaces = @(Get-NetIPConfiguration | Where-Object { $_.IPv4DefaultGateway -and $_.IPv4Address })
|
||||
$addresses = @($interfaces | ForEach-Object { $_.IPv4Address.IPAddress } | Select-Object -Unique)
|
||||
if ($addresses.Count -ne 1) { throw 'Specify -ListenIp with the non-loopback IPv4 address assigned to this server NIC.' }
|
||||
$ListenIp = $addresses[0]
|
||||
}
|
||||
$address = $null
|
||||
if (-not [Net.IPAddress]::TryParse($ListenIp,[ref]$address) -or
|
||||
$address.AddressFamily -ne [Net.Sockets.AddressFamily]::InterNetwork -or
|
||||
[Net.IPAddress]::IsLoopback($address) -or $ListenIp -eq '0.0.0.0') {
|
||||
throw 'Use an assigned non-loopback IPv4 NIC address; do not use 0.0.0.0.'
|
||||
}
|
||||
if (-not (Get-NetIPAddress -AddressFamily IPv4 -IPAddress $ListenIp -ErrorAction SilentlyContinue)) {
|
||||
throw "$ListenIp is not assigned to this server. On cloud hosts, use the NIC private IPv4, not the NAT public IPv4."
|
||||
}
|
||||
if (-not $OutputPath) { $OutputPath = Join-Path $PSScriptRoot 'BuddyDefense.caddyfile' }
|
||||
$site = @"
|
||||
https://BuddyDefense.xworld.ren:15888 {
|
||||
bind $ListenIp
|
||||
reverse_proxy 127.0.0.1:15888 {
|
||||
transport http {
|
||||
read_timeout 0
|
||||
write_timeout 0
|
||||
}
|
||||
}
|
||||
}
|
||||
"@
|
||||
[IO.File]::WriteAllText([IO.Path]::GetFullPath($OutputPath),$site,(New-Object Text.UTF8Encoding($false)))
|
||||
Write-Output "Generated $OutputPath. Merge this site into the existing Caddyfile, then validate and reload."
|
||||
Write-Output $site
|
||||
@@ -0,0 +1,88 @@
|
||||
# Windows Server 发布与启动
|
||||
|
||||
该包为 Windows x64 AIServer,提供登录、WebSocket、`/game/state` 和 `/game/command`,玩法配置已编译进 exe。使用静态 VC 运行库,不需要安装 .NET 或 Visual Studio。建议 Windows Server 2016 或更新版本。
|
||||
|
||||
## 1. 上传
|
||||
|
||||
将 ZIP 解压到服务器 `C:\Services\AIGame`。不要放到网站静态资源目录,也不要覆盖现有 Kestrel 服务文件。该目录应有 `aiserver.exe`、以下脚本与本文档。
|
||||
|
||||
本包不含任何开发账号数据库。首次启动创建 `data\aiserver.db`;后续更新保留该文件及其 `-wal` / `-shm` 配套文件,先停服再替换 exe。已有线上 ASP.NET/Kestrel 的账号、token 和存档不会自动迁入本服务。若已有正式玩家,需要先做数据迁移;不要将旧后端的数据库直接改名覆盖。本机已在小游戏中创建的游客账号可以在新服务中重新注册,但不会自动带入旧服存档。
|
||||
|
||||
## 2. 首次前台运行
|
||||
|
||||
打开 PowerShell:
|
||||
|
||||
```powershell
|
||||
cd C:\Services\AIGame
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\Run-Server.ps1
|
||||
```
|
||||
|
||||
另开窗口检查:
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\Services\AIGame\Test-Server.ps1
|
||||
```
|
||||
|
||||
默认只监听 `127.0.0.1:15888`。`/health` 应返回 `service=aiserver`、`gameProtocol=1`;无登录令牌访问 `/game/state` 返回 **401** 是正常的,404 表示反代仍指向旧后端。
|
||||
|
||||
## 3. 开机后台启动
|
||||
|
||||
先关闭前台测试进程,再以管理员身份打开 PowerShell:
|
||||
|
||||
```powershell
|
||||
cd C:\Services\AIGame
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\Install-StartupTask.ps1
|
||||
Get-ScheduledTask -TaskName AIGame-AIServer
|
||||
```
|
||||
|
||||
脚本注册计划任务,以 Local Service 运行,开机自动启动,退出后每分钟重试,日志写入 `logs\`;不是通过 `sc.exe create` 注册的原生 Windows Service。AIServer 只监听环回地址。公网 15888 由 Caddy 监听,需要在服务器防火墙和云安全组允许 TCP 15888。更换端口时使用 `-Port` 并同步反代。停止和卸载:
|
||||
|
||||
安装前会检查通配/环回地址的端口占用,启动后确认 `/health` 真正由 AIServer 返回,失败会报错;仅注册计划任务不能证明服务已运行。
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\Stop-Server.ps1
|
||||
# 如需同时移除开机任务:增加 -UninstallTask。数据库和日志保留。
|
||||
```
|
||||
|
||||
## 4. 将游戏域名切到新后端
|
||||
|
||||
客户端登录/玩法 HTTP:`https://BuddyDefense.xworld.ren:15888`;WebSocket:`wss://BuddyDefense.xworld.ren:15888/ws?pid=...&token=...`。
|
||||
|
||||
先给 `BuddyDefense.xworld.ren` 配置指向服务器公网 IPv4 的 DNS A 记录,再准备由 Caddy 管理的该域名有效证书。默认 ACME 需要公网 80/443 的挑战入口,已有 Caddy 继续统一管理证书。微信后台也需更新 request/socket 合法域名配置。
|
||||
|
||||
**本机 Caddy 与 AIServer 都使用 15888 时,必须分别绑定网卡 IP 和 127.0.0.1,不能让 Caddy 默认监听全部网卡。** 在服务器运行以下脚本,会自动识别唯一默认网关的 IPv4 网卡并生成配置片段;多网卡时使用 `-ListenIp` 指定:
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\New-CaddySite.ps1
|
||||
# 多网卡示例:-ListenIp 10.0.0.5(替换成服务器实际网卡 IPv4)
|
||||
```
|
||||
|
||||
将生成的 `BuddyDefense.caddyfile` 内容合入现有 Caddyfile。示意如下,`SERVER_NIC_IPV4` 必须替换为服务器实际网卡地址;云主机有 NAT 时使用网卡上的私网 IPv4,不要填写未分配给网卡的公网地址:
|
||||
|
||||
```caddyfile
|
||||
https://BuddyDefense.xworld.ren:15888 {
|
||||
bind SERVER_NIC_IPV4
|
||||
reverse_proxy 127.0.0.1:15888 {
|
||||
transport http {
|
||||
read_timeout 0
|
||||
write_timeout 0
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
对实际使用的 Caddyfile 执行 `caddy.exe validate --config <实际配置路径>`,通过后执行 `caddy.exe reload --config <实际配置路径>`。不要用示例文件替换整个多站点配置;如果当前入口为 IIS/Nginx,在该入口将游戏域名的完整路径反代到同一个地址并启用 WebSocket。
|
||||
|
||||
**`/register`、`/login`、`/ws`、`/game/state`、`/game/command` 必须指向同一个 AIServer。不能只转发 `/game/*`:旧 Kestrel 签发的 token 无法由新的 SQLite 服务验证。** `/health` 同样转发用于确认切换。
|
||||
|
||||
公网验证:
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\Test-Server.ps1 -BaseUrl https://BuddyDefense.xworld.ren:15888
|
||||
# 可选完整联调,会新增一个 deploy_probe_* 测试账号:
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\Test-Server.ps1 -BaseUrl https://BuddyDefense.xworld.ren:15888 -ExerciseAccount
|
||||
```
|
||||
|
||||
切换后,小游戏重新登录获取新服务的 token。旧服务和数据库暂时保留,便于必要时恢复原反代配置。不要将新服务对旧 token 返回的 401 与缺少路由的 404 混淆。
|
||||
|
||||
参考:[Windows 计划任务账户](https://learn.microsoft.com/en-us/powershell/module/scheduledtasks/new-scheduledtaskprincipal)、[任务重启设置](https://learn.microsoft.com/en-us/powershell/module/scheduledtasks/new-scheduledtasksettingsset)、[Caddy 反向代理](https://caddyserver.com/docs/caddyfile/directives/reverse_proxy)、[Caddy bind](https://caddyserver.com/docs/caddyfile/directives/bind)。
|
||||
@@ -0,0 +1,25 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[ValidateRange(1,65535)][int]$Port = 15888,
|
||||
[string]$ListenAddress = '127.0.0.1',
|
||||
[string]$DatabasePath = ''
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$serverRoot = $PSScriptRoot
|
||||
$executable = Join-Path $serverRoot 'aiserver.exe'
|
||||
if (-not (Test-Path -LiteralPath $executable)) { throw "Missing $executable" }
|
||||
if (-not $DatabasePath) { $DatabasePath = Join-Path $serverRoot 'data/aiserver.db' }
|
||||
$DatabasePath = [IO.Path]::GetFullPath($DatabasePath)
|
||||
$dataDirectory = Split-Path -Parent $DatabasePath
|
||||
$logs = Join-Path $serverRoot 'logs'
|
||||
New-Item -ItemType Directory -Path $dataDirectory,$logs -Force | Out-Null
|
||||
$log = Join-Path $logs ("aiserver-{0}.log" -f (Get-Date -Format yyyyMMdd))
|
||||
Push-Location $serverRoot
|
||||
try {
|
||||
"$(Get-Date -Format o) Starting AIServer on ${ListenAddress}:$Port" | Add-Content -LiteralPath $log
|
||||
& $executable --host $ListenAddress --port $Port --db $DatabasePath --daemon 2>&1 |
|
||||
Tee-Object -FilePath $log -Append
|
||||
$serverExit = $LASTEXITCODE
|
||||
if ($serverExit -eq 0) { $serverExit = 1 } # A daemon exiting should trigger task recovery.
|
||||
exit $serverExit
|
||||
} finally { Pop-Location }
|
||||
@@ -0,0 +1,15 @@
|
||||
[CmdletBinding(SupportsShouldProcess)]
|
||||
param([string]$TaskName = 'AIGame-AIServer', [switch]$UninstallTask)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$expected = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot 'aiserver.exe'))
|
||||
if ($PSCmdlet.ShouldProcess($expected, 'Stop this release and its startup task')) {
|
||||
$task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||
if ($task) {
|
||||
Stop-ScheduledTask -TaskName $TaskName
|
||||
if ($UninstallTask) { Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false }
|
||||
}
|
||||
Get-CimInstance Win32_Process -Filter "Name='aiserver.exe'" | Where-Object {
|
||||
$_.ExecutablePath -and [IO.Path]::GetFullPath($_.ExecutablePath) -eq $expected
|
||||
} | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }
|
||||
Write-Output 'Stopped. Database and logs preserved.'
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
[CmdletBinding()]
|
||||
param([string]$BaseUrl = 'http://127.0.0.1:15888', [switch]$ExerciseAccount)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$BaseUrl = $BaseUrl.TrimEnd('/')
|
||||
$health = Invoke-RestMethod -Uri "$BaseUrl/health" -TimeoutSec 10
|
||||
if (-not $health.ok -or $health.service -ne 'aiserver' -or $health.gameProtocol -ne 1) {
|
||||
throw 'Wrong backend or unsupported gameplay protocol.'
|
||||
}
|
||||
try {
|
||||
Invoke-WebRequest -Uri "$BaseUrl/game/state" -UseBasicParsing -TimeoutSec 10 | Out-Null
|
||||
throw 'Unauthenticated gameplay endpoint unexpectedly accepted the request.'
|
||||
} catch {
|
||||
if (-not $_.Exception.Response -or [int]$_.Exception.Response.StatusCode -ne 401) { throw }
|
||||
}
|
||||
Write-Output 'PASS health and gameplay route (unauthenticated state returns 401, not 404).'
|
||||
if ($ExerciseAccount) {
|
||||
# Explicit opt-in: this creates one fresh deployment-test account.
|
||||
$username = 'deploy_probe_' + [Guid]::NewGuid().ToString('N')
|
||||
$body = @{ username=$username; password=[Guid]::NewGuid().ToString('N') } | ConvertTo-Json -Compress
|
||||
$session = Invoke-RestMethod -Uri "$BaseUrl/register" -Method Post -ContentType 'application/json' -Body $body -TimeoutSec 10
|
||||
if (-not $session.token -or -not $session.pid) { throw 'Registration returned no session.' }
|
||||
$login = Invoke-RestMethod -Uri "$BaseUrl/login" -Method Post -ContentType 'application/json' -Body $body -TimeoutSec 10
|
||||
if (-not $login.token -or $login.pid -ne $session.pid) { throw 'Login did not return the registered identity.' }
|
||||
$session = $login
|
||||
$state = Invoke-RestMethod -Uri "$BaseUrl/game/state" -Headers @{Authorization="Bearer $($session.token)"} -TimeoutSec 15
|
||||
if (-not $state.state -or $state.state.schema -lt 14 -or -not $state.configVersion) { throw 'Incomplete gameplay snapshot.' }
|
||||
$wsBase = $BaseUrl -replace '^https:', 'wss:' -replace '^http:', 'ws:'
|
||||
$uri = [Uri]("$wsBase/ws?pid=$($session.pid)&token=$([Uri]::EscapeDataString($session.token))")
|
||||
$socket = New-Object System.Net.WebSockets.ClientWebSocket
|
||||
$timeout = New-Object System.Threading.CancellationTokenSource
|
||||
$timeout.CancelAfter(10000)
|
||||
try {
|
||||
[void]$socket.ConnectAsync($uri,$timeout.Token).GetAwaiter().GetResult()
|
||||
if ($socket.State -ne [Net.WebSockets.WebSocketState]::Open) { throw 'WebSocket did not open.' }
|
||||
[void]$socket.CloseAsync([Net.WebSockets.WebSocketCloseStatus]::NormalClosure,'probe complete',$timeout.Token).GetAwaiter().GetResult()
|
||||
} finally { $socket.Dispose(); $timeout.Dispose() }
|
||||
Write-Output "PASS registration, login, authenticated state (schema $($state.state.schema)) and WebSocket. Test pid=$($session.pid)."
|
||||
}
|
||||
@@ -240,6 +240,15 @@ void ws_loop(socket_t s) {
|
||||
buf.erase(buf.begin(), buf.begin() + static_cast<std::ptrdiff_t>(header + mask_len + payload_len));
|
||||
|
||||
if (opcode == 0x8) { // close
|
||||
// Complete the peer's close handshake before closing the TCP socket.
|
||||
std::vector<uint8_t> close_frame{0x88};
|
||||
if (payload.size() <= 125 && payload.size() != 1) {
|
||||
close_frame.push_back(static_cast<uint8_t>(payload.size()));
|
||||
close_frame.insert(close_frame.end(), payload.begin(), payload.end());
|
||||
} else {
|
||||
close_frame.insert(close_frame.end(), {2, 0x03, 0xEA}); // 1002 protocol error
|
||||
}
|
||||
send_all(s, reinterpret_cast<const char*>(close_frame.data()), close_frame.size());
|
||||
return;
|
||||
}
|
||||
if (opcode == 0x9) { // ping -> pong
|
||||
|
||||
Reference in New Issue
Block a user