Files
Campus-25/server/PackageServer.ps1
2026-10-14 06:59:51 +02:00

354 lines
16 KiB
PowerShell

# ============================================================
# Build + package the 2025 Rec Room server (E12354 base, retargeted at
# gameauth.ddns.net + our Photon IDs) into campus-deploy.tar.gz.
#
# This is the SUCCESSOR to Campus2025Server\PackageServer.ps1 (which
# built the 2023 server). Uses the same tarball name so the existing
# DeployCampusServer.bat / GoLive.bat upload pipeline works unchanged;
# just point them at this folder.
# ============================================================
$ErrorActionPreference = "Stop"
Set-Location $PSScriptRoot
$src = "Server\2025-RecRoom-Server-E12354-main" # C# project folder inside this repo
$csproj = "$src\CampusServer.csproj"
$out = Join-Path $env:TEMP "campus-deploy-2025"
$pkg = "campus-deploy.tar.gz"
Write-Host ""
Write-Host " ==========================================="
Write-Host " Campus 2025 (E12354 base) - build + package"
Write-Host " ==========================================="
Write-Host ""
if (-not (Test-Path $csproj)) { Write-Host " [X] $csproj not found."; pause; exit 1 }
# Unique stamp for this package so DeployCampusServer.bat can confirm the VPS is
# actually running THIS package (not some stale prior build).
$tag = -join ((1..6) | ForEach-Object { "0123456789abcdef"[(Get-Random -Max 16)] })
$buildId = "{0} #{1}" -f (Get-Date -Format "yyyy-MM-dd HH:mm:ss"), $tag
Write-Host " [1/4] Publishing (linux-x64, self-contained) ..."
if (Test-Path $out) { Remove-Item $out -Recurse -Force }
dotnet publish $csproj -c Release -r linux-x64 --self-contained true -o $out -v quiet -clp:ErrorsOnly
if ($LASTEXITCODE -ne 0) { Write-Host " [X] Build failed - see errors above."; pause; exit 1 }
Write-Host " [2/4] Adding data + web + build stamp ..."
# The 2025 server reads Data / Web from ContentRoot (Environment.CurrentDirectory on
# the VPS = /opt/campus). Publish doesn't automatically copy either, so we do it here.
# "Website" is the public site Kestrel now serves at the domain root (it replaced the
# separate Flask process in Website/server.py). Without it in the tarball the landing
# page 404s on the VPS even though it works locally.
foreach ($d in @("Data", "Web", "AdminPanel", "Website")) {
if (Test-Path "$src\$d") {
# Data goes via robocopy so the avatar-pack dumps (Data\CDN\AvatarBlobs\<n>\ - tens of GB,
# uploaded to the VPS separately and imported there) and any archives never enter the
# tarball. Only the flat .assetbundle files in AvatarBlobs are served.
if ($d -eq "Data") {
$xd = @(); Get-ChildItem "$src\Data\CDN\AvatarBlobs" -Directory -ErrorAction SilentlyContinue | ForEach-Object { $xd += $_.FullName }
$xd += "$src\Data\AvatarImportStaging"
# Runtime-only state that must never be shipped over the live copy: the request log
# (it would replace the VPS log with the dev box's) and the upload-guard quarantine.
$xd += "$src\Data\Quarantine"
$rc = & robocopy "$src\Data" "$out\Data" /E /XD @xd /XF *.zip *.7z *.rar APILog.txt /NFL /NDL /NJH /NJS /NP
if ($LASTEXITCODE -ge 8) { throw "robocopy Data failed ($LASTEXITCODE)" }
$n = (Get-ChildItem "$out\Data" -Recurse -File -ErrorAction SilentlyContinue).Count
Write-Host (" + Data ({0} files, packs excluded)" -f $n)
continue
}
Copy-Item "$src\$d" "$out\" -Recurse -Force
$n = (Get-ChildItem "$src\$d" -Recurse -File -ErrorAction SilentlyContinue).Count
Write-Host (" + $d ({0} files)" -f $n)
}
}
# Cache-bust the website's own scripts/styles with THIS build's tag. The HTML referenced
# app.js?v=<hand-typed date> and Kestrel serves static files with max-age=3600, so after a
# deploy every browser kept running the previous app.js for up to an hour (or until the
# hand-typed tag was remembered and bumped). Now every packaged HTML page points at
# app.js?v=<build tag>, which changes on every package, so the new site is live on the
# next page load with no hard refresh.
$siteOut = Join-Path $out "Website"
if (Test-Path $siteOut) {
$bumped = 0
Get-ChildItem $siteOut -Filter *.html -Recurse | ForEach-Object {
$html = [IO.File]::ReadAllText($_.FullName)
$new = [regex]::Replace($html, '((?:app|data)\.js|style\.css)\?v=[A-Za-z0-9._-]+', ('$1?v=' + $tag))
if ($new -ne $html) { [IO.File]::WriteAllText($_.FullName, $new, [Text.UTF8Encoding]::new($false)); $bumped++ }
}
Write-Host (" + cache-busted app.js/data.js/style.css in {0} page(s) (v={1})" -f $bumped, $tag)
}
# Tachyon voice server SOURCE (C++23). It lives next to Server\ / Website\ in this repo
# (tachyon-server-master\tachyon-server-master, double-nested from the zip). It can't be
# cross-built from Windows, so the SOURCE ships in the tarball under tachyon/ and install.sh
# builds it on the VPS - guarded and non-fatal, so a Tachyon problem never blocks the campus
# server. Skip build/ (local output) and the empty submodule checkout; install.sh re-clones it.
$tachyonSrc = "tachyon-server-master\tachyon-server-master"
if (Test-Path "$tachyonSrc\CMakeLists.txt") {
$tdst = "$out\tachyon"
New-Item -ItemType Directory -Force -Path $tdst | Out-Null
Get-ChildItem $tachyonSrc -Force | Where-Object { $_.Name -notin @('build', '.git', '.gitignore') } | ForEach-Object {
Copy-Item $_.FullName $tdst -Recurse -Force
}
$tn = (Get-ChildItem $tdst -Recurse -File -ErrorAction SilentlyContinue).Count
Write-Host (" + tachyon ({0} files)" -f $tn)
}
else {
Write-Host " [!] tachyon source not found at $tachyonSrc - voice server NOT bundled"
}
# Build stamp - written verbatim, no trailing newline; the installer compares this
# byte-for-byte against LastBuild.txt to prove the deploy landed.
[IO.File]::WriteAllText("$out\Data\BUILD.txt", $buildId)
[IO.File]::WriteAllText("LastBuild.txt", $buildId)
Write-Host " [3/4] Writing installer ..."
# MUST be LF-only. A CRLF shebang makes bash fail with "bad interpreter".
$install = @'
#!/usr/bin/env bash
set -e
APP=/opt/campus
UNIT=/etc/systemd/system/campus.service
BIN_NAME=CampusServer
echo ""
# One deploy at a time. Two overlapping deploys (a second DeployCampusServer.bat started while
# the first was still in its Tachyon build step) replaced /opt/campus/*.dll under a RUNNING
# server: .NET memory-maps its DLLs, so the process died with SIGBUS / BadImageFormatException
# ("The format of the file '/opt/campus/CampusServer.dll' is invalid") - four times on
# 2026-09-11, each within seconds of an SSH login. Every such crash drops every player at once.
LOCK=/run/campus-deploy.lock
exec 9>"$LOCK"
if ! flock -n 9; then
echo " [X] another deploy is still running (lock $LOCK). Wait for it to finish, then re-run."
exit 1
fi
echo " [1/6] stopping the server ..."
systemctl stop campus 2>/dev/null || true
# stop is synchronous, but be certain nothing is still mapping the old binaries before they
# are overwritten: wait for the unit to be fully inactive and the process to be gone.
for i in $(seq 1 30); do
if ! systemctl is-active --quiet campus && ! pgrep -x "$BIN_NAME" >/dev/null 2>&1; then break; fi
sleep 1
done
if pgrep -x "$BIN_NAME" >/dev/null 2>&1; then
echo " [X] $BIN_NAME is still running after 30s - refusing to overwrite its files."
exit 1
fi
echo " [2/6] copying files ..."
mkdir -p "$APP"
# Preserve live DBs across deploys — burner accounts, dorm state, heartbeats etc.
# live in Data/DBs/*.db and would otherwise be clobbered by the tarball's seed copies.
DB_BACKUP=$(mktemp -d /tmp/campus-dbs-XXXXXX)
if [ -d "$APP/Data/DBs" ] && [ "$(ls -A "$APP/Data/DBs" 2>/dev/null)" ]; then
echo " preserving existing $APP/Data/DBs -> $DB_BACKUP"
cp -a "$APP/Data/DBs/." "$DB_BACKUP/"
HAVE_LIVE_DBS=1
else
HAVE_LIVE_DBS=0
fi
cp -r ./* "$APP"/
chmod +x "$APP/$BIN_NAME" 2>/dev/null || true
if [ "$HAVE_LIVE_DBS" = "1" ]; then
mkdir -p "$APP/Data/DBs"
cp -a "$DB_BACKUP/." "$APP/Data/DBs/"
echo " restored live DBs (kept burner accounts, etc.)"
fi
rm -rf "$DB_BACKUP"
echo " [3/6] writing / refreshing systemd unit ..."
# Same unit name (campus.service) as the 2023 server so nothing else on the VPS
# needs to change - Caddy, journalctl -u campus, our watch script all keep working.
# ExecStart points at the 2025 binary (CampusServer, forced via AssemblyName in the csproj).
cat > "$UNIT" <<UNITEOF
[Unit]
Description=Campus 2025 (E12354 base) Rec Room server
After=network.target
[Service]
Type=simple
WorkingDirectory=$APP
# Kestrel binds inside the container. Caddy on the same box handles TLS + proxies
# https://gameauth.ddns.net/* to localhost:8080, so only HTTP-on-8080 is needed.
Environment=ASPNETCORE_URLS=http://0.0.0.0:8080
# Workstation GC (matches <ServerGarbageCollector>false</ServerGarbageCollector> in the csproj;
# this env var would otherwise override it). Server GC reserved a per-core heap and held ~1.3 GB
# resident for a few dozen players on a 4-core box.
Environment=DOTNET_gcServer=0
ExecStart=$APP/$BIN_NAME
Restart=always
RestartSec=3
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
UNITEOF
systemctl daemon-reload
echo " [4/6] starting the server ..."
systemctl enable campus >/dev/null 2>&1 || true
systemctl start campus
sleep 3
echo " [5/6] status ..."
systemctl is-active campus || true
echo " [6/6] smoke test - GET / ..."
# The 2025 server routes "/" to the NS response (WebController.NsRoot). If the
# server is up and answering right, this prints 200. On any other code it dumps
# the tail of the journal so the operator can see WHY it's not answering.
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 http://127.0.0.1:8080/ || echo 000)
echo " GET http://127.0.0.1:8080/ -> HTTP $code"
# Same URL, but asking as a browser would: this must come back as the website, not the
# NS JSON. Catches a missing Website/ folder, which is otherwise invisible until someone
# opens the domain in a browser and gets the NameServer blob.
site=$(curl -s --max-time 5 -H 'Accept: text/html' http://127.0.0.1:8080/ || true)
if echo "$site" | grep -qi '<html'; then
echo " GET / (Accept: text/html) -> website OK"
else
echo " [!] / with Accept: text/html did NOT return the website - is Website/ in the tarball?"
fi
if [ "$code" != "200" ]; then
echo ""
echo " [!] Non-200 - last 20 lines of journal:"
journalctl -u campus --no-pager -n 20 || true
fi
# ---- Tachyon voice server (optional, NON-FATAL) --------------------------------------------
# The campus server is already up above. This builds + runs the bundled Tachyon voice server
# IF the C++ toolchain is present; ANY failure here only warns and the deploy still succeeds.
# Voice stays on Photon until ConfigCampus.TachyonVoiceEndpoint is set, so this being down
# changes nothing about the game until you flip that on.
TACHYON="$APP/tachyon"
# The address the CLIENT dials for voice (UDP). Must be reachable from the internet AND a raw
# IP:port - the client's LiteNetLib voice connector does not resolve hostnames (every address in
# Tachyon's own examples is an IP), so a DDNS hostname here means the client never dials it.
# Written into a campus systemd drop-in below ONLY if Tachyon comes up, so the game is pointed at
# Tachyon only when it's actually running.
TACHYON_PUBLIC="50.114.179.17:7777"
if [ -d "$TACHYON" ]; then
echo ""
echo " [tachyon] setting up voice server (non-fatal) ..."
(
set -e
cd "$TACHYON"
# Deps (Debian/Ubuntu). build-essential is REQUIRED - it brings gcc, g++ AND make; installing
# g++ alone leaves cmake unable to find "make" (CMAKE_MAKE_PROGRAM not set), which is exactly
# what failed the first deploy. Errors are shown, not swallowed, so a broken apt is visible.
if command -v apt-get >/dev/null 2>&1; then
echo " installing build deps (build-essential, cmake, curl/ssl dev) ..."
DEBIAN_FRONTEND=noninteractive apt-get update -y || echo " [tachyon] apt-get update failed - continuing"
DEBIAN_FRONTEND=noninteractive apt-get install -y \
build-essential cmake git pkg-config libcurl4-openssl-dev libssl-dev \
|| echo " [tachyon] apt-get install failed - toolchain may be incomplete"
fi
# Preflight: without a compiler AND make, cmake can't build. Skip with a clear, actionable
# message rather than dumping raw CMake errors into the deploy log.
if ! command -v cmake >/dev/null 2>&1 || ! command -v make >/dev/null 2>&1 \
|| ! { command -v g++ >/dev/null 2>&1 || command -v c++ >/dev/null 2>&1; }; then
echo " [tachyon] build toolchain missing (need cmake + make + g++). Install with:"
echo " apt-get install -y build-essential cmake git pkg-config libcurl4-openssl-dev libssl-dev"
echo " then re-run the deploy, or build by hand (tachyon/deploy/DEPLOY.md). Skipping - campus server is unaffected."
exit 0
fi
# Submodule ships empty (LiteNetLibPP) - clone it if the source isn't there.
if [ ! -e vendor/LiteNetLibPP/CMakeLists.txt ]; then
echo " fetching LiteNetLibPP submodule ..."
rm -rf vendor/LiteNetLibPP
git clone --depth 1 https://github.com/Revan600/LiteNetLibPP.git vendor/LiteNetLibPP
fi
# Build only when needed (first deploy, or a source file newer than the binary).
if [ ! -x build/tachyon ] || [ -n "$(find src CMakeLists.txt -newer build/tachyon 2>/dev/null)" ]; then
echo " building tachyon (this can take a couple of minutes) ..."
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release >/dev/null
cmake --build build -j"$(nproc)" >/dev/null
fi
test -x build/tachyon
# Open UDP 7777 if ufw is the firewall (guarded - other setups are the operator's job).
if command -v ufw >/dev/null 2>&1; then ufw allow 7777/udp >/dev/null 2>&1 || true; fi
# systemd unit pointing at the shipped build + conf.
cat > /etc/systemd/system/tachyon.service <<TUNIT
[Unit]
Description=Tachyon voice server (Campus)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=$TACHYON
ExecStart=$TACHYON/build/tachyon --config $TACHYON/tachyon.conf
Restart=on-failure
RestartSec=3
[Install]
WantedBy=multi-user.target
TUNIT
systemctl daemon-reload
systemctl enable tachyon >/dev/null 2>&1 || true
systemctl restart tachyon
sleep 1
if systemctl is-active --quiet tachyon; then
echo " tachyon is UP (UDP 7777)."
else
echo " [tachyon] built but not active - journalctl -u tachyon"
fi
) || echo " [tachyon] setup skipped/failed (toolchain missing?) - build manually: tachyon/deploy/DEPLOY.md. Campus server is unaffected."
# Point campus voice at Tachyon ONLY if it actually came up; otherwise make sure voice is on
# Photon. Done via a systemd drop-in that sets CAMPUS_TACHYON_ENDPOINT (ConfigCampus reads it),
# so no rebuild is needed to toggle. Keyed on the live service state, so it self-heals across
# deploys: a Tachyon that stops building/running reverts the game to Photon on the next deploy.
CAMPUS_DROPIN=/etc/systemd/system/campus.service.d/tachyon.conf
if systemctl is-active --quiet tachyon 2>/dev/null; then
mkdir -p /etc/systemd/system/campus.service.d
cat > "$CAMPUS_DROPIN" <<DROPIN
[Service]
Environment=CAMPUS_TACHYON_ENDPOINT=$TACHYON_PUBLIC
Environment=CAMPUS_TACHYON_SERVER_ID=tachyon-1
DROPIN
echo " [tachyon] voice ENABLED -> $TACHYON_PUBLIC (restarting campus to pick it up)"
systemctl daemon-reload
systemctl restart campus
elif [ -f "$CAMPUS_DROPIN" ]; then
rm -f "$CAMPUS_DROPIN"
systemctl daemon-reload
systemctl restart campus
echo " [tachyon] not running -> voice reverted to Photon (removed campus drop-in)"
fi
fi
'@
# Force LF line endings + UTF-8 no BOM (bash rejects CRLF shebang / BOM).
$installLF = $install -replace "`r`n","`n"
[IO.File]::WriteAllBytes("$out\install.sh", [Text.UTF8Encoding]::new($false).GetBytes($installLF))
Write-Host " [4/4] Packing $pkg ..."
if (Test-Path $pkg) { Remove-Item $pkg -Force }
# tar is native on Windows 10+; -C to switch into the publish dir so paths in the
# tarball are relative ("./install.sh" etc.) - what the installer expects.
tar -czf $pkg -C $out .
if ($LASTEXITCODE -ne 0) { Write-Host " [X] tar failed"; pause; exit 1 }
$szMB = [math]::Round((Get-Item $pkg).Length / 1MB, 1)
Write-Host ""
Write-Host " ==========================================="
Write-Host " PACKAGED OK"
Write-Host (" file : $pkg ({0} MB)" -f $szMB)
Write-Host " build : $buildId"
Write-Host ""
Write-Host " Next: DeployCampusServer.bat"
Write-Host " ==========================================="