354 lines
16 KiB
PowerShell
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 " ==========================================="
|