# ============================================================ # 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\\ - 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= 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=, 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" <false 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 ' 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 </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" < $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 " ==========================================="