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:
udx
2026-10-24 18:04:41 +08:00
co-authored by Cursor
parent 6ebc9401ce
commit 45e62132c8
1432 changed files with 2971856 additions and 2798 deletions
+11
View File
@@ -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."
}
+33
View File
@@ -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
+88
View File
@@ -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)。
+25
View File
@@ -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 }
+15
View File
@@ -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.'
}
+38
View File
@@ -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)."
}