diff --git a/.gitignore b/.gitignore index c94076d36..95c2cf93a 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,9 @@ earthly/** /sour /sourdump /sour-build +.home/ +.pip-cache/ +.go-cache/ +.emscripten-cache/ +.gopath/ +bin/sour \ No newline at end of file diff --git a/README.md b/README.md index 3f14e8c3e..0e3ffcf60 100644 --- a/README.md +++ b/README.md @@ -1,55 +1,114 @@

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

-

- Discord - sour releases - sour License Badge MIT -

+## 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. + +There's multiple deployments of this game available: + +- 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/) + +## The Common Games Collection + +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 -## What is this? +- 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 -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. +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. -Give it a try. +If you want to get in touch, all details are in the [Common Games Collection Repository](https://github.com/Kaesual/common-games-collection). -## Installation +## Updates in this fork -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. +- 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/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 +- 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 -You can also install Sour via `brew`: +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 + +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 -# Install the latest version: -brew install cfoust/taps/sour +# Ubuntu/Debian +sudo apt install git-lfs + +# macOS (Homebrew) +brew install git-lfs -# Or a specific one: -brew install cfoust/taps/sour@0.2.2 +# One‑time init, then pull LFS content +git lfs install +git lfs pull ``` -In addition to all of the base game assets, these archives only contain three maps: `complex`, `dust2`, and `turbine`. +### Building the web game with Docker (Emscripten) -## Running Sour +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. -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. +```bash +# Build everything and put it into a nice new docker image, ready to host +./scripts/build +``` -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`. +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-builder-image # builds the builder docker image +scripts/build-assets +scripts/build-game +scripts/build-proxy +scripts/build-web +scripts/build-server +scripts/build-game-image +``` -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. +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. -## Configuration +### Running the server in Docker -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`: +After building, you can run the integrated server locally with: ```bash -sour serve config.yaml +# Default: serves on 0.0.0.0:1337 +./scripts/serve-image + +# Override bind address/port +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 `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. +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 @@ -70,6 +129,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 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 Here is a high level description of the repository's contents: @@ -80,16 +141,18 @@ 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. - -## Contributing +- `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. -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. +Above is the original architecture section by cfoust. This repository additionally has: -## Inspiration +- `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 +- 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 +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/.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/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/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/assets/sour/data/menus.cfg b/assets/sour/data/menus.cfg index 3cbeda8d0..4ca86f805 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/commongames" 0 ] genmapitems = [ 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 16bf363ff..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: ( @@ -302,9 +347,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}`, @@ -375,19 +420,39 @@ 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()"] + // ] + + // 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 [ guibutton "mods.." "showgui mods" guibutton "put mods in url.." [js "Module.assets.modsToURL()"] guibutton "reload page.." [js "window.location.reload()"] ] + ${internalServers} + injectedmenu = [ guilist [ guiimage (concatword "packages/icons/" (playermodelicon) ".jpg") [chooseplayermodel] 1.15 @@ -403,7 +468,6 @@ function App() { ] ] ] - ${renderDiscordHeader(authState)} guibar if (isconnected) [ if (|| $editing (m_edit (getmode))) [ @@ -415,10 +479,9 @@ function App() { guibutton "master.." [showgui master] guibutton "disconnect" "disconnect" "exit" guibar - ] [ - ${CONFIG.menuOptions} - 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) [ @@ -426,7 +489,6 @@ function App() { ] [ guibutton "enter fullscreen.." [fullscreen 1] ] - ${renderDiscordButton(authState)} guibutton "options.." "showgui options" guibutton "about.." "showgui about" ] @@ -440,10 +502,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 = () => { @@ -488,6 +554,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 @@ -496,11 +570,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) @@ -548,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) { @@ -580,6 +664,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) } @@ -816,17 +903,47 @@ function App() { if (serverMessage.Op === MessageType.Info) { const { Cluster, Master } = serverMessage + const combined = [...(Master || [])] + + // Rebuild the Servers GUI with a Built in tab based on Cluster + let gui: Maybe = null + try { + 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 + gui = `newgui integrated [ + ${rows} + guitab "servers" + guiservers [ + guilist [ + guicheckbox \"auto-sort\" autosortservers + if (= $autosortservers 0) [ guibar ; guibutton \"sort\" \"sortservers\" ] + ] + guibar + ] 17 + ] "" [initservers]` + setInternalServers(gui) + } catch (e) { + console.warn('failed to build built-in servers tab', e) + } if ( BananaBread == null || BananaBread.execute == null || BananaBread.injectServer == null ) { - cachedServers = Master + cachedServers = combined return } - injectServers(Master) + // Inject only master into engine list as before + injectServers(combined) return } @@ -940,6 +1057,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 +1066,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/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/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/cmd/sour/serve.go b/cmd/sour/serve.go index 2fe267113..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) @@ -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 new file mode 100644 index 000000000..60e23cedd --- /dev/null +++ b/dev.auto.yaml @@ -0,0 +1,107 @@ +# 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: + - "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: + - "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 + + ingress: + desktop: + - port: 28785 + target: ffa + serverInfo: + enabled: false + + matchmaking: + duel: + - name: "ffa" + preset: "ffa-duel" + forceRespawn: "dead" + default: true + - name: "insta" + preset: "insta-duel" + forceRespawn: "dead" + + spaces: + - preset: ffa + config: + alias: ffa + - preset: insta-dust2 + config: + alias: insta-dust2 + - preset: ffa-dust2 + config: + alias: ffa-dust2 + - preset: insta + config: + alias: insta + - preset: ctf + config: + alias: ctf + - preset: effic + config: + alias: effic + +client: + assets: + - "#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..91947de11 --- /dev/null +++ b/docker/common.env @@ -0,0 +1,5 @@ +HOME=/workspace/.home +GOPATH=/workspace/.gopath +GOCACHE=/workspace/.go-cache +PIP_CACHE_DIR=/workspace/.pip-cache +EM_CACHE=/workspace/.emscripten-cache \ No newline at end of file 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/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/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 000000000..c9649fb56 --- /dev/null +++ b/docker/nginx.conf @@ -0,0 +1,117 @@ +events {} + +http { + map $http_upgrade $connection_upgrade { + default upgrade; + '' 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 + # 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; + 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 60s; + proxy_send_timeout 60s; + 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 60s; + proxy_send_timeout 60s; + 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/docker/serve-entrypoint.sh b/docker/serve-entrypoint.sh new file mode 100644 index 000000000..d02aa1cea --- /dev/null +++ b/docker/serve-entrypoint.sh @@ -0,0 +1,39 @@ +#!/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 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/docker/serve.Dockerfile b/docker/serve.Dockerfile new file mode 100644 index 000000000..63ba39771 --- /dev/null +++ b/docker/serve.Dockerfile @@ -0,0 +1,28 @@ +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 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 + +# 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/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/src/engine/main.cpp b/game/src/engine/main.cpp index d44f9d91e..efb3af60f 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__ + // 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()); #ifdef SDL_VIDEO_DRIVER_X11 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/game/unadjusted_movement.patch b/game/unadjusted_movement.patch new file mode 100644 index 000000000..82f74abb3 --- /dev/null +++ b/game/unadjusted_movement.patch @@ -0,0 +1,52 @@ +--- 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(); ++ try { ++ var promise = Module["canvas"].requestPointerLock({ unadjustedMovement: true }); ++ if (promise) promise.catch(function() { ++ Module["canvas"].requestPointerLock(); ++ }); ++ } catch (e) { ++ Module["canvas"].requestPointerLock(); ++ } + ev.preventDefault(); + } + }, false); +@@ -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) { ++ 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(); +@@ -6769,7 +6785,14 @@ + + function requestPointerLock(target) { + if (target.requestPointerLock) { +- target.requestPointerLock(); ++ 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 { 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 diff --git a/pkg/gameserver/clients.go b/pkg/gameserver/clients.go index ea4673999..ff22fa5cb 100644 --- a/pkg/gameserver/clients.go +++ b/pkg/gameserver/clients.go @@ -2,6 +2,7 @@ package gameserver import ( "fmt" + "log" "strconv" "strings" "time" @@ -49,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 { @@ -218,6 +220,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..8cf36942b 100644 --- a/pkg/gameserver/module.go +++ b/pkg/gameserver/module.go @@ -300,24 +300,76 @@ 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 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() { - // client may not spawn + // 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.Alive)). + Int32("lifeSequence", lifeSequence). + Int32("expectedLifeSequence", client.LifeSequence). + Bool("lastSpawnAttemptIsZero", client.LastSpawnAttempt.IsZero()). + Msg("Spawn confirmation rejected") return } - client.State = playerstate.Alive - client.SelectedWeapon = weapon.ByID(weapon.ID(_weapon)) + // Clear spawn attempt - spawn process is complete client.LastSpawnAttempt = time.Time{} + + // 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{ - 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) } @@ -338,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() @@ -526,11 +581,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 +631,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 +695,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..819bd0c3c 100644 --- a/pkg/gameserver/packet_handling.go +++ b/pkg/gameserver/packet_handling.go @@ -103,24 +103,34 @@ 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 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 (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 (expected Alive or Editing)", + client.SessionID, client.CN, client.State) } case P.N_ADDBOT, P.N_DELBOT: @@ -189,6 +199,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 +207,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 +322,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 +349,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, 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, ammo=%d, reloadEnd=%v", + client.SessionID, client.CN, client.Ammo[wpn.ID], client.GunReloadEnd) return } diff --git a/pkg/gameserver/relay/relay.go b/pkg/gameserver/relay/relay.go index 17adc5f11..1ce2f0a67 100644 --- a/pkg/gameserver/relay/relay.go +++ b/pkg/gameserver/relay/relay.go @@ -1,7 +1,7 @@ package relay import ( - "errors" + "sort" "time" "github.com/cfoust/sour/pkg/game/protocol" @@ -90,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 @@ -110,37 +113,51 @@ 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) { 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}) } } @@ -168,16 +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) - for cn := range r.send { - order = append(order, cn) - pkt := packets[cn] - if pkt == nil { - continue + // 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(clientsWithPackets, func(i, j int) bool { + return clientsWithPackets[i] < clientsWithPackets[j] + }) + + // 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...) @@ -189,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 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/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 { diff --git a/scripts/build b/scripts/build new file mode 100755 index 000000000..4ea79b0ce --- /dev/null +++ b/scripts/build @@ -0,0 +1,65 @@ +#!/bin/bash + +set -euo pipefail +cd "$(dirname "$0")/.." +source "$(dirname "$0")/buildenv-vars" + +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 + +# build image if it doesn't exist +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." +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-assets b/scripts/build-assets new file mode 100755 index 000000000..f7c565a6f --- /dev/null +++ b/scripts/build-assets @@ -0,0 +1,38 @@ +#!/usr/bin/env bash + +set -euo pipefail + +source "$(dirname "$0")/buildenv-vars" + +$CONTAINER_CMD run \ + --rm \ + --user "$HOST_UID:$HOST_GID" \ + --env-file "$PROJECT_ROOT/docker/common.env" \ + -e ASSET_OUTPUT_DIR="$ASSET_OUTPUT_DIR" \ + -e ASSET_CACHE_DIR="$ASSET_CACHE_DIR" \ + -e ASSET_MAPS="$ASSET_MAPS" \ + -v "$PROJECT_ROOT":/workspace \ + -w /workspace/assets \ + "$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 + # 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 + python3 base.py \ + --root https://static.sourga.me/blobs/6481/.index.source \ + --models \ + --download \ + --outdir dist \ + "$@" + ' + +echo "Successfully built assets" diff --git a/scripts/build-builder-image b/scripts/build-builder-image new file mode 100755 index 000000000..016288a7b --- /dev/null +++ b/scripts/build-builder-image @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +set -euo pipefail + +source "$(dirname "$0")/buildenv-vars" + +build_image() { + $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 new file mode 100755 index 000000000..f8cbd7d6a --- /dev/null +++ b/scripts/build-game @@ -0,0 +1,28 @@ +#!/usr/bin/env bash + +set -euo pipefail + +source "$(dirname "$0")/buildenv-vars" + +# Run as the host user to avoid root-owned files; mount a writable EM_CACHE +mkdir -p "$EM_CACHE_DIR" + +$CONTAINER_CMD 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/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-game-image b/scripts/build-game-image new file mode 100755 index 000000000..11f3d5118 --- /dev/null +++ b/scripts/build-game-image @@ -0,0 +1,22 @@ +#!/usr/bin/env bash + +set -euo pipefail + +source "$(dirname "$0")/buildenv-vars" + +# Build sour binary +$CONTAINER_CMD 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 +$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 new file mode 100755 index 000000000..f7679bb08 --- /dev/null +++ b/scripts/build-proxy @@ -0,0 +1,22 @@ +#!/usr/bin/env bash + +set -euo pipefail + +source "$(dirname "$0")/buildenv-vars" + +$CONTAINER_CMD 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" + ' + +echo "Successfully built proxy" + diff --git a/scripts/build-server b/scripts/build-server new file mode 100755 index 000000000..237fec209 --- /dev/null +++ b/scripts/build-server @@ -0,0 +1,17 @@ +#!/usr/bin/env bash + +set -euo pipefail + +source "$(dirname "$0")/buildenv-vars" + +# Build sour binary +$CONTAINER_CMD 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 new file mode 100755 index 000000000..7b7740e36 --- /dev/null +++ b/scripts/build-web @@ -0,0 +1,36 @@ +#!/usr/bin/env bash + +set -euo pipefail + +source "$(dirname "$0")/buildenv-vars" + +mkdir -p "$EM_CACHE_DIR" + +$CONTAINER_CMD 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 + rm -rf .parcel-cache + yarn install + yarn build + 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 -a dist/. /workspace/pkg/server/static/site/ + ' + +echo "Successfully built web client" 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 new file mode 100755 index 000000000..9ae24a1bd --- /dev/null +++ b/scripts/serve @@ -0,0 +1,68 @@ +#!/usr/bin/env bash + +set -euo pipefail + +source "$(dirname "$0")/buildenv-vars" + +# 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 + +# 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 + if [ -f "$CONFIG_FILE" ]; then + REL="${CONFIG_FILE#$PROJECT_ROOT/}" + CMD="go run ./cmd/sour serve \"$REL\"" + fi +else + # If a dev.auto.yaml exists, use it; otherwise run with defaults + if [ -f "$PROJECT_ROOT/dev.auto.yaml" ]; then + REL="${PROJECT_ROOT#$PROJECT_ROOT/}dev.auto.yaml" + CMD="go run ./cmd/sour serve \"dev.auto.yaml\"" + fi +fi + +handle_signal() { + echo "SIGINT received, stopping container..." + $CONTAINER_CMD stop sour-game + exit 0 +} +trap handle_signal SIGINT +trap handle_signal SIGTERM + +cleanup() { + $CONTAINER_CMD rm -f sour-game >/dev/null 2>&1 || true +} +trap cleanup INT TERM EXIT + +$CONTAINER_CMD run \ + --rm \ + --init \ + --user "$HOST_UID:$HOST_GID" \ + --name sour-game \ + --env-file "$PROJECT_ROOT/docker/common.env" \ + -p "$WEB_ADDR:$WEB_PORT:$WEB_PORT" \ + -v "$PROJECT_ROOT":/workspace \ + -w /workspace \ + "$IMAGE_TAG" \ + bash -lc "$CMD" & + +PID=$! +wait $PID diff --git a/scripts/serve-image b/scripts/serve-image new file mode 100755 index 000000000..52f53f61c --- /dev/null +++ b/scripts/serve-image @@ -0,0 +1,32 @@ +#!/usr/bin/env bash + +set -euo pipefail + +source "$(dirname "$0")/buildenv-vars" + +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..." + $CONTAINER_CMD stop sour-game + exit 0 +} +trap handle_signal SIGINT +trap handle_signal SIGTERM + +$CONTAINER_CMD run \ + --rm \ + --init \ + --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 \ + -v "$PROJECT_ROOT/.cache/assets":/workspace/.cache/assets \ + sour-game:latest & + +PID=$! +wait $PID 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