From c59cf2b5e792cee56d1910caf552af9f8d2eab79 Mon Sep 17 00:00:00 2001 From: Jan Hangebrauck Date: Fri, 29 Aug 2025 10:24:08 +0200 Subject: [PATCH 01/25] potential fix for iframe keyboard events --- game/src/engine/main.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/game/src/engine/main.cpp b/game/src/engine/main.cpp index d44f9d91e..a1274b58a 100644 --- a/game/src/engine/main.cpp +++ b/game/src/engine/main.cpp @@ -1282,6 +1282,13 @@ int main(int argc, char **argv) { logoutf("init: sdl"); +#if __EMSCRIPTEN__ + // Ensure keyboard events are captured when running inside iframes by + // attaching SDL's keyboard listeners to the iframe document. + // This must be set before SDL_Init. + SDL_SetHint(SDL_HINT_EMSCRIPTEN_KEYBOARD_ELEMENT, "#document"); +#endif + if(SDL_Init(SDL_INIT_TIMER|SDL_INIT_VIDEO|SDL_INIT_AUDIO)<0) fatal("Unable to initialize SDL: %s", SDL_GetError()); #ifdef SDL_VIDEO_DRIVER_X11 From 5bc8b968a5c4ead004a99fc2137dfbb0cd40fc5b Mon Sep 17 00:00:00 2001 From: Jan Hangebrauck Date: Fri, 29 Aug 2025 10:24:39 +0200 Subject: [PATCH 02/25] wip: dockerize build pipeline and serve --- .gitignore | 2 ++ README.md | 33 ++++++++++++++++++- assets/.gitignore | 2 ++ assets/README.md | 10 +++--- assets/base.py | 10 +++--- assets/setup | 2 +- dev.auto.yaml | 7 ++++ docker/emscripten.Dockerfile | 59 +++++++++++++++++++++++++++++++++ scripts/build-assets-docker | 49 ++++++++++++++++++++++++++++ scripts/build-game-docker | 58 +++++++++++++++++++++++++++++++++ scripts/clean-generated | 15 +++++++++ scripts/serve-docker | 63 ++++++++++++++++++++++++++++++++++++ 12 files changed, 298 insertions(+), 12 deletions(-) create mode 100644 dev.auto.yaml create mode 100644 docker/emscripten.Dockerfile create mode 100755 scripts/build-assets-docker create mode 100755 scripts/build-game-docker create mode 100755 scripts/clean-generated create mode 100755 scripts/serve-docker diff --git a/.gitignore b/.gitignore index c94076d36..819c28627 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ earthly/** /sour /sourdump /sour-build +.home/ +.pip-cache/ \ No newline at end of file diff --git a/README.md b/README.md index 3f14e8c3e..250cbae9f 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,37 @@ Running `sour` will start a Sour server accessible to web clients at `http://0.0 By serving on `0.0.0.0` by default, the Sour server will be available to other devices on the local network at IP of the device running the Sour server. +### Building the web game with Docker (Emscripten) + +If you prefer building in a containerized environment, a Dockerfile and helper script are provided: + +```bash +# Build the image and compile the game into game/dist/game +./scripts/build-game-docker + +# Optionally control output directory +GAME_OUTPUT_DIR=client/dist/game ./scripts/build-game-docker +``` + +This uses an Ubuntu base with Emscripten 3.1.8 (same as CI), mounts your checkout at `/workspace`, and runs `game/build` inside the container. Artifacts will appear under `game/dist/game` by default. + +### Running the server in Docker + +After building the game and client (and optionally assets), you can run the integrated server with: + +```bash +# Default: serves on 0.0.0.0:1337 +./scripts/serve-docker + +# With a config file +./scripts/serve-docker dev.yaml + +# Override bind address/port +WEB_ADDR=127.0.0.1 WEB_PORT=1337 ./scripts/serve-docker +``` + +The script mounts your workspace and runs `go run ./cmd/sour serve` inside the container using your UID/GID so no files are owned by root. + ## Configuration Sour is highly configurable. When run without arguments, `sour` defaults to running `sour serve` with the [default Sour configuration](https://github.com/cfoust/sour/blob/main/pkg/config/default.yaml). You change Sour's configuration by providing the path to a configuration file to `sour serve`: @@ -80,7 +111,7 @@ Here is a high level description of the repository's contents: - Gives clients both on the web and desktop client access to game servers managed by Sour. - `game`: All of the Cube 2 code and Emscripten compilation scripts. Originally this was a fork of [BananaBread](https://github.com/kripken/BananaBread), kripken's original attempt at compiling Sauerbraten for the web. Since then I have upgraded the game to the newest mainline version several times and moved to WebGL2. - `client`: A React web application that uses the compiled Sauerbraten game found in `game`, pulls assets, and proxies all server communication over a WebSocket. -- `assets`: Scripts for building web-compatible game assets. This is an extremely complicated topic and easily the most difficult aspect of shipping Sauerbraten to the web. Check out this [section's README](services/assets) for more information. +- `assets`: Scripts for building web-compatible game assets. This is an extremely complicated topic and easily the most difficult aspect of shipping Sauerbraten to the web. Check out this [section's README](assets/README.md) for more information. ## Contributing diff --git a/assets/.gitignore b/assets/.gitignore index 330106c1a..755b8260e 100644 --- a/assets/.gitignore +++ b/assets/.gitignore @@ -7,3 +7,5 @@ quadropolis *.tar.gz cache/** /dist +.index.source +.index.json \ No newline at end of file diff --git a/assets/README.md b/assets/README.md index 387f511d4..b9878e546 100644 --- a/assets/README.md +++ b/assets/README.md @@ -57,15 +57,15 @@ Asset sources are specified at runtime using the `ASSET_SOURCE` environment vari # Valid ASSET_SOURCES: ###################### -# /assets/.index.json is the asset source that comes baked into the image. Generally you want this even if you're using your own map sources; this is because Sour automatically loads the `base` bundle, which contains all of the basic assets necessary to run the game, like main menu graphics. -ASSET_SOURCE="/assets/.index.json" +# /assets/.index.source is the asset source that comes baked into the image. Generally you want this even if you're using your own map sources; this is because Sour automatically loads the `base` bundle, which contains all of the basic assets necessary to run the game, like main menu graphics. +ASSET_SOURCE="/assets/.index.source" # Asset sources are separated by single semicolons. -ASSET_SOURCE="/assets/.index.json;https://example.com/2bfc017.index.json" -# As an example, if a user runs `/map complex`, Sour first searches /assets/.index.json; if there is a `complex` map, it loads that verssion even if one also exists in the second source. +ASSET_SOURCE="/assets/.index.source;https://example.com/2bfc017.index.source" +# As an example, if a user runs `/map complex`, Sour first searches /assets/.index.source; if there is a `complex` map, it loads that verssion even if one also exists in the second source. # In production (sourga.me) the ASSET_SOURCE looks like this: -ASSET_SOURCE="/assets/.index.json;https://static.sourga.me/blobs/XXXXX.index.json;https://static.sourga.me/quadropolis/XXXXX.index.json" +ASSET_SOURCE="/assets/.index.source;https://static.sourga.me/blobs/XXXXX.index.source;https://static.sourga.me/quadropolis/XXXXX.index.source" # In other words, Sour will load maps that appear in the latest SVN version of the game _first_, then from Quadropolis if the map did not appear in the base game. ``` diff --git a/assets/base.py b/assets/base.py index 92ded19d7..504fd85cb 100644 --- a/assets/base.py +++ b/assets/base.py @@ -172,11 +172,11 @@ def expand_hudguns(prefix: str) -> List[str]: maps = list(filter(lambda a: a.endswith('.ogz'), files)) if args.maps: maps = list(map(lambda a: f"packages/base/{a}.ogz", args.maps)) - - maps.append("packages/base/xmwhub.ogz") - - if 'none' in args.maps: - maps = [] + if 'none' in args.maps: + maps = [] + else: + # Only add the default map when no explicit map list is provided. + maps.append("packages/base/xmwhub.ogz") outdir = args.outdir os.makedirs(outdir, exist_ok=True) diff --git a/assets/setup b/assets/setup index 03c841f6d..ecee1e1d6 100755 --- a/assets/setup +++ b/assets/setup @@ -9,7 +9,7 @@ set -e mkdir -p cache if ! pip3 list | grep "cbor2" > /dev/null 2>&1; then - pip3 install -r requirements.txt + pip3 install --user -r requirements.txt fi #sauer_archive="sauerbraten-6481.tar.gz" diff --git a/dev.auto.yaml b/dev.auto.yaml new file mode 100644 index 000000000..ec67cfd4c --- /dev/null +++ b/dev.auto.yaml @@ -0,0 +1,7 @@ +server:json + cacheDirectory: "./.cache/assets" + assets: + - "fs:assets/dist/.index.source" +client: + assets: + - "#origin/assets/0/.index.source" diff --git a/docker/emscripten.Dockerfile b/docker/emscripten.Dockerfile new file mode 100644 index 000000000..59abce986 --- /dev/null +++ b/docker/emscripten.Dockerfile @@ -0,0 +1,59 @@ +FROM ubuntu:22.04 + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && apt-get install -y \ + build-essential \ + cmake \ + git \ + python3 \ + python3-pip \ + curl \ + ca-certificates \ + unzip \ + xz-utils \ + patch \ + pkg-config \ + imagemagick \ + inotify-tools \ + ucommon-utils \ + unrar \ + zlib1g-dev \ + libenet-dev \ + swig \ + npm \ + && rm -rf /var/lib/apt/lists/* + +# Install Emscripten SDK (same version used in CI) +RUN git clone https://github.com/emscripten-core/emsdk.git /emsdk \ + && cd /emsdk \ + && ./emsdk install 3.1.8 \ + && ./emsdk activate 3.1.8 + +# Install modern Go toolchain (for go.mod go 1.22.x) +ENV GOVER=1.22.5 +RUN curl -fsSL https://go.dev/dl/go${GOVER}.linux-amd64.tar.gz -o /tmp/go.tgz \ + && rm -rf /usr/local/go \ + && tar -C /usr/local -xzf /tmp/go.tgz \ + && rm /tmp/go.tgz + +# Make Emscripten available in all shells +ENV EMSDK=/emsdk \ + EM_CONFIG=/emsdk/.emscripten \ + PATH=/emsdk:/emsdk/upstream/emscripten:/emsdk/node/14.18.2_64bit/bin:$PATH + +# Prepend Go to PATH +ENV PATH=/usr/local/go/bin:$PATH + +# Ensure Yarn is available in the build image (installed as root) +RUN npm i -g yarn + +# Default workdir where the repo will be mounted +WORKDIR /workspace + +# Show versions for easier troubleshooting +RUN emcc -v && python3 --version && cmake --version && go version + +CMD ["bash"] + + diff --git a/scripts/build-assets-docker b/scripts/build-assets-docker new file mode 100755 index 000000000..362130346 --- /dev/null +++ b/scripts/build-assets-docker @@ -0,0 +1,49 @@ +#!/usr/bin/env bash + +set -euo pipefail + +IMAGE_TAG="sour-emscripten:3.1.8" +PROJECT_ROOT="$(cd "$(dirname "$0")"/.. && pwd)" + +build_image() { + docker build -f "$PROJECT_ROOT/docker/emscripten.Dockerfile" -t "$IMAGE_TAG" "$PROJECT_ROOT" +} + +build_image + +HOST_UID="$(id -u)" +HOST_GID="$(id -g)" + +ASSET_OUTPUT_DIR=${ASSET_OUTPUT_DIR:-output} +# Space-separated maps to build; use 'none' to skip maps +ASSET_MAPS=${ASSET_MAPS:-"complex dust2 turbine"} + +exec docker run --rm \ + --user "$HOST_UID:$HOST_GID" \ + -e ASSET_OUTPUT_DIR="$ASSET_OUTPUT_DIR" \ + -e HOME="/workspace/.home" \ + -e PIP_CACHE_DIR="/workspace/.pip-cache" \ + -e ASSET_MAPS="$ASSET_MAPS" \ + -v "$PROJECT_ROOT":/workspace \ + -w /workspace/assets \ + "$IMAGE_TAG" \ + bash -lc ' + set -e + mkdir -p "$HOME" "$PIP_CACHE_DIR" + python3 -m pip install --user -r requirements.txt || true + ./setup + # Rebuild sourdump to ensure latest code is used + rm -f sourdump + go build -o sourdump ../cmd/sourdump/main.go + # Build base assets; configure maps via ASSET_MAPS + # Expand ASSET_MAPS into positional args + set -- $ASSET_MAPS + python3 base.py \ + --root https://static.sourga.me/blobs/6481/.index.source \ + --models \ + --download \ + --outdir dist \ + "$@" + ' + + diff --git a/scripts/build-game-docker b/scripts/build-game-docker new file mode 100755 index 000000000..ceb71dc60 --- /dev/null +++ b/scripts/build-game-docker @@ -0,0 +1,58 @@ +#!/usr/bin/env bash + +set -euo pipefail + +IMAGE_TAG="sour-emscripten:3.1.8" +PROJECT_ROOT="$(cd "$(dirname "$0")"/.. && pwd)" +SCRIPTS_DIR="$PROJECT_ROOT/scripts" +EM_CACHE_DIR="$PROJECT_ROOT/.emscripten-cache" + +build_image() { + docker build -f "$PROJECT_ROOT/docker/emscripten.Dockerfile" -t "$IMAGE_TAG" "$PROJECT_ROOT" +} + +build_image + + +# Allow overriding output dir +GAME_OUTPUT_DIR=${GAME_OUTPUT_DIR:-dist/game} + +# Run as the host user to avoid root-owned files; mount a writable EM_CACHE +HOST_UID="$(id -u)" +HOST_GID="$(id -g)" +mkdir -p "$EM_CACHE_DIR" + +exec docker run --rm \ + --user "$HOST_UID:$HOST_GID" \ + -e GAME_OUTPUT_DIR="$GAME_OUTPUT_DIR" \ + -e EM_CACHE="/workspace/.emscripten-cache" \ + -v "$PROJECT_ROOT":/workspace \ + -w /workspace/game \ + "$IMAGE_TAG" \ + bash -lc ' + set -e + source /emsdk/emsdk_env.sh + + echo "[1/3] Building WASM game..." + cd /workspace/game + ./build + + echo "[2/3] Building web client..." + cd /workspace/client + yarn install + yarn build + cp src/index.html src/favicon.ico src/background.png dist/ + mkdir -p dist/game && cp -r ../game/dist/game/* dist/game/ + + echo "[3/3] Staging site for Go server..." + rm -rf /workspace/pkg/server/static/site + mkdir -p /workspace/pkg/server/static/site + cp -r dist/* /workspace/pkg/server/static/site/ + + echo "[4/4] Building assets..." + ' + +cd "$SCRIPTS_DIR" +./build-assets-docker + + diff --git a/scripts/clean-generated b/scripts/clean-generated new file mode 100755 index 000000000..56da64ba1 --- /dev/null +++ b/scripts/clean-generated @@ -0,0 +1,15 @@ +#!/usr/bin/env bash + +set -euo pipefail + +PROJECT_ROOT="$(cd "$(dirname "$0")"/.. && pwd)" + +rm -rf "$PROJECT_ROOT/game/dist/game" || true +rm -rf "$PROJECT_ROOT/client/dist" || true +rm -rf "$PROJECT_ROOT/pkg/server/static/site" || true +rm -rf "$PROJECT_ROOT/.emscripten-cache" || true + +# Also remove common temporary outputs under assets +rm -rf "$PROJECT_ROOT/assets/dist" || true + +echo "Cleaned generated outputs." diff --git a/scripts/serve-docker b/scripts/serve-docker new file mode 100755 index 000000000..c3fb1849f --- /dev/null +++ b/scripts/serve-docker @@ -0,0 +1,63 @@ +#!/usr/bin/env bash + +set -euo pipefail + +IMAGE_TAG="sour-emscripten:3.1.8" +PROJECT_ROOT="$(cd "$(dirname "$0")"/.. && pwd)" + +# Build the image if missing +if ! docker image inspect "$IMAGE_TAG" >/dev/null 2>&1; then + docker build -f "$PROJECT_ROOT/docker/emscripten.Dockerfile" -t "$IMAGE_TAG" "$PROJECT_ROOT" +fi + +HOST_UID="$(id -u)" +HOST_GID="$(id -g)" + +# Ports and address +WEB_ADDR=${WEB_ADDR:-0.0.0.0} +WEB_PORT=${WEB_PORT:-1337} + +# Optional config file path (in host workspace). Pass as first arg. +CONFIG_FILE=${1:-} + +# Ensure local assets index symlink exists (use relative path so it works in container) +if [ -f "$PROJECT_ROOT/assets/dist/.index.source" ]; then + # Always refresh symlink to ensure it works inside the container + (cd "$PROJECT_ROOT/assets" && ln -sf "dist/.index.source" ".index.source") +fi + +CMD="go run ./cmd/sour serve" +if [ -n "$CONFIG_FILE" ]; then + # Map host path to container path + if [ -f "$CONFIG_FILE" ]; then + REL="${CONFIG_FILE#$PROJECT_ROOT/}" + CMD="go run ./cmd/sour serve \"$REL\"" + fi +else + # Auto-generate a simple config pointing to local assets index if present + if [ -f "$PROJECT_ROOT/assets/.index.source" ]; then + AUTO_CFG="$PROJECT_ROOT/dev.auto.yaml" + cat > "$AUTO_CFG" < Date: Fri, 29 Aug 2025 12:58:37 +0200 Subject: [PATCH 03/25] fix game serving fix keyboard events not detected issue (in iframe as well as standalone) wip: update readme --- README.md | 42 +++++++++++++++++++++++++----- client/src/index.tsx | 12 +++++++++ dev.auto.yaml | 21 ++++++++++++++- game/src/engine/main.cpp | 8 +++--- scripts/build-all | 10 ++++++++ scripts/build-assets-docker | 6 ----- scripts/build-docker-image | 13 ++++++++++ scripts/build-game-docker | 15 ----------- scripts/clean-generated | 9 +++++++ scripts/serve-docker | 51 +++++++++++++++++++++++++++++++------ 10 files changed, 146 insertions(+), 41 deletions(-) create mode 100755 scripts/build-all create mode 100755 scripts/build-docker-image diff --git a/README.md b/README.md index 250cbae9f..c5d421caa 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,12 @@

-

- Discord - sour releases - sour License Badge MIT -

- ## What is this? +This is a fork of the excellent [original sour repository](https://github.com/cfoust/sour) made by cfoust. It has been made with the goal to embed sauerbraten as an in-community game experience into the Common Ground platform. I want to enable a Quake-like experience, and add additional features like Tournaments between communities. + + + Sour is a Cube 2: Sauerbraten server that serves a fully-featured web-version of Sauerbraten (with support for mobile devices) in addition to accepting connections from the traditional, desktop version of the game. Sour is the easiest way to play Sauerbraten with your friends. Give it a try. @@ -32,6 +30,24 @@ brew install cfoust/taps/sour@0.2.2 In addition to all of the base game assets, these archives only contain three maps: `complex`, `dust2`, and `turbine`. +### Prerequisite: Git LFS + +This repository stores large binary assets (textures, images, etc.) in Git LFS. After cloning, ensure LFS is installed and fetch objects, or some files will be tiny pointer stubs that fail at runtime. + +Brief setup: + +```bash +# Ubuntu/Debian +sudo apt install git-lfs + +# macOS (Homebrew) +brew install git-lfs + +# One‑time init, then pull LFS content +git lfs install +git lfs pull +``` + ## Running Sour To run Sour, extract a release archive anywhere you wish, navigate to that directory, and run `./sour`. If you installed Sour with `brew`, just run `sour` in any terminal session. @@ -113,9 +129,21 @@ Here is a high level description of the repository's contents: - `client`: A React web application that uses the compiled Sauerbraten game found in `game`, pulls assets, and proxies all server communication over a WebSocket. - `assets`: Scripts for building web-compatible game assets. This is an extremely complicated topic and easily the most difficult aspect of shipping Sauerbraten to the web. Check out this [section's README](assets/README.md) for more information. +**Updates in this fork** + +- dockerized the build pipeline for the game client as well as assets +- uses one docker helper container that compiles everything and can also serve the game server +- fixed a bug that prevented keyboard events to work in iframes + + + ## Contributing -Join us on [Discord](https://discord.gg/WP3EbYym4M) to chat with us and see how you can help out! Check out the [issues tab](https://github.com/cfoust/sour/issues) to get an idea of what needs doing. +This repository is maintained by the Common Ground Team (I'm one of the founders) as an in-community gaming experience. Common Ground itself is a progressive web app and supports embedding custom games and plugins into Communities. If you're interested in the project, join our [Common Ground community on app.cg](https://app.cg/c/commonground/). + +Besides Sour / Sauerbraten, I also made a Luanti (think "open source minecraft") game plugin available on app.cg. Like Sour, it is also a web assembly game with an original c++ codebase. You can find my [minetest-wasm repository here](https://github.com/Kaesual/minetest-wasm). You can play both games right in your browser, in the [Video Games community on app.cg](https://app.cg/c/videogames/). + +The original repository was made by cfoust. You can join the community on [Discord](https://discord.gg/WP3EbYym4M) to chat with them and see how you can help out! Check out the [cfoust sour issues tab](https://github.com/cfoust/sour/issues) to get an idea of what needs doing. ## Inspiration diff --git a/client/src/index.tsx b/client/src/index.tsx index 16bf363ff..03ce610b2 100644 --- a/client/src/index.tsx +++ b/client/src/index.tsx @@ -940,6 +940,7 @@ function App() { className="game" style={{ opacity: state.type !== GameStateType.Ready ? 0 : 1 }} id="canvas" + tabIndex={0} ref={(canvas) => { if (canvas != null) { // This is a bug in mobile Safari where Reader holds on to canvas refs @@ -948,9 +949,20 @@ function App() { canvas._evaluatedForTextContent = true // @ts-ignore canvas._cachedElementBoundingRect = {} + // Ensure the canvas is focusable for keyboard events + // (SDL with Emscripten binds keyboard to the target element) + // eslint-disable-next-line no-param-reassign + canvas.tabIndex = 0 } Module.canvas = canvas }} + onMouseDown={(_e: React.MouseEvent) => { + // Focus the canvas so key events are delivered here + // (important when SDL binds to #canvas) + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + const el = document.getElementById('canvas') as HTMLCanvasElement | null + if (el) el.focus() + }} onContextMenu={(event) => event.preventDefault()} > {BROWSER.isMobile && ( diff --git a/dev.auto.yaml b/dev.auto.yaml index ec67cfd4c..c93434a2a 100644 --- a/dev.auto.yaml +++ b/dev.auto.yaml @@ -1,7 +1,26 @@ -server:json +server: cacheDirectory: "./.cache/assets" assets: - "fs:assets/dist/.index.source" + presets: + - name: "default" + default: true + config: + defaultMode: "ffa" + defaultMap: "complex" + maps: ["complex", "dust2", "turbine"] + - name: "insta" + config: + defaultMode: "insta" + defaultMap: "dust2" + maps: ["complex", "dust2", "turbine"] + spaces: + - preset: default + config: + alias: lobby + - preset: insta + config: + alias: insta client: assets: - "#origin/assets/0/.index.source" diff --git a/game/src/engine/main.cpp b/game/src/engine/main.cpp index a1274b58a..efb3af60f 100644 --- a/game/src/engine/main.cpp +++ b/game/src/engine/main.cpp @@ -1283,10 +1283,10 @@ int main(int argc, char **argv) logoutf("init: sdl"); #if __EMSCRIPTEN__ - // Ensure keyboard events are captured when running inside iframes by - // attaching SDL's keyboard listeners to the iframe document. - // This must be set before SDL_Init. - SDL_SetHint(SDL_HINT_EMSCRIPTEN_KEYBOARD_ELEMENT, "#document"); + // Bind SDL keyboard events to the canvas element present in the DOM. + // This avoids null targets in some environments and works in iframes. + // Must be set before SDL_Init. + SDL_SetHint(SDL_HINT_EMSCRIPTEN_KEYBOARD_ELEMENT, "#canvas"); #endif if(SDL_Init(SDL_INIT_TIMER|SDL_INIT_VIDEO|SDL_INIT_AUDIO)<0) fatal("Unable to initialize SDL: %s", SDL_GetError()); diff --git a/scripts/build-all b/scripts/build-all new file mode 100755 index 000000000..c9ff1d16c --- /dev/null +++ b/scripts/build-all @@ -0,0 +1,10 @@ +#!/bin/bash + +set -euo pipefail +cd "$(dirname "$0")/.." + +./scripts/build-docker-helper +./scripts/build-assets-docker +./scripts/build-game-docker + +echo "Build complete. You can now run ./scripts/serve-docker to run the game server." \ No newline at end of file diff --git a/scripts/build-assets-docker b/scripts/build-assets-docker index 362130346..62941573a 100755 --- a/scripts/build-assets-docker +++ b/scripts/build-assets-docker @@ -5,12 +5,6 @@ set -euo pipefail IMAGE_TAG="sour-emscripten:3.1.8" PROJECT_ROOT="$(cd "$(dirname "$0")"/.. && pwd)" -build_image() { - docker build -f "$PROJECT_ROOT/docker/emscripten.Dockerfile" -t "$IMAGE_TAG" "$PROJECT_ROOT" -} - -build_image - HOST_UID="$(id -u)" HOST_GID="$(id -g)" diff --git a/scripts/build-docker-image b/scripts/build-docker-image new file mode 100755 index 000000000..1d69d58e4 --- /dev/null +++ b/scripts/build-docker-image @@ -0,0 +1,13 @@ +#!/usr/bin/env bash + +set -euo pipefail + +IMAGE_TAG="sour-emscripten:3.1.8" +PROJECT_ROOT="$(cd "$(dirname "$0")"/.. && pwd)" +EM_CACHE_DIR="$PROJECT_ROOT/.emscripten-cache" + +build_image() { + docker build -f "$PROJECT_ROOT/docker/emscripten.Dockerfile" -t "$IMAGE_TAG" "$PROJECT_ROOT" +} + +build_image \ No newline at end of file diff --git a/scripts/build-game-docker b/scripts/build-game-docker index ceb71dc60..80ca480ac 100755 --- a/scripts/build-game-docker +++ b/scripts/build-game-docker @@ -4,16 +4,8 @@ set -euo pipefail IMAGE_TAG="sour-emscripten:3.1.8" PROJECT_ROOT="$(cd "$(dirname "$0")"/.. && pwd)" -SCRIPTS_DIR="$PROJECT_ROOT/scripts" EM_CACHE_DIR="$PROJECT_ROOT/.emscripten-cache" -build_image() { - docker build -f "$PROJECT_ROOT/docker/emscripten.Dockerfile" -t "$IMAGE_TAG" "$PROJECT_ROOT" -} - -build_image - - # Allow overriding output dir GAME_OUTPUT_DIR=${GAME_OUTPUT_DIR:-dist/game} @@ -48,11 +40,4 @@ exec docker run --rm \ rm -rf /workspace/pkg/server/static/site mkdir -p /workspace/pkg/server/static/site cp -r dist/* /workspace/pkg/server/static/site/ - - echo "[4/4] Building assets..." ' - -cd "$SCRIPTS_DIR" -./build-assets-docker - - diff --git a/scripts/clean-generated b/scripts/clean-generated index 56da64ba1..0307813fc 100755 --- a/scripts/clean-generated +++ b/scripts/clean-generated @@ -8,8 +8,17 @@ rm -rf "$PROJECT_ROOT/game/dist/game" || true rm -rf "$PROJECT_ROOT/client/dist" || true rm -rf "$PROJECT_ROOT/pkg/server/static/site" || true rm -rf "$PROJECT_ROOT/.emscripten-cache" || true +rm -rf "$PROJECT_ROOT/.pip-cache" || true +rm -rf "$PROJECT_ROOT/.home" || true # Also remove common temporary outputs under assets rm -rf "$PROJECT_ROOT/assets/dist" || true +rm -rf "$PROJECT_ROOT/assets/output" || true +rm -rf "$PROJECT_ROOT/assets/cache" || true +rm -f "$PROJECT_ROOT/assets/.index.source" || true +rm -f "$PROJECT_ROOT/assets/.index.json" || true + +# Remove auto-generated dev config +rm -f "$PROJECT_ROOT/dev.auto.yaml" || true echo "Cleaned generated outputs." diff --git a/scripts/serve-docker b/scripts/serve-docker index c3fb1849f..8bf8ca3e4 100755 --- a/scripts/serve-docker +++ b/scripts/serve-docker @@ -5,11 +5,6 @@ set -euo pipefail IMAGE_TAG="sour-emscripten:3.1.8" PROJECT_ROOT="$(cd "$(dirname "$0")"/.. && pwd)" -# Build the image if missing -if ! docker image inspect "$IMAGE_TAG" >/dev/null 2>&1; then - docker build -f "$PROJECT_ROOT/docker/emscripten.Dockerfile" -t "$IMAGE_TAG" "$PROJECT_ROOT" -fi - HOST_UID="$(id -u)" HOST_GID="$(id -g)" @@ -26,6 +21,13 @@ if [ -f "$PROJECT_ROOT/assets/dist/.index.source" ]; then (cd "$PROJECT_ROOT/assets" && ln -sf "dist/.index.source" ".index.source") fi +# Ensure the static site can serve required overlay assets (like downloading.png) +SITE_DIR="$PROJECT_ROOT/pkg/server/static/site" +if [ -d "$PROJECT_ROOT/assets/sour/packages" ]; then + mkdir -p "$SITE_DIR/packages" + cp -rf "$PROJECT_ROOT/assets/sour/packages/"* "$SITE_DIR/packages/" 2>/dev/null || true +fi + CMD="go run ./cmd/sour serve" if [ -n "$CONFIG_FILE" ]; then # Map host path to container path @@ -38,10 +40,29 @@ else if [ -f "$PROJECT_ROOT/assets/.index.source" ]; then AUTO_CFG="$PROJECT_ROOT/dev.auto.yaml" cat > "$AUTO_CFG" </dev/null 2>&1 || true' INT TERM EXIT + +docker run \ --rm \ + --init \ --user "$HOST_UID:$HOST_GID" \ --name sour-serve \ -e HOME="/workspace/.home" \ @@ -60,4 +92,7 @@ exec docker run \ -v "$PROJECT_ROOT":/workspace \ -w /workspace \ "$IMAGE_TAG" \ - bash -lc "$CMD" + bash -lc "$CMD" & + +PID=$! +wait $PID From 0bd0d58e923b1c6bafd58d16eaf9d23453c5a121 Mon Sep 17 00:00:00 2001 From: Jan Hangebrauck Date: Fri, 29 Aug 2025 13:04:32 +0200 Subject: [PATCH 04/25] rename scripts --- scripts/build-all | 10 ++++++---- scripts/{build-assets-docker => build-assets} | 0 scripts/{build-game-docker => build-game} | 0 scripts/{serve-docker => serve} | 0 4 files changed, 6 insertions(+), 4 deletions(-) rename scripts/{build-assets-docker => build-assets} (100%) rename scripts/{build-game-docker => build-game} (100%) rename scripts/{serve-docker => serve} (100%) diff --git a/scripts/build-all b/scripts/build-all index c9ff1d16c..2c3e79d27 100755 --- a/scripts/build-all +++ b/scripts/build-all @@ -3,8 +3,10 @@ set -euo pipefail cd "$(dirname "$0")/.." -./scripts/build-docker-helper -./scripts/build-assets-docker -./scripts/build-game-docker +pwd -echo "Build complete. You can now run ./scripts/serve-docker to run the game server." \ No newline at end of file +./scripts/build-docker-image +./scripts/build-assets +./scripts/build-game + +echo "Build complete. You can now run ./scripts/serve to run the game server." \ No newline at end of file diff --git a/scripts/build-assets-docker b/scripts/build-assets similarity index 100% rename from scripts/build-assets-docker rename to scripts/build-assets diff --git a/scripts/build-game-docker b/scripts/build-game similarity index 100% rename from scripts/build-game-docker rename to scripts/build-game diff --git a/scripts/serve-docker b/scripts/serve similarity index 100% rename from scripts/serve-docker rename to scripts/serve From 5f4504a7bb635e767d5b78ec537388c3cae7ec40 Mon Sep 17 00:00:00 2001 From: Jan Hangebrauck Date: Fri, 29 Aug 2025 23:12:13 +0200 Subject: [PATCH 05/25] build fully dockerized and in userspace unified cache dir handling during build unified env var handling for build steps added menu items for servers improved build structure --- .gitignore | 6 +++- assets/package.py | 4 +++ assets/sour/data/menus.cfg | 3 ++ client/src/index.tsx | 30 ++++++++++++----- cmd/sour/serve.go | 3 ++ dev.auto.yaml | 69 +++++++++++++++++++++++++++++++++++--- docker/common.env | 6 ++++ docker/serve-entrypoint.sh | 35 +++++++++++++++++++ docker/serve.Dockerfile | 27 +++++++++++++++ scripts/build-all | 4 +++ scripts/build-assets | 7 ++-- scripts/build-game | 16 ++------- scripts/build-proxy | 24 +++++++++++++ scripts/build-serve-image | 25 ++++++++++++++ scripts/build-web | 41 ++++++++++++++++++++++ scripts/clean-generated | 18 ++++++---- scripts/run-serve-image | 34 +++++++++++++++++++ scripts/serve | 39 +++------------------ 18 files changed, 322 insertions(+), 69 deletions(-) create mode 100644 docker/common.env create mode 100644 docker/serve-entrypoint.sh create mode 100644 docker/serve.Dockerfile create mode 100755 scripts/build-proxy create mode 100755 scripts/build-serve-image create mode 100755 scripts/build-web create mode 100755 scripts/run-serve-image diff --git a/.gitignore b/.gitignore index 819c28627..95c2cf93a 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,8 @@ earthly/** /sourdump /sour-build .home/ -.pip-cache/ \ No newline at end of file +.pip-cache/ +.go-cache/ +.emscripten-cache/ +.gopath/ +bin/sour \ No newline at end of file diff --git a/assets/package.py b/assets/package.py index 3193e8f9b..4b994a867 100644 --- a/assets/package.py +++ b/assets/package.py @@ -266,8 +266,12 @@ def run_sourdump(roots: List[str], args: List[str]) -> str: root_args.append("-root") root_args.append(root) + cache_dir = os.getenv('ASSET_CACHE_DIR', 'cache/') + args = [ "./sourdump", + "-cache", + cache_dir, *root_args, *args, ] diff --git a/assets/sour/data/menus.cfg b/assets/sour/data/menus.cfg index 3cbeda8d0..21fcaff2d 100644 --- a/assets/sour/data/menus.cfg +++ b/assets/sour/data/menus.cfg @@ -39,6 +39,9 @@ newgui about [ guitext [BananaBread written by Alon Zakai] guitext [Continued by Caleb Foust] guitext "https://github.com/cfoust/sour" 0 + guitext [Continued and embedded into app.cg by Jan] + guitext "https://github.com/Kaesual/sour" 0 + guitext "https://app.cg/c/videogames" 0 ] genmapitems = [ diff --git a/client/src/index.tsx b/client/src/index.tsx index 03ce610b2..d2aa1ba19 100644 --- a/client/src/index.tsx +++ b/client/src/index.tsx @@ -375,13 +375,22 @@ function App() { React.useEffect(() => { if (state.type !== GameStateType.Ready) return - const menu = ` - newgui discord [ - guibutton "copy authkey command.." [js "Module.discord.copyKey()"] - //guibutton "regenerate auth key.." [js "Module.discord.regenKey()"] - guibutton "log out.." [js "Module.discord.logout()"] - ] + // To show the server browser, add: + // guibutton "server browser.." "showgui servers" + // + // Proxy setup is incomplete though at the moment, so + // connecting won't work + + // Removed discord button for now + // + // newgui discord [ + // guibutton "copy authkey command.." [js "Module.discord.copyKey()"] + // //guibutton "regenerate auth key.." [js "Module.discord.regenKey()"] + // guibutton "log out.." [js "Module.discord.logout()"] + // ] + + const menu = ` newgui content [ guibutton "mods.." "showgui mods" guibutton "put mods in url.." [js "Module.assets.modsToURL()"] @@ -417,6 +426,10 @@ function App() { guibar ] [ ${CONFIG.menuOptions} + guibutton "join insta-dust2" "join insta-dust2" + guibutton "join ffa-dust2" "join ffa-dust2" + guibutton "join insta rotating maps" "join insta" + guibutton "join ffa rotating maps" "join lobby" guibutton "create private game..." "creategame ffa" ] guibutton "random map.." "map random" @@ -816,17 +829,18 @@ function App() { if (serverMessage.Op === MessageType.Info) { const { Cluster, Master } = serverMessage + const combined = [...(Master || []), ...(Cluster || [])] if ( BananaBread == null || BananaBread.execute == null || BananaBread.injectServer == null ) { - cachedServers = Master + cachedServers = combined return } - injectServers(Master) + injectServers(combined) return } diff --git a/cmd/sour/serve.go b/cmd/sour/serve.go index 2fe267113..91accaed7 100644 --- a/cmd/sour/serve.go +++ b/cmd/sour/serve.go @@ -220,6 +220,9 @@ func serveCommand(configs []string) error { go cluster.PollUsers(ctx, newConnections) go cluster.PollDuels(ctx) + // Start periodic server list watcher/broadcasts for the web server browser + wsIngress.StartWatcher(ctx) + // Encode the client config as json clientConfig, err := json.Marshal(config.Client) if err != nil { diff --git a/dev.auto.yaml b/dev.auto.yaml index c93434a2a..a94ab6855 100644 --- a/dev.auto.yaml +++ b/dev.auto.yaml @@ -1,26 +1,87 @@ +# This is Sour's default configuration. server: cacheDirectory: "./.cache/assets" assets: - "fs:assets/dist/.index.source" presets: + - name: "ffa-duel" + virtual: true + config: + defaultMode: "ffa" + defaultMap: "turbine" + - name: "insta-duel" + virtual: true + config: + defaultMode: "insta" + defaultMap: "turbine" - name: "default" default: true + config: + defaultMode: "coop" + defaultMap: "xmwhub" + - name: "ffa" config: defaultMode: "ffa" defaultMap: "complex" - maps: ["complex", "dust2", "turbine"] + maps: + - "turbine" + - "complex" + - "dust2" - name: "insta" config: defaultMode: "insta" + defaultMap: "complex" + maps: + - "turbine" + - "complex" + - "dust2" + - name: "insta-dust2" + config: + defaultMode: "insta" + defaultMap: "dust2" + maps: + - "dust2" + - name: "ffa-dust2" + config: + defaultMode: "ffa" defaultMap: "dust2" - maps: ["complex", "dust2", "turbine"] + maps: + - "dust2" + - name: "explore" + config: + matchLength: 180 + + ingress: + desktop: + - port: 28785 + target: lobby + serverInfo: + enabled: false + + matchmaking: + duel: + - name: "ffa" + preset: "ffa-duel" + forceRespawn: "dead" + default: true + - name: "insta" + preset: "insta-duel" + forceRespawn: "dead" + spaces: - - preset: default + - preset: ffa config: alias: lobby + - preset: insta-dust2 + config: + alias: insta-dust2 + - preset: ffa-dust2 + config: + alias: ffa-dust2 - preset: insta config: alias: insta + client: assets: - - "#origin/assets/0/.index.source" + - "#origin/assets/0/.index.source" \ No newline at end of file diff --git a/docker/common.env b/docker/common.env new file mode 100644 index 000000000..2aaeb7c23 --- /dev/null +++ b/docker/common.env @@ -0,0 +1,6 @@ +HOME=/workspace/.home +GOPATH=/workspace/.gopath +GOCACHE=/workspace/.go-cache +PIP_CACHE_DIR=/workspace/.pip-cache +EM_CACHE=/workspace/.emscripten-cache + diff --git a/docker/serve-entrypoint.sh b/docker/serve-entrypoint.sh new file mode 100644 index 000000000..244f6988f --- /dev/null +++ b/docker/serve-entrypoint.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# Optional: dev config mounted at /workspace/dev.auto.yaml +CONFIG_FILE="" +if [ -f "/workspace/dev.auto.yaml" ]; then + CONFIG_FILE="dev.auto.yaml" +fi + +# Start wsproxy (websocket -> UDP ENet proxy) if present +if [ -x "/workspace/proxy/wsproxy" ]; then + # Run wsproxy to handle /service/proxy/u/ + # We will front this via the Go server later; for now, bind on 1338 + /workspace/proxy/wsproxy 1338 & + PROXY_PID=$! +fi + +if [ -n "$CONFIG_FILE" ]; then + /workspace/bin/sour serve "$CONFIG_FILE" & +else + /workspace/bin/sour serve & +fi +SERVER_PID=$? +# Ensure the script stops and cleans up on SIGINT and SIGTERM + +cleanup() { + [[ -n "${PROXY_PID:-}" ]] && kill "${PROXY_PID}" >/dev/null 2>&1 || true + [[ -n "${SERVER_PID:-}" ]] && kill "${SERVER_PID}" >/dev/null 2>&1 || true + exit 0 +} + +trap cleanup INT TERM + +wait diff --git a/docker/serve.Dockerfile b/docker/serve.Dockerfile new file mode 100644 index 000000000..5623b7865 --- /dev/null +++ b/docker/serve.Dockerfile @@ -0,0 +1,27 @@ +FROM ubuntu:22.04 + +WORKDIR /workspace + +RUN apt-get update && apt-get install -y \ + zlib1g \ + libenet7 \ + && rm -rf /var/lib/apt/lists/* + +# Copy built artifacts from mounted context at build time +# Expect caller to have run build-web, build-game, build-assets, build-proxy +COPY pkg/server/static/site /workspace/pkg/server/static/site +COPY assets/dist /workspace/assets/dist +RUN ln -s /workspace/assets/dist/.index.source /workspace/assets/.index.source +COPY bin/sour /workspace/bin/sour +COPY proxy/wsproxy /workspace/proxy/wsproxy + +# Entrypoint script +COPY docker/serve-entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh +RUN chmod -R 777 /workspace + +EXPOSE 1337 + +ENTRYPOINT ["/entrypoint.sh"] + + diff --git a/scripts/build-all b/scripts/build-all index 2c3e79d27..30e64f923 100755 --- a/scripts/build-all +++ b/scripts/build-all @@ -5,8 +5,12 @@ cd "$(dirname "$0")/.." pwd +./scripts/clean-generated ./scripts/build-docker-image ./scripts/build-assets ./scripts/build-game +./scripts/build-proxy +./scripts/build-web +./scripts/build-serve-image echo "Build complete. You can now run ./scripts/serve to run the game server." \ No newline at end of file diff --git a/scripts/build-assets b/scripts/build-assets index 62941573a..b7caf35ca 100755 --- a/scripts/build-assets +++ b/scripts/build-assets @@ -8,15 +8,17 @@ PROJECT_ROOT="$(cd "$(dirname "$0")"/.. && pwd)" HOST_UID="$(id -u)" HOST_GID="$(id -g)" +# Cache for downloaded assets persists across runs +ASSET_CACHE_DIR=${ASSET_CACHE_DIR:-cache} ASSET_OUTPUT_DIR=${ASSET_OUTPUT_DIR:-output} # Space-separated maps to build; use 'none' to skip maps ASSET_MAPS=${ASSET_MAPS:-"complex dust2 turbine"} exec docker run --rm \ --user "$HOST_UID:$HOST_GID" \ + --env-file "$PROJECT_ROOT/docker/common.env" \ -e ASSET_OUTPUT_DIR="$ASSET_OUTPUT_DIR" \ - -e HOME="/workspace/.home" \ - -e PIP_CACHE_DIR="/workspace/.pip-cache" \ + -e ASSET_CACHE_DIR="$ASSET_CACHE_DIR" \ -e ASSET_MAPS="$ASSET_MAPS" \ -v "$PROJECT_ROOT":/workspace \ -w /workspace/assets \ @@ -25,6 +27,7 @@ exec docker run --rm \ set -e mkdir -p "$HOME" "$PIP_CACHE_DIR" python3 -m pip install --user -r requirements.txt || true + mkdir -p "$ASSET_CACHE_DIR" ./setup # Rebuild sourdump to ensure latest code is used rm -f sourdump diff --git a/scripts/build-game b/scripts/build-game index 80ca480ac..65df2c259 100755 --- a/scripts/build-game +++ b/scripts/build-game @@ -16,8 +16,8 @@ mkdir -p "$EM_CACHE_DIR" exec docker run --rm \ --user "$HOST_UID:$HOST_GID" \ + --env-file "$PROJECT_ROOT/docker/common.env" \ -e GAME_OUTPUT_DIR="$GAME_OUTPUT_DIR" \ - -e EM_CACHE="/workspace/.emscripten-cache" \ -v "$PROJECT_ROOT":/workspace \ -w /workspace/game \ "$IMAGE_TAG" \ @@ -25,19 +25,7 @@ exec docker run --rm \ set -e source /emsdk/emsdk_env.sh - echo "[1/3] Building WASM game..." + echo "[1/1] Building WASM game..." cd /workspace/game ./build - - echo "[2/3] Building web client..." - cd /workspace/client - yarn install - yarn build - cp src/index.html src/favicon.ico src/background.png dist/ - mkdir -p dist/game && cp -r ../game/dist/game/* dist/game/ - - echo "[3/3] Staging site for Go server..." - rm -rf /workspace/pkg/server/static/site - mkdir -p /workspace/pkg/server/static/site - cp -r dist/* /workspace/pkg/server/static/site/ ' diff --git a/scripts/build-proxy b/scripts/build-proxy new file mode 100755 index 000000000..59a5902b7 --- /dev/null +++ b/scripts/build-proxy @@ -0,0 +1,24 @@ +#!/usr/bin/env bash + +set -euo pipefail + +IMAGE_TAG="sour-emscripten:3.1.8" +PROJECT_ROOT="$(cd "$(dirname "$0")"/.. && pwd)" + +HOST_UID="$(id -u)" +HOST_GID="$(id -g)" + +exec docker run --rm \ + --user "$HOST_UID:$HOST_GID" \ + --env-file "$PROJECT_ROOT/docker/common.env" \ + -v "$PROJECT_ROOT":/workspace \ + -w /workspace/proxy \ + "$IMAGE_TAG" \ + bash -lc ' + set -e + make clean || true + make + echo "Built proxy: /workspace/proxy/wsproxy" + ' + + diff --git a/scripts/build-serve-image b/scripts/build-serve-image new file mode 100755 index 000000000..79867a864 --- /dev/null +++ b/scripts/build-serve-image @@ -0,0 +1,25 @@ +#!/usr/bin/env bash + +set -euo pipefail + +IMAGE_TAG="sour-emscripten:3.1.8" +PROJECT_ROOT="$(cd "$(dirname "$0")"/.. && pwd)" +HOST_UID="$(id -u)" +HOST_GID="$(id -g)" + +# Build sour binary +docker run \ + --rm \ + --user "$HOST_UID:$HOST_GID" \ + --env-file "$PROJECT_ROOT/docker/common.env" \ + -v "$PROJECT_ROOT":/workspace \ + -w /workspace \ + "$IMAGE_TAG" \ + bash -lc 'go build -o /workspace/bin/sour ./cmd/sour' + +# Build the serve image +docker build -f "$PROJECT_ROOT/docker/serve.Dockerfile" -t sour-serve:local "$PROJECT_ROOT" + +echo "Built image sour-serve:local" + + diff --git a/scripts/build-web b/scripts/build-web new file mode 100755 index 000000000..89b02274f --- /dev/null +++ b/scripts/build-web @@ -0,0 +1,41 @@ +#!/usr/bin/env bash + +set -euo pipefail + +IMAGE_TAG="sour-emscripten:3.1.8" +PROJECT_ROOT="$(cd "$(dirname "$0")"/.. && pwd)" +EM_CACHE_DIR="$PROJECT_ROOT/.emscripten-cache" + +# Allow overriding output dir +GAME_OUTPUT_DIR=${GAME_OUTPUT_DIR:-dist/game} + +# Run as the host user to avoid root-owned files; mount a writable EM_CACHE +HOST_UID="$(id -u)" +HOST_GID="$(id -g)" +mkdir -p "$EM_CACHE_DIR" + +exec docker run \ + --rm \ + --user "$HOST_UID:$HOST_GID" \ + --env-file "$PROJECT_ROOT/docker/common.env" \ + -e GAME_OUTPUT_DIR="$GAME_OUTPUT_DIR" \ + -v "$PROJECT_ROOT":/workspace \ + -w /workspace/game \ + "$IMAGE_TAG" \ + bash -lc ' + set -e + source /emsdk/emsdk_env.sh + + echo "[1/2] Building web client..." + cd /workspace/client + rm -rf dist + yarn install + yarn build + cp src/index.html src/favicon.ico src/background.png dist/ + mkdir -p dist/game && cp -r ../game/dist/game/* dist/game/ + + echo "[2/2] Staging site for Go server..." + rm -rf /workspace/pkg/server/static/site + mkdir -p /workspace/pkg/server/static/site + cp -r dist/* /workspace/pkg/server/static/site/ + ' diff --git a/scripts/clean-generated b/scripts/clean-generated index 0307813fc..07645d2d2 100755 --- a/scripts/clean-generated +++ b/scripts/clean-generated @@ -7,18 +7,24 @@ PROJECT_ROOT="$(cd "$(dirname "$0")"/.. && pwd)" rm -rf "$PROJECT_ROOT/game/dist/game" || true rm -rf "$PROJECT_ROOT/client/dist" || true rm -rf "$PROJECT_ROOT/pkg/server/static/site" || true -rm -rf "$PROJECT_ROOT/.emscripten-cache" || true -rm -rf "$PROJECT_ROOT/.pip-cache" || true -rm -rf "$PROJECT_ROOT/.home" || true # Also remove common temporary outputs under assets rm -rf "$PROJECT_ROOT/assets/dist" || true rm -rf "$PROJECT_ROOT/assets/output" || true -rm -rf "$PROJECT_ROOT/assets/cache" || true rm -f "$PROJECT_ROOT/assets/.index.source" || true rm -f "$PROJECT_ROOT/assets/.index.json" || true -# Remove auto-generated dev config -rm -f "$PROJECT_ROOT/dev.auto.yaml" || true +# Cache for emscripten, pip, go, and gopath persists across runs, only remove if you want to +# re-build the assets +# rm -rf "$PROJECT_ROOT/.emscripten-cache" || true +# rm -rf "$PROJECT_ROOT/.pip-cache" || true +# rm -rf "$PROJECT_ROOT/.go-cache" || true +# rm -rf "$PROJECT_ROOT/.gopath" || true +# chmod -R +w "$PROJECT_ROOT/.home" || true # Allow removal of .home +# rm -rf "$PROJECT_ROOT/.home" || true + +# Cache for downloaded assets persists across runs, only remove if you want to +# re-download all assets +# rm -rf "$PROJECT_ROOT/assets/cache" || true echo "Cleaned generated outputs." diff --git a/scripts/run-serve-image b/scripts/run-serve-image new file mode 100755 index 000000000..d6356e6a9 --- /dev/null +++ b/scripts/run-serve-image @@ -0,0 +1,34 @@ +#!/usr/bin/env bash + +set -euo pipefail + +PROJECT_ROOT="$(cd "$(dirname "$0")"/.. && pwd)" + +HOST_UID="$(id -u)" +HOST_GID="$(id -g)" +WEB_ADDR=${WEB_ADDR:-0.0.0.0} +WEB_PORT=${WEB_PORT:-1337} + +# Optionally pass a config file (e.g., dev.auto.yaml) +CONFIG_FILE=${1:-} + +handle_signal() { + echo "SIGINT received, stopping container..." + docker stop sour-serve + exit 0 +} +trap handle_signal SIGINT +trap handle_signal SIGTERM + +docker run \ + --rm \ + --init \ + --name sour-serve \ + --user "$HOST_UID:$HOST_GID" \ + --env-file "$PROJECT_ROOT/docker/common.env" \ + -p "$WEB_ADDR:$WEB_PORT:$WEB_PORT" \ + -v "$PROJECT_ROOT/dev.auto.yaml":/workspace/dev.auto.yaml:ro \ + sour-serve:local & + +PID=$! +wait $PID diff --git a/scripts/serve b/scripts/serve index 8bf8ca3e4..4a544f84e 100755 --- a/scripts/serve +++ b/scripts/serve @@ -36,39 +36,10 @@ if [ -n "$CONFIG_FILE" ]; then CMD="go run ./cmd/sour serve \"$REL\"" fi else - # Auto-generate a simple config pointing to local assets index if present - if [ -f "$PROJECT_ROOT/assets/.index.source" ]; then - AUTO_CFG="$PROJECT_ROOT/dev.auto.yaml" - cat > "$AUTO_CFG" < Date: Sat, 30 Aug 2025 00:00:06 +0200 Subject: [PATCH 06/25] podman compatibility --- scripts/build-assets | 14 +++++++++++++- scripts/build-docker-image | 14 +++++++++++++- scripts/build-game | 14 +++++++++++++- scripts/build-proxy | 14 +++++++++++++- scripts/build-serve-image | 16 ++++++++++++++-- scripts/build-web | 14 +++++++++++++- scripts/run-serve-image | 16 ++++++++++++++-- scripts/serve | 21 ++++++++++++++++++--- 8 files changed, 111 insertions(+), 12 deletions(-) diff --git a/scripts/build-assets b/scripts/build-assets index b7caf35ca..72d7cb6ca 100755 --- a/scripts/build-assets +++ b/scripts/build-assets @@ -2,6 +2,18 @@ set -euo pipefail +CONTAINER_ENGINE=${CONTAINER_ENGINE:-} +if [ -z "${CONTAINER_ENGINE}" ]; then + if command -v docker >/dev/null 2>&1; then + CONTAINER_ENGINE=docker + elif command -v podman >/dev/null 2>&1; then + CONTAINER_ENGINE=podman + else + echo "Neither docker nor podman found. Please install one or set CONTAINER_ENGINE." + exit 1 + fi +fi + IMAGE_TAG="sour-emscripten:3.1.8" PROJECT_ROOT="$(cd "$(dirname "$0")"/.. && pwd)" @@ -14,7 +26,7 @@ ASSET_OUTPUT_DIR=${ASSET_OUTPUT_DIR:-output} # Space-separated maps to build; use 'none' to skip maps ASSET_MAPS=${ASSET_MAPS:-"complex dust2 turbine"} -exec docker run --rm \ +exec "$CONTAINER_ENGINE" run --rm \ --user "$HOST_UID:$HOST_GID" \ --env-file "$PROJECT_ROOT/docker/common.env" \ -e ASSET_OUTPUT_DIR="$ASSET_OUTPUT_DIR" \ diff --git a/scripts/build-docker-image b/scripts/build-docker-image index 1d69d58e4..68536d80c 100755 --- a/scripts/build-docker-image +++ b/scripts/build-docker-image @@ -2,12 +2,24 @@ set -euo pipefail +CONTAINER_ENGINE=${CONTAINER_ENGINE:-} +if [ -z "${CONTAINER_ENGINE}" ]; then + if command -v docker >/dev/null 2>&1; then + CONTAINER_ENGINE=docker + elif command -v podman >/dev/null 2>&1; then + CONTAINER_ENGINE=podman + else + echo "Neither docker nor podman found. Please install one or set CONTAINER_ENGINE." + exit 1 + fi +fi + IMAGE_TAG="sour-emscripten:3.1.8" PROJECT_ROOT="$(cd "$(dirname "$0")"/.. && pwd)" EM_CACHE_DIR="$PROJECT_ROOT/.emscripten-cache" build_image() { - docker build -f "$PROJECT_ROOT/docker/emscripten.Dockerfile" -t "$IMAGE_TAG" "$PROJECT_ROOT" + "$CONTAINER_ENGINE" build -f "$PROJECT_ROOT/docker/emscripten.Dockerfile" -t "$IMAGE_TAG" "$PROJECT_ROOT" } build_image \ No newline at end of file diff --git a/scripts/build-game b/scripts/build-game index 65df2c259..9ea3ce10a 100755 --- a/scripts/build-game +++ b/scripts/build-game @@ -2,6 +2,18 @@ set -euo pipefail +CONTAINER_ENGINE=${CONTAINER_ENGINE:-} +if [ -z "${CONTAINER_ENGINE}" ]; then + if command -v docker >/dev/null 2>&1; then + CONTAINER_ENGINE=docker + elif command -v podman >/dev/null 2>&1; then + CONTAINER_ENGINE=podman + else + echo "Neither docker nor podman found. Please install one or set CONTAINER_ENGINE." + exit 1 + fi +fi + IMAGE_TAG="sour-emscripten:3.1.8" PROJECT_ROOT="$(cd "$(dirname "$0")"/.. && pwd)" EM_CACHE_DIR="$PROJECT_ROOT/.emscripten-cache" @@ -14,7 +26,7 @@ HOST_UID="$(id -u)" HOST_GID="$(id -g)" mkdir -p "$EM_CACHE_DIR" -exec docker run --rm \ +exec "$CONTAINER_ENGINE" run --rm \ --user "$HOST_UID:$HOST_GID" \ --env-file "$PROJECT_ROOT/docker/common.env" \ -e GAME_OUTPUT_DIR="$GAME_OUTPUT_DIR" \ diff --git a/scripts/build-proxy b/scripts/build-proxy index 59a5902b7..6faebf6cc 100755 --- a/scripts/build-proxy +++ b/scripts/build-proxy @@ -2,13 +2,25 @@ set -euo pipefail +CONTAINER_ENGINE=${CONTAINER_ENGINE:-} +if [ -z "${CONTAINER_ENGINE}" ]; then + if command -v docker >/dev/null 2>&1; then + CONTAINER_ENGINE=docker + elif command -v podman >/dev/null 2>&1; then + CONTAINER_ENGINE=podman + else + echo "Neither docker nor podman found. Please install one or set CONTAINER_ENGINE." + exit 1 + fi +fi + IMAGE_TAG="sour-emscripten:3.1.8" PROJECT_ROOT="$(cd "$(dirname "$0")"/.. && pwd)" HOST_UID="$(id -u)" HOST_GID="$(id -g)" -exec docker run --rm \ +exec "$CONTAINER_ENGINE" run --rm \ --user "$HOST_UID:$HOST_GID" \ --env-file "$PROJECT_ROOT/docker/common.env" \ -v "$PROJECT_ROOT":/workspace \ diff --git a/scripts/build-serve-image b/scripts/build-serve-image index 79867a864..9a7a90a40 100755 --- a/scripts/build-serve-image +++ b/scripts/build-serve-image @@ -2,13 +2,25 @@ set -euo pipefail +CONTAINER_ENGINE=${CONTAINER_ENGINE:-} +if [ -z "${CONTAINER_ENGINE}" ]; then + if command -v docker >/dev/null 2>&1; then + CONTAINER_ENGINE=docker + elif command -v podman >/dev/null 2>&1; then + CONTAINER_ENGINE=podman + else + echo "Neither docker nor podman found. Please install one or set CONTAINER_ENGINE." + exit 1 + fi +fi + IMAGE_TAG="sour-emscripten:3.1.8" PROJECT_ROOT="$(cd "$(dirname "$0")"/.. && pwd)" HOST_UID="$(id -u)" HOST_GID="$(id -g)" # Build sour binary -docker run \ +"$CONTAINER_ENGINE" run \ --rm \ --user "$HOST_UID:$HOST_GID" \ --env-file "$PROJECT_ROOT/docker/common.env" \ @@ -18,7 +30,7 @@ docker run \ bash -lc 'go build -o /workspace/bin/sour ./cmd/sour' # Build the serve image -docker build -f "$PROJECT_ROOT/docker/serve.Dockerfile" -t sour-serve:local "$PROJECT_ROOT" +"$CONTAINER_ENGINE" build -f "$PROJECT_ROOT/docker/serve.Dockerfile" -t sour-serve:local "$PROJECT_ROOT" echo "Built image sour-serve:local" diff --git a/scripts/build-web b/scripts/build-web index 89b02274f..307ef8c64 100755 --- a/scripts/build-web +++ b/scripts/build-web @@ -2,6 +2,18 @@ set -euo pipefail +CONTAINER_ENGINE=${CONTAINER_ENGINE:-} +if [ -z "${CONTAINER_ENGINE}" ]; then + if command -v docker >/dev/null 2>&1; then + CONTAINER_ENGINE=docker + elif command -v podman >/dev/null 2>&1; then + CONTAINER_ENGINE=podman + else + echo "Neither docker nor podman found. Please install one or set CONTAINER_ENGINE." + exit 1 + fi +fi + IMAGE_TAG="sour-emscripten:3.1.8" PROJECT_ROOT="$(cd "$(dirname "$0")"/.. && pwd)" EM_CACHE_DIR="$PROJECT_ROOT/.emscripten-cache" @@ -14,7 +26,7 @@ HOST_UID="$(id -u)" HOST_GID="$(id -g)" mkdir -p "$EM_CACHE_DIR" -exec docker run \ +exec "$CONTAINER_ENGINE" run \ --rm \ --user "$HOST_UID:$HOST_GID" \ --env-file "$PROJECT_ROOT/docker/common.env" \ diff --git a/scripts/run-serve-image b/scripts/run-serve-image index d6356e6a9..d4cc8f727 100755 --- a/scripts/run-serve-image +++ b/scripts/run-serve-image @@ -2,6 +2,18 @@ set -euo pipefail +CONTAINER_ENGINE=${CONTAINER_ENGINE:-} +if [ -z "${CONTAINER_ENGINE}" ]; then + if command -v docker >/dev/null 2>&1; then + CONTAINER_ENGINE=docker + elif command -v podman >/dev/null 2>&1; then + CONTAINER_ENGINE=podman + else + echo "Neither docker nor podman found. Please install one or set CONTAINER_ENGINE." + exit 1 + fi +fi + PROJECT_ROOT="$(cd "$(dirname "$0")"/.. && pwd)" HOST_UID="$(id -u)" @@ -14,13 +26,13 @@ CONFIG_FILE=${1:-} handle_signal() { echo "SIGINT received, stopping container..." - docker stop sour-serve + "$CONTAINER_ENGINE" stop sour-serve exit 0 } trap handle_signal SIGINT trap handle_signal SIGTERM -docker run \ +"$CONTAINER_ENGINE" run \ --rm \ --init \ --name sour-serve \ diff --git a/scripts/serve b/scripts/serve index 4a544f84e..8cb190caa 100755 --- a/scripts/serve +++ b/scripts/serve @@ -2,6 +2,18 @@ set -euo pipefail +CONTAINER_ENGINE=${CONTAINER_ENGINE:-} +if [ -z "${CONTAINER_ENGINE}" ]; then + if command -v docker >/dev/null 2>&1; then + CONTAINER_ENGINE=docker + elif command -v podman >/dev/null 2>&1; then + CONTAINER_ENGINE=podman + else + echo "Neither docker nor podman found. Please install one or set CONTAINER_ENGINE." + exit 1 + fi +fi + IMAGE_TAG="sour-emscripten:3.1.8" PROJECT_ROOT="$(cd "$(dirname "$0")"/.. && pwd)" @@ -45,15 +57,18 @@ fi handle_signal() { echo "SIGINT received, stopping container..." - docker stop sour-serve + "$CONTAINER_ENGINE" stop sour-serve exit 0 } trap handle_signal SIGINT trap handle_signal SIGTERM -trap 'docker rm -f sour-serve >/dev/null 2>&1 || true' INT TERM EXIT +cleanup() { + "$CONTAINER_ENGINE" rm -f sour-serve >/dev/null 2>&1 || true +} +trap cleanup INT TERM EXIT -docker run \ +"$CONTAINER_ENGINE" run \ --rm \ --init \ --user "$HOST_UID:$HOST_GID" \ From 8ffb3379594f2979be3fc7a6dd834805a9c9c50b Mon Sep 17 00:00:00 2001 From: Jan Hangebrauck Date: Sat, 30 Aug 2025 09:58:25 +0200 Subject: [PATCH 07/25] fixed all urls for relative hosting --- client/src/config.ts | 25 +++++++++++++++++++++++-- client/src/index.html | 12 ++++++++---- client/src/index.tsx | 18 +++++++++++------- client/src/unsafe-startup.js | 11 +++++++---- docker/common.env | 3 +-- docker/serve.Dockerfile | 3 ++- scripts/build-serve-image | 4 ++-- scripts/build-web | 6 +++--- scripts/run-serve-image | 1 - 9 files changed, 57 insertions(+), 26 deletions(-) diff --git a/client/src/config.ts b/client/src/config.ts index e2622dd2d..7904ba6d8 100644 --- a/client/src/config.ts +++ b/client/src/config.ts @@ -32,12 +32,33 @@ function fillHost(url: string): string { function fillAssetHost(url: string): string { const newHost = fillHost(url) + // Rebase assets to current path if pointing at same-origin /assets + // This makes assets work when the app is hosted under a subpath (e.g., /sour/) + const rebase = (absolute: string): string => { + try { + const u = new URL(absolute, window.location.href) + if ( + u.origin === window.location.origin && + u.pathname.startsWith('/assets/') + ) { + const baseAssetsPath = new URL('assets/', window.location.href).pathname + u.pathname = baseAssetsPath + u.pathname.slice('/assets/'.length) + return u.toString() + } + return u.toString() + } catch (_e) { + return absolute + } + } + + const rebased = rebase(newHost) + // Don't cache asset sources pointing to this host if (url.includes(REPLACED.HOST) || url.includes(REPLACED.ORIGIN)) { - return `!${newHost}` + return `!${rebased}` } - return newHost + return rebased } function getInjected(): Maybe { diff --git a/client/src/index.html b/client/src/index.html index 90905a67d..589f695f9 100644 --- a/client/src/index.html +++ b/client/src/index.html @@ -33,7 +33,7 @@ { WASM_PROMISE_RESOLVE = resolve; }) + // Override the locateFile function to use the game directory + Module.locateFile = function(path, prefix) { + return new URL('game/' + path, window.location.href).toString(); + } - + - - + + diff --git a/client/src/index.tsx b/client/src/index.tsx index d2aa1ba19..fed4f2d28 100644 --- a/client/src/index.tsx +++ b/client/src/index.tsx @@ -302,9 +302,9 @@ function App() { Module.socket = (addr, port) => { const { protocol, host } = window.location - const prefix = `${ - protocol === 'https:' ? 'wss://' : 'ws:/' - }${host}/service/proxy/` + const wsProtocol = protocol === 'https:' ? 'wss:' : 'ws:' + const basePath = new URL('service/proxy/', window.location.href).pathname + const prefix = `${wsProtocol}//${host}${basePath}` return new WebSocket( addr === 'sour' ? prefix : `${prefix}u/${addr}:${port}`, @@ -453,10 +453,14 @@ function App() { const [serverURL] = CONFIG.servers - const { protocol, host } = window.location - const ws = new WebSocket( - `${protocol === 'https:' ? 'wss://' : 'ws:/'}${serverURL}` - ) + const { protocol } = window.location + const wsProtocol = protocol === 'https:' ? 'wss:' : 'ws:' + const sameHost = serverURL.startsWith(window.location.host) + const baseWsPath = new URL('ws/', window.location.href).pathname + const targetUrl = sameHost + ? `${wsProtocol}//${window.location.host}${baseWsPath}` + : `${wsProtocol}//${serverURL}` + const ws = new WebSocket(targetUrl) ws.binaryType = 'arraybuffer' ws.onopen = () => { diff --git a/client/src/unsafe-startup.js b/client/src/unsafe-startup.js index b2aa74535..5d29eee9a 100644 --- a/client/src/unsafe-startup.js +++ b/client/src/unsafe-startup.js @@ -8,9 +8,9 @@ export default function start() { websocket: { url: (addr, port) => { const { protocol, host } = window.location - const prefix = `${ - protocol === 'https:' ? 'wss://' : 'ws:/' - }${host}/service/proxy/` + const wsProtocol = protocol === 'https:' ? 'wss:' : 'ws:' + const basePath = new URL('service/proxy/', window.location.href).pathname + const prefix = `${wsProtocol}//${host}${basePath}` return addr === 'sour' ? prefix : `${prefix}u/${addr}:${port}` }, @@ -106,7 +106,10 @@ export default function start() { BananaBread.execute(`screenres ${width} ${height}`) }, locateFile: (file) => { - if (file.endsWith('.wasm')) return `/game/${file}` + if (file.endsWith('.wasm')) { + const u = new URL(`game/${file}`, window.location.href) + return u.pathname + } return null }, preRun: [], diff --git a/docker/common.env b/docker/common.env index 2aaeb7c23..91947de11 100644 --- a/docker/common.env +++ b/docker/common.env @@ -2,5 +2,4 @@ HOME=/workspace/.home GOPATH=/workspace/.gopath GOCACHE=/workspace/.go-cache PIP_CACHE_DIR=/workspace/.pip-cache -EM_CACHE=/workspace/.emscripten-cache - +EM_CACHE=/workspace/.emscripten-cache \ No newline at end of file diff --git a/docker/serve.Dockerfile b/docker/serve.Dockerfile index 5623b7865..63ba39771 100644 --- a/docker/serve.Dockerfile +++ b/docker/serve.Dockerfile @@ -9,9 +9,10 @@ RUN apt-get update && apt-get install -y \ # Copy built artifacts from mounted context at build time # Expect caller to have run build-web, build-game, build-assets, build-proxy -COPY pkg/server/static/site /workspace/pkg/server/static/site COPY assets/dist /workspace/assets/dist RUN ln -s /workspace/assets/dist/.index.source /workspace/assets/.index.source + +COPY pkg/server/static/site /workspace/pkg/server/static/site COPY bin/sour /workspace/bin/sour COPY proxy/wsproxy /workspace/proxy/wsproxy diff --git a/scripts/build-serve-image b/scripts/build-serve-image index 9a7a90a40..df0d428dd 100755 --- a/scripts/build-serve-image +++ b/scripts/build-serve-image @@ -30,8 +30,8 @@ HOST_GID="$(id -g)" bash -lc 'go build -o /workspace/bin/sour ./cmd/sour' # Build the serve image -"$CONTAINER_ENGINE" build -f "$PROJECT_ROOT/docker/serve.Dockerfile" -t sour-serve:local "$PROJECT_ROOT" +"$CONTAINER_ENGINE" build -f "$PROJECT_ROOT/docker/serve.Dockerfile" -t sour-serve:latest "$PROJECT_ROOT" -echo "Built image sour-serve:local" +echo "Built image sour-serve:latest" diff --git a/scripts/build-web b/scripts/build-web index 307ef8c64..85c267094 100755 --- a/scripts/build-web +++ b/scripts/build-web @@ -43,11 +43,11 @@ exec "$CONTAINER_ENGINE" run \ rm -rf dist yarn install yarn build - cp src/index.html src/favicon.ico src/background.png dist/ - mkdir -p dist/game && cp -r ../game/dist/game/* dist/game/ + cp -a src/index.html src/favicon.ico src/background.png dist/ + mkdir -p dist/game && cp -a ../game/dist/game/. dist/game/ echo "[2/2] Staging site for Go server..." rm -rf /workspace/pkg/server/static/site mkdir -p /workspace/pkg/server/static/site - cp -r dist/* /workspace/pkg/server/static/site/ + cp -a dist/. /workspace/pkg/server/static/site/ ' diff --git a/scripts/run-serve-image b/scripts/run-serve-image index d4cc8f727..a82b518ee 100755 --- a/scripts/run-serve-image +++ b/scripts/run-serve-image @@ -37,7 +37,6 @@ trap handle_signal SIGTERM --init \ --name sour-serve \ --user "$HOST_UID:$HOST_GID" \ - --env-file "$PROJECT_ROOT/docker/common.env" \ -p "$WEB_ADDR:$WEB_PORT:$WEB_PORT" \ -v "$PROJECT_ROOT/dev.auto.yaml":/workspace/dev.auto.yaml:ro \ sour-serve:local & From 7035a246867abd65b0db053c6b81ab43132b710b Mon Sep 17 00:00:00 2001 From: Jan Hangebrauck Date: Sat, 30 Aug 2025 17:58:18 +0200 Subject: [PATCH 08/25] update image and readme --- README.md | 10 ++++++---- gh-assets/sour-cg.png | 3 +++ 2 files changed, 9 insertions(+), 4 deletions(-) create mode 100644 gh-assets/sour-cg.png diff --git a/README.md b/README.md index c5d421caa..2828a0411 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,20 @@

- Sour Cover Image + Sour on app.cg Cover Image

## What is this? -This is a fork of the excellent [original sour repository](https://github.com/cfoust/sour) made by cfoust. It has been made with the goal to embed sauerbraten as an in-community game experience into the Common Ground platform. I want to enable a Quake-like experience, and add additional features like Tournaments between communities. +This is a fork of the excellent [original sour repository](https://github.com/cfoust/sour) made by cfoust. It has been made with the goal to embed sauerbraten as an in-community game experience into the Common Ground platform. I want to enable a Quake-like experience, and I'm planning to add additional features like Tournaments between communities - stay tuned! šŸš€ +Sour is a Cube 2: Sauerbraten server that serves a fully-featured web-version of Sauerbraten (with support for mobile devices) in addition to accepting connections from the traditional, desktop version of the game. Sour is the easiest way to play Sauerbraten with your friends. +There's the original sourga.me run by cfoust. -Sour is a Cube 2: Sauerbraten server that serves a fully-featured web-version of Sauerbraten (with support for mobile devices) in addition to accepting connections from the traditional, desktop version of the game. Sour is the easiest way to play Sauerbraten with your friends. +And there's this version running as a videogames community plugin on app.cg, where you can also stream, chat and discover other games I made. -Give it a try. +**Note: This readme is work in progress and still needs some updates!** ## Installation diff --git a/gh-assets/sour-cg.png b/gh-assets/sour-cg.png new file mode 100644 index 000000000..f1acdb439 --- /dev/null +++ b/gh-assets/sour-cg.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fc503d68df4a43abe9b86b9a2226db5b1603e0aa87700570a72289c45a3d2416 +size 1817173 From 6d677dff2f9b7636e390cc4e46f7370377b88c09 Mon Sep 17 00:00:00 2001 From: Jan Hangebrauck Date: Sat, 30 Aug 2025 18:24:54 +0200 Subject: [PATCH 09/25] update readme --- README.md | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 2828a0411..939f76b7e 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,15 @@ And there's this version running as a - - Sour on app.cg Cover Image - + Sour on app.cg Cover Image

-## What is this? +## What is Sour? This is a fork of the excellent [original sour repository](https://github.com/cfoust/sour) made by cfoust. It has been made with the goal to embed sauerbraten as an in-community game experience into the Common Ground platform. I want to enable a Quake-like experience, and I'm planning to add additional features like Tournaments between communities - stay tuned! šŸš€ -Sour is a Cube 2: Sauerbraten server that serves a fully-featured web-version of Sauerbraten (with support for mobile devices) in addition to accepting connections from the traditional, desktop version of the game. Sour is the easiest way to play Sauerbraten with your friends. +Sour is a Cube 2: Sauerbraten server that serves a fully-featured web-version of Sauerbraten (with support for mobile devices) in +addition to accepting connections from the traditional, desktop version of the game. Sour is the easiest way to play Sauerbraten with your friends. -There's the original sourga.me run by cfoust. +There's multiple deployments of this game available: -And there's this version running as a videogames community plugin on app.cg, where you can also stream, chat and discover other games I made. +- The original version made and hosted by cfoust, available on [sourga.me](https://sourga.me) +- The in-community version on app.cg, in the [Common Games community](https://app.cg/c/commongames) +- A standalone version of the Common Ground version [here](https://embed.commonground.cg/sour/) -**Note: This readme is work in progress and still needs some updates!** +## The Common Games Collection -**2025-08-30 Updates made in this repository** +This repository is part of a broader effort to build a collection of Open Source games, which I call the [Common Games Collection](https://github.com/Kaesual/common-games-collection). Many such games exist, but are often cumbersome to build and thereby restricted to experts. I'm trying to build a unified collection, and make sure that all the games -- The build is now fully dockerized, completely in userspace. It can also generate a ready-to-host docker image with all assets. The image is also available [here](https://hub.docker.com/r/janhan/sour), but it currently lacks the config file (see `scripts/run-serve-image` for how to do it). +- have a proper dockerized build pipeline (simple to run on any OS) +- can generate docker images ready for self hosting +- can easily be hosted on any path behind a reverse nginx proxy (for https support and structure) +- can be run in iframes, since this is required for my use case +My idea is that as a collective, we can build a collection of great games where the community can *focus on modifying the games*, and knowledge about the (sometimes delicate) process of *converting a game to web assembly* can be shared, too. This way, it becomes easier to add more games over time. + +## Updates in this fork + +- The build is now fully dockerized, completely in userspace +- The build generates a ready-to-host docker image with all assets. The image is also available [here](https://hub.docker.com/r/janhan/sour), but it currently lacks the config file (see `scripts/run-serve-image` for how to do it). - Fixed an issue that prevented keyboard events from being picked up when running in iframes - Made hosting possible under a relative path when combined with a reverse nginx proxy + rewrite rule - And some more I probably forgot :D The docker build is only tested on linux, with docker, but I tried to make it podman compatible. -## Installation - -You can download an archive containing the Sour server and all necessary assets from [the releases page](https://github.com/cfoust/sour/releases). For now, only Linux and macOS are supported. - -You can also install Sour via `brew`: - -```bash -# Install the latest version: -brew install cfoust/taps/sour - -# Or a specific one: -brew install cfoust/taps/sour@0.2.2 -``` - -In addition to all of the base game assets, these archives only contain three maps: `complex`, `dust2`, and `turbine`. +## Local Development and Deployment ### Prerequisite: Git LFS @@ -60,26 +56,27 @@ git lfs install git lfs pull ``` -## Running Sour - -To run Sour, extract a release archive anywhere you wish, navigate to that directory, and run `./sour`. If you installed Sour with `brew`, just run `sour` in any terminal session. - -Running `sour` will start a Sour server accessible to web clients at `http://0.0.0.0:1337` and to desktop clients on port 28785. In other words, you should be able to connect to the Sour server in the Sauerbraten desktop client by running `/connect localhost`. - -By serving on `0.0.0.0` by default, the Sour server will be available to other devices on the local network at IP of the device running the Sour server. - ### Building the web game with Docker (Emscripten) -If you prefer building in a containerized environment, a Dockerfile and helper script are provided: +A Dockerfile and helper script for building in userspace are provided. To avoid issues, make sure to check out the repository with the same user who will run the build. When running the build with docker, this user also needs to be in the docker group. For podman this is not necessary. ```bash # Build everything and put it into a nice new docker image, ready to host ./scripts/build-all ``` -This uses an Ubuntu base with Emscripten 3.1.8 (same as CI), mounts your checkout at `/workspace`, and runs `game/build` inside the container. Artifacts will appear under `game/dist/game` by default. +This uses an Ubuntu base with Emscripten 3.1.8 (same as CI), mounts your checkout at `/workspace`, and runs the build scripts in the container. It creates a new docker image called `sour-serve:latest` by default. `build-all` is just a wrapper for the following build scripts: + +```bash +scripts/build-docker-image # builds the builder docker image +scripts/build-assets +scripts/build-game +scripts/build-proxy +scripts/build-web +scripts/build-serve-image +``` -Needs more updating here. +All steps can be run independently, e.g. if you only updated the web interface, you can run `scripts/build-web && scripts/build-serve-image` to update the image. Some assets are downloaded and cached during the first asset build, so it takes longer the first time. After that, building assets runs quite fast. ### Running the server in Docker @@ -93,15 +90,11 @@ After building, you can run the integrated server locally with: WEB_ADDR=127.0.0.1 WEB_PORT=1337 ./scripts/run-serve-image ``` -The script mounts your workspace and runs `go run ./cmd/sour serve` inside the container using your UID/GID so no files are owned by root. +The script mounts your workspace and runs `go run ./cmd/sour serve` inside the container using your UID/GID so no files are owned by root. There's also the older `scripts/serve` that I used before the docker images, should still work if you don't want to re-build the container every time, but must be restarted after making updates. ## Configuration -Sour is highly configurable. When run without arguments, `sour` defaults to running `sour serve` with the [default Sour configuration](https://github.com/cfoust/sour/blob/main/pkg/config/default.yaml). You change Sour's configuration by providing the path to a configuration file to `sour serve`: - -```bash -sour serve config.yaml -``` +Sour is highly configurable. When run without arguments, `sour` defaults to running `sour serve` with the [default Sour configuration](https://github.com/Kaesual/sour/blob/main/pkg/config/default.yaml). By default, the `run-serve-image` script mounts the `dev.auto.yaml` from this repository folder. You can make changes there or mount your own config file. Sour can be configured using `.yaml` or `.json` files; the structure is the same in both cases. @@ -125,6 +118,8 @@ These configurations are merged from left to right using [CUE](https://cuelang.o - **Preserve the experience of playing the original game.** While it is possible that Sour may someday support arbitrary game modes, assets, clients, and server code, the vanilla game experience should still be available. - **Be the best example of a cross-platform, open-source FPS.** Deployment of Sour on your own infrastructure with whatever configuration you like should be easy. Every aspect of Sour should be configurable. +Note: The goals above are originally from cfoust. My own goal to add would be embedding Sour into app.cg in a way that there's cross-community gaming activity. + ## Architecture Here is a high level description of the repository's contents: @@ -139,24 +134,19 @@ Here is a high level description of the repository's contents: **Updates in this fork** -- dockerized the build pipeline for the game client as well as assets +- `scripts`: dockerized the build pipeline for the game client as well as assets - uses one docker helper container that compiles everything and can also serve the game server - fixed a bug that prevented keyboard events to work in iframes - - - -## Contributing - -This repository is maintained by the Common Ground Team (I'm one of the founders) as an in-community gaming experience. Common Ground itself is a progressive web app and supports embedding custom games and plugins into Communities. If you're interested in the project, join our [Common Ground community on app.cg](https://app.cg/c/commonground/). - -Besides Sour / Sauerbraten, I also made a Luanti (think "open source minecraft") game plugin available on app.cg. Like Sour, it is also a web assembly game with an original c++ codebase. You can find my [minetest-wasm repository here](https://github.com/Kaesual/minetest-wasm). You can play both games right in your browser, in the [Video Games community on app.cg](https://app.cg/c/videogames/). - -The original repository was made by cfoust. You can join the community on [Discord](https://discord.gg/WP3EbYym4M) to chat with them and see how you can help out! Check out the [cfoust sour issues tab](https://github.com/cfoust/sour/issues) to get an idea of what needs doing. +- provide several simple build scripts for different steps of the process ## Inspiration +Original text from cfoust + Some years ago I came across [BananaBread](https://github.com/kripken/BananaBread), which was a basic tech demo that used [Emscripten](https://emscripten.org/) to compile Sauerbraten for the web. The project was limited in scope and done at a time when bandwidth was a lot more precious. ## License +Original text from cfoust + Each project that was forked into this repository has its own original license intact, though the glue code and subsequent modifications I have made are licensed according to the MIT license specified in `LICENSE`. diff --git a/assets/sour/data/menus.cfg b/assets/sour/data/menus.cfg index 21fcaff2d..4ca86f805 100644 --- a/assets/sour/data/menus.cfg +++ b/assets/sour/data/menus.cfg @@ -41,7 +41,7 @@ newgui about [ guitext "https://github.com/cfoust/sour" 0 guitext [Continued and embedded into app.cg by Jan] guitext "https://github.com/Kaesual/sour" 0 - guitext "https://app.cg/c/videogames" 0 + guitext "https://app.cg/c/commongames" 0 ] genmapitems = [ From 5f8013f12f34413a7c8b0389125fe0e822c8e820 Mon Sep 17 00:00:00 2001 From: Jan Hangebrauck Date: Wed, 3 Sep 2025 20:42:23 +0200 Subject: [PATCH 12/25] update readme --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index fba459c38..df1ded52f 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,8 @@ Sour is highly configurable. When run without arguments, `sour` defaults to runn Sour can be configured using `.yaml` or `.json` files; the structure is the same in both cases. +Warning: The section below is from the original readme and hasn't been updated yet, it will probably need a docker command instead as the docker image is where the system requirements are installed. + To print the default configuration to standard output, run `sour config`: ```bash From 2cd54ca8e0941655ed559e20cb822f7fe96182cc Mon Sep 17 00:00:00 2001 From: Jan Hangebrauck Date: Wed, 3 Sep 2025 20:42:58 +0200 Subject: [PATCH 13/25] fix build consistency --- scripts/build-web | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/build-web b/scripts/build-web index 85c267094..439b050a3 100755 --- a/scripts/build-web +++ b/scripts/build-web @@ -41,6 +41,7 @@ exec "$CONTAINER_ENGINE" run \ echo "[1/2] Building web client..." cd /workspace/client rm -rf dist + rm -rf .parcel-cache yarn install yarn build cp -a src/index.html src/favicon.ico src/background.png dist/ From 6fea92271ea1bd64ea9fc5d2b25a04291848a556 Mon Sep 17 00:00:00 2001 From: Jan Hangebrauck Date: Wed, 3 Sep 2025 20:45:12 +0200 Subject: [PATCH 14/25] ensure deterministic packet distribution in relay add a lot of server rejection logging --- pkg/gameserver/clients.go | 4 ++ pkg/gameserver/module.go | 90 ++++++++++++++++++++++++++----- pkg/gameserver/packet_handling.go | 18 +++++++ pkg/gameserver/relay/relay.go | 34 ++++++++---- 4 files changed, 123 insertions(+), 23 deletions(-) diff --git a/pkg/gameserver/clients.go b/pkg/gameserver/clients.go index ea4673999..3d21816ea 100644 --- a/pkg/gameserver/clients.go +++ b/pkg/gameserver/clients.go @@ -2,6 +2,7 @@ package gameserver import ( "fmt" + "log" "strconv" "strings" "time" @@ -218,6 +219,9 @@ func (s *Server) SendWelcome(c *Client) { // Tells other clients that the client disconnected, giving a disconnect reason in case it's not a normal leave. func (cm *ClientManager) Disconnect(c *Client, reason disconnectreason.ID) { + log.Printf("Client %d (CN: %d) disconnecting: final state=%d, life sequence=%d, reason=%s", + c.SessionID, c.CN, c.State, c.LifeSequence, reason.String()) + cm.Relay(c, P.ClientDisconnected{int32(c.CN)}) msg := "" diff --git a/pkg/gameserver/module.go b/pkg/gameserver/module.go index fe3bb5b5a..a402b106e 100644 --- a/pkg/gameserver/module.go +++ b/pkg/gameserver/module.go @@ -300,24 +300,53 @@ func (s *Server) UniqueName(p *game.Player) string { } func (s *Server) Spawn(client *Client) { + oldLifeSequence := client.LifeSequence client.Spawn() + log.Info(). + Uint32("sessionID", client.SessionID). + Uint32("CN", client.CN). + Int32("oldLifeSequence", oldLifeSequence). + Int32("newLifeSequence", client.LifeSequence). + Msg("Client spawned: life sequence incremented") s.GameMode.Spawn(&client.PlayerState) } func (s *Server) ConfirmSpawn(client *Client, lifeSequence, _weapon int32) { if client.State != playerstate.Dead || lifeSequence != client.LifeSequence || client.LastSpawnAttempt.IsZero() { - // client may not spawn + log.Warn(). + Uint32("sessionID", client.SessionID). + Uint32("CN", client.CN). + Int("state", int(client.State)). + Int("expectedState", int(playerstate.Dead)). + Int32("lifeSequence", lifeSequence). + Int32("expectedLifeSequence", client.LifeSequence). + Bool("lastSpawnAttemptIsZero", client.LastSpawnAttempt.IsZero()). + Msg("Spawn confirmation rejected") return } + // FIRST: Update client state to Alive BEFORE broadcasting client.State = playerstate.Alive client.SelectedWeapon = weapon.ByID(weapon.ID(_weapon)) client.LastSpawnAttempt = time.Time{} + + log.Info(). + Uint32("sessionID", client.SessionID). + Uint32("CN", client.CN). + Msg("Spawn confirmed: client set to Alive state") + // SECOND: Notify ALL clients (including spawning client - they need their own spawn confirmation) client.Packets.Publish(P.SpawnResponse{ - EntityState: client.ToWire(), + EntityState: client.ToWire(), // Now contains ALIVE state + correct life sequence }) - + + log.Info(). + Uint32("sessionID", client.SessionID). + Uint32("CN", client.CN). + Int32("lifeSequence", client.LifeSequence). + Msg("Spawn notification sent immediately to other clients") + + // THIRD: Handle competitive mode timing if clock, competitive := s.GameMode.(game.Competitive); competitive { clock.Spawned(&client.Player) } @@ -526,11 +555,24 @@ func (s *Server) HandleShoot(client *Client, wpn weapon.Weapon, id int32, from, rays := int32(0) for _, h := range hits { target := s.Clients.GetClientByCN(h.target) - if target == nil || - target.State != playerstate.Alive || - target.LifeSequence != h.lifeSequence || - h.rays < 1 || - h.distance > wpn.Range+1.0 { + if target == nil { + log.Warn().Uint32("targetCN", h.target).Uint32("clientSessionID", client.SessionID).Uint32("clientCN", client.CN).Msg("Damage rejected for non-existent target") + continue + } + if target.State != playerstate.Alive { + log.Warn().Uint32("targetSessionID", target.SessionID).Uint32("targetCN", target.CN).Int("targetState", int(target.State)).Msg("Damage rejected: target state is not Alive") + continue + } + if target.LifeSequence != h.lifeSequence { + log.Warn().Uint32("targetSessionID", target.SessionID).Uint32("targetCN", target.CN).Int32("targetLifeSequence", target.LifeSequence).Int32("hitLifeSequence", h.lifeSequence).Uint32("clientSessionID", client.SessionID).Uint32("clientCN", client.CN).Msg("Damage rejected: life sequence mismatch") + continue + } + if h.rays < 1 { + log.Warn().Uint32("targetSessionID", target.SessionID).Uint32("targetCN", target.CN).Int32("rays", h.rays).Uint32("clientSessionID", client.SessionID).Uint32("clientCN", client.CN).Msg("Damage rejected: invalid rays count") + continue + } + if h.distance > wpn.Range+1.0 { + log.Warn().Uint32("targetSessionID", target.SessionID).Uint32("targetCN", target.CN).Float64("distance", h.distance).Float64("weaponRange", wpn.Range+1.0).Uint32("clientSessionID", client.SessionID).Uint32("clientCN", client.CN).Msg("Damage rejected: distance exceeds weapon range") continue } @@ -563,11 +605,24 @@ func (s *Server) HandleExplode(client *Client, millis int32, wpn weapon.Weapon, hits: for i, h := range hits { target := s.Clients.GetClientByCN(h.target) - if target == nil || - target.State != playerstate.Alive || - target.LifeSequence != h.lifeSequence || - h.distance < 0 || - h.distance > wpn.ExplosionRadius { + if target == nil { + log.Warn().Uint32("targetCN", h.target).Uint32("clientSessionID", client.SessionID).Uint32("clientCN", client.CN).Msg("Explosion damage rejected for non-existent target") + continue + } + if target.State != playerstate.Alive { + log.Warn().Uint32("targetSessionID", target.SessionID).Uint32("targetCN", target.CN).Int("targetState", int(target.State)).Msg("Explosion damage rejected: target state is not Alive") + continue + } + if target.LifeSequence != h.lifeSequence { + log.Warn().Uint32("targetSessionID", target.SessionID).Uint32("targetCN", target.CN).Int32("targetLifeSequence", target.LifeSequence).Int32("hitLifeSequence", h.lifeSequence).Uint32("clientSessionID", client.SessionID).Uint32("clientCN", client.CN).Msg("Explosion damage rejected: life sequence mismatch") + continue + } + if h.distance < 0 { + log.Warn().Uint32("targetSessionID", target.SessionID).Uint32("targetCN", target.CN).Float64("distance", h.distance).Uint32("clientSessionID", client.SessionID).Uint32("clientCN", client.CN).Msg("Explosion damage rejected: negative distance") + continue + } + if h.distance > wpn.ExplosionRadius { + log.Warn().Uint32("targetSessionID", target.SessionID).Uint32("targetCN", target.CN).Float64("distance", h.distance).Float64("explosionRadius", wpn.ExplosionRadius).Uint32("clientSessionID", client.SessionID).Uint32("clientCN", client.CN).Msg("Explosion damage rejected: distance exceeds explosion radius") continue } @@ -614,6 +669,15 @@ func (s *Server) applyDamage(attacker, victim *Client, damage int32, wpnID weapo } } if victim.Health <= 0 { + log.Warn(). + Uint32("victimSessionID", victim.SessionID). + Uint32("victimCN", victim.CN). + Uint32("attackerSessionID", attacker.SessionID). + Uint32("attackerCN", attacker.CN). + Int32("finalHealth", victim.Health). + Int32("damage", damage). + Int32("weaponID", int32(wpnID)). + Msg("Player killed - HandleFrag called") s.GameMode.HandleFrag(&attacker.Player, &victim.Player) } } diff --git a/pkg/gameserver/packet_handling.go b/pkg/gameserver/packet_handling.go index 68dff4e84..3a74e17f4 100644 --- a/pkg/gameserver/packet_handling.go +++ b/pkg/gameserver/packet_handling.go @@ -107,6 +107,9 @@ func (s *Server) HandlePacket(client *Client, channelID uint8, message P.Message msg.State.LifeSequence = client.LifeSequence client.Positions.Publish(msg) client.Position = mapVec(msg.State.O) + } else { + log.Printf("Position update rejected for client %d (CN: %d): client state is %d (expected Alive=%d), life sequence=%d", + client.SessionID, client.CN, client.State, playerstate.Alive, client.LifeSequence) } return @@ -114,6 +117,9 @@ func (s *Server) HandlePacket(client *Client, channelID uint8, message P.Message msg := message.(P.JumpPad) if client.State == playerstate.Alive { s.relay.FlushPositionAndSend(client.CN, msg) + } else { + log.Printf("Jumppad event rejected for client %d (CN: %d): client state is %d (not Alive)", + client.SessionID, client.CN, client.State) } case P.N_TELEPORT: @@ -121,6 +127,9 @@ func (s *Server) HandlePacket(client *Client, channelID uint8, message P.Message if client.State == playerstate.Alive { s.relay.FlushPositionAndSend(client.CN, msg) + } else { + log.Printf("Teleport event rejected for client %d (CN: %d): client state is %d (not Alive)", + client.SessionID, client.CN, client.State) } case P.N_ADDBOT, P.N_DELBOT: @@ -189,6 +198,7 @@ func (s *Server) HandlePacket(client *Client, channelID uint8, message P.Message } if toggle { + log.Printf("Client %d (CN: %d) transitioning to spectator mode from state %d", spectator.SessionID, spectator.CN, spectator.State) if client.State == playerstate.Alive { s.GameMode.HandleFrag(&spectator.Player, &spectator.Player) } @@ -196,6 +206,7 @@ func (s *Server) HandlePacket(client *Client, channelID uint8, message P.Message s.Clock.Leave(&spectator.Player) spectator.State = playerstate.Spectator } else { + log.Printf("Client %d (CN: %d) leaving spectator mode, transitioning to Dead state", spectator.SessionID, spectator.CN) spectator.State = playerstate.Dead if teamedMode, ok := s.GameMode.(game.TeamMode); ok { teamedMode.Join(&spectator.Player) @@ -310,8 +321,11 @@ func (s *Server) HandlePacket(client *Client, channelID uint8, message P.Message case P.N_TRYSPAWN: if !client.Joined || client.State != playerstate.Dead || !client.LastSpawnAttempt.IsZero() || !s.GameMode.CanSpawn(&client.Player) { + log.Printf("Spawn attempt rejected for client %d (CN: %d): joined=%t, state=%d (expected Dead=%d), lastSpawnAttempt.IsZero=%t, canSpawn=%t", + client.SessionID, client.CN, client.Joined, client.State, playerstate.Dead, client.LastSpawnAttempt.IsZero(), s.GameMode.CanSpawn(&client.Player)) return } + log.Printf("Spawning client %d (CN: %d) with life sequence %d", client.SessionID, client.CN, client.LifeSequence+1) s.Spawn(client) client.Send(P.SpawnState{int32(client.CN), client.ToWire()}) @@ -334,8 +348,12 @@ func (s *Server) HandlePacket(client *Client, channelID uint8, message P.Message case P.N_SHOOT: msg := message.(P.Shoot) + log.Printf("Shoot request from client %d (CN: %d): state=%d, weapon=%d", + client.SessionID, client.CN, client.State, msg.Gun) + wpn := weapon.ByID(weapon.ID(msg.Gun)) if time.Now().Before(client.GunReloadEnd) || client.Ammo[wpn.ID] <= 0 { + log.Printf("Shoot rejected for client %d (CN: %d): reload or no ammo", client.SessionID, client.CN) return } diff --git a/pkg/gameserver/relay/relay.go b/pkg/gameserver/relay/relay.go index 17adc5f11..fdbc62fb5 100644 --- a/pkg/gameserver/relay/relay.go +++ b/pkg/gameserver/relay/relay.go @@ -2,6 +2,7 @@ package relay import ( "errors" + "sort" "time" "github.com/cfoust/sour/pkg/game/protocol" @@ -126,21 +127,26 @@ func (r *Relay) FlushPositionAndSend(cn uint32, p protocol.Message) { r.mutex.Lock() defer r.mutex.Unlock() + // Create deterministic order for consistent packet delivery + order := make([]uint32, 0, len(r.send)) + for _cn := range r.send { + if _cn != cn { + order = append(order, _cn) + } + } + sort.Slice(order, func(i, j int) bool { + return order[i] < order[j] + }) + if pos := r.positions[cn]; pos != nil { - for _cn, send := range r.send { - if _cn == cn { - continue - } - send(0, pos) + for _, _cn := range order { + r.send[_cn](0, pos) } delete(r.positions, cn) } - for _cn, send := range r.send { - if _cn == cn { - continue - } - send(0, []protocol.Message{p}) + for _, _cn := range order { + r.send[_cn](0, []protocol.Message{p}) } } @@ -172,8 +178,16 @@ func (r *Relay) flush(packets map[uint32][]protocol.Message, prefix func(uint32, lengths := map[uint32]int{} combined := make([]protocol.Message, 0, 2*len(packets)*40) + // Create deterministic order instead of random map iteration for cn := range r.send { order = append(order, cn) + } + // Sort to ensure consistent ordering across flushes + sort.Slice(order, func(i, j int) bool { + return order[i] < order[j] + }) + + for _, cn := range order { pkt := packets[cn] if pkt == nil { continue From 869658a1821642873fefc9eaa0037b1790ea330b Mon Sep 17 00:00:00 2001 From: Jan Hangebrauck Date: Thu, 4 Sep 2025 08:57:04 +0200 Subject: [PATCH 15/25] update build scripts --- README.md | 42 +++++----- scripts/build | 78 +++++++++++++++++++ scripts/build-all | 16 ---- scripts/build-assets | 12 +-- ...build-docker-image => build-builder-image} | 1 - scripts/build-game | 6 +- .../{build-serve-image => build-game-image} | 4 +- scripts/build-proxy | 4 +- scripts/build-server | 32 ++++++++ scripts/build-web | 4 +- scripts/clean-generated | 30 ------- scripts/serve | 6 +- scripts/{run-serve-image => serve-image} | 7 +- 13 files changed, 159 insertions(+), 83 deletions(-) create mode 100755 scripts/build delete mode 100755 scripts/build-all rename scripts/{build-docker-image => build-builder-image} (92%) rename scripts/{build-serve-image => build-game-image} (91%) create mode 100755 scripts/build-server delete mode 100755 scripts/clean-generated rename scripts/{run-serve-image => serve-image} (86%) diff --git a/README.md b/README.md index df1ded52f..ec92b8013 100644 --- a/README.md +++ b/README.md @@ -29,13 +29,19 @@ My idea is that as a collective, we can build a collection of great games where ## Updates in this fork - The build is now fully dockerized, completely in userspace -- The build generates a ready-to-host docker image with all assets. The image is also available [here](https://hub.docker.com/r/janhan/sour), but it currently lacks the config file (see `scripts/run-serve-image` for how to do it). +- The build generates a ready-to-host docker image with all assets. The image is also available [here](https://hub.docker.com/r/janhan/sour), but it currently lacks the config file (see `scripts/serve-image` for how to do it). - Fixed an issue that prevented keyboard events from being picked up when running in iframes - Made hosting possible under a relative path when combined with a reverse nginx proxy + rewrite rule -- And some more I probably forgot :D +- I added some more pre-set server types running on the web server for instant connection (insta + ffa dust2 & insta + ffa map rotation), which can be joined directly from the main menu The docker build is only tested on linux, with docker, but I tried to make it podman compatible. +## Issues / What's missing + +There's currently still desync issues in the built-in gameserver. Players become invisible to each other sometimes and stop being able to damage each other. This does not affect all player combinations, some players can still be damaged, seen by others, the combination seems random. I'm trying fix attempts on the `fix-desync` branch, but no success so far. I've added a lot of logging there too, which I will merge into the main branch at some point. + +Also, the built in proxy server of the docker image (which is already compiled in the according step) is not yet set up for proper use, so it's not possible yet to connect to official servers with the code in this repository. I'll try to fix that soon, too. (tm) + ## Local Development and Deployment ### Prerequisite: Git LFS @@ -62,21 +68,22 @@ A Dockerfile and helper script for building in userspace are provided. To avoid ```bash # Build everything and put it into a nice new docker image, ready to host -./scripts/build-all +./scripts/build ``` -This uses an Ubuntu base with Emscripten 3.1.8 (same as CI), mounts your checkout at `/workspace`, and runs the build scripts in the container. It creates a new docker image called `sour-serve:latest` by default. `build-all` is just a wrapper for the following build scripts: +This uses an Ubuntu base with Emscripten 3.1.8 (same as CI), mounts your checkout at `/workspace`, and runs the build scripts in the container. It creates a new docker image called `sour-game:latest` by default. `build` is just a wrapper for the following build scripts: ```bash -scripts/build-docker-image # builds the builder docker image +scripts/build-builder-image # builds the builder docker image scripts/build-assets scripts/build-game scripts/build-proxy scripts/build-web -scripts/build-serve-image +scripts/build-server +scripts/build-game-image ``` -All steps can be run independently, e.g. if you only updated the web interface, you can run `scripts/build-web && scripts/build-serve-image` to update the image. Some assets are downloaded and cached during the first asset build, so it takes longer the first time. After that, building assets runs quite fast. +All steps can be run independently, e.g. if you only updated the web interface, you can run `scripts/build web image` to update the image. Some assets are downloaded and cached during the first asset build, so it takes longer the first time. After that, building assets runs quite fast. ### Running the server in Docker @@ -84,17 +91,17 @@ After building, you can run the integrated server locally with: ```bash # Default: serves on 0.0.0.0:1337 -./scripts/run-serve-image +./scripts/serve-image # Override bind address/port -WEB_ADDR=127.0.0.1 WEB_PORT=1337 ./scripts/run-serve-image +WEB_ADDR=127.0.0.1 WEB_PORT=1337 ./scripts/serve-image ``` The script mounts your workspace and runs `go run ./cmd/sour serve` inside the container using your UID/GID so no files are owned by root. There's also the older `scripts/serve` that I used before the docker images, should still work if you don't want to re-build the container every time, but must be restarted after making updates. ## Configuration -Sour is highly configurable. When run without arguments, `sour` defaults to running `sour serve` with the [default Sour configuration](https://github.com/Kaesual/sour/blob/main/pkg/config/default.yaml). By default, the `run-serve-image` script mounts the `dev.auto.yaml` from this repository folder. You can make changes there or mount your own config file. +Sour is highly configurable. When run without arguments, `sour` defaults to running `sour serve` with the [default Sour configuration](https://github.com/Kaesual/sour/blob/main/pkg/config/default.yaml). By default, the `serve-image` script mounts the `dev.auto.yaml` from this repository folder. You can make changes there or mount your own config file. Sour can be configured using `.yaml` or `.json` files; the structure is the same in both cases. @@ -120,7 +127,7 @@ These configurations are merged from left to right using [CUE](https://cuelang.o - **Preserve the experience of playing the original game.** While it is possible that Sour may someday support arbitrary game modes, assets, clients, and server code, the vanilla game experience should still be available. - **Be the best example of a cross-platform, open-source FPS.** Deployment of Sour on your own infrastructure with whatever configuration you like should be easy. Every aspect of Sour should be configurable. -Note: The goals above are originally from cfoust. My own goal to add would be embedding Sour into app.cg in a way that there's cross-community gaming activity. +Note: The goals above are originally from cfoust. My own long-term goal to add would be embedding Sour into app.cg in a way that there's cross-community gaming activity and ranked / tournaments. And potentially, community governed report / review systems. ## Architecture @@ -134,18 +141,13 @@ Here is a high level description of the repository's contents: - `client`: A React web application that uses the compiled Sauerbraten game found in `game`, pulls assets, and proxies all server communication over a WebSocket. - `assets`: Scripts for building web-compatible game assets. This is an extremely complicated topic and easily the most difficult aspect of shipping Sauerbraten to the web. Check out this [section's README](assets/README.md) for more information. -**Updates in this fork** +Above is the original architecture section by cfoust. This repository additionally has: -- `scripts`: dockerized the build pipeline for the game client as well as assets +- `scripts/`: a simple, dockerized build pipeline for the game client, assets, server etc. +- generates a docker container ready for hosting - uses one docker helper container that compiles everything and can also serve the game server -- fixed a bug that prevented keyboard events to work in iframes -- provide several simple build scripts for different steps of the process - -## Inspiration - -Original text from cfoust +- a bugfix for keyboard events to work in iframes -Some years ago I came across [BananaBread](https://github.com/kripken/BananaBread), which was a basic tech demo that used [Emscripten](https://emscripten.org/) to compile Sauerbraten for the web. The project was limited in scope and done at a time when bandwidth was a lot more precious. ## License diff --git a/scripts/build b/scripts/build new file mode 100755 index 000000000..0f648237f --- /dev/null +++ b/scripts/build @@ -0,0 +1,78 @@ +#!/bin/bash + +set -euo pipefail +cd "$(dirname "$0")/.." + +ALLOWED_STEPS=("assets" "game" "proxy" "web" "server" "image") + +if [ "$#" -eq 0 ]; then + echo "Running the script with no parameters will run all build steps (and that's fine)." + echo "If you updated code and only want to build a specific part, you can also pass a custom set of steps." + echo "Available steps: ${ALLOWED_STEPS[*]}" + echo " Example: ./scripts/build game server image" + echo "The build will continue automatically in 2 seconds..." + sleep 2 +else + for step in "$@"; do + found=0 + for allowed in "${ALLOWED_STEPS[@]}"; do + if [[ "$step" == "$allowed" ]]; then + found=1 + break + fi + done + if [[ $found -eq 0 ]]; then + echo "Error: Unknown build step '$step'." + echo "Available steps: ${ALLOWED_STEPS[*]}" + exit 1 + fi + done + echo "Building steps: $* ..." +fi + +CONTAINER_ENGINE=${CONTAINER_ENGINE:-} +if [ -z "${CONTAINER_ENGINE}" ]; then + if command -v docker >/dev/null 2>&1; then + CONTAINER_ENGINE=docker + elif command -v podman >/dev/null 2>&1; then + CONTAINER_ENGINE=podman + else + echo "Neither docker nor podman found. Please install one or set CONTAINER_ENGINE." + exit 1 + fi +fi + +IMAGE_TAG="sour-emscripten:3.1.8" + +# build image if it doesn't exist +if ! "$CONTAINER_ENGINE" image inspect "$IMAGE_TAG" >/dev/null 2>&1; then + ./scripts/build-builder-image +else + echo "Image $IMAGE_TAG already exists. Skipping build." +fi + +if [[ " $* " == *" assets "* || "$#" -eq 0 ]]; then + ./scripts/build-assets +fi + +if [[ " $* " == *" game "* || "$#" -eq 0 ]]; then + ./scripts/build-game +fi + +if [[ " $* " == *" proxy "* || "$#" -eq 0 ]]; then + ./scripts/build-proxy +fi + +if [[ " $* " == *" web "* || "$#" -eq 0 ]]; then +./scripts/build-web +fi + +if [[ " $* " == *" server "* || "$#" -eq 0 ]]; then + ./scripts/build-server +fi + +if [[ " $* " == *" image "* || "$#" -eq 0 ]]; then + ./scripts/build-game-image +fi + +echo "Build complete. You can now run ./scripts/serve or ./scripts/serve-image to run the game server." \ No newline at end of file diff --git a/scripts/build-all b/scripts/build-all deleted file mode 100755 index 30e64f923..000000000 --- a/scripts/build-all +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/bash - -set -euo pipefail -cd "$(dirname "$0")/.." - -pwd - -./scripts/clean-generated -./scripts/build-docker-image -./scripts/build-assets -./scripts/build-game -./scripts/build-proxy -./scripts/build-web -./scripts/build-serve-image - -echo "Build complete. You can now run ./scripts/serve to run the game server." \ No newline at end of file diff --git a/scripts/build-assets b/scripts/build-assets index 72d7cb6ca..6fd62ff4c 100755 --- a/scripts/build-assets +++ b/scripts/build-assets @@ -26,7 +26,8 @@ ASSET_OUTPUT_DIR=${ASSET_OUTPUT_DIR:-output} # Space-separated maps to build; use 'none' to skip maps ASSET_MAPS=${ASSET_MAPS:-"complex dust2 turbine"} -exec "$CONTAINER_ENGINE" run --rm \ +"$CONTAINER_ENGINE" run \ + --rm \ --user "$HOST_UID:$HOST_GID" \ --env-file "$PROJECT_ROOT/docker/common.env" \ -e ASSET_OUTPUT_DIR="$ASSET_OUTPUT_DIR" \ @@ -37,13 +38,14 @@ exec "$CONTAINER_ENGINE" run --rm \ "$IMAGE_TAG" \ bash -lc ' set -e + rm -rf /workspace/assets/dist || true mkdir -p "$HOME" "$PIP_CACHE_DIR" python3 -m pip install --user -r requirements.txt || true mkdir -p "$ASSET_CACHE_DIR" ./setup - # Rebuild sourdump to ensure latest code is used - rm -f sourdump - go build -o sourdump ../cmd/sourdump/main.go + # OPTIONAL: Rebuild sourdump to ensure latest code is used + # rm -f sourdump + # go build -o sourdump ../cmd/sourdump/main.go # Build base assets; configure maps via ASSET_MAPS # Expand ASSET_MAPS into positional args set -- $ASSET_MAPS @@ -55,4 +57,4 @@ exec "$CONTAINER_ENGINE" run --rm \ "$@" ' - +echo "Successfully built assets" diff --git a/scripts/build-docker-image b/scripts/build-builder-image similarity index 92% rename from scripts/build-docker-image rename to scripts/build-builder-image index 68536d80c..668cc85fc 100755 --- a/scripts/build-docker-image +++ b/scripts/build-builder-image @@ -16,7 +16,6 @@ fi IMAGE_TAG="sour-emscripten:3.1.8" PROJECT_ROOT="$(cd "$(dirname "$0")"/.. && pwd)" -EM_CACHE_DIR="$PROJECT_ROOT/.emscripten-cache" build_image() { "$CONTAINER_ENGINE" build -f "$PROJECT_ROOT/docker/emscripten.Dockerfile" -t "$IMAGE_TAG" "$PROJECT_ROOT" diff --git a/scripts/build-game b/scripts/build-game index 9ea3ce10a..c71bc5d80 100755 --- a/scripts/build-game +++ b/scripts/build-game @@ -26,7 +26,8 @@ HOST_UID="$(id -u)" HOST_GID="$(id -g)" mkdir -p "$EM_CACHE_DIR" -exec "$CONTAINER_ENGINE" run --rm \ +"$CONTAINER_ENGINE" run \ + --rm \ --user "$HOST_UID:$HOST_GID" \ --env-file "$PROJECT_ROOT/docker/common.env" \ -e GAME_OUTPUT_DIR="$GAME_OUTPUT_DIR" \ @@ -39,5 +40,8 @@ exec "$CONTAINER_ENGINE" run --rm \ echo "[1/1] Building WASM game..." cd /workspace/game + rm -rf dist/game ./build ' + +echo "Successfully built game" \ No newline at end of file diff --git a/scripts/build-serve-image b/scripts/build-game-image similarity index 91% rename from scripts/build-serve-image rename to scripts/build-game-image index df0d428dd..e65577da3 100755 --- a/scripts/build-serve-image +++ b/scripts/build-game-image @@ -30,8 +30,8 @@ HOST_GID="$(id -g)" bash -lc 'go build -o /workspace/bin/sour ./cmd/sour' # Build the serve image -"$CONTAINER_ENGINE" build -f "$PROJECT_ROOT/docker/serve.Dockerfile" -t sour-serve:latest "$PROJECT_ROOT" +"$CONTAINER_ENGINE" build -f "$PROJECT_ROOT/docker/serve.Dockerfile" -t sour-game:latest "$PROJECT_ROOT" -echo "Built image sour-serve:latest" +echo "Built image sour-game:latest" diff --git a/scripts/build-proxy b/scripts/build-proxy index 6faebf6cc..d2ff783e9 100755 --- a/scripts/build-proxy +++ b/scripts/build-proxy @@ -20,7 +20,8 @@ PROJECT_ROOT="$(cd "$(dirname "$0")"/.. && pwd)" HOST_UID="$(id -u)" HOST_GID="$(id -g)" -exec "$CONTAINER_ENGINE" run --rm \ +"$CONTAINER_ENGINE" run \ + --rm \ --user "$HOST_UID:$HOST_GID" \ --env-file "$PROJECT_ROOT/docker/common.env" \ -v "$PROJECT_ROOT":/workspace \ @@ -33,4 +34,5 @@ exec "$CONTAINER_ENGINE" run --rm \ echo "Built proxy: /workspace/proxy/wsproxy" ' +echo "Successfully built proxy" diff --git a/scripts/build-server b/scripts/build-server new file mode 100755 index 000000000..9e4d4ba71 --- /dev/null +++ b/scripts/build-server @@ -0,0 +1,32 @@ +#!/usr/bin/env bash + +set -euo pipefail + +CONTAINER_ENGINE=${CONTAINER_ENGINE:-} +if [ -z "${CONTAINER_ENGINE}" ]; then + if command -v docker >/dev/null 2>&1; then + CONTAINER_ENGINE=docker + elif command -v podman >/dev/null 2>&1; then + CONTAINER_ENGINE=podman + else + echo "Neither docker nor podman found. Please install one or set CONTAINER_ENGINE." + exit 1 + fi +fi + +IMAGE_TAG="sour-emscripten:3.1.8" +PROJECT_ROOT="$(cd "$(dirname "$0")"/.. && pwd)" +HOST_UID="$(id -u)" +HOST_GID="$(id -g)" + +# Build sour binary +"$CONTAINER_ENGINE" run \ + --rm \ + --user "$HOST_UID:$HOST_GID" \ + --env-file "$PROJECT_ROOT/docker/common.env" \ + -v "$PROJECT_ROOT":/workspace \ + -w /workspace \ + "$IMAGE_TAG" \ + bash -lc 'go build -o /workspace/bin/sour ./cmd/sour' + +echo "Successfully built sour server binary" diff --git a/scripts/build-web b/scripts/build-web index 439b050a3..9f740760a 100755 --- a/scripts/build-web +++ b/scripts/build-web @@ -26,7 +26,7 @@ HOST_UID="$(id -u)" HOST_GID="$(id -g)" mkdir -p "$EM_CACHE_DIR" -exec "$CONTAINER_ENGINE" run \ +"$CONTAINER_ENGINE" run \ --rm \ --user "$HOST_UID:$HOST_GID" \ --env-file "$PROJECT_ROOT/docker/common.env" \ @@ -52,3 +52,5 @@ exec "$CONTAINER_ENGINE" run \ mkdir -p /workspace/pkg/server/static/site cp -a dist/. /workspace/pkg/server/static/site/ ' + +echo "Successfully built web client" diff --git a/scripts/clean-generated b/scripts/clean-generated deleted file mode 100755 index 07645d2d2..000000000 --- a/scripts/clean-generated +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -PROJECT_ROOT="$(cd "$(dirname "$0")"/.. && pwd)" - -rm -rf "$PROJECT_ROOT/game/dist/game" || true -rm -rf "$PROJECT_ROOT/client/dist" || true -rm -rf "$PROJECT_ROOT/pkg/server/static/site" || true - -# Also remove common temporary outputs under assets -rm -rf "$PROJECT_ROOT/assets/dist" || true -rm -rf "$PROJECT_ROOT/assets/output" || true -rm -f "$PROJECT_ROOT/assets/.index.source" || true -rm -f "$PROJECT_ROOT/assets/.index.json" || true - -# Cache for emscripten, pip, go, and gopath persists across runs, only remove if you want to -# re-build the assets -# rm -rf "$PROJECT_ROOT/.emscripten-cache" || true -# rm -rf "$PROJECT_ROOT/.pip-cache" || true -# rm -rf "$PROJECT_ROOT/.go-cache" || true -# rm -rf "$PROJECT_ROOT/.gopath" || true -# chmod -R +w "$PROJECT_ROOT/.home" || true # Allow removal of .home -# rm -rf "$PROJECT_ROOT/.home" || true - -# Cache for downloaded assets persists across runs, only remove if you want to -# re-download all assets -# rm -rf "$PROJECT_ROOT/assets/cache" || true - -echo "Cleaned generated outputs." diff --git a/scripts/serve b/scripts/serve index 8cb190caa..ad3cc19d7 100755 --- a/scripts/serve +++ b/scripts/serve @@ -57,14 +57,14 @@ fi handle_signal() { echo "SIGINT received, stopping container..." - "$CONTAINER_ENGINE" stop sour-serve + "$CONTAINER_ENGINE" stop sour-game exit 0 } trap handle_signal SIGINT trap handle_signal SIGTERM cleanup() { - "$CONTAINER_ENGINE" rm -f sour-serve >/dev/null 2>&1 || true + "$CONTAINER_ENGINE" rm -f sour-game >/dev/null 2>&1 || true } trap cleanup INT TERM EXIT @@ -72,7 +72,7 @@ trap cleanup INT TERM EXIT --rm \ --init \ --user "$HOST_UID:$HOST_GID" \ - --name sour-serve \ + --name sour-game \ --env-file "$PROJECT_ROOT/docker/common.env" \ -p "$WEB_ADDR:$WEB_PORT:$WEB_PORT" \ -v "$PROJECT_ROOT":/workspace \ diff --git a/scripts/run-serve-image b/scripts/serve-image similarity index 86% rename from scripts/run-serve-image rename to scripts/serve-image index a82b518ee..9bf7c6397 100755 --- a/scripts/run-serve-image +++ b/scripts/serve-image @@ -26,7 +26,7 @@ CONFIG_FILE=${1:-} handle_signal() { echo "SIGINT received, stopping container..." - "$CONTAINER_ENGINE" stop sour-serve + "$CONTAINER_ENGINE" stop sour-game exit 0 } trap handle_signal SIGINT @@ -35,11 +35,12 @@ trap handle_signal SIGTERM "$CONTAINER_ENGINE" run \ --rm \ --init \ - --name sour-serve \ + --name sour-game \ --user "$HOST_UID:$HOST_GID" \ -p "$WEB_ADDR:$WEB_PORT:$WEB_PORT" \ -v "$PROJECT_ROOT/dev.auto.yaml":/workspace/dev.auto.yaml:ro \ - sour-serve:local & + -v "$PROJECT_ROOT/.cache/assets":/workspace/.cache/assets \ + sour-game:latest & PID=$! wait $PID From 6d2467a51f291b7a7eb82488394ad7514943a537 Mon Sep 17 00:00:00 2001 From: Jan Hangebrauck Date: Fri, 5 Sep 2025 10:06:34 +0200 Subject: [PATCH 16/25] fix container restart / init behavior potential fix: mark player as alive on spawn, not on confirmspawn. should resolve rejected position updates. --- docker/serve-entrypoint.sh | 8 ++++-- pkg/gameserver/module.go | 43 ++++++++++++++++++++++++------- pkg/gameserver/packet_handling.go | 11 ++++---- 3 files changed, 45 insertions(+), 17 deletions(-) diff --git a/docker/serve-entrypoint.sh b/docker/serve-entrypoint.sh index 244f6988f..d02aa1cea 100644 --- a/docker/serve-entrypoint.sh +++ b/docker/serve-entrypoint.sh @@ -21,7 +21,7 @@ if [ -n "$CONFIG_FILE" ]; then else /workspace/bin/sour serve & fi -SERVER_PID=$? +SERVER_PID=$! # Ensure the script stops and cleans up on SIGINT and SIGTERM cleanup() { @@ -32,4 +32,8 @@ cleanup() { trap cleanup INT TERM -wait +# Wait specifically for the server process - if it dies, the container should exit +wait $SERVER_PID + +# If we reach here, the server has exited - clean up the proxy +[[ -n "${PROXY_PID:-}" ]] && kill "${PROXY_PID}" >/dev/null 2>&1 || true diff --git a/pkg/gameserver/module.go b/pkg/gameserver/module.go index a402b106e..6c9c2ff7a 100644 --- a/pkg/gameserver/module.go +++ b/pkg/gameserver/module.go @@ -302,22 +302,30 @@ func (s *Server) UniqueName(p *game.Player) string { func (s *Server) Spawn(client *Client) { oldLifeSequence := client.LifeSequence client.Spawn() + + // CRITICAL FIX: Set state to Alive immediately after spawn + // This prevents the race condition where position updates arrive + // before N_SPAWN confirmation on channel 1 + client.State = playerstate.Alive + log.Info(). Uint32("sessionID", client.SessionID). Uint32("CN", client.CN). Int32("oldLifeSequence", oldLifeSequence). Int32("newLifeSequence", client.LifeSequence). - Msg("Client spawned: life sequence incremented") + Msg("Client spawned: life sequence incremented and state set to Alive") s.GameMode.Spawn(&client.PlayerState) } func (s *Server) ConfirmSpawn(client *Client, lifeSequence, _weapon int32) { - if client.State != playerstate.Dead || lifeSequence != client.LifeSequence || client.LastSpawnAttempt.IsZero() { + // Updated validation: client should now be Alive (set in Spawn() function) + // and have matching life sequence + if client.State != playerstate.Alive || lifeSequence != client.LifeSequence || client.LastSpawnAttempt.IsZero() { log.Warn(). Uint32("sessionID", client.SessionID). Uint32("CN", client.CN). Int("state", int(client.State)). - Int("expectedState", int(playerstate.Dead)). + Int("expectedState", int(playerstate.Alive)). Int32("lifeSequence", lifeSequence). Int32("expectedLifeSequence", client.LifeSequence). Bool("lastSpawnAttemptIsZero", client.LastSpawnAttempt.IsZero()). @@ -325,15 +333,30 @@ func (s *Server) ConfirmSpawn(client *Client, lifeSequence, _weapon int32) { return } - // FIRST: Update client state to Alive BEFORE broadcasting - client.State = playerstate.Alive - client.SelectedWeapon = weapon.ByID(weapon.ID(_weapon)) + // Clear spawn attempt - spawn process is complete client.LastSpawnAttempt = time.Time{} - log.Info(). - Uint32("sessionID", client.SessionID). - Uint32("CN", client.CN). - Msg("Spawn confirmed: client set to Alive state") + // Follow original protocol: set weapon to what client confirms + // The client sends their CURRENT weapon choice in N_SPAWN (after any weapon switches) + // This ensures client-server weapon synchronization + requestedWeapon := weapon.ByID(weapon.ID(_weapon)) + if _weapon < int32(weapon.Saw) || _weapon > int32(weapon.Pistol) { + // Invalid weapon, keep current + log.Warn(). + Uint32("sessionID", client.SessionID). + Uint32("CN", client.CN). + Int32("requestedWeapon", _weapon). + Int("currentWeapon", int(client.SelectedWeapon.ID)). + Msg("Invalid weapon in spawn confirmation, keeping current") + } else { + // Update to client's actual weapon choice + client.SelectedWeapon = requestedWeapon + log.Info(). + Uint32("sessionID", client.SessionID). + Uint32("CN", client.CN). + Int("weapon", int(requestedWeapon.ID)). + Msg("Spawn confirmed: weapon synchronized with client") + } // SECOND: Notify ALL clients (including spawning client - they need their own spawn confirmation) client.Packets.Publish(P.SpawnResponse{ diff --git a/pkg/gameserver/packet_handling.go b/pkg/gameserver/packet_handling.go index 3a74e17f4..1e163f904 100644 --- a/pkg/gameserver/packet_handling.go +++ b/pkg/gameserver/packet_handling.go @@ -108,8 +108,8 @@ func (s *Server) HandlePacket(client *Client, channelID uint8, message P.Message client.Positions.Publish(msg) client.Position = mapVec(msg.State.O) } else { - log.Printf("Position update rejected for client %d (CN: %d): client state is %d (expected Alive=%d), life sequence=%d", - client.SessionID, client.CN, client.State, playerstate.Alive, client.LifeSequence) + log.Printf("Position update rejected for client %d (CN: %d): client state is %d (expected Alive=%d), life sequence=%d, lastSpawnAttempt.IsZero=%t", + client.SessionID, client.CN, client.State, playerstate.Alive, client.LifeSequence, client.LastSpawnAttempt.IsZero()) } return @@ -348,12 +348,13 @@ func (s *Server) HandlePacket(client *Client, channelID uint8, message P.Message case P.N_SHOOT: msg := message.(P.Shoot) - log.Printf("Shoot request from client %d (CN: %d): state=%d, weapon=%d", - client.SessionID, client.CN, client.State, msg.Gun) + log.Printf("Shoot request from client %d (CN: %d): state=%d, weapon=%d, ammo=%d", + client.SessionID, client.CN, client.State, msg.Gun, client.Ammo[weapon.ID(msg.Gun)]) wpn := weapon.ByID(weapon.ID(msg.Gun)) if time.Now().Before(client.GunReloadEnd) || client.Ammo[wpn.ID] <= 0 { - log.Printf("Shoot rejected for client %d (CN: %d): reload or no ammo", client.SessionID, client.CN) + log.Printf("Shoot rejected for client %d (CN: %d): reload or no ammo, ammo=%d, reloadEnd=%v", + client.SessionID, client.CN, client.Ammo[wpn.ID], client.GunReloadEnd) return } From 955877e9bdb24811373f00795d303d4dc4fe78a6 Mon Sep 17 00:00:00 2001 From: Jan Hangebrauck Date: Fri, 5 Sep 2025 11:13:28 +0200 Subject: [PATCH 17/25] allow position updates from editing players fix edit mode concurrent write issue --- pkg/gameserver/packet_handling.go | 15 ++++++++------- pkg/server/watcher/module.go | 8 ++++++-- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/pkg/gameserver/packet_handling.go b/pkg/gameserver/packet_handling.go index 1e163f904..819bd0c3c 100644 --- a/pkg/gameserver/packet_handling.go +++ b/pkg/gameserver/packet_handling.go @@ -103,32 +103,33 @@ func (s *Server) HandlePacket(client *Client, channelID uint8, message P.Message msg := message.(P.Pos) // client sending his position and movement in the world - if client.State == playerstate.Alive { + // Allow position updates from both Alive players and Editing players (matches original server) + if client.State == playerstate.Alive || client.State == playerstate.Editing { msg.State.LifeSequence = client.LifeSequence client.Positions.Publish(msg) client.Position = mapVec(msg.State.O) } else { - log.Printf("Position update rejected for client %d (CN: %d): client state is %d (expected Alive=%d), life sequence=%d, lastSpawnAttempt.IsZero=%t", - client.SessionID, client.CN, client.State, playerstate.Alive, client.LifeSequence, client.LastSpawnAttempt.IsZero()) + log.Printf("Position update rejected for client %d (CN: %d): client state is %d (expected Alive=%d or Editing=%d), life sequence=%d, lastSpawnAttempt.IsZero=%t", + client.SessionID, client.CN, client.State, playerstate.Alive, playerstate.Editing, client.LifeSequence, client.LastSpawnAttempt.IsZero()) } return case P.N_JUMPPAD: msg := message.(P.JumpPad) - if client.State == playerstate.Alive { + if client.State == playerstate.Alive || client.State == playerstate.Editing { s.relay.FlushPositionAndSend(client.CN, msg) } else { - log.Printf("Jumppad event rejected for client %d (CN: %d): client state is %d (not Alive)", + log.Printf("Jumppad event rejected for client %d (CN: %d): client state is %d (expected Alive or Editing)", client.SessionID, client.CN, client.State) } case P.N_TELEPORT: msg := message.(P.Teleport) - if client.State == playerstate.Alive { + if client.State == playerstate.Alive || client.State == playerstate.Editing { s.relay.FlushPositionAndSend(client.CN, msg) } else { - log.Printf("Teleport event rejected for client %d (CN: %d): client state is %d (not Alive)", + log.Printf("Teleport event rejected for client %d (CN: %d): client state is %d (expected Alive or Editing)", client.SessionID, client.CN, client.State) } diff --git a/pkg/server/watcher/module.go b/pkg/server/watcher/module.go index fc53669ab..d7bc4f930 100644 --- a/pkg/server/watcher/module.go +++ b/pkg/server/watcher/module.go @@ -223,9 +223,13 @@ func (watcher *Watcher) ReceivePings() { func (watcher *Watcher) Get() Servers { watcher.serverMutex.Lock() - servers := watcher.servers + // Return a copy of the servers map to prevent concurrent map iteration and map write + serversCopy := make(Servers) + for key, server := range watcher.servers { + serversCopy[key] = server + } watcher.serverMutex.Unlock() - return servers + return serversCopy } func (watcher *Watcher) Watch() error { From c5f8c3a3a5ca0c6886dbde56aea1da56cbbab015 Mon Sep 17 00:00:00 2001 From: Jan Hangebrauck Date: Fri, 5 Sep 2025 14:04:15 +0200 Subject: [PATCH 18/25] fix packet slicing mechanism for clients with nil packets (e.g. network issues or zombie CNs) prevent zombie CN accumulation in relay --- pkg/gameserver/module.go | 5 ++- pkg/gameserver/relay/relay.go | 82 +++++++++++++++++++++++++---------- 2 files changed, 63 insertions(+), 24 deletions(-) diff --git a/pkg/gameserver/module.go b/pkg/gameserver/module.go index 6c9c2ff7a..8cf36942b 100644 --- a/pkg/gameserver/module.go +++ b/pkg/gameserver/module.go @@ -390,7 +390,10 @@ func (s *Server) Disconnect(client *Client, reason disconnectreason.ID) { s.Clients.Disconnect(client, reason) err := s.relay.RemoveClient(client.CN) if err != nil { - log.Error().Err(err).Msgf("could not disconnect %d", client.SessionID) + // ZOMBIE CN PREVENTION: Even if RemoveClient fails, our resilient AddClient + // will force-cleanup any zombie CNs when new clients connect to the same CN. + // This prevents accumulation while maintaining system robustness. + log.Warn().Err(err).Uint32("sessionID", client.SessionID).Uint32("CN", client.CN).Msg("Relay cleanup failed, but will be force-cleaned on CN reuse") } if len(s.Clients.PrivilegedUsers()) == 0 { s.Unsupervised() diff --git a/pkg/gameserver/relay/relay.go b/pkg/gameserver/relay/relay.go index fdbc62fb5..1ce2f0a67 100644 --- a/pkg/gameserver/relay/relay.go +++ b/pkg/gameserver/relay/relay.go @@ -1,7 +1,6 @@ package relay import ( - "errors" "sort" "time" @@ -91,8 +90,11 @@ func (r *Relay) AddClient(cn uint32, sf sendFunc) (positions *Publisher, packets defer r.mutex.Unlock() if _, ok := r.send[cn]; ok { - // client is already being serviced - return nil, nil + // ZOMBIE CN FIX: Force cleanup of existing CN before adding new client + // This handles cases where RemoveClient failed due to race conditions, + // preventing persistent "zombie CNs" that would block future connections + // with the same CN and cause permanent player invisibility issues. + r.forceRemoveClient(cn) } r.send[cn] = sf @@ -111,16 +113,25 @@ func (r *Relay) RemoveClient(cn uint32) error { defer r.mutex.Unlock() if _, ok := r.send[cn]; !ok { - return errors.New("no such client") + // ZOMBIE CN FIX: Don't return error for already-removed clients + // This prevents logging errors during normal double-disconnect scenarios + return nil } + r.forceRemoveClient(cn) + return nil +} + +// forceRemoveClient performs cleanup without error checking - used to fix zombie CNs +func (r *Relay) forceRemoveClient(cn uint32) { + // Force cleanup all relay state for this CN + // Note: We don't need to drain channels as they will be garbage collected + // when the publisher is closed properly by the client delete(r.incPositions, cn) delete(r.positions, cn) delete(r.incClientPackets, cn) delete(r.clientPackets, cn) delete(r.send, cn) - - return nil } func (r *Relay) FlushPositionAndSend(cn uint32, p protocol.Message) { @@ -174,24 +185,29 @@ func (r *Relay) flush(packets map[uint32][]protocol.Message, prefix func(uint32, return } - order := make([]uint32, 0, len(r.send)) + // CRITICAL FIX: Only process clients who actually have packets to avoid slice bound errors. + // Previously, we iterated over all connected clients (r.send) but only clients with packets + // were included in the combined array, causing offset misalignment and wrong packet delivery. + + // Only include clients who actually have packets to send + clientsWithPackets := make([]uint32, 0, len(packets)) lengths := map[uint32]int{} combined := make([]protocol.Message, 0, 2*len(packets)*40) - // Create deterministic order instead of random map iteration - for cn := range r.send { - order = append(order, cn) + // Create deterministic order of clients with packets + for cn := range packets { + if packets[cn] != nil { + clientsWithPackets = append(clientsWithPackets, cn) + } } // Sort to ensure consistent ordering across flushes - sort.Slice(order, func(i, j int) bool { - return order[i] < order[j] + sort.Slice(clientsWithPackets, func(i, j int) bool { + return clientsWithPackets[i] < clientsWithPackets[j] }) - for _, cn := range order { - pkt := packets[cn] - if pkt == nil { - continue - } + // Build combined array with only clients who have packets + for _, cn := range clientsWithPackets { + pkt := packets[cn] // We know this is not nil pkt = append(prefix(cn, pkt), pkt...) lengths[cn] = len(pkt) combined = append(combined, pkt...) @@ -203,12 +219,32 @@ func (r *Relay) flush(packets map[uint32][]protocol.Message, prefix func(uint32, combined = append(combined, combined...) - offset := 0 - for _, cn := range order { - l := lengths[cn] - offset += l - p := combined[offset : (len(combined)/2)-l+offset] - r.send[cn](channel, p) + // Send to ALL connected clients, but exclude each client's own packets + allClients := make([]uint32, 0, len(r.send)) + for cn := range r.send { + allClients = append(allClients, cn) + } + sort.Slice(allClients, func(i, j int) bool { + return allClients[i] < allClients[j] + }) + + for _, receiverCN := range allClients { + // Build packet for this receiver by excluding their own packets + var receiverPackets []protocol.Message + offset := 0 + for _, senderCN := range clientsWithPackets { + l := lengths[senderCN] + if senderCN != receiverCN { + // Include this sender's packets for this receiver + senderData := combined[offset : offset+l] + receiverPackets = append(receiverPackets, senderData...) + } + offset += l + } + + if len(receiverPackets) > 0 { + r.send[receiverCN](channel, receiverPackets) + } } // clear packets From 9493257eb040484b32b55c0208c17cb418110113 Mon Sep 17 00:00:00 2001 From: Jan Hangebrauck Date: Sat, 6 Sep 2025 20:52:31 +0200 Subject: [PATCH 19/25] update readme --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ec92b8013..0e3ffcf60 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,9 @@ This repository is part of a broader effort to build a collection of Open Source - can easily be hosted on any path behind a reverse nginx proxy (for https support and structure) - can be run in iframes, since this is required for my use case -My idea is that as a collective, we can build a collection of great games where the community can *focus on modifying the games*, and knowledge about the (sometimes delicate) process of *converting a game to web assembly* can be shared, too. This way, it becomes easier to add more games over time. +My idea is that as a collective, we can build a collection of great games where the community can *focus on modifying the games*, and knowledge about the (sometimes delicate) process of *converting a game to web assembly* can be shared, too. I mainly focus on polished multiplayer games at the moment. + +If you want to get in touch, all details are in the [Common Games Collection Repository](https://github.com/Kaesual/common-games-collection). ## Updates in this fork From affe0fdf5d27ccb293d542fb04080e666de7d2cb Mon Sep 17 00:00:00 2001 From: Jan Hangebrauck Date: Sat, 6 Sep 2025 20:52:31 +0200 Subject: [PATCH 20/25] update readme --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ec92b8013..0e3ffcf60 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,9 @@ This repository is part of a broader effort to build a collection of Open Source - can easily be hosted on any path behind a reverse nginx proxy (for https support and structure) - can be run in iframes, since this is required for my use case -My idea is that as a collective, we can build a collection of great games where the community can *focus on modifying the games*, and knowledge about the (sometimes delicate) process of *converting a game to web assembly* can be shared, too. This way, it becomes easier to add more games over time. +My idea is that as a collective, we can build a collection of great games where the community can *focus on modifying the games*, and knowledge about the (sometimes delicate) process of *converting a game to web assembly* can be shared, too. I mainly focus on polished multiplayer games at the moment. + +If you want to get in touch, all details are in the [Common Games Collection Repository](https://github.com/Kaesual/common-games-collection). ## Updates in this fork From eb55a3475db346be407e15d3465f6aacc98a9c51 Mon Sep 17 00:00:00 2001 From: Jan Hangebrauck Date: Wed, 10 Sep 2025 19:14:50 +0200 Subject: [PATCH 21/25] added server browser for integrated game servers added more game presets and default spaces added nginx docker-compose setup with working proxy auto-apply resolution changes clean up build scripts --- client/src/index.tsx | 99 ++++++++++++++++++++++++++++++++---- client/src/protocol.ts | 2 +- cmd/sour/serve.go | 2 +- dev.auto.yaml | 24 ++++++++- docker/docker-compose.yml | 25 +++++++++ docker/nginx.conf | 61 ++++++++++++++++++++++ game/src/engine/menus.cpp | 8 +-- pkg/server/ingress/ws.go | 36 ++++++++++++- pkg/server/service/module.go | 9 ++++ scripts/build | 17 +------ scripts/build-assets | 26 +--------- scripts/build-builder-image | 17 +------ scripts/build-game | 23 +-------- scripts/build-game-image | 21 ++------ scripts/build-proxy | 20 +------- scripts/build-server | 19 +------ scripts/build-web | 24 +-------- scripts/buildenv-vars | 80 +++++++++++++++++++++++++++++ scripts/serve | 24 ++------- scripts/serve-image | 20 ++------ scripts/serve-nginx | 9 ++++ 21 files changed, 358 insertions(+), 208 deletions(-) create mode 100644 docker/docker-compose.yml create mode 100644 docker/nginx.conf create mode 100755 scripts/buildenv-vars create mode 100755 scripts/serve-nginx diff --git a/client/src/index.tsx b/client/src/index.tsx index fed4f2d28..1fb3f67cf 100644 --- a/client/src/index.tsx +++ b/client/src/index.tsx @@ -389,6 +389,15 @@ function App() { // guibutton "log out.." [js "Module.discord.logout()"] // ] + // Removed menu options for now + // + // ${CONFIG.menuOptions} + // guibutton "join insta-dust2" "join insta-dust2" + // guibutton "join ffa-dust2" "join ffa-dust2" + // guibutton "join insta rotating maps" "join insta" + // guibutton "join ffa rotating maps" "join lobby" + // ${renderDiscordHeader(authState)} + // ${renderDiscordButton(authState)} const menu = ` newgui content [ @@ -412,7 +421,6 @@ function App() { ] ] ] - ${renderDiscordHeader(authState)} guibar if (isconnected) [ if (|| $editing (m_edit (getmode))) [ @@ -424,14 +432,9 @@ function App() { guibutton "master.." [showgui master] guibutton "disconnect" "disconnect" "exit" guibar - ] [ - ${CONFIG.menuOptions} - guibutton "join insta-dust2" "join insta-dust2" - guibutton "join ffa-dust2" "join ffa-dust2" - guibutton "join insta rotating maps" "join insta" - guibutton "join ffa rotating maps" "join lobby" - guibutton "create private game..." "creategame ffa" ] + guibutton "server browser.." "showgui integrated" + guibutton "create private game..." "creategame ffa" guibutton "random map.." "map random" guibutton "content.." "showgui content" if ($fullscreen) [ @@ -439,7 +442,6 @@ function App() { ] [ guibutton "enter fullscreen.." [fullscreen 1] ] - ${renderDiscordButton(authState)} guibutton "options.." "showgui options" guibutton "about.." "showgui about" ] @@ -505,6 +507,14 @@ function App() { } const injectServers = (servers: any) => { + const count = servers?.length ?? 0 + if (!servers || count === 0) { + console.log('[sour] no servers to inject; keeping existing list') + return + } + // Clear previous list entirely to avoid conflicting state + BananaBread.execute('clearservers 1') + console.log('[sour] injecting servers:', count) R.map((server) => { const { Host, Port, Info, Length } = server @@ -513,11 +523,18 @@ function App() { // Copy data to Emscripten heap (directly accessed from Module.HEAPU8) const dataHeap = new Uint8Array(Module.HEAPU8.buffer, pointer, Length) - dataHeap.set(new Uint8Array(Info.buffer, Info.byteOffset, Length)) + const source = + Info instanceof Uint8Array + ? new Uint8Array(Info.buffer, Info.byteOffset, Length) + : new Uint8Array(Info, 0, Length) + dataHeap.set(source) // Call function and get result BananaBread.injectServer(Host, Port, pointer, Length) + // Mark injected entries as kept so engine maintenance doesn't remove them + BananaBread.execute(`keepserver ${Host} ${Port}`) + // Free memory Module._free(pointer) }, servers) @@ -597,6 +614,9 @@ function App() { type: GameStateType.Ready, }) + // Re-enable automatic updates (built-in tab is rendered separately) + BananaBread.execute('autoupdateservers 1') + if (cachedServers != null) { injectServers(cachedServers) } @@ -833,7 +853,7 @@ function App() { if (serverMessage.Op === MessageType.Info) { const { Cluster, Master } = serverMessage - const combined = [...(Master || []), ...(Cluster || [])] + const combined = [...(Master || [])] if ( BananaBread == null || @@ -844,7 +864,64 @@ function App() { return } + // Inject only master into engine list as before injectServers(combined) + + // Refresh our separate GUI without stealing focus; do not call showgui here + + // Rebuild the Servers GUI with a Built in tab based on Cluster + try { + const MODE_NAMES = [ + 'ffa', + 'coop-edit', + 'teamplay', + 'insta', + 'insta team', + 'efficiency', + 'efficiency team', + 'tactics', + 'tactics team', + 'capture', + 'regen capture', + 'ctf', + 'insta ctf', + 'protect', + 'insta protect', + 'hold', + 'insta hold', + 'efficiency ctf', + 'efficiency protect', + 'efficiency hold', + 'collect', + 'insta collect', + 'efficiency collect', + ] as const + const modeName = (id: number): string => MODE_NAMES[id] ?? `mode ${id}` + + const builtins = (Cluster || []) as any[] + const rows = builtins + .map( + (s: any) => + `guibutton "${s.Alias} (^f2${s.NumClients} player${s.NumClients === 1 ? '' : 's'}^f7) - ${modeName(s.Mode)} ${s.Map}" "join ${s.Alias}"` + ) + .join("\n") + + // Create a new, separate GUI so we don't mutate the stock "servers" GUI + const gui = `newgui integrated [ + ${rows} + guitab "servers" + guiservers [ + guilist [ + guicheckbox \"auto-sort\" autosortservers + if (= $autosortservers 0) [ guibar ; guibutton \"sort\" \"sortservers\" ] + ] + guibar + ] 17 + ] "" [initservers]` + BananaBread.execute(gui) + } catch (e) { + console.warn('failed to build built-in servers tab', e) + } return } diff --git a/client/src/protocol.ts b/client/src/protocol.ts index 601d9103e..7d777710c 100644 --- a/client/src/protocol.ts +++ b/client/src/protocol.ts @@ -30,7 +30,7 @@ export type ServerInfo = { export type InfoMessage = { Op: MessageType.Info Master: ServerInfo[] - Cluster: string[] + Cluster: { Alias: string; Map: string; Mode: number; NumClients: number; Description: string }[] } export type PacketMessage = { diff --git a/cmd/sour/serve.go b/cmd/sour/serve.go index 91accaed7..1d9db6d19 100644 --- a/cmd/sour/serve.go +++ b/cmd/sour/serve.go @@ -176,7 +176,7 @@ func serveCommand(configs []string) error { } newConnections := make(chan ingress.Connection) - wsIngress := ingress.NewWSIngress(newConnections) + wsIngress := ingress.NewWSIngress(newConnections, cluster) enet := make([]*ingress.ENetIngress, 0) infoServices := make([]*servers.ServerInfoService, 0) cluster.StartServers(ctx) diff --git a/dev.auto.yaml b/dev.auto.yaml index a94ab6855..60e23cedd 100644 --- a/dev.auto.yaml +++ b/dev.auto.yaml @@ -47,6 +47,20 @@ server: defaultMap: "dust2" maps: - "dust2" + - name: "ctf" + config: + defaultMode: "ctf" + defaultMap: "dust2" + maps: + - "dust2" + - name: "effic" + config: + defaultMode: "effic" + defaultMap: "dust2" + maps: + - "dust2" + - "complex" + - "turbine" - name: "explore" config: matchLength: 180 @@ -54,7 +68,7 @@ server: ingress: desktop: - port: 28785 - target: lobby + target: ffa serverInfo: enabled: false @@ -71,7 +85,7 @@ server: spaces: - preset: ffa config: - alias: lobby + alias: ffa - preset: insta-dust2 config: alias: insta-dust2 @@ -81,6 +95,12 @@ server: - preset: insta config: alias: insta + - preset: ctf + config: + alias: ctf + - preset: effic + config: + alias: effic client: assets: diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 000000000..0301933b9 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,25 @@ +version: "3.9" + +services: + game: + image: sour-game:latest + container_name: sour-game + # expose game HTTP (1337) and ws proxy (1338) to nginx only + init: true + volumes: + - ../dev.auto.yaml:/workspace/dev.auto.yaml:ro + - ../.cache/assets:/workspace/.cache/assets + expose: + - "1337" + - "1338" + + nginx: + image: nginx:alpine + container_name: sour-nginx + depends_on: + - game + ports: + - "8080:8080" + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf:ro + diff --git a/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 000000000..ea1aa52d9 --- /dev/null +++ b/docker/nginx.conf @@ -0,0 +1,61 @@ +events {} + +http { + map $http_upgrade $connection_upgrade { + default upgrade; + '' close; + } + + server { + listen 8080; + + # Forward WebSocket proxy traffic to the ws proxy on 1338 + location /service/proxy/ { + # Strip the /service/proxy/ prefix so wsproxy sees /u/ + rewrite ^/service/proxy/(.*)$ /$1 break; + + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Origin $http_origin; + proxy_set_header Sec-WebSocket-Protocol $http_sec_websocket_protocol; + proxy_set_header Sec-WebSocket-Extensions $http_sec_websocket_extensions; + proxy_set_header Sec-WebSocket-Version $http_sec_websocket_version; + proxy_set_header Sec-WebSocket-Key $http_sec_websocket_key; + proxy_pass_header Sec-WebSocket-Protocol; + proxy_read_timeout 86400; + proxy_send_timeout 86400; + proxy_buffering off; + proxy_redirect off; + proxy_pass http://game:1338; + } + + # Forward WebSocket proxy traffic to the ws proxy on 1338 + location ~ ^/ws/?$ { + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_read_timeout 86400; + proxy_buffering off; + proxy_redirect off; + proxy_pass http://game:1337; + } + + # Forward all other web traffic to the game server on 1337 + location / { + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_pass http://game:1337; + } + } +} diff --git a/game/src/engine/menus.cpp b/game/src/engine/menus.cpp index f2d8e3bc0..ca88d53f0 100644 --- a/game/src/engine/menus.cpp +++ b/game/src/engine/menus.cpp @@ -694,22 +694,22 @@ static struct applymenu : menu void gui(g3d_gui &g, bool firstpass) { if(guistack.empty()) return; - g.start(menustart, 0.03f); + /* g.start(menustart, 0.03f); g.text("the following settings have changed:", GUI_TEXT_COLOR, "info"); loopv(needsapply) g.text(needsapply[i].desc, GUI_TEXT_COLOR, "info"); g.separator(); g.text("apply changes now?", GUI_TEXT_COLOR, "info"); if(g.button("yes", GUI_BUTTON_COLOR, "action")&G3D_UP) - { + { */ int changetypes = 0; loopv(needsapply) changetypes |= needsapply[i].type; if(changetypes&CHANGE_GFX) updatelater.add().schedule("resetgl"); if(changetypes&CHANGE_SOUND) updatelater.add().schedule("resetsound"); clearlater = true; - } + /* } if(g.button("no", GUI_BUTTON_COLOR, "action")&G3D_UP) clearlater = true; - g.end(); + g.end(); */ } void clear() diff --git a/pkg/server/ingress/ws.go b/pkg/server/ingress/ws.go index 2c7b70c3b..ae217b5cb 100644 --- a/pkg/server/ingress/ws.go +++ b/pkg/server/ingress/ws.go @@ -50,7 +50,18 @@ type InfoMessage struct { // All of the servers from the master (real Sauerbraten servers.) Master []ServerInfo // All of the servers this cluster hosts. - Cluster []string + Cluster []struct{ + Alias string + Map string + Mode int + NumClients int + Description string + } +} + +// ClusterLister provides a minimal interface to enumerate built-in servers. +type ClusterLister interface { + ForEachClusterServer(func(alias, mapName string, mode, numClients int, desc string)) } // Contains a packet from the server a client is connected to. @@ -226,13 +237,15 @@ type WSIngress struct { mutex sync.Mutex serverWatcher *watcher.Watcher httpServer *http.Server + serverManager ClusterLister } -func NewWSIngress(newClients chan Connection) *WSIngress { +func NewWSIngress(newClients chan Connection, manager ClusterLister) *WSIngress { return &WSIngress{ newClients: newClients, clients: make(map[*WSClient]struct{}), serverWatcher: watcher.NewWatcher(), + serverManager: manager, } } @@ -496,6 +509,25 @@ func (server *WSIngress) BuildBroadcast() ([]byte, error) { Master: masterServers, } + // Add built-in servers from this cluster in a lightweight summary + if server.serverManager != nil { + server.serverManager.ForEachClusterServer(func(alias, mapName string, mode, numClients int, desc string) { + infoMessage.Cluster = append(infoMessage.Cluster, struct{ + Alias string + Map string + Mode int + NumClients int + Description string + }{ + Alias: alias, + Map: mapName, + Mode: mode, + NumClients: numClients, + Description: desc, + }) + }) + } + bytes, err := cbor.Marshal(infoMessage) if err != nil { return nil, err diff --git a/pkg/server/service/module.go b/pkg/server/service/module.go index 99f7a2bbe..264d3af7a 100644 --- a/pkg/server/service/module.go +++ b/pkg/server/service/module.go @@ -236,3 +236,12 @@ func (server *Cluster) PollUsers(ctx context.Context, newConnections chan ingres func (server *Cluster) Shutdown() { server.servers.Shutdown() } + +// ForEachClusterServer enumerates running built-in servers (alias, map, mode, players, description) +func (server *Cluster) ForEachClusterServer(cb func(alias, mapName string, mode, numClients int, desc string)) { + server.servers.Mutex.Lock() + for _, gs := range server.servers.Servers { + cb(gs.Reference(), gs.Map, int(gs.GameMode.ID()), gs.NumClients(), gs.Description) + } + server.servers.Mutex.Unlock() +} diff --git a/scripts/build b/scripts/build index 0f648237f..4ea79b0ce 100755 --- a/scripts/build +++ b/scripts/build @@ -2,6 +2,7 @@ set -euo pipefail cd "$(dirname "$0")/.." +source "$(dirname "$0")/buildenv-vars" ALLOWED_STEPS=("assets" "game" "proxy" "web" "server" "image") @@ -30,22 +31,8 @@ else echo "Building steps: $* ..." fi -CONTAINER_ENGINE=${CONTAINER_ENGINE:-} -if [ -z "${CONTAINER_ENGINE}" ]; then - if command -v docker >/dev/null 2>&1; then - CONTAINER_ENGINE=docker - elif command -v podman >/dev/null 2>&1; then - CONTAINER_ENGINE=podman - else - echo "Neither docker nor podman found. Please install one or set CONTAINER_ENGINE." - exit 1 - fi -fi - -IMAGE_TAG="sour-emscripten:3.1.8" - # build image if it doesn't exist -if ! "$CONTAINER_ENGINE" image inspect "$IMAGE_TAG" >/dev/null 2>&1; then +if ! $CONTAINER_CMD image inspect "$IMAGE_TAG" >/dev/null 2>&1; then ./scripts/build-builder-image else echo "Image $IMAGE_TAG already exists. Skipping build." diff --git a/scripts/build-assets b/scripts/build-assets index 6fd62ff4c..f7c565a6f 100755 --- a/scripts/build-assets +++ b/scripts/build-assets @@ -2,31 +2,9 @@ set -euo pipefail -CONTAINER_ENGINE=${CONTAINER_ENGINE:-} -if [ -z "${CONTAINER_ENGINE}" ]; then - if command -v docker >/dev/null 2>&1; then - CONTAINER_ENGINE=docker - elif command -v podman >/dev/null 2>&1; then - CONTAINER_ENGINE=podman - else - echo "Neither docker nor podman found. Please install one or set CONTAINER_ENGINE." - exit 1 - fi -fi +source "$(dirname "$0")/buildenv-vars" -IMAGE_TAG="sour-emscripten:3.1.8" -PROJECT_ROOT="$(cd "$(dirname "$0")"/.. && pwd)" - -HOST_UID="$(id -u)" -HOST_GID="$(id -g)" - -# Cache for downloaded assets persists across runs -ASSET_CACHE_DIR=${ASSET_CACHE_DIR:-cache} -ASSET_OUTPUT_DIR=${ASSET_OUTPUT_DIR:-output} -# Space-separated maps to build; use 'none' to skip maps -ASSET_MAPS=${ASSET_MAPS:-"complex dust2 turbine"} - -"$CONTAINER_ENGINE" run \ +$CONTAINER_CMD run \ --rm \ --user "$HOST_UID:$HOST_GID" \ --env-file "$PROJECT_ROOT/docker/common.env" \ diff --git a/scripts/build-builder-image b/scripts/build-builder-image index 668cc85fc..016288a7b 100755 --- a/scripts/build-builder-image +++ b/scripts/build-builder-image @@ -2,23 +2,10 @@ set -euo pipefail -CONTAINER_ENGINE=${CONTAINER_ENGINE:-} -if [ -z "${CONTAINER_ENGINE}" ]; then - if command -v docker >/dev/null 2>&1; then - CONTAINER_ENGINE=docker - elif command -v podman >/dev/null 2>&1; then - CONTAINER_ENGINE=podman - else - echo "Neither docker nor podman found. Please install one or set CONTAINER_ENGINE." - exit 1 - fi -fi - -IMAGE_TAG="sour-emscripten:3.1.8" -PROJECT_ROOT="$(cd "$(dirname "$0")"/.. && pwd)" +source "$(dirname "$0")/buildenv-vars" build_image() { - "$CONTAINER_ENGINE" build -f "$PROJECT_ROOT/docker/emscripten.Dockerfile" -t "$IMAGE_TAG" "$PROJECT_ROOT" + $CONTAINER_CMD build -f "$PROJECT_ROOT/docker/emscripten.Dockerfile" -t "$IMAGE_TAG" "$PROJECT_ROOT" } build_image \ No newline at end of file diff --git a/scripts/build-game b/scripts/build-game index c71bc5d80..f8cbd7d6a 100755 --- a/scripts/build-game +++ b/scripts/build-game @@ -2,31 +2,12 @@ set -euo pipefail -CONTAINER_ENGINE=${CONTAINER_ENGINE:-} -if [ -z "${CONTAINER_ENGINE}" ]; then - if command -v docker >/dev/null 2>&1; then - CONTAINER_ENGINE=docker - elif command -v podman >/dev/null 2>&1; then - CONTAINER_ENGINE=podman - else - echo "Neither docker nor podman found. Please install one or set CONTAINER_ENGINE." - exit 1 - fi -fi - -IMAGE_TAG="sour-emscripten:3.1.8" -PROJECT_ROOT="$(cd "$(dirname "$0")"/.. && pwd)" -EM_CACHE_DIR="$PROJECT_ROOT/.emscripten-cache" - -# Allow overriding output dir -GAME_OUTPUT_DIR=${GAME_OUTPUT_DIR:-dist/game} +source "$(dirname "$0")/buildenv-vars" # Run as the host user to avoid root-owned files; mount a writable EM_CACHE -HOST_UID="$(id -u)" -HOST_GID="$(id -g)" mkdir -p "$EM_CACHE_DIR" -"$CONTAINER_ENGINE" run \ +$CONTAINER_CMD run \ --rm \ --user "$HOST_UID:$HOST_GID" \ --env-file "$PROJECT_ROOT/docker/common.env" \ diff --git a/scripts/build-game-image b/scripts/build-game-image index e65577da3..11f3d5118 100755 --- a/scripts/build-game-image +++ b/scripts/build-game-image @@ -2,25 +2,10 @@ set -euo pipefail -CONTAINER_ENGINE=${CONTAINER_ENGINE:-} -if [ -z "${CONTAINER_ENGINE}" ]; then - if command -v docker >/dev/null 2>&1; then - CONTAINER_ENGINE=docker - elif command -v podman >/dev/null 2>&1; then - CONTAINER_ENGINE=podman - else - echo "Neither docker nor podman found. Please install one or set CONTAINER_ENGINE." - exit 1 - fi -fi - -IMAGE_TAG="sour-emscripten:3.1.8" -PROJECT_ROOT="$(cd "$(dirname "$0")"/.. && pwd)" -HOST_UID="$(id -u)" -HOST_GID="$(id -g)" +source "$(dirname "$0")/buildenv-vars" # Build sour binary -"$CONTAINER_ENGINE" run \ +$CONTAINER_CMD run \ --rm \ --user "$HOST_UID:$HOST_GID" \ --env-file "$PROJECT_ROOT/docker/common.env" \ @@ -30,7 +15,7 @@ HOST_GID="$(id -g)" bash -lc 'go build -o /workspace/bin/sour ./cmd/sour' # Build the serve image -"$CONTAINER_ENGINE" build -f "$PROJECT_ROOT/docker/serve.Dockerfile" -t sour-game:latest "$PROJECT_ROOT" +$CONTAINER_CMD build -f "$PROJECT_ROOT/docker/serve.Dockerfile" -t sour-game:latest "$PROJECT_ROOT" echo "Built image sour-game:latest" diff --git a/scripts/build-proxy b/scripts/build-proxy index d2ff783e9..f7679bb08 100755 --- a/scripts/build-proxy +++ b/scripts/build-proxy @@ -2,25 +2,9 @@ set -euo pipefail -CONTAINER_ENGINE=${CONTAINER_ENGINE:-} -if [ -z "${CONTAINER_ENGINE}" ]; then - if command -v docker >/dev/null 2>&1; then - CONTAINER_ENGINE=docker - elif command -v podman >/dev/null 2>&1; then - CONTAINER_ENGINE=podman - else - echo "Neither docker nor podman found. Please install one or set CONTAINER_ENGINE." - exit 1 - fi -fi +source "$(dirname "$0")/buildenv-vars" -IMAGE_TAG="sour-emscripten:3.1.8" -PROJECT_ROOT="$(cd "$(dirname "$0")"/.. && pwd)" - -HOST_UID="$(id -u)" -HOST_GID="$(id -g)" - -"$CONTAINER_ENGINE" run \ +$CONTAINER_CMD run \ --rm \ --user "$HOST_UID:$HOST_GID" \ --env-file "$PROJECT_ROOT/docker/common.env" \ diff --git a/scripts/build-server b/scripts/build-server index 9e4d4ba71..237fec209 100755 --- a/scripts/build-server +++ b/scripts/build-server @@ -2,25 +2,10 @@ set -euo pipefail -CONTAINER_ENGINE=${CONTAINER_ENGINE:-} -if [ -z "${CONTAINER_ENGINE}" ]; then - if command -v docker >/dev/null 2>&1; then - CONTAINER_ENGINE=docker - elif command -v podman >/dev/null 2>&1; then - CONTAINER_ENGINE=podman - else - echo "Neither docker nor podman found. Please install one or set CONTAINER_ENGINE." - exit 1 - fi -fi - -IMAGE_TAG="sour-emscripten:3.1.8" -PROJECT_ROOT="$(cd "$(dirname "$0")"/.. && pwd)" -HOST_UID="$(id -u)" -HOST_GID="$(id -g)" +source "$(dirname "$0")/buildenv-vars" # Build sour binary -"$CONTAINER_ENGINE" run \ +$CONTAINER_CMD run \ --rm \ --user "$HOST_UID:$HOST_GID" \ --env-file "$PROJECT_ROOT/docker/common.env" \ diff --git a/scripts/build-web b/scripts/build-web index 9f740760a..7b7740e36 100755 --- a/scripts/build-web +++ b/scripts/build-web @@ -2,31 +2,11 @@ set -euo pipefail -CONTAINER_ENGINE=${CONTAINER_ENGINE:-} -if [ -z "${CONTAINER_ENGINE}" ]; then - if command -v docker >/dev/null 2>&1; then - CONTAINER_ENGINE=docker - elif command -v podman >/dev/null 2>&1; then - CONTAINER_ENGINE=podman - else - echo "Neither docker nor podman found. Please install one or set CONTAINER_ENGINE." - exit 1 - fi -fi +source "$(dirname "$0")/buildenv-vars" -IMAGE_TAG="sour-emscripten:3.1.8" -PROJECT_ROOT="$(cd "$(dirname "$0")"/.. && pwd)" -EM_CACHE_DIR="$PROJECT_ROOT/.emscripten-cache" - -# Allow overriding output dir -GAME_OUTPUT_DIR=${GAME_OUTPUT_DIR:-dist/game} - -# Run as the host user to avoid root-owned files; mount a writable EM_CACHE -HOST_UID="$(id -u)" -HOST_GID="$(id -g)" mkdir -p "$EM_CACHE_DIR" -"$CONTAINER_ENGINE" run \ +$CONTAINER_CMD run \ --rm \ --user "$HOST_UID:$HOST_GID" \ --env-file "$PROJECT_ROOT/docker/common.env" \ diff --git a/scripts/buildenv-vars b/scripts/buildenv-vars new file mode 100755 index 000000000..0240c9026 --- /dev/null +++ b/scripts/buildenv-vars @@ -0,0 +1,80 @@ +#!/usr/bin/env bash + +# Script to detect available container and compose commands +# This script sets CONTAINER_CMD and COMPOSE_CMD environment variables +# Usage: source scripts/detect-container-tools.sh + +set -euo pipefail + +IMAGE_TAG="sour-emscripten:3.1.8" +PROJECT_ROOT="$(cd "$(dirname "$0")"/.. && pwd)" +HOST_UID="$(id -u)" +HOST_GID="$(id -g)" + +# Cache for downloaded assets persists across runs +ASSET_CACHE_DIR=${ASSET_CACHE_DIR:-cache} +ASSET_OUTPUT_DIR=${ASSET_OUTPUT_DIR:-output} +# Space-separated maps to build; use 'none' to skip maps +ASSET_MAPS=${ASSET_MAPS:-"complex dust2 turbine"} + +# Cache for emscripten +EM_CACHE_DIR="$PROJECT_ROOT/.emscripten-cache" + +# Allow overriding output dir +GAME_OUTPUT_DIR=${GAME_OUTPUT_DIR:-dist/game} + +# Detect container command (docker or podman) +CONTAINER_CMD=${CONTAINER_CMD:-} +if [ -z "${CONTAINER_CMD}" ]; then + if command -v docker >/dev/null 2>&1; then + CONTAINER_CMD="docker" + elif command -v podman >/dev/null 2>&1; then + CONTAINER_CMD="podman" + else + echo "Neither docker nor podman found. Please install one or set CONTAINER_CMD." >&2 + exit 1 + fi +fi +# Detect compose command based on container engine + +COMPOSE_CMD=${COMPOSE_CMD:-} +if [ -z "${COMPOSE_CMD}" ]; then + if [ "${CONTAINER_CMD}" = "docker" ]; then + if command -v docker-compose >/dev/null 2>&1; then + COMPOSE_CMD="docker-compose" + elif command -v docker compose >/dev/null 2>&1; then + COMPOSE_CMD="docker compose" + else + echo "Neither docker-compose nor docker compose found. Please install one or set COMPOSE_CMD." >&2 + exit 1 + fi + elif [ "${CONTAINER_CMD}" = "podman" ]; then + if command -v podman-compose >/dev/null 2>&1; then + COMPOSE_CMD="podman-compose" + elif command -v podman compose >/dev/null 2>&1; then + COMPOSE_CMD="podman compose" + else + echo "Neither podman-compose nor podman compose found. Please install one or set COMPOSE_CMD." >&2 + exit 1 + fi + fi +fi + +# Export the variables so they're available to calling scripts +export CONTAINER_CMD +export COMPOSE_CMD +export IMAGE_TAG +export PROJECT_ROOT +export HOST_UID +export HOST_GID +export ASSET_CACHE_DIR +export ASSET_OUTPUT_DIR +export ASSET_MAPS +export EM_CACHE_DIR +export GAME_OUTPUT_DIR + +# Optional: Print detected commands for debugging +if [ "${DEBUG:-}" = "1" ]; then + echo "Detected container command: ${CONTAINER_CMD}" >&2 + echo "Detected compose command: ${COMPOSE_CMD}" >&2 +fi \ No newline at end of file diff --git a/scripts/serve b/scripts/serve index ad3cc19d7..9ae24a1bd 100755 --- a/scripts/serve +++ b/scripts/serve @@ -2,23 +2,7 @@ set -euo pipefail -CONTAINER_ENGINE=${CONTAINER_ENGINE:-} -if [ -z "${CONTAINER_ENGINE}" ]; then - if command -v docker >/dev/null 2>&1; then - CONTAINER_ENGINE=docker - elif command -v podman >/dev/null 2>&1; then - CONTAINER_ENGINE=podman - else - echo "Neither docker nor podman found. Please install one or set CONTAINER_ENGINE." - exit 1 - fi -fi - -IMAGE_TAG="sour-emscripten:3.1.8" -PROJECT_ROOT="$(cd "$(dirname "$0")"/.. && pwd)" - -HOST_UID="$(id -u)" -HOST_GID="$(id -g)" +source "$(dirname "$0")/buildenv-vars" # Ports and address WEB_ADDR=${WEB_ADDR:-0.0.0.0} @@ -57,18 +41,18 @@ fi handle_signal() { echo "SIGINT received, stopping container..." - "$CONTAINER_ENGINE" stop sour-game + $CONTAINER_CMD stop sour-game exit 0 } trap handle_signal SIGINT trap handle_signal SIGTERM cleanup() { - "$CONTAINER_ENGINE" rm -f sour-game >/dev/null 2>&1 || true + $CONTAINER_CMD rm -f sour-game >/dev/null 2>&1 || true } trap cleanup INT TERM EXIT -"$CONTAINER_ENGINE" run \ +$CONTAINER_CMD run \ --rm \ --init \ --user "$HOST_UID:$HOST_GID" \ diff --git a/scripts/serve-image b/scripts/serve-image index 9bf7c6397..52f53f61c 100755 --- a/scripts/serve-image +++ b/scripts/serve-image @@ -2,22 +2,8 @@ set -euo pipefail -CONTAINER_ENGINE=${CONTAINER_ENGINE:-} -if [ -z "${CONTAINER_ENGINE}" ]; then - if command -v docker >/dev/null 2>&1; then - CONTAINER_ENGINE=docker - elif command -v podman >/dev/null 2>&1; then - CONTAINER_ENGINE=podman - else - echo "Neither docker nor podman found. Please install one or set CONTAINER_ENGINE." - exit 1 - fi -fi +source "$(dirname "$0")/buildenv-vars" -PROJECT_ROOT="$(cd "$(dirname "$0")"/.. && pwd)" - -HOST_UID="$(id -u)" -HOST_GID="$(id -g)" WEB_ADDR=${WEB_ADDR:-0.0.0.0} WEB_PORT=${WEB_PORT:-1337} @@ -26,13 +12,13 @@ CONFIG_FILE=${1:-} handle_signal() { echo "SIGINT received, stopping container..." - "$CONTAINER_ENGINE" stop sour-game + $CONTAINER_CMD stop sour-game exit 0 } trap handle_signal SIGINT trap handle_signal SIGTERM -"$CONTAINER_ENGINE" run \ +$CONTAINER_CMD run \ --rm \ --init \ --name sour-game \ diff --git a/scripts/serve-nginx b/scripts/serve-nginx new file mode 100755 index 000000000..4dc488846 --- /dev/null +++ b/scripts/serve-nginx @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +set -euo pipefail + +source "$(dirname "$0")/buildenv-vars" + +cd "$PROJECT_ROOT" + +$COMPOSE_CMD -f "$PROJECT_ROOT/docker/docker-compose.yml" up --force-recreate From d276b0d58bed3ec85c91367b7c527047479ff807 Mon Sep 17 00:00:00 2001 From: Jan Hangebrauck Date: Wed, 10 Sep 2025 22:37:11 +0200 Subject: [PATCH 22/25] unadjustedMovement patch to prevent mouse acceleration --- game/build | 1 + game/unadjusted_movement.patch | 29 +++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 game/unadjusted_movement.patch diff --git a/game/build b/game/build index 59318379a..44327595c 100755 --- a/game/build +++ b/game/build @@ -22,4 +22,5 @@ mkdir -p "$OUTPUT_DIR" patch sauerbraten.js file_create.patch patch sauerbraten.js arbitrary_ws.patch patch sauerbraten.js resolve_wasm.patch +patch sauerbraten.js unadjusted_movement.patch cp -r js/api.js sauerbraten.* "$OUTPUT_DIR" diff --git a/game/unadjusted_movement.patch b/game/unadjusted_movement.patch new file mode 100644 index 000000000..cd8dd87fb --- /dev/null +++ b/game/unadjusted_movement.patch @@ -0,0 +1,29 @@ +--- sauerbraten.js.orig 2025-09-10 22:28:09.208186363 +0200 ++++ sauerbraten.js 2025-09-10 22:29:26.992134029 +0200 +@@ -5280,7 +5280,7 @@ + if (Module["elementPointerLock"]) { + canvas.addEventListener("click", function(ev) { + if (!Browser.pointerLock && Module["canvas"].requestPointerLock) { +- Module["canvas"].requestPointerLock(); ++ Module["canvas"].requestPointerLock({ unadjustedMovement: true }); + ev.preventDefault(); + } + }, false); +@@ -5351,7 +5351,7 @@ + var canvasContainer = canvas.parentNode; + if ((document["fullscreenElement"] || document["mozFullScreenElement"] || document["msFullscreenElement"] || document["webkitFullscreenElement"] || document["webkitCurrentFullScreenElement"]) === canvasContainer) { + canvas.exitFullscreen = Browser.exitFullscreen; +- if (Browser.lockPointer) canvas.requestPointerLock(); ++ if (Browser.lockPointer) canvas.requestPointerLock({ unadjustedMovement: true }); + Browser.isFullscreen = true; + if (Browser.resizeCanvas) { + Browser.setFullscreenCanvasSize(); +@@ -6770,7 +6770,7 @@ + + function requestPointerLock(target) { + if (target.requestPointerLock) { +- target.requestPointerLock(); ++ target.requestPointerLock({ unadjustedMovement: true }); + } else if (target.msRequestPointerLock) { + target.msRequestPointerLock(); + } else { From 07dd34b3e38cc3968d2e15aa7cb487871733e282 Mon Sep 17 00:00:00 2001 From: Jan Hangebrauck Date: Wed, 10 Sep 2025 22:50:32 +0200 Subject: [PATCH 23/25] fix GetClientByCN --- pkg/gameserver/clients.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/pkg/gameserver/clients.go b/pkg/gameserver/clients.go index 3d21816ea..ff22fa5cb 100644 --- a/pkg/gameserver/clients.go +++ b/pkg/gameserver/clients.go @@ -50,11 +50,12 @@ func (cm *ClientManager) GetClientByCN(cn uint32) *Client { cm.mutex.RLock() defer cm.mutex.RUnlock() - if int(cn) < 0 || int(cn) >= len(cm.clients) { - return nil + for _, client := range cm.clients { + if client.CN == cn { + return client + } } - - return cm.clients[cn] + return nil } func (cm *ClientManager) GetClientByID(sessionId uint32) *Client { From 904a96bf2675656d087cc34482e4d4e5f32be631 Mon Sep 17 00:00:00 2001 From: Jan Hangebrauck Date: Thu, 11 Sep 2025 01:42:33 +0200 Subject: [PATCH 24/25] use unadjustedMovement if available add game mode id to name list fix builtin server ui load delay --- client/src/index.tsx | 108 ++++++++++++++++++++------------- docker/nginx.conf | 7 ++- game/unadjusted_movement.patch | 39 +++++++++--- 3 files changed, 100 insertions(+), 54 deletions(-) diff --git a/client/src/index.tsx b/client/src/index.tsx index 1fb3f67cf..ff5c64bcf 100644 --- a/client/src/index.tsx +++ b/client/src/index.tsx @@ -118,6 +118,33 @@ const pushURLState = (url: string) => { const clearURLState = () => pushURLState('/') +const MODE_NAMES = [ + 'ffa', + 'coop-edit', + 'teamplay', + 'insta', + 'insta team', + 'efficiency', + 'efficiency team', + 'tactics', + 'tactics team', + 'capture', + 'regen capture', + 'ctf', + 'insta ctf', + 'protect', + 'insta protect', + 'hold', + 'insta hold', + 'efficiency ctf', + 'efficiency protect', + 'efficiency hold', + 'collect', + 'insta collect', + 'efficiency collect', +] as const +const modeName = (id: number): string => MODE_NAMES[id] ?? `mode ${id}` + export type CommandRequest = { id: number promiseSet: PromiseSet @@ -208,6 +235,24 @@ function App() { }, }) + const internalServersRef = React.useRef(`newgui integrated [ + guitab "servers" + guiservers [ + guilist [ + guicheckbox \"auto-sort\" autosortservers + if (= $autosortservers 0) [ guibar ; guibutton \"sort\" \"sortservers\" ] + ] + guibar + ] 17 + ] "" [initservers] + `) + const [internalServers, _setInternalServers] = React.useState(internalServersRef.current) + const setInternalServers = React.useCallback((gui: string) => { + if (internalServersRef.current === gui) return + internalServersRef.current = gui + _setInternalServers(gui) + }, []) + React.useEffect(() => { Module.gameState = { playerState: ( @@ -406,6 +451,8 @@ function App() { guibutton "reload page.." [js "window.location.reload()"] ] + ${internalServers} + injectedmenu = [ guilist [ guiimage (concatword "packages/icons/" (playermodelicon) ".jpg") [chooseplayermodel] 1.15 @@ -582,10 +629,13 @@ function App() { Module.FS_createPath(`/`, 'demo', true, true) if (BROWSER.isFirefox || BROWSER.isSafari) { + // Disable effects for Firefox/Safari due to WebGL performance issues BananaBread.execute('skipparticles 1') BananaBread.execute('glare 0') } else { + // Full quality for other browsers BananaBread.execute('skipparticles 0') + BananaBread.execute('glare 1') } if (!BROWSER.isMobile) { @@ -855,49 +905,9 @@ function App() { const { Cluster, Master } = serverMessage const combined = [...(Master || [])] - if ( - BananaBread == null || - BananaBread.execute == null || - BananaBread.injectServer == null - ) { - cachedServers = combined - return - } - - // Inject only master into engine list as before - injectServers(combined) - - // Refresh our separate GUI without stealing focus; do not call showgui here - // Rebuild the Servers GUI with a Built in tab based on Cluster + let gui: Maybe = null try { - const MODE_NAMES = [ - 'ffa', - 'coop-edit', - 'teamplay', - 'insta', - 'insta team', - 'efficiency', - 'efficiency team', - 'tactics', - 'tactics team', - 'capture', - 'regen capture', - 'ctf', - 'insta ctf', - 'protect', - 'insta protect', - 'hold', - 'insta hold', - 'efficiency ctf', - 'efficiency protect', - 'efficiency hold', - 'collect', - 'insta collect', - 'efficiency collect', - ] as const - const modeName = (id: number): string => MODE_NAMES[id] ?? `mode ${id}` - const builtins = (Cluster || []) as any[] const rows = builtins .map( @@ -907,7 +917,7 @@ function App() { .join("\n") // Create a new, separate GUI so we don't mutate the stock "servers" GUI - const gui = `newgui integrated [ + gui = `newgui integrated [ ${rows} guitab "servers" guiservers [ @@ -918,10 +928,22 @@ function App() { guibar ] 17 ] "" [initservers]` - BananaBread.execute(gui) + setInternalServers(gui) } catch (e) { console.warn('failed to build built-in servers tab', e) } + + if ( + BananaBread == null || + BananaBread.execute == null || + BananaBread.injectServer == null + ) { + cachedServers = combined + return + } + + // Inject only master into engine list as before + injectServers(combined) return } diff --git a/docker/nginx.conf b/docker/nginx.conf index ea1aa52d9..bd82dcaaa 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -27,8 +27,8 @@ http { proxy_set_header Sec-WebSocket-Version $http_sec_websocket_version; proxy_set_header Sec-WebSocket-Key $http_sec_websocket_key; proxy_pass_header Sec-WebSocket-Protocol; - proxy_read_timeout 86400; - proxy_send_timeout 86400; + proxy_read_timeout 60s; + proxy_send_timeout 60s; proxy_buffering off; proxy_redirect off; proxy_pass http://game:1338; @@ -43,7 +43,8 @@ http { proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_read_timeout 86400; + proxy_read_timeout 60s; + proxy_send_timeout 60s; proxy_buffering off; proxy_redirect off; proxy_pass http://game:1337; diff --git a/game/unadjusted_movement.patch b/game/unadjusted_movement.patch index cd8dd87fb..82f74abb3 100644 --- a/game/unadjusted_movement.patch +++ b/game/unadjusted_movement.patch @@ -1,29 +1,52 @@ ---- sauerbraten.js.orig 2025-09-10 22:28:09.208186363 +0200 -+++ sauerbraten.js 2025-09-10 22:29:26.992134029 +0200 -@@ -5280,7 +5280,7 @@ +--- sauerbraten.js.orig 2025-09-10 22:51:23.261415063 +0200 ++++ sauerbraten.js 2025-09-10 23:38:13.752435014 +0200 +@@ -5279,7 +5279,14 @@ if (Module["elementPointerLock"]) { canvas.addEventListener("click", function(ev) { if (!Browser.pointerLock && Module["canvas"].requestPointerLock) { - Module["canvas"].requestPointerLock(); -+ Module["canvas"].requestPointerLock({ unadjustedMovement: true }); ++ try { ++ var promise = Module["canvas"].requestPointerLock({ unadjustedMovement: true }); ++ if (promise) promise.catch(function() { ++ Module["canvas"].requestPointerLock(); ++ }); ++ } catch (e) { ++ Module["canvas"].requestPointerLock(); ++ } ev.preventDefault(); } }, false); -@@ -5351,7 +5351,7 @@ +@@ -5350,7 +5357,16 @@ var canvasContainer = canvas.parentNode; if ((document["fullscreenElement"] || document["mozFullScreenElement"] || document["msFullscreenElement"] || document["webkitFullscreenElement"] || document["webkitCurrentFullScreenElement"]) === canvasContainer) { canvas.exitFullscreen = Browser.exitFullscreen; - if (Browser.lockPointer) canvas.requestPointerLock(); -+ if (Browser.lockPointer) canvas.requestPointerLock({ unadjustedMovement: true }); ++ if (Browser.lockPointer) { ++ try { ++ var promise = canvas.requestPointerLock({ unadjustedMovement: true }); ++ if (promise) promise.catch(function() { ++ canvas.requestPointerLock(); ++ }); ++ } catch (e) { ++ canvas.requestPointerLock(); ++ } ++ } Browser.isFullscreen = true; if (Browser.resizeCanvas) { Browser.setFullscreenCanvasSize(); -@@ -6770,7 +6770,7 @@ +@@ -6769,7 +6785,14 @@ function requestPointerLock(target) { if (target.requestPointerLock) { - target.requestPointerLock(); -+ target.requestPointerLock({ unadjustedMovement: true }); ++ try { ++ var promise = target.requestPointerLock({ unadjustedMovement: true }); ++ if (promise) promise.catch(function() { ++ target.requestPointerLock(); ++ }); ++ } catch (e) { ++ target.requestPointerLock(); ++ } } else if (target.msRequestPointerLock) { target.msRequestPointerLock(); } else { From 53ac829a1dc2ad960e084dd110a66739206b89dc Mon Sep 17 00:00:00 2001 From: Jan Hangebrauck Date: Thu, 11 Sep 2025 11:41:38 +0200 Subject: [PATCH 25/25] proxy ip filter added --- docker/nginx.conf | 59 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 57 insertions(+), 2 deletions(-) diff --git a/docker/nginx.conf b/docker/nginx.conf index bd82dcaaa..c9649fb56 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -6,12 +6,67 @@ http { '' close; } + # IP blocking map for security - blocks dangerous/private IP ranges + map $ip $blocked_ip { + default 0; + # Loopback addresses + "~^127\." 1; + # Private networks (RFC 1918) + "~^10\." 1; + "~^192\.168\." 1; + "~^172\.(1[6-9]|2[0-9]|3[0-1])\." 1; + # Link-local addresses (RFC 3927) + "~^169\.254\." 1; + # Multicast addresses + "~^224\." 1; + "~^225\." 1; + "~^226\." 1; + "~^227\." 1; + "~^228\." 1; + "~^229\." 1; + "~^230\." 1; + "~^231\." 1; + "~^232\." 1; + "~^233\." 1; + "~^234\." 1; + "~^235\." 1; + "~^236\." 1; + "~^237\." 1; + "~^238\." 1; + "~^239\." 1; + # Broadcast and reserved ranges + "~^255\." 1; + "~^0\." 1; + # Reserved for future use (Class E) + "~^24[0-9]\." 1; + "~^25[0-5]\." 1; + } + server { listen 8080; # Forward WebSocket proxy traffic to the ws proxy on 1338 - location /service/proxy/ { - # Strip the /service/proxy/ prefix so wsproxy sees /u/ + # Captures IP:port from URL path + location ~ ^/service/proxy/u/(.+)$ { + set $ip_port $1; + + # Extract just the IP part (before the colon) for security validation + if ($ip_port ~ "^([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}):([0-9]+)$") { + set $ip $1; + set $port $2; + } + + # If format doesn't match IP:port, return 400 + if ($ip = "") { + return 400; + } + + # Check if IP is in blocked ranges using the map directive + if ($blocked_ip) { + return 403; + } + + # IP is safe to proxy - strip the /service/proxy/ prefix so wsproxy sees /u/ rewrite ^/service/proxy/(.*)$ /$1 break; proxy_http_version 1.1;