From 4b7fdcaa23ff1b1eb6d413a0442ea7d72e53f8b2 Mon Sep 17 00:00:00 2001 From: esefxdz Date: Thu, 15 Oct 2026 20:40:59 +0300 Subject: [PATCH] Windows version: host the server from your own PC setup.cmd installs the same DM/GunGame server natively on Windows 10/11 (no Docker or Python) into Documents\cs16, then helps set up how friends join (LAN, playit.gg, port forwarding, VPN). cs.cmd is the menu. Also: setup falls back to backup copies of the freely licensed mod downloads on this Gitea when a main site is down (tools/make-mirror.sh fills them), and the README is rewritten as a plain guide. Co-Authored-By: Claude Opus 5.5 --- .gitattributes | 3 + README.md | 140 +++++---- cs.cmd | 8 + cstrike/addons/metamod/plugins.ini | 3 + docs/HOW-IT-WORKS.md | 33 +++ setup.cmd | 9 + tools/install-addons.sh | 7 +- tools/make-mirror.sh | 60 ++++ tools/versions-windows.txt | 8 + tools/versions.txt | 4 + windows/common.ps1 | 461 +++++++++++++++++++++++++++++ windows/cs.ps1 | 123 ++++++++ windows/install.ps1 | 130 ++++++++ windows/join.ps1 | 111 +++++++ windows/setup.ps1 | 205 +++++++++++++ windows/tests/common.Tests.ps1 | 179 +++++++++++ 16 files changed, 1412 insertions(+), 72 deletions(-) create mode 100644 cs.cmd create mode 100644 setup.cmd create mode 100644 tools/make-mirror.sh create mode 100644 tools/versions-windows.txt create mode 100644 windows/common.ps1 create mode 100644 windows/cs.ps1 create mode 100644 windows/install.ps1 create mode 100644 windows/join.ps1 create mode 100644 windows/setup.ps1 create mode 100644 windows/tests/common.Tests.ps1 diff --git a/.gitattributes b/.gitattributes index b28863c..26f4820 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,5 @@ * text=auto eol=lf *.wav binary +# Windows launchers and scripts need CRLF (batch files break with LF) +*.cmd text eol=crlf +*.ps1 text eol=crlf diff --git a/README.md b/README.md index 62bca60..e5b478b 100644 --- a/README.md +++ b/README.md @@ -1,110 +1,110 @@ # cs16-dm-server -A **Counter-Strike 1.6 deathmatch server** in Docker. One command sets it up, and a menu runs it. +A Counter-Strike 1.6 deathmatch server you can set up without knowing anything about servers. It runs on a Windows PC (for playing with friends tonight) or on a Linux machine (for a server that's always on). -Free-for-all deathmatch: instant respawn, a gun menu, no rounds, everyone against everyone. Switch to **GunGame** any time. Bots keep it busy when nobody's on, and they leave as people join. Your friends can join with the Steam version or a non-Steam client. +What you get: -- **One command:** `./setup.sh` asks 5 questions, then downloads the game (SteamCMD) and the mods (pinned versions, checksum-verified), compiles the plugins and starts the server. -- **One menu:** `./cs` shows status, and lets you change the map, switch DM ↔ GunGame, set the bot count, see the kill counter, restart, or update. No console commands to memorize. -- **Game feel:** damage numbers, killstreak and multikill announcer (Quake/UT voices), headshot and knife sounds, +HP per kill, a K/D/streak HUD line, rock-the-vote (`rtv`), `/rank` and `/top15` stats. -- **All-time kill counter** (`./kills`), with top fraggers and favourite weapons. Bots count too, so it climbs fast. -- **Recovers on its own:** Docker restarts the server after a crash, a watchdog restarts it if it hangs, and backups run nightly. +- Free-for-all deathmatch: instant respawn, gun menu, no rounds. GunGame is one menu option away. +- Bots that fill the server when it's empty and leave as people join. +- Damage numbers, killstreak announcer, +HP per kill, rock-the-vote (`rtv`), `/rank` and `/top15`. +- An all-time kill counter. +- Players can join with the Steam version or a non-Steam client. -Built on ReHLDS + ReGameDLL + Metamod-R + AMX Mod X + ReAPI + ReUnion + CSDM-ReAPI + YaPB + GunGame. Details: [docs/HOW-IT-WORKS.md](docs/HOW-IT-WORKS.md). +## Windows -## Quick start +You need 64-bit Windows 10 or 11 and about 1.5 GB of free space. -You need: -- a Linux machine on x86-64 (the game server is 32-bit x86, so no Raspberry Pi or other ARM; any small box or VPS works: with 6 bots the server uses ~10% of one core and ~100 MB of RAM) -- [Docker with the compose plugin](https://docs.docker.com/engine/install/) -- `git` and `python3` -- ~1.5 GB of disk +1. Click **Download ZIP** on this page and unzip it (Downloads is fine). +2. Double-click `setup.cmd`. Answer the questions or press Enter to keep the defaults. It downloads the game (about 600 MB) and starts the server in its own window. Say Yes when Windows asks for admin permission: that lets your friends through the firewall. +3. Setup then asks how your friends will join and helps with that (see below). + +The server is installed in `Documents\cs16` (or `C:\Users\\cs16` if your Documents folder is synced by OneDrive). From then on, double-click `cs.cmd` in that folder to start or stop the server, change the map or mode, set the bots, or see the kill counter. Closing the server window stops the server. + +Running `setup.cmd` again is safe: it keeps your settings. `setup.cmd -Update` also updates the game files. + +This was tested on Windows 10. Windows 11 should work the same way. + +## Linux + +For a server that stays on. You need an x86-64 machine (no Raspberry Pi: the game server is 32-bit x86), Docker with the compose plugin, `git` and `python3`. With 6 bots it uses about 10% of one core and 100 MB of RAM. ```sh git clone https://git.userevolt.app/esef/cs16-dm-server.git cd cs16-dm-server ./setup.sh # asks: server name, join password, port, mode, bots -./scripts/install-cron.sh # optional but recommended: watchdog + nightly backup +./scripts/install-cron.sh # optional: restarts a hung server, nightly backups ``` -Run it as a normal user in the `docker` group, not as root. When setup finishes it prints the `connect` addresses. - -### Letting people in from the internet - -Pick one: - -- **Port forward:** forward **UDP 27015** (your `CS_PORT`) on your router to this machine. Players use `connect :27015`. -- **No port forwarding** (CGNAT, dorm, mobile internet): make a free [playit.gg](https://playit.gg) account, install their agent on this machine, and add a tunnel. Choose type **UDP** and local address `127.0.0.1:27015`. Players then use the address playit gives you, for example `connect something.ply.gg:12345`. Every player arrives from playit's IP, and the server is set up for that: `sv_rehlds_maxclients_from_single_ip 16`, short RCON bans. Never ban players by IP, because an IP ban would kick everyone. -- **Just friends:** a VPN like Tailscale or ZeroTier. Everyone joins with `connect :27015`. - -How players join, Steam or not: [docs/PLAYERS.md](docs/PLAYERS.md). - -## Running it +Run it as a normal user in the `docker` group, not as root. Then use `./cs` for the menu: ``` -./cs menu: pick a number +./cs menu ./cs status who's on, map, mode ./cs map de_inferno change map -./cs mode gg GunGame (./cs mode dm = back to deathmatch; the mode is remembered) -./cs bots 6 fill up to 6 players with bots (0 = none; saved in .env) -./cs kills all-time kill counter -./rcon server console (./rcon for one command) +./cs mode gg GunGame (./cs mode dm goes back) +./cs bots 6 fill up to 6 players with bots (0 = none) +./cs kills kill counter +./rcon server console ``` -For admins, maps, fast downloads, several servers and backups, see [docs/ADMIN.md](docs/ADMIN.md). +## How friends join + +Everyone opens Counter-Strike 1.6, presses `~` for the console and types `connect
`. Which address depends on where they are: + +| Where your friends are | What to do | They connect to | +|---|---|---| +| Same house | nothing | the LAN address setup shows, e.g. `192.168.1.20:27015` | +| Anywhere, easiest | free [playit.gg](https://playit.gg) account, add a **UDP** tunnel to `127.0.0.1:27015` | the address playit gives you, e.g. `name.ply.gg:12345` | +| Anywhere, no extra app | forward **UDP 27015** on your router to this PC | your public IP, e.g. `203.0.113.5:27015` | +| A fixed group | everyone installs Tailscale or ZeroTier | your VPN address | + +On Windows, setup walks you through these, and `cs.cmd` → 10 brings it back. Port forwarding doesn't work on some connections (mobile internet, many dorms, some fiber providers). If friends can't connect after forwarding, use playit. + +With playit, every player arrives from playit's address, so never ban by IP: it would kick everyone. + +More for players (Steam and non-Steam): [docs/PLAYERS.md](docs/PLAYERS.md). + +## When something goes wrong + +- **Setup stopped with an error.** Fix what it says (usually the internet connection) and run it again. It continues where it stopped. +- **Friends can't connect, but you can.** On Windows, run `cs.cmd` → 10 → 1 and accept the admin prompt so the firewall lets them in. If they're not in your house, check the playit tunnel or the port forward. +- **"Unable to initialize Steam" (Windows).** Start the server from `cs.cmd`, not by opening `hlds.exe` directly. +- **The server window closes right away, or setup says files are missing.** Your antivirus probably removed a file. Allow the install folder in it and run `setup.cmd` again. +- **The port is taken.** Setup offers the next free port. Your friends then need that port in the address. ## Settings -The basics live in `.env` (created from [`.env.example`](.env.example) by setup). After editing it, run `./cs apply`, which is the same as `docker compose up -d`. +Windows keeps them in `settings.txt`, Linux in `.env` (same names). After a change, restart the server (`cs.cmd` → 7 on Windows, `./cs apply` on Linux). | Setting | Default | | |---|---|---| | `CS_HOSTNAME` | `CS 1.6 Deathmatch` | name in the server browser | -| `CS_PASSWORD` | empty | join password; empty = open server | +| `CS_PASSWORD` | empty | join password; empty = anyone can join | | `CS_MAXPLAYERS` | `16` | | | `CS_PORT` | `27015` | game port (UDP) | -| `CS_MAP` | `de_dust2` | first map; after that `hlds/cstrike/mapcycle.txt` | -| `CS_MODE` | `dm` | starting mode, `dm` or `gg` (first install only; after that use `./cs mode`) | -| `CS_BOTS` | `6` | bots fill the server up to this many players; `0` = no bots | -| `CS_BOT_DIFFICULTY` | `2` | `0` easy … `4` expert | -| `CS_NAME` | `cs16` | Docker container name | +| `CS_MAP` | `de_dust2` | first map, then `mapcycle.txt` | +| `CS_MODE` | `dm` | `dm` or `gg` on the first install; after that the menu remembers it | +| `CS_BOTS` | `6` | bots fill the server up to this many players; `0` = none | +| `CS_BOT_DIFFICULTY` | `2` | `0` easy to `4` expert | +| `CS_NAME` | `cs16` | Docker container name (Linux only) | -Everything else goes in **`hlds/cstrike/custom.cfg`**. It runs after `server.cfg` on every map and setup never overwrites it. Examples are inside: time limit, alltalk, the extras' HP-per-kill and sounds. +Anything else goes in `hlds/cstrike/custom.cfg`, which setup never overwrites. It has examples inside (time limit, alltalk, HP per kill, sounds). -### What the modes are - -| | Deathmatch (`dm`) | GunGame (`gg`) | +| | Deathmatch | GunGame | |---|---|---| -| Plugin | CSDM-ReAPI | GunGame 2.13c | | Respawn | 0.7 s, 2 s spawn protection | 1 s | -| Weapons | gun menu on spawn | glock → pistols → shotguns → SMGs → rifles → M249 → HE → knife, 2 kills per level | -| Teams | free for all | free for all | -| Map | 20 min | 30 min, 30 s knife warmup | +| Weapons | gun menu on spawn | glock, pistols, shotguns, SMGs, rifles, M249, HE, knife; 2 kills per level | +| Map time | 20 min | 30 min, 30 s knife warmup | -Both use the DM spawn points for the 11 stock maps in the default mapcycle. To add your own maps, see [docs/ADMIN.md](docs/ADMIN.md). - -## Layout - -``` -setup.sh install / re-run / --update -cs, rcon, kills menu, console, kill counter -.env.example settings template (.env is yours, not in git) -docker-compose.yml the container; Dockerfile = Debian + 32-bit libs + SteamCMD -cstrike/ configs copied into the game on every setup (server.cfg, plugin lists, GunGame cfg) -defaults/ mapcycle.txt + custom.cfg, copied once and then yours -plugins/ source of the own plugins (extras, rtv, mode switch), compiled by setup -tools/ versions.txt (pinned downloads + sha256), install-addons.sh, start.sh, hlq.py -scripts/ watchdog, backup, install-cron -hlds/ the game server (downloaded; maps, logs, stats live here) -``` +Both are free for all, with spawn points for the 11 stock maps in the default mapcycle. Admins, custom maps, fast downloads and backups: [docs/ADMIN.md](docs/ADMIN.md). How it's put together: [docs/HOW-IT-WORKS.md](docs/HOW-IT-WORKS.md). ## Updating -`git pull && ./setup.sh` picks up new configs and plugins. `./setup.sh --update` also updates the game from Steam. Neither changes your `.env`, secrets, admins (`users.ini`), `mapcycle.txt`, `custom.cfg`, CSDM `config.ini`, stats or the kill counter. +Linux: `git pull && ./setup.sh` (add `--update` to also update the game). Windows: download the new ZIP and run its `setup.cmd`; it updates your existing install. Your settings, passwords, admins, mapcycle, `custom.cfg`, stats and kill counter are kept. ## Credits -Downloaded by setup from their own projects (not part of this repo): +Setup downloads these from their own projects. Each file is checked against a pinned checksum; some have a backup copy on this Gitea in case their site is down. - [ReHLDS](https://github.com/rehlds/ReHLDS), [ReGameDLL_CS](https://github.com/rehlds/ReGameDLL_CS), [Metamod-R](https://github.com/rehlds/Metamod-R), [ReAPI](https://github.com/rehlds/ReAPI), [ReUnion](https://github.com/rehlds/ReUnion) - [AMX Mod X](https://www.amxmodx.org) @@ -112,8 +112,6 @@ Downloaded by setup from their own projects (not part of this repo): - [YaPB](https://github.com/yapb/yapb) - [GunGame by Avalanche](https://github.com/fysiks1/amxx-gungame_cstrike) - DM spawn points from [CSDM 2.1 by Arkshine](https://github.com/Arkshine/CSDM) -- announcer sounds from [hpoon/HLDS-CS1.6](https://github.com/hpoon/HLDS-CS1.6) +- Announcer sounds from [hpoon/HLDS-CS1.6](https://github.com/hpoon/HLDS-CS1.6) -Counter-Strike is Valve's. The game server files come from Steam (SteamCMD, anonymous login). - -The scripts, configs and plugins in this repo are MIT licensed ([LICENSE](LICENSE)). +Counter-Strike belongs to Valve. The game files come from Steam (SteamCMD, anonymous login). The scripts, configs and plugins here are MIT licensed ([LICENSE](LICENSE)). diff --git a/cs.cmd b/cs.cmd new file mode 100644 index 0000000..72a44b2 --- /dev/null +++ b/cs.cmd @@ -0,0 +1,8 @@ +@echo off +rem cs16-dm-server menu: start/stop the server, map, mode, bots, kill counter, how friends join. +rem Or directly: cs.cmd status, start, stop, restart, map de_nuke, mode gg, bots 4, kills, join +title CS 1.6 server +rem from a PowerShell 7 terminal, Windows PowerShell would otherwise load the wrong modules +set PSModulePath= +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0windows\cs.ps1" %* +if "%~1"=="" pause diff --git a/cstrike/addons/metamod/plugins.ini b/cstrike/addons/metamod/plugins.ini index 72769f0..eda0b6a 100644 --- a/cstrike/addons/metamod/plugins.ini +++ b/cstrike/addons/metamod/plugins.ini @@ -1,3 +1,6 @@ linux addons/reunion/reunion_mm_i386.so linux addons/amxmodx/dlls/amxmodx_mm_i386.so linux addons/yapb/bin/yapb.so +win32 addons/reunion/reunion_mm.dll +win32 addons/amxmodx/dlls/amxmodx_mm.dll +win32 addons/yapb/bin/yapb.dll diff --git a/docs/HOW-IT-WORKS.md b/docs/HOW-IT-WORKS.md index f20c8c4..b886386 100644 --- a/docs/HOW-IT-WORKS.md +++ b/docs/HOW-IT-WORKS.md @@ -83,3 +83,36 @@ GunGame settings (`configs/gungame.cfg`): ## Kill counter `./kills` counts the `"X<..>" killed "Y<..>" with "weapon"` lines in `hlds/cstrike/logs/*.log`, keeping the counts per log file in `kills-tally.json`. Because the counts live in that file, the totals survive the 30-day log cleanup. + +## Windows version (`setup.cmd`, `windows/`) + +The same stack, native instead of Docker: the win32 builds of every part, `hlds.exe` instead of `hlds_linux`. The Windows scripts are Windows PowerShell 5.1 (built into Windows 10 and 11) and mirror the Linux ones: `windows/setup.ps1` = `setup.sh`, `windows/install.ps1` = `tools/install-addons.sh`, `Start-CsServer` in `windows/common.ps1` = `tools/start.sh`, `windows/cs.ps1` = `cs` + `rcon` + `kills`. They read the same `cstrike/`, `plugins/`, `defaults/` and `tools/versions.txt`; `tools/versions-windows.txt` swaps in the Windows builds of AMX Mod X and YaPB (files with `-linux` in the name are skipped). Settings go in `settings.txt` with the `.env` names. + +Things that differ from Linux: + +- **Steam installed on the same PC.** The server's `steam_api.dll` finds the Steam client's `steamclient.dll` through `HKCU\Software\Valve\Steam\ActiveProcess\SteamClientDll` and loads that instead of its own, which ends in "Unable to initialize Steam". `Start-CsServer` renames that value until the server answers queries (a few seconds), then puts it back. Steam rewrites it at every start anyway, and every script repairs it on load if a start was interrupted. +- **Firewall.** Setup adds an allow rule for `hlds\hlds.exe` on all network types (admin prompt) before the first start, so Windows' own popup doesn't appear, and removes block rules Windows made if that popup was ever cancelled. Normal users often can't read firewall rules, so the result is remembered in `secrets\firewall-rule.txt`. +- **Install folder.** `Documents\cs16`, or `%USERPROFILE%\cs16` when Documents is synced by OneDrive, or `C:\cs16` when the user folder has non-ASCII characters (SteamCMD and HLDS can't handle them). The folder is remembered in `%LOCALAPPDATA%\cs16-dm-server\install-folder.txt`, so running a newly downloaded `setup.cmd` updates the existing install. +- **Console log.** `-condebug` writes `hlds\qconsole.log`; setup shows its end when the server doesn't come up. +- **No watchdog or backups.** It's meant to be started and closed like a game. + +Tests for the pure functions: `Invoke-Pester windows\tests` (Pester 3.4 ships with Windows 10). + +## Backup downloads + +If a main download URL fails (or returns something that doesn't match the hash), setup tries the same file name under the `#mirror` URL in `tools/versions.txt`: the `mods` release on this repo's Gitea. Only the freely licensed files are there (ReHLDS, ReGameDLL, Metamod-R, AMX Mod X, ReAPI, ReUnion, YaPB); `tools/make-mirror.sh` uploads them. After bumping one of those versions, run it again. + +## Layout + +``` +setup.sh, cs, rcon, kills Linux: install, menu, console, kill counter +setup.cmd, cs.cmd Windows: install, menu (scripts in windows/) +.env.example Linux settings template +docker-compose.yml the container; Dockerfile = Debian + 32-bit libs + SteamCMD +cstrike/ configs copied into the game on every setup +defaults/ mapcycle.txt + custom.cfg, copied once, then yours +plugins/ the own plugins (extras, rtv, mode switch), compiled by setup +tools/ pinned downloads (versions*.txt), install-addons.sh, start.sh, hlq.py, make-mirror.sh +scripts/ Linux watchdog, backup, install-cron +hlds/ the game server (downloaded; maps, logs, stats) +``` diff --git a/setup.cmd b/setup.cmd new file mode 100644 index 0000000..71d1e1f --- /dev/null +++ b/setup.cmd @@ -0,0 +1,9 @@ +@echo off +rem cs16-dm-server: double-click to install or update the CS 1.6 server on this PC. +rem Options: setup.cmd -Yes (no questions) setup.cmd -Update (also update the game files) +title CS 1.6 server setup +rem from a PowerShell 7 terminal, Windows PowerShell would otherwise load the wrong modules +set PSModulePath= +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0windows\setup.ps1" %* +echo. +pause diff --git a/tools/install-addons.sh b/tools/install-addons.sh index dcb593c..ea2dfaf 100755 --- a/tools/install-addons.sh +++ b/tools/install-addons.sh @@ -17,10 +17,15 @@ sha() { sha256sum < "$1" | cut -c1-64; } step "downloads (pinned, checked against tools/versions.txt)" mkdir -p $DL/gg $DL/snd $DL/spawns +MIRROR=$(sed -n 's/^#mirror //p' $R/tools/versions.txt | tr -d '\r') grep -v '^#' $R/tools/versions.txt | while read -r f sum url; do [ -n "$f" ] || continue [ -f "$DL/$f" ] && [ "$(sha "$DL/$f")" = "$sum" ] && continue - curl -fsSL --retry 3 -o "$DL/$f.part" "$url" || die "download failed: $url" + # main site first; if that fails, the backup copy on the project's Gitea (same checksum check) + curl -fsSL --retry 3 -o "$DL/$f.part" "$url" || + { [ -n "$MIRROR" ] && echo " $f: main site failed, trying the backup copy" && + curl -fsSL --retry 2 -o "$DL/$f.part" "$MIRROR${f##*/}"; } || + die "download failed: $url" got=$(sha "$DL/$f.part") [ "$got" = "$sum" ] || { rm -f "$DL/$f.part"; die "checksum mismatch for $f: expected $sum, got $got"; } mv "$DL/$f.part" "$DL/$f" diff --git a/tools/make-mirror.sh b/tools/make-mirror.sh new file mode 100644 index 0000000..433f016 --- /dev/null +++ b/tools/make-mirror.sh @@ -0,0 +1,60 @@ +#!/bin/sh +# For the maintainer: puts backup copies of the freely licensed downloads on the Gitea release +# "mods", which setup falls back to when a main site is down (see "#mirror" in versions.txt). +# Only MIT / GPL / Unlicense files are mirrored; the rest (CSDM-ReAPI, GunGame, sounds, spawn +# files) has no license and stays download-only. +# sh tools/make-mirror.sh uses your saved git login for git.userevolt.app +# GITEA_TOKEN= sh tools/make-mirror.sh +# Needs curl and python3 (or python). Safe to run again: files already on the release are skipped. +set -e +cd "$(dirname "$0")/.." +API=https://git.userevolt.app/api/v1/repos/esef/cs16-dm-server +TAG=mods +PY=$(command -v python3 || command -v python) || { echo "needs python3"; exit 1; } +DIR=$(mktemp -d); trap 'rm -rf "$DIR"' EXIT +CFG=$DIR/curl.cfg # credentials go to curl through a file, not the command line +umask 077 +if [ -n "$GITEA_TOKEN" ]; then + printf 'header = "Authorization: token %s"\n' "$GITEA_TOKEN" > "$CFG" +else + cred=$(printf 'protocol=https\nhost=git.userevolt.app\n\n' | GIT_TERMINAL_PROMPT=0 git credential fill 2>/dev/null) || + { echo "no saved git login for git.userevolt.app: push once, or set GITEA_TOKEN"; exit 1; } + u=$(printf '%s\n' "$cred" | sed -n 's/^username=//p') + pw=$(printf '%s\n' "$cred" | sed -n 's/^password=//p') + printf 'user = "%s:%s"\n' "$u" "$pw" > "$CFG" +fi +umask 022 +json() { "$PY" -c "import json,sys; d=json.load(sys.stdin); print($1)"; } +curl -fsS -K "$CFG" -o /dev/null https://git.userevolt.app/api/v1/user || + { echo "Gitea didn't accept the login (wrong or expired?)"; exit 1; } + +echo "== downloading and checking" +cat tools/versions.txt tools/versions-windows.txt | tr -d '\r' | grep -v '^#' | + grep -E '^(rehlds-bin|regamedll-bin|metamod-bin|amxmodx-|reapi-bin|reunion-|yapb-)' | + while read -r f sum url; do + curl -fsSL --retry 3 -o "$DIR/$f" "$url" + got=$( (sha256sum "$DIR/$f" 2>/dev/null || shasum -a 256 "$DIR/$f") | cut -c1-64) + [ "$got" = "$sum" ] || { echo "checksum mismatch for $f"; exit 1; } + echo " ok $f" + done + +echo "== release '$TAG'" +id=$(curl -fsS -K "$CFG" "$API/releases/tags/$TAG" 2>/dev/null | json "d['id']" || true) +if [ -z "$id" ]; then + body='Backup copies of the mod downloads, used by setup only when a main site is down. Every file is checked against tools/versions.txt before it is installed. Sources and licenses: ReHLDS, ReGameDLL_CS (MIT), Metamod-R, ReAPI, ReUnion (GPL-3.0) at github.com/rehlds; AMX Mod X (GPL-3.0) at github.com/alliedmodders/amxmodx; YaPB (Unlicense) at github.com/yapb/yapb.' + id=$(printf '{"tag_name":"%s","target_commitish":"main","name":"Mod downloads (backup copies)","body":"%s"}' "$TAG" "$body" | + curl -fsS -K "$CFG" -H 'Content-Type: application/json' -d @- "$API/releases" | json "d['id']") + echo " created (id $id)" +else + echo " exists (id $id)" +fi +have=$(curl -fsS -K "$CFG" "$API/releases/$id/assets" | json "'\n'.join(a['name'] for a in d)") + +echo "== uploading" +for p in "$DIR"/*; do + f=$(basename "$p") + if printf '%s\n' "$have" | grep -qxF "$f"; then echo " have $f"; continue; fi + curl -fsS -K "$CFG" -F "attachment=@$p" "$API/releases/$id/assets?name=$f" > /dev/null + echo " up $f" +done +echo "done: https://git.userevolt.app/esef/cs16-dm-server/releases/tag/$TAG" diff --git a/tools/versions-windows.txt b/tools/versions-windows.txt new file mode 100644 index 0000000..b3f80e7 --- /dev/null +++ b/tools/versions-windows.txt @@ -0,0 +1,8 @@ +# Windows-only pinned downloads (windows/setup.ps1). Same format as versions.txt. +# Windows uses versions.txt minus the files with "-linux" in their name, plus this list. + +# AMX Mod X 1.10 Windows build (GPL-3.0) +amxmodx-1.10.0-git5486-base-windows.zip 9016824ac5ae0751e1aa9647ad9a506fcf4af6e63ad1ccf5dd7769025b00c6a0 https://www.amxmodx.org/amxxdrop/1.10/amxmodx-1.10.0-git5486-base-windows.zip +amxmodx-1.10.0-git5486-cstrike-windows.zip 56f0f4bf0148005e7cf1151a50ac701c84e73c3d35627514a4dbf0ab52e15450 https://www.amxmodx.org/amxxdrop/1.10/amxmodx-1.10.0-git5486-cstrike-windows.zip +# YaPB Windows build (Unlicense) +yapb-4.4.957-windows.zip c32a5b4d77c08c3c59c417568c5719f9227238c6d627d478cfb1d9f7d9c113f4 https://github.com/yapb/yapb/releases/download/4.4.957/yapb-4.4.957-windows.zip diff --git a/tools/versions.txt b/tools/versions.txt index 9c9d1cd..3b94d35 100644 --- a/tools/versions.txt +++ b/tools/versions.txt @@ -1,6 +1,10 @@ # Pinned third-party downloads: # setup.sh fetches each one into dl/ and refuses to install it if the hash differs. # Bump a version: change url + file name, run ./setup.sh, put the new hash it prints here. +# Windows (windows/setup.ps1) skips the files with "-linux" in their name and adds tools/versions-windows.txt. +# If a main URL fails, setup tries instead (still checked against the hash). +# Only freely licensed files are mirrored there (tools/make-mirror.sh); the rest just fails as before. +#mirror https://git.userevolt.app/esef/cs16-dm-server/releases/download/mods/ # engine + game: ReHLDS, ReGameDLL_CS (MIT) rehlds-bin-3.15.0.896.zip 997baeb7ef3842dab3e034d82dc651ebfe560c23b158adce660a6b97976b4e2b https://github.com/rehlds/ReHLDS/releases/download/3.15.0.896/rehlds-bin-3.15.0.896.zip diff --git a/windows/common.ps1 b/windows/common.ps1 new file mode 100644 index 0000000..2520dd4 --- /dev/null +++ b/windows/common.ps1 @@ -0,0 +1,461 @@ +# Shared helpers for the Windows scripts (setup.ps1, install.ps1, join.ps1, cs.ps1). +# Windows PowerShell 5.1: keep this file ASCII and avoid PowerShell 7-only syntax. +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' # the progress bar makes downloads crawl in 5.1 +# older Windows 10 installs still default to TLS 1.0: GitHub needs 1.2 +[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 +Add-Type -AssemblyName System.IO.Compression.FileSystem + +$Script:Utf8 = New-Object Text.UTF8Encoding $false + +# Defaults for settings.txt (same keys as the Linux .env) +$Script:DefaultSettings = [ordered]@{ + CS_HOSTNAME = 'CS 1.6 Deathmatch' + CS_PASSWORD = '' + CS_MAXPLAYERS = '16' + CS_PORT = '27015' + CS_MAP = 'de_dust2' + CS_MODE = 'dm' + CS_BOTS = '6' + CS_BOT_DIFFICULTY = '2' + CS_PUBLIC_ADDR = '' +} + +function Say($text) { Write-Host ''; Write-Host "== $text" -ForegroundColor Cyan } +function Step($text) { Write-Host " - $text" } +function Warn($text) { Write-Host $text -ForegroundColor Yellow } + +# Writes text as UTF-8 without BOM (HLDS ignores UTF-16 cfg files, which is what 5.1's > writes) +function Write-TextFile($path, $text) { [IO.File]::WriteAllText($path, $text, $Script:Utf8) } + +# ---------- settings.txt (KEY=value lines, # comments) ---------- + +function Read-Settings($path) { + $s = [ordered]@{} + foreach ($k in $Script:DefaultSettings.Keys) { $s[$k] = $Script:DefaultSettings[$k] } + if (Test-Path -LiteralPath $path) { + foreach ($line in [IO.File]::ReadAllLines($path)) { + if ($line -match '^\s*#' -or $line -notmatch '=') { continue } + $i = $line.IndexOf('=') + $s[$line.Substring(0, $i).Trim()] = $line.Substring($i + 1).Trim() + } + } + return $s +} + +function Write-Setting($path, $key, $value) { + $value = ([string]$value) -replace '["\r\n]', '' + $lines = New-Object Collections.Generic.List[string] + if (Test-Path -LiteralPath $path) { $lines.AddRange([string[]][IO.File]::ReadAllLines($path)) } + $done = $false + for ($i = 0; $i -lt $lines.Count; $i++) { + if ($lines[$i] -match ('^' + [regex]::Escape($key) + '=')) { $lines[$i] = "$key=$value"; $done = $true } + } + if (-not $done) { $lines.Add("$key=$value") } + Write-TextFile $path (($lines -join "`r`n") + "`r`n") +} + +# ---------- install folder ---------- + +# Where to install by default: Documents\cs16, unless Documents is synced by OneDrive or the +# path has characters SteamCMD/HLDS can't handle. Returns @{ Path = ...; Reason = ... }. +function Resolve-InstallDir($documents, $userProfile, [string[]]$oneDriveRoots) { + $path = Join-Path $documents 'cs16' + $reason = '' + foreach ($od in $oneDriveRoots) { + if ($od -and $documents.StartsWith($od, [StringComparison]::OrdinalIgnoreCase)) { + $path = Join-Path $userProfile 'cs16' + $reason = 'Your Documents folder is synced by OneDrive, which would upload ~1 GB of server files and can break them.' + } + } + if ($path -match '[^\x20-\x7e]') { + $path = 'C:\cs16' + $reason = 'Your user folder name has special characters that the game server cannot handle.' + } + return @{ Path = $path; Reason = $reason } +} + +# Returns a problem description for a folder the user typed, or $null when it is usable +function Test-InstallDir($path) { + if (-not [IO.Path]::IsPathRooted($path)) { return 'give a full path, like C:\cs16' } + if ($path -match '[^\x20-\x7e]') { return 'use only plain letters and digits in the path (no accents or other alphabets)' } + if ($path -match '[\[\]]') { return 'leave out [ and ] in the folder name' } + foreach ($pf in @($env:ProgramFiles, ${env:ProgramFiles(x86)})) { + if ($pf -and $path.StartsWith($pf, [StringComparison]::OrdinalIgnoreCase)) { return 'Program Files needs admin rights: pick another folder' } + } + return $null +} + +# ---------- pinned downloads ---------- + +# tools/versions.txt minus Linux-only files, plus tools/versions-windows.txt +# Each item also gets a Mirror URL (backup copy on the project's Gitea, '#mirror ' line) +function Get-PinnedDownloads($repo) { + $out = @(); $mirror = '' + foreach ($f in @('versions.txt', 'versions-windows.txt')) { + $p = Join-Path $repo "tools\$f" + if (-not (Test-Path -LiteralPath $p)) { continue } + foreach ($line in [IO.File]::ReadAllLines($p)) { + $line = $line.Trim() + if ($line.StartsWith('#mirror ')) { $mirror = $line.Substring(8).Trim(); continue } + if ($line -eq '' -or $line.StartsWith('#')) { continue } + $parts = $line -split '\s+' + if ($parts.Count -lt 3 -or $parts[0] -match '-linux') { continue } + $out += [pscustomobject]@{ File = $parts[0]; Sha = $parts[1].ToLower(); Url = $parts[2]; Mirror = '' } + } + } + if ($mirror) { foreach ($i in $out) { $i.Mirror = $mirror + ($i.File -split '/')[-1] } } + return $out +} + +function Get-Sha256($path) { # .NET directly: Get-FileHash is missing when 5.1 inherits PowerShell 7's module path + $sha = [Security.Cryptography.SHA256]::Create(); $f = [IO.File]::OpenRead($path) + try { return (-join ($sha.ComputeHash($f) | ForEach-Object { $_.ToString('x2') })) } finally { $f.Dispose(); $sha.Dispose() } +} + +function Get-Url($url, $outFile) { + for ($try = 1; $try -le 3; $try++) { + try { Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $outFile -TimeoutSec 120; return } + catch { if ($try -eq 3) { throw "download failed: $url ($($_.Exception.Message))" }; Start-Sleep -Seconds (2 * $try) } + } +} + +# Downloads one pinned file into $dlDir unless it is already there with the right hash +function Get-Pinned($item, $dlDir) { + $dest = Join-Path $dlDir ($item.File -replace '/', '\') + if ((Test-Path -LiteralPath $dest) -and (Get-Sha256 $dest) -eq $item.Sha) { return $dest } + New-Item -ItemType Directory -Force -Path (Split-Path $dest) | Out-Null + $part = "$dest.part" + # main site first; if it fails or sends something else (an error page, a blocked-site page), + # the backup copy. Whatever arrives must match the pinned hash. + $got = $null + try { Get-Url $item.Url $part; $got = Get-Sha256 $part } catch { if (-not $item.Mirror) { throw } } + if ($got -ne $item.Sha -and $item.Mirror) { + Write-Host " $($item.File): main site failed, trying the backup copy" + try { Get-Url $item.Mirror $part } catch { throw "download failed: $($item.Url) (and the backup copy)" } + $got = Get-Sha256 $part + } + if ($got -ne $item.Sha) { + Remove-Item -LiteralPath $part -Force + throw "checksum mismatch for $($item.File): expected $($item.Sha), got $got" + } + Move-Item -LiteralPath $part -Destination $dest -Force + return $dest +} + +# Extracts the entries of $zip under $prefix (e.g. 'bin/win32/') into $dest, without the prefix +function Expand-ZipPart($zip, $prefix, $dest) { + $z = [IO.Compression.ZipFile]::OpenRead($zip) + try { + $n = 0 + foreach ($e in $z.Entries) { + $name = $e.FullName -replace '\\', '/' + if (-not $name.StartsWith($prefix) -or $name -eq $prefix) { continue } + $target = Join-Path $dest (($name.Substring($prefix.Length)) -replace '/', '\') + if ($name.EndsWith('/')) { New-Item -ItemType Directory -Force -Path $target | Out-Null; continue } # e.g. amxmodx\logs + New-Item -ItemType Directory -Force -Path (Split-Path $target) | Out-Null + [IO.Compression.ZipFileExtensions]::ExtractToFile($e, $target, $true) + $n++ + } + if ($n -eq 0) { throw "nothing under '$prefix' in $zip" } + } finally { $z.Dispose() } +} + +# Old 6-field CSDM spawn lines (de_prodigy, de_train) -> the 10 fields CSDM-ReAPI reads +function Convert-SpawnLine($line) { + $f = @($line.Trim() -split '\s+' | Where-Object { $_ -ne '' }) + if ($f.Count -eq 6) { return "$($line.TrimEnd()) 0 0 0 0" } + return $line +} + +# ---------- talking to the server (A2S query, RCON) ---------- + +$Script:Hdr = [byte[]](255, 255, 255, 255) + +function Send-Udp($client, [byte[]]$data) { [void]$client.Send($data, $data.Length) } + +function Receive-Udp($client) { + $ep = New-Object Net.IPEndPoint ([Net.IPAddress]::Any), 0 + try { return $client.Receive([ref]$ep) } catch { return $null } +} + +function Read-CString([byte[]]$d, [ref]$pos) { + $start = $pos.Value + while ($pos.Value -lt $d.Length -and $d[$pos.Value] -ne 0) { $pos.Value++ } + $s = [Text.Encoding]::UTF8.GetString($d, $start, $pos.Value - $start) + $pos.Value++ + return $s +} + +# Parses an A2S_INFO reply ('I' packet with the 4-byte header) +function ConvertFrom-A2SInfo([byte[]]$d) { + if ($d.Length -lt 6 -or $d[4] -ne 0x49) { return $null } + $pos = 6 + $name = Read-CString $d ([ref]$pos); $map = Read-CString $d ([ref]$pos) + [void](Read-CString $d ([ref]$pos)); [void](Read-CString $d ([ref]$pos)) + $pos += 2 # app id + return [pscustomobject]@{ Name = $name; Map = $map; Players = $d[$pos]; Max = $d[$pos + 1]; Bots = $d[$pos + 2]; Password = ($d[$pos + 5] -ne 0) } +} + +function Get-A2SInfo($hostName, [int]$port, [int]$timeoutMs = 3000) { + $c = New-Object Net.Sockets.UdpClient + try { + $c.Client.ReceiveTimeout = $timeoutMs + $c.Connect($hostName, $port) + $q = $Script:Hdr + [Text.Encoding]::ASCII.GetBytes("TSource Engine Query") + [byte[]](0) + Send-Udp $c $q + $d = Receive-Udp $c + if ($d -and $d.Length -ge 9 -and $d[4] -eq 0x41) { # challenge first: ask again with it + Send-Udp $c ($q + $d[5..8]) + $d = Receive-Udp $c + } + if (-not $d) { return $null } + return ConvertFrom-A2SInfo $d + } catch { return $null } finally { $c.Close() } +} + +function Format-A2SInfo($i) { "$($i.Name) | map $($i.Map) | players $($i.Players)/$($i.Max) (bots $($i.Bots))" } + +function Invoke-Rcon([int]$port, $password, $cmd) { + $c = New-Object Net.Sockets.UdpClient + try { + $c.Client.ReceiveTimeout = 3000 + $c.Connect('127.0.0.1', $port) + Send-Udp $c ($Script:Hdr + [Text.Encoding]::ASCII.GetBytes("challenge rcon`n")) + $d = Receive-Udp $c + if (-not $d) { return '(no answer: server down or changing map?)' } + $parts = [Text.Encoding]::ASCII.GetString($d, 4, $d.Length - 4).Trim() -split '\s+' + if ($parts.Count -lt 3) { return '(no answer: server down or changing map?)' } + Send-Udp $c ($Script:Hdr + [Text.Encoding]::UTF8.GetBytes("rcon $($parts[2]) `"$password`" $cmd")) + $sb = New-Object Text.StringBuilder + while ($true) { # long replies come in several packets + $d = Receive-Udp $c + if (-not $d) { break } + if ($d.Length -ge 5 -and $d[0] -eq 255 -and $d[1] -eq 255 -and $d[2] -eq 255 -and $d[3] -eq 255) { + [void]$sb.Append([Text.Encoding]::UTF8.GetString($d, 5, $d.Length - 5)) + } else { [void]$sb.Append([Text.Encoding]::UTF8.GetString($d)) } + $c.Client.ReceiveTimeout = 400 + } + $text = ($sb.ToString() -split "`r?`n" | Where-Object { $_ -match '^Bad rcon_password' -or $_ -notmatch 'rcon_password|sv_password' }) -join "`n" + if ($text.Trim() -eq '') { return '(ok, no output)' } + return $text.TrimEnd() + } catch { return '(no answer: server down or changing map?)' } finally { $c.Close() } +} + +# ---------- kill counter ---------- + +$Script:KillRx = New-Object regex '^L (\d\d/\d\d/\d{4}) - [\d:]+: "(.+?)<\d+><[^>]*><[^>]*>" killed ".+?<\d+><[^>]*><[^>]*>" with "([^"]+)"' + +function Get-KillLines([string[]]$lines) { + foreach ($l in $lines) { + $m = $Script:KillRx.Match($l) + if ($m.Success) { [pscustomobject]@{ Day = $m.Groups[1].Value; Killer = $m.Groups[2].Value; Weapon = $m.Groups[3].Value } } + } +} + +# Reads a text file the server may still be writing (File.ReadAllLines fails on it) +function Read-SharedLines($path) { + $fs = New-Object IO.FileStream $path, ([IO.FileMode]::Open), ([IO.FileAccess]::Read), ([IO.FileShare]::ReadWrite) + $r = New-Object IO.StreamReader $fs, $Script:Utf8 + try { return @($r.ReadToEnd() -split "`r?`n") } finally { $r.Dispose() } +} + +function ConvertTo-Hash($o) { + $h = @{} + if ($o) { foreach ($p in $o.PSObject.Properties) { $h[$p.Name] = [int]$p.Value } } + return $h +} + +# Counts the kill lines in the server logs. Per-log counts are kept in $stateFile so totals +# survive deleted logs. Returns @{ Total; Days; Maps; Who (hashtable); Weapons (hashtable) }. +function Update-KillTally($logsDir, $stateFile) { + $files = @{} + if (Test-Path -LiteralPath $stateFile) { + try { + $st = [IO.File]::ReadAllText($stateFile) | ConvertFrom-Json + foreach ($p in $st.files.PSObject.Properties) { + $v = $p.Value + $files[$p.Name] = @{ size = [long]$v.size; kills = [int]$v.kills; who = (ConvertTo-Hash $v.who); wpn = (ConvertTo-Hash $v.wpn); days = @($v.days) } + } + } catch { $files = @{} } + } + if (Test-Path -LiteralPath $logsDir) { + foreach ($f in Get-ChildItem -LiteralPath $logsDir -Filter *.log) { + if ($files.ContainsKey($f.Name) -and $files[$f.Name].size -eq $f.Length) { continue } + $who = @{}; $wpn = @{}; $days = @{}; $n = 0 + foreach ($k in Get-KillLines (Read-SharedLines $f.FullName)) { + $n++; $days[$k.Day] = 1; $who[$k.Killer] = 1 + [int]$who[$k.Killer]; $wpn[$k.Weapon] = 1 + [int]$wpn[$k.Weapon] + } + $files[$f.Name] = @{ size = $f.Length; kills = $n; who = $who; wpn = $wpn; days = @($days.Keys) } + } + } + $json = @{ files = $files } | ConvertTo-Json -Depth 6 -Compress + Write-TextFile "$stateFile.tmp" $json + Move-Item -LiteralPath "$stateFile.tmp" -Destination $stateFile -Force + + $t = @{ Total = 0; Days = @{}; Maps = $files.Count; Who = @{}; Weapons = @{} } + foreach ($v in $files.Values) { + $t.Total += $v.kills + foreach ($d in $v.days) { if ($d) { $t.Days[$d] = 1 } } + foreach ($k in $v.who.Keys) { $t.Who[$k] = $v.who[$k] + [int]$t.Who[$k] } + foreach ($k in $v.wpn.Keys) { $t.Weapons[$k] = $v.wpn[$k] + [int]$t.Weapons[$k] } + } + return $t +} + +# ---------- the server process ---------- + +function Get-ServerProcess($hldsDir) { + $exe = Join-Path $hldsDir 'hlds.exe' + Get-Process -Name hlds -ErrorAction SilentlyContinue | Where-Object { + try { $_.Path -and ($_.Path -ieq $exe) } catch { $false } + } | Select-Object -First 1 +} + +function Test-UdpPortFree([int]$port) { + try { $u = New-Object Net.Sockets.UdpClient $port; $u.Close(); return $true } catch { return $false } +} + +function Get-RconPassword($root) { ([IO.File]::ReadAllText((Join-Path $root 'secrets\rcon-password.txt'))).Trim() } + +# Writes the per-start configs from settings.txt (twin of tools/start.sh), then starts hlds.exe +# in its own window. Does nothing when this install's server is already running. +function Start-CsServer($root) { + $h = Join-Path $root 'hlds'; $c = Join-Path $h 'cstrike' + if (Get-ServerProcess $h) { Write-Host ' the server is already running'; return $false } + $s = Read-Settings (Join-Path $root 'settings.txt') + $clean = { param($v) ([string]$v) -replace '["\r\n]', '' } + Write-TextFile (Join-Path $c 'env.cfg') ("// written from settings.txt at every start: edit settings.txt instead`r`nhostname `"" + (& $clean $s.CS_HOSTNAME) + "`"`r`n") + Write-TextFile (Join-Path $c 'secrets.cfg') ("// written at every start (RCON: secrets\rcon-password.txt, join: CS_PASSWORD in settings.txt)`r`n" + + "rcon_password `"" + (Get-RconPassword $root) + "`"`r`nsv_password `"" + (& $clean $s.CS_PASSWORD) + "`"`r`n") + $ycfg = Join-Path $c 'addons\yapb\conf\yapb.cfg' + $y = [IO.File]::ReadAllText($ycfg) + $y = $y -replace '(?m)^yb_quota ".*"', ('yb_quota "' + [int]$s.CS_BOTS + '"') + $y = $y -replace '(?m)^yb_difficulty ".*"', ('yb_difficulty "' + [int]$s.CS_BOT_DIFFICULTY + '"') + Write-TextFile $ycfg $y + $hargs = "-console -condebug -game cstrike -port $([int]$s.CS_PORT) +maxplayers $([int]$s.CS_MAXPLAYERS) +map $($s.CS_MAP) +log on" + Restore-SteamClientSetting + # With Steam installed, the server's steam_api.dll loads the Steam client's steamclient.dll + # (found through this registry value) instead of its own, and stops with "Unable to initialize + # Steam". So the value is hidden until the server answers queries (Steam is set up by then: + # a few seconds) or gives up. Steam writes it again at every start, and + # Restore-SteamClientSetting (run whenever a script loads this file) repairs an interrupted run. + # (A 64-bit PowerShell can't see the 32-bit server's DLLs, so the query is the signal.) + $hidden = $false + if ((Get-ItemProperty -Path $Script:SteamKey -ErrorAction SilentlyContinue).SteamClientDll) { + Rename-ItemProperty -Path $Script:SteamKey -Name SteamClientDll -NewName $Script:SteamHidden + $hidden = $true + } + try { + $p = Start-Process -FilePath (Join-Path $h 'hlds.exe') -WorkingDirectory $h -ArgumentList $hargs -PassThru + if ($hidden) { + $end = (Get-Date).AddSeconds(90) + while ((Get-Date) -lt $end -and -not $p.HasExited) { + if (Get-A2SInfo '127.0.0.1' ([int]$s.CS_PORT) 1000) { break } + Start-Sleep -Milliseconds 500 + } + } + } finally { if ($hidden) { Restore-SteamClientSetting } } + return $true +} + +$Script:SteamKey = 'HKCU:\Software\Valve\Steam\ActiveProcess' +$Script:SteamHidden = 'SteamClientDll_hidden_by_cs16' + +function Restore-SteamClientSetting { + $v = Get-ItemProperty -Path $Script:SteamKey -ErrorAction SilentlyContinue + if (-not $v -or -not $v.($Script:SteamHidden)) { return } + if ($v.SteamClientDll) { Remove-ItemProperty -Path $Script:SteamKey -Name $Script:SteamHidden } # Steam wrote a fresh one + else { Rename-ItemProperty -Path $Script:SteamKey -Name $Script:SteamHidden -NewName SteamClientDll } +} + +function Stop-CsServer($root) { + $h = Join-Path $root 'hlds' + $p = Get-ServerProcess $h + if (-not $p) { return $false } + $s = Read-Settings (Join-Path $root 'settings.txt') + [void](Invoke-Rcon ([int]$s.CS_PORT) (Get-RconPassword $root) 'quit') + if (-not $p.WaitForExit(8000)) { Stop-Process -Id $p.Id -Force } + return $true +} + +# Waits for the server to answer a query on 127.0.0.1; returns the info or $null +function Wait-CsServer([int]$port, [int]$seconds = 60) { + $end = (Get-Date).AddSeconds($seconds) + while ((Get-Date) -lt $end) { + $i = Get-A2SInfo '127.0.0.1' $port 2000 + if ($i) { return $i } + Write-Host -NoNewline '.' + Start-Sleep -Seconds 2 + } + return $null +} + +function Get-ConsoleTail($root, [int]$n = 20) { + $log = Join-Path $root 'hlds\qconsole.log' # -condebug writes it next to hlds.exe + if (-not (Test-Path -LiteralPath $log)) { return @() } + Get-Content -LiteralPath $log -Tail $n | Where-Object { $_ -notmatch 'rcon_password|sv_password|Rcon:' } +} + +function Get-ServerMode($root) { + if (Test-Path -LiteralPath (Join-Path $root 'hlds\cstrike\addons\amxmodx\configs\plugins-gungame.ini')) { 'GunGame' } else { 'deathmatch' } +} + +# ---------- network addresses ---------- + +$Script:VirtualIf = 'vEthernet|Loopback|VirtualBox|VMware|Hyper-V|WSL|Tailscale|ZeroTier|Bluetooth' + +function Get-LanIPv4 { + @(Get-NetIPAddress -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object { + $_.IPAddress -notmatch '^(127\.|169\.254\.)' -and $_.InterfaceAlias -notmatch $Script:VirtualIf -and + -not (Test-CgnatAddress $_.IPAddress) + } | ForEach-Object { $_.IPAddress }) +} + +function Test-CgnatAddress($ip) { # 100.64.0.0/10: Tailscale uses it + $o = $ip -split '\.' + return ($o[0] -eq '100' -and [int]$o[1] -ge 64 -and [int]$o[1] -le 127) +} + +function Get-VpnIPv4 { + @(Get-NetIPAddress -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object { + $_.InterfaceAlias -match 'Tailscale|ZeroTier' -or (Test-CgnatAddress $_.IPAddress) + } | ForEach-Object { [pscustomobject]@{ IP = $_.IPAddress; Name = $_.InterfaceAlias } }) +} + +# ---------- firewall ---------- + +$Script:FwRule = 'CS 1.6 server (cs16-dm-server)' + +# Normal (non-admin) sessions often can't read firewall rules, so the admin step below leaves a +# marker with the exact server path once its rule is in place. +function Get-FirewallMarker($root) { Join-Path $root 'secrets\firewall-rule.txt' } + +function Test-FirewallRule($root) { + $m = Get-FirewallMarker $root + if (-not (Test-Path -LiteralPath $m)) { return $false } + return ([IO.File]::ReadAllText($m).Trim() -ieq (Join-Path $root 'hlds\hlds.exe')) +} + +# Asks Windows (admin prompt) to let hlds.exe receive connections on every network type, and +# removes the block rule Windows creates when its own "allow access" popup gets cancelled. +# The elevated part checks its own result and reports it through its exit code. +function Add-FirewallRule($root) { + $exePath = Join-Path $root 'hlds\hlds.exe' + $exe = $exePath -replace "'", "''" # inside single quotes below + $ps = "`$ErrorActionPreference = 'Stop'; try { " + + "Get-NetFirewallApplicationFilter -Program '$exe' -ErrorAction SilentlyContinue | Get-NetFirewallRule | Where-Object { `$_.Direction -eq 'Inbound' -and `$_.Action -eq 'Block' } | Remove-NetFirewallRule; " + + "Get-NetFirewallRule -DisplayName '$($Script:FwRule)' -ErrorAction SilentlyContinue | Remove-NetFirewallRule; " + + "New-NetFirewallRule -DisplayName '$($Script:FwRule)' -Direction Inbound -Action Allow -Program '$exe' -Profile Any | Out-Null; " + + "if (Get-NetFirewallRule -DisplayName '$($Script:FwRule)' -ErrorAction SilentlyContinue) { exit 0 } else { exit 1 } } catch { exit 1 }" + $enc = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($ps)) + try { + $p = Start-Process -FilePath powershell.exe -Verb RunAs -Wait -PassThru -WindowStyle Hidden -ArgumentList "-NoProfile -EncodedCommand $enc" + if ($p.ExitCode -ne 0) { return $false } + Write-TextFile (Get-FirewallMarker $root) $exePath + return $true + } catch { return $false } # the admin prompt was answered No +} + +# repair a Steam setting left hidden by an interrupted Start-CsServer +Restore-SteamClientSetting diff --git a/windows/cs.ps1 b/windows/cs.ps1 new file mode 100644 index 0000000..a043ed7 --- /dev/null +++ b/windows/cs.ps1 @@ -0,0 +1,123 @@ +# Control menu for Windows: double-click cs.cmd (pick a number) +# or directly: cs.cmd status | start | stop | restart | map | mode dm|gg | bots | kills +# | say | join | console | update +param([Parameter(ValueFromRemainingArguments = $true)][string[]]$Cmd) +. (Join-Path $PSScriptRoot 'common.ps1') +trap { Write-Host "error: $($_.Exception.Message)" -ForegroundColor Red; exit 1 } + +$Root = Split-Path -Parent $PSScriptRoot +$settingsFile = Join-Path $Root 'settings.txt' +if (-not (Test-Path -LiteralPath $settingsFile)) { throw 'not set up yet: double-click setup.cmd first' } +$H = Join-Path $Root 'hlds'; $C = Join-Path $H 'cstrike' + +function Port { [int](Read-Settings $settingsFile).CS_PORT } +function Rcon($command) { Invoke-Rcon (Port) (Get-RconPassword $Root) $command } +function Answered($reply) { -not $reply.StartsWith('(no answer') } + +function Show-Status { + if (-not (Get-ServerProcess $H)) { Write-Host ' server is stopped (start it: 9)'; return } + $i = Get-A2SInfo '127.0.0.1' (Port) 2000 + if ($i) { Write-Host " $(Format-A2SInfo $i)" } else { Write-Host ' server window open, not answering yet (starting or changing map?)' } + Write-Host " mode: $(Get-ServerMode $Root)" +} +function Show-Players { + Show-Status + if (Get-ServerProcess $H) { (Rcon 'status') -split "`n" | Where-Object { $_ -match '^(#|map|players)' } | ForEach-Object { Write-Host " $_" } } +} +function Set-Map($m) { + $maps = @(Get-ChildItem -LiteralPath "$C\maps" -Filter *.bsp -ErrorAction SilentlyContinue | ForEach-Object { $_.BaseName } | Sort-Object) + if (-not $m) { + Write-Host ' Maps on this server:' + for ($i = 0; $i -lt $maps.Count; $i++) { Write-Host (' {0,3}) {1}' -f ($i + 1), $maps[$i]) } + $m = (Read-Host ' map name or number').Trim() + if ($m -match '^\d+$' -and [int]$m -ge 1 -and [int]$m -le $maps.Count) { $m = $maps[[int]$m - 1] } + } + if (-not $m) { return } + if ($maps -notcontains $m) { Write-Host " no map called '$m' in $C\maps"; return } + $r = Rcon "changelevel $m" + if (Answered $r) { Write-Host " changing to $m" } else { Write-Host " $r" } +} +function Set-Mode($m) { + if (-not $m) { $m = (Read-Host ' mode: dm = deathmatch, gg = GunGame').Trim() } + if ($m -ne 'dm' -and $m -ne 'gg') { Write-Host ' use dm or gg'; return } + $r = Rcon "esef_mode $m" + if (Answered $r) { ($r -split "`n" | Where-Object { $_ -match 'esef_mode' }) | ForEach-Object { Write-Host " $_" } } else { Write-Host " $r" } +} +function Set-Bots($n) { + $s = Read-Settings $settingsFile + if (-not $n) { $n = (Read-Host " fill the server up to how many players with bots? (0 = no bots, now $($s.CS_BOTS))").Trim() } + if ($n -notmatch '^\d+$') { Write-Host ' give a number'; return } + Write-Setting $settingsFile 'CS_BOTS' $n # remembered for the next start + $r = Rcon "yb_quota $n" + if (Answered $r) { Write-Host " bots: up to $n players (saved)" } else { Write-Host " saved, applies when the server runs" } +} +function Show-Kills { + $t = Update-KillTally "$C\logs" (Join-Path $Root 'kills-tally.json') + Write-Host '' + Write-Host (' ALL-TIME KILLS: {0:N0} ({1} days of carnage, {2} maps played)' -f $t.Total, $t.Days.Count, $t.Maps) + Write-Host ''; Write-Host ' top fraggers:' + $i = 0 + foreach ($e in ($t.Who.GetEnumerator() | Sort-Object Value -Descending | Select-Object -First 5)) { $i++; Write-Host (' {0}. {1,-18} {2:N0}' -f $i, $e.Key, $e.Value) } + Write-Host ''; Write-Host ' weapons of choice:' + foreach ($e in ($t.Weapons.GetEnumerator() | Sort-Object Value -Descending | Select-Object -First 5)) { Write-Host (' {0,-18} {1:N0}' -f $e.Key, $e.Value) } +} +function Start-Server { + if (Start-CsServer $Root) { + Write-Host -NoNewline ' starting' + $i = Wait-CsServer (Port) 60 + Write-Host '' + if ($i) { Write-Host " up: $(Format-A2SInfo $i)" } else { Write-Host ' no answer after 60 s, last lines:'; Get-ConsoleTail $Root 20 | ForEach-Object { Write-Host " $_" } } + } +} +function Stop-Server { if (Stop-CsServer $Root) { Write-Host ' stopped' } else { Write-Host ' the server is not running' } } +function Open-Console { + Write-Host ' CS 1.6 console: status, changelevel de_nuke, yb_quota 6, esef_mode gg, say hi. Empty line or exit to leave.' + while ($true) { + $l = (Read-Host 'cs16').Trim() + if ($l -eq '' -or $l -eq 'exit' -or $l -eq 'quit') { break } + Write-Host (Rcon $l) + } +} + +function Run($choice, $rest) { + switch ($choice) { + { $_ -in 'status', '1' } { Show-Players } + { $_ -in 'map', '2' } { Set-Map $rest } + { $_ -in 'mode', '3' } { Set-Mode $rest } + { $_ -in 'bots', '4' } { Set-Bots $rest } + { $_ -in 'kills', '5' } { Show-Kills } + { $_ -in 'say', '6' } { + $t = $rest; if (-not $t) { $t = (Read-Host ' message').Trim() } + if ($t) { [void](Rcon "say $t"); Write-Host ' sent' } + } + { $_ -in 'restart', '7' } { [void](Stop-CsServer $Root); Start-Server } + { $_ -in 'stop', '8' } { Stop-Server } + { $_ -in 'start', '9' } { Start-Server } + { $_ -in 'join', '10' } { & (Join-Path $PSScriptRoot 'join.ps1') -Root $Root } + { $_ -in 'console', '11' } { Open-Console } + { $_ -in 'update', '12' } { & (Join-Path $PSScriptRoot 'setup.ps1') -Yes -Update } + default { Write-Host " unknown: $choice" } + } +} + +if ($Cmd.Count -gt 0) { Run $Cmd[0] (($Cmd | Select-Object -Skip 1) -join ' '); exit 0 } + +while ($true) { + $s = Read-Settings $settingsFile + Write-Host '' + Write-Host "== $($s.CS_HOSTNAME) == (UDP $($s.CS_PORT))" -ForegroundColor Cyan + Show-Status + Write-Host @' + + 1) who's playing 7) restart server + 2) change map 8) stop server + 3) game mode (DM / GG) 9) start server + 4) bots 10) how friends join + 5) kill counter 11) console (type server commands) + 6) message to players 12) update game + mods + 0) quit +'@ + $choice = (Read-Host '>').Trim() + if ($choice -eq '' -or $choice -eq '0' -or $choice -eq 'q') { break } + Run $choice '' +} diff --git a/windows/install.ps1 b/windows/install.ps1 new file mode 100644 index 0000000..653d4dc --- /dev/null +++ b/windows/install.ps1 @@ -0,0 +1,130 @@ +# Puts the mod stack on top of the Valve game files in hlds\ (Windows twin of tools/install-addons.sh). +# setup.ps1 runs it after every game download/update: a Steam "validate" puts Valve's engine back. +# Safe to run again. Kept on re-runs: maps, stats, bot navigation, and your edits to users.ini +# (admins), amxx.cfg, csdm\config.ini, mapcycle.txt and custom.cfg. +param([Parameter(Mandatory = $true)][string]$Root) +. (Join-Path $PSScriptRoot 'common.ps1') + +$DL = Join-Path $Root 'dl'; $H = Join-Path $Root 'hlds'; $C = Join-Path $H 'cstrike' +$A = Join-Path $C 'addons'; $CF = Join-Path $A 'amxmodx\configs' +if (-not (Test-Path -LiteralPath (Join-Path $H 'hlds.exe'))) { throw "no game files in $H (the Steam download didn't finish)" } + +function Zip($pattern) { + $f = @(Get-ChildItem -LiteralPath $DL -Filter $pattern | Sort-Object Name | Select-Object -Last 1) + if ($f.Count -eq 0) { throw "missing download $pattern in $DL" } + return $f[0].FullName +} +function Read-ZipText($zip, $entry) { + $z = [IO.Compression.ZipFile]::OpenRead($zip) + try { + $e = $z.GetEntry($entry); if (-not $e) { throw "$entry not found in $zip" } + $r = New-Object IO.StreamReader $e.Open(); try { return $r.ReadToEnd() } finally { $r.Close() } + } finally { $z.Dispose() } +} +function Edit-File($path, [scriptblock]$change) { Write-TextFile $path (& $change ([IO.File]::ReadAllText($path))) } + +Step 'downloads (pinned, checked against tools/versions*.txt)' +foreach ($item in Get-PinnedDownloads $Root) { [void](Get-Pinned $item $DL) } + +# Game mode in use right now (re-run) or from settings.txt (first install) +# (the plugin lists only say something after a finished install: the repo itself ships an active +# plugins-gungame.ini, so an interrupted first install would otherwise come back as GunGame) +$settings = Read-Settings (Join-Path $Root 'settings.txt') +$first = -not (Test-Path -LiteralPath "$C\.cs16-dm-server") +if ($first) { $mode = $settings.CS_MODE } +elseif (Test-Path -LiteralPath "$CF\plugins-gungame.ini") { $mode = 'gg' } +else { $mode = 'dm' } +if ($mode -ne 'dm' -and $mode -ne 'gg') { throw "CS_MODE must be dm or gg (is: $mode)" } + +# Files people edit: save them now, put them back after the archives are unpacked +$keepDir = Join-Path $DL '.keep' +if (Test-Path -LiteralPath $keepDir) { Remove-Item -LiteralPath $keepDir -Recurse -Force } +if (-not $first) { + foreach ($f in 'addons\amxmodx\configs\users.ini', 'addons\amxmodx\configs\amxx.cfg', 'addons\amxmodx\configs\csdm\config.ini') { + if (Test-Path -LiteralPath "$C\$f") { + New-Item -ItemType Directory -Force -Path (Split-Path "$keepDir\$f") | Out-Null + Copy-Item -LiteralPath "$C\$f" -Destination "$keepDir\$f" -Force + } + } +} + +Step 'ReHLDS (engine)' +Expand-ZipPart (Zip 'rehlds-bin-*.zip') 'bin/win32/' $H + +Step 'ReGameDLL (game logic)' +Expand-ZipPart (Zip 'regamedll-bin-*.zip') 'bin/win32/cstrike/' $C +# game.cfg runs at every map start and would force alltalk off: server.cfg decides that instead +Edit-File "$C\game.cfg" { param($t) $t -replace '(?m)^sv_alltalk "0"', '// sv_alltalk "0" // cs16-dm-server: set in server.cfg instead' } + +Step 'Metamod-R' +Expand-ZipPart (Zip 'metamod-bin-*.zip') 'addons/' $A +if (-not (Test-Path -LiteralPath "$C\liblist.gam.orig")) { Copy-Item -LiteralPath "$C\liblist.gam" -Destination "$C\liblist.gam.orig" } +Edit-File "$C\liblist.gam" { param($t) $t -replace '(?m)^gamedll ".*"', 'gamedll "addons\metamod\metamod.dll"' } + +Step 'AMX Mod X + ReAPI' +Expand-ZipPart (Zip 'amxmodx-*-base-windows.zip') '' $C +Expand-ZipPart (Zip 'amxmodx-*-cstrike-windows.zip') '' $C +Expand-ZipPart (Zip 'reapi-bin-*.zip') 'addons/' $A + +Step 'ReUnion (non-Steam clients)' +$reu = Zip 'reunion-*.zip' +Expand-ZipPart $reu 'bin/Windows/' "$A\reunion" +$salt = ([IO.File]::ReadAllText((Join-Path $Root 'secrets\reunion-salt.txt'))).Trim() +if (-not $salt) { throw 'secrets\reunion-salt.txt is empty' } +# IDClientsLimit 0: several non-Steam players with the same placeholder ID can all join +$cfg = (Read-ZipText $reu 'reunion.cfg') -replace '(?m)^SteamIdHashSalt =.*$', "SteamIdHashSalt = $salt" -replace '(?m)^IDClientsLimit = .*$', 'IDClientsLimit = 0' +Write-TextFile "$C\reunion.cfg" $cfg + +Step 'CSDM-ReAPI (deathmatch) + spawn points' +Expand-ZipPart (Zip 'CSDM-ReAPI-*.zip') 'cstrike/' $C +New-Item -ItemType Directory -Force -Path "$CF\csdm\spawns" | Out-Null +foreach ($f in Get-ChildItem -LiteralPath "$DL\spawns" -Filter *.spawns.cfg) { + $lines = [IO.File]::ReadAllLines($f.FullName) | ForEach-Object { Convert-SpawnLine $_ } + $text = ($lines -join "`n") + "`n" + Write-TextFile "$CF\csdm\spawns\$($f.Name)" $text + Write-TextFile "$CF\csdm\$($f.Name)" $text # GunGame looks for them one folder up (Linux: a symlink) +} + +Step 'YaPB (bots)' +Expand-ZipPart (Zip 'yapb-*-windows.zip') '' $C +# FFA-aware bots; quota = fill up to CS_BOTS players (Start-CsServer sets the number) +Edit-File "$A\yapb\conf\yapb.cfg" { param($t) $t -replace '(?m)^yb_csdm_mode ".*"', 'yb_csdm_mode "2"' -replace '(?m)^yb_quota_mode ".*"', 'yb_quota_mode "fill"' } + +Step 'GunGame + announcer sounds' +Copy-Item -LiteralPath "$DL\gg\gungame.sma" -Destination "$A\amxmodx\scripting\" -Force +Copy-Item -LiteralPath "$DL\gg\gungame.txt" -Destination "$A\amxmodx\data\lang\" -Force +New-Item -ItemType Directory -Force -Path "$C\sound\gungame", "$C\sound\misc" | Out-Null +Copy-Item -Path "$DL\gg\*.wav" -Destination "$C\sound\gungame\" -Force +Copy-Item -Path "$DL\snd\*.wav" -Destination "$C\sound\misc\" -Force + +Step 'configs from the repo' +Copy-Item -Path (Join-Path $Root 'cstrike\*') -Destination $C -Recurse -Force +if ($first) { Copy-Item -LiteralPath (Join-Path $Root 'defaults\mapcycle.txt') -Destination "$C\mapcycle.txt" -Force } +if (-not (Test-Path -LiteralPath "$C\custom.cfg")) { Copy-Item -LiteralPath (Join-Path $Root 'defaults\custom.cfg') -Destination "$C\custom.cfg" } +if (Test-Path -LiteralPath $keepDir) { + Copy-Item -Path "$keepDir\*" -Destination $C -Recurse -Force + Remove-Item -LiteralPath $keepDir -Recurse -Force +} + +Step 'compiling plugins' +Copy-Item -Path (Join-Path $Root 'plugins\*.sma') -Destination "$A\amxmodx\scripting\" -Force +Push-Location "$A\amxmodx\scripting" +try { + $ErrorActionPreference = 'Continue' # 5.1 turns a native program's stderr into a fatal error under 'Stop' + foreach ($p in 'rtv', 'esef_extras', 'esef_mode', 'gungame') { + $out = & .\amxxpc.exe "$p.sma" "-o..\plugins\$p.amxx" 2>&1 + if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath "..\plugins\$p.amxx")) { $out | Write-Host; throw "compiling $p.sma failed" } + } +} finally { Pop-Location; $ErrorActionPreference = 'Stop' } + +Step "game mode: $mode" +# exactly one of the two plugin lists is active (.ini), the other one is .ini.off +foreach ($ini in 'plugins-gungame.ini', 'plugins-CSDM_ReAPI.ini') { + if (Test-Path -LiteralPath "$CF\$ini") { Remove-Item -LiteralPath "$CF\$ini.off" -Force -ErrorAction SilentlyContinue } # fresh copy wins + else { Move-Item -LiteralPath "$CF\$ini.off" -Destination "$CF\$ini" } +} +if ($mode -eq 'gg') { Move-Item -LiteralPath "$CF\plugins-CSDM_ReAPI.ini" -Destination "$CF\plugins-CSDM_ReAPI.ini.off" } +else { Move-Item -LiteralPath "$CF\plugins-gungame.ini" -Destination "$CF\plugins-gungame.ini.off" } + +Write-TextFile "$C\.cs16-dm-server" '' +Write-Host ' mod stack installed' diff --git a/windows/join.ps1 b/windows/join.ps1 new file mode 100644 index 0000000..1d2f3c8 --- /dev/null +++ b/windows/join.ps1 @@ -0,0 +1,111 @@ +# "How will your friends join?" - the last step of setup.cmd, and option 10 in the cs.cmd menu. +# Each choice either sets things up or walks you through it. +param([Parameter(Mandatory = $true)][string]$Root) +. (Join-Path $PSScriptRoot 'common.ps1') + +$settingsFile = Join-Path $Root 'settings.txt' +$s = Read-Settings $settingsFile +$port = [int]$s.CS_PORT + +function Pause-Enter($text = 'Press Enter when done') { [void](Read-Host " $text") } + +function Ensure-Firewall { + if (Test-FirewallRule $Root) { Write-Host ' Windows Firewall: the server is allowed in.'; return } + Write-Host ' Windows Firewall blocks players from other PCs until you allow the server.' + Write-Host ' Windows will now ask for admin permission (one time).' + if (Add-FirewallRule $Root) { Write-Host ' Firewall: allowed.' -ForegroundColor Green } + else { + Warn ' Firewall not changed. Only players on this PC can join until you allow it:' + Warn ' run cs.cmd -> 10 (how friends join) and say Yes to the admin prompt.' + } +} + +function Show-Connect($addr) { Write-Host " connect $addr" -ForegroundColor Green } + +function Join-Lan { + Ensure-Firewall + $ips = @(Get-LanIPv4) + Write-Host '' + Write-Host ' Players in your home open Counter-Strike, press ~ for the console and type:' + if ($ips.Count -eq 0) { Show-Connect ":$port"; Write-Host ' (no network address found: are you connected to your router?)' } + foreach ($ip in $ips) { Show-Connect "${ip}:$port" } + Write-Host ' On this same PC:'; Show-Connect "127.0.0.1:$port" +} + +function Join-Playit { + Write-Host '' + Write-Host ' playit.gg gives your server a public address without touching your router. It is free.' + Write-Host ' 1. Your browser opens playit.gg/download: download and install the Windows app.' + Write-Host ' 2. Start playit. It opens a page to sign up (or log in) and link this PC: follow it.' + Write-Host ' 3. On playit.gg, add a tunnel:' + Write-Host ' type: UDP' -ForegroundColor Green + Write-Host " local address: 127.0.0.1 local port: $port" -ForegroundColor Green + Write-Host ' 4. playit shows the address of the tunnel, something like name.ply.gg:12345' + Write-Host ' Keep the playit app running while you play.' + Pause-Enter 'Press Enter to open playit.gg' + Start-Process 'https://playit.gg/download' + $addr = (Read-Host ' Paste the tunnel address here (or Enter to do it later)').Trim() + if ($addr) { + $addr = $addr -replace '^(udp|https?)://', '' -replace '/.*$', '' + Write-Setting $settingsFile 'CS_PUBLIC_ADDR' $addr + Write-Host ' Saved. Your friends open Counter-Strike, press ~ and type:' + Show-Connect $addr + $hp = $addr -split ':' + if ($hp.Count -eq 2 -and (Get-A2SInfo $hp[0] ([int]$hp[1]) 4000)) { Write-Host ' Checked: the server answers through the tunnel.' -ForegroundColor Green } + else { Write-Host " (could not reach the server through the tunnel yet: check the tunnel is UDP and points to 127.0.0.1:$port)" } + } +} + +function Join-PortForward { + Ensure-Firewall + $gw = (Get-NetRoute -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue | Sort-Object RouteMetric | Select-Object -First 1).NextHop + $lan = @(Get-LanIPv4) + $pub = $null + try { $pub = (Invoke-WebRequest -UseBasicParsing -Uri 'https://api.ipify.org' -TimeoutSec 10).Content.Trim() } catch { } + Write-Host '' + Write-Host ' Your router has to send game traffic to this PC:' + Write-Host " 1. Open your router's page in a browser: http://$gw (the login is often on a sticker on the router)" + Write-Host ' 2. Find "Port forwarding" (sometimes under NAT, Virtual server or Gaming) and add:' + Write-Host " protocol: UDP external port: $port internal port: $port" -ForegroundColor Green + Write-Host " device / internal IP: $(if ($lan.Count) { $lan[0] } else { "this PC's IP" })" -ForegroundColor Green + Write-Host ' 3. Save. Some routers need a restart.' + Write-Host ' Tip: in your router, give this PC a fixed (reserved) IP, or the rule breaks when the IP changes.' + Write-Host '' + if ($pub) { Write-Host ' Your friends then type in the Counter-Strike console:'; Show-Connect "${pub}:$port"; Write-Setting $settingsFile 'CS_PUBLIC_ADDR' "${pub}:$port" } + else { Write-Host " Your friends then use connect :$port (look it up on whatismyip.com)" } + Write-Host ' You cannot test this from your own PC (most routers do not loop back): ask a friend to connect.' + Write-Host ' If it never works, your internet may be behind CGNAT (common on mobile and some fiber):' + Write-Host ' use playit.gg instead (option 2).' +} + +function Join-Vpn { + $v = @(Get-VpnIPv4) # @(): one address would otherwise come back as a single object + Write-Host '' + Write-Host ' With a VPN like Tailscale or ZeroTier, everyone installs the same app and joins your network.' + Write-Host ' Good for a fixed group of friends; no router setup.' + if ($v.Count) { + Ensure-Firewall + Write-Host ' Found on this PC:' + foreach ($x in $v) { Write-Host " $($x.Name):"; Show-Connect "$($x.IP):$port" } + } else { + Write-Host ' Not installed yet. Get one (free for small groups):' + Write-Host ' Tailscale: https://tailscale.com/download' + Write-Host ' ZeroTier: https://www.zerotier.com/download/' + Write-Host ' Invite your friends to your network, then run this step again (cs.cmd -> 10) to see your address.' + } +} + +Say 'How will your friends join?' +Write-Host ' 1) same house / LAN' +Write-Host ' 2) over the internet with playit.gg (easiest, no router setup)' +Write-Host ' 3) over the internet with port forwarding on your router' +Write-Host ' 4) VPN (Tailscale / ZeroTier)' +Write-Host ' 0) skip for now (cs.cmd -> 10 brings you back here)' +$c = (Read-Host ' >').Trim() +switch ($c) { + '1' { Join-Lan } + '2' { Join-Playit } + '3' { Join-PortForward } + '4' { Join-Vpn } + default { Write-Host ' skipped: double-click cs.cmd and pick 10 any time.' } +} diff --git a/windows/setup.ps1 b/windows/setup.ps1 new file mode 100644 index 0000000..4342c06 --- /dev/null +++ b/windows/setup.ps1 @@ -0,0 +1,205 @@ +# cs16-dm-server setup for Windows 10/11: double-click setup.cmd (which runs this). +# setup.cmd first install (asks a few questions), or re-run safely any time +# setup.cmd -Yes no questions: use settings.txt as it is (or the defaults) +# setup.cmd -Update also update the game files from Steam (then re-installs the mods) +# Steps: checks -> install folder -> questions -> secrets -> SteamCMD + CS 1.6 (~600 MB) +# -> mod stack -> firewall -> start -> "how will your friends join?" -> summary. +param([switch]$Yes, [switch]$Update) +. (Join-Path $PSScriptRoot 'common.ps1') +trap { + Write-Host '' + Write-Host "error: $($_.Exception.Message)" -ForegroundColor Red + Write-Host 'Fix the problem above, then double-click setup.cmd again (it continues where it stopped).' + exit 1 +} + +function Ask($question, $default) { + if ($Yes) { return $default } + $r = Read-Host " $question [$default]" + if ($r -eq '') { return $default } + return $r.Trim() +} +function New-Secret { + $chars = [char[]]'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' + $bytes = New-Object byte[] 32 + (New-Object Security.Cryptography.RNGCryptoServiceProvider).GetBytes($bytes) + -join ($bytes | ForEach-Object { $chars[$_ % $chars.Length] }) +} + +$repo = Split-Path -Parent $PSScriptRoot + +Say 'Checking this PC' +if (-not [Environment]::Is64BitOperatingSystem) { throw 'this needs 64-bit Windows 10 or 11' } +if ([Environment]::OSVersion.Version.Major -lt 10) { throw 'this needs Windows 10 or 11' } +Write-Host ' ok' + +Say 'Install folder' +$def = Resolve-InstallDir ([Environment]::GetFolderPath('MyDocuments')) $env:USERPROFILE @($env:OneDrive, $env:OneDriveConsumer, $env:OneDriveCommercial) +# Remembered install folder, so running setup.cmd from the downloaded copy again updates that install +$rememberFile = Join-Path $env:LOCALAPPDATA 'cs16-dm-server\install-folder.txt' +$known = $null +if (Test-Path -LiteralPath $rememberFile) { + $k = [IO.File]::ReadAllText($rememberFile).Trim() + if ($k -and (Test-Path -LiteralPath (Join-Path $k 'settings.txt'))) { $known = $k } +} +$repoFull = [IO.Path]::GetFullPath($repo).TrimEnd('\') +if ((Test-Path -LiteralPath (Join-Path $repo 'settings.txt')) -or ($repo -ieq $def.Path)) { + $Root = $repo # already installed here: update in place + Write-Host " $Root" +} else { + if ($known) { + $Root = $known + Write-Host " updating your server in $Root" + } else { + if ($def.Reason) { Warn " $($def.Reason)" } + while ($true) { + $Root = (Ask 'Install the server in' $def.Path).Trim().Trim('"').TrimEnd('\') # "Copy as path" adds quotes + $problem = Test-InstallDir $Root + if (-not $problem) { + $full = [IO.Path]::GetFullPath($Root) + if ($full -ieq $repoFull -or $full.StartsWith($repoFull + '\', [StringComparison]::OrdinalIgnoreCase)) { + $problem = 'pick a folder outside the downloaded files' + } + } + if (-not $problem) { break } + Warn " $problem" + if ($Yes) { throw $problem } + } + } + New-Item -ItemType Directory -Force -Path $Root | Out-Null + $skip = 'hlds', 'dl', 'steamcmd', 'secrets', 'settings.txt', 'kills-tally.json', '.git' + foreach ($item in Get-ChildItem -LiteralPath $repo -Force) { + if ($skip -contains $item.Name) { continue } + Copy-Item -LiteralPath $item.FullName -Destination $Root -Recurse -Force + } + # files from a browser download are marked "from the internet": Windows would warn on every run + Get-ChildItem -LiteralPath $Root -Recurse -File | Unblock-File + Write-Host " copied the server files to $Root" +} +$drive = Get-PSDrive -Name $Root.Substring(0, 1) -ErrorAction SilentlyContinue +if ($drive -and $drive.Free -lt 2GB -and -not (Test-Path -LiteralPath (Join-Path $Root 'hlds\hlds.exe'))) { + throw "not enough disk space on $($drive.Name): (needs 2 GB free)" +} +New-Item -ItemType Directory -Force -Path (Split-Path $rememberFile) | Out-Null +Write-TextFile $rememberFile $Root +$H = Join-Path $Root 'hlds' +$settingsFile = Join-Path $Root 'settings.txt' + +Say 'Settings' +if (-not (Test-Path -LiteralPath $settingsFile)) { + $d = $Script:DefaultSettings + $name = Ask 'Server name' $d.CS_HOSTNAME + $pw = Ask 'Join password (Enter = open server, anyone can join)' '' + $m = Ask 'Game mode: dm = deathmatch, gg = GunGame' $d.CS_MODE + if ($m -ne 'gg') { $m = 'dm' } + $b = Ask 'Bots (fill the server up to this many players, 0 = none)' $d.CS_BOTS + if ($b -notmatch '^\d+$') { $b = '0' } + $diff = Ask 'Bot difficulty (0 easy .. 4 expert)' $d.CS_BOT_DIFFICULTY + if ($diff -notmatch '^[0-4]$') { $diff = $d.CS_BOT_DIFFICULTY } + Write-TextFile $settingsFile "# cs16-dm-server settings. Edit, then restart the server (cs.cmd -> 7). No double quotes in values.`r`n" + foreach ($k in $d.Keys) { Write-Setting $settingsFile $k $d[$k] } + Write-Setting $settingsFile 'CS_HOSTNAME' $name + Write-Setting $settingsFile 'CS_PASSWORD' $pw + Write-Setting $settingsFile 'CS_MODE' $m + Write-Setting $settingsFile 'CS_BOTS' $b + Write-Setting $settingsFile 'CS_BOT_DIFFICULTY' $diff + Write-Host ' saved in settings.txt' +} else { Write-Host ' using settings.txt' } + +$sec = Join-Path $Root 'secrets' +New-Item -ItemType Directory -Force -Path $sec | Out-Null +if (-not (Test-Path -LiteralPath "$sec\rcon-password.txt")) { Write-TextFile "$sec\rcon-password.txt" (New-Secret) } +# ReUnion salt: non-Steam players' IDs are derived from it. Keep it, or their stats change. +if (-not (Test-Path -LiteralPath "$sec\reunion-salt.txt")) { Write-TextFile "$sec\reunion-salt.txt" (New-Secret) } + +# the running server holds the engine files open: stop it while they're replaced +if (Stop-CsServer $Root) { Write-Host ' stopped the running server for the update' } + +$s = Read-Settings $settingsFile +$port = [int]$s.CS_PORT +if (-not (Test-UdpPortFree $port)) { + $free = $port + 1 + while ($free -lt $port + 50 -and -not (Test-UdpPortFree $free)) { $free++ } + Warn " UDP port $port is in use by another program." + $ans = Ask "Use port $free instead? (y/n)" 'y' + if ($ans -notmatch '^y') { throw "port $port is busy: close the program using it, or set CS_PORT in settings.txt" } + Write-Setting $settingsFile 'CS_PORT' $free + $port = $free +} +Write-Host " server '$($s.CS_HOSTNAME)', UDP port $port, bots $($s.CS_BOTS)" + +if (-not (Test-Path -LiteralPath (Join-Path $H 'cstrike\liblist.gam')) -or $Update) { + Say 'Downloading Counter-Strike 1.6 server files (SteamCMD, about 600 MB, takes a while)' + $sc = Join-Path $Root 'steamcmd' + if (-not (Test-Path -LiteralPath "$sc\steamcmd.exe")) { + New-Item -ItemType Directory -Force -Path $sc | Out-Null + Get-Url 'https://steamcdn-a.akamaihd.net/client/installer/steamcmd.zip' "$sc\steamcmd.zip" + Expand-ZipPart "$sc\steamcmd.zip" '' $sc + Remove-Item -LiteralPath "$sc\steamcmd.zip" + } + # validate only on the first download: it would also put back Valve's versions of edited files + $first = -not (Test-Path -LiteralPath (Join-Path $H 'cstrike\liblist.gam')) + $mc = Join-Path $H 'cstrike\mapcycle.txt'; $mcKeep = Join-Path $Root 'dl\mapcycle.txt.keep' + if (Test-Path -LiteralPath $mc) { New-Item -ItemType Directory -Force -Path (Split-Path $mcKeep) | Out-Null; Copy-Item -LiteralPath $mc -Destination $mcKeep -Force } + # steam_legacy: ReHLDS needs engine build <= 8684. app 90 often fails on the first try (and + # SteamCMD updates itself on its first run), so retry. + $scArgs = @('+force_install_dir', $H, '+login', 'anonymous', '+app_set_config', '90', 'mod', 'cstrike', '+app_update', '90', '-beta', 'steam_legacy') + if ($first) { $scArgs += 'validate' } + $scArgs += '+quit' + $ok = $false + for ($i = 1; $i -le 4; $i++) { + $ErrorActionPreference = 'Continue' + & "$sc\steamcmd.exe" @scArgs | Out-Host + $ErrorActionPreference = 'Stop' + if ((Test-Path -LiteralPath (Join-Path $H 'cstrike\liblist.gam')) -and (Test-Path -LiteralPath (Join-Path $H 'hlds.exe')) -and $LASTEXITCODE -eq 0) { $ok = $true; break } + Write-Host " SteamCMD attempt $i didn't finish, trying again..." + } + if (-not $ok) { throw 'game download failed: check your internet connection and double-click setup.cmd again' } + if (Test-Path -LiteralPath $mcKeep) { Move-Item -LiteralPath $mcKeep -Destination $mc -Force } +} else { + Write-Host ''; Write-Host ' game files present (setup.cmd -Update to update them)' +} + +Say 'Installing the mod stack' +& (Join-Path $Root 'windows\install.ps1') -Root $Root + +if (-not (Test-FirewallRule $Root)) { + Say 'Windows Firewall' + if ($Yes) { Write-Host ' skipped (-Yes): allow it later with cs.cmd -> 10' } + else { + Write-Host ' Players on other PCs can only join when Windows Firewall lets the server in.' + Write-Host ' Windows will ask for admin permission once (say Yes).' + if (Add-FirewallRule $Root) { Write-Host ' allowed' -ForegroundColor Green } + else { Warn ' not changed: only this PC can join for now. Try again with cs.cmd -> 10.' } + } +} + +Say 'Starting the server' +[void](Start-CsServer $Root) +Write-Host -NoNewline ' waiting for it to answer' +$info = Wait-CsServer $port 60 +if (-not $info) { + Write-Host '' + $missing = @('hlds.exe', 'swds.dll', 'cstrike\dlls\mp.dll', 'cstrike\addons\metamod\metamod.dll', 'cstrike\addons\amxmodx\dlls\amxmodx_mm.dll', 'cstrike\addons\yapb\bin\yapb.dll') | + Where-Object { -not (Test-Path -LiteralPath (Join-Path $H $_)) } + if ($missing) { Warn " missing: $($missing -join ', ') - maybe removed by your antivirus. Allow the folder $Root in it, then run setup.cmd again." } + Write-Host ' last lines from the server:' + Get-ConsoleTail $Root 20 | ForEach-Object { Write-Host " $_" } + throw "the server didn't answer within 60 s (full log: $H\qconsole.log)" +} +Write-Host ' up' +Write-Host " $(Format-A2SInfo $info)" + +if (-not $Yes) { & (Join-Path $Root 'windows\join.ps1') -Root $Root } + +Say 'Done' +$s = Read-Settings $settingsFile +Write-Host ' Join from Counter-Strike 1.6: press ~ for the console, then type' +Write-Host " connect 127.0.0.1:$port (on this PC)" -ForegroundColor Green +foreach ($ip in Get-LanIPv4) { Write-Host " connect ${ip}:$port (same house)" -ForegroundColor Green } +if ($s.CS_PUBLIC_ADDR) { Write-Host " connect $($s.CS_PUBLIC_ADDR) (friends over the internet)" -ForegroundColor Green } +Write-Host '' +Write-Host " The server runs in its own window. Close that window to stop it." +Write-Host " Menu (start, stop, map, mode, bots, kill counter, ...): double-click cs.cmd in" +Write-Host " $Root" +if ($repo -ine $Root -and -not $Yes) { Start-Process explorer.exe -ArgumentList "`"$Root`"" } diff --git a/windows/tests/common.Tests.ps1 b/windows/tests/common.Tests.ps1 new file mode 100644 index 0000000..7fca055 --- /dev/null +++ b/windows/tests/common.Tests.ps1 @@ -0,0 +1,179 @@ +# Pester 3.4 (ships with Windows 10): powershell -NoProfile -Command "Invoke-Pester windows\tests" +$here = Split-Path -Parent $MyInvocation.MyCommand.Path +. (Join-Path $here '..\common.ps1') +$repo = Resolve-Path (Join-Path $here '..\..') + +Describe 'settings.txt' { + $f = Join-Path $TestDrive 'settings.txt' + It 'falls back to the defaults when the file is missing' { + (Read-Settings $f).CS_PORT | Should Be '27015' + } + It 'replaces a key, keeps comments, appends new keys, strips double quotes' { + [IO.File]::WriteAllText($f, "# my server`r`nCS_HOSTNAME=old name`r`nCS_BOTS=6`r`n") + Write-Setting $f 'CS_HOSTNAME' 'My "cool" server' + Write-Setting $f 'CS_PUBLIC_ADDR' 'x.ply.gg:123' + $s = Read-Settings $f + $s.CS_HOSTNAME | Should Be 'My cool server' + $s.CS_BOTS | Should Be '6' + $s.CS_PUBLIC_ADDR | Should Be 'x.ply.gg:123' + ([IO.File]::ReadAllLines($f))[0] | Should Be '# my server' + } + It 'keeps = signs inside values' { + Write-Setting $f 'CS_PASSWORD' 'a=b' + (Read-Settings $f).CS_PASSWORD | Should Be 'a=b' + } +} + +Describe 'install folder' { + It 'uses Documents\cs16 normally, spaces included' { + $r = Resolve-InstallDir 'C:\Users\John Smith\Documents' 'C:\Users\John Smith' @('C:\Users\John Smith\OneDrive') + $r.Path | Should Be 'C:\Users\John Smith\Documents\cs16' + $r.Reason | Should Be '' + } + It 'avoids a OneDrive-synced Documents folder' { + $r = Resolve-InstallDir 'C:\Users\jo\OneDrive\Documents' 'C:\Users\jo' @('C:\Users\jo\OneDrive', $null) + $r.Path | Should Be 'C:\Users\jo\cs16' + $r.Reason | Should Match 'OneDrive' + } + It 'falls back to C:\cs16 for non-ASCII user folders' { + $r = Resolve-InstallDir ('C:\Users\Zo' + [char]0xEB + '\Documents') ('C:\Users\Zo' + [char]0xEB) @() + $r.Path | Should Be 'C:\cs16' + } + It 'refuses relative, non-ASCII and Program Files paths' { + Test-InstallDir 'cs16' | Should Not BeNullOrEmpty + Test-InstallDir ('C:\J' + [char]0xF6 + 'rg\cs16') | Should Not BeNullOrEmpty + Test-InstallDir (Join-Path $env:ProgramFiles 'cs16') | Should Not BeNullOrEmpty + Test-InstallDir 'D:\Games\cs 16' | Should BeNullOrEmpty + } +} + +Describe 'pinned downloads' { + $all = Get-PinnedDownloads $repo + It 'drops the Linux-only files' { + @($all | Where-Object { $_.File -match '-linux' }).Count | Should Be 0 + } + It 'adds the Windows builds of AMX Mod X and YaPB' { + @($all | Where-Object { $_.File -eq 'amxmodx-1.10.0-git5486-base-windows.zip' }).Count | Should Be 1 + @($all | Where-Object { $_.File -eq 'amxmodx-1.10.0-git5486-cstrike-windows.zip' }).Count | Should Be 1 + @($all | Where-Object { $_.File -eq 'yapb-4.4.957-windows.zip' }).Count | Should Be 1 + } + It 'keeps the shared files with lower-case hashes' { + $r = $all | Where-Object { $_.File -like 'rehlds-bin-*' } + $r.Sha | Should Match '^[0-9a-f]{64}$' + @($all | Where-Object { $_.File -like 'spawns/*' }).Count | Should BeGreaterThan 5 + } + It 'refuses a file whose hash does not match' { + $src = Join-Path $TestDrive 'src.bin'; [IO.File]::WriteAllText($src, 'hello') + $item = [pscustomobject]@{ File = 'x/bad.bin'; Sha = ('0' * 64); Url = ([Uri]$src).AbsoluteUri } + { Get-Pinned $item (Join-Path $TestDrive 'dl') } | Should Throw 'checksum mismatch' + Test-Path (Join-Path $TestDrive 'dl\x\bad.bin') | Should Be $false + } + It 'gives every file a backup URL on the Gitea mirror' { + ($all | Where-Object { $_.File -eq 'spawns/de_dust2.spawns.cfg' }).Mirror | Should Match '/releases/download/mods/de_dust2\.spawns\.cfg$' + } + It 'uses the backup copy when the main site fails or sends the wrong file' { + $good = Join-Path $TestDrive 'mirror.bin'; [IO.File]::WriteAllText($good, 'real file') + $wrong = Join-Path $TestDrive 'blocked.html'; [IO.File]::WriteAllText($wrong, 'blocked') + foreach ($main in @((Join-Path $TestDrive 'missing.bin'), $wrong)) { + $item = [pscustomobject]@{ File = 'm/f.bin'; Sha = (Get-Sha256 $good); Url = ([Uri]$main).AbsoluteUri; Mirror = ([Uri]$good).AbsoluteUri } + $dl = Join-Path $TestDrive ('dl' + [guid]::NewGuid()) + [IO.File]::ReadAllText((Get-Pinned $item $dl)) | Should Be 'real file' + } + } + It 'keeps a file whose hash matches' { + $src = Join-Path $TestDrive 'src.bin' + $item = [pscustomobject]@{ File = 'x/good.bin'; Sha = (Get-Sha256 $src); Url = ([Uri]$src).AbsoluteUri } + $p = Get-Pinned $item (Join-Path $TestDrive 'dl') + [IO.File]::ReadAllText($p) | Should Be 'hello' + } +} + +Describe 'spawn files' { + It 'pads old 6-field lines to 10 fields' { + Convert-SpawnLine '-1024 512 64 0 90 0' | Should Be '-1024 512 64 0 90 0 0 0 0 0' + } + It 'leaves 10-field lines alone' { + Convert-SpawnLine '1 2 3 4 5 6 7 8 9 10' | Should Be '1 2 3 4 5 6 7 8 9 10' + } +} + +Describe 'zip extraction' { + It 'extracts only the entries under the prefix, without it' { + $src = Join-Path $TestDrive 'z'; New-Item -ItemType Directory -Force "$src\bin\win32\sub", "$src\bin\linux32" | Out-Null + 'a' | Set-Content "$src\bin\win32\sub\a.txt"; 'b' | Set-Content "$src\bin\linux32\b.txt" + $zip = Join-Path $TestDrive 'z.zip' + [IO.Compression.ZipFile]::CreateFromDirectory($src, $zip) + $out = Join-Path $TestDrive 'out' + Expand-ZipPart $zip 'bin/win32/' $out + Test-Path "$out\sub\a.txt" | Should Be $true + Test-Path "$out\b.txt" | Should Be $false + { Expand-ZipPart $zip 'nope/' $out } | Should Throw + } +} + +Describe 'A2S_INFO reply' { + It 'reads name, map and player counts' { + $b = New-Object Collections.Generic.List[byte] + $b.AddRange([byte[]](255, 255, 255, 255, 0x49, 48)) + foreach ($s in 'My server', 'de_dust2', 'cstrike', 'Counter-Strike') { $b.AddRange([Text.Encoding]::UTF8.GetBytes($s)); $b.Add(0) } + $b.AddRange([byte[]](10, 0, 3, 16, 2, 100, 119, 1, 1)) # appid, players, max, bots, type, env, password, vac + $i = ConvertFrom-A2SInfo $b.ToArray() + $i.Name | Should Be 'My server' + $i.Map | Should Be 'de_dust2' + $i.Players | Should Be 3 + $i.Max | Should Be 16 + $i.Bots | Should Be 2 + $i.Password | Should Be $true + } + It 'returns nothing for another packet type' { + ConvertFrom-A2SInfo ([byte[]](255, 255, 255, 255, 0x41, 1, 2, 3, 4)) | Should BeNullOrEmpty + } +} + +Describe 'kill counter' { + $lines = @( + 'L 10/15/2026 - 20:01:02: "Helios<3>" killed "articwolf<4>" with "ak47"', + 'L 10/15/2026 - 20:01:05: "a "quoted" <5>" killed "Helios<3>" with "deagle"', + ('L 10/16/2026 - 00:00:01: "Zo' + [char]0xEB + '<6><>" killed "x<7>" with "knife"'), + 'L 10/15/2026 - 20:01:09: "Helios<3>" triggered "Planted_The_Bomb"' + ) + It 'finds every kill line, odd names included' { + $k = @(Get-KillLines $lines) + $k.Count | Should Be 3 + $k[0].Killer | Should Be 'Helios' + $k[1].Killer | Should Be 'a "quoted" ' + $k[1].Weapon | Should Be 'deagle' + $k[2].Day | Should Be '10/16/2026' + } + It 'tallies logs and keeps counts of deleted logs' { + $logs = Join-Path $TestDrive 'logs'; New-Item -ItemType Directory -Force $logs | Out-Null + [IO.File]::WriteAllLines("$logs\L1015000.log", [string[]]$lines, (New-Object Text.UTF8Encoding $false)) + $state = Join-Path $TestDrive 'kills-tally.json' + $t = Update-KillTally $logs $state + $t.Total | Should Be 3 + $t.Who['Helios'] | Should Be 1 + $t.Days.Count | Should Be 2 + Remove-Item "$logs\L1015000.log" + [IO.File]::WriteAllLines("$logs\L1016000.log", [string[]]@($lines[0])) + $t = Update-KillTally $logs $state + $t.Total | Should Be 4 + $t.Who['Helios'] | Should Be 2 + $t.Maps | Should Be 2 + } + It 'reads the log the server is still writing' { + $logs = Join-Path $TestDrive 'live'; New-Item -ItemType Directory -Force $logs | Out-Null + $w = New-Object IO.FileStream "$logs\L1017000.log", ([IO.FileMode]::Create), ([IO.FileAccess]::Write), ([IO.FileShare]::Read) + try { + $b = [Text.Encoding]::UTF8.GetBytes($lines[0] + "`n"); $w.Write($b, 0, $b.Length); $w.Flush() + (Update-KillTally $logs (Join-Path $TestDrive 'live.json')).Total | Should Be 1 + } finally { $w.Dispose() } + } +} + +Describe 'network' { + It 'recognises the Tailscale address range' { + Test-CgnatAddress '100.118.97.33' | Should Be $true + Test-CgnatAddress '100.12.1.1' | Should Be $false + Test-CgnatAddress '192.168.1.5' | Should Be $false + } +}