diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..82280be9f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +.git +build +build-* +tools/browser-emsdk +browser/node_modules +.codex +**/__pycache__ +*.o +*.a +.scons* +config.h + +.env +**/.env diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0f02f7838..f3b9d5c79 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,10 +5,22 @@ on: branches: [master] pull_request: workflow_dispatch: + inputs: + browser_only: + description: Run only browser, transport, gateway, and deployment checks + required: false + type: boolean + default: false + +# Keep a newer PR/manual run from competing with obsolete builds of its branch. +concurrency: + group: build-${{ github.head_ref || github.ref_name }} + cancel-in-progress: ${{ github.ref != 'refs/heads/master' }} jobs: linux: name: linux (${{ matrix.image }}) + if: ${{ github.event_name != 'workflow_dispatch' || !inputs.browser_only }} runs-on: ${{ matrix.runner }} env: CCACHE: 1 @@ -54,7 +66,7 @@ jobs: ca-certificates git ${{ matrix.compiler }} python3 scons pkg-config ccache \ libsdl2-dev libsdl2-image-dev libsdl2-net-dev libsdl2-ttf-dev \ libvorbis-dev libogg-dev libspeex-dev \ - libboost-date-time-dev libboost-thread-dev libboost-system-dev \ + libboost-date-time-dev libboost-thread-dev libboost-system-dev libssl-dev \ zlib1g-dev libfribidi-dev libpcre3-dev \ libgl1-mesa-dev libglu1-mesa-dev libepoxy-dev libgl1-mesa-dri xvfb xauth \ libcppunit-dev @@ -82,6 +94,9 @@ jobs: id: cache_ready run: ccache -z + - name: Test build identities + run: python3 -m unittest discover -s tests/build_system -v + - name: Build glob2 run: scons CXX=${{ matrix.compiler }} -j$(nproc) release=1 @@ -101,114 +116,124 @@ jobs: scons CXX=${{ matrix.compiler }} -j$(nproc) release=1 torus-render-test mkdir -p /tmp/glob2-render-profile GLOB2_USER_DIR=/tmp/glob2-render-profile SDL_AUDIODRIVER=dummy LIBGL_ALWAYS_SOFTWARE=1 \ - xvfb-run -a build/src/torus-render-test -g -F -m -s 1120x720 + xvfb-run -a build/linux/client/release/src/torus-render-test -g -F -m -s 1120x720 GLOB2_USER_DIR=/tmp/glob2-render-profile SDL_AUDIODRIVER=dummy SDL_VIDEODRIVER=dummy \ - build/src/torus-render-test -G -F -m -s 1120x720 + build/linux/client/release/src/torus-render-test -G -F -m -s 1120x720 + + - name: Build the YOG server + run: scons CXX=${{ matrix.compiler }} -j$(nproc) release=1 server=1 --build=build-server - name: Build and run custom-game setup regressions run: | scons -j$(nproc) release=1 server=0 custom-setup-test - timeout 180s ./build/src/CustomGameSetupHarness + timeout 180s ./build/linux/client/release/src/CustomGameSetupHarness mkdir -p artifacts/custom-game-ci - timeout 180s xvfb-run -a -s '-screen 0 1024x768x24' ./build/src/CustomGameSetupHarness artifacts/custom-game-ci > artifacts/custom-game-ci/ui.log 2>&1 - timeout 60s xvfb-run -a ./build/src/CustomGameSetupHarness preferences-write - timeout 60s xvfb-run -a ./build/src/CustomGameSetupHarness preferences-read + timeout 180s xvfb-run -a -s '-screen 0 1024x768x24' ./build/linux/client/release/src/CustomGameSetupHarness artifacts/custom-game-ci > artifacts/custom-game-ci/ui.log 2>&1 + timeout 60s xvfb-run -a ./build/linux/client/release/src/CustomGameSetupHarness preferences-write + timeout 60s xvfb-run -a ./build/linux/client/release/src/CustomGameSetupHarness preferences-read cat artifacts/custom-game-ci/ui.log ! grep -q 'no such key' artifacts/custom-game-ci/ui.log + - name: Test nonblocking screen execution + run: | + scons release=1 -j$(nproc) screen-test + ./build/linux/client/release/libgag/src/ScreenExecutionHarness + + - name: Test incremental engine sessions + run: | + scons release=1 -j$(nproc) session-test + python3 test/run-engine-session-test.py + - name: Build and run the selection lifetime regression run: | scons -j$(nproc) release=1 server=0 selection-test - ./build/src/GameGUISelectionHarness + ./build/linux/client/release/src/GameGUISelectionHarness - name: Build and run the wrapped building footprint regression run: | scons -j$(nproc) release=1 server=0 building-footprint-test - ./build/src/BuildingFootprintHarness - ./build/src/BuildingFootprintHarness --load test/fixtures/wrapped-building/reproducer.game + ./build/linux/client/release/src/BuildingFootprintHarness + ./build/linux/client/release/src/BuildingFootprintHarness --load test/fixtures/wrapped-building/reproducer.game - name: Build and run the entering unit save regression run: | scons -j$(nproc) release=1 server=0 entering-unit-save-test - ./build/src/EnteringUnitSaveHarness - ./build/src/EnteringUnitSaveHarness --load test/fixtures/entering-explorer/reproducer.game + ./build/linux/client/release/src/EnteringUnitSaveHarness + ./build/linux/client/release/src/EnteringUnitSaveHarness --load test/fixtures/entering-explorer/reproducer.game - name: Test trapped colony elimination run: | scons -j$(nproc) release=1 server=0 trapped-unit-test - python3 test/run-savegame-safety-tests.py --check-preferences build/src/TrappedUnitLifecycleTest + python3 test/run-savegame-safety-tests.py --check-preferences build/linux/client/release/src/TrappedUnitLifecycleTest - name: Build and run the entering unit draw regression run: | scons -j$(nproc) release=1 server=0 entering-unit-draw-test - timeout 300s xvfb-run -a -s '-screen 0 1024x768x24' ./build/src/EnteringUnitDrawHarness + timeout 300s xvfb-run -a -s '-screen 0 1024x768x24' ./build/linux/client/release/src/EnteringUnitDrawHarness - name: Build and run the immobile unit gradient regression run: | scons -j$(nproc) release=1 server=0 immobile-unit-gradient-test - ./build/src/ImmobileUnitGradientHarness + ./build/linux/client/release/src/ImmobileUnitGradientHarness - name: Build and run the building expulsion regression run: | scons -j$(nproc) release=1 server=0 building-expel-test - timeout 300s ./build/src/BuildingExpelHarness + timeout 300s ./build/linux/client/release/src/BuildingExpelHarness - name: Build and run the hiring bucket regression run: | scons -j$(nproc) release=1 server=0 hiring-bucket-test - python3 test/run-savegame-safety-tests.py --check-preferences build/src/HiringBucketHarness . + python3 test/run-savegame-safety-tests.py --check-preferences build/linux/client/release/src/HiringBucketHarness . - name: Build and run the resource-fetch target regression run: | scons -j$(nproc) release=1 server=0 resource-fetch-target-test - python3 test/run-savegame-safety-tests.py --check-preferences build/src/ResourceFetchTargetHarness . + python3 test/run-savegame-safety-tests.py --check-preferences build/linux/client/release/src/ResourceFetchTargetHarness . - name: Test savegame loading and atomic autosaves run: | scons -j$(nproc) release=1 server=0 savegame-safety-test buffered-file-test - python3 test/run-savegame-safety-tests.py build/src/SavegameSafetyHarness - ./build/src/BufferedFileStreamHarness + python3 test/run-savegame-safety-tests.py build/linux/client/release/src/SavegameSafetyHarness + ./build/linux/client/release/src/BufferedFileStreamHarness - name: Test team statistics save compatibility run: | scons -j$(nproc) release=1 server=0 team-stats-save-test - python3 test/run-savegame-safety-tests.py --check-preferences build/src/TeamStatsSaveHarness . - python3 test/run-savegame-safety-tests.py --check-preferences --expect-stdout test/fixtures/team-stats/version88.expected.txt build/src/TeamStatsSaveHarness . --legacy test/fixtures/team-stats/version88.game - python3 test/run-savegame-safety-tests.py --check-preferences --expect-stdout test/fixtures/team-stats/version84.expected.txt build/src/TeamStatsSaveHarness . --legacy games/gd-small-2ai.game - - - name: Build the YOG server - run: scons CXX=${{ matrix.compiler }} -j$(nproc) release=1 server=1 --build=build-server + python3 test/run-savegame-safety-tests.py --check-preferences build/linux/client/release/src/TeamStatsSaveHarness . + python3 test/run-savegame-safety-tests.py --check-preferences --expect-stdout test/fixtures/team-stats/version88.expected.txt build/linux/client/release/src/TeamStatsSaveHarness . --legacy test/fixtures/team-stats/version88.game + python3 test/run-savegame-safety-tests.py --check-preferences --expect-stdout test/fixtures/team-stats/version84.expected.txt build/linux/client/release/src/TeamStatsSaveHarness . --legacy games/gd-small-2ai.game - name: Build and run the terrain resource regression run: | scons -j$(nproc) release=1 server=0 terrain-test - ./build/src/TerrainResourcesHarness + ./build/linux/client/release/src/TerrainResourcesHarness - name: Build and run the global gradient regression run: | scons -j$(nproc) release=1 server=0 global-gradient-test - ./build/src/GlobalGradientHarness + ./build/linux/client/release/src/GlobalGradientHarness - name: Build and run the LAN session regression run: | scons -j$(nproc) release=1 server=0 lan-test - python3 test/run_lan_session_test.py build/src/LANSessionHarness + python3 test/run_lan_session_test.py build/linux/client/release/src/LANSessionHarness - name: Build and run the aspect-ratio regression env: LIBGL_ALWAYS_SOFTWARE: 1 run: | scons -j$(nproc) release=1 server=0 aspect-test - timeout 60s xvfb-run -a -s '-screen 0 1600x1400x24' ./build/libgag/src/FullscreenAspectHarness gl - timeout 60s xvfb-run -a -s '-screen 0 1600x1400x24' ./build/libgag/src/FullscreenAspectHarness software + timeout 60s xvfb-run -a -s '-screen 0 1600x1400x24' ./build/linux/client/release/libgag/src/FullscreenAspectHarness gl + timeout 60s xvfb-run -a -s '-screen 0 1600x1400x24' ./build/linux/client/release/libgag/src/FullscreenAspectHarness software - name: Build and run the window resize regression env: LIBGL_ALWAYS_SOFTWARE: 1 run: | scons -j$(nproc) release=1 server=0 resize-test - timeout 60s xvfb-run -a -s '-screen 0 1600x1400x24' ./build/libgag/src/WindowResizeHarness gl - timeout 60s xvfb-run -a -s '-screen 0 1600x1400x24' ./build/libgag/src/WindowResizeHarness software + timeout 60s xvfb-run -a -s '-screen 0 1600x1400x24' ./build/linux/client/release/libgag/src/WindowResizeHarness gl + timeout 60s xvfb-run -a -s '-screen 0 1600x1400x24' ./build/linux/client/release/libgag/src/WindowResizeHarness software - name: Check game rendering across repeated map copies env: @@ -216,8 +241,8 @@ jobs: SDL_AUDIODRIVER: dummy run: | scons -j$(nproc) release=1 server=0 map-render-resize-test - python3 test/run-savegame-safety-tests.py --check-preferences build/src/MapRenderResizeHarness - SDL_VIDEODRIVER=x11 LIBGL_ALWAYS_SOFTWARE=1 xvfb-run -a -s '-screen 0 1920x1200x24' python3 test/run-savegame-safety-tests.py --check-preferences build/src/MapRenderResizeHarness --gl + python3 test/run-savegame-safety-tests.py --check-preferences build/linux/client/release/src/MapRenderResizeHarness + SDL_VIDEODRIVER=x11 LIBGL_ALWAYS_SOFTWARE=1 xvfb-run -a -s '-screen 0 1920x1200x24' python3 test/run-savegame-safety-tests.py --check-preferences build/linux/client/release/src/MapRenderResizeHarness --gl - name: Build and run the tests working-directory: test @@ -235,13 +260,13 @@ jobs: SDL_AUDIODRIVER: dummy run: | scons -j$(nproc) release=1 server=0 menu-colony-harness - ./build/src/MenuColonyHarness check data/menu/colony.bin - ./build/src/MenuColonyHarness navigation unused + ./build/linux/client/release/src/MenuColonyHarness check data/menu/colony.bin + ./build/linux/client/release/src/MenuColonyHarness navigation unused - name: Test game speed controls and playback run: | scons -j$(nproc) release=1 server=0 speed-tests - xvfb-run -a python3 test/run-game-speed-tests.py + GLOB2_BUILD_DIR=build/linux/client/release xvfb-run -a python3 test/run-game-speed-tests.py - name: Build without OpenGL run: | @@ -271,6 +296,7 @@ jobs: windows: name: windows (mingw-w64) + if: ${{ github.event_name != 'workflow_dispatch' || !inputs.browser_only }} runs-on: windows-latest defaults: run: @@ -296,19 +322,23 @@ jobs: mingw-w64-x86_64-zlib mingw-w64-x86_64-fribidi mingw-w64-x86_64-pcre + mingw-w64-x86_64-openssl mingw-w64-x86_64-boost mingw-w64-x86_64-libepoxy mingw-w64-x86_64-libsystre - uses: actions/checkout@v4 + - name: Test build identities + run: python3 -m unittest discover -s tests/build_system -v + - name: Build glob2 run: scons -j$(nproc) release=1 mingw=1 - name: Test trapped colony elimination run: | scons -j$(nproc) release=1 mingw=1 server=0 trapped-unit-test - python3 test/run-savegame-safety-tests.py --check-preferences build/src/TrappedUnitLifecycleTest.exe + python3 test/run-savegame-safety-tests.py --check-preferences build/mingw/client/release/src/TrappedUnitLifecycleTest.exe - name: Test torus geometry, picking and cloud coordinates run: | @@ -324,7 +354,7 @@ jobs: - name: Build and run the hiring bucket regression run: | scons -j$(nproc) release=1 mingw=1 server=0 hiring-bucket-test - python3 test/run-savegame-safety-tests.py --check-preferences build/src/HiringBucketHarness.exe . + python3 test/run-savegame-safety-tests.py --check-preferences build/mingw/client/release/src/HiringBucketHarness.exe . - name: Check game rendering across repeated map copies env: @@ -332,19 +362,101 @@ jobs: SDL_AUDIODRIVER: dummy run: | scons -j$(nproc) release=1 mingw=1 server=0 map-render-resize-test - python3 test/run-savegame-safety-tests.py --check-preferences build/src/MapRenderResizeHarness.exe + python3 test/run-savegame-safety-tests.py --check-preferences build/mingw/client/release/src/MapRenderResizeHarness.exe + + - name: Build the YOG server + run: scons -j$(nproc) release=1 mingw=1 server=1 --build=build-server - name: Test savegame loading and atomic autosaves run: | scons -j$(nproc) release=1 mingw=1 server=0 savegame-safety-test - python3 test/run-savegame-safety-tests.py build/src/SavegameSafetyHarness.exe + python3 test/run-savegame-safety-tests.py build/mingw/client/release/src/SavegameSafetyHarness.exe - name: Test team statistics save compatibility run: | scons -j$(nproc) release=1 mingw=1 server=0 team-stats-save-test - python3 test/run-savegame-safety-tests.py --check-preferences build/src/TeamStatsSaveHarness.exe . - python3 test/run-savegame-safety-tests.py --check-preferences --expect-stdout test/fixtures/team-stats/version88.expected.txt build/src/TeamStatsSaveHarness.exe . --legacy test/fixtures/team-stats/version88.game - python3 test/run-savegame-safety-tests.py --check-preferences --expect-stdout test/fixtures/team-stats/version84.expected.txt build/src/TeamStatsSaveHarness.exe . --legacy games/gd-small-2ai.game - - - name: Build the YOG server - run: scons -j$(nproc) release=1 mingw=1 server=1 --build=build-server + python3 test/run-savegame-safety-tests.py --check-preferences build/mingw/client/release/src/TeamStatsSaveHarness.exe . + python3 test/run-savegame-safety-tests.py --check-preferences --expect-stdout test/fixtures/team-stats/version88.expected.txt build/mingw/client/release/src/TeamStatsSaveHarness.exe . --legacy test/fixtures/team-stats/version88.game + python3 test/run-savegame-safety-tests.py --check-preferences --expect-stdout test/fixtures/team-stats/version84.expected.txt build/mingw/client/release/src/TeamStatsSaveHarness.exe . --legacy games/gd-small-2ai.game + + web: + name: browser + runs-on: ubuntu-24.04 + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + - name: Install native and build dependencies + run: | + # The hosted image adds an unpinned Chrome repository, while this job + # installs its pinned browsers through Playwright below. + sudo rm -f \ + /etc/apt/sources.list.d/google-chrome.list \ + /etc/apt/sources.list.d/google-chrome.sources + sudo apt-get update -qq + sudo apt-get install -y scons libsdl2-dev libsdl2-image-dev libsdl2-net-dev libsdl2-ttf-dev libvorbis-dev libogg-dev libspeex-dev libboost-date-time-dev libboost-thread-dev libboost-system-dev libssl-dev zlib1g-dev libfribidi-dev libgl1-mesa-dev libglu1-mesa-dev libepoxy-dev + - name: Install pinned Emscripten + run: python3 browser/setup.py + - name: Test build identities + run: python3 -m unittest discover -s tests/build_system -v + - name: Build WebAssembly client + run: scons target=web release=1 -j$(nproc) + - name: Build and test gateway + run: | + scons role=gateway release=1 -j$(nproc) + python3 -m unittest discover -s tests/gateway -v + - name: Build headless router + run: scons role=router release=1 -j$(nproc) + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: browser/package-lock.json + - name: Build transport integration fixture + run: | + scons release=1 transport-test -j$(nproc) + python3 test/run-network-transport-tests.py + python3 -m unittest discover -s tests/transport -v + - name: Test browser lifecycle, renderers and multiplayer + working-directory: browser + env: + GLOB2_FIREFOX_HEADED: '1' + run: | + npm ci --ignore-scripts + npx playwright install --with-deps chromium firefox webkit + # Firefox needs an X display for WebGL2 and a real audio service to + # complete AudioContext.resume(). A null sink keeps CI silent. + sudo apt-get install -y pulseaudio + pulseaudio --start --exit-idle-time=-1 --load='module-null-sink sink_name=glob2_ci' + node --test unit/*.test.js + # Chromium carries the complete behavior suite. The other engines + # exercise startup, gameplay and viewport integration without + # repeating every persistence and multiplayer scenario. + xvfb-run -a npx playwright test --project=chromium + xvfb-run -a npx playwright test runtime-build.spec.js viewport.spec.js --project=firefox --project=webkit + # WebGL2 shares the game behavior above; keep focused adapter, + # lifecycle, persistence and rendering coverage in Chromium. + GLOB2_TEST_RENDERER=webgl2 xvfb-run -a npx playwright test runtime-build.spec.js session-reload.spec.js viewport.spec.js rendering.spec.js input.spec.js --project=chromium + xvfb-run -a npx playwright test --config visibility.config.js + GLOB2_TEST_RENDERER=webgl2 xvfb-run -a npx playwright test --config visibility.config.js + - name: Test self-hosting with real TLS and persistent volumes + run: | + docker compose -f deploy/compose.yaml build lobby + python3 -m unittest discover -s tests/deployment -v + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: browser-failure-traces + retention-days: 7 + path: | + build/browser-test-results + build/browser-test-report + - uses: actions/upload-artifact@v4 + if: github.event_name == 'workflow_dispatch' + with: + name: glob2-web-development + retention-days: 7 + path: | + build/emscripten/client/release/index.html + build/emscripten/client/release/index.js + build/emscripten/client/release/index.wasm + build/emscripten/client/release/index.data diff --git a/.gitignore b/.gitignore index c0c192685..851a3d63c 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,10 @@ Glob2-*.dmg /build-server/ /artifacts/ +/tools/browser-emsdk/ +/build-browser/ +/build-browser.log +/build-*-support.log +/test/build/ +/build-*.log +/browser/node_modules diff --git a/SConstruct b/SConstruct index f295acf2d..06b3544a7 100644 --- a/SConstruct +++ b/SConstruct @@ -2,12 +2,16 @@ EnsureSConsVersion(3, 0, 0) import sys import os import glob -from io import StringIO +import io +import atexit +from pathlib import Path sys.path.append( os.path.abspath("scons") ) import bundle import ccache import dmg import nsis +from sources import INCLUDE_DIRECTORIES +from build_layout import build_identity, default_directory, prepare_directory, write_if_changed, BuildLock isWindowsPlatform = sys.platform=='win32' isLinuxPlatform = sys.platform.startswith('linux') @@ -15,7 +19,7 @@ isDarwinPlatform = sys.platform=='darwin' def establish_options(env): - opts = Variables('options_cache.py') + opts = Variables() opts.Add("CXX", "C++ compiler", env["CXX"]) opts.Add("CXXFLAGS", "Manually add to the CXXFLAGS", "-g") opts.Add("LINKFLAGS", "Manually add to the LINKFLAGS", "-g") @@ -27,6 +31,7 @@ def establish_options(env): opts.Add("DATADIR", "Directory where data will be put, set to the same as INSTALLDIR", "/usr/local/share") opts.Add(BoolVariable("release", "Build for release", 0)) opts.Add(BoolVariable("opengl", "Enable OpenGL detection; set to 0 for software rendering only", 1)) + opts.Add(BoolVariable("wss", "Enable native secure WebSocket transport", 1)) opts.Add(BoolVariable("profile", "Build with profiling on", 0)) opts.Add(BoolVariable("mingw", "Build with mingw enabled if not auto-detected", 0)) opts.Add(BoolVariable("mingwcross", "Cross-compile with mingw for Win32", 0)) @@ -35,17 +40,28 @@ def establish_options(env): opts.Add("font", "Build the game using an alternative font placed in the data/font folder", "sans.ttf") Help(opts.GenerateHelpText(env)) opts.Update(env) - opts.Save("options_cache.py", env) - if env.GetOption('clean'): - Execute(Delete("options_cache.py")) + opts.Save(str(Path(env["BUILDDIR"]) / "options.py"), env) class Configuration: - """Handles the config.h file""" - def __init__(self): - self.f = StringIO() - self.f.write("// config.h. Generated by scons\n") + """Writes only the selected target configuration, without touching source files.""" + def __init__(self, env): + self.env = env + self.path = Path(env["BUILDDIR"]) / "include/glob2/BuildConfig.h" + self.f = io.StringIO() + self.f.write("#pragma once\n// Generated by SCons.\n") self.f.write("\n") + def finish(self): + content = self.f.getvalue() + def write_configuration(target, source, env): + write_if_changed(str(target[0]), content) + return 0 + # Register a generated header with SCons before dependency scanning. + # Writing it directly while parsing leaves a cold build's cached + # directory lookup unaware of it, so the next build recompiles objects. + self.env.Command(str(self.path), Value(content), + Action(write_configuration, 'Generating $TARGET')) + def add(self, variable, doc, value=""): self.f.write("// %s\n" % doc) self.f.write("#define %s %s\n" % (variable, value)) @@ -53,8 +69,8 @@ class Configuration: def configure(env, server_only): """Configures glob2""" - conf = Configure(env.Clone()) - configfile = Configuration() + conf = Configure(env.Clone(), conf_dir=str(Path(env["BUILDDIR"]) / "configure"), log_file=str(Path(env["BUILDDIR"]) / "configure.log")) + configfile = Configuration(env) configfile.add("PACKAGE", "Name of package", "\"glob2\"") configfile.add("PACKAGE_BUGREPORT", "Define to the address where bug reports for this package should be sent.", "\"glob2-devel@nongnu.org\"") if isDarwinPlatform: @@ -147,6 +163,15 @@ def configure(env, server_only): if conf.CheckLib("boost_system"): env.Append(LIBS=["boost_system"]) env.Append(LIBS=["pthread"]) + if not server_only and env["wss"]: + if not conf.CheckCXXHeader("openssl/ssl.h") or not conf.CheckLib("ssl") or not conf.CheckLib("crypto"): + missing.append("OpenSSL development headers and libraries") + env.Append(LIBS=["ssl", "crypto"]) + configfile.add("GLOB2_NATIVE_WSS", "Defined when native secure WebSocket support is compiled") + if not server_only: + if env["mingw"] or env["mingwcross"]: + env.Append(LIBS=["ws2_32", "mswsock"]) + if not conf.CheckCXXHeader("boost/logic/tribool.hpp"): @@ -235,6 +260,7 @@ def configure(env, server_only): print("Missing %s" % t) Exit(1) + configfile.finish() conf.Finish() contents = configfile.f.getvalue() previous = None @@ -254,22 +280,56 @@ def main(): metavar='portaudio', help='should portaudio be used') AddOption('--build', - default='build', + default=None, help='build directory') - env = Environment() + identity = build_identity(ARGUMENTS) + bdir = str(GetOption('build') or default_directory(identity)) + Path(bdir).mkdir(parents=True, exist_ok=True) + build_lock = BuildLock(bdir) + atexit.register(build_lock.close) + prepare_directory(bdir, identity) + temporary = str((Path(bdir) / 'tmp').resolve()) + Path(temporary).mkdir(parents=True, exist_ok=True) + # Includes response files created by SCons itself, not just compiler children. + import tempfile + tempfile.tempdir = temporary + SConsignFile(str(Path(bdir) / '.sconsign')) + if identity['target'] == 'web': + from web_build import build_web + build_web(bdir, identity, ARGUMENTS) + return + if identity['role'] == 'gateway': + from gateway_build import build_gateway + build_gateway(bdir, identity, ARGUMENTS) + return + env = Environment(tools=[]) # SCons scrubs the shell environment for build commands; without TMPDIR, # tools like ar fall back to /tmp, which sandboxed environments may block. if 'TMPDIR' in os.environ: env['ENV']['TMPDIR'] = os.environ['TMPDIR'] + # Respect an explicit Apple toolchain selection without changing xcode-select globally. + if 'DEVELOPER_DIR' in os.environ: + env['ENV']['DEVELOPER_DIR'] = os.environ['DEVELOPER_DIR'] # Likewise for SOURCE_DATE_EPOCH, needed by build tools for reproducible builds. if 'SOURCE_DATE_EPOCH' in os.environ: env['ENV']['SOURCE_DATE_EPOCH'] = os.environ['SOURCE_DATE_EPOCH'] + # Compiler discovery also needs the selected SDK, not just compilation. + env.Tool('default') + env['BUILDDIR'] = bdir + env.Prepend(CPPPATH=[env.Dir(bdir + '/include')]) + env['ENV'].update(TMPDIR=temporary, TMP=temporary, TEMP=temporary) env["VERSION"] = "0.9.5.0" establish_options(env) + env["server"] = identity["role"] in ("server", "router") + env["role"] = identity["role"] + if identity["role"] == "router": + env.Append(CPPDEFINES=["GLOB2_ROUTER_ONLY"]) # Emit compile_commands.json for clangd / IDE LSPs. env.Tool('compilation_db') - env.CompilationDatabase() + database = env.CompilationDatabase(bdir + '/compile_commands.json') + env.Alias('compile_commands.json', database) + env.Default(database) if env['mingw'] or env['mingwcross']: Tool('mingw')(env) @@ -327,17 +387,7 @@ def main(): env.Append(CPPDEFINES=["WIN32"]) configure(env, server_only) - env.Append(CPPPATH=['#libgag/include', '#']) - env.Append(CPPPATH=['#libusl/src', '#']) - env.Append(CPPPATH=['#src', '#src/yog', '#src/ai', '#src/building', - '#src/game/entities', - '#src/gui', - '#src/map', '#src/map/edit', '#src/map/generator', - '#src/map/gradient', '#src/map/io', '#src/map/pathfind', - '#src/net', '#src/net/irc', '#src/net/message', - '#src/sgsl', - '#src/team', - '#src/unit']) + env.Append(CPPPATH=['#'+path for path in INCLUDE_DIRECTORIES]) env.Append(CXXFLAGS=["-Wall", "-fPIC"]) # Uninitialized-read diagnostics: DET_INIT=zero|pattern forces deterministic # stack initialization (see CLAUDE.md). Env var, not a cached scons option. @@ -391,12 +441,12 @@ def main(): PackTar(env["TARFILE"], Split("COPYING INSTALL mkdist mkinstall mkuninstall README README.hg SConstruct")) #packaging for apple - if isDarwinPlatform and env["release"] and "bundle" in COMMAND_LINE_TARGETS: + if isDarwinPlatform and env["release"] and "package" in COMMAND_LINE_TARGETS: bundle.generate(env) dmg.generate(env) env.Replace( - BUNDLE_NAME="Glob2", - BUNDLE_BINARIES=["src/glob2"], + BUNDLE_NAME=bdir+"/Glob2", + BUNDLE_BINARIES=[bdir+"/src/glob2"], BUNDLE_RESOURCEDIRS=["data","maps", "campaigns"], BUNDLE_PLIST="darwin/Info.plist", BUNDLE_ICON="darwin/Glob2.icns" ) @@ -415,7 +465,6 @@ def main(): Export('crossroot_abs') Export('isWindowsPlatform') - bdir = GetOption('build') targets = [ "campaigns", "data", diff --git a/browser/ApplicationHost.cpp b/browser/ApplicationHost.cpp new file mode 100644 index 000000000..edae23bf2 --- /dev/null +++ b/browser/ApplicationHost.cpp @@ -0,0 +1,212 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include +#include +#include +#include + +namespace GAGCore::ApplicationHost +{ +namespace +{ +struct ScheduledLoop { std::unique_ptr loop; std::function complete; }; +void scheduledFrame(void* opaque) +{ + auto* state = static_cast(opaque); + if (EM_ASM_INT({ return Module.gpuRestorePending ? 1 : 0; })) { + // SDK-pinned compatibility state owns shaders and streaming buffers. + // Recreate it before asking the shared renderer to restore textures. + EM_ASM({ + // Objects from the lost context are already destroyed by WebGL. + // Forget their handles rather than deleting them in the new context. + GL.textures.fill(null); + GL.buffers.fill(null); + GLImmediate.currentRenderer = null; + GLImmediate.lastRenderer = null; + GLImmediate.lastArrayBuffer = null; + GLImmediate.lastProgram = null; + GLImmediate.fixedFunctionProgram = null; + GLImmediate.currentMatrix = 0; + GLImmediate.totalEnabledClientAttributes = 0; + GLImmediate.enabledClientAttributes = new Array(2).fill(0); + GLImmediate.init(); + GLctx.useProgram(null); + GLctx.currentProgram = 0; + GLctx.bindBuffer(GLctx.ARRAY_BUFFER, null); + GLctx.currentArrayBufferBinding = 0; + }); + GraphicContext::restoreBrowserContext(); + EM_ASM({ + Module.gpuRestorePending = false; + Module.gpuLost = false; + Module.visibilityPending = true; + Module.gpuRestores = (Module.gpuRestores || 0) + 1; + }); + } + std::vector events; + SDL_Event event; + while (SDL_PollEvent(&event)) events.push_back(event); + if (!state->loop->frame(SDL_GetTicks(), events)) { + state->loop.reset(); + auto complete = std::move(state->complete); + delete state; + complete(); + return; + } + // Each callback completes before the next frame is scheduled. Browser UI + // transitions and loading jobs must return control to this host. + emscripten_async_call(scheduledFrame, state, state->loop->delay(SDL_GetTicks())); +} +} +void run(std::unique_ptr loop, std::function complete) +{ + auto* state = new ScheduledLoop{std::move(loop), std::move(complete)}; + emscripten_async_call(scheduledFrame, state, 0); +} + +void wait(std::uint32_t) +{ + throw std::logic_error("Blocking application loops are unavailable in the browser; use scheduled screens or jobs"); +} +bool takeVisibilityChange(bool& hidden) +{ + const int state = EM_ASM_INT({ + const current = Boolean(document.hidden || Module.gpuLost); + // Querying the current state as well as the event latch makes the host + // resilient to a visibility edge delivered between browser callbacks. + if (!Module.visibilityPending && Module.hostHidden === current) return -1; + Module.visibilityPending = false; + Module.hostHidden = current; + return current ? 1 : 0; + }); + if (state < 0) return false; + hidden = state != 0; + return true; +} +bool takeViewportSize(int& width, int& height) +{ + return EM_ASM_INT({ + const size = Module.pendingViewport; + Module.pendingViewport = null; + if (!size || size.width <= 0 || size.height <= 0) return 0; + HEAP32[$0 >> 2] = size.width; + HEAP32[$1 >> 2] = size.height; + return 1; + }, &width, &height); +} +namespace { +class BrowserFileSelection : public FileSelection { + int id; +public: + explicit BrowserFileSelection(const std::string& extension) { + id = EM_ASM_INT({ + Module.fileSelections ||= new Map(); + const id = Module.nextFileSelectionId = (Module.nextFileSelectionId || 0) + 1; + const selection = new Glob2FileSelection([UTF8ToString($0)]); + Module.fileSelections.set(id, selection); + selection.pick(document); + return id; + }, extension.c_str()); + } + ~BrowserFileSelection() override { + EM_ASM({ Module.fileSelections.get($0).dispose(); Module.fileSelections.delete($0); }, id); + } + FileSelectionState state() const override { + return static_cast(EM_ASM_INT({ + const state = Module.fileSelections.get($0).state; + return state === 'selected' ? 1 : state === 'cancelled' ? 2 : state === 'failed' ? 3 : 0; + }, id)); + } + SelectedFile takeFile() override { + if (state() != FileSelectionState::Selected) throw std::logic_error("No selected file is available"); + const int nameSize = EM_ASM_INT({ return lengthBytesUTF8(Module.fileSelections.get($0).file.name) + 1; }, id); + const int size = EM_ASM_INT({ return Module.fileSelections.get($0).file.bytes.length; }, id); + std::vector name(nameSize); + SelectedFile file; + file.bytes.resize(size); + EM_ASM({ + const selection = Module.fileSelections.get($0); + stringToUTF8(selection.file.name, $1, $2); + HEAPU8.set(selection.file.bytes, $3); + selection.file = null; + selection.state = 'cancelled'; + }, id, name.data(), nameSize, file.bytes.data()); + file.name = name.data(); + return file; + } +}; +} +bool canImportFiles() { return true; } +std::unique_ptr selectFile(const std::string& extension) { + return std::make_unique(extension); +} +bool storageRestoreFailed() { return EM_ASM_INT({ return Module.storageRestore === 'failed'; }); } +bool canExportFiles() { return true; } +bool exportFile(const std::string& name, const std::vector& bytes) +{ + return EM_ASM_INT({ + try { + const blob = new Blob([HEAPU8.slice($1, $1 + $2)], {type:'application/octet-stream'}); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = UTF8ToString($0).replace(/[\\/]/g, '_'); + anchor.click(); + setTimeout(() => URL.revokeObjectURL(url), 60000); + return 1; + } catch (_) { return 0; } + }, name.c_str(), bytes.data(), bytes.size()); +} +namespace { +class BrowserPersistence : public Persistence { + int id; +public: + BrowserPersistence() { + id = EM_ASM_INT({ + Module.persistenceResults ||= new Map(); + const id = Module.nextPersistenceId = (Module.nextPersistenceId || 0) + 1; + Module.persistenceResults.set(id, 0); + const complete = state => { if (Module.persistenceResults.has(id)) Module.persistenceResults.set(id, state); }; + Module.storage.flush().then(() => complete(1), () => complete(2)); + return id; + }); + } + ~BrowserPersistence() override { EM_ASM({ Module.persistenceResults.delete($0); }, id); } + PersistenceState state() const override { + return static_cast(EM_ASM_INT({ return Module.persistenceResults.get($0); }, id)); + } +}; +} +std::unique_ptr persistStorage() { return std::make_unique(); } +void importChanged(const char* state) { EM_ASM({ Module.importState = UTF8ToString($0); }, state); } +void screenChanged(const char* name) +{ + EM_ASM({ Module['glob2Screen'] = Module['glob2ScreenClass'] = UTF8ToString($0); }, name); +} +void simulationAdvanced(std::uint32_t tick) +{ + EM_ASM({ Module['glob2Tick'] = $0; Module['glob2Screen'] = 'match'; }, tick); +} +void exited(int result) +{ + EM_ASM({ + Module['glob2Screen'] = 'exited'; + if (Module['onGameExit']) Module['onGameExit']($0); + }, result); +} +void roomReady(bool canStart) { EM_ASM({ Module.glob2RoomCanStart = Boolean($0); }, canStart); } +void matchFrame(bool paused) +{ + EM_ASM({ + Module['glob2Frames'] = (Module['glob2Frames'] || 0) + 1; + Module['glob2Paused'] = Boolean($0); + }, paused); +} +void overviewDrawn(bool drawn) +{ + // Reported every match frame; publish only changes. + static int published = -1; + if (published == int(drawn)) return; + published = drawn; + EM_ASM({ Module['glob2Torus'] = Boolean($0); }, drawn); +} +} diff --git a/browser/IRCTextMessageHandler.cpp b/browser/IRCTextMessageHandler.cpp new file mode 100644 index 000000000..507dec4ab --- /dev/null +++ b/browser/IRCTextMessageHandler.cpp @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// The browser lobby uses YOG chat. The optional native TCP IRC bridge is not +// available through the fixed-backend WebSocket gateway. +#include "IRCTextMessageHandler.h" + +IRCTextMessageHandler::IRCTextMessageHandler() : irc(incoming, incomingMutex), userListModified(false) {} +IRCTextMessageHandler::~IRCTextMessageHandler() = default; +void IRCTextMessageHandler::startIRC(const std::string&) {} +void IRCTextMessageHandler::stopIRC() {} +void IRCTextMessageHandler::update() {} +void IRCTextMessageHandler::sendCommand(const std::string&) {} +bool IRCTextMessageHandler::hasUserListBeenModified() { return false; } +std::vector& IRCTextMessageHandler::getUsers() { return users; } +void IRCTextMessageHandler::addTextMessageListener(IRCTextMessageListener* listener) { listeners.add(listener); } +void IRCTextMessageHandler::removeTextMessageListener(IRCTextMessageListener* listener) { listeners.remove(listener); } +void IRCTextMessageHandler::sendToAllListeners(const std::string& message) { + listeners.notify(&IRCTextMessageListener::handleIRCTextMessage, message); +} diff --git a/browser/NetTransport.cpp b/browser/NetTransport.cpp new file mode 100644 index 000000000..110da7505 --- /dev/null +++ b/browser/NetTransport.cpp @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "NetTransport.h" +#include +#include +#include + +EM_JS(char*, glob2_websocket_endpoint, (int port), { + const route = port === 7491 ? '/router' : '/yog'; + const base = globalThis.glob2Config?.websocketBase || + ((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host); + return stringToNewUTF8(new URL(route, base).href); +}); + +namespace { +class WebSocketTransport final : public NetTransport { + EMSCRIPTEN_WEBSOCKET_T socket = 0; + State status = State::Closed; + std::deque> incoming; + size_t incomingBytes = 0; +public: + ~WebSocketTransport() override { close(); } + void open(const std::string& address, uint16_t port) override { + close(); + if (!emscripten_websocket_is_supported()) return; + std::string url = address; + if (!url.starts_with("ws://") && !url.starts_with("wss://")) { + char* endpoint = glob2_websocket_endpoint(port); + url = endpoint; + std::free(endpoint); + } + EmscriptenWebSocketCreateAttributes attributes{}; + attributes.url = url.c_str(); + socket = emscripten_websocket_new(&attributes); + if (socket <= 0) { socket = 0; return; } + status = State::Connecting; + emscripten_websocket_set_onopen_callback(socket, this, [](int, const EmscriptenWebSocketOpenEvent* e, void* data) { + auto& self = *static_cast(data); + if (self.socket == e->socket) self.status = State::Connected; + return true; + }); + emscripten_websocket_set_onclose_callback(socket, this, [](int, const EmscriptenWebSocketCloseEvent* e, void* data) { + auto& self = *static_cast(data); + if (self.socket == e->socket) self.status = State::Closed; + return true; + }); + emscripten_websocket_set_onerror_callback(socket, this, [](int, const EmscriptenWebSocketErrorEvent* e, void* data) { + auto& self = *static_cast(data); + if (self.socket == e->socket) self.status = State::Closed; + return true; + }); + emscripten_websocket_set_onmessage_callback(socket, this, [](int, const EmscriptenWebSocketMessageEvent* e, void* data) { + auto& self = *static_cast(data); + if (self.socket != e->socket) return true; + if (e->isText || e->numBytes > queueLimit - self.incomingBytes) { self.close(); return true; } + if (e->numBytes) { + self.incoming.emplace_back(e->data, e->data + e->numBytes); + self.incomingBytes += e->numBytes; + } + return true; + }); + } + void close() override { + if (socket) { + // Deleting the Emscripten handle detaches all four JS callbacks. + emscripten_websocket_close(socket, 1000, nullptr); + emscripten_websocket_delete(socket); + socket = 0; + } + status = State::Closed; + incoming.clear(); incomingBytes = 0; + } + State state() const override { return status; } + bool send(std::vector bytes) override { + size_t buffered = 0; + if (status != State::Connected || + emscripten_websocket_get_buffered_amount(socket, &buffered) != EMSCRIPTEN_RESULT_SUCCESS || + buffered > queueLimit || bytes.size() > queueLimit - buffered) return false; + // Gateway messages are limited to 64 KiB. Protocol frames can cross + // any number of WebSocket messages, just as they cross TCP reads. + for (size_t offset = 0; offset < bytes.size(); offset += chunkLimit) { + const size_t count = std::min(chunkLimit, bytes.size() - offset); + if (emscripten_websocket_send_binary(socket, bytes.data() + offset, count) != EMSCRIPTEN_RESULT_SUCCESS) return false; + } + return true; + } + bool receive(std::vector& bytes) override { + if (incoming.empty()) return false; + bytes = std::move(incoming.front()); incoming.pop_front(); + incomingBytes -= bytes.size(); + return true; + } +}; +} +std::unique_ptr makeNetTransport() { return std::make_unique(); } diff --git a/browser/README.md b/browser/README.md new file mode 100644 index 000000000..db6425fe9 --- /dev/null +++ b/browser/README.md @@ -0,0 +1,136 @@ +# Browser platform development + +Globulation 2 runs in a full-page browser client with campaigns, tutorials, +custom games, map editing, local saves and experimental YOG cross-play. +WebGL2 is the default where the browser accelerates it; otherwise the game uses +the software renderer. `?renderer=webgl2` or `?renderer=software` forces either +one. The pinned Emscripten 4.0.15 build +shares game logic and the GPU renderer with desktop. The browser host schedules frames and cooperative jobs without Asyncify. This is a development target, not a supported stable release. +The browser ADRs under `docs/browser` describe the implementation boundaries and +remaining release gates. + +## Build + +From the repository root: + +```sh +python3 browser/setup.py +scons target=web release=1 -j8 +python3 -m http.server 8765 --bind 127.0.0.1 --directory build/emscripten/client/release +``` + +Open http://127.0.0.1:8765. The game starts automatically and fills the page. +The SDK and build output are ignored by Git. Shared source manifests in +`scons/sources.py` drive native and browser builds. Each target owns its +configuration, objects, compilation database, cache, and signature database. +Native builds do not require Emscripten; browser builds do not probe system libraries. + +`browser/toolchain.json` pins the SDK revision and version. Boost headers come +from the SDK's checksum-verified Boost port. `emsdk=/path/to/emsdk` selects an +already installed matching SDK. Omit `release=1` for a debug build. +`python3 browser/build.py` remains a compatibility wrapper for the release build. +See [delivery contracts](../docs/browser/implementation.md) for output paths and +remaining release gates. + +## Playing and saving + +Use the game's Quit button to wait for final storage writes before closing. +If that write fails, the game offers Retry save or Quit without saving. Closing +or refreshing the browser tab directly cannot wait for asynchronous saves. +Campaign creation/editing also waits for durable storage before returning. + + +Use Tutorial, Campaign, Custom Game or Editor. Clicking the canvas focuses +keyboard input and enables music. Live resize updates the internal resolution +at frame boundaries in scheduled browser flows. +Add `?renderer=software` or `?renderer=webgl2` to the URL to force a renderer. +With WebGL2, press G in a match for the torus overview, and the map zoom controls +work; the software renderer keeps the flat, unzoomed map. +High-quality graphics (including clouds) default to off for new browser profiles. +You can enable them in Settings; existing saved preferences are preserved. + +Manual game/editor saves wait for durable IndexedDB persistence and offer retry +and export on failure. Saves belong to this browser profile and origin +(including the port); clearing site data deletes them. Use the in-game import +and export controls for backups. See [storage](../docs/browser/storage.md) for +format validation, campaign backups and remaining legacy-writer limitations. +Settings also waits for durable preferences/keyboard storage and offers Retry or +Continue on failure; Continue does not confirm a saved copy. + +## Scope + +This is a desktop-browser experiment with mouse and keyboard controls. +The YOG entry uses the WebSocket gateway; LAN remains unavailable in browsers. +The lobby uses YOG chat; the separate native IRC bridge is unavailable. +See `docs/browser/gateway.md` for routing. Full matches and recovery remain experimental. Voice chat is a no-op; music uses the +existing Vorbis mixer. Map fertility calculation runs cooperatively on the +browser thread. WebGL2 reuses the existing GPU renderer through Emscripten compatibility glue; +there is no mobile UI adaptation. + +Browser and desktop multiplayer clients and YOG must use the same protocol +(version 29). Update all components together. See the [admission contract](../docs/browser/protocol.md). +Guests, invitations and coordinated refresh/reconnect recovery remain unfinished. + +## Compatibility note + +Newly generated map layouts change on every platform. Generation previously +mixed synchronized randomness with libc randomness, time-based reseeding, and +shared Perlin tables, so a seed did not reliably identify a layout. Generation +now isolates that state and makes a seed reproducible on one platform. Existing +maps, saves, replays, and simulation rules are unchanged. + +Bit-exact native/WebAssembly generation from the same seed is not yet promised +because height-map generation uses floating point. This cannot split an active +YOG match: the host selects a map file and clients download those exact bytes +before play. See [ADR 005](../docs/browser/adr-005-generation-randomness.md). + +## Automated tests + +The maintained Playwright suite starts an isolated local HTTP server and uses +fresh browser profiles for every test. It covers page startup, a custom match, +pause over multiple observed engine frames, save persistence across reload, +loading and audio activation. Player actions use real mouse/keyboard input; +assertions read `glob2Diagnostics` without changing game state. + +```sh +cd browser +npm ci --ignore-scripts +npx playwright install chromium firefox webkit +npm test +``` + +On Linux CI or a container without a desktop session, Firefox needs Xvfb for +WebGL2 and an audio service for `AudioContext.resume()` to complete. After +installing the Playwright browser dependencies, use: + +```sh +sudo apt-get install -y pulseaudio +pulseaudio --start --exit-idle-time=-1 --load='module-null-sink sink_name=glob2_ci' +GLOB2_FIREFOX_HEADED=1 xvfb-run -a npm test +``` + +The null sink processes audio silently. On a workstation with an existing sound +server, use that server instead. `GLOB2_FIREFOX_HEADED=1` affects Firefox only; +the tests still require actual WebGL2 and audio activation. It does not bypass +assertions or select the software game renderer. Other environments retain the +default headless browser configuration. + +For the separate real-window visibility suite in a Linux container, set `CI=1` +and run `xvfb-run -a npx playwright test --config visibility.config.js`. Its local +test browser then uses `--no-sandbox` and SwiftShader; these settings affect only +the test process and do not establish hardware GPU performance. + +Use `npm test -- --project=chromium` for a focused run. The package lock pins the +test runner and its browser revisions. Failures retain traces and screenshots +under `build/browser-test-results`. WebKit automation does not substitute for +release testing in actual Safari, nor Chromium for Edge. + +Run the suite with `GLOB2_TEST_RENDERER=webgl2` or `software` to force a renderer +throughout. Otherwise tests get the default selection, which is software in +headless browsers that emulate WebGL2. +Dedicated renderer tests exercise resize and actual context loss/restoration. +New multiplayer features, including reconnect recovery, are outside this change. + +Build outputs and the SDK are ignored local files. Serve the output directory; +opening the HTML as a `file:` URL is unsupported. The SDL audio backend still +uses deprecated ScriptProcessorNode. diff --git a/browser/VoiceRecorder.cpp b/browser/VoiceRecorder.cpp new file mode 100644 index 000000000..d1224b96d --- /dev/null +++ b/browser/VoiceRecorder.cpp @@ -0,0 +1,8 @@ +#include "VoiceRecorder.h" +VoiceRecorder::VoiceRecorder() : speexEncoderState(nullptr), frameSize(0), + recordingThread(nullptr), ordersMutex(nullptr), recordingNow(false), + recordThreadRun(false), stopRecordingTimeout(0) {} +VoiceRecorder::~VoiceRecorder() = default; +void VoiceRecorder::startRecording() {} +void VoiceRecorder::stopRecording() {} +std::shared_ptr VoiceRecorder::getNextOrder() { return {}; } diff --git a/browser/audio.js b/browser/audio.js new file mode 100644 index 000000000..074569b8f --- /dev/null +++ b/browser/audio.js @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Invoke directly from a user gesture, while browser activation is still valid. +async function glob2ActivateAudio(context, reportError) { + if (!context || context.state === 'closed' || context.state === 'running') return; + try { + await context.resume(); + } catch (error) { + // SDL can close the context while a gesture's resume promise is pending. + // A later gesture may retry other failures; never leave a rejected promise + // unhandled or claim that audio started when the browser refused it. + if (context.state !== 'closed') reportError(error); + } +} +// Firefox rejects a pending SDL resume after SDL_Quit closes the AudioContext. +// The rejection is delivered after the synchronous shutdown has completed, so +// suppress only that exact, otherwise unobserved shutdown race. +glob2ActivateAudio.ignoreClosedRejection = function(event, exited) { + const reason = event?.reason; + if (!exited || reason?.name !== 'InvalidStateError' || + !/closed before resume completed/i.test(reason?.message || '')) return false; + event.preventDefault(); + return true; +}; +if (typeof module !== 'undefined' && module.exports) module.exports = glob2ActivateAudio; +if (typeof globalThis !== 'undefined') globalThis.glob2ActivateAudio = glob2ActivateAudio; diff --git a/browser/benchmarks/rendering.cjs b/browser/benchmarks/rendering.cjs new file mode 100644 index 000000000..352367d22 --- /dev/null +++ b/browser/benchmarks/rendering.cjs @@ -0,0 +1,35 @@ +// Quick renderer comparison using real controls. Controlled release fixtures +// and longer measurements are still required for performance qualification. +const {chromium} = require('playwright'); +(async () => { + const renderer = process.argv[2]; + if (!['software','webgl2'].includes(renderer)) throw new Error('Pass software or webgl2'); + const args = process.env.GLOB2_ANGLE ? ['--use-angle=' + process.env.GLOB2_ANGLE] : []; + const browser = await chromium.launch({headless:false, args}); + try { + const page = await browser.newPage({viewport:{width:1200,height:900}}); + const url = new URL(process.env.GLOB2_TEST_URL || 'http://127.0.0.1:8770'); + url.searchParams.set('renderer', renderer); + await page.goto(url.href); + const screen = name => page.waitForFunction(name => glob2Diagnostics.snapshot().screen.includes(name), name); + await screen('MainMenuScreen'); + const gpu = await page.evaluate(() => { + const gl = document.querySelector('#canvas').getContext('webgl2'); + if (!gl) return null; + const extension = gl.getExtension('WEBGL_debug_renderer_info'); + return extension ? gl.getParameter(extension.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER); + }); + const click = (x,y) => page.locator('#canvas').click({position:{x,y},delay:80}); + await click(760,410); await screen('CustomGameScreen'); + await click(380,280); await click(810,590); + await page.waitForFunction(() => glob2Diagnostics.snapshot().tick > 25, null, {timeout:60000}); + const sample = () => page.evaluate(() => ({time:performance.now(),...glob2Diagnostics.snapshot()})); + const before = await sample(); + if (before.renderer !== renderer) throw new Error('Requested renderer is unavailable'); + await page.waitForTimeout(6000); + const after = await sample(); + console.log(JSON.stringify({renderer,gpu,fixture:'first custom map, default options', + elapsedMs:after.time-before.time,ticks:after.tick-before.tick, + ticksPerSecond:(after.tick-before.tick)*1000/(after.time-before.time)},null,2)); + } finally { await browser.close(); } +})().catch(error => { console.error(error); process.exitCode=1; }); diff --git a/browser/build.py b/browser/build.py new file mode 100644 index 000000000..87a59adc1 --- /dev/null +++ b/browser/build.py @@ -0,0 +1,8 @@ +#!/usr/bin/env python3 +"""Compatibility entry point; all build logic lives in SCons.""" +import os +from pathlib import Path +import subprocess +import sys +os.chdir(Path(__file__).resolve().parent.parent) +raise SystemExit(subprocess.call(['scons', 'target=web', 'release=1', '-j'+os.environ.get('JOBS','8'), *sys.argv[1:]])) diff --git a/browser/file-selection.js b/browser/file-selection.js new file mode 100644 index 000000000..b72af3d8c --- /dev/null +++ b/browser/file-selection.js @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Read browser-selected files without trusting a name, extension, or reported size. +// Format validation belongs to the shared game loader after this boundary. +class Glob2FileSelection { + constructor(extensions, limit = 64 * 1024 * 1024) { + this.extensions = new Set(extensions); + this.limit = limit; + this.state = 'pending'; + this.file = null; + this.error = null; + this.disposed = false; + } + async select(file) { + if (this.disposed || this.state !== 'pending') return; + if (!file) { this.state = 'cancelled'; return; } + try { + const name = file.name; + if (typeof name !== 'string' || !name.length || name.length > 128 || + /[\\/<>:"|?*\x00-\x1f\x7f]/.test(name) || name === '.' || name === '..' || + name.endsWith('.') || name.endsWith(' ')) throw new Error('Invalid file name'); + const dot = name.lastIndexOf('.'); + if (dot <= 0) throw new Error('Missing file extension'); + const extension = name.slice(dot + 1).toLowerCase(); + if (!this.extensions.has(extension)) throw new Error('Unsupported file type'); + if (!Number.isSafeInteger(file.size) || file.size <= 0 || file.size > this.limit) + throw new Error('File exceeds import size limit'); + this.state = 'reading'; + const bytes = new Uint8Array(await file.arrayBuffer()); + if (this.disposed) return; + if (bytes.length !== file.size || bytes.length > this.limit) throw new Error('File size changed while reading'); + this.file = {name, bytes}; + this.state = 'selected'; + } catch (error) { + if (!this.disposed) { this.error = error; this.state = 'failed'; } + } + } + pick(document) { + if (this.disposed || this.input || this.state !== 'pending') return; + const input = this.input = document.createElement('input'); + input.type = 'file'; input.accept = [...this.extensions].map(extension => '.' + extension).join(','); + input.hidden = true; + const cleanup = () => { input.remove(); if (this.input === input) this.input = null; }; + input.addEventListener('change', () => { this.select(input.files?.[0] || null); cleanup(); }, {once:true}); + input.addEventListener('cancel', () => { this.select(null); cleanup(); }, {once:true}); + document.body.appendChild(input); + try { input.click(); } + catch (error) { this.error = error; this.state = 'failed'; cleanup(); } + } + dispose() { + this.disposed = true; this.file = null; + if (this.input) { this.input.remove(); this.input = null; } + } +} +if (typeof module !== 'undefined' && module.exports) module.exports = Glob2FileSelection; +if (typeof globalThis !== 'undefined') globalThis.Glob2FileSelection = Glob2FileSelection; diff --git a/browser/package-lock.json b/browser/package-lock.json new file mode 100644 index 000000000..e7a247508 --- /dev/null +++ b/browser/package-lock.json @@ -0,0 +1,58 @@ +{ + "name": "glob2-browser-tests", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "glob2-browser-tests", + "devDependencies": { + "@playwright/test": "1.63.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + } + } +} diff --git a/browser/package-static.py b/browser/package-static.py new file mode 100644 index 000000000..01c0bbd96 --- /dev/null +++ b/browser/package-static.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +"""Package the release with versioned asset names for static bucket hosting.""" +import hashlib +from pathlib import Path + +root = Path(__file__).resolve().parent.parent +source = root / 'build/emscripten/client/release' +destination = root / 'build/browser-static' +destination.mkdir(parents=True, exist_ok=True) +files = {ext: (source / f'index.{ext}').read_bytes() + for ext in ('html', 'js', 'wasm', 'data')} +version = hashlib.sha256(b''.join(files.values())).hexdigest()[:16] +names = {ext: f'index-{version}.{ext}' for ext in ('js', 'wasm', 'data')} +script = files['js'].decode() +for ext in ('wasm', 'data'): + script = script.replace(f'"index.{ext}"', f'"{names[ext]}"') +(destination / names['js']).write_text(script) +for ext in ('wasm', 'data'): + (destination / names[ext]).write_bytes(files[ext]) +html = files['html'].decode().replace('src="index.js"', f'src="{names["js"]}"') +html = html.replace('src=index.js>', f'src="{names["js"]}">') +assert names['js'] in html, 'Expected Emscripten script tag' +(destination / 'index.html').write_text(html) +print(f'Packaged {version} in {destination}') diff --git a/browser/package.json b/browser/package.json new file mode 100644 index 000000000..5389ea1ff --- /dev/null +++ b/browser/package.json @@ -0,0 +1,6 @@ +{ + "name": "glob2-browser-tests", + "private": true, + "scripts": {"test": "node --test unit/*.test.js && playwright test"}, + "devDependencies": {"@playwright/test": "1.63.0"} +} diff --git a/browser/playwright.config.js b/browser/playwright.config.js new file mode 100644 index 000000000..f9ba3ee98 --- /dev/null +++ b/browser/playwright.config.js @@ -0,0 +1,28 @@ +const {defineConfig} = require('@playwright/test'); +const path = require('node:path'); +module.exports = defineConfig({ + testDir: './tests', + outputDir: '../build/browser-test-results', + timeout: 90000, + expect: {timeout: 30000}, + workers: 1, + forbidOnly: Boolean(process.env.CI), + retries: 0, + reporter: [['list'], ['html', {outputFolder:'../build/browser-test-report', open:'never'}]], + use: { + baseURL: process.env.GLOB2_TEST_URL || 'http://127.0.0.1:8770', + viewport: {width:1200, height:900}, + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + }, + projects: ['chromium','firefox','webkit'].map(browserName => ({ + name:browserName, + use:{browserName, ...(browserName === 'firefox' && process.env.GLOB2_FIREFOX_HEADED === '1' ? {headless:false} : {})}, + })), + webServer: process.env.GLOB2_TEST_URL ? undefined : { + command: 'python3 -m http.server 8770 --bind 127.0.0.1 --directory build/emscripten/client/release', + cwd: path.resolve(__dirname, '..'), + url: 'http://127.0.0.1:8770', + reuseExistingServer: false, + }, +}); diff --git a/browser/setup.py b/browser/setup.py new file mode 100644 index 000000000..24e2d0c49 --- /dev/null +++ b/browser/setup.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python3 +"""Install the exact SDK revision described by toolchain.json.""" +import json +from pathlib import Path +import subprocess + +root = Path(__file__).resolve().parent.parent +lock = json.loads((root / 'browser/toolchain.json').read_text()) +sdk = root / 'tools/browser-emsdk' +if not sdk.exists(): + subprocess.run(['git', 'clone', 'https://github.com/emscripten-core/emsdk.git', str(sdk)], check=True) +subprocess.run(['git', '-C', str(sdk), 'checkout', '--detach', lock['emsdk_commit']], check=True) +for action in ('install','activate'): + subprocess.run([str(sdk / 'emsdk'), action, lock['emscripten']], check=True) diff --git a/browser/shell.html b/browser/shell.html new file mode 100644 index 000000000..94fdbbc83 --- /dev/null +++ b/browser/shell.html @@ -0,0 +1,209 @@ + + + + + + +Globulation 2 + + + +
+ Globulation 2 + + Loading game… +
+ + +{{{ SCRIPT }}} + + diff --git a/browser/storage.js b/browser/storage.js new file mode 100644 index 000000000..7e56b7e4e --- /dev/null +++ b/browser/storage.js @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// The adapter supplies persistence and scheduling; no game or filesystem formats live here. +class Glob2Storage { + constructor(persist, schedule = callback => setTimeout(callback, 0)) { + this.persist = persist; + this.schedule = schedule; + this.generation = 0; + this.committed = 0; + this.active = false; + this.scheduled = false; + this.error = null; + this.waiters = []; + this.state = 'restoring'; + } + restored(error) { + this.error = error || null; + this.state = error ? 'restore-failed' : 'persisted'; + } + changed() { + ++this.generation; + if (this.state === 'restoring' || this.state === 'restore-failed') return; + this.error = null; + this.state = 'writing'; + this.enqueue(); + } + flush() { + if (this.state === 'restoring' || this.state === 'restore-failed') + return Promise.reject(this.error || new Error('Storage has not restored')); + this.changed(); + return new Promise((resolve, reject) => this.waiters.push({generation:this.generation, resolve, reject})); + } + enqueue() { + if (this.active || this.scheduled) return; + this.scheduled = true; + this.schedule(() => { this.scheduled = false; this.write(); }); + } + write() { + if (this.active || this.committed === this.generation) return; + this.active = true; + const generation = this.generation; + const complete = error => { + this.active = false; + this.error = error || null; + if (!error) this.committed = generation; + const remaining = []; + for (const waiter of this.waiters) { + if (error) waiter.reject(error); + else if (waiter.generation <= generation) waiter.resolve(); + else remaining.push(waiter); + } + this.waiters = remaining; + this.state = error ? 'failed' : this.committed === this.generation ? 'persisted' : 'writing'; + if (!error && this.committed !== this.generation) this.enqueue(); + }; + try { this.persist(complete); } catch (error) { complete(error); } + } +} +if (typeof module !== 'undefined' && module.exports) module.exports = Glob2Storage; +if (typeof globalThis !== 'undefined') globalThis.Glob2Storage = Glob2Storage; diff --git a/browser/tests/campaign-editor-storage.spec.js b/browser/tests/campaign-editor-storage.spec.js new file mode 100644 index 000000000..e9ca47a60 --- /dev/null +++ b/browser/tests/campaign-editor-storage.spec.js @@ -0,0 +1,60 @@ +const {test,expect}=require('@playwright/test'); +const {clickMainMenu,gameURL}=require('./main-menu'); +const state=page=>page.evaluate(()=>glob2Diagnostics.snapshot()); +const screen=(page,name)=>expect.poll(async()=>(await state(page)).screen).toContain(name); +const click=(page,x,y)=>page.locator('#canvas').click({position:{x,y},delay:80}); +const digest=page=>page.evaluate(()=>glob2Diagnostics.campaignDefinitionDigest('Browser_Campaign.txt')); +async function edit(page){ + await page.goto(gameURL());await screen(page,'MainMenuScreen'); + await clickMainMenu(page,'editor');await screen(page,'EditorMainMenu'); + await click(page,600,420);await screen(page,'CampaignEditor'); + await click(page,750,280); + await page.locator('#canvas').press('Home'); + for(let i=0;i<7;i++)await page.locator('#canvas').press('Delete'); + await page.keyboard.type('Browser Campaign',{delay:30}); +} + +test('campaign authoring waits for durable persistence before closing',async({page})=>{ + await edit(page); + await page.evaluate(()=>{ + const sync=FS.syncfs; + FS.syncfs=function(populate,callback){ + if(!populate){window.releaseCampaignWrite=()=>{FS.syncfs=sync;sync.call(FS,populate,callback);};return;} + return sync.call(FS,populate,callback); + }; + }); + await click(page,630,660); + await expect.poll(()=>page.evaluate(()=>typeof window.releaseCampaignWrite)).toBe('function'); + await screen(page,'CampaignEditor'); + await click(page,820,660); // Cancel cannot complete a pending save. + await screen(page,'CampaignEditor'); + await page.evaluate(()=>window.releaseCampaignWrite()); + await screen(page,'EditorMainMenu'); + const saved=await digest(page);expect(saved?.size).toBeGreaterThan(0); + await page.reload();await screen(page,'MainMenuScreen'); + expect(await digest(page)).toEqual(saved); +}); + +test('campaign authoring retains the editor on quota failure and can retry',async({page,context},info)=>{ + await edit(page); + await page.evaluate(()=>{ + const put=IDBObjectStore.prototype.put; + window.failCampaignWrite=true; + IDBObjectStore.prototype.put=function(...args){ + if(window.failCampaignWrite)throw new DOMException('Injected quota exhaustion','QuotaExceededError'); + return put.apply(this,args); + }; + }); + await click(page,630,660); + await expect.poll(async()=>(await state(page)).persistence).toBe('failed'); + await screen(page,'CampaignEditor'); + await expect.poll(()=>require('./pixels').hasLightText(page,{x:610,y:548,width:290,height:65})).toBe(true); + await page.screenshot({path:info.outputPath('campaign-editor-save-failure.png')}); + const restored=await context.newPage();await restored.goto(gameURL());await screen(restored,'MainMenuScreen'); + expect(await digest(restored)).toBeNull();await restored.close(); + await page.evaluate(()=>window.failCampaignWrite=false); + await click(page,630,660);await screen(page,'EditorMainMenu'); + const saved=await digest(page);expect(saved?.size).toBeGreaterThan(0); + await page.reload();await screen(page,'MainMenuScreen'); + expect(await digest(page)).toEqual(saved); +}); diff --git a/browser/tests/campaign-progress.spec.js b/browser/tests/campaign-progress.spec.js new file mode 100644 index 000000000..9974aefb3 --- /dev/null +++ b/browser/tests/campaign-progress.spec.js @@ -0,0 +1,101 @@ +const {test,expect}=require('@playwright/test'); +const {clickMainMenu,gameURL}=require('./main-menu'); +const fs=require('node:fs/promises'); +const state=page=>page.evaluate(()=>glob2Diagnostics.snapshot()); +const screen=(page,name)=>expect.poll(async()=>(await state(page)).screen).toContain(name); +const menu=(page,x,y)=>page.locator('#canvas').click({position:{x:x+280,y:y+210},delay:80}); +const stored=page=>page.evaluate(()=>glob2Diagnostics.campaignDigest('Tutorial_Campaign.txt')); +async function tutorial(page) {await clickMainMenu(page,'tutorial');await screen(page,'CampaignMenuScreen');} +async function backup(page) { + const downloading=page.waitForEvent('download');await menu(page,230,495); + const download=await downloading;expect(download.suggestedFilename()).toBe('campaign-progress.campaign'); + return fs.readFile(await download.path()); +} +async function restore(page,buffer) { + const selecting=page.waitForEvent('filechooser');await menu(page,80,495); + await (await selecting).setFiles({name:'backup.campaign',mimeType:'application/octet-stream',buffer}); +} +// Produce a prior-progress fixture from an actual exported campaign definition. +// Tests deliver it through the user's file chooser, never by editing game state. +function completedFirst(original) { + const bytes=Buffer.from(original);let offset=8; + const text=()=>{const length=bytes.readUInt32BE(offset);offset+=4+length;}; + expect(bytes.subarray(0,4).toString()).toBe('G2CP'); + text();text();const count=bytes.readUInt32BE(offset);offset+=4;expect(count).toBeGreaterThan(0); + text();text();const prerequisites=bytes.readUInt32BE(offset);offset+=4; + for(let i=0;i{await page.goto(gameURL());await screen(page,'MainMenuScreen');}); + +test('campaign progress merges through the chooser, survives reload and rejects invalid backups',async({page},info)=>{ + await tutorial(page); + const initial=await backup(page), completed=completedFirst(initial); + await restore(page,completed);await expect.poll(async()=>(await state(page)).import).toBe('succeeded'); + expect(await backup(page)).toEqual(completed); + await page.screenshot({path:info.outputPath('campaign-progress-import.png')}); + await page.reload();await screen(page,'MainMenuScreen');await tutorial(page); + expect(await backup(page)).toEqual(completed); + const future=Buffer.from(completed);future.writeUInt32BE(2,4); + const mismatched=Buffer.from(completed);mismatched[12]^=1; // campaign identity + for(const invalid of [completed.subarray(0,3),completed.subarray(0,completed.length-1),future,mismatched]) { + await restore(page,invalid);await expect.poll(async()=>(await state(page)).import).toBe('invalid'); + expect(await backup(page)).toEqual(completed); + } + await restore(page,initial);await expect.poll(async()=>(await state(page)).import).toBe('succeeded'); + expect(await backup(page)).toEqual(completed); // importing an older backup cannot erase progress +}); + +for(const recovery of ['retry','discard']) test(`campaign persistence failure preserves the durable backup with ${recovery}`,async({page,context},info)=>{ + await tutorial(page);const initial=await backup(page); + await menu(page,480,450);await screen(page,'MainMenuScreen'); + const original=await stored(page);expect(original).not.toBeNull(); + await tutorial(page); + await page.evaluate(()=>{ + window.campaignQuota=true; + const put=IDBObjectStore.prototype.put; + IDBObjectStore.prototype.put=function(...args){ + if(window.campaignQuota)throw new DOMException('Injected quota exhaustion','QuotaExceededError'); + return put.apply(this,args); + }; + }); + const completed=completedFirst(initial); + await restore(page,completed);await expect.poll(async()=>(await state(page)).import).toBe('failed'); + await screen(page,'CampaignMenuScreen'); + expect(await backup(page)).toEqual(completed); + await page.screenshot({path:info.outputPath('campaign-persistence-failure.png')}); + const recovered=await context.newPage();await recovered.goto(gameURL());await screen(recovered,'MainMenuScreen'); + expect(await stored(recovered)).toEqual(original);await recovered.close(); + await page.evaluate(()=>window.campaignQuota=false); + if(recovery==='retry') { + await menu(page,400,200);await expect.poll(async()=>(await state(page)).import).toBe('succeeded'); + } else { + await menu(page,480,450);await screen(page,'MainMenuScreen'); + await expect.poll(async()=>(await state(page)).persistence).toBe('persisted'); + } + await page.reload();await screen(page,'MainMenuScreen');await tutorial(page); + expect(await backup(page)).toEqual(recovery==='retry'?completed:initial); +}); + +test('leaving a new campaign waits for persistence and discard removes its uncommitted file',async({page})=>{ + await tutorial(page); + await page.evaluate(()=>{ + window.campaignQuota=true; + const put=IDBObjectStore.prototype.put; + IDBObjectStore.prototype.put=function(...args){ + if(window.campaignQuota)throw new DOMException('Injected quota exhaustion','QuotaExceededError'); + return put.apply(this,args); + }; + }); + await menu(page,480,450); + await expect.poll(async()=>(await state(page)).import).toBe('failed'); + await screen(page,'CampaignMenuScreen'); + expect(await stored(page)).not.toBeNull(); + await page.evaluate(()=>window.campaignQuota=false); + await menu(page,480,450);await screen(page,'MainMenuScreen'); + await expect.poll(async()=>(await state(page)).persistence).toBe('persisted'); + await page.reload();await screen(page,'MainMenuScreen'); + expect(await stored(page)).toBeNull(); +}); diff --git a/browser/tests/chooser-errors.spec.js b/browser/tests/chooser-errors.spec.js new file mode 100644 index 000000000..9101bc3e1 --- /dev/null +++ b/browser/tests/chooser-errors.spec.js @@ -0,0 +1,27 @@ +const {test, expect} = require('@playwright/test'); +const {clickMainMenu,gameURL}=require('./main-menu'); +const state = page => page.evaluate(() => glob2Diagnostics.snapshot()); +const screen = (page, name) => expect.poll(async () => (await state(page)).screen).toContain(name); +const click = (page, x, y) => page.locator('#canvas').click({position:{x,y}, delay:80}); + +test('corrupt local files leave the chooser responsive and cannot accept an old selection', async ({page}) => { + const errors = []; + page.on('pageerror', error => errors.push(String(error))); + await page.goto(gameURL()); await screen(page, 'MainMenuScreen'); + // Inject damaged local storage, rather than bypassing the import validator. + // The valid fixture is only selected for its header/preview, never played. + await page.evaluate(() => { + FS.writeFile('/home/web_user/.glob2/games/AAA_Valid.game', FS.readFile('/maps/balanced.map')); + FS.writeFile('/home/web_user/.glob2/games/AAB_Corrupt.game', new Uint8Array([98,97,100])); + }); + await clickMainMenu(page,'load'); await screen(page,'ChooseMapScreen'); + await click(page,380,280); + await click(page,380,300); + await page.locator('#canvas').press('Enter', {delay:80}); + await screen(page,'ChooseMapScreen'); + await page.setViewportSize({width:800,height:600}); + await expect.poll(async () => (await state(page)).width).toBe(800); + await page.locator('#canvas').press('Escape', {delay:80}); + await screen(page,'MainMenuScreen'); + expect(errors).toEqual([]); +}); diff --git a/browser/tests/editor-storage.spec.js b/browser/tests/editor-storage.spec.js new file mode 100644 index 000000000..b2479e0b8 --- /dev/null +++ b/browser/tests/editor-storage.spec.js @@ -0,0 +1,49 @@ +const {test,expect}=require('@playwright/test'); +const {clickMainMenu,gameURL}=require('./main-menu'); +const fs=require('node:fs/promises'); +const {createHash}=require('node:crypto'); +const state=page=>page.evaluate(()=>glob2Diagnostics.snapshot()); +const screen=(page,name)=>expect.poll(async()=>(await state(page)).screen).toContain(name); +const click=(page,x,y)=>page.locator('#canvas').click({position:{x,y},delay:80}); + +for(const fault of ['quota','aborted transaction']) test(`editor save before quit survives ${fault} with export and retry`,async({page,context},info)=>{ + await page.goto(gameURL());await screen(page,'MainMenuScreen'); + await clickMainMenu(page,'editor');await screen(page,'EditorMainMenu'); + await click(page,600,300);await screen(page,'NewMapScreen'); + await click(page,440,650);await screen(page,'MapEditorScreen'); + await page.locator('#canvas').press('Escape',{delay:80}); + await click(page,600,525);await screen(page,'MessageScreen'); + await click(page,390,570);await screen(page,'MapEditorScreen'); + await click(page,600,515);await page.locator('#canvas').press('Home'); + for(let i=0;i<40;i++)await page.locator('#canvas').press('Delete'); + await page.locator('#canvas').pressSequentially('Editor durability',{delay:20}); + await page.evaluate(fault=>{ + window.editorStorageFault=true; + const put=IDBObjectStore.prototype.put; + IDBObjectStore.prototype.put=function(...args){ + if(window.editorStorageFault){ + if(fault==='quota')throw new DOMException('Injected quota exhaustion','QuotaExceededError'); + this.transaction.abort(); + } + return put.apply(this,args); + }; + },fault); + await click(page,520,555); + await expect.poll(async()=>(await state(page)).persistence).toBe('failed'); + await screen(page,'MapEditorScreen'); + const digest=()=>page.evaluate(()=>glob2Diagnostics.mapDigest('Editor_durability.map')); + const local=await digest();expect(local).not.toBeNull(); + const downloadEvent=page.waitForEvent('download');await click(page,600,422); + const download=await downloadEvent;expect(download.suggestedFilename()).toBe('Editor_durability.map'); + const bytes=await fs.readFile(await download.path()); + expect({size:bytes.length,sha256:createHash('sha256').update(bytes).digest('hex')}).toEqual(local); + await page.screenshot({path:info.outputPath('editor-save-failure.png')}); + const restored=await context.newPage();await restored.goto(gameURL());await screen(restored,'MainMenuScreen'); + expect(await restored.evaluate(()=>glob2Diagnostics.mapDigest('Editor_durability.map'))).toBeNull(); + await restored.close(); + await page.evaluate(()=>window.editorStorageFault=false); + await click(page,520,555);await screen(page,'EditorMainMenu'); + await expect.poll(async()=>(await state(page)).persistence).toBe('persisted'); + const saved=await digest();expect(saved).not.toBeNull(); + await page.reload();await screen(page,'MainMenuScreen');expect(await digest()).toEqual(saved); +}); diff --git a/browser/tests/file-selection.spec.js b/browser/tests/file-selection.spec.js new file mode 100644 index 000000000..b355e220d --- /dev/null +++ b/browser/tests/file-selection.spec.js @@ -0,0 +1,34 @@ +const {test, expect} = require('@playwright/test'); +const path = require('node:path'); + +// Exercise real browser file inputs; selection is owned by the application host, +// independently of the screen that will validate and persist the selected bytes. +test.beforeEach(async ({page}) => { + await page.setContent(''); + await page.addScriptTag({path:path.resolve(__dirname, '../file-selection.js')}); + await page.evaluate(() => { + document.querySelector('button').onclick = () => { + window.selection = new Glob2FileSelection(['game']); + selection.pick(document); + }; + }); +}); + +test('real file chooser returns exact bytes and releases its input', async ({page}) => { + const chooser = page.waitForEvent('filechooser'); + await page.getByRole('button', {name:'Import'}).click(); + await (await chooser).setFiles({name:'backup.game', mimeType:'application/octet-stream', buffer:Buffer.from([0,255,1,128])}); + await expect.poll(() => page.evaluate(() => selection.state)).toBe('selected'); + expect(await page.evaluate(() => ({name:selection.file.name, bytes:[...selection.file.bytes]}))) + .toEqual({name:'backup.game', bytes:[0,255,1,128]}); + await expect(page.locator('input[type=file]')).toHaveCount(0); +}); + +test('file chooser accept filter does not bypass file type validation', async ({page}) => { + const chooser = page.waitForEvent('filechooser'); + await page.getByRole('button', {name:'Import'}).click(); + await (await chooser).setFiles({name:'backup.exe', mimeType:'application/octet-stream', buffer:Buffer.from('invalid')}); + await expect.poll(() => page.evaluate(() => selection.state)).toBe('failed'); + expect(await page.evaluate(() => selection.file)).toBeNull(); + await expect(page.locator('input[type=file]')).toHaveCount(0); +}); diff --git a/browser/tests/import.spec.js b/browser/tests/import.spec.js new file mode 100644 index 000000000..8f13bfedc --- /dev/null +++ b/browser/tests/import.spec.js @@ -0,0 +1,133 @@ +const {test, expect} = require('@playwright/test'); +const {clickMainMenu,gameURL,clickCustomGameStart}=require('./main-menu'); +const fs = require('node:fs/promises'); +const path = require('node:path'); +const {createHash} = require('node:crypto'); +const state = page => page.evaluate(() => glob2Diagnostics.snapshot()); +const screen = (page, name) => expect.poll(async () => (await state(page)).screen).toContain(name); +const click = (page,x,y) => page.locator('#canvas').click({position:{x,y},delay:80}); +const menu = (page,x,y) => click(page,x+280,y+210); +const digest = bytes => ({size:bytes.length,sha256:createHash('sha256').update(bytes).digest('hex')}); +async function select(page,name,bytes) { + const chooser = page.waitForEvent('filechooser'); + await menu(page,60,485); + await (await chooser).setFiles({name,mimeType:'application/octet-stream',buffer:bytes}); +} +async function imported(page) { await expect.poll(async () => (await state(page)).import).toBe('succeeded'); } +async function chooseSave(page,name) { + const names=await page.evaluate(() => glob2Diagnostics.saves()); + const index=names.indexOf(name); + expect(index).toBeGreaterThanOrEqual(0); + await menu(page,100,70+16*index); +} +async function exportedSave(page) { + await clickMainMenu(page,'custom'); await screen(page,'CustomGameScreen'); + await clickCustomGameStart(page); // A fresh profile has a valid premade map preselected. + await expect.poll(async () => (await state(page)).tick).toBeGreaterThan(25); + await page.locator('#canvas').press('p',{delay:80}); + await expect.poll(async () => (await state(page)).paused).toBe(true); + await page.locator('#canvas').press('Escape',{delay:80}); + await click(page,600,400); await click(page,600,515); + await page.locator('#canvas').press('Home'); + for(let i=0;i<50;i++) await page.locator('#canvas').press('Delete'); + await page.locator('#canvas').pressSequentially('Original'); + await click(page,520,555); + await expect.poll(() => page.evaluate(() => glob2Diagnostics.saveDigest('Original.game'))).not.toBeNull(); + await expect.poll(async () => (await state(page)).persistence).toBe('persisted'); + await page.reload(); await screen(page,'MainMenuScreen'); + await clickMainMenu(page,'load'); await screen(page,'ChooseMapScreen'); await chooseSave(page,'Original.game'); + const download = page.waitForEvent('download'); await menu(page,340,320); + const file=await download; + expect(file.suggestedFilename()).toBe('Original.game'); + return fs.readFile(await file.path()); +} +test.beforeEach(async ({page}) => { await page.goto(gameURL()); await screen(page,'MainMenuScreen'); }); + +test('imports an exported save, preserves duplicate names, rejects corruption and reloads the imported game', async ({page},info) => { + const errors=[]; page.on('pageerror',error=>errors.push(String(error))); + const bytes=await exportedSave(page), expected=digest(bytes); + await select(page,'Original.game',bytes); await imported(page); + expect(await page.evaluate(() => glob2Diagnostics.saveDigest('Original.game'))).toEqual(expected); + expect(await page.evaluate(() => glob2Diagnostics.saveDigest('Original_(1).game'))).toEqual(expected); + await page.screenshot({path:info.outputPath('imported-save.png')}); + const badOffset=Buffer.from(bytes), offsetField=4+bytes.readUInt32BE(0)+12; + badOffset.writeUInt32BE(bytes.readUInt32BE(offsetField)+1,offsetField); + for(const corrupt of [badOffset,bytes.subarray(0,3),bytes.subarray(0,bytes.length-1),Buffer.concat([bytes,Buffer.from('extra')])]) { + await select(page,'Corrupt.game',corrupt); + await expect.poll(async () => (await state(page)).import).toBe('invalid'); + expect(await page.evaluate(() => glob2Diagnostics.saveDigest('Corrupt.game'))).toBeNull(); + } + await page.reload(); await screen(page,'MainMenuScreen'); + expect(await page.evaluate(() => glob2Diagnostics.saveDigest('Original_(1).game'))).toEqual(expected); + await clickMainMenu(page,'load'); await screen(page,'ChooseMapScreen'); + await chooseSave(page,'Original_(1).game'); await menu(page,530,380); + await expect.poll(async () => (await state(page)).screen).toBe('match'); + const loaded=(await state(page)).tick; + await expect.poll(async () => (await state(page)).tick).toBeGreaterThan(loaded+25); + expect(errors).toEqual([]); +}); + +test('imports a custom map and starts it through the normal setup screen', async ({page},info) => { + const bytes=await fs.readFile(path.resolve(__dirname,'../../maps/balanced.map')); + // CustomGameScreen has no import affordance of its own (#237 replaced its + // map browsing with "Premade maps"/"Your maps" library tabs); importing a + // map still only happens through the editor's "Load Map" chooser, which + // writes into the same maps/ library the lobby's "Your maps" tab lists. + await clickMainMenu(page,'editor'); await screen(page,'EditorMainMenu'); + await menu(page,320,150); await screen(page,'ChooseMapScreen'); + await select(page,'Imported.map',bytes); await imported(page); + expect(await page.evaluate(() => glob2Diagnostics.mapDigest('Imported.map'))).toEqual(digest(bytes)); + await page.screenshot({path:info.outputPath('imported-map.png')}); + // Leave without loading it into the editor; only the library entry matters here. + await page.locator('#canvas').press('Escape'); await screen(page,'EditorMainMenu'); + await page.locator('#canvas').press('Escape'); await screen(page,'MainMenuScreen'); + + await clickMainMenu(page,'custom'); await screen(page,'CustomGameScreen'); + await click(page,425,141); // "Your maps" library. + await click(page,291,176); // The imported map's row (only entry in a fresh profile). + await clickCustomGameStart(page); + await expect.poll(async () => (await state(page)).tick).toBeGreaterThan(25); +}); + +test('imports a complete replay and rejects a truncated command stream', async ({page}) => { + const bytes=await fs.readFile(path.resolve(__dirname,'../../tests/baselines/cross-replay.replay')); + await clickMainMenu(page,'load'); await screen(page,'ChooseMapScreen'); await menu(page,340,440); + await select(page,'Broken.replay',bytes.subarray(0,bytes.length-1)); + await expect.poll(async () => (await state(page)).import).toBe('invalid'); + await select(page,'Imported.replay',bytes); await imported(page); + expect(await page.evaluate(() => glob2Diagnostics.replayDigest('Imported.replay'))).toEqual(digest(bytes)); + const download=page.waitForEvent('download'); await menu(page,340,320); + expect(digest(await fs.readFile(await (await download).path()))).toEqual(digest(bytes)); + await menu(page,530,380); + await expect.poll(async () => (await state(page)).screen).toBe('match'); + const loaded=(await state(page)).tick; + await expect.poll(async () => (await state(page)).tick).toBeGreaterThan(loaded+25); +}); + +test('failed import persistence offers export and retry without overwriting a save', async ({page,context},info) => { + const bytes=await exportedSave(page); + await page.evaluate(() => { + window.importQuota = false; + const put=IDBObjectStore.prototype.put; + IDBObjectStore.prototype.put=function(...args) { + if(window.importQuota) throw new DOMException('Injected quota exhaustion','QuotaExceededError'); + return put.apply(this,args); + }; + window.importQuota=true; + }); + await select(page,'Original.game',bytes); + await expect.poll(async () => (await state(page)).import).toBe('failed'); + await page.screenshot({path:info.outputPath('import-persistence-failure.png')}); + expect(await page.evaluate(() => glob2Diagnostics.saveDigest('Original.game'))).toEqual(digest(bytes)); + const download=page.waitForEvent('download'); await menu(page,340,320); + expect(digest(await fs.readFile(await (await download).path()))).toEqual(digest(bytes)); + const restored=await context.newPage(); + await restored.goto(gameURL()); await screen(restored,'MainMenuScreen'); + expect(await restored.evaluate(() => glob2Diagnostics.saveDigest('Original.game'))).toEqual(digest(bytes)); + expect(await restored.evaluate(() => glob2Diagnostics.saveDigest('Original_(1).game'))).toBeNull(); + await restored.close(); + await page.evaluate(() => window.importQuota=false); + await menu(page,60,485); await imported(page); + await page.reload(); await screen(page,'MainMenuScreen'); + expect(await page.evaluate(() => glob2Diagnostics.saveDigest('Original_(1).game'))).toEqual(digest(bytes)); +}); diff --git a/browser/tests/input.spec.js b/browser/tests/input.spec.js new file mode 100644 index 000000000..83fb27a89 --- /dev/null +++ b/browser/tests/input.spec.js @@ -0,0 +1,50 @@ +const {test,expect}=require('@playwright/test'); +const {clickMainMenu,gameURL,clickCustomGameStart}=require('./main-menu'); +const state=page=>page.evaluate(()=>glob2Diagnostics.snapshot()); +const screen=(page,name)=>expect.poll(async()=>(await state(page)).screen).toContain(name); +const click=(page,x,y)=>page.locator('#canvas').click({position:{x,y},delay:80}); + +test('reload and address-bar shortcuts remain available with game focus',async({page})=>{ + await page.goto(gameURL());await screen(page,'MainMenuScreen'); + // Synthetic events cannot invoke browser chrome. They do exercise the real + // installed SDL listeners and expose whether those cancel the browser action. + const results=await page.evaluate(()=>{ + const canvas=document.getElementById('canvas');canvas.focus(); + const results=[]; + for(const modifier of ['ctrlKey','metaKey'])for(const key of ['r','l']) { + for(const shiftKey of [false,true]) { + const options={key,code:'Key'+key.toUpperCase(),[modifier]:true,shiftKey, + bubbles:true,cancelable:true}; + for(const type of ['keydown','keypress']) { + const event=new KeyboardEvent(type,options); + canvas.dispatchEvent(event); + results.push({modifier,key,shiftKey,type,cancelled:event.defaultPrevented}); + } + canvas.dispatchEvent(new KeyboardEvent('keyup',options)); + } + } + return results; + }); + expect(results.filter(result=>result.cancelled)).toEqual([]); + // Ordinary game controls still reach SDL after the shortcut attempts. + await clickMainMenu(page,'custom');await screen(page,'CustomGameScreen'); + await page.locator('#canvas').press('Escape',{delay:80}); + await screen(page,'MainMenuScreen'); +}); + +test('click coordinates stay correct when browser motion delivery is missing',async({page})=>{ + await page.goto(gameURL());await screen(page,'MainMenuScreen'); + await clickMainMenu(page,'custom');await screen(page,'CustomGameScreen'); + // Inject a lost/coalesced motion delivery, while keeping actual player button + // input. SDL must receive the button's own position, not its last hover point. + await page.evaluate(()=>document.addEventListener('mousemove',event=>{ + if(event.isTrusted)event.stopImmediatePropagation(); + },true)); + await clickCustomGameStart(page); // A fresh profile has a valid premade map preselected. + await expect.poll(async()=>(await state(page)).tick).toBeGreaterThan(25); + await page.setViewportSize({width:1000,height:700}); + await expect.poll(async()=>(await state(page)).width).toBe(1000); + await page.locator('#canvas').press('Escape',{delay:80}); + await expect.poll(()=>require('./pixels').hasLightText(page,{x:360,y:382,width:280,height:34})).toBe(true); + await click(page,500,400);await screen(page,'EndGameScreen'); +}); diff --git a/browser/tests/main-menu.js b/browser/tests/main-menu.js new file mode 100644 index 000000000..62e39f19e --- /dev/null +++ b/browser/tests/main-menu.js @@ -0,0 +1,90 @@ +const point = (width, height, action) => { + const compact = height < 640; + const panelX = Math.max(20, Math.min(72, Math.floor(width / 20))); + const panelH = Math.min(height - 40, 620); + const panelY = Math.floor((height - panelH) / 2); + const panelW = compact ? 312 : 368; + const x = panelX + 24; + const w = panelW - 48; + let y = panelY + (compact ? 74 : 110); + const entries = {}; + const add = (name, h) => { + entries[name] = {x: x + Math.floor(w / 2), y: y + Math.floor(h / 2)}; + y += h; + }; + add('custom', compact ? 38 : 46); + y += 8; + add('campaign', compact ? 30 : 38); + y += 6; + add('load', compact ? 30 : 38); + y += 6; + add('tutorial', compact ? 30 : 38); + y += compact ? 12 : 18; + add('yog', compact ? 28 : 34); + y += 4 + (compact ? 12 : 18); + const utilityH = compact ? 28 : 32; + const utilityW = Math.floor(w / 2) - 4; + for (const [index, name] of ['settings', 'editor', 'credits', 'quit'].entries()) { + entries[name] = { + x: x + (index % 2) * (Math.floor(w / 2) + 4) + Math.floor(utilityW / 2), + y: y + Math.floor(index / 2) * (utilityH + 4) + Math.floor(utilityH / 2), + }; + } + return entries[action]; +}; + +exports.gameURL = () => { + const entry = process.env.GLOB2_TEST_ENTRY_PATH || '/'; + const renderer = process.env.GLOB2_TEST_RENDERER; + if (!renderer) return entry; + if (!['software', 'webgl2'].includes(renderer)) { + throw new Error('Unknown test renderer: ' + renderer); + } + const url = new URL(entry, 'http://localhost'); + url.searchParams.set('renderer', renderer); + return url.pathname + url.search; +}; + +exports.clickMainMenu = async (page, action) => { + const {width, height} = page.viewportSize(); + return page.locator('#canvas').click({position: point(width, height, action), delay: 80}); +}; + +// Mirrors SettingsScreen::layout()'s panel/footer math (src/SettingsScreenLayout.cpp) +// for the footer's two buttons. Assumes the footer status line stays on one +// line, which holds at every viewport this suite resizes to while Settings +// is open; a panel narrower than ~500px could wrap it and shift these. +const settingsFooter = (width, height) => { + const panelW = Math.min(width - 32, 960); + const panelH = Math.min(height - 32, 720); + const panelX = Math.floor((width - panelW) / 2); + const panelY = Math.floor((height - panelH) / 2); + const footH = 64; + const footerX = panelX, footerY = panelY + panelH - footH, footerW = panelW; + const doneX = footerX + footerW - 112, doneY = footerY + 12; + return { + done: {x: doneX + 48, y: doneY + 20}, + // Always visible, and always closes Settings in one click regardless of + // any save failure — see SettingsScreen::abandon(). + cancel: {x: doneX - 52, y: doneY + 20}, + }; +}; +exports.settingsFooter = settingsFooter; +exports.clickSettingsDone = (page) => { + const {width, height} = page.viewportSize(); + return page.locator('#canvas').click({position: settingsFooter(width, height).done, delay: 80}); +}; +exports.clickSettingsCancel = (page) => { + const {width, height} = page.viewportSize(); + return page.locator('#canvas').click({position: settingsFooter(width, height).cancel, delay: 80}); +}; + +// Mirrors CustomGameScreen::renderLobby()'s "start" button rect +// (src/CustomGameScreen.cpp). A fresh profile auto-selects a valid premade +// map (FourSquares1), so this alone is enough to launch a match — no map +// or player pick required first. +exports.clickCustomGameStart = (page) => { + const {width, height} = page.viewportSize(); + const w = Math.min(width - 32, 1120), x = Math.floor((width - w) / 2); + return page.locator('#canvas').click({position: {x: x + w - 165 + 82, y: height - 52 + 17}, delay: 80}); +}; diff --git a/browser/tests/multiplayer.spec.js b/browser/tests/multiplayer.spec.js new file mode 100644 index 000000000..7595aacac --- /dev/null +++ b/browser/tests/multiplayer.spec.js @@ -0,0 +1,482 @@ +const {gameURL,clickMainMenu}=require('./main-menu'); +const {test, expect} = require('@playwright/test'); +// Continuous trace screenshots force readback from both WebGL contexts on each +// input action. Keep diagnostic traces and capture gameplay explicitly below. +test.use({trace:{mode:'retain-on-failure', screenshots:false, snapshots:true, sources:true}}); +const {spawn} = require('node:child_process'); +const {createInterface} = require('node:readline'); +const {mkdtemp, rm, readFile} = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const {randomUUID} = require('node:crypto'); + +const root = path.resolve(__dirname, '../..'); +const platform = os.platform() === 'win32' ? 'windows' : os.platform(); +let lobby, gateway, tlsForwarder, work, profile, endpoint, secureEndpoint; +async function start(binary, args, ready) { + const child = spawn(binary, args, {cwd: work, stdio: ['ignore', 'pipe', 'pipe']}); + try { + const line = await new Promise((resolve, reject) => { + const lines = createInterface({input: child.stdout}); + const timer = setTimeout(() => reject(new Error('Server readiness timed out')), 15000); + let errors = ''; + child.stderr.on('data', chunk => { errors = (errors + chunk).slice(-4000); }); + child.once('error', reject); + child.once('exit', code => reject(new Error(`Server exited ${code}: ${errors}`))); + lines.on('line', line => { if (ready(line)) { clearTimeout(timer); lines.close(); resolve(line); } }); + }); + return {child, line}; + } catch (error) { child.kill(); throw error; } +} +async function stop(child) { + if (!child || child.exitCode !== null || child.signalCode !== null) return; + await new Promise(resolve => { child.once('exit', resolve); child.kill(); }); +} + +test.beforeAll(async ({baseURL}) => { + work = await mkdtemp(path.join(os.tmpdir(), 'glob2-yog-')); + profile = 'glob2-yog-test-' + randomUUID(); + lobby = (await start(path.join(root, `build/${platform}/client/release/src/net-connection-test`), + ['--serve', profile], line => line === 'YOG test server ready')).child; + const started = await start(path.join(root, `build/${platform}/gateway/release/glob2-ws-gateway`), + ['--port', '0', '--origin', new URL(baseURL).origin], line => line.startsWith('gateway listening on ')); + gateway = started.child; + const port = started.line.split(':').pop().trim(); + endpoint = 'ws://127.0.0.1:' + port; + const tls = await start('python3', [path.join(root, 'tests/transport/tls_forwarder.py'), work, port], + line => line.startsWith('TLS forwarder listening on ')); + tlsForwarder = tls.child; + secureEndpoint = 'wss://localhost:' + tls.line.split(' ').pop(); +}); +test.afterAll(async () => { + await stop(tlsForwarder); await stop(gateway); await stop(lobby); + if (work) await rm(work, {recursive: true, force: true}); + if (profile) await rm(path.join(os.homedir(), '.' + profile), {recursive: true, force: true}); +}); + +test('browser YOG login exchanges the native protocol through the real gateway', async ({page}) => { + await page.addInitScript(base => { globalThis.glob2Config = {websocketBase: base}; }, endpoint); + const received = [], sent = [], errors = []; + page.on('pageerror', error => errors.push(String(error))); + // Observe actual wire bytes; player actions below use only the real controls. + page.on('websocket', socket => { + socket.on('framesent', ({payload}) => sent.push(Buffer.from(payload))); + socket.on('framereceived', ({payload}) => received.push(Buffer.from(payload))); + }); + const types = chunks => { + const bytes = Buffer.concat(chunks), result = []; + for (let offset = 0; offset + 2 <= bytes.length;) { + const length = bytes.readUInt16BE(offset); + if (length === 0 || offset + 2 + length > bytes.length) break; + result.push(bytes[offset + 2]); offset += length + 2; + } + return result; + }; + const screen = name => expect.poll(async () => (await page.evaluate(() => glob2Diagnostics.snapshot())).screen).toContain(name); + const click = (x, y) => page.locator('#canvas').click({position: {x, y}, delay: 80}); + await page.goto(gameURL()); await screen('MainMenuScreen'); + await clickMainMenu(page,'yog'); await screen('YOGLoginScreen'); + await click(420, 510); + await page.locator('#canvas').press('Home'); + for (let i = 0; i < 32; ++i) await page.locator('#canvas').press('Delete'); + await page.keyboard.type('transportfixture'); + await click(810, 590); + // Server information, then refusal of an unregistered test account. This + // proves bidirectional native/Wasm codecs without publishing or using accounts. + await expect.poll(() => types(received)).toContain(10); + await expect.poll(() => types(sent)).toContain(9); + await expect.poll(() => types(sent)).toContain(1); + await expect.poll(() => types(received)).toContain(7); + await click(810, 650); await screen('MainMenuScreen'); + expect(errors).toEqual([]); +}); + +test('registered browser player enters and leaves the native YOG lobby', async ({page}, testInfo) => { + await page.addInitScript(base => { globalThis.glob2Config = {websocketBase: base}; }, endpoint); + const errors = []; + page.on('pageerror', error => errors.push(String(error))); + const screen = name => expect.poll(async () => (await page.evaluate(() => glob2Diagnostics.snapshot())).screen).toContain(name); + const click = (x, y) => page.locator('#canvas').click({position: {x, y}, delay: 80}); + await page.goto(gameURL()); await screen('MainMenuScreen'); + await clickMainMenu(page,'yog'); await screen('YOGLoginScreen'); + await click(420, 510); + await page.locator('#canvas').press('Home'); + for (let i = 0; i < 32; ++i) await page.locator('#canvas').press('Delete'); + await page.keyboard.type('transportplayer'); + await click(420, 580); + await page.locator('#canvas').press('Home'); + for (let i = 0; i < 32; ++i) await page.locator('#canvas').press('Delete'); + await page.keyboard.type('fixture-only'); + await click(810, 590); + await screen('YOGSessionScreen'); + await page.screenshot({path: testInfo.outputPath('yog-lobby.png')}); + await page.locator('#canvas').press('Escape'); + await screen('MainMenuScreen'); + expect(errors).toEqual([]); +}); + +async function loginPlayer(page, name) { + await page.addInitScript(base => { globalThis.glob2Config = {websocketBase: base}; }, endpoint); + const screen = target => expect.poll(async () => (await page.evaluate(() => glob2Diagnostics.snapshot())).screen).toContain(target); + const click = (x, y) => page.locator('#canvas').click({position: {x, y}, delay: 80}); + await page.goto(gameURL()); await screen('MainMenuScreen'); + await clickMainMenu(page,'yog'); await screen('YOGLoginScreen'); + for (const [y, value] of [[510, name], [580, 'fixture-only']]) { + await click(420, y); await page.locator('#canvas').press('Home'); + for (let i = 0; i < 32; ++i) await page.locator('#canvas').press('Delete'); + await page.keyboard.type(value); + } + await click(810, 590); await screen('YOGSessionScreen'); +} +test('YOG registration remains scheduled through resize and cancellation', async ({page}) => { + const screen = name => expect.poll(async () => (await page.evaluate(() => glob2Diagnostics.snapshot())).screen).toContain(name); + const click = (x,y) => page.locator('#canvas').click({position:{x,y},delay:80}); + await page.goto(gameURL()); await screen('MainMenuScreen'); + await clickMainMenu(page,'yog'); await screen('YOGLoginScreen'); + for(const viewport of [{width:1000,height:700},{width:1200,height:900}]) { + const previous = page.viewportSize(); + await click(previous.width/2+210,previous.height/2+80); + await screen('YOGRegisterScreen'); + await page.setViewportSize(viewport); + await expect.poll(async () => (await page.evaluate(() => glob2Diagnostics.snapshot())).width).toBe(viewport.width); + await click(viewport.width/2+210,viewport.height/2+200); + await screen('YOGLoginScreen'); + } + await page.locator('#canvas').press('Escape'); await screen('MainMenuScreen'); +}); + +test('YOG map selection and upload screens resize and return to their owning tabs', async ({page}) => { + const screen = name => expect.poll(async () => (await page.evaluate(() => glob2Diagnostics.snapshot())).screen).toContain(name); + const click = (x,y) => page.locator('#canvas').click({position:{x,y},delay:80}); + await loginPlayer(page,'transportplayer'); + await click(1090,815); await screen('ChooseMapScreen'); + await page.setViewportSize({width:1000,height:700}); + await expect.poll(async () => (await page.evaluate(() => glob2Diagnostics.snapshot())).width).toBe(1000); + await page.locator('#canvas').press('Escape'); await screen('YOGSessionScreen'); + // The Maps tab and its Upload action use the same scheduled child ownership. + await click(480,90); await click(890,615); await screen('ChooseMapScreen'); + await click(280,180); await click(710,490); await screen('YOGClientMapUploadScreen'); + await page.setViewportSize({width:1200,height:900}); + await expect.poll(async () => (await page.evaluate(() => glob2Diagnostics.snapshot())).width).toBe(1200); + await page.locator('#canvas').press('Escape'); await screen('YOGSessionScreen'); + await page.locator('#canvas').press('Escape'); await screen('MainMenuScreen'); +}); + +test('YOG match settings resize and return to their room', async ({page}) => { + const types = receivedTypes(page); + const screen = name => expect.poll(async () => (await page.evaluate(() => glob2Diagnostics.snapshot())).screen).toContain(name); + const click = (x,y) => page.locator('#canvas').click({position:{x,y},delay:80}); + await loginPlayer(page,'transportplayer'); + await click(1090,815); await screen('ChooseMapScreen'); + await click(380,280); await click(810,590); + await expect.poll(types).toContain(16); + await click(1090,435); await screen('CustomGameOtherOptions'); + await page.setViewportSize({width:1000,height:700}); + await expect.poll(async () => (await page.evaluate(() => glob2Diagnostics.snapshot())).width).toBe(1000); + await page.locator('#canvas').press('Escape'); await screen('YOGSessionScreen'); + const lists = types().filter(type => type === 50).length; + await click(890,525); + await expect.poll(() => types().filter(type => type === 50).length).toBeGreaterThan(lists); + await click(890,665); await screen('MainMenuScreen'); +}); + +test('YOG disconnect message remains scheduled and returns cleanly after resize', async ({page}) => { + let connection, backend; + await page.routeWebSocket('**/yog', route => { connection=route; backend=route.connectToServer(); }); + await loginPlayer(page,'transportplayer'); + await connection.close(); await backend.close(); + const screen = name => expect.poll(async () => (await page.evaluate(() => glob2Diagnostics.snapshot())).screen).toContain(name); + await screen('MessageScreen'); + await page.setViewportSize({width:1000,height:700}); + await expect.poll(async () => (await page.evaluate(() => glob2Diagnostics.snapshot())).width).toBe(1000); + await page.locator('#canvas').press('Escape'); await screen('MainMenuScreen'); +}); + +function receivedTypes(page, direction = 'framereceived', decode = body => body[0]) { + const result = []; + page.on('websocket', socket => { + let bytes = Buffer.alloc(0); + socket.on(direction, ({payload}) => { + bytes = Buffer.concat([bytes, Buffer.from(payload)]); + while (bytes.length >= 2) { + const size = bytes.readUInt16BE(0); + if (!size || bytes.length < size + 2) break; + result.push(decode(bytes.subarray(2, size + 2))); bytes = bytes.subarray(size + 2); + } + }); + }); + return () => result; +} + +function sentChecksums(page) { + const checksums = []; + page.on('websocket', socket => { + let bytes = Buffer.alloc(0); + socket.on('framesent', ({payload}) => { + bytes = Buffer.concat([bytes, Buffer.from(payload)]); + while (bytes.length >= 2) { + const size = bytes.readUInt16BE(0); + if (!size || bytes.length < size + 2) break; + const message = bytes.subarray(2, size + 2); + if (message[0] === 44 && message.length >= 11) { // NetSendOrder + const checksum = message.readUInt32BE(message.length - 4); + if (checksum !== 0xffffffff) checksums.push(checksum); + } + bytes = bytes.subarray(size + 2); + } + }); + }); + return checksums; +} + +const multiplayerAIs = ['no AI', 'Numbi', 'Castor', 'Warrush', 'ReachToInfinity', 'Nicowar', 'Cortex'] + .map((name, id) => ({name, id})) + .filter(ai => process.env.GLOB2_ALL_AIS === '1' || ai.id === 0 || ai.id === 6); +for (const ai of multiplayerAIs) +test(`two browser players create, join and start a YOG match (${ai.name})`, async ({page, browser, baseURL}, testInfo) => { + // Two WebGL clients share the headless browser's software GPU. This is a + // correctness fixture; controlled performance gates use a reference GPU. + test.setTimeout(180000); + const other = await browser.newContext({baseURL, viewport: {width: 1200, height: 900}}); + const guest = await other.newPage(); + try { + const hostTypes = receivedTypes(page), guestTypes = receivedTypes(guest); + const guestSent = receivedTypes(guest, 'framesent'), hostSent = receivedTypes(page, 'framesent'); + const hostChecksums = sentChecksums(page), guestChecksums = sentChecksums(guest); + const errors = []; + for (const target of [page, guest]) target.on('pageerror', error => errors.push(String(error))); + const click = (target, x, y) => target.locator('#canvas').click({position: {x, y}, delay: 80}); + await loginPlayer(page, 'transportplayer'); + await loginPlayer(guest, 'transportguest'); + const lists = guestTypes().filter(type => type === 50).length; + await click(page, 1090, 815); + await expect.poll(async () => (await page.evaluate(() => glob2Diagnostics.snapshot())).screen).toContain('ChooseMapScreen'); + await click(page, 380, 280); await click(page, 810, 590); + await expect.poll(hostTypes).toContain(16); // NetCreateGameAccepted + await expect.poll(() => guestTypes().filter(type => type === 50).length).toBeGreaterThan(lists); + await click(guest, 100, 130); await click(guest, 1090, 245); + await expect.poll(guestTypes).toContain(18); // NetGameJoinAccepted + await expect.poll(guestSent).toContain(47); // NetSetGameInRouter + await expect.poll(async () => (await guest.evaluate(() => glob2Diagnostics.snapshot())).screen).toContain('YOGSessionScreen'); + await guest.screenshot({path: testInfo.outputPath('joined-room.png')}); + if (ai.id) { + await click(page, 1090, 410 - 30 * (ai.id - 1)); + await expect.poll(hostSent).toContain(12); // NetAddAI + } + const ready = hostTypes().filter(type => type === 26).length; + await click(guest, 1170, 455); + await expect.poll(guestSent).toContain(26); + await expect.poll(() => hostTypes().filter(type => type === 26).length).toBeGreaterThan(ready); + await click(page, 1090, 475); + for (const target of [page, guest]) + await expect.poll(async () => (await target.evaluate(() => glob2Diagnostics.snapshot())).tick, {timeout:60000}).toBeGreaterThan(100); + for (const target of [page, guest]) + expect((await target.evaluate(() => glob2Diagnostics.snapshot())).screenClass).toContain('GameSessionScreen'); + await expect.poll(() => Math.min(hostChecksums.length, guestChecksums.length)).toBeGreaterThanOrEqual(25); + const count = Math.min(hostChecksums.length, guestChecksums.length); + expect(count).toBeGreaterThanOrEqual(25); + expect(hostChecksums.slice(0, count)).toEqual(guestChecksums.slice(0, count)); + expect(errors).toEqual([]); + await testInfo.attach('matching-order-checksums', {body: JSON.stringify({count, checksums: hostChecksums.slice(0, count)}), contentType: 'application/json'}); + await page.screenshot({path: testInfo.outputPath('multiplayer-match.png')}); + // Complete both clients' normal end-game flow instead of ending the fixture + // by closing browser contexts while the match is still running. + for (const target of [page, guest]) { + await target.locator('#canvas').press('Escape',{delay:80}); + await expect.poll(() => require('./pixels').hasLightText(target, {x:460,y:482,width:280,height:34})).toBe(true); + await click(target, 600, 500); + await expect.poll(async () => (await target.evaluate(() => glob2Diagnostics.snapshot())).screen).toContain('EndGameScreen'); + await target.locator('#canvas').press('Enter'); + await expect.poll(async () => (await target.evaluate(() => glob2Diagnostics.snapshot())).screen).toContain('YOGSessionScreen'); + } + expect(errors).toEqual([]); + } finally { await other.close(); } +}); + + +for (const transport of ['TCP', 'WSS']) +test(`browser and native players complete matching simulation checkpoints (${transport})`, async ({page}, testInfo) => { + const nativeProfile = 'glob2-native-peer-' + randomUUID(); + let peer; + const peerLog = []; + try { + const hostTypes = receivedTypes(page), checksums = sentChecksums(page); + const readyPlayers = receivedTypes(page, 'framereceived', body => body[0] === 26 ? body.readUInt16BE(1) : null); + const click = (x, y) => page.locator('#canvas').click({position: {x, y}, delay: 80}); + await loginPlayer(page, 'transportplayer'); + await click(1090, 815); + await expect.poll(async () => (await page.evaluate(() => glob2Diagnostics.snapshot())).screen).toContain('ChooseMapScreen'); + await click(380, 280); await click(810, 590); + await expect.poll(hostTypes).toContain(16); + const started = await start(path.join(root, `build/${platform}/client/release/src/native-multiplayer-peer`), + transport === 'WSS' ? [nativeProfile, secureEndpoint, path.join(work, 'cert.pem')] : [nativeProfile], + line => line.startsWith('native peer joined order-rate=')); + peer = started.child; + peer.stdout.on('data', chunk => peerLog.push(String(chunk))); + peer.stderr.on('data', chunk => peerLog.push(String(chunk))); + const nativePlayerID = Number(/player-id=(\d+)/.exec(started.line)?.[1]); + expect(nativePlayerID).toBeGreaterThan(0); + // A single native Ready message is sufficient; repeated host readiness + // transitions depend on timing and are not part of the admission contract. + await expect.poll(readyPlayers).toContain(nativePlayerID); + // Wire observation precedes the next SDL frame; wait for the actual control. + await expect.poll(async () => (await page.evaluate(() => glob2Diagnostics.snapshot())).roomCanStart).toBe(true); + await click(1090, 475); + await expect.poll(async () => (await page.evaluate(() => glob2Diagnostics.snapshot())).tick).toBeGreaterThan(125); + expect((await page.evaluate(() => glob2Diagnostics.snapshot())).screenClass).toContain('GameSessionScreen'); + await page.screenshot({path: testInfo.outputPath('native-cross-play.png')}); + await expect.poll(async () => (await page.evaluate(() => glob2Diagnostics.snapshot())).tick).toBeGreaterThan(250); + await page.locator('#canvas').press('Escape',{delay:80}); + await expect.poll(() => require('./pixels').hasLightText(page, {x:460,y:482,width:280,height:34})).toBe(true); + await click(600, 500); + await expect.poll(async () => (await page.evaluate(() => glob2Diagnostics.snapshot())).screen).toContain('EndGameScreen'); + await expect.poll(() => peer.exitCode !== null || peer.signalCode !== null, {timeout:45000}).toBe(true); + expect(peer.signalCode).toBeNull(); + expect(peer.exitCode).toBe(0); + const bytes = await readFile(path.join(os.homedir(), '.' + nativeProfile, 'replays/last_game.replay.checksums')); + const teams = bytes.readUInt32LE(4), count = bytes.readUInt32LE(12); + expect(count).toBeGreaterThanOrEqual(250); + expect(count).toBeLessThan(1000); // Victory, not the native safety timeout. + const native = new Map(); + let offset = 20; + const u32 = () => { const value = bytes.readUInt32LE(offset); offset += 4; return value; }; + for (let i = 0; i < count; ++i) { + const tick = u32(), checksum = u32(); native.set(tick, checksum); + for (let team = 0; team < teams; ++team) { + u32(); // Team checksum. + for (let group = 0; group < 2; ++group) { + const objects = u32(); + for (let object = 0; object < objects; ++object) { + offset += 6; // Object ID and checksum. + const fields = u32(); offset += fields * 4; + } + } + } + } + expect(offset).toBe(bytes.length); + expect(checksums.length).toBeGreaterThanOrEqual(25); + await testInfo.attach('native-checkpoints', {body: bytes, contentType: 'application/octet-stream'}); + await testInfo.attach('browser-order-checksums', {body: JSON.stringify(checksums), contentType: 'application/json'}); + await testInfo.attach('checksum-alignment', {body: JSON.stringify({native: [...native].slice(0, 30), browser: checksums.slice(0, 8)}), contentType: 'application/json'}); + // YOG finalizes the order rate at match start, after room readiness. + const orderRate = Number(/native match order-rate=(\d+)/.exec(peerLog.join(''))?.[1]); + expect(orderRate).toBeGreaterThan(0); + // Align command-boundary checksums using the negotiated order rate. + const compared = Math.min(checksums.length, Math.ceil(count / orderRate)); + for (let i = 0; i < compared; ++i) expect(checksums[i]).toBe(native.get(i * orderRate)); + await page.locator('#canvas').press('Enter'); + await expect.poll(async () => (await page.evaluate(() => glob2Diagnostics.snapshot())).screen).toContain('YOGSessionScreen'); + + } finally { + await stop(peer); + await testInfo.attach('native-peer-log', {body: peerLog.join(''), contentType: 'text/plain'}); + await rm(path.join(os.homedir(), '.' + nativeProfile), {recursive: true, force: true}); + } +}); + +test('YOG admission enforces greeting, exact version, authentication and retry order', async ({page}) => { + const version = Number((await readFile(path.join(root,'src/Version.h'),'utf8')).match(/^#define NET_PROTOCOL_VERSION (\d+)/m)[1]); + await page.goto('/'); + for (const scenario of ['old version','future version','login before greeting','registration before greeting', + 'room before greeting','room before login','repeated greeting','repeated login','greeting after login','retry login']) { + const result = await page.evaluate(({endpoint,version,scenario}) => new Promise((resolve,reject) => { + const socket = new WebSocket(endpoint+'/yog'); socket.binaryType='arraybuffer'; + let pending = new Uint8Array(), done=false, opened=false; + const received=[]; + const timer=setTimeout(()=>finish(new Error('Admission did not finish: '+scenario)),5000); + function finish(error) { + if(done)return;done=true;clearTimeout(timer);socket.close(); + if(error)reject(error);else resolve(received); + } + const text = value => {const bytes=new TextEncoder().encode(value);return [bytes.length>>>24,(bytes.length>>>16)&255,(bytes.length>>>8)&255,bytes.length&255,...bytes];}; + const send = body => socket.send(Uint8Array.from([body.length>>>8,body.length&255,...body])); + const hello = offset => send([9,(version+offset)>>>8,(version+offset)&255]); + const login = password => send([1,...text('transportplayer'),...text(password)]); + const room = () => send([15,...text('Must not create')]); + socket.onopen=()=>{ + opened=true; + if(scenario==='old version')hello(-1); + else if(scenario==='future version')hello(1); + else if(scenario==='login before greeting')login('fixture-only'); + else if(scenario==='registration before greeting')send([2,...text('mustnotregister'),...text('fixture-only')]); + else if(scenario==='room before greeting')room(); + else hello(0); + }; + socket.onmessage=event=>{ + const incoming=new Uint8Array(event.data),combined=new Uint8Array(pending.length+incoming.length); + combined.set(pending);combined.set(incoming,pending.length);pending=combined; + while(pending.length>=2){ + const size=pending[0]*256+pending[1];if(pending.length{if(!opened)finish(new Error('WebSocket transport failed: '+scenario));}; + socket.onclose=()=>finish(opened?null:new Error('WebSocket never opened: '+scenario)); + }),{endpoint,version,scenario}); + const types=result.map(body=>body[0]); + if(scenario.endsWith('version')) { + expect(result).toContainEqual([7,5]);expect(types).not.toContain(10); + }else if(['repeated login','greeting after login','retry login'].includes(scenario)) { + expect(types.filter(type=>type===4)).toHaveLength(1); + if(scenario==='retry login')expect(types).toContain(7); + }else { + expect(types).not.toContain(4); + if(['room before login','repeated greeting'].includes(scenario))expect(types).toContain(10); + } + expect(types).not.toContain(0); // Invalid registration must not be accepted. + expect(types).not.toContain(16); // Invalid room creation must not be accepted. + } +}); + +for (const mismatch of ['client version','legacy server','server version']) +test(`browser reports incompatible release before transmitting credentials (${mismatch})`, async ({page}, info) => { + await page.addInitScript(base=>{globalThis.glob2Config={websocketBase:base};},endpoint); + const sent=[],received=[]; + await page.routeWebSocket('**/yog', route=>{ + const server=route.connectToServer(); + route.onMessage(message=>{ + const bytes=Buffer.from(message);sent.push(bytes[2]); + if(bytes[2]===9 && mismatch==='client version'){ + // Fault injection at the transport boundary; drive the real login UI. + bytes.writeUInt16BE(bytes.readUInt16BE(3)+1,3); + } + server.send(bytes); + }); + server.onMessage(message=>{ + let bytes=Buffer.from(message);received.push(bytes[2]); + if(bytes[2]===10 && mismatch==='legacy server'){ + bytes=bytes.subarray(0,bytes.length-2);bytes.writeUInt16BE(bytes.length-2,0); + }else if(bytes[2]===10 && mismatch==='server version')bytes.writeUInt16BE(bytes.readUInt16BE(7)+1,7); + route.send(bytes); + }); + }); + const screen=name=>expect.poll(async()=>(await page.evaluate(()=>glob2Diagnostics.snapshot())).screen).toContain(name); + const click=(x,y)=>page.locator('#canvas').click({position:{x,y},delay:80}); + await page.goto(gameURL());await screen('MainMenuScreen'); + await clickMainMenu(page,'yog');await screen('YOGLoginScreen'); + await click(420,580);await page.keyboard.type('never-transmit-this'); + await click(810,590); + await expect.poll(()=>received).toContain(mismatch==='client version'?7:10); + await expect.poll(()=>sent).toContain(3); // Client processed refusal and disconnected. + await screen('YOGLoginScreen'); + expect(sent).toContain(9);expect(sent).not.toContain(1);expect(sent).not.toContain(2); + await expect.poll(()=>require('./pixels').hasLightText(page,{x:305,y:345,width:590,height:110})).toBe(true); + await page.screenshot({path:info.outputPath('incompatible-release.png')}); + await click(810,650);await screen('MainMenuScreen'); +}); diff --git a/browser/tests/pixels.js b/browser/tests/pixels.js new file mode 100644 index 000000000..559ded645 --- /dev/null +++ b/browser/tests/pixels.js @@ -0,0 +1,71 @@ +// Screenshots capture the presented frame for both 2D and WebGL canvases; +// WebGL's non-preserved drawing buffer may already be cleared between frames. +async function hasRenderedPixels(page) { + const png = await page.locator('#canvas').screenshot(); + return page.evaluate(async base64 => { + const blob = await (await fetch('data:image/png;base64,' + base64)).blob(); + const bitmap = await createImageBitmap(blob); + const canvas = document.createElement('canvas'); + canvas.width = bitmap.width; canvas.height = bitmap.height; + const context = canvas.getContext('2d'); + context.drawImage(bitmap,0,0); + bitmap.close(); + return context.getImageData(0,0,canvas.width,canvas.height).data.some((value,index) => index % 4 !== 3 && value > 16); + }, png.toString('base64')); +} +module.exports = {hasRenderedPixels}; + +// Restrict to the inside of a text box, excluding its border and controls. +async function hasLightText(page, clip) { + const png = await page.screenshot({clip}); + return page.evaluate(async base64 => { + const blob = await (await fetch('data:image/png;base64,' + base64)).blob(); + const bitmap = await createImageBitmap(blob); + const canvas = document.createElement('canvas'); + canvas.width = bitmap.width; canvas.height = bitmap.height; + const context = canvas.getContext('2d'); context.drawImage(bitmap, 0, 0); bitmap.close(); + const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data; + let light = 0; + for (let i = 0; i < pixels.length; i += 4) + if (pixels[i] > 200 && pixels[i+1] > 200 && pixels[i+2] > 200) ++light; + return light > 50; + }, png.toString('base64')); +} +module.exports.hasLightText = hasLightText; + +// Same idea for the redesigned Settings/CustomGame panels: dark text on a +// pale paper background rather than light text on a dark overlay. +async function hasDarkText(page, clip) { + const png = await page.screenshot({clip}); + return page.evaluate(async base64 => { + const blob = await (await fetch('data:image/png;base64,' + base64)).blob(); + const bitmap = await createImageBitmap(blob); + const canvas = document.createElement('canvas'); + canvas.width = bitmap.width; canvas.height = bitmap.height; + const context = canvas.getContext('2d'); context.drawImage(bitmap, 0, 0); bitmap.close(); + const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data; + let dark = 0; + for (let i = 0; i < pixels.length; i += 4) + if (pixels[i] < 120 && pixels[i+1] < 120 && pixels[i+2] < 120) ++dark; + return dark > 50; + }, png.toString('base64')); +} +module.exports.hasDarkText = hasDarkText; + +// Share of a clip darker than those pale panels in every channel. +async function darkShare(page, clip) { + const png = await page.screenshot({clip}); + return page.evaluate(async base64 => { + const blob = await (await fetch('data:image/png;base64,' + base64)).blob(); + const bitmap = await createImageBitmap(blob); + const canvas = document.createElement('canvas'); + canvas.width = bitmap.width; canvas.height = bitmap.height; + const context = canvas.getContext('2d'); context.drawImage(bitmap, 0, 0); bitmap.close(); + const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data; + let dark = 0; + for (let i = 0; i < pixels.length; i += 4) + if (pixels[i] < 130 && pixels[i+1] < 130 && pixels[i+2] < 130) ++dark; + return dark / (pixels.length / 4); + }, png.toString('base64')); +} +module.exports.darkShare = darkShare; diff --git a/browser/tests/rendering.spec.js b/browser/tests/rendering.spec.js new file mode 100644 index 000000000..0b919e881 --- /dev/null +++ b/browser/tests/rendering.spec.js @@ -0,0 +1,143 @@ +const {test, expect} = require('@playwright/test'); +const {clickMainMenu,clickSettingsDone,clickCustomGameStart}=require('./main-menu'); +const state = page => page.evaluate(() => glob2Diagnostics.snapshot()); +const screen = (page, name) => expect.poll(async () => (await state(page)).screen).toContain(name); +const click = (page, x, y) => page.locator('#canvas').click({position:{x,y}, delay:80}); + +test('WebGL2 draws a playable match and resizes its drawing buffer', async ({page}, info) => { + const errors = []; + page.on('pageerror', error => errors.push(String(error))); + page.on('console', message => { if (/GL_INVALID|GL_INVALID_OPERATION|WebGL:.*(INVALID|error)|Aborted/.test(message.text())) errors.push(message.text()); }); + await page.goto('/?renderer=webgl2'); + await screen(page, 'MainMenuScreen'); + expect((await state(page)).renderer).toBe('webgl2'); + expect(await page.evaluate(() => document.querySelector('#canvas').getContext('webgl2') instanceof WebGL2RenderingContext)).toBe(true); + await clickMainMenu(page,'custom'); await screen(page, 'CustomGameScreen'); + await clickCustomGameStart(page); // A fresh profile has a valid premade map preselected. + await expect.poll(async () => (await state(page)).tick).toBeGreaterThan(25); + await page.setViewportSize({width:1280,height:720}); + await expect.poll(async () => { const s = await state(page); return [s.width,s.height]; }).toEqual([1280,720]); + expect(await page.evaluate(() => { const gl=document.querySelector('#canvas').getContext('webgl2'); return [gl.drawingBufferWidth,gl.drawingBufferHeight,gl.getError()]; })).toEqual([1280,720,0]); + await expect.poll(() => require('./pixels').hasRenderedPixels(page)).toBe(true); + await page.screenshot({path:info.outputPath('webgl2-match.png')}); + await page.locator('#canvas').press('Escape',{delay:80}); + await click(page,640,410); await screen(page,'EndGameScreen'); + expect(errors).toEqual([]); +}); + +test('software renderer remains available', async ({page}) => { + await page.goto('/?renderer=software'); + await screen(page,'MainMenuScreen'); + expect((await state(page)).renderer).toBe('software'); + expect(await page.evaluate(() => Boolean(document.querySelector('#canvas').getContext('2d')))).toBe(true); + await clickMainMenu(page,'settings'); await screen(page,'SettingsScreen'); +}); + +// Test browsers usually emulate WebGL2. These tests answer the shell's acceleration +// probe at the browser boundary; its renderer selection runs unchanged. +const UNMASKED_RENDERER_WEBGL = 0x9246; +test('the default renderer is WebGL2 when the browser accelerates it', async ({page}) => { + await page.addInitScript(name => { + const getContext = HTMLCanvasElement.prototype.getContext; + HTMLCanvasElement.prototype.getContext = function(type, attributes) { + return getContext.call(this, type, type === 'webgl2' ? {...attributes, failIfMajorPerformanceCaveat:false} : attributes); + }; + const getParameter = WebGL2RenderingContext.prototype.getParameter; + WebGL2RenderingContext.prototype.getParameter = function(parameter) { + return parameter === name ? 'Test hardware GPU' : getParameter.call(this, parameter); + }; + }, UNMASKED_RENDERER_WEBGL); + await page.goto('/'); await screen(page,'MainMenuScreen'); + expect((await state(page)).renderer).toBe('webgl2'); + expect(await page.evaluate(() => document.querySelector('#canvas').getContext('webgl2') instanceof WebGL2RenderingContext)).toBe(true); +}); + +const fallbacks = { + unavailable: () => { + const getContext = HTMLCanvasElement.prototype.getContext; + HTMLCanvasElement.prototype.getContext = function(type, attributes) { + return type === 'webgl2' ? null : getContext.call(this, type, attributes); + }; + }, + emulated: name => { + const getParameter = WebGL2RenderingContext.prototype.getParameter; + WebGL2RenderingContext.prototype.getParameter = function(parameter) { + return parameter === name ? 'ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (LLVM 10.0.0)), SwiftShader driver)' + : getParameter.call(this, parameter); + }; + }, +}; +for (const [reason, stub] of Object.entries(fallbacks)) { + test(`the default renderer falls back to software when WebGL2 is ${reason}`, async ({page}) => { + await page.addInitScript(stub, UNMASKED_RENDERER_WEBGL); + await page.goto('/'); await screen(page,'MainMenuScreen'); + expect((await state(page)).renderer).toBe('software'); + expect(await page.evaluate(() => Boolean(document.querySelector('#canvas').getContext('2d')))).toBe(true); + await clickMainMenu(page,'settings'); await screen(page,'SettingsScreen'); + }); +} + + +test('WebGL context restoration keeps the match and can recover repeatedly', async ({page}, info) => { + const errors = []; + page.on('pageerror', error => errors.push(String(error))); + await page.goto('/?renderer=webgl2'); await screen(page,'MainMenuScreen'); + await clickMainMenu(page,'custom'); await screen(page,'CustomGameScreen'); + await clickCustomGameStart(page); // A fresh profile has a valid premade map preselected. + await expect.poll(async () => (await state(page)).tick).toBeGreaterThan(25); + for (let count=1; count<=2; ++count) { + await page.evaluate(() => { + window.contextLoss = document.querySelector('#canvas').getContext('webgl2').getExtension('WEBGL_lose_context'); + contextLoss.loseContext(); + }); + await expect.poll(async () => (await state(page)).contextLost).toBe(true); + const paused = (await state(page)).tick; + // Exercise a real suspension interval, not just immediate restoration. + await page.waitForTimeout(250); + expect((await state(page)).tick).toBe(paused); + await page.evaluate(() => contextLoss.restoreContext()); + await expect.poll(async () => (await state(page)).contextRestores).toBe(count); + await expect.poll(async () => (await state(page)).tick).toBeGreaterThan(paused); + await expect.poll(() => require('./pixels').hasRenderedPixels(page)).toBe(true); + expect(await page.evaluate(() => document.querySelector('#canvas').getContext('webgl2').getError())).toBe(0); + } + await page.screenshot({path:info.outputPath('webgl2-restored.png')}); + await page.locator('#canvas').press('Escape',{delay:80}); + await click(page,600,500); await screen(page,'EndGameScreen'); + expect(errors).toEqual([]); +}); + +test('WebGL context restoration retains settings, editor and confirmation controls', async ({page}, info) => { + const errors = []; + page.on('pageerror', error => errors.push(String(error))); + await page.goto('/?renderer=webgl2'); + await screen(page, 'MainMenuScreen'); + let restores = 0; + async function recover(expectedScreen) { + await screen(page, expectedScreen); + await page.evaluate(() => { + window.contextLoss = document.querySelector('#canvas').getContext('webgl2').getExtension('WEBGL_lose_context'); + contextLoss.loseContext(); + }); + await expect.poll(async () => (await state(page)).contextLost).toBe(true); + await page.evaluate(() => contextLoss.restoreContext()); + await expect.poll(async () => (await state(page)).contextRestores).toBe(++restores); + await screen(page, expectedScreen); + await expect.poll(() => require('./pixels').hasRenderedPixels(page)).toBe(true); + expect(await page.evaluate(() => document.querySelector('#canvas').getContext('webgl2').getError())).toBe(0); + } + await clickMainMenu(page,'settings'); await recover('SettingsScreen'); + await clickSettingsDone(page); await screen(page,'MainMenuScreen'); + await clickMainMenu(page,'editor'); await screen(page,'EditorMainMenu'); + await click(page,600,300); await screen(page,'NewMapScreen'); + await click(page,440,650); await recover('MapEditorScreen'); + await page.locator('#canvas').press('Escape',{delay:80}); + await click(page,600,525); await recover('MessageScreen'); + await page.screenshot({path:info.outputPath('webgl2-restored-editor-dialog.png')}); + await page.locator('#canvas').press('Escape',{delay:80}); + await screen(page,'MapEditorScreen'); + await page.locator('#canvas').press('Escape',{delay:80}); + await click(page,600,525); await screen(page,'MessageScreen'); + await click(page,600,570); await screen(page,'EditorMainMenu'); + expect(errors).toEqual([]); +}); diff --git a/browser/tests/replay-save.spec.js b/browser/tests/replay-save.spec.js new file mode 100644 index 000000000..1785f18b5 --- /dev/null +++ b/browser/tests/replay-save.spec.js @@ -0,0 +1,66 @@ +const {test,expect}=require('@playwright/test'); +const {clickMainMenu,gameURL,clickCustomGameStart}=require('./main-menu'); +const state=page=>page.evaluate(()=>glob2Diagnostics.snapshot()); +const screen=(page,name)=>expect.poll(async()=>(await state(page)).screen).toContain(name); +const click=(page,x,y)=>page.locator('#canvas').click({position:{x,y},delay:80}); +const digest=page=>page.evaluate(()=>glob2Diagnostics.replayDigest('AAA_review_replay.replay')); + +async function endMatch(page) { + await page.goto(gameURL());await screen(page,'MainMenuScreen'); + await clickMainMenu(page,'custom');await screen(page,'CustomGameScreen'); + await clickCustomGameStart(page); // A fresh profile has a valid premade map preselected. + await expect.poll(async()=>(await state(page)).tick).toBeGreaterThan(50); + await page.locator('#canvas').press('Escape');await click(page,600,500); + await screen(page,'EndGameScreen'); + await click(page,1060,815);await screen(page,'LoadSaveScreen'); + await click(page,600,515);await page.locator('#canvas').pressSequentially('AAA review replay',{delay:20}); +} + +test('end-game replay save remains scheduled during resize and durable persistence',async({page})=>{ + const errors=[];page.on('pageerror',e=>errors.push(String(e))); + await endMatch(page); + await page.setViewportSize({width:800,height:600}); + await page.evaluate(()=>{ + const sync=FS.syncfs; + FS.syncfs=function(populate,callback){ + if(!populate){window.releaseReplaySave=()=>{FS.syncfs=sync;sync.call(FS,populate,callback);};return;} + return sync.call(FS,populate,callback); + }; + }); + await click(page,320,405); + await expect.poll(async()=>(await state(page)).persistence).toBe('writing'); + await screen(page,'LoadSaveScreen'); + await page.locator('#canvas').press('Escape');await screen(page,'LoadSaveScreen'); + await page.evaluate(()=>releaseReplaySave());await screen(page,'EndGameScreen'); + const saved=await digest(page);expect(saved).not.toBeNull(); + await page.reload();await screen(page,'MainMenuScreen');expect(await digest(page)).toEqual(saved); + // Load the file through the real replay chooser after a fresh browser startup. + await page.setViewportSize({width:1200,height:900}); + await page.reload();await screen(page,'MainMenuScreen'); + await clickMainMenu(page,'load');await screen(page,'ChooseMapScreen'); + await click(page,620,650); // Switch the chooser from saved games to replays. + await click(page,370,290);await click(page,810,590); + await screen(page,'match'); + await expect.poll(async()=>(await state(page)).tick).toBeGreaterThan(25); + expect(errors).toEqual([]); +}); + +test('end-game replay save offers export and retry after quota failure',async({page})=>{ + await endMatch(page); + await page.evaluate(()=>{ + window.replayQuota=true; + const put=IDBObjectStore.prototype.put; + IDBObjectStore.prototype.put=function(...args){ + if(window.replayQuota)throw new DOMException('Injected quota exhaustion','QuotaExceededError'); + return put.apply(this,args); + }; + }); + await click(page,520,555); + await expect.poll(async()=>(await state(page)).persistence).toBe('failed');await screen(page,'LoadSaveScreen'); + const download=page.waitForEvent('download');await click(page,600,420); + expect((await download).suggestedFilename()).toBe('AAA_review_replay.replay'); + await page.evaluate(()=>window.replayQuota=false); + await click(page,520,555);await screen(page,'EndGameScreen'); + const saved=await digest(page);await page.reload();await screen(page,'MainMenuScreen'); + expect(await digest(page)).toEqual(saved); +}); diff --git a/browser/tests/runtime-build.spec.js b/browser/tests/runtime-build.spec.js new file mode 100644 index 000000000..8731f525c --- /dev/null +++ b/browser/tests/runtime-build.spec.js @@ -0,0 +1,30 @@ +const {test, expect} = require('@playwright/test'); +const {gameURL} = require('./main-menu'); + +test('loading page presents branded progress before the runtime is ready', async ({page}) => { + let release; + const gate = new Promise(resolve => { release = resolve; }); + await page.route('**/index.wasm', async route => { + await gate; + await route.continue(); + }); + const navigation = page.goto(gameURL()); + await expect(page.locator('#loading')).toBeVisible(); + await expect(page.locator('#loading-status')).not.toBeEmpty(); + expect(await page.locator('body').evaluate(element => getComputedStyle(element).backgroundImage)).not.toBe('none'); + await expect(page.locator('#canvas')).toHaveCSS('opacity', '0'); + release(); + await navigation; + await expect.poll(() => page.evaluate(() => glob2Diagnostics.snapshot().screen)).toContain('MainMenuScreen'); + await expect(page.locator('#loading')).toHaveAttribute('aria-hidden', 'true'); + await expect(page.locator('#canvas')).toHaveCSS('opacity', '1'); +}); + +// Check the actual served release, not just the SCons flag list. Accidentally +// restoring Asyncify would otherwise let a blocking UI regression pass E2E. +test('browser runtime does not instrument stacks with Asyncify', async ({request}) => { + const response = await request.get('/index.wasm'); + expect(response.ok()).toBeTruthy(); + const module = await WebAssembly.compile(await response.body()); + expect(WebAssembly.Module.exports(module).filter(entry => /asyncify/i.test(entry.name))).toEqual([]); +}); diff --git a/browser/tests/session-reload.spec.js b/browser/tests/session-reload.spec.js new file mode 100644 index 000000000..66061d93d --- /dev/null +++ b/browser/tests/session-reload.spec.js @@ -0,0 +1,81 @@ +const {test,expect}=require('@playwright/test'); +const {clickMainMenu,gameURL,clickCustomGameStart}=require('./main-menu'); +const path=require('node:path'); +const state=page=>page.evaluate(()=>glob2Diagnostics.snapshot()); +const screen=(page,name)=>expect.poll(async()=>(await state(page)).screen).toContain(name); +const click=(page,x,y)=>page.locator('#canvas').click({position:{x,y},delay:80}); + +async function startAndSave(page) { + await page.goto(gameURL());await screen(page,'MainMenuScreen'); + const existing=await page.evaluate(()=>glob2Diagnostics.saves()); + await clickMainMenu(page,'custom');await screen(page,'CustomGameScreen'); + await clickCustomGameStart(page); // A fresh profile has a valid premade map preselected. + await expect.poll(async()=>(await state(page)).tick).toBeGreaterThan(25); + await page.locator('#canvas').press('Escape',{delay:80}); + await click(page,600,400);await click(page,520,555); + await expect.poll(async()=>{ + const saves=await page.evaluate(()=>glob2Diagnostics.saves()); + return saves.filter(name=>!existing.includes(name)).length; + }).toBe(1); + await expect.poll(async()=>(await state(page)).persistence).toBe('persisted'); + return (await page.evaluate(()=>glob2Diagnostics.saves())).find(name=>!existing.includes(name)); +} +async function loadFirst(page,replay=false,observeLoading=true) { + await page.locator('#canvas').press('Escape',{delay:80}); + await click(page,600,replay?375:350); + await click(page,500,360); + // Start observing before the click so a fast cooperative load is not missed. + const loading=observeLoading ? page.waitForFunction(()=>glob2Diagnostics.snapshot().screenClass.includes('GameLoadScreen')) : Promise.resolve(); + await click(page,520,555);await loading; +} + +test('an active match can load the same saved game repeatedly through the scheduled loader',async({page})=>{ + const errors=[];page.on('pageerror',error=>errors.push(String(error))); + const name=await startAndSave(page); + const digest=await page.evaluate(name=>glob2Diagnostics.saveDigest(name),name); + for(let repeat=0;repeat<2;++repeat) { + const before=(await state(page)).tick; + await expect.poll(async()=>(await state(page)).tick).toBeGreaterThan(before+25); + await loadFirst(page);await screen(page,'match'); + expect((await state(page)).screenClass).toContain('GameSessionScreen'); + const loaded=(await state(page)).tick; + await expect.poll(async()=>(await state(page)).tick).toBeGreaterThan(loaded+25); + expect(await page.evaluate(name=>glob2Diagnostics.saveDigest(name),name)).toEqual(digest); + } + expect(errors).toEqual([]); +}); + +test('a damaged in-game load returns through a scheduled error notice and permits another match',async({page})=>{ + const errors=[];page.on('pageerror',error=>errors.push(String(error))); + const name=await startAndSave(page); + // Damage the stored bytes at the filesystem boundary; all loading uses UI input. + await page.evaluate(name=>FS.writeFile('/home/web_user/.glob2/games/'+name,new Uint8Array([1,2,3])),name); + await loadFirst(page,false,false);await screen(page,'MessageScreen'); + await page.setViewportSize({width:800,height:600}); + await expect.poll(async()=>(await state(page)).width).toBe(800); + await page.locator('#canvas').press('Escape',{delay:80});await screen(page,'CustomGameScreen'); + await page.setViewportSize({width:1200,height:900}); + await expect.poll(async()=>(await state(page)).width).toBe(1200); + await clickCustomGameStart(page); // A fresh profile has a valid premade map preselected. + await screen(page,'match'); + const restarted=(await state(page)).tick; + await expect.poll(async()=>(await state(page)).tick).toBeGreaterThan(restarted+25); + expect(errors).toEqual([]); +}); + +test('an active replay can be loaded again through the scheduled loader',async({page})=>{ + const errors=[];page.on('pageerror',error=>errors.push(String(error))); + await page.goto(gameURL());await screen(page,'MainMenuScreen'); + await clickMainMenu(page,'load');await screen(page,'ChooseMapScreen');await click(page,620,650); + const chooser=page.waitForEvent('filechooser');await click(page,340,695); + await (await chooser).setFiles({name:'AAA Replay.replay',mimeType:'application/octet-stream', + buffer:await require('node:fs/promises').readFile(path.resolve(__dirname,'../../tests/baselines/cross-replay.replay'))}); + await expect.poll(async()=>(await state(page)).import).toBe('succeeded'); + await click(page,380,280);await click(page,810,590);await screen(page,'match'); + const original=(await state(page)).tick; + await expect.poll(async()=>(await state(page)).tick).toBeGreaterThan(original+25); + await loadFirst(page,true);await screen(page,'match'); + const loaded=(await state(page)).tick; + await expect.poll(async()=>(await state(page)).tick).toBeGreaterThan(loaded+25); + expect(errors).toEqual([]); +}); diff --git a/browser/tests/settings-storage.spec.js b/browser/tests/settings-storage.spec.js new file mode 100644 index 000000000..b071e1780 --- /dev/null +++ b/browser/tests/settings-storage.spec.js @@ -0,0 +1,100 @@ +const {test,expect}=require('@playwright/test'); +const {clickMainMenu,gameURL,clickSettingsDone,clickSettingsCancel}=require('./main-menu'); +const {hasDarkText}=require('./pixels'); +const state=page=>page.evaluate(()=>glob2Diagnostics.snapshot()); +const screen=(page,name)=>expect.poll(async()=>(await state(page)).screen).toContain(name); +const click=(page,x,y)=>page.locator('#canvas').click({position:{x,y},delay:80}); +const preferences=page=>page.evaluate(()=>glob2Diagnostics.preferences()); +// Coordinates below are specific to the suite's fixed 1200×900 default +// viewport (settings-storage tests never resize) and to the Display & +// graphics category, which is selected by default when Settings opens. +const AUDIO_TAB=(page)=>click(page,208,233); // Sidebar "Audio" entry. +const MUTE_ROW=(page)=>click(page,700,224); // Anywhere on the "Mute audio" row toggles it. +const openGraphicsDetail=(page)=>click(page,950,508); // "Graphics detail" choice control. +const selectFull=(page)=>click(page,950,545); // "Full" option in the opened dropdown. + +test('new browser profiles are muted and an explicit unmute survives reload',async({page})=>{ + await page.goto(gameURL());await screen(page,'MainMenuScreen'); + await clickMainMenu(page,'settings');await screen(page,'SettingsScreen'); + await clickSettingsDone(page);await screen(page,'MainMenuScreen'); + expect(await preferences(page)).toEqual({optionFlags:1,mute:1}); + await clickMainMenu(page,'settings');await screen(page,'SettingsScreen'); + await AUDIO_TAB(page); + await MUTE_ROW(page); // Actual Mute toggle, in the Audio category. + await clickSettingsDone(page);await screen(page,'MainMenuScreen'); + expect((await preferences(page)).mute).toBe(0); + await page.reload();await screen(page,'MainMenuScreen'); + await clickMainMenu(page,'settings');await screen(page,'SettingsScreen'); + await clickSettingsDone(page);await screen(page,'MainMenuScreen'); + expect((await preferences(page)).mute).toBe(0); +}); + +for (const fault of ['quota','aborted transaction']) test(`settings survive ${fault} with visible failure and durable retry`,async({page,context},info)=>{ + await page.goto(gameURL()); await screen(page,'MainMenuScreen'); + await clickMainMenu(page,'settings'); await screen(page,'SettingsScreen'); + await clickSettingsDone(page); await screen(page,'MainMenuScreen'); + expect(await preferences(page)).toEqual({optionFlags:1,mute:1}); + await clickMainMenu(page,'settings'); await screen(page,'SettingsScreen'); + // Inject the fault before the change: every edit auto-saves immediately + // (that's the whole point of the redesigned screen), so injecting after + // the click would let this write land before the fault ever applies. + await page.evaluate(fault=>{ + window.settingsStorageFault=true; + const put=IDBObjectStore.prototype.put; + IDBObjectStore.prototype.put=function(...args){ + if(window.settingsStorageFault){ + if(fault==='quota')throw new DOMException('Injected quota exhaustion','QuotaExceededError'); + this.transaction.abort(); + } + return put.apply(this,args); + }; + },fault); + await openGraphicsDetail(page); await selectFull(page); // Turn high-quality graphics on. + await expect.poll(async()=>(await state(page)).persistence,{timeout:10000}).toBe('failed'); + await clickSettingsDone(page); // Retries the write; still fails while the fault is active. + await screen(page,'SettingsScreen'); + await expect.poll(()=>hasDarkText(page,{x:144,y:758,width:150,height:26})).toBe(true); + await page.screenshot({path:info.outputPath('settings-save-failure.png')}); + const restored=await context.newPage(); await restored.goto(gameURL()); await screen(restored,'MainMenuScreen'); + expect(await preferences(restored)).toEqual({optionFlags:1,mute:1}); await restored.close(); + await page.evaluate(()=>window.settingsStorageFault=false); + await clickSettingsDone(page); await screen(page,'MainMenuScreen'); + expect(await preferences(page)).toEqual({optionFlags:0,mute:1}); + await page.reload(); await screen(page,'MainMenuScreen'); + expect(await preferences(page)).toEqual({optionFlags:0,mute:1}); +}); + +test('settings wait for durable storage before closing',async({page})=>{ + await page.goto(gameURL());await screen(page,'MainMenuScreen'); + await clickMainMenu(page,'settings');await screen(page,'SettingsScreen'); + // Stall the storage adapter, without a production test flag or changing game state. + await page.evaluate(()=>{ + const sync=FS.syncfs; + FS.syncfs=function(populate,callback){ + if(!populate){ window.releaseSettingsWrite=()=>{FS.syncfs=sync;sync.call(FS,populate,callback);}; return; } + return sync.call(FS,populate,callback); + }; + }); + await clickSettingsDone(page); + await expect.poll(()=>page.evaluate(()=>typeof window.releaseSettingsWrite)).toBe('function'); + await screen(page,'SettingsScreen'); + await page.locator('#canvas').press('Escape',{delay:80}); + await screen(page,'SettingsScreen'); + await page.evaluate(()=>window.releaseSettingsWrite()); + await screen(page,'MainMenuScreen'); +}); + +test('settings can continue after failure without claiming a durable save',async({page,context})=>{ + await page.goto(gameURL());await screen(page,'MainMenuScreen'); + await clickMainMenu(page,'settings');await screen(page,'SettingsScreen'); + await clickSettingsDone(page);await screen(page,'MainMenuScreen'); + await clickMainMenu(page,'settings');await screen(page,'SettingsScreen'); + await page.evaluate(()=>{IDBObjectStore.prototype.put=function(){throw new DOMException('Injected quota exhaustion','QuotaExceededError');};}); + await openGraphicsDetail(page); await selectFull(page); + await expect.poll(async()=>(await state(page)).persistence,{timeout:10000}).toBe('failed'); + await screen(page,'SettingsScreen'); + await clickSettingsCancel(page);await screen(page,'MainMenuScreen'); + expect((await state(page)).persistence).toBe('failed'); + const restored=await context.newPage();await restored.goto(gameURL());await screen(restored,'MainMenuScreen'); + expect(await preferences(restored)).toEqual({optionFlags:1,mute:1}); +}); diff --git a/browser/tests/shutdown-storage.spec.js b/browser/tests/shutdown-storage.spec.js new file mode 100644 index 000000000..8167bed03 --- /dev/null +++ b/browser/tests/shutdown-storage.spec.js @@ -0,0 +1,63 @@ +const {test,expect}=require('@playwright/test'); +const {clickMainMenu,gameURL,clickSettingsDone}=require('./main-menu'); +const state=page=>page.evaluate(()=>glob2Diagnostics.snapshot()); +const screen=(page,name)=>expect.poll(async()=>(await state(page)).screen).toContain(name); +const click=(page,x,y)=>page.locator('#canvas').click({position:{x,y},delay:80}); + +async function start(page){ + await page.goto(gameURL()); await screen(page,'MainMenuScreen'); + await clickMainMenu(page,'settings'); await screen(page,'SettingsScreen'); + await clickSettingsDone(page); await screen(page,'MainMenuScreen'); +} +async function failWrites(page){ + await page.evaluate(()=>{ + const put=IDBObjectStore.prototype.put; + window.failShutdownWrite=true; + IDBObjectStore.prototype.put=function(...args){ + if(window.failShutdownWrite)throw new DOMException('Injected quota exhaustion','QuotaExceededError'); + return put.apply(this,args); + }; + }); +} + +test('quit waits for final persistence through resize and escape',async({page})=>{ + await start(page); + await page.evaluate(()=>{ + const sync=FS.syncfs; + FS.syncfs=function(populate,callback){ + if(!populate){window.releaseShutdownWrite=()=>{FS.syncfs=sync;sync.call(FS,populate,callback);};return;} + return sync.call(FS,populate,callback); + }; + }); + await clickMainMenu(page,'quit'); await screen(page,'ShutdownScreen'); + await expect.poll(()=>page.evaluate(()=>typeof window.releaseShutdownWrite)).toBe('function'); + await page.setViewportSize({width:1000,height:700}); + await expect.poll(async()=>(await state(page)).width).toBe(1000); + await page.locator('#canvas').press('Escape',{delay:80}); + await screen(page,'ShutdownScreen'); + await page.evaluate(()=>window.releaseShutdownWrite()); + await screen(page,'exited'); + expect((await state(page)).persistence).toBe('persisted'); + await page.reload(); await screen(page,'MainMenuScreen'); + expect(await page.evaluate(()=>glob2Diagnostics.preferences())).toEqual({optionFlags:1,mute:1}); +}); + +test('quit offers visible retry after a failed final save',async({page},info)=>{ + await start(page); await failWrites(page); + await clickMainMenu(page,'quit'); await screen(page,'ShutdownScreen'); + await expect.poll(async()=>(await state(page)).persistence).toBe('failed'); + await expect.poll(()=>require('./pixels').hasLightText(page,{x:300,y:440,width:600,height:24})).toBe(true); + await page.screenshot({path:info.outputPath('shutdown-save-failure.png')}); + await page.evaluate(()=>window.failShutdownWrite=false); + await click(page,440,570); await screen(page,'exited'); + expect((await state(page)).persistence).toBe('persisted'); +}); + +test('failed final save requires an explicit quit without saving',async({page})=>{ + await start(page); await failWrites(page); + await clickMainMenu(page,'quit'); await screen(page,'ShutdownScreen'); + await expect.poll(async()=>(await state(page)).persistence).toBe('failed'); + await page.locator('#canvas').press('Escape',{delay:80}); await screen(page,'ShutdownScreen'); + await click(page,760,570); await screen(page,'exited'); + expect((await state(page)).persistence).toBe('failed'); +}); diff --git a/browser/tests/single-player.spec.js b/browser/tests/single-player.spec.js new file mode 100644 index 000000000..e4aea6b30 --- /dev/null +++ b/browser/tests/single-player.spec.js @@ -0,0 +1,401 @@ +const {gameURL,clickMainMenu,clickSettingsDone,clickCustomGameStart}=require('./main-menu'); +const {darkShare}=require('./pixels'); +const {test, expect} = require('@playwright/test'); + +const state = page => page.evaluate(() => glob2Diagnostics.snapshot()); +const screen = (page, name) => expect.poll(async () => (await state(page)).screen).toContain(name); +const click = (page, x, y) => page.locator('#canvas').click({position:{x,y}, delay:80}); +const menu = (page, x, y) => click(page, x + 280, y + 210); + +// Hold the first scheduled turn after entering a loader. The real Escape event +// can then reach a pending job without racing a fast machine's completed load. +// This injects the browser timer boundary; gameplay and diagnostics stay unchanged. +async function holdLoader(page, name) { + await page.evaluate(name => { + const schedule = window.setTimeout; + window.setTimeout = function(callback, delay, ...args) { + if (glob2Diagnostics.snapshot().screen.includes(name)) { + window.setTimeout = schedule; + window.releaseLoaderTurn = () => { + delete window.releaseLoaderTurn; + schedule(callback, delay, ...args); + }; + return 0; + } + return schedule(callback, delay, ...args); + }; + }, name); +} +async function cancelHeldLoader(page, name) { + await screen(page, name); + await expect.poll(() => page.evaluate(() => typeof releaseLoaderTurn)).toBe('function'); + await page.locator('#canvas').press('Escape', {delay:80}); + await page.evaluate(() => releaseLoaderTurn()); +} + +test.beforeEach(async ({page}, info) => { + // Pin the editor's wall-time seed before runtime initialization, matching + // the native generation fixture. Animation and cooperative timers remain real. + if (info.title.startsWith('map generation can')) await page.clock.setFixedTime(12345 * 1000); + await page.goto(gameURL()); + await screen(page, 'MainMenuScreen'); +}); + +test('starts with a full-page game and restored local storage', async ({page}) => { + expect(await state(page)).toMatchObject({width:1200, height:900, restore:'ready'}); + await expect(page.locator('button')).toHaveCount(0); + await expect(page.locator('#loading')).toHaveAttribute('aria-hidden','true'); + expect(await page.locator('#canvas').boundingBox()).toMatchObject({x:0,y:0,width:1200,height:900}); +}); + +test('application host returns from settings and credits and shuts down cleanly', async ({page}) => { + const errors = []; + page.on('pageerror', error => errors.push(String(error))); + await clickMainMenu(page, 'settings'); + await screen(page, 'SettingsScreen'); + await clickSettingsDone(page); + await screen(page, 'MainMenuScreen'); + await clickMainMenu(page, 'credits'); + await screen(page, 'CreditScreen'); + await page.locator('#canvas').press('Escape'); + await screen(page, 'MainMenuScreen'); + await clickMainMenu(page, 'quit'); + await screen(page, 'exited'); + expect(errors).toEqual([]); +}); + +test('campaign selector returns to its suspended parent and can reopen', async ({page}) => { + await clickMainMenu(page, 'campaign'); + await screen(page, 'CampaignMainMenu'); + for (let attempt = 0; attempt < 2; ++attempt) { + await menu(page, 320, 90); + await screen(page, 'CampaignSelectorScreen'); + await page.locator('#canvas').press('Escape'); + await screen(page, 'CampaignMainMenu'); + } + await page.locator('#canvas').press('Escape'); + await screen(page, 'MainMenuScreen'); +}); + +test('tutorial sessions quit through the end screen and can restart', async ({page}) => { + await clickMainMenu(page, 'tutorial'); + await screen(page, 'CampaignMenuScreen'); + for (let attempt = 0; attempt < 2; ++attempt) { + await menu(page, 100, 60); + await menu(page, 160, 450); + await screen(page, 'match'); + await expect.poll(async () => (await state(page)).tick).toBeGreaterThan(25); + await page.locator('#canvas').press('Escape'); + await click(page, 600, 500); + await screen(page, 'EndGameScreen'); + await page.locator('#canvas').press('Enter'); + await screen(page, 'CampaignMenuScreen'); + } +}); + +test('game rules and AI descriptions return to setup, and a finished game returns there too', async ({page}) => { + const errors = []; + page.on('pageerror', error => errors.push(String(error))); + await clickMainMenu(page, 'custom'); + await screen(page, 'CustomGameScreen'); + // The lobby redesign (#237) folded "other options" into inline Game Rules + // rows - no separate screen to navigate to and back from anymore. + await click(page, 969, 35); // Game Rules tab. + await click(page, 452, 133); // "Quick clash" tile. + // The AI profile picker (Players & Teams tab, a colony's Info button) is a + // screen CustomGameScreen pushes - see CustomGameScreen::showAIProfile. + // It must actually be pushed, not blocking-executed: the browser host has + // no Asyncify, so the old choose()/Screen::execute() pattern this replaced + // threw and froze the page (docs/browser/adr-003-screen-execution.md). + await click(page, 593, 35); // Players & Teams tab. + await click(page, 1113, 438); // Colony 4's Info button (AI by default). + await screen(page, 'CustomGameChoiceScreen'); + await click(page, 213, 201); // Warrush row. + await click(page, 728, 862); // "Use Warrush". + await screen(page, 'CustomGameScreen'); + await clickCustomGameStart(page); // A fresh profile has a valid premade map preselected. + await expect.poll(async () => (await state(page)).tick).toBeGreaterThan(25); + await page.locator('#canvas').press('Escape'); + await click(page, 600, 500); + await screen(page, 'EndGameScreen'); + await page.locator('#canvas').press('Enter'); + await screen(page, 'CustomGameScreen'); + await page.locator('#canvas').press('Escape'); + await screen(page, 'MainMenuScreen'); + expect(errors).toEqual([]); +}); + +test('custom match pauses, persists and resumes after reload', async ({page}) => { + const errors = []; + page.on('pageerror', error => errors.push(String(error))); + await clickMainMenu(page, 'custom'); + await screen(page, 'CustomGameScreen'); + await clickCustomGameStart(page); // A fresh profile has a valid premade map preselected. + await expect.poll(async () => (await state(page)).tick).toBeGreaterThan(25); + await page.locator('#canvas').press('p', {delay:80}); + await expect.poll(async () => (await state(page)).paused).toBe(true); + const paused = await state(page); + await expect.poll(async () => (await state(page)).frames).toBeGreaterThan(paused.frames + 10); + expect((await state(page)).tick).toBe(paused.tick); + + await page.locator('#canvas').press('Escape', {delay:80}); + await click(page, 600, 400); + await click(page, 600, 515); + await page.locator('#canvas').press('Home'); + for (let i=0;i<40;i++) await page.locator('#canvas').press('Delete'); + await page.locator('#canvas').pressSequentially('Browser regression', {delay:20}); + await click(page, 520, 555); + const digest = () => page.evaluate(() => glob2Diagnostics.saveDigest('Browser_regression.game')); + await expect.poll(digest).not.toBeNull(); + await expect.poll(async () => (await state(page)).persisting).toBe(false); + const saved = await digest(); + expect(saved.size).toBeGreaterThan(1000); + await page.reload(); + await screen(page, 'MainMenuScreen'); + expect(await digest()).toEqual(saved); + // Each test owns a fresh browser context; only this test's save exists. + expect(await page.evaluate(() => glob2Diagnostics.saves())).toEqual(['Browser_regression.game']); + await clickMainMenu(page, 'load'); await screen(page, 'ChooseMapScreen'); + await menu(page, 100, 70); + const downloadEvent = page.waitForEvent('download'); + await menu(page, 340, 320); + const download = await downloadEvent; + expect(download.suggestedFilename()).toBe('Browser_regression.game'); + const bytes = await require('node:fs/promises').readFile(await download.path()); + expect({size:bytes.length,sha256:require('node:crypto').createHash('sha256').update(bytes).digest('hex')}).toEqual(saved); + await menu(page, 530, 440); await screen(page, 'MainMenuScreen'); + + await clickMainMenu(page, 'load'); + await screen(page, 'ChooseMapScreen'); + await menu(page, 100, 70); + await menu(page, 530, 380); + await expect.poll(async () => (await state(page)).tick).toBeGreaterThanOrEqual(paused.tick); + await expect.poll(async () => (await state(page)).audio).toBe('running'); + expect(errors).toEqual([]); +}); + +test('editor setup and campaign entry dialogs return to their retained parents', async ({page}) => { + const errors = []; + page.on('pageerror', error => errors.push(String(error))); + await clickMainMenu(page, 'editor'); + await screen(page, 'EditorMainMenu'); + await menu(page, 320, 90); + await screen(page, 'NewMapScreen'); + await page.locator('#canvas').press('Escape', {delay:80}); + await screen(page, 'EditorMainMenu'); + await menu(page, 320, 210); + await screen(page, 'CampaignEditor'); + await menu(page, 80, 380); + await screen(page, 'ChooseMapScreen'); + await menu(page, 100, 70); + await menu(page, 530, 380); + await screen(page, 'CampaignMapEntryEditor'); + await menu(page, 350, 450); + await screen(page, 'CampaignEditor'); + await menu(page, 100, 60); + await menu(page, 240, 380); + await screen(page, 'CampaignMapEntryEditor'); + await menu(page, 540, 450); + await screen(page, 'CampaignEditor'); + await menu(page, 540, 450); + await screen(page, 'EditorMainMenu'); + await page.locator('#canvas').press('Escape', {delay:80}); + await screen(page, 'MainMenuScreen'); + expect(errors).toEqual([]); +}); + +test('map editor frames resume after cancelling quit and can discard a new map', async ({page}) => { + const errors = []; + page.on('pageerror', error => errors.push(String(error))); + await clickMainMenu(page, 'editor'); + await screen(page, 'EditorMainMenu'); + await menu(page, 320, 90); + await screen(page, 'NewMapScreen'); + await menu(page, 160, 440); + await screen(page, 'MapEditorScreen'); + await page.locator('#canvas').press('Escape', {delay:80}); + await click(page, 600, 525); + await screen(page, 'MessageScreen'); + await page.locator('#canvas').press('Escape', {delay:80}); + await screen(page, 'MapEditorScreen'); + await page.locator('#canvas').press('Escape', {delay:80}); + await click(page, 600, 525); + await screen(page, 'MessageScreen'); + await menu(page, 320, 360); + await screen(page, 'EditorMainMenu'); + expect(errors).toEqual([]); +}); + + +test('editor save cancellation keeps edits open and completed fertility saves the map', async ({page}) => { + await clickMainMenu(page, 'editor'); + await screen(page, 'EditorMainMenu'); + await menu(page, 320, 90); + await screen(page, 'NewMapScreen'); + await menu(page, 160, 440); + await screen(page, 'MapEditorScreen'); + await page.locator('#canvas').press('Escape', {delay:80}); + await click(page, 600, 525); + await screen(page, 'MessageScreen'); + await menu(page, 110, 360); // Save before quit. + await screen(page, 'MapEditorScreen'); + await page.locator('#canvas').press('Escape', {delay:80}); // Cancel file selection. + await page.locator('#canvas').press('Escape', {delay:80}); // Reopen editor menu. + await click(page, 600, 525); + await screen(page, 'MessageScreen'); // Unsaved edits are still present. + await menu(page, 110, 360); + await screen(page, 'MapEditorScreen'); + await click(page, 600, 515); + await page.locator('#canvas').press('Home'); + for (let i=0; i<40; ++i) await page.locator('#canvas').press('Delete'); + await page.locator('#canvas').pressSequentially('Browser editor', {delay:20}); + await click(page, 520, 555); + await screen(page, 'EditorMainMenu'); // Returns only after job and map write complete. + const digest = () => page.evaluate(() => glob2Diagnostics.mapDigest('Browser_editor.map')); + await expect.poll(digest).not.toBeNull(); + await expect.poll(async () => (await state(page)).persisting).toBe(false); + const saved = await digest(); + expect(saved.size).toBeGreaterThan(1000); + await page.reload(); + await screen(page, 'MainMenuScreen'); + expect(await digest()).toEqual(saved); +}); + + +test('editor map loading can be cancelled and restarted', async ({page}) => { + const errors = []; + page.on('pageerror', error => errors.push(String(error))); + await clickMainMenu(page, 'editor'); + await screen(page, 'EditorMainMenu'); + for (const cancel of [true, false]) { + await menu(page, 320, 150); + await screen(page, 'ChooseMapScreen'); + await menu(page, 100, 70); + if (cancel) await holdLoader(page, 'EditorLoadScreen'); + await menu(page, 530, 380); + if (cancel) { + await cancelHeldLoader(page, 'EditorLoadScreen'); + } else { + await screen(page, 'MapEditorScreen'); + await page.locator('#canvas').press('Escape', {delay:80}); + await click(page, 600, 525); + } + await screen(page, 'EditorMainMenu'); + } + expect(errors).toEqual([]); +}); + +test('custom and tutorial startup can be cancelled and retried', async ({page}) => { + const errors = []; + page.on('pageerror', error => errors.push(String(error))); + await clickMainMenu(page, 'custom'); + await screen(page, 'CustomGameScreen'); + await holdLoader(page, 'GameLoadScreen'); + await clickCustomGameStart(page); + await cancelHeldLoader(page, 'GameLoadScreen'); + await screen(page, 'CustomGameScreen'); + await page.locator('#canvas').press('Escape', {delay:80}); + await screen(page, 'MainMenuScreen'); + await clickMainMenu(page, 'tutorial'); + await screen(page, 'CampaignMenuScreen'); + await menu(page, 100, 60); + await holdLoader(page, 'GameLoadScreen'); + await menu(page, 160, 450); + await cancelHeldLoader(page, 'GameLoadScreen'); + await screen(page, 'CampaignMenuScreen'); + await menu(page, 160, 450); // The same selected mission remains available. + await screen(page, 'match'); + await expect.poll(async () => (await state(page)).tick).toBeGreaterThan(25); + expect(errors).toEqual([]); +}); + +test('a generated custom map loads after its setup screen closes', async ({page}) => { + const errors = []; + page.on('pageerror', error => errors.push(String(error))); + // Each generated preview lives in its own private temporary directory. + const previews = () => page.evaluate(() => FS.readdir('/tmp').filter(name => name.startsWith('glob2-custom-'))); + await clickMainMenu(page, 'custom'); + await screen(page, 'CustomGameScreen'); + const {width} = page.viewportSize(); + await click(page, Math.floor((width - Math.min(width - 32, 1120)) / 2) + 225, 100); // "Random map" + await expect.poll(previews).toHaveLength(1); + await clickCustomGameStart(page); + await screen(page, 'match'); + await expect.poll(async () => (await state(page)).tick).toBeGreaterThan(25); + expect(await previews()).toEqual([]); // Removed once loading no longer needs it. + expect(errors).toEqual([]); +}); + +test('the custom game preview draws the selected map', async ({page}) => { + await clickMainMenu(page, 'custom'); + await screen(page, 'CustomGameScreen'); + // Mirrors CustomGameScreen::renderMap()'s preview square on the Map tab. + const {width, height} = page.viewportSize(); + const w = Math.min(width - 32, 1120), leftW = w < 800 ? 280 : Math.floor(w * 46 / 100); + const rightX = Math.floor((width - w) / 2) + leftW + 24, rightW = w - leftW - 24; + const size = Math.min(rightW, height - 181 - 126); + const clip = {x: rightX + Math.floor((rightW - size) / 2), y: 179, width: size, height: size}; + // A fresh profile previews FourSquares1: water and grass, not a flat panel. + await expect.poll(() => darkShare(page, clip)).toBeGreaterThan(0.25); +}); + +test('cancelling an editor replacement preserves edits and a completed load replaces the map', async ({page}) => { + const errors = []; + page.on('pageerror', error => errors.push(String(error))); + await clickMainMenu(page, 'editor'); + await screen(page, 'EditorMainMenu'); + await menu(page, 320, 90); + await screen(page, 'NewMapScreen'); + await menu(page, 160, 440); + await screen(page, 'MapEditorScreen'); + for (const cancel of [true, false]) { + await page.locator('#canvas').press('Escape', {delay:80}); + await click(page, 600, 325); // Load map from inside the editor. + await click(page, 500, 365); + await holdLoader(page, 'EditorLoadScreen'); + await click(page, 520, 555); + if (cancel) await cancelHeldLoader(page, 'EditorLoadScreen'); + else { + await screen(page, 'EditorLoadScreen'); + await page.evaluate(() => releaseLoaderTurn()); + } + await screen(page, 'MapEditorScreen'); + await page.locator('#canvas').press('Escape', {delay:80}); + await click(page, 600, 525); + if (cancel) { + await screen(page, 'MessageScreen'); // The original unsaved map is retained. + await page.locator('#canvas').press('Escape', {delay:80}); + await screen(page, 'MapEditorScreen'); + } else await screen(page, 'EditorMainMenu'); // Replacement is unmodified. + } + expect(errors).toEqual([]); +}); + + +for (const terrain of [{name:'swamp', y:140}, {name:'concrete islands', y:235}]) { +test(`map generation can be cancelled before retrying (${terrain.name})`, async ({page}) => { + const errors = []; + page.on('pageerror', error => errors.push(String(error))); + await clickMainMenu(page, 'editor'); + await screen(page, 'EditorMainMenu'); + await menu(page, 320, 90); + await screen(page, 'NewMapScreen'); + await menu(page, 110, 60); // 256 columns. + await menu(page, 110, 85); // 256 rows. + await menu(page, 100, 235); // Concrete islands keep substantial work pending. + await menu(page, 160, 440); + await screen(page, 'EditorGenerateScreen'); + await page.locator('#canvas').press('Escape', {delay:80}); + await screen(page, 'NewMapScreen'); + await menu(page, 100, terrain.y); // Exercise height-map and partition jobs. + await menu(page, 160, 440); + await screen(page, 'MapEditorScreen'); + await page.locator('#canvas').press('Escape', {delay:80}); + await click(page, 600, 525); + await screen(page, 'MessageScreen'); + await menu(page, 320, 360); + await screen(page, 'EditorMainMenu'); + expect(errors).toEqual([]); +}); + +} diff --git a/browser/tests/storage.spec.js b/browser/tests/storage.spec.js new file mode 100644 index 000000000..56672b272 --- /dev/null +++ b/browser/tests/storage.spec.js @@ -0,0 +1,100 @@ +const {gameURL,clickMainMenu,clickSettingsCancel,clickCustomGameStart}=require('./main-menu'); +const {test,expect}=require('@playwright/test'); +const fs=require('node:fs/promises'); +const {createHash}=require('node:crypto'); +const state=page=>page.evaluate(()=>glob2Diagnostics.snapshot()); +const screen=(page,name)=>expect.poll(async ()=>(await state(page)).screen).toContain(name); +const click=(page,x,y)=>page.locator('#canvas').click({position:{x,y},delay:80}); +async function save(page) { + await page.locator('#canvas').press('Escape',{delay:80}); + const frames=(await state(page)).frames; + await expect.poll(async ()=>(await state(page)).frames).toBeGreaterThan(frames+2); + await click(page,600,400); await click(page,600,515); + await page.locator('#canvas').press('Home'); + for(let i=0;i<50;i++) await page.locator('#canvas').press('Delete'); + await page.locator('#canvas').pressSequentially('Durability regression'); + await click(page,520,555); +} +for (const fault of ['abort','quota']) test(`${fault} failure retains the previous durable save and can retry`,async ({page,context},info)=>{ + // Inject a failure at the browser database boundary, not into game behavior. + await page.addInitScript(fault=>{ + let fail=false; + window.storageFault={enable:()=>fail=true,disable:()=>fail=false}; + const transaction=IDBDatabase.prototype.transaction; + IDBDatabase.prototype.transaction=function(...args){ + const tx=transaction.apply(this,args); + if(fail && fault==='abort' && args[1]==='readwrite') queueMicrotask(()=>tx.abort()); + return tx; + }; + const put=IDBObjectStore.prototype.put; + IDBObjectStore.prototype.put=function(...args) { + if(fail && fault==='quota') throw new DOMException('Injected quota exhaustion','QuotaExceededError'); + return put.apply(this,args); + }; + }, fault); + await page.goto(gameURL()); await screen(page,'MainMenuScreen'); + await clickMainMenu(page,'custom'); await screen(page,'CustomGameScreen'); + await clickCustomGameStart(page); // A fresh profile has a valid premade map preselected. + await expect.poll(async ()=>(await state(page)).tick).toBeGreaterThan(25); + await page.locator('#canvas').press('p',{delay:80}); + await expect.poll(async ()=>(await state(page)).paused).toBe(true); + const digest=()=>page.evaluate(()=>glob2Diagnostics.saveDigest('Durability_regression.game')); + await save(page); + await expect.poll(digest).not.toBeNull(); + await expect.poll(async ()=>(await state(page)).persistence).toBe('persisted'); + const original=await digest(), tick=(await state(page)).tick; + await page.locator('#canvas').press('p',{delay:80}); + await expect.poll(async ()=>(await state(page)).tick).toBeGreaterThan(tick+25); + await page.locator('#canvas').press('p',{delay:80}); + await expect.poll(async ()=>(await state(page)).paused).toBe(true); + await page.evaluate(()=>storageFault.enable()); + await save(page); + await expect.poll(async ()=>(await state(page)).persistence).toBe('failed'); + const changed=await digest(); expect(changed).not.toEqual(original); + const downloadEvent=page.waitForEvent('download'); + await click(page,600,422); + const download=await downloadEvent; + expect(download.suggestedFilename()).toBe('Durability_regression.game'); + const exported=await fs.readFile(await download.path()); + expect({size:exported.length,sha256:createHash('sha256').update(exported).digest('hex')}).toEqual(changed); + await page.screenshot({path:info.outputPath('save-persistence-failure.png')}); + const restored=await context.newPage(); + await restored.goto(gameURL()); await screen(restored,'MainMenuScreen'); + expect(await restored.evaluate(()=>glob2Diagnostics.saveDigest('Durability_regression.game'))).toEqual(original); + await restored.close(); + await page.evaluate(()=>storageFault.disable()); + // The original save dialog is retained; click OK to retry the operation. + await click(page,520,555); + await expect.poll(async ()=>(await state(page)).persistence).toBe('persisted'); + // Retry serializes the current game again; compare with that replacement, + // rather than assuming every local GUI field is unchanged since the failure. + const retried=await digest(); expect(retried).not.toEqual(original); + await page.reload(); await screen(page,'MainMenuScreen'); + expect(await digest()).toEqual(retried); +}); + +test('restore failure is explained before entering the game', async ({page},info)=>{ + await page.addInitScript(()=>{ + const open=IDBFactory.prototype.open; + window.restoreFault={attempts:0}; + IDBFactory.prototype.open=function(...args){ + ++restoreFault.attempts; + if(!sessionStorage.getItem('restoreFaultDisabled')) + throw new DOMException('Injected storage refusal','SecurityError'); + return open.apply(this,args); + }; + }); + await page.goto(gameURL()); await screen(page,'MessageScreen'); + expect((await state(page)).restore).toBe('failed'); + expect((await state(page)).persistence).toBe('restore-failed'); + await page.screenshot({path:info.outputPath('storage-restore-failure.png')}); + await click(page,390,570); await screen(page,'MainMenuScreen'); + await clickMainMenu(page,'settings'); await screen(page,'SettingsScreen'); + await clickSettingsCancel(page); await screen(page,'MainMenuScreen'); + // Startup and settings writes must not retry the database after failed restore. + expect(await page.evaluate(()=>restoreFault.attempts)).toBe(1); + expect((await state(page)).persistence).toBe('restore-failed'); + await page.evaluate(()=>sessionStorage.setItem('restoreFaultDisabled','1')); + await page.reload(); await screen(page,'MainMenuScreen'); + expect((await state(page)).restore).toBe('ready'); +}); diff --git a/browser/tests/team-colors.spec.js b/browser/tests/team-colors.spec.js new file mode 100644 index 000000000..5fa7bc656 --- /dev/null +++ b/browser/tests/team-colors.spec.js @@ -0,0 +1,47 @@ +const {test, expect} = require('@playwright/test'); +const {clickMainMenu}=require('./main-menu'); + +// Inspect the presented worker sprite, excluding the adjacent colored text. +// The first tutorial uses the standard red team. This catches a hue shift +// caused by decoding BGRA sprite bytes with an RGBA display format. +async function workerColors(page, width) { + const png = await page.screenshot({clip:{x:((width-640)>>2)+12,y:0,width:18,height:16}}); + return page.evaluate(async base64 => { + const bitmap = await createImageBitmap(await (await fetch('data:image/png;base64,'+base64)).blob()); + const canvas = document.createElement('canvas'); + canvas.width = bitmap.width; canvas.height = bitmap.height; + const context = canvas.getContext('2d'); + context.drawImage(bitmap,0,0); bitmap.close(); + const pixels = context.getImageData(0,0,canvas.width,canvas.height).data; + let red=0, purple=0; + for (let i=0;i80 && r>g*1.5 && r>b*1.5) red++; + if (r>80 && b>80 && r>g*1.5 && b>g*1.5) purple++; + } + return {red,purple}; + }, png.toString('base64')); +} + +for (const renderer of ['software','webgl2']) { + test(`${renderer} preserves the red tutorial team before and after resize`, async ({page}, info) => { + const screen = name => expect.poll(() => page.evaluate(() => glob2Diagnostics.snapshot().screen)).toContain(name); + const click = (x,y) => page.locator('#canvas').click({position:{x,y},delay:80}); + const entry = new URL(process.env.GLOB2_TEST_ENTRY_PATH || '/', 'http://localhost'); + entry.searchParams.set('renderer',renderer); + await page.goto(entry.pathname+entry.search); + await screen('MainMenuScreen'); + expect(await page.evaluate(() => glob2Diagnostics.snapshot().renderer)).toBe(renderer); + await clickMainMenu(page,'tutorial'); await screen('CampaignMenuScreen'); + await click(380,270); await click(440,660); + await screen('match'); + for (const width of [1200,1280]) { + await page.setViewportSize({width,height:900}); + await expect.poll(() => page.evaluate(() => glob2Diagnostics.snapshot().width)).toBe(width); + const colors = await workerColors(page,width); + expect(colors.red, JSON.stringify(colors)).toBeGreaterThan(10); + expect(colors.purple, JSON.stringify(colors)).toBe(0); + } + await page.screenshot({path:info.outputPath('red-tutorial-team.png')}); + }); +} diff --git a/browser/tests/torus.spec.js b/browser/tests/torus.spec.js new file mode 100644 index 000000000..3e01c7ed8 --- /dev/null +++ b/browser/tests/torus.spec.js @@ -0,0 +1,95 @@ +const {gameURL, clickMainMenu, clickCustomGameStart} = require('./main-menu'); +const {test, expect} = require('@playwright/test'); +const state = page => page.evaluate(() => glob2Diagnostics.snapshot()); +const screen = (page, name) => expect.poll(async () => (await state(page)).screen).toContain(name); +const toggle = page => page.locator('#canvas').press('g', {delay: 80}); +// Bottom-left of the play area at the default 1200x900 viewport: open sky below +// the fitted ring in the overview, fog or ground on the flat map. +const corner = {x: 20, y: 760, width: 200, height: 100}; +// The 1.8 s transition advances at most 0.1 s per frame. SwiftShader draws the +// overview at about 3 fps, where the transition takes several seconds. +const transition = {timeout: 45000}; + +function urlWith(renderer) { + const url = new URL(gameURL(), 'http://localhost'); + url.searchParams.set('renderer', renderer); + return url.pathname + url.search; +} + +async function startMatch(page, renderer) { + await page.goto(urlWith(renderer)); await screen(page, 'MainMenuScreen'); + await clickMainMenu(page, 'custom'); await screen(page, 'CustomGameScreen'); + await clickCustomGameStart(page); // A fresh profile has a valid premade map preselected. + await expect.poll(async () => (await state(page)).tick, {timeout: 60000}).toBeGreaterThan(25); +} + +// Share of a clip matching the overview's sky clear colour, rgb(6, 9, 15). The +// flat map clears to rgb(0, 0, 32) and fog is black, so neither matches. +async function skyShare(page, clip) { + const png = await page.screenshot({clip}); + return page.evaluate(async base64 => { + const blob = await (await fetch('data:image/png;base64,' + base64)).blob(); + const bitmap = await createImageBitmap(blob); + const canvas = document.createElement('canvas'); + canvas.width = bitmap.width; canvas.height = bitmap.height; + const context = canvas.getContext('2d'); context.drawImage(bitmap, 0, 0); bitmap.close(); + const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data; + let sky = 0; + for (let i = 0; i < pixels.length; i += 4) + if (Math.abs(pixels[i] - 6) <= 5 && Math.abs(pixels[i+1] - 9) <= 5 && Math.abs(pixels[i+2] - 15) <= 5) ++sky; + return sky / (pixels.length / 4); + }, png.toString('base64')); +} + +const glError = page => page.evaluate(() => document.querySelector('#canvas').getContext('webgl2').getError()); + +test('WebGL2 switches between the flat map and the torus overview', async ({page}, info) => { + test.setTimeout(180000); + const errors = []; page.on('pageerror', error => errors.push(error.message)); + await startMatch(page, 'webgl2'); + expect(await state(page)).toMatchObject({renderer: 'webgl2', torus: false}); + expect(await skyShare(page, corner)).toBeLessThan(0.5); + + await toggle(page); + await expect.poll(async () => (await state(page)).torus).toBe(true); + await expect.poll(() => skyShare(page, corner), transition).toBeGreaterThan(0.6); + await page.screenshot({path: info.outputPath('torus-overview.png')}); + expect(await glError(page)).toBe(0); + const tick = (await state(page)).tick; + await expect.poll(async () => (await state(page)).tick).toBeGreaterThan(tick); + + await toggle(page); + await expect.poll(async () => (await state(page)).torus, transition).toBe(false); + await expect.poll(() => skyShare(page, corner)).toBeLessThan(0.5); + expect(await glError(page)).toBe(0); + expect(errors).toEqual([]); +}); + +test('the torus overview recovers after WebGL context loss', async ({page}) => { + test.setTimeout(180000); + const errors = []; page.on('pageerror', error => errors.push(error.message)); + await startMatch(page, 'webgl2'); + await toggle(page); + await expect.poll(() => skyShare(page, corner), transition).toBeGreaterThan(0.6); + + await page.evaluate(() => { + window.contextLoss = document.querySelector('#canvas').getContext('webgl2').getExtension('WEBGL_lose_context'); + contextLoss.loseContext(); + }); + await expect.poll(async () => (await state(page)).contextLost).toBe(true); + await page.evaluate(() => contextLoss.restoreContext()); + await expect.poll(async () => (await state(page)).contextRestores).toBe(1); + await expect.poll(async () => (await state(page)).torus, transition).toBe(true); + await expect.poll(() => skyShare(page, corner), transition).toBeGreaterThan(0.6); + expect(await glError(page)).toBe(0); + expect(errors).toEqual([]); +}); + +test('the software renderer keeps the flat map', async ({page}) => { + test.setTimeout(120000); + await startMatch(page, 'software'); + await toggle(page); + await page.waitForTimeout(1500); + expect((await state(page)).torus).toBe(false); + expect(await skyShare(page, corner)).toBeLessThan(0.5); +}); diff --git a/browser/tests/viewport.spec.js b/browser/tests/viewport.spec.js new file mode 100644 index 000000000..287bd8b41 --- /dev/null +++ b/browser/tests/viewport.spec.js @@ -0,0 +1,92 @@ +const {gameURL,clickMainMenu,clickSettingsDone,clickCustomGameStart}=require('./main-menu'); +const {test, expect} = require('@playwright/test'); +const snapshot = page => page.evaluate(() => glob2Diagnostics.snapshot()); +const screen = (page, name) => expect.poll(async () => (await snapshot(page)).screen).toContain(name); +const click = (page,x,y) => page.locator('#canvas').click({position:{x,y},delay:80}); +const menu = (page,x,y) => { const {width,height}=page.viewportSize(); return click(page,x+(width-640)/2,y+(height-480)/2); }; +async function resize(page,width,height,backingScale=1) { + await page.setViewportSize({width,height}); + await expect.poll(async () => { const s=await snapshot(page); return [s.width,s.height]; }).toEqual([width*backingScale,height*backingScale]); + expect(await page.locator('#canvas').boundingBox()).toMatchObject({x:0,y:0,width,height}); + // Input and dimensions can work even when an offscreen surface is never presented. + await expect.poll(() => require('./pixels').hasRenderedPixels(page)).toBe(true); +} +test.beforeEach(async ({page}) => { await page.goto(gameURL()); await screen(page,'MainMenuScreen'); }); +test('menus follow the viewport and keep their controls clickable', async ({page}) => { + await resize(page,1280,720); + await clickMainMenu(page,'settings'); await screen(page,'SettingsScreen'); + await resize(page,900,650); + await clickSettingsDone(page); await screen(page,'MainMenuScreen'); + await resize(page,1440,900); + await clickMainMenu(page,'credits'); await screen(page,'CreditScreen'); + await page.locator('#canvas').press('Escape'); await screen(page,'MainMenuScreen'); +}); +test('a running match survives resize and its open menu follows the new center', async ({page}, info) => { + // Cold texture creation on a headless software GPU can dominate startup. + test.setTimeout(120000); + await clickMainMenu(page,'custom'); await screen(page,'CustomGameScreen'); + await clickCustomGameStart(page); // A fresh profile has a valid premade map preselected. + await expect.poll(async () => (await snapshot(page)).tick, {timeout:60000}).toBeGreaterThan(25); + const before=(await snapshot(page)).tick; + await resize(page,1400,800); await resize(page,900,650); + await expect.poll(async () => (await snapshot(page)).tick).toBeGreaterThan(before); + await resize(page,500,400); + expect((await snapshot(page)).screenClass).toContain('GameSessionScreen'); + const small = (await snapshot(page)).tick; + await expect.poll(async () => (await snapshot(page)).tick).toBeGreaterThan(small); + await resize(page,900,650); + const beforeMenu = (await snapshot(page)).frames; + await page.locator('#canvas').press('Escape',{delay:80}); + await expect.poll(async () => (await snapshot(page)).frames).toBeGreaterThan(beforeMenu + 2); + await resize(page,1280,720); + await page.screenshot({path:info.outputPath('resized-game-menu.png')}); + await click(page,640,410); await screen(page,'EndGameScreen'); + await page.locator('#canvas').press('Enter'); await screen(page,'CustomGameScreen'); +}); +test('editor dialogs and discard controls follow viewport changes', async ({page}) => { + await clickMainMenu(page,'editor'); await screen(page,'EditorMainMenu'); + await menu(page,320,90); await screen(page,'NewMapScreen'); + await menu(page,160,440); await screen(page,'MapEditorScreen'); + await page.locator('#canvas').press('Escape',{delay:80}); + await resize(page,1280,720); + await click(page,640,435); await screen(page,'MessageScreen'); + await resize(page,1000,800); + await menu(page,320,360); await screen(page,'EditorMainMenu'); +}); + +test('small viewports retain the active screen and continue rendering', async ({page}, info) => { + await clickMainMenu(page,'settings'); await screen(page,'SettingsScreen'); + await resize(page,500,400); await screen(page,'SettingsScreen'); + await page.screenshot({path:info.outputPath('small-viewport.png')}); + await resize(page,1100,700); await screen(page,'SettingsScreen'); + await clickSettingsDone(page); await screen(page,'MainMenuScreen'); +}); + +test.describe('initial small viewport', () => { + test.use({viewport:{width:500,height:400}}); + test('starts at the actual browser dimensions', async ({page}) => { + expect(await snapshot(page)).toMatchObject({width:500,height:400}); + await expect.poll(() => require('./pixels').hasRenderedPixels(page)).toBe(true); + await clickMainMenu(page,'settings'); await screen(page,'SettingsScreen'); + }); +}); + +test.describe('initial wide viewport', () => { + // Not 4:3, so the 800x600 placeholder's CSS fit must not decide the window size. + test.use({viewport:{width:1280,height:554}}); + test('fills the page before any resize', async ({page}) => { + expect(await snapshot(page)).toMatchObject({width:1280,height:554}); + expect(await page.locator('#canvas').boundingBox()).toMatchObject({x:0,y:0,width:1280,height:554}); + await expect.poll(() => require('./pixels').hasRenderedPixels(page)).toBe(true); + }); +}); + +test.describe('high density display', () => { + test.use({deviceScaleFactor:2}); + test('uses the renderer-appropriate backing resolution', async ({page}) => { + expect(await page.evaluate(() => devicePixelRatio)).toBe(2); + const backingScale = (await snapshot(page)).renderer === 'webgl2' ? 2 : 1; + await resize(page,1100,750,backingScale); + await clickMainMenu(page,'settings'); await screen(page,'SettingsScreen'); + }); +}); diff --git a/browser/toolchain.json b/browser/toolchain.json new file mode 100644 index 000000000..56a47d068 --- /dev/null +++ b/browser/toolchain.json @@ -0,0 +1,5 @@ +{ + "emscripten": "4.0.15", + "emsdk_commit": "5eb0bde7585670252e8ba05e9d361627bffd08b5", + "boost": "1.83.0" +} diff --git a/browser/unit/audio.test.js b/browser/unit/audio.test.js new file mode 100644 index 000000000..52b35a1fe --- /dev/null +++ b/browser/unit/audio.test.js @@ -0,0 +1,47 @@ +const {test} = require('node:test'); +const assert = require('node:assert/strict'); +const activate = require('../audio'); + +test('audio activation ignores missing, running and closed contexts', async () => { + await activate(undefined, assert.fail); + for (const state of ['running', 'closed']) + await activate({state, resume:assert.fail}, assert.fail); +}); + +test('closing audio during a pending resume does not reject the gesture handler', async () => { + let reject; + const context = {state:'suspended', resume:() => new Promise((_, fail) => { reject = fail; })}; + const pending = activate(context, assert.fail); + context.state = 'closed'; + reject(new Error('Closed before resume completed')); + await pending; +}); + +test('activation failures are reported and the next gesture can retry', async () => { + const failure = new Error('Audio device unavailable'); + const errors = []; + const context = {state:'suspended', resume:async () => { throw failure; }}; + await activate(context, error => errors.push(error)); + assert.deepEqual(errors, [failure]); + assert.equal(context.state, 'suspended'); + context.resume = () => { context.state = 'running'; return Promise.resolve(); }; + const pending = activate(context, assert.fail); + // resume must run synchronously in the gesture, before the first await. + assert.equal(context.state, 'running'); + await pending; +}); + +test('only Firefox closed-resume rejection is suppressed after game exit', () => { + let prevented = 0; + const event = { + reason:{name:'InvalidStateError', message:'Closed before resume completed'}, + preventDefault:() => ++prevented, + }; + assert.equal(activate.ignoreClosedRejection(event, false), false); + assert.equal(activate.ignoreClosedRejection({ + ...event, reason:{name:'InvalidStateError', message:'Another audio error'} + }, true), false); + assert.equal(prevented, 0); + assert.equal(activate.ignoreClosedRejection(event, true), true); + assert.equal(prevented, 1); +}); diff --git a/browser/unit/file-selection.test.js b/browser/unit/file-selection.test.js new file mode 100644 index 000000000..e4f0129da --- /dev/null +++ b/browser/unit/file-selection.test.js @@ -0,0 +1,65 @@ +const {test} = require('node:test'); +const assert = require('node:assert/strict'); +const Selection = require('../file-selection'); +const file=(name,size=3,read=async()=>new Uint8Array(size).buffer)=>({name,size,arrayBuffer:read}); +test('accepts selected bytes without treating the extension as format validation',async()=>{ + const selection=new Selection(['game']); + await selection.select(file('Backup.GAME')); + assert.equal(selection.state,'selected'); + assert.equal(selection.file.name,'Backup.GAME'); + assert.equal(selection.file.bytes.length,3); +}); +test('rejects paths, control characters, unsupported extensions and oversized inputs before reading',async()=>{ + for(const name of ['../a.game','folder/a.game','folder\\a.game','a\0.game','a.game.','a.game ','a.exe','game','.game','C:backup.game','x'.repeat(129)+'.game']) { + let read=false; const selection=new Selection(['game']); + await selection.select(file(name,3,async()=>{read=true;return new ArrayBuffer(3);})); + assert.equal(selection.state,'failed',name); assert.equal(read,false,name); + } + for(const size of [0,-1,Infinity,1.5,65*1024*1024]) { + let read=false; const selection=new Selection(['game']); + await selection.select(file('a.game',size,async()=>{read=true;return new ArrayBuffer(0);})); + assert.equal(selection.state,'failed'); assert.equal(read,false); + } +}); +test('rejects changed sizes and read failures',async()=>{ + for(const read of [async()=>new ArrayBuffer(4),async()=>{throw Error('Read failed');}]) { + const selection=new Selection(['game']);await selection.select(file('a.game',3,read)); + assert.equal(selection.state,'failed');assert.equal(selection.file,null); + } +}); +test('cancellation and disposal cannot publish late file contents',async()=>{ + const cancelled=new Selection(['game']); await cancelled.select(null); + assert.equal(cancelled.state,'cancelled'); + let finish;const selection=new Selection(['game']); + const reading=selection.select(file('a.game',3,()=>new Promise(resolve=>finish=resolve))); + selection.dispose(); finish(new ArrayBuffer(3)); await reading; + assert.equal(selection.file,null);assert.notEqual(selection.state,'selected'); +}); + +test('a second selection cannot replace an in-flight read',async()=>{ + let finish;const selection=new Selection(['game']); + const reading=selection.select(file('first.game',3,()=>new Promise(resolve=>finish=resolve))); + await selection.select(file('second.game')); + finish(new ArrayBuffer(3));await reading; + assert.equal(selection.file.name,'first.game'); +}); +function documentFixture() { + const handlers={};let removed=0,clicks=0; + const input={files:[],addEventListener:(name,callback)=>handlers[name]=callback, + remove:()=>++removed,click:()=>++clicks}; + const document={createElement:()=>input,body:{appendChild:()=>{}}}; + return {document,input,handlers,removed:()=>removed,clicks:()=>clicks}; +} +test('picker uses accepted extensions and releases its input on cancellation',async()=>{ + const fixture=documentFixture(),selection=new Selection(['game','map']); + selection.pick(fixture.document); + assert.equal(fixture.input.accept,'.game,.map');assert.equal(fixture.clicks(),1); + fixture.handlers.cancel(); + assert.equal(selection.state,'cancelled');assert.equal(fixture.removed(),1); +}); +test('disposing an open picker ignores its late change event',async()=>{ + const fixture=documentFixture(),selection=new Selection(['game']); + selection.pick(fixture.document);selection.dispose(); + fixture.input.files=[file('late.game')];fixture.handlers.change(); + await Promise.resolve();assert.equal(selection.file,null); +}); diff --git a/browser/unit/storage.test.js b/browser/unit/storage.test.js new file mode 100644 index 000000000..fe53a99e8 --- /dev/null +++ b/browser/unit/storage.test.js @@ -0,0 +1,47 @@ +const {test} = require('node:test'); +const assert = require('node:assert/strict'); +const Storage = require('../storage'); +function fixture() { + const jobs=[], writes=[]; + const storage=new Storage(callback=>writes.push(callback), callback=>jobs.push(callback)); + storage.restored(null); + return {storage,jobs,writes}; +} +test('coalesces writes and acknowledges only the completed generation', async()=>{ + const {storage,jobs,writes}=fixture(); + storage.changed(); storage.changed(); + let firstDone=false, secondDone=false; + const first=storage.flush().then(()=>firstDone=true); + assert.equal(jobs.length,1); jobs.shift()(); + const second=storage.flush().then(()=>secondDone=true); + assert.equal(writes.length,1); assert.equal(jobs.length,0); + writes.shift()(null); await first; + assert.equal(firstDone,true); assert.equal(secondDone,false); + assert.equal(storage.state,'writing'); jobs.shift()(); + writes.shift()(null); await second; + assert.equal(storage.state,'persisted'); +}); +test('quota failure is retained, rejects completion, and allows an explicit retry', async()=>{ + const {storage,jobs,writes}=fixture(); + const failure=new Error('Quota exceeded'); + const result=assert.rejects(storage.flush(), /Quota exceeded/); + jobs.shift()(); writes.shift()(failure); await result; + assert.equal(storage.state,'failed'); assert.equal(storage.committed,0); + assert.equal(jobs.length,0); + const retry=storage.flush(); jobs.shift()(); writes.shift()(null); await retry; + assert.equal(storage.state,'persisted'); +}); +test('restore failure cannot overwrite existing storage', async()=>{ + let writes=0; + const storage=new Storage(()=>++writes); + storage.restored(new Error('Restore failed')); + storage.changed(); await assert.rejects(storage.flush(), /Restore failed/); + assert.equal(writes,0); assert.equal(storage.state,'restore-failed'); +}); +test('synchronous adapter errors reject the operation', async()=>{ + const jobs=[]; + const storage=new Storage(()=>{throw Error('Database closed');}, callback=>jobs.push(callback)); + storage.restored(null); + const result=assert.rejects(storage.flush(),/Database closed/); + jobs.shift()(); await result; assert.equal(storage.state,'failed'); +}); diff --git a/browser/visibility.config.js b/browser/visibility.config.js new file mode 100644 index 000000000..64b45eb22 --- /dev/null +++ b/browser/visibility.config.js @@ -0,0 +1,2 @@ +const base = require('./playwright.config'); +module.exports = {...base, testDir:'./visibility', projects:[{name:'chromium-visibility'}]}; diff --git a/browser/visibility/lifecycle.spec.js b/browser/visibility/lifecycle.spec.js new file mode 100644 index 000000000..fdf361371 --- /dev/null +++ b/browser/visibility/lifecycle.spec.js @@ -0,0 +1,98 @@ +const {test, expect, chromium} = require('@playwright/test'); +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const {spawn} = require('node:child_process'); +const {clickMainMenu, gameURL} = require('../tests/main-menu'); + +// Playwright's normal focus override keeps background documents visible. +// Use a real browser window and the default context without that override. +// Linux runners require a display (for example xvfb-run). +test('background single-player suspends and returns without catching up', async ({baseURL}, info) => { + const profile = await fs.mkdtemp(path.join(os.tmpdir(),'glob2-visibility-')); + // Linux CI lacks user namespaces and a hardware GPU. Give this local test + // window the same sandbox policy as Playwright and an explicit software GPU; + // these flags are never supplied to players' browsers. + const ciArgs = process.env.CI && process.platform === 'linux' + ? ['--no-sandbox', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] : []; + const child = spawn(chromium.executablePath(), ['--remote-debugging-port=0', + '--user-data-dir='+profile, '--no-first-run', '--no-default-browser-check', ...ciArgs, 'about:blank'], + {stdio:['ignore','ignore','pipe']}); + let launchError, launchLog = ''; + child.stderr.on('data', chunk => { launchLog = (launchLog + chunk).slice(-4000); }); + const exited = new Promise(resolve => { + child.once('exit',resolve); + child.once('error',error => { launchError = error; resolve(); }); + }); + let browser, page; + try { + let port; + await expect.poll(async () => { + if (launchError) throw launchError; + if (child.exitCode !== null || child.signalCode !== null) + throw new Error('Chromium exited before its debug port opened: ' + launchLog); + try { port=(await fs.readFile(path.join(profile,'DevToolsActivePort'),'utf8')).split('\n')[0]; return true; } + catch { return false; } + }).toBe(true); + browser = await chromium.connectOverCDP('http://127.0.0.1:'+port,{noDefaults:true}); + const context=browser.contexts()[0]; page=context.pages()[0]; + await page.setViewportSize({width:1200,height:900}); + const snapshot=()=>page.evaluate(()=>glob2Diagnostics.snapshot()); + const screen=name=>expect.poll(async ()=>(await snapshot()).screen).toContain(name); + const click=async (x,y)=>{ + const s=await snapshot(), box=await page.locator('#canvas').boundingBox(); + await page.locator('#canvas').click({position:{x:(x+(s.width-640)/2)*box.width/s.width, + y:(y+(s.height-480)/2)*box.height/s.height},delay:80}); + }; + await page.bringToFront(); + await page.goto(new URL(gameURL(), baseURL).href); await screen('MainMenuScreen'); + await page.evaluate(()=>{ + window.visibilityInputs=[]; + for (const type of ['mousedown','mouseup','keydown','keyup']) + document.addEventListener(type,event=>visibilityInputs.push({type,x:event.clientX,y:event.clientY,key:event.key, + hidden:document.hidden,focus:document.hasFocus(),screen:glob2Diagnostics.snapshot().screen}),true); + }); + if (process.env.GLOB2_TEST_RENDERER) + expect((await snapshot()).renderer).toBe(process.env.GLOB2_TEST_RENDERER); + await clickMainMenu(page,'custom'); await screen('CustomGameScreen'); + await click(100,70); await click(530,380); + await expect.poll(async ()=>(await snapshot()).tick).toBeGreaterThan(25); + const other=await context.newPage(); + await other.bringToFront(); + await expect.poll(()=>page.evaluate(()=>document.visibilityState)).toBe('hidden'); + // Observe a full second after the host has had a callback to suspend. + await expect.poll(async () => { + const before=await snapshot(); + await other.evaluate(()=>new Promise(resolve=>setTimeout(resolve,1000))); + return (await snapshot()).tick===before.tick; + }).toBe(true); + const hidden=await snapshot(); + await other.evaluate(()=>new Promise(resolve=>setTimeout(resolve,2000))); + expect((await snapshot()).tick).toBe(hidden.tick); + await page.bringToFront(); + await expect.poll(()=>page.evaluate(()=>document.visibilityState)).toBe('visible'); + await expect.poll(async ()=>(await snapshot()).tick).toBeGreaterThan(hidden.tick); + // A 2s hidden interval must not become a burst of 50 overdue simulation ticks. + expect((await snapshot()).tick-hidden.tick).toBeLessThan(15); + await page.locator('#canvas').press('Escape',{delay:80}); + // The menu does not set the explicit simulation-pause flag. Wait for the + // presented Quit label instead of treating frame count as menu readiness. + await expect.poll(()=>require('../tests/pixels').hasLightText(page, + {x:460,y:482,width:280,height:34})).toBe(true); + const frames=(await snapshot()).frames; + await expect.poll(async ()=>(await snapshot()).frames).toBeGreaterThan(frames+2); + await click(320,290); await screen('EndGameScreen'); + } catch (error) { + if (page && !page.isClosed()) { + await page.screenshot({path:info.outputPath('visibility-failure.png')}).catch(()=>{}); + await info.attach('visibility-state', {body:JSON.stringify(await page.evaluate(()=>glob2Diagnostics.snapshot()).catch(()=>null)),contentType:'application/json'}); + console.log('Visibility input diagnostics:',await page.evaluate(()=>({events:window.visibilityInputs,ratio:devicePixelRatio,inner:[innerWidth,innerHeight]})).catch(()=>null)); + } + throw error; + } finally { + if(browser) await browser.close(); + if(child.exitCode===null) child.kill(); + await exited; + await fs.rm(profile,{recursive:true,force:true}); + } +}); diff --git a/darwin/README.md b/darwin/README.md new file mode 100644 index 000000000..52486e17b --- /dev/null +++ b/darwin/README.md @@ -0,0 +1,7 @@ +# macOS build dependency + +Native secure WebSocket support is enabled by default and uses OpenSSL from +Homebrew or MacPorts. The existing application-bundle step copies `libssl`, +`libcrypto`, and their non-system dependencies into `Contents/Frameworks` and +rewrites their install names. Build with `wss=0` when producing a TCP-only +client without OpenSSL or Boost.Beast. diff --git a/data/texts.ar.txt b/data/texts.ar.txt index 6b0a50ae3..876f49693 100644 --- a/data/texts.ar.txt +++ b/data/texts.ar.txt @@ -1764,3 +1764,81 @@ OpenGL غير متوفر في هذا الإصدار. عرض الطارة تلقائيًا [settings Automatically show the torus overview while moving around the map (OpenGL).] إظهار نظرة عامة للخريطة على سطح طارة تلقائيًا أثناء التنقل في الخريطة (OpenGL). +[ERROR_CANT_SAVE_MAP] +تعذر حفظ الخريطة. تحقق من الوجهة والمساحة المتاحة. تعديلاتك لا تزال مفتوحة. +[Loading headers] +جارٍ تحميل الترويسات... +[Loading teams] +جارٍ تحميل الفرق... +[Loading terrain] +جارٍ تحميل التضاريس... +[Building gradients] +جارٍ إنشاء التدرجات... +[Loading players] +جارٍ تحميل اللاعبين... +[Loading scripts] +جارٍ تحميل النصوص البرمجية... +[Generating map] +جارٍ إنشاء الخريطة... +[ERROR_CANT_GENERATE_MAP] +تعذر إنشاء الخريطة. جرّب إعدادات إنشاء مختلفة. +[Loading units] +جارٍ تحميل الوحدات... +[Loading buildings] +جارٍ تحميل المباني... +[Resolving team links] +جارٍ حل روابط الفرق... +[saving to storage] +جارٍ الحفظ... +[save failed retry] +فشل الحفظ. أعد المحاولة. +[export save] +تصدير الحفظ +[storage restore failed] +تعذرت استعادة تخزين المتصفح. البيانات المحفوظة الحالية لم تتغير. يمكنك متابعة هذه الجلسة، لكن التغييرات المحلية لن تُحفظ. صدّر الملفات المهمة من مربع حوار فشل الحفظ. أعد التحميل لمحاولة استعادة التخزين مرة أخرى. +[continue] +متابعة +[export file] +تصدير الملف +[export failed] +فشل تصدير الملف +[import file] +استيراد +[select import file] +اختر ملفًا للاستيراد +[validating import] +جارٍ التحقق من الملف... +[import succeeded] +تم استيراد الملف +[import cancelled] +تم إلغاء الاستيراد +[import failed] +فشل الاستيراد: ملف غير صالح أو غير مدعوم +[import persistence failed] +غير محفوظ: اختر استيراد لإعادة المحاولة أو تصدير الملف +[import progress] +تقدم الاستيراد +[export progress] +تقدم التصدير +[retry save] +إعادة محاولة الحفظ +[campaign save failed] +لم يتم حفظ تقدم الحملة. أعد المحاولة أو صدّر نسخة احتياطية. +[leave without saving] +الخروج دون حفظ +[campaign import failed] +ملف تقدم غير صالح أو إصدار حملة مختلف +[network release mismatch] +بروتوكولا العميل والخادم مختلفان. ثبّت نفس إصدار Glob2 المثبت على الخادم. +[settings continue] +متابعة +[settings save failed] +حفظ الإعدادات غير مؤكد. أعد المحاولة أو تابع. +[shutdown save failed] +الحفظ النهائي غير مؤكد. أعد المحاولة أو اخرج دون حفظ. +[quit without saving] +الخروج دون حفظ +[game closed] +تم إغلاق اللعبة. يمكنك إغلاق هذه النافذة أو إعادة التحميل للعب مرة أخرى. +[campaign editor save failed] +الحفظ غير مؤكد. موافق يعيد المحاولة؛ إلغاء يعود إلى قائمة المحرر. diff --git a/data/texts.br.txt b/data/texts.br.txt index ee9f9ff15..3949516f1 100644 --- a/data/texts.br.txt +++ b/data/texts.br.txt @@ -517,7 +517,7 @@ Descrição do mapa [Map Discovered] Mapa [Map download failure: lost connection] -<<<<<<< /media/disk-2/attila/glob2/data/texts.br.txt +Falha no download do mapa: conexão perdida [Map name: %0] Nome do mapa: %0. [map name] @@ -1762,3 +1762,81 @@ Aplicar os gráficos ao carregar a próxima partida ou o editor de mapas (OpenGL Visão toroidal automática [settings Automatically show the torus overview while moving around the map (OpenGL).] Mostrar automaticamente a visão geral toroidal enquanto você se desloca pelo mapa (OpenGL). +[ERROR_CANT_SAVE_MAP] +Não foi possível salvar o mapa. Verifique o destino e o espaço disponível. Suas edições continuam abertas. +[Loading headers] +Carregando cabeçalhos... +[Loading teams] +Carregando equipes... +[Loading terrain] +Carregando o terreno... +[Building gradients] +Gerando gradientes... +[Loading players] +Carregando jogadores... +[Loading scripts] +Carregando scripts... +[Generating map] +Gerando o mapa... +[ERROR_CANT_GENERATE_MAP] +Não foi possível gerar o mapa. Tente outras configurações de geração. +[Loading units] +Carregando unidades... +[Loading buildings] +Carregando construções... +[Resolving team links] +Resolvendo vínculos de equipe... +[saving to storage] +Salvando... +[save failed retry] +Falha ao salvar. Tente de novo. +[export save] +Exportar jogo salvo +[storage restore failed] +Não foi possível restaurar o armazenamento do navegador. Os dados salvos existentes não foram alterados. Você pode continuar esta sessão, mas as alterações locais não poderão ser mantidas. Exporte os jogos importantes na caixa de falha de salvamento. Recarregue para tentar restaurar o armazenamento de novo. +[continue] +Continuar +[export file] +Exportar arquivo +[export failed] +Falha ao exportar o arquivo +[import file] +Importar +[select import file] +Escolha um arquivo para importar +[validating import] +Validando o arquivo... +[import succeeded] +Arquivo importado +[import cancelled] +Importação cancelada +[import failed] +Falha na importação: arquivo inválido ou não suportado +[import persistence failed] +Não salvo: escolha Importar para tentar de novo ou Exportar arquivo +[import progress] +Progresso da importação +[export progress] +Progresso da exportação +[retry save] +Tentar salvar de novo +[campaign save failed] +Progresso da campanha não salvo. Tente novamente ou exporte um backup. +[leave without saving] +Sair sem salvar +[campaign import failed] +Arquivo de progresso inválido ou versão de campanha diferente +[network release mismatch] +Os protocolos do cliente e do servidor são diferentes. Instale a mesma versão do Glob2 do servidor. +[settings continue] +Continuar +[settings save failed] +Salvamento das configurações não confirmado. Tente de novo, ou continue. +[shutdown save failed] +Salvamento final não confirmado. Tente de novo, ou saia sem salvar. +[quit without saving] +Sair sem salvar +[game closed] +Jogo encerrado. Você pode fechar esta janela ou recarregar para jogar de novo. +[campaign editor save failed] +Salvamento não confirmado. OK tenta de novo; Cancelar volta ao menu do editor. diff --git a/data/texts.ca.txt b/data/texts.ca.txt index 34a9f531f..1f0783505 100644 --- a/data/texts.ca.txt +++ b/data/texts.ca.txt @@ -1774,3 +1774,81 @@ Aplica els gràfics quan es carregui la propera partida o l’editor de mapes (O Vista toroidal automàtica [settings Automatically show the torus overview while moving around the map (OpenGL).] Mostra automàticament la vista general toroidal mentre us desplaceu pel mapa (OpenGL). +[ERROR_CANT_SAVE_MAP] +No s'ha pogut desar el mapa. Comproveu la destinació i l'espai disponible. Les vostres edicions encara estan obertes. +[Loading headers] +Carregant capçaleres... +[Loading teams] +Carregant equips... +[Loading terrain] +Carregant el terreny... +[Building gradients] +Generant gradients... +[Loading players] +Carregant jugadors... +[Loading scripts] +Carregant scripts... +[Generating map] +Generant el mapa... +[ERROR_CANT_GENERATE_MAP] +No s'ha pogut generar el mapa. Proveu altres paràmetres de generació. +[Loading units] +Carregant unitats... +[Loading buildings] +Carregant construccions... +[Resolving team links] +Resolent enllaços d'equip... +[saving to storage] +Desant... +[save failed retry] +Ha fallat el desat. Torneu-ho a provar. +[export save] +Exporta la partida desada +[storage restore failed] +No s'ha pogut restaurar l'emmagatzematge del navegador. Les dades desades existents no s'han modificat. Podeu continuar aquesta sessió, però els canvis locals no es podran conservar. Exporteu les partides importants des del diàleg d'error de desat. Torneu a carregar per reintentar la restauració de l'emmagatzematge. +[continue] +Continua +[export file] +Exporta el fitxer +[export failed] +Ha fallat l'exportació del fitxer +[import file] +Importa +[select import file] +Trieu un fitxer per importar +[validating import] +Validant el fitxer... +[import succeeded] +Fitxer importat +[import cancelled] +Importació cancel·lada +[import failed] +Ha fallat la importació: fitxer no vàlid o no compatible +[import persistence failed] +No desat: trieu Importa per reintentar-ho, o Exporta el fitxer +[import progress] +Progrés de la importació +[export progress] +Progrés de l'exportació +[retry save] +Reintenta el desat +[campaign save failed] +El progrés de la campanya no s'ha desat. Torneu-ho a provar o exporteu una còpia de seguretat. +[leave without saving] +Surt sense desar +[campaign import failed] +Fitxer de progrés no vàlid o versió de campanya diferent +[network release mismatch] +Els protocols del client i del servidor difereixen. Instal·leu la mateixa versió de Glob2 que el servidor. +[settings continue] +Continua +[settings save failed] +Desat dels ajustos no confirmat. Torneu-ho a provar, o continueu. +[shutdown save failed] +Desat final no confirmat. Torneu-ho a provar, o sortiu sense desar. +[quit without saving] +Surt sense desar +[game closed] +Partida tancada. Podeu tancar aquesta finestra o tornar a carregar per jugar de nou. +[campaign editor save failed] +Desat no confirmat. D'acord ho reintenta; Cancel·la torna al menú de l'editor. diff --git a/data/texts.cz.txt b/data/texts.cz.txt index 026d20a00..531057fe8 100644 --- a/data/texts.cz.txt +++ b/data/texts.cz.txt @@ -1766,3 +1766,81 @@ Vybraná grafika se použije při příštím načtení hry nebo editoru map (Op Automatický toroidní pohled [settings Automatically show the torus overview while moving around the map (OpenGL).] Automaticky zobrazovat přehled mapy ve tvaru torusu při pohybu po mapě (OpenGL). +[ERROR_CANT_SAVE_MAP] +Mapu se nepodařilo uložit. Zkontrolujte cíl a dostupné místo. Vaše úpravy jsou stále otevřené. +[Loading headers] +Načítání hlaviček... +[Loading teams] +Načítání týmů... +[Loading terrain] +Načítání terénu... +[Building gradients] +Vytvářejí se gradienty... +[Loading players] +Načítání hráčů... +[Loading scripts] +Načítání skriptů... +[Generating map] +Generuje se mapa... +[ERROR_CANT_GENERATE_MAP] +Mapu se nepodařilo vytvořit. Zkuste jiná nastavení generování. +[Loading units] +Načítání jednotek... +[Loading buildings] +Načítání budov... +[Resolving team links] +Řeší se propojení týmů... +[saving to storage] +Ukládá se... +[save failed retry] +Uložení selhalo. Zkuste to znovu. +[export save] +Exportovat uloženou hru +[storage restore failed] +Úložiště prohlížeče se nepodařilo obnovit. Stávající uložená data nebyla změněna. Můžete pokračovat v této relaci, ale místní změny nebude možné uchovat. Důležité uložené hry exportujte z dialogu chyby uložení. Obnovte stránku a zkuste obnovení úložiště znovu. +[continue] +Pokračovat +[export file] +Exportovat soubor +[export failed] +Export souboru selhal +[import file] +Importovat +[select import file] +Vyberte soubor k importu +[validating import] +Ověřuje se soubor... +[import succeeded] +Soubor byl importován +[import cancelled] +Import zrušen +[import failed] +Import selhal: neplatný nebo nepodporovaný soubor +[import persistence failed] +Neuloženo: zvolte Importovat pro nový pokus, nebo Exportovat soubor +[import progress] +Průběh importu +[export progress] +Průběh exportu +[retry save] +Zkusit uložení znovu +[campaign save failed] +Postup tažení nebyl uložen. Zkuste to znovu nebo exportujte zálohu. +[leave without saving] +Ukončit bez uložení +[campaign import failed] +Neplatný soubor postupu nebo jiná verze tažení +[network release mismatch] +Protokoly klienta a serveru se liší. Nainstalujte stejnou verzi Glob2 jako server. +[settings continue] +Pokračovat +[settings save failed] +Uložení nastavení nepotvrzeno. Zkuste to znovu, nebo pokračujte. +[shutdown save failed] +Závěrečné uložení nepotvrzeno. Zkuste to znovu, nebo ukončete bez uložení. +[quit without saving] +Ukončit bez uložení +[game closed] +Hra byla ukončena. Toto okno můžete zavřít nebo znovu načíst stránku a hrát znovu. +[campaign editor save failed] +Uložení nepotvrzeno. OK zkusí to znovu; Zrušit se vrátí do nabídky editoru. diff --git a/data/texts.de.txt b/data/texts.de.txt index 8b56bc6b6..39e3dbc04 100644 --- a/data/texts.de.txt +++ b/data/texts.de.txt @@ -1766,3 +1766,81 @@ Grafik beim nächsten Laden einer Partie oder des Karteneditors anwenden (OpenGL Automatische Torusansicht [settings Automatically show the torus overview while moving around the map (OpenGL).] Zeigt automatisch die torusförmige Kartenübersicht an, während Sie sich auf der Karte bewegen (OpenGL). +[ERROR_CANT_SAVE_MAP] +Die Karte konnte nicht gespeichert werden. Prüfen Sie Ziel und verfügbaren Speicherplatz. Ihre Änderungen sind weiterhin offen. +[Loading headers] +Kopfdaten werden geladen... +[Loading teams] +Teams werden geladen... +[Loading terrain] +Gelände wird geladen... +[Building gradients] +Verlaufsfelder werden erstellt... +[Loading players] +Spieler werden geladen... +[Loading scripts] +Skripte werden geladen... +[Generating map] +Karte wird generiert... +[ERROR_CANT_GENERATE_MAP] +Die Karte konnte nicht generiert werden. Versuchen Sie andere Generierungseinstellungen. +[Loading units] +Einheiten werden geladen... +[Loading buildings] +Gebäude werden geladen... +[Resolving team links] +Team-Verknüpfungen werden aufgelöst... +[saving to storage] +Wird gespeichert... +[save failed retry] +Speichern fehlgeschlagen. Erneut versuchen. +[export save] +Speicherstand exportieren +[storage restore failed] +Der Browser-Speicher konnte nicht wiederhergestellt werden. Vorhandene gespeicherte Daten wurden nicht verändert. Sie können diese Sitzung fortsetzen, aber lokale Änderungen können nicht dauerhaft gespeichert werden. Exportieren Sie wichtige Spielstände über den Speicherfehler-Dialog. Laden Sie neu, um die Wiederherstellung erneut zu versuchen. +[continue] +Weiter +[export file] +Datei exportieren +[export failed] +Dateiexport fehlgeschlagen +[import file] +Importieren +[select import file] +Datei zum Importieren auswählen +[validating import] +Datei wird überprüft... +[import succeeded] +Datei importiert +[import cancelled] +Import abgebrochen +[import failed] +Import fehlgeschlagen: ungültige oder nicht unterstützte Datei +[import persistence failed] +Nicht gespeichert: Importieren zum erneuten Versuch oder Datei exportieren wählen +[import progress] +Importfortschritt +[export progress] +Exportfortschritt +[retry save] +Speichern erneut versuchen +[campaign save failed] +Kampagnenfortschritt nicht gespeichert. Erneut versuchen oder eine Sicherung exportieren. +[leave without saving] +Beenden ohne zu speichern +[campaign import failed] +Ungültige Fortschrittsdatei oder abweichende Kampagnenversion +[network release mismatch] +Client- und Serverprotokoll stimmen nicht überein. Installieren Sie dieselbe Glob2-Version wie der Server. +[settings continue] +Weiter +[settings save failed] +Einstellungen speichern nicht bestätigt. Erneut versuchen oder fortfahren. +[shutdown save failed] +Letztes Speichern nicht bestätigt. Erneut versuchen oder ohne Speichern beenden. +[quit without saving] +Beenden ohne zu speichern +[game closed] +Spiel beendet. Sie können dieses Fenster schließen oder neu laden, um erneut zu spielen. +[campaign editor save failed] +Speichern nicht bestätigt. OK versucht es erneut; Abbrechen kehrt zum Editor-Menü zurück. diff --git a/data/texts.dk.txt b/data/texts.dk.txt index a41a2ee83..77cd8eabf 100644 --- a/data/texts.dk.txt +++ b/data/texts.dk.txt @@ -1842,3 +1842,81 @@ Anvend grafikken, næste gang et spil eller korteditoren indlæses (OpenGL). Automatisk torusvisning [settings Automatically show the torus overview while moving around the map (OpenGL).] Vis automatisk den torusformede kortoversigt, mens du bevæger dig rundt på kortet (OpenGL). +[ERROR_CANT_SAVE_MAP] +Kortet kunne ikke gemmes. Tjek destinationen og den tilgængelige plads. Dine ændringer er stadig åbne. +[Loading headers] +Indlæser headere... +[Loading teams] +Indlæser hold... +[Loading terrain] +Indlæser terræn... +[Building gradients] +Bygger gradienter... +[Loading players] +Indlæser spillere... +[Loading scripts] +Indlæser scripts... +[Generating map] +Genererer kort... +[ERROR_CANT_GENERATE_MAP] +Kortet kunne ikke genereres. Prøv andre genereringsindstillinger. +[Loading units] +Indlæser enheder... +[Loading buildings] +Indlæser bygninger... +[Resolving team links] +Løser holdforbindelser... +[saving to storage] +Gemmer... +[save failed retry] +Lagring mislykkedes. Prøv igen. +[export save] +Eksportér gemt spil +[storage restore failed] +Browserens lager kunne ikke gendannes. Eksisterende gemte data er ikke ændret. Du kan fortsætte denne session, men lokale ændringer kan ikke bevares. Eksportér vigtige gemte spil fra fejldialogen for lagring. Genindlæs for at prøve at gendanne lageret igen. +[continue] +Fortsæt +[export file] +Eksportér fil +[export failed] +Eksport af fil mislykkedes +[import file] +Importér +[select import file] +Vælg en fil at importere +[validating import] +Validerer fil... +[import succeeded] +Fil importeret +[import cancelled] +Import annulleret +[import failed] +Import mislykkedes: ugyldig eller ikke-understøttet fil +[import persistence failed] +Ikke gemt: vælg Importér for at prøve igen, eller Eksportér fil +[import progress] +Importfremskridt +[export progress] +Eksportfremskridt +[retry save] +Prøv at gemme igen +[campaign save failed] +Kampagnens fremskridt blev ikke gemt. Prøv igen, eller eksportér en sikkerhedskopi. +[leave without saving] +Afslut uden at gemme +[campaign import failed] +Ugyldig fremskridtsfil eller anden kampagneversion +[network release mismatch] +Klient- og serverprotokoller er forskellige. Installér samme Glob2-version som serveren. +[settings continue] +Fortsæt +[settings save failed] +Lagring af indstillinger ikke bekræftet. Prøv igen, eller fortsæt. +[shutdown save failed] +Endelig lagring ikke bekræftet. Prøv igen, eller afslut uden at gemme. +[quit without saving] +Afslut uden at gemme +[game closed] +Spillet er lukket. Du kan lukke dette vindue eller genindlæse for at spille igen. +[campaign editor save failed] +Lagring ikke bekræftet. OK prøver igen; Annuller går tilbage til editormenuen. diff --git a/data/texts.en.txt b/data/texts.en.txt index abc5c8e05..67dbd6cf6 100644 --- a/data/texts.en.txt +++ b/data/texts.en.txt @@ -1764,3 +1764,81 @@ Apply artwork on the next game or editor load (OpenGL). Automatic torus view [settings Automatically show the torus overview while moving around the map (OpenGL).] Automatically show the torus overview while moving around the map (OpenGL). +[ERROR_CANT_SAVE_MAP] +The map could not be saved. Check the destination and available space. Your edits are still open. +[Loading headers] +Loading headers... +[Loading teams] +Loading teams... +[Loading terrain] +Loading terrain... +[Building gradients] +Building gradients... +[Loading players] +Loading players... +[Loading scripts] +Loading scripts... +[Generating map] +Generating map... +[ERROR_CANT_GENERATE_MAP] +The map could not be generated. Try different generation settings. +[Loading units] +Loading units... +[Loading buildings] +Loading buildings... +[Resolving team links] +Resolving team links... +[saving to storage] +Saving... +[save failed retry] +Save failed. Retry. +[export save] +Export save +[storage restore failed] +Browser storage could not be restored. Existing saved data has not been changed. You can continue this session, but local changes cannot be persisted. Export important saves from the save-failure dialog. Reload to try restoring storage again. +[continue] +Continue +[export file] +Export file +[export failed] +File export failed +[import file] +Import +[select import file] +Choose a file to import +[validating import] +Validating file... +[import succeeded] +File imported +[import cancelled] +Import cancelled +[import failed] +Import failed: invalid or unsupported file +[import persistence failed] +Not saved: select Import to retry or Export file +[import progress] +Import progress +[export progress] +Export progress +[retry save] +Retry save +[campaign save failed] +Campaign progress not saved. Retry or export a backup. +[leave without saving] +Leave without saving +[campaign import failed] +Invalid progress file or different campaign version +[network release mismatch] +Client and server protocols differ. Install the same Glob2 release as the server. +[settings continue] +Continue +[settings save failed] +Settings save not confirmed. Retry, or continue. +[shutdown save failed] +Final save not confirmed. Retry, or quit without saving. +[quit without saving] +Quit without saving +[game closed] +Game closed. You can close this window or reload to play again. +[campaign editor save failed] +Save not confirmed. OK retries; Cancel returns to the editor menu. diff --git a/data/texts.eo.txt b/data/texts.eo.txt index 2d8581aef..468299331 100644 --- a/data/texts.eo.txt +++ b/data/texts.eo.txt @@ -1764,3 +1764,81 @@ La bildoj estos aplikitaj ĉe la sekva ŝargo de ludo aŭ mapredaktilo (OpenGL). Aŭtomata tora vido [settings Automatically show the torus overview while moving around the map (OpenGL).] Aŭtomate montru la torforman superrigardon de la mapo dum moviĝado tra la mapo (OpenGL). +[ERROR_CANT_SAVE_MAP] +La mapo ne povis esti konservita. Kontrolu la celon kaj la disponeblan spacon. Viaj redaktoj ankoraŭ estas malfermitaj. +[Loading headers] +Ŝargante kapojn... +[Loading teams] +Ŝargante teamojn... +[Loading terrain] +Ŝargante terenon... +[Building gradients] +Konstruante gradientojn... +[Loading players] +Ŝargante ludantojn... +[Loading scripts] +Ŝargante skriptojn... +[Generating map] +Generante mapon... +[ERROR_CANT_GENERATE_MAP] +La mapo ne povis esti generita. Provu aliajn generajn agordojn. +[Loading units] +Ŝargante unuojn... +[Loading buildings] +Ŝargante konstruaĵojn... +[Resolving team links] +Solvante teamajn ligilojn... +[saving to storage] +Konservante... +[save failed retry] +Konservado malsukcesis. Reprovu. +[export save] +Eksporti konservaĵon +[storage restore failed] +La retumila konservejo ne povis esti restarigita. Ekzistantaj konservitaj datumoj ne ŝanĝiĝis. Vi povas daŭrigi ĉi tiun seancon, sed lokaj ŝanĝoj ne povos esti konservitaj. Eksportu gravajn konservaĵojn el la dialogo pri konserva malsukceso. Reŝargu por reprovi restarigi la konservejon. +[continue] +Daŭrigi +[export file] +Eksporti dosieron +[export failed] +Eksportado de dosiero malsukcesis +[import file] +Importi +[select import file] +Elektu dosieron por importi +[validating import] +Kontrolante dosieron... +[import succeeded] +Dosiero importita +[import cancelled] +Importado nuligita +[import failed] +Importado malsukcesis: nevalida aŭ nesubtenata dosiero +[import persistence failed] +Ne konservita: elektu Importi por reprovi, aŭ Eksporti dosieron +[import progress] +Importa progreso +[export progress] +Eksporta progreso +[retry save] +Reprovi konservadon +[campaign save failed] +Kampanja progreso ne konservita. Reprovu aŭ eksportu sekurkopion. +[leave without saving] +Eliri sen konservi +[campaign import failed] +Nevalida progresa dosiero aŭ malsama kampanja versio +[network release mismatch] +La protokoloj de kliento kaj servilo malsamas. Instalu la saman version de Glob2 kiel la servilo. +[settings continue] +Daŭrigi +[settings save failed] +Konservado de agordoj ne konfirmita. Reprovu, aŭ daŭrigu. +[shutdown save failed] +Fina konservado ne konfirmita. Reprovu, aŭ eliru sen konservi. +[quit without saving] +Eliri sen konservi +[game closed] +La ludo fermiĝis. Vi povas fermi ĉi tiun fenestron aŭ reŝargi por denove ludi. +[campaign editor save failed] +Konservado ne konfirmita. Bone reprovas; Nuligi revenas al la redaktila menuo. diff --git a/data/texts.es.txt b/data/texts.es.txt index 9b3b17b8f..3659e7b2f 100644 --- a/data/texts.es.txt +++ b/data/texts.es.txt @@ -1766,3 +1766,81 @@ Aplica los gráficos al cargar la próxima partida o el editor de mapas (OpenGL) Vista toroidal automática [settings Automatically show the torus overview while moving around the map (OpenGL).] Muestra automáticamente la vista general toroidal mientras te desplazas por el mapa (OpenGL). +[ERROR_CANT_SAVE_MAP] +No se pudo guardar el mapa. Compruebe el destino y el espacio disponible. Sus cambios siguen abiertos. +[Loading headers] +Cargando cabeceras... +[Loading teams] +Cargando equipos... +[Loading terrain] +Cargando terreno... +[Building gradients] +Generando gradientes... +[Loading players] +Cargando jugadores... +[Loading scripts] +Cargando scripts... +[Generating map] +Generando mapa... +[ERROR_CANT_GENERATE_MAP] +No se pudo generar el mapa. Pruebe otras opciones de generación. +[Loading units] +Cargando unidades... +[Loading buildings] +Cargando construcciones... +[Resolving team links] +Resolviendo enlaces de equipo... +[saving to storage] +Guardando... +[save failed retry] +Error al guardar. Reintente. +[export save] +Exportar partida guardada +[storage restore failed] +No se pudo restaurar el almacenamiento del navegador. Los datos guardados existentes no se han modificado. Puede continuar esta sesión, pero los cambios locales no se podrán conservar. Exporte las partidas importantes desde el diálogo de error de guardado. Recargue para intentar restaurar el almacenamiento de nuevo. +[continue] +Continuar +[export file] +Exportar archivo +[export failed] +Error al exportar el archivo +[import file] +Importar +[select import file] +Elija un archivo para importar +[validating import] +Validando archivo... +[import succeeded] +Archivo importado +[import cancelled] +Importación cancelada +[import failed] +Error al importar: archivo inválido o no admitido +[import persistence failed] +No guardado: elija Importar para reintentar o Exportar archivo +[import progress] +Progreso de importación +[export progress] +Progreso de exportación +[retry save] +Reintentar guardado +[campaign save failed] +Progreso de la campaña no guardado. Reintente o exporte una copia de seguridad. +[leave without saving] +Salir sin guardar +[campaign import failed] +Archivo de progreso inválido o versión de campaña diferente +[network release mismatch] +Los protocolos del cliente y del servidor difieren. Instale la misma versión de Glob2 que el servidor. +[settings continue] +Continuar +[settings save failed] +Guardado de ajustes no confirmado. Reintente, o continúe. +[shutdown save failed] +Guardado final no confirmado. Reintente, o salga sin guardar. +[quit without saving] +Salir sin guardar +[game closed] +Partida cerrada. Puede cerrar esta ventana o recargar para volver a jugar. +[campaign editor save failed] +Guardado no confirmado. Aceptar reintenta; Cancelar vuelve al menú del editor. diff --git a/data/texts.eu.txt b/data/texts.eu.txt index 0c2ed27ea..00d998797 100644 --- a/data/texts.eu.txt +++ b/data/texts.eu.txt @@ -1776,3 +1776,81 @@ Erabili hautatutako artelana joko bat edo mapa-editorea kargatzen den hurrengoan Toroide-ikuspegi automatikoa [settings Automatically show the torus overview while moving around the map (OpenGL).] Erakutsi automatikoki toroide formako maparen ikuspegi orokorra mapan mugitzen zaren bitartean (OpenGL). +[ERROR_CANT_SAVE_MAP] +Mapa ezin izan da gorde. Egiaztatu helmuga eta erabilgarri dagoen lekua. Zure edizioak oraindik irekita daude. +[Loading headers] +Goiburuak kargatzen... +[Loading teams] +Taldeak kargatzen... +[Loading terrain] +Lurraldea kargatzen... +[Building gradients] +Gradienteak eraikitzen... +[Loading players] +Jokalariak kargatzen... +[Loading scripts] +Script-ak kargatzen... +[Generating map] +Mapa sortzen... +[ERROR_CANT_GENERATE_MAP] +Mapa ezin izan da sortu. Probatu sortze-ezarpen desberdinak. +[Loading units] +Unitateak kargatzen... +[Loading buildings] +Eraikinak kargatzen... +[Resolving team links] +Taldeen loturak ebazten... +[saving to storage] +Gordetzen... +[save failed retry] +Gordetzeak huts egin du. Saiatu berriro. +[export save] +Esportatu gordetako jokoa +[storage restore failed] +Nabigatzailearen biltegia ezin izan da leheneratu. Lehendik gordetako datuak ez dira aldatu. Saio honekin jarraitu dezakezu, baina tokiko aldaketak ezin izango dira mantendu. Esportatu joko garrantzitsuak gordetze-errorearen elkarrizketa-koadrotik. Berritu orria biltegia berriro leheneratzen saiatzeko. +[continue] +Jarraitu +[export file] +Esportatu fitxategia +[export failed] +Fitxategia esportatzeak huts egin du +[import file] +Inportatu +[select import file] +Aukeratu inportatzeko fitxategi bat +[validating import] +Fitxategia balioztatzen... +[import succeeded] +Fitxategia inportatu da +[import cancelled] +Inportazioa bertan behera utzi da +[import failed] +Inportazioak huts egin du: fitxategi baliogabea edo onartu gabea +[import persistence failed] +Gorde gabe: aukeratu Inportatu berriro saiatzeko, edo Esportatu fitxategia +[import progress] +Inportazioaren aurrerapena +[export progress] +Esportazioaren aurrerapena +[retry save] +Saiatu berriro gordetzen +[campaign save failed] +Kanpainaren aurrerapena ez da gorde. Saiatu berriro edo esportatu babeskopia bat. +[leave without saving] +Irten gorde gabe +[campaign import failed] +Aurrerapen fitxategi baliogabea edo kanpaina bertsio desberdina +[network release mismatch] +Bezeroaren eta zerbitzariaren protokoloak desberdinak dira. Instalatu zerbitzariak duen Glob2 bertsio bera. +[settings continue] +Jarraitu +[settings save failed] +Ezarpenak gordetzea ez da berretsi. Saiatu berriro, edo jarraitu. +[shutdown save failed] +Azken gordetzea ez da berretsi. Saiatu berriro, edo irten gorde gabe. +[quit without saving] +Irten gorde gabe +[game closed] +Jokoa itxi da. Leiho hau itxi dezakezu edo orria berritu berriro jolasteko. +[campaign editor save failed] +Gordetzea ez da berretsi. Ados-ek berriro saiatzen du; Utzi-k editorearen menura itzultzen du. diff --git a/data/texts.fa.txt b/data/texts.fa.txt index 12b85bb48..ef44f1a80 100644 --- a/data/texts.fa.txt +++ b/data/texts.fa.txt @@ -1764,3 +1764,81 @@ OpenGL در این بیلد موجود نیست. نمای خودکار چنبره [settings Automatically show the torus overview while moving around the map (OpenGL).] هنگام حرکت در نقشه (OpenGL) نمای کلی نقشه به شکل چنبره را به صورت خودکار نشان دهید. +[ERROR_CANT_SAVE_MAP] +نقشه ذخیره نشد. مقصد و فضای موجود را بررسی کنید. ویرایش‌های شما هنوز باز است. +[Loading headers] +در حال بارگذاری سرآیندها... +[Loading teams] +در حال بارگذاری تیم‌ها... +[Loading terrain] +در حال بارگذاری زمین... +[Building gradients] +در حال ساخت گرادیان‌ها... +[Loading players] +در حال بارگذاری بازیکنان... +[Loading scripts] +در حال بارگذاری اسکریپت‌ها... +[Generating map] +در حال ساخت نقشه... +[ERROR_CANT_GENERATE_MAP] +نقشه ساخته نشد. تنظیمات ساخت دیگری را امتحان کنید. +[Loading units] +در حال بارگذاری واحدها... +[Loading buildings] +در حال بارگذاری ساختمان‌ها... +[Resolving team links] +در حال حل پیوندهای تیم... +[saving to storage] +در حال ذخیره... +[save failed retry] +ذخیره ناموفق بود. دوباره تلاش کنید. +[export save] +صدور بازی ذخیره‌شده +[storage restore failed] +فضای ذخیره‌سازی مرورگر بازیابی نشد. داده‌های ذخیره‌شده موجود تغییر نکرده‌اند. می‌توانید این جلسه را ادامه دهید، اما تغییرات محلی ماندگار نخواهند شد. بازی‌های مهم را از کادر خطای ذخیره صادر کنید. برای تلاش دوباره برای بازیابی فضای ذخیره‌سازی، صفحه را دوباره بارگذاری کنید. +[continue] +ادامه +[export file] +صدور فایل +[export failed] +صدور فایل ناموفق بود +[import file] +درون‌ریزی +[select import file] +فایلی برای درون‌ریزی انتخاب کنید +[validating import] +در حال بررسی فایل... +[import succeeded] +فایل درون‌ریزی شد +[import cancelled] +درون‌ریزی لغو شد +[import failed] +درون‌ریزی ناموفق بود: فایل نامعتبر یا پشتیبانی‌نشده +[import persistence failed] +ذخیره نشد: درون‌ریزی را برای تلاش دوباره یا صدور فایل را انتخاب کنید +[import progress] +پیشرفت درون‌ریزی +[export progress] +پیشرفت صدور +[retry save] +تلاش دوباره برای ذخیره +[campaign save failed] +پیشرفت کمپین ذخیره نشد. دوباره تلاش کنید یا یک نسخه پشتیبان صادر کنید. +[leave without saving] +خروج بدون ذخیره +[campaign import failed] +فایل پیشرفت نامعتبر یا نسخه کمپین متفاوت است +[network release mismatch] +پروتکل‌های کلاینت و سرور متفاوت است. همان نسخه Glob2 سرور را نصب کنید. +[settings continue] +ادامه +[settings save failed] +ذخیره تنظیمات تأیید نشد. دوباره تلاش کنید یا ادامه دهید. +[shutdown save failed] +ذخیره نهایی تأیید نشد. دوباره تلاش کنید یا بدون ذخیره خارج شوید. +[quit without saving] +خروج بدون ذخیره +[game closed] +بازی بسته شد. می‌توانید این پنجره را ببندید یا صفحه را دوباره بارگذاری کنید تا دوباره بازی کنید. +[campaign editor save failed] +ذخیره تأیید نشد. تأیید دوباره تلاش می‌کند؛ لغو به منوی ویرایشگر بازمی‌گردد. diff --git a/data/texts.fi.txt b/data/texts.fi.txt index 219a9752d..ab78b2e67 100644 --- a/data/texts.fi.txt +++ b/data/texts.fi.txt @@ -1766,3 +1766,81 @@ Ota grafiikka käyttöön, kun seuraava peli tai karttaeditori ladataan (OpenGL) Automaattinen torusnäkymä [settings Automatically show the torus overview while moving around the map (OpenGL).] Näytä toruksen muotoinen karttanäkymä automaattisesti liikkuessasi kartalla (OpenGL). +[ERROR_CANT_SAVE_MAP] +Karttaa ei voitu tallentaa. Tarkista kohde ja käytettävissä oleva tila. Muutoksesi ovat yhä avoinna. +[Loading headers] +Ladataan otsikkotietoja... +[Loading teams] +Ladataan joukkueita... +[Loading terrain] +Ladataan maastoa... +[Building gradients] +Rakennetaan liukuvärejä... +[Loading players] +Ladataan pelaajia... +[Loading scripts] +Ladataan skriptejä... +[Generating map] +Luodaan karttaa... +[ERROR_CANT_GENERATE_MAP] +Karttaa ei voitu luoda. Kokeile eri luontiasetuksia. +[Loading units] +Ladataan yksiköitä... +[Loading buildings] +Ladataan rakennuksia... +[Resolving team links] +Ratkaistaan joukkueiden linkkejä... +[saving to storage] +Tallennetaan... +[save failed retry] +Tallennus epäonnistui. Yritä uudelleen. +[export save] +Vie tallennus +[storage restore failed] +Selaimen tallennustilaa ei voitu palauttaa. Olemassa olevaa tallennettua dataa ei ole muutettu. Voit jatkaa tätä istuntoa, mutta paikallisia muutoksia ei voida säilyttää. Vie tärkeät tallennukset tallennusvirheen valintaikkunasta. Lataa sivu uudelleen yrittääksesi palauttaa tallennustilan uudelleen. +[continue] +Jatka +[export file] +Vie tiedosto +[export failed] +Tiedoston vienti epäonnistui +[import file] +Tuo +[select import file] +Valitse tuotava tiedosto +[validating import] +Tarkistetaan tiedostoa... +[import succeeded] +Tiedosto tuotu +[import cancelled] +Tuonti peruutettu +[import failed] +Tuonti epäonnistui: virheellinen tai tukematon tiedosto +[import persistence failed] +Ei tallennettu: valitse Tuo yrittääksesi uudelleen, tai Vie tiedosto +[import progress] +Tuonnin edistyminen +[export progress] +Viennin edistyminen +[retry save] +Yritä tallennusta uudelleen +[campaign save failed] +Kampanjan edistymistä ei tallennettu. Yritä uudelleen tai vie varmuuskopio. +[leave without saving] +Poistu tallentamatta +[campaign import failed] +Virheellinen edistymistiedosto tai eri kampanjaversio +[network release mismatch] +Asiakkaan ja palvelimen protokollat eroavat. Asenna sama Glob2-versio kuin palvelimella. +[settings continue] +Jatka +[settings save failed] +Asetusten tallennusta ei vahvistettu. Yritä uudelleen tai jatka. +[shutdown save failed] +Lopullista tallennusta ei vahvistettu. Yritä uudelleen tai poistu tallentamatta. +[quit without saving] +Poistu tallentamatta +[game closed] +Peli suljettu. Voit sulkea tämän ikkunan tai ladata sivun uudelleen pelataksesi taas. +[campaign editor save failed] +Tallennusta ei vahvistettu. OK yrittää uudelleen; Peruuta palaa editorin valikkoon. diff --git a/data/texts.fr.txt b/data/texts.fr.txt index 5d0c5bba1..366b3d35c 100644 --- a/data/texts.fr.txt +++ b/data/texts.fr.txt @@ -1776,3 +1776,81 @@ Appliquer les graphismes au prochain chargement d’une partie ou de l’éditeu Vue torique automatique [settings Automatically show the torus overview while moving around the map (OpenGL).] Afficher automatiquement l'aperçu de la carte en forme de tore tout en vous déplaçant sur la carte (OpenGL). +[ERROR_CANT_SAVE_MAP] +La carte n'a pas pu être sauvegardée. Vérifiez la destination et l'espace disponible. Vos modifications sont toujours ouvertes. +[Loading headers] +Chargement des en-têtes... +[Loading teams] +Chargement des équipes... +[Loading terrain] +Chargement du terrain... +[Building gradients] +Génération des dégradés... +[Loading players] +Chargement des joueurs... +[Loading scripts] +Chargement des scripts... +[Generating map] +Génération de la carte... +[ERROR_CANT_GENERATE_MAP] +La carte n'a pas pu être générée. Essayez d'autres paramètres de génération. +[Loading units] +Chargement des unités... +[Loading buildings] +Chargement des constructions... +[Resolving team links] +Résolution des liens d'équipe... +[saving to storage] +Sauvegarde... +[save failed retry] +Échec de la sauvegarde. Réessayez. +[export save] +Exporter la sauvegarde +[storage restore failed] +Le stockage du navigateur n'a pas pu être restauré. Les données sauvegardées existantes n'ont pas été modifiées. Vous pouvez continuer cette session, mais les modifications locales ne pourront pas être conservées. Exportez les sauvegardes importantes depuis la fenêtre d'échec de sauvegarde. Rechargez pour retenter la restauration du stockage. +[continue] +Continuer +[export file] +Exporter le fichier +[export failed] +Échec de l'exportation du fichier +[import file] +Importer +[select import file] +Choisissez un fichier à importer +[validating import] +Validation du fichier... +[import succeeded] +Fichier importé +[import cancelled] +Importation annulée +[import failed] +Échec de l'importation : fichier invalide ou non pris en charge +[import persistence failed] +Non sauvegardé : choisissez Importer pour réessayer ou Exporter le fichier +[import progress] +Progression de l'importation +[export progress] +Progression de l'exportation +[retry save] +Réessayer la sauvegarde +[campaign save failed] +Progression de la campagne non sauvegardée. Réessayez ou exportez une sauvegarde. +[leave without saving] +Quitter sans sauvegarder +[campaign import failed] +Fichier de progression invalide ou version de campagne différente +[network release mismatch] +Les protocoles du client et du serveur diffèrent. Installez la même version de Glob2 que le serveur. +[settings continue] +Continuer +[settings save failed] +Sauvegarde des paramètres non confirmée. Réessayez, ou continuez. +[shutdown save failed] +Sauvegarde finale non confirmée. Réessayez, ou quittez sans sauvegarder. +[quit without saving] +Quitter sans sauvegarder +[game closed] +Partie terminée. Vous pouvez fermer cette fenêtre ou recharger pour rejouer. +[campaign editor save failed] +Sauvegarde non confirmée. OK réessaie ; Annuler revient au menu de l'éditeur. diff --git a/data/texts.gr.txt b/data/texts.gr.txt index 63643dfc0..e6050a0c3 100644 --- a/data/texts.gr.txt +++ b/data/texts.gr.txt @@ -1836,3 +1836,81 @@ Cortex Αυτόματη τοροειδής προβολή [settings Automatically show the torus overview while moving around the map (OpenGL).] Αυτόματη εμφάνιση της επισκόπησης του χάρτη σε σχήμα τόρου ενώ μετακινείστε στον χάρτη (OpenGL). +[ERROR_CANT_SAVE_MAP] +Ο χάρτης δεν ήταν δυνατό να αποθηκευτεί. Ελέγξτε τον προορισμό και τον διαθέσιμο χώρο. Οι αλλαγές σας παραμένουν ανοιχτές. +[Loading headers] +Φόρτωση κεφαλίδων... +[Loading teams] +Φόρτωση ομάδων... +[Loading terrain] +Φόρτωση εδάφους... +[Building gradients] +Δημιουργία διαβαθμίσεων... +[Loading players] +Φόρτωση παικτών... +[Loading scripts] +Φόρτωση σεναρίων... +[Generating map] +Δημιουργία χάρτη... +[ERROR_CANT_GENERATE_MAP] +Ο χάρτης δεν ήταν δυνατό να δημιουργηθεί. Δοκιμάστε άλλες ρυθμίσεις δημιουργίας. +[Loading units] +Φόρτωση μονάδων... +[Loading buildings] +Φόρτωση κτιρίων... +[Resolving team links] +Επίλυση συνδέσμων ομάδας... +[saving to storage] +Αποθήκευση... +[save failed retry] +Η αποθήκευση απέτυχε. Δοκιμάστε ξανά. +[export save] +Εξαγωγή αποθηκευμένου παιχνιδιού +[storage restore failed] +Δεν ήταν δυνατή η επαναφορά του χώρου αποθήκευσης του προγράμματος περιήγησης. Τα υπάρχοντα αποθηκευμένα δεδομένα δεν έχουν αλλάξει. Μπορείτε να συνεχίσετε αυτήν τη συνεδρία, αλλά οι τοπικές αλλαγές δεν θα μπορούν να διατηρηθούν. Εξαγάγετε σημαντικά αποθηκευμένα παιχνίδια από το παράθυρο διαλόγου σφάλματος αποθήκευσης. Επαναφορτώστε για να δοκιμάσετε ξανά την επαναφορά του χώρου αποθήκευσης. +[continue] +Συνέχεια +[export file] +Εξαγωγή αρχείου +[export failed] +Η εξαγωγή αρχείου απέτυχε +[import file] +Εισαγωγή +[select import file] +Επιλέξτε αρχείο για εισαγωγή +[validating import] +Επικύρωση αρχείου... +[import succeeded] +Το αρχείο εισήχθη +[import cancelled] +Η εισαγωγή ακυρώθηκε +[import failed] +Η εισαγωγή απέτυχε: μη έγκυρο ή μη υποστηριζόμενο αρχείο +[import persistence failed] +Δεν αποθηκεύτηκε: επιλέξτε Εισαγωγή για νέα προσπάθεια, ή Εξαγωγή αρχείου +[import progress] +Πρόοδος εισαγωγής +[export progress] +Πρόοδος εξαγωγής +[retry save] +Νέα προσπάθεια αποθήκευσης +[campaign save failed] +Η πρόοδος της εκστρατείας δεν αποθηκεύτηκε. Δοκιμάστε ξανά ή εξαγάγετε αντίγραφο ασφαλείας. +[leave without saving] +Έξοδος χωρίς αποθήκευση +[campaign import failed] +Μη έγκυρο αρχείο προόδου ή διαφορετική έκδοση εκστρατείας +[network release mismatch] +Τα πρωτόκολλα πελάτη και διακομιστή διαφέρουν. Εγκαταστήστε την ίδια έκδοση Glob2 με τον διακομιστή. +[settings continue] +Συνέχεια +[settings save failed] +Η αποθήκευση ρυθμίσεων δεν επιβεβαιώθηκε. Δοκιμάστε ξανά, ή συνεχίστε. +[shutdown save failed] +Η τελική αποθήκευση δεν επιβεβαιώθηκε. Δοκιμάστε ξανά, ή εξέλθετε χωρίς αποθήκευση. +[quit without saving] +Έξοδος χωρίς αποθήκευση +[game closed] +Το παιχνίδι έκλεισε. Μπορείτε να κλείσετε αυτό το παράθυρο ή να επαναφορτώσετε τη σελίδα για να παίξετε ξανά. +[campaign editor save failed] +Η αποθήκευση δεν επιβεβαιώθηκε. Το OK ξαναδοκιμάζει· η Ακύρωση επιστρέφει στο μενού του επεξεργαστή. diff --git a/data/texts.hu.txt b/data/texts.hu.txt index ba3f8442a..c2074a53b 100644 --- a/data/texts.hu.txt +++ b/data/texts.hu.txt @@ -1766,3 +1766,81 @@ A kiválasztott grafika a játék vagy a térképszerkesztő következő betölt Automatikus tórusznézet [settings Automatically show the torus overview while moving around the map (OpenGL).] A tórusz alakú térkép áttekintésének automatikus megjelenítése a térképen való mozgás közben (OpenGL). +[ERROR_CANT_SAVE_MAP] +A térképet nem sikerült menteni. Ellenőrizze a célt és a rendelkezésre álló helyet. A szerkesztések továbbra is nyitva vannak. +[Loading headers] +Fejlécek betöltése... +[Loading teams] +Csapatok betöltése... +[Loading terrain] +Terep betöltése... +[Building gradients] +Színátmenetek építése... +[Loading players] +Játékosok betöltése... +[Loading scripts] +Szkriptek betöltése... +[Generating map] +Térkép generálása... +[ERROR_CANT_GENERATE_MAP] +A térképet nem sikerült legenerálni. Próbáljon más generálási beállításokat. +[Loading units] +Egységek betöltése... +[Loading buildings] +Épületek betöltése... +[Resolving team links] +Csapatkapcsolatok feloldása... +[saving to storage] +Mentés... +[save failed retry] +A mentés sikertelen. Próbálja újra. +[export save] +Mentés exportálása +[storage restore failed] +A böngésző tárolóját nem sikerült helyreállítani. A meglévő mentett adatok nem változtak. Folytathatja ezt a munkamenetet, de a helyi változtatások nem menthetők meg tartósan. Exportálja a fontos mentéseket a mentési hiba párbeszédpaneléből. Töltse újra az oldalt, hogy újra megpróbálja helyreállítani a tárolót. +[continue] +Folytatás +[export file] +Fájl exportálása +[export failed] +A fájl exportálása sikertelen +[import file] +Importálás +[select import file] +Válasszon importálandó fájlt +[validating import] +Fájl ellenőrzése... +[import succeeded] +Fájl importálva +[import cancelled] +Az importálás megszakítva +[import failed] +Az importálás sikertelen: érvénytelen vagy nem támogatott fájl +[import persistence failed] +Nincs mentve: válassza az Importálás lehetőséget az újrapróbálkozáshoz, vagy a Fájl exportálását +[import progress] +Importálás folyamata +[export progress] +Exportálás folyamata +[retry save] +Mentés újrapróbálása +[campaign save failed] +A hadjárat haladása nem mentődött. Próbálja újra, vagy exportáljon biztonsági mentést. +[leave without saving] +Kilépés mentés nélkül +[campaign import failed] +Érvénytelen haladásfájl vagy eltérő hadjáratverzió +[network release mismatch] +A kliens és a szerver protokollja eltér. Telepítse a szerverrel megegyező Glob2-verziót. +[settings continue] +Folytatás +[settings save failed] +A beállítások mentése nincs megerősítve. Próbálja újra, vagy folytassa. +[shutdown save failed] +A végső mentés nincs megerősítve. Próbálja újra, vagy lépjen ki mentés nélkül. +[quit without saving] +Kilépés mentés nélkül +[game closed] +A játék bezárult. Bezárhatja ezt az ablakot, vagy újratöltheti az oldalt az ismételt játékhoz. +[campaign editor save failed] +A mentés nincs megerősítve. Az OK újra próbálkozik; a Mégse visszatér a szerkesztő menüjébe. diff --git a/data/texts.id.txt b/data/texts.id.txt index 73bab062d..b5b724945 100644 --- a/data/texts.id.txt +++ b/data/texts.id.txt @@ -1762,3 +1762,81 @@ Grafis diterapkan saat permainan atau editor peta dimuat berikutnya (OpenGL). Tampilan torus otomatis [settings Automatically show the torus overview while moving around the map (OpenGL).] Secara otomatis menampilkan ikhtisar peta berbentuk torus saat bergerak di sekitar peta (OpenGL). +[ERROR_CANT_SAVE_MAP] +Peta tidak dapat disimpan. Periksa tujuan dan ruang yang tersedia. Perubahan Anda masih terbuka. +[Loading headers] +Memuat header... +[Loading teams] +Memuat tim... +[Loading terrain] +Memuat medan... +[Building gradients] +Membangun gradien... +[Loading players] +Memuat pemain... +[Loading scripts] +Memuat skrip... +[Generating map] +Membuat peta... +[ERROR_CANT_GENERATE_MAP] +Peta tidak dapat dibuat. Coba pengaturan pembuatan yang lain. +[Loading units] +Memuat unit... +[Loading buildings] +Memuat bangunan... +[Resolving team links] +Menyelesaikan tautan tim... +[saving to storage] +Menyimpan... +[save failed retry] +Penyimpanan gagal. Coba lagi. +[export save] +Ekspor simpanan +[storage restore failed] +Penyimpanan peramban tidak dapat dipulihkan. Data tersimpan yang ada tidak berubah. Anda dapat melanjutkan sesi ini, tetapi perubahan lokal tidak akan tersimpan. Ekspor simpanan penting dari dialog kegagalan penyimpanan. Muat ulang untuk mencoba memulihkan penyimpanan lagi. +[continue] +Lanjutkan +[export file] +Ekspor berkas +[export failed] +Ekspor berkas gagal +[import file] +Impor +[select import file] +Pilih berkas untuk diimpor +[validating import] +Memvalidasi berkas... +[import succeeded] +Berkas diimpor +[import cancelled] +Impor dibatalkan +[import failed] +Impor gagal: berkas tidak valid atau tidak didukung +[import persistence failed] +Belum tersimpan: pilih Impor untuk mencoba lagi, atau Ekspor berkas +[import progress] +Progres impor +[export progress] +Progres ekspor +[retry save] +Coba simpan lagi +[campaign save failed] +Progres kampanye tidak tersimpan. Coba lagi atau ekspor cadangan. +[leave without saving] +Keluar tanpa menyimpan +[campaign import failed] +Berkas progres tidak valid atau versi kampanye berbeda +[network release mismatch] +Protokol klien dan server berbeda. Pasang versi Glob2 yang sama dengan server. +[settings continue] +Lanjutkan +[settings save failed] +Penyimpanan pengaturan belum dikonfirmasi. Coba lagi, atau lanjutkan. +[shutdown save failed] +Penyimpanan akhir belum dikonfirmasi. Coba lagi, atau keluar tanpa menyimpan. +[quit without saving] +Keluar tanpa menyimpan +[game closed] +Permainan ditutup. Anda dapat menutup jendela ini atau memuat ulang untuk bermain lagi. +[campaign editor save failed] +Penyimpanan belum dikonfirmasi. OK akan mencoba lagi; Batal kembali ke menu editor. diff --git a/data/texts.it.txt b/data/texts.it.txt index d1f94de42..4b94c7c4a 100644 --- a/data/texts.it.txt +++ b/data/texts.it.txt @@ -1828,3 +1828,81 @@ Applica la grafica al prossimo caricamento di una partita o dell’editor di map Vista toroidale automatica [settings Automatically show the torus overview while moving around the map (OpenGL).] Mostra automaticamente la panoramica toroidale mentre ti sposti sulla mappa (OpenGL). +[ERROR_CANT_SAVE_MAP] +Impossibile salvare la mappa. Controlla la destinazione e lo spazio disponibile. Le modifiche sono ancora aperte. +[Loading headers] +Caricamento intestazioni... +[Loading teams] +Caricamento squadre... +[Loading terrain] +Caricamento terreno... +[Building gradients] +Generazione dei gradienti... +[Loading players] +Caricamento giocatori... +[Loading scripts] +Caricamento script... +[Generating map] +Generazione della mappa... +[ERROR_CANT_GENERATE_MAP] +Impossibile generare la mappa. Prova altre impostazioni di generazione. +[Loading units] +Caricamento unità... +[Loading buildings] +Caricamento edifici... +[Resolving team links] +Risoluzione dei collegamenti tra squadre... +[saving to storage] +Salvataggio... +[save failed retry] +Salvataggio non riuscito. Riprova. +[export save] +Esporta salvataggio +[storage restore failed] +Impossibile ripristinare l'archiviazione del browser. I dati salvati esistenti non sono stati modificati. Puoi continuare questa sessione, ma le modifiche locali non potranno essere mantenute. Esporta i salvataggi importanti dalla finestra di errore di salvataggio. Ricarica per tentare di nuovo il ripristino dell'archiviazione. +[continue] +Continua +[export file] +Esporta file +[export failed] +Esportazione del file non riuscita +[import file] +Importa +[select import file] +Scegli un file da importare +[validating import] +Verifica del file... +[import succeeded] +File importato +[import cancelled] +Importazione annullata +[import failed] +Importazione non riuscita: file non valido o non supportato +[import persistence failed] +Non salvato: scegli Importa per riprovare o Esporta file +[import progress] +Avanzamento importazione +[export progress] +Avanzamento esportazione +[retry save] +Riprova il salvataggio +[campaign save failed] +Progresso della campagna non salvato. Riprova o esporta un backup. +[leave without saving] +Esci senza salvare +[campaign import failed] +File di progresso non valido o versione della campagna diversa +[network release mismatch] +I protocolli client e server differiscono. Installa la stessa versione di Glob2 del server. +[settings continue] +Continua +[settings save failed] +Salvataggio delle impostazioni non confermato. Riprova, oppure continua. +[shutdown save failed] +Salvataggio finale non confermato. Riprova, oppure esci senza salvare. +[quit without saving] +Esci senza salvare +[game closed] +Partita chiusa. Puoi chiudere questa finestra o ricaricare per giocare di nuovo. +[campaign editor save failed] +Salvataggio non confermato. Ok riprova; Annulla torna al menu dell'editor. diff --git a/data/texts.ja.txt b/data/texts.ja.txt index 59f1b0326..5b37450e0 100644 --- a/data/texts.ja.txt +++ b/data/texts.ja.txt @@ -1762,3 +1762,81 @@ Globulation 2 のカーソルを表示します。 自動トーラス表示 [settings Automatically show the torus overview while moving around the map (OpenGL).] マップ内を移動するときに、トーラス(ドーナツ)形状のマップ全体図を自動的に表示します (OpenGL)。 +[ERROR_CANT_SAVE_MAP] +マップを保存できませんでした。保存先と空き容量を確認してください。編集内容はまだ開いています。 +[Loading headers] +ヘッダーを読み込んでいます... +[Loading teams] +チームを読み込んでいます... +[Loading terrain] +地形を読み込んでいます... +[Building gradients] +勾配を構築しています... +[Loading players] +プレイヤーを読み込んでいます... +[Loading scripts] +スクリプトを読み込んでいます... +[Generating map] +マップを生成しています... +[ERROR_CANT_GENERATE_MAP] +マップを生成できませんでした。別の生成設定を試してください。 +[Loading units] +ユニットを読み込んでいます... +[Loading buildings] +建物を読み込んでいます... +[Resolving team links] +チームのリンクを解決しています... +[saving to storage] +保存しています... +[save failed retry] +保存に失敗しました。再試行してください。 +[export save] +セーブデータをエクスポート +[storage restore failed] +ブラウザのストレージを復元できませんでした。既存の保存データは変更されていません。このセッションは続行できますが、ローカルの変更は保存できません。保存失敗ダイアログから重要なセーブデータをエクスポートしてください。再読み込みしてストレージの復元を再試行してください。 +[continue] +続ける +[export file] +ファイルをエクスポート +[export failed] +ファイルのエクスポートに失敗しました +[import file] +インポート +[select import file] +インポートするファイルを選択してください +[validating import] +ファイルを検証しています... +[import succeeded] +ファイルをインポートしました +[import cancelled] +インポートをキャンセルしました +[import failed] +インポートに失敗しました: 無効またはサポートされていないファイルです +[import persistence failed] +未保存: 再試行するには「インポート」、またはファイルをエクスポートしてください +[import progress] +インポートの進行状況 +[export progress] +エクスポートの進行状況 +[retry save] +保存を再試行 +[campaign save failed] +キャンペーンの進行状況が保存されていません。再試行するか、バックアップをエクスポートしてください。 +[leave without saving] +保存せずに終了 +[campaign import failed] +進行データが無効か、キャンペーンのバージョンが異なります +[network release mismatch] +クライアントとサーバーのプロトコルが異なります。サーバーと同じGlob2のバージョンをインストールしてください。 +[settings continue] +続ける +[settings save failed] +設定の保存が確認されていません。再試行するか、続けてください。 +[shutdown save failed] +最終保存が確認されていません。再試行するか、保存せずに終了してください。 +[quit without saving] +保存せずに終了 +[game closed] +ゲームが終了しました。このウィンドウを閉じるか、再読み込みして再度プレイできます。 +[campaign editor save failed] +保存が確認されていません。OKで再試行、キャンセルでエディタメニューに戻ります。 diff --git a/data/texts.keys.txt b/data/texts.keys.txt index a1fe56a5c..b5aead429 100644 --- a/data/texts.keys.txt +++ b/data/texts.keys.txt @@ -880,3 +880,42 @@ [settings Apply artwork on the next game or editor load (OpenGL).] [settings Automatic torus view] [settings Automatically show the torus overview while moving around the map (OpenGL).] +[ERROR_CANT_SAVE_MAP] +[Loading headers] +[Loading teams] +[Loading terrain] +[Building gradients] +[Loading players] +[Loading scripts] +[Generating map] +[ERROR_CANT_GENERATE_MAP] +[Loading units] +[Loading buildings] +[Resolving team links] +[saving to storage] +[save failed retry] +[export save] +[storage restore failed] +[continue] +[export file] +[export failed] +[import file] +[select import file] +[validating import] +[import succeeded] +[import cancelled] +[import failed] +[import persistence failed] +[import progress] +[export progress] +[retry save] +[campaign save failed] +[leave without saving] +[campaign import failed] +[network release mismatch] +[settings continue] +[settings save failed] +[shutdown save failed] +[quit without saving] +[game closed] +[campaign editor save failed] diff --git a/data/texts.ko.txt b/data/texts.ko.txt index a30df9dbd..a3efdcaea 100644 --- a/data/texts.ko.txt +++ b/data/texts.ko.txt @@ -1762,3 +1762,81 @@ Globulation 2 커서를 표시합니다. 자동 토러스 보기 [settings Automatically show the torus overview while moving around the map (OpenGL).] 지도 안에서 이동할 때 토러스(도넛) 모양의 지도 전체 보기를 자동으로 표시합니다(OpenGL). +[ERROR_CANT_SAVE_MAP] +맵을 저장할 수 없습니다. 저장 위치와 사용 가능한 공간을 확인하세요. 편집 내용은 아직 열려 있습니다. +[Loading headers] +헤더 불러오는 중... +[Loading teams] +팀 불러오는 중... +[Loading terrain] +지형 불러오는 중... +[Building gradients] +그라디언트 생성 중... +[Loading players] +플레이어 불러오는 중... +[Loading scripts] +스크립트 불러오는 중... +[Generating map] +맵 생성 중... +[ERROR_CANT_GENERATE_MAP] +맵을 생성할 수 없습니다. 다른 생성 설정을 시도해 보세요. +[Loading units] +유닛 불러오는 중... +[Loading buildings] +건물 불러오는 중... +[Resolving team links] +팀 연결 해석 중... +[saving to storage] +저장 중... +[save failed retry] +저장에 실패했습니다. 다시 시도하세요. +[export save] +저장 게임 내보내기 +[storage restore failed] +브라우저 저장소를 복원할 수 없습니다. 기존에 저장된 데이터는 변경되지 않았습니다. 이 세션을 계속할 수 있지만 로컬 변경 사항은 유지되지 않습니다. 저장 실패 대화상자에서 중요한 저장 파일을 내보내세요. 저장소 복원을 다시 시도하려면 새로고침하세요. +[continue] +계속 +[export file] +파일 내보내기 +[export failed] +파일 내보내기에 실패했습니다 +[import file] +가져오기 +[select import file] +가져올 파일을 선택하세요 +[validating import] +파일 확인 중... +[import succeeded] +파일을 가져왔습니다 +[import cancelled] +가져오기가 취소되었습니다 +[import failed] +가져오기 실패: 잘못되었거나 지원되지 않는 파일입니다 +[import persistence failed] +저장되지 않음: 다시 시도하려면 가져오기, 또는 파일 내보내기를 선택하세요 +[import progress] +가져오기 진행 상황 +[export progress] +내보내기 진행 상황 +[retry save] +저장 다시 시도 +[campaign save failed] +캠페인 진행 상황이 저장되지 않았습니다. 다시 시도하거나 백업을 내보내세요. +[leave without saving] +저장하지 않고 나가기 +[campaign import failed] +진행 파일이 잘못되었거나 캠페인 버전이 다릅니다 +[network release mismatch] +클라이언트와 서버의 프로토콜이 다릅니다. 서버와 동일한 Glob2 버전을 설치하세요. +[settings continue] +계속 +[settings save failed] +설정 저장이 확인되지 않았습니다. 다시 시도하거나 계속하세요. +[shutdown save failed] +최종 저장이 확인되지 않았습니다. 다시 시도하거나 저장하지 않고 나가세요. +[quit without saving] +저장하지 않고 나가기 +[game closed] +게임이 종료되었습니다. 이 창을 닫거나 새로고침하여 다시 플레이할 수 있습니다. +[campaign editor save failed] +저장이 확인되지 않았습니다. 확인은 다시 시도하고, 취소는 편집기 메뉴로 돌아갑니다. diff --git a/data/texts.nl.txt b/data/texts.nl.txt index 1f54e7dd1..19648d67f 100644 --- a/data/texts.nl.txt +++ b/data/texts.nl.txt @@ -1790,3 +1790,81 @@ Pas de grafische beelden toe wanneer een volgende partij of de kaarteditor wordt Automatische torusweergave [settings Automatically show the torus overview while moving around the map (OpenGL).] Toon automatisch het torusvormige kaartoverzicht terwijl u over de kaart beweegt (OpenGL). +[ERROR_CANT_SAVE_MAP] +De kaart kon niet worden opgeslagen. Controleer de bestemming en beschikbare ruimte. Uw wijzigingen staan nog open. +[Loading headers] +Kopgegevens worden geladen... +[Loading teams] +Teams worden geladen... +[Loading terrain] +Terrein wordt geladen... +[Building gradients] +Gradiënten worden opgebouwd... +[Loading players] +Spelers worden geladen... +[Loading scripts] +Scripts worden geladen... +[Generating map] +Kaart wordt gegenereerd... +[ERROR_CANT_GENERATE_MAP] +De kaart kon niet worden gegenereerd. Probeer andere generatie-instellingen. +[Loading units] +Eenheden worden geladen... +[Loading buildings] +Gebouwen worden geladen... +[Resolving team links] +Teamkoppelingen worden herleid... +[saving to storage] +Bezig met opslaan... +[save failed retry] +Opslaan mislukt. Probeer opnieuw. +[export save] +Opgeslagen spel exporteren +[storage restore failed] +De browseropslag kon niet worden hersteld. Bestaande opgeslagen gegevens zijn niet gewijzigd. U kunt deze sessie voortzetten, maar lokale wijzigingen kunnen niet worden bewaard. Exporteer belangrijke opgeslagen spellen via het opslagfout-dialoogvenster. Herlaad om het herstellen van de opslag opnieuw te proberen. +[continue] +Doorgaan +[export file] +Bestand exporteren +[export failed] +Bestand exporteren mislukt +[import file] +Importeren +[select import file] +Kies een bestand om te importeren +[validating import] +Bestand wordt gecontroleerd... +[import succeeded] +Bestand geïmporteerd +[import cancelled] +Importeren geannuleerd +[import failed] +Importeren mislukt: ongeldig of niet-ondersteund bestand +[import persistence failed] +Niet opgeslagen: kies Importeren om opnieuw te proberen of Bestand exporteren +[import progress] +Voortgang van import +[export progress] +Voortgang van export +[retry save] +Opslaan opnieuw proberen +[campaign save failed] +Campagnevoortgang niet opgeslagen. Probeer opnieuw of exporteer een back-up. +[leave without saving] +Afsluiten zonder op te slaan +[campaign import failed] +Ongeldig voortgangsbestand of andere campagneversie +[network release mismatch] +Client- en serverprotocol verschillen. Installeer dezelfde Glob2-versie als de server. +[settings continue] +Doorgaan +[settings save failed] +Opslaan van instellingen niet bevestigd. Probeer opnieuw, of ga door. +[shutdown save failed] +Laatste keer opslaan niet bevestigd. Probeer opnieuw, of sluit af zonder op te slaan. +[quit without saving] +Afsluiten zonder op te slaan +[game closed] +Spel gesloten. U kunt dit venster sluiten of herladen om opnieuw te spelen. +[campaign editor save failed] +Opslaan niet bevestigd. OK probeert opnieuw; Annuleren gaat terug naar het editormenu. diff --git a/data/texts.pl.txt b/data/texts.pl.txt index 63df2cad3..6bafef3d1 100644 --- a/data/texts.pl.txt +++ b/data/texts.pl.txt @@ -1766,3 +1766,81 @@ Wybrana grafika zostanie zastosowana przy następnym załadowaniu gry lub edytor Automatyczny widok torusa [settings Automatically show the torus overview while moving around the map (OpenGL).] Automatycznie pokazuj przegląd mapy w kształcie torusa podczas poruszania się po mapie (OpenGL). +[ERROR_CANT_SAVE_MAP] +Nie udało się zapisać mapy. Sprawdź miejsce docelowe i dostępną przestrzeń. Twoje zmiany wciąż są otwarte. +[Loading headers] +Wczytywanie nagłówków... +[Loading teams] +Wczytywanie drużyn... +[Loading terrain] +Wczytywanie terenu... +[Building gradients] +Tworzenie gradientów... +[Loading players] +Wczytywanie graczy... +[Loading scripts] +Wczytywanie skryptów... +[Generating map] +Generowanie mapy... +[ERROR_CANT_GENERATE_MAP] +Nie udało się wygenerować mapy. Spróbuj innych ustawień generowania. +[Loading units] +Wczytywanie jednostek... +[Loading buildings] +Wczytywanie budynków... +[Resolving team links] +Rozwiązywanie powiązań drużyn... +[saving to storage] +Zapisywanie... +[save failed retry] +Zapis nieudany. Spróbuj ponownie. +[export save] +Eksportuj zapis +[storage restore failed] +Nie udało się przywrócić pamięci przeglądarki. Istniejące zapisane dane nie zostały zmienione. Możesz kontynuować tę sesję, ale zmiany lokalne nie zostaną zachowane. Wyeksportuj ważne zapisy z okna błędu zapisu. Przeładuj stronę, aby ponowić przywracanie pamięci. +[continue] +Kontynuuj +[export file] +Eksportuj plik +[export failed] +Eksport pliku nie powiódł się +[import file] +Importuj +[select import file] +Wybierz plik do zaimportowania +[validating import] +Sprawdzanie pliku... +[import succeeded] +Plik zaimportowany +[import cancelled] +Import anulowany +[import failed] +Import nie powiódł się: nieprawidłowy lub nieobsługiwany plik +[import persistence failed] +Niezapisane: wybierz Importuj, aby spróbować ponownie, lub Eksportuj plik +[import progress] +Postęp importu +[export progress] +Postęp eksportu +[retry save] +Ponów zapis +[campaign save failed] +Postęp kampanii nie został zapisany. Spróbuj ponownie lub wyeksportuj kopię zapasową. +[leave without saving] +Wyjdź bez zapisywania +[campaign import failed] +Nieprawidłowy plik postępu lub inna wersja kampanii +[network release mismatch] +Protokoły klienta i serwera się różnią. Zainstaluj taką samą wersję Glob2 jak na serwerze. +[settings continue] +Kontynuuj +[settings save failed] +Zapis ustawień niepotwierdzony. Spróbuj ponownie lub kontynuuj. +[shutdown save failed] +Zapis końcowy niepotwierdzony. Spróbuj ponownie lub wyjdź bez zapisywania. +[quit without saving] +Wyjdź bez zapisywania +[game closed] +Gra zamknięta. Możesz zamknąć to okno lub przeładować stronę, aby zagrać ponownie. +[campaign editor save failed] +Zapis niepotwierdzony. OK ponawia próbę; Anuluj wraca do menu edytora. diff --git a/data/texts.pt.txt b/data/texts.pt.txt index 5a7e362a0..ff066df38 100644 --- a/data/texts.pt.txt +++ b/data/texts.pt.txt @@ -1772,3 +1772,81 @@ Aplicar os gráficos ao carregar a próxima partida ou o editor de mapas (OpenGL Vista toroidal automática [settings Automatically show the torus overview while moving around the map (OpenGL).] Mostrar automaticamente a vista geral toroidal enquanto se desloca pelo mapa (OpenGL). +[ERROR_CANT_SAVE_MAP] +Não foi possível gravar o mapa. Verifique o destino e o espaço disponível. As suas edições continuam abertas. +[Loading headers] +A carregar cabeçalhos... +[Loading teams] +A carregar equipas... +[Loading terrain] +A carregar o terreno... +[Building gradients] +A gerar gradientes... +[Loading players] +A carregar jogadores... +[Loading scripts] +A carregar scripts... +[Generating map] +A gerar o mapa... +[ERROR_CANT_GENERATE_MAP] +Não foi possível gerar o mapa. Experimente outras definições de geração. +[Loading units] +A carregar unidades... +[Loading buildings] +A carregar construções... +[Resolving team links] +A resolver ligações de equipa... +[saving to storage] +A gravar... +[save failed retry] +Falha ao gravar. Tente novamente. +[export save] +Exportar jogo gravado +[storage restore failed] +Não foi possível restaurar o armazenamento do navegador. Os dados gravados existentes não foram alterados. Pode continuar esta sessão, mas as alterações locais não poderão ser mantidas. Exporte os jogos importantes a partir da caixa de diálogo de falha de gravação. Recarregue para tentar restaurar o armazenamento novamente. +[continue] +Continuar +[export file] +Exportar ficheiro +[export failed] +Falha ao exportar o ficheiro +[import file] +Importar +[select import file] +Escolha um ficheiro para importar +[validating import] +A validar o ficheiro... +[import succeeded] +Ficheiro importado +[import cancelled] +Importação cancelada +[import failed] +Falha na importação: ficheiro inválido ou não suportado +[import persistence failed] +Não gravado: escolha Importar para tentar novamente ou Exportar ficheiro +[import progress] +Progresso da importação +[export progress] +Progresso da exportação +[retry save] +Tentar gravar novamente +[campaign save failed] +Progresso da campanha não gravado. Tente novamente ou exporte uma cópia de segurança. +[leave without saving] +Sair sem gravar +[campaign import failed] +Ficheiro de progresso inválido ou versão de campanha diferente +[network release mismatch] +Os protocolos do cliente e do servidor são diferentes. Instale a mesma versão do Glob2 que o servidor. +[settings continue] +Continuar +[settings save failed] +Gravação das definições não confirmada. Tente novamente, ou continue. +[shutdown save failed] +Gravação final não confirmada. Tente novamente, ou saia sem gravar. +[quit without saving] +Sair sem gravar +[game closed] +Jogo fechado. Pode fechar esta janela ou recarregar para jogar novamente. +[campaign editor save failed] +Gravação não confirmada. OK repete a tentativa; Cancelar volta ao menu do editor. diff --git a/data/texts.ro.txt b/data/texts.ro.txt index 3dc953a18..22503b1f0 100644 --- a/data/texts.ro.txt +++ b/data/texts.ro.txt @@ -1766,3 +1766,81 @@ Grafica selectată se aplică la următoarea încărcare a jocului sau a editoru Vizualizare toroidală automată [settings Automatically show the torus overview while moving around the map (OpenGL).] Afișați automat prezentarea generală a hărții în formă de torus în timp ce vă deplasați pe hartă (OpenGL). +[ERROR_CANT_SAVE_MAP] +Harta nu a putut fi salvată. Verificați destinația și spațiul disponibil. Modificările dvs. sunt încă deschise. +[Loading headers] +Se încarcă anteturile... +[Loading teams] +Se încarcă echipele... +[Loading terrain] +Se încarcă terenul... +[Building gradients] +Se generează gradienții... +[Loading players] +Se încarcă jucătorii... +[Loading scripts] +Se încarcă scripturile... +[Generating map] +Se generează harta... +[ERROR_CANT_GENERATE_MAP] +Harta nu a putut fi generată. Încercați alte setări de generare. +[Loading units] +Se încarcă unitățile... +[Loading buildings] +Se încarcă construcțiile... +[Resolving team links] +Se rezolvă legăturile dintre echipe... +[saving to storage] +Se salvează... +[save failed retry] +Salvare eșuată. Reîncercați. +[export save] +Exportă jocul salvat +[storage restore failed] +Stocarea din browser nu a putut fi restaurată. Datele salvate existente nu au fost modificate. Puteți continua această sesiune, dar modificările locale nu vor putea fi păstrate. Exportați salvările importante din fereastra de eroare de salvare. Reîncărcați pentru a reîncerca restaurarea stocării. +[continue] +Continuă +[export file] +Exportă fișierul +[export failed] +Exportul fișierului a eșuat +[import file] +Importă +[select import file] +Alegeți un fișier de importat +[validating import] +Se validează fișierul... +[import succeeded] +Fișier importat +[import cancelled] +Import anulat +[import failed] +Import eșuat: fișier nevalid sau neacceptat +[import persistence failed] +Nesalvat: alegeți Importă pentru a reîncerca sau Exportă fișierul +[import progress] +Progres import +[export progress] +Progres export +[retry save] +Reîncearcă salvarea +[campaign save failed] +Progresul campaniei nu a fost salvat. Reîncercați sau exportați o copie de rezervă. +[leave without saving] +Ieși fără a salva +[campaign import failed] +Fișier de progres nevalid sau versiune de campanie diferită +[network release mismatch] +Protocoalele clientului și serverului diferă. Instalați aceeași versiune Glob2 ca serverul. +[settings continue] +Continuă +[settings save failed] +Salvarea setărilor neconfirmată. Reîncercați sau continuați. +[shutdown save failed] +Salvarea finală neconfirmată. Reîncercați sau ieșiți fără a salva. +[quit without saving] +Ieși fără a salva +[game closed] +Joc închis. Puteți închide această fereastră sau reîncărca pentru a juca din nou. +[campaign editor save failed] +Salvare neconfirmată. OK reîncearcă; Anulare revine la meniul editorului. diff --git a/data/texts.ru.txt b/data/texts.ru.txt index 88553314a..74ec70d22 100644 --- a/data/texts.ru.txt +++ b/data/texts.ru.txt @@ -1764,3 +1764,81 @@ OpenGL недоступен в этой сборке. Автоматический вид тора [settings Automatically show the torus overview while moving around the map (OpenGL).] Автоматически отображать обзор карты в форме тора при перемещении по карте (OpenGL). +[ERROR_CANT_SAVE_MAP] +Не удалось сохранить карту. Проверьте путь и доступное место. Ваши изменения всё ещё открыты. +[Loading headers] +Загрузка заголовков... +[Loading teams] +Загрузка команд... +[Loading terrain] +Загрузка местности... +[Building gradients] +Построение градиентов... +[Loading players] +Загрузка игроков... +[Loading scripts] +Загрузка скриптов... +[Generating map] +Создание карты... +[ERROR_CANT_GENERATE_MAP] +Не удалось создать карту. Попробуйте другие параметры генерации. +[Loading units] +Загрузка юнитов... +[Loading buildings] +Загрузка построек... +[Resolving team links] +Установление связей команд... +[saving to storage] +Сохранение... +[save failed retry] +Ошибка сохранения. Повторите попытку. +[export save] +Экспортировать сохранение +[storage restore failed] +Не удалось восстановить хранилище браузера. Существующие сохранённые данные не изменены. Вы можете продолжить сеанс, но локальные изменения не смогут сохраниться. Экспортируйте важные сохранения из окна ошибки сохранения. Перезагрузите страницу, чтобы повторить попытку восстановления хранилища. +[continue] +Продолжить +[export file] +Экспортировать файл +[export failed] +Не удалось экспортировать файл +[import file] +Импорт +[select import file] +Выберите файл для импорта +[validating import] +Проверка файла... +[import succeeded] +Файл импортирован +[import cancelled] +Импорт отменён +[import failed] +Ошибка импорта: недопустимый или неподдерживаемый файл +[import persistence failed] +Не сохранено: выберите «Импорт» для повтора или «Экспортировать файл» +[import progress] +Ход импорта +[export progress] +Ход экспорта +[retry save] +Повторить сохранение +[campaign save failed] +Прогресс кампании не сохранён. Повторите попытку или экспортируйте резервную копию. +[leave without saving] +Выйти без сохранения +[campaign import failed] +Недопустимый файл прогресса или другая версия кампании +[network release mismatch] +Протоколы клиента и сервера различаются. Установите ту же версию Glob2, что и на сервере. +[settings continue] +Продолжить +[settings save failed] +Сохранение настроек не подтверждено. Повторите попытку или продолжите. +[shutdown save failed] +Итоговое сохранение не подтверждено. Повторите попытку или выйдите без сохранения. +[quit without saving] +Выйти без сохранения +[game closed] +Игра закрыта. Можно закрыть это окно или перезагрузить страницу, чтобы сыграть снова. +[campaign editor save failed] +Сохранение не подтверждено. «ОК» повторяет попытку; «Отмена» возвращает в меню редактора. diff --git a/data/texts.si.txt b/data/texts.si.txt index b1706a81f..1ebe113d4 100644 --- a/data/texts.si.txt +++ b/data/texts.si.txt @@ -1766,3 +1766,81 @@ Izbrana grafika se uporabi ob naslednjem nalaganju igre ali urejevalnika zemljev Samodejni torusni pogled [settings Automatically show the torus overview while moving around the map (OpenGL).] Samodejno prikaži pregled zemljevida v obliki torusa med premikanjem po zemljevidu (OpenGL). +[ERROR_CANT_SAVE_MAP] +Zemljevida ni bilo mogoče shraniti. Preverite cilj in razpoložljiv prostor. Vaše spremembe so še vedno odprte. +[Loading headers] +Nalaganje glav... +[Loading teams] +Nalaganje ekip... +[Loading terrain] +Nalaganje terena... +[Building gradients] +Ustvarjanje gradientov... +[Loading players] +Nalaganje igralcev... +[Loading scripts] +Nalaganje skriptov... +[Generating map] +Ustvarjanje zemljevida... +[ERROR_CANT_GENERATE_MAP] +Zemljevida ni bilo mogoče ustvariti. Poskusite druge nastavitve ustvarjanja. +[Loading units] +Nalaganje enot... +[Loading buildings] +Nalaganje stavb... +[Resolving team links] +Razreševanje povezav ekip... +[saving to storage] +Shranjevanje... +[save failed retry] +Shranjevanje ni uspelo. Poskusite znova. +[export save] +Izvozi shranjeno igro +[storage restore failed] +Shrambe brskalnika ni bilo mogoče obnoviti. Obstoječi shranjeni podatki niso bili spremenjeni. To sejo lahko nadaljujete, vendar lokalnih sprememb ne bo mogoče ohraniti. Pomembne shranjene igre izvozite iz pogovornega okna napake shranjevanja. Za nov poskus obnovitve shrambe stran znova naložite. +[continue] +Nadaljuj +[export file] +Izvozi datoteko +[export failed] +Izvoz datoteke ni uspel +[import file] +Uvozi +[select import file] +Izberite datoteko za uvoz +[validating import] +Preverjanje datoteke... +[import succeeded] +Datoteka uvožena +[import cancelled] +Uvoz preklican +[import failed] +Uvoz ni uspel: neveljavna ali nepodprta datoteka +[import persistence failed] +Ni shranjeno: izberite Uvozi za nov poskus, ali Izvozi datoteko +[import progress] +Napredek uvoza +[export progress] +Napredek izvoza +[retry save] +Poskusi znova shraniti +[campaign save failed] +Napredek pohoda ni bil shranjen. Poskusite znova ali izvozite varnostno kopijo. +[leave without saving] +Izhod brez shranjevanja +[campaign import failed] +Neveljavna datoteka napredka ali druga različica pohoda +[network release mismatch] +Protokola odjemalca in strežnika se razlikujeta. Namestite enako različico Glob2 kot strežnik. +[settings continue] +Nadaljuj +[settings save failed] +Shranjevanje nastavitev ni potrjeno. Poskusite znova ali nadaljujte. +[shutdown save failed] +Končno shranjevanje ni potrjeno. Poskusite znova ali izstopite brez shranjevanja. +[quit without saving] +Izhod brez shranjevanja +[game closed] +Igra je zaprta. To okno lahko zaprete ali stran znova naložite, da igrate še enkrat. +[campaign editor save failed] +Shranjevanje ni potrjeno. V redu poskusi znova; Prekliči se vrne v meni urejevalnika. diff --git a/data/texts.sk.txt b/data/texts.sk.txt index 8ce179f40..f096c6b2e 100644 --- a/data/texts.sk.txt +++ b/data/texts.sk.txt @@ -1774,3 +1774,81 @@ Vybraná grafika sa použije pri ďalšom načítaní hry alebo editora máp (Op Automatické toroidné zobrazenie [settings Automatically show the torus overview while moving around the map (OpenGL).] Automaticky zobraziť prehľad mapy v tvare torusu pri pohybe po mape (OpenGL). +[ERROR_CANT_SAVE_MAP] +Mapu sa nepodarilo uložiť. Skontrolujte cieľ a dostupné miesto. Vaše úpravy sú stále otvorené. +[Loading headers] +Načítavanie hlavičiek... +[Loading teams] +Načítavanie tímov... +[Loading terrain] +Načítavanie terénu... +[Building gradients] +Vytvárajú sa prechody... +[Loading players] +Načítavanie hráčov... +[Loading scripts] +Načítavanie skriptov... +[Generating map] +Generuje sa mapa... +[ERROR_CANT_GENERATE_MAP] +Mapu sa nepodarilo vygenerovať. Skúste iné nastavenia generovania. +[Loading units] +Načítavanie jednotiek... +[Loading buildings] +Načítavanie budov... +[Resolving team links] +Riešia sa prepojenia tímov... +[saving to storage] +Ukladá sa... +[save failed retry] +Uloženie zlyhalo. Skúste to znova. +[export save] +Exportovať uloženú hru +[storage restore failed] +Úložisko prehliadača sa nepodarilo obnoviť. Existujúce uložené dáta neboli zmenené. Môžete pokračovať v tejto relácii, ale lokálne zmeny sa nebudú dať zachovať. Dôležité uložené hry exportujte z dialógu chyby uloženia. Obnovte stránku a skúste obnovenie úložiska znova. +[continue] +Pokračovať +[export file] +Exportovať súbor +[export failed] +Export súboru zlyhal +[import file] +Importovať +[select import file] +Vyberte súbor na import +[validating import] +Overuje sa súbor... +[import succeeded] +Súbor bol importovaný +[import cancelled] +Import zrušený +[import failed] +Import zlyhal: neplatný alebo nepodporovaný súbor +[import persistence failed] +Neuložené: vyberte Importovať na opätovný pokus, alebo Exportovať súbor +[import progress] +Priebeh importu +[export progress] +Priebeh exportu +[retry save] +Skúsiť uloženie znova +[campaign save failed] +Postup kampane nebol uložený. Skúste to znova alebo exportujte zálohu. +[leave without saving] +Ukončiť bez uloženia +[campaign import failed] +Neplatný súbor postupu alebo iná verzia kampane +[network release mismatch] +Protokoly klienta a servera sa líšia. Nainštalujte rovnakú verziu Glob2 ako server. +[settings continue] +Pokračovať +[settings save failed] +Uloženie nastavení nepotvrdené. Skúste to znova, alebo pokračujte. +[shutdown save failed] +Záverečné uloženie nepotvrdené. Skúste to znova, alebo ukončite bez uloženia. +[quit without saving] +Ukončiť bez uloženia +[game closed] +Hra bola ukončená. Toto okno môžete zavrieť alebo znova načítať stránku a hrať znova. +[campaign editor save failed] +Uloženie nepotvrdené. OK to skúsi znova; Zrušiť sa vráti do ponuky editora. diff --git a/data/texts.sr.txt b/data/texts.sr.txt index 9356daf8e..84872ce83 100644 --- a/data/texts.sr.txt +++ b/data/texts.sr.txt @@ -1766,3 +1766,81 @@ OpenGL није доступан у овој верзији. Аутоматски торусни приказ [settings Automatically show the torus overview while moving around the map (OpenGL).] Аутоматски прикажи преглед мапе у облику торуса током кретања по мапи (OpenGL). +[ERROR_CANT_SAVE_MAP] +Мапа није могла бити сачувана. Проверите одредиште и доступан простор. Ваше измене су и даље отворене. +[Loading headers] +Учитавање заглавља... +[Loading teams] +Учитавање тимова... +[Loading terrain] +Учитавање терена... +[Building gradients] +Праве се градијенти... +[Loading players] +Учитавање играча... +[Loading scripts] +Учитавање скрипти... +[Generating map] +Генерисање мапе... +[ERROR_CANT_GENERATE_MAP] +Мапа није могла бити генерисана. Пробајте друга подешавања генерисања. +[Loading units] +Учитавање јединица... +[Loading buildings] +Учитавање зграда... +[Resolving team links] +Решавање веза тимова... +[saving to storage] +Чување... +[save failed retry] +Чување није успело. Покушајте поново. +[export save] +Извези сачувану игру +[storage restore failed] +Складиште прегледача није могло бити обновљено. Постојећи сачувани подаци нису измењени. Можете наставити ову сесију, али локалне измене неће моћи да се сачувају. Извезите важне сачуване игре из дијалога грешке чувања. Поново учитајте страницу да бисте покушали поново да обновите складиште. +[continue] +Настави +[export file] +Извези датотеку +[export failed] +Извоз датотеке није успео +[import file] +Увези +[select import file] +Изаберите датотеку за увоз +[validating import] +Провера датотеке... +[import succeeded] +Датотека је увезена +[import cancelled] +Увоз отказан +[import failed] +Увоз није успео: неважећа или неподржана датотека +[import persistence failed] +Није сачувано: изаберите Увези да покушате поново, или Извези датотеку +[import progress] +Напредак увоза +[export progress] +Напредак извоза +[retry save] +Покушај поново да сачуваш +[campaign save failed] +Напредак кампање није сачуван. Покушајте поново или извезите резервну копију. +[leave without saving] +Излаз без чувања +[campaign import failed] +Неважећа датотека напретка или друга верзија кампање +[network release mismatch] +Протоколи клијента и сервера се разликују. Инсталирајте исту верзију Glob2 као сервер. +[settings continue] +Настави +[settings save failed] +Чување подешавања није потврђено. Покушајте поново, или наставите. +[shutdown save failed] +Коначно чување није потврђено. Покушајте поново, или изађите без чувања. +[quit without saving] +Излаз без чувања +[game closed] +Игра је затворена. Можете затворити овај прозор или поново учитати страницу да бисте опет играли. +[campaign editor save failed] +Чување није потврђено. У реду поново покушава; Откажи се враћа у мени едитора. diff --git a/data/texts.sv.txt b/data/texts.sv.txt index a8152a4f2..f586c8594 100644 --- a/data/texts.sv.txt +++ b/data/texts.sv.txt @@ -1776,3 +1776,81 @@ Använd grafiken nästa gång ett spel eller kartredigeraren laddas (OpenGL). Automatisk torusvy [settings Automatically show the torus overview while moving around the map (OpenGL).] Visa automatiskt den torusformade kartöversikten medan du flyttar runt på kartan (OpenGL). +[ERROR_CANT_SAVE_MAP] +Kartan kunde inte sparas. Kontrollera destinationen och tillgängligt utrymme. Dina ändringar är fortfarande öppna. +[Loading headers] +Läser in rubriker... +[Loading teams] +Läser in lag... +[Loading terrain] +Läser in terräng... +[Building gradients] +Bygger gradienter... +[Loading players] +Läser in spelare... +[Loading scripts] +Läser in skript... +[Generating map] +Skapar karta... +[ERROR_CANT_GENERATE_MAP] +Kartan kunde inte skapas. Prova andra genereringsinställningar. +[Loading units] +Läser in enheter... +[Loading buildings] +Läser in byggnader... +[Resolving team links] +Löser lagkopplingar... +[saving to storage] +Sparar... +[save failed retry] +Sparning misslyckades. Försök igen. +[export save] +Exportera sparfil +[storage restore failed] +Webbläsarens lagring kunde inte återställas. Befintlig sparad data har inte ändrats. Du kan fortsätta den här sessionen, men lokala ändringar kan inte sparas permanent. Exportera viktiga sparfiler från dialogrutan för sparfel. Ladda om för att försöka återställa lagringen igen. +[continue] +Fortsätt +[export file] +Exportera fil +[export failed] +Filexport misslyckades +[import file] +Importera +[select import file] +Välj en fil att importera +[validating import] +Verifierar fil... +[import succeeded] +Fil importerad +[import cancelled] +Import avbruten +[import failed] +Import misslyckades: ogiltig eller ej stödd fil +[import persistence failed] +Ej sparat: välj Importera för att försöka igen, eller Exportera fil +[import progress] +Importförlopp +[export progress] +Exportförlopp +[retry save] +Försök spara igen +[campaign save failed] +Kampanjförloppet sparades inte. Försök igen eller exportera en säkerhetskopia. +[leave without saving] +Avsluta utan att spara +[campaign import failed] +Ogiltig förloppsfil eller annan kampanjversion +[network release mismatch] +Klient- och serverprotokoll skiljer sig åt. Installera samma Glob2-version som servern. +[settings continue] +Fortsätt +[settings save failed] +Sparning av inställningar ej bekräftad. Försök igen, eller fortsätt. +[shutdown save failed] +Slutlig sparning ej bekräftad. Försök igen, eller avsluta utan att spara. +[quit without saving] +Avsluta utan att spara +[game closed] +Spelet stängt. Du kan stänga det här fönstret eller ladda om för att spela igen. +[campaign editor save failed] +Sparning ej bekräftad. OK försöker igen; Avbryt återgår till redigerarmenyn. diff --git a/data/texts.tr.txt b/data/texts.tr.txt index 53860b8ff..3eaa9c8d3 100644 --- a/data/texts.tr.txt +++ b/data/texts.tr.txt @@ -1774,3 +1774,81 @@ Seçilen grafikler, oyun veya harita düzenleyici bir sonraki yüklendiğinde uy Otomatik torus görünümü [settings Automatically show the torus overview while moving around the map (OpenGL).] Haritada dolaşırken torus şeklindeki haritaya genel bakışı otomatik olarak gösterin (OpenGL). +[ERROR_CANT_SAVE_MAP] +Harita kaydedilemedi. Hedefi ve kullanılabilir alanı kontrol edin. Düzenlemeleriniz hâlâ açık. +[Loading headers] +Başlıklar yükleniyor... +[Loading teams] +Takımlar yükleniyor... +[Loading terrain] +Arazi yükleniyor... +[Building gradients] +Gradyanlar oluşturuluyor... +[Loading players] +Oyuncular yükleniyor... +[Loading scripts] +Betikler yükleniyor... +[Generating map] +Harita oluşturuluyor... +[ERROR_CANT_GENERATE_MAP] +Harita oluşturulamadı. Farklı oluşturma ayarları deneyin. +[Loading units] +Birimler yükleniyor... +[Loading buildings] +Binalar yükleniyor... +[Resolving team links] +Takım bağlantıları çözümleniyor... +[saving to storage] +Kaydediliyor... +[save failed retry] +Kayıt başarısız oldu. Yeniden deneyin. +[export save] +Kaydı dışa aktar +[storage restore failed] +Tarayıcı depolaması geri yüklenemedi. Mevcut kayıtlı veriler değiştirilmedi. Bu oturuma devam edebilirsiniz, ancak yerel değişiklikler kalıcı olamaz. Önemli kayıtları, kayıt hatası penceresinden dışa aktarın. Depolamayı yeniden geri yüklemeyi denemek için sayfayı yenileyin. +[continue] +Devam et +[export file] +Dosyayı dışa aktar +[export failed] +Dosya dışa aktarma başarısız oldu +[import file] +İçe aktar +[select import file] +İçe aktarılacak bir dosya seçin +[validating import] +Dosya doğrulanıyor... +[import succeeded] +Dosya içe aktarıldı +[import cancelled] +İçe aktarma iptal edildi +[import failed] +İçe aktarma başarısız: geçersiz veya desteklenmeyen dosya +[import persistence failed] +Kaydedilmedi: yeniden denemek için İçe Aktar veya Dosyayı Dışa Aktar'ı seçin +[import progress] +İçe aktarma ilerlemesi +[export progress] +Dışa aktarma ilerlemesi +[retry save] +Kaydetmeyi yeniden dene +[campaign save failed] +Kampanya ilerlemesi kaydedilmedi. Yeniden deneyin veya bir yedek dışa aktarın. +[leave without saving] +Kaydetmeden çık +[campaign import failed] +Geçersiz ilerleme dosyası veya farklı kampanya sürümü +[network release mismatch] +İstemci ve sunucu protokolleri farklı. Sunucuyla aynı Glob2 sürümünü yükleyin. +[settings continue] +Devam et +[settings save failed] +Ayar kaydı onaylanmadı. Yeniden deneyin veya devam edin. +[shutdown save failed] +Son kayıt onaylanmadı. Yeniden deneyin veya kaydetmeden çıkın. +[quit without saving] +Kaydetmeden çık +[game closed] +Oyun kapatıldı. Bu pencereyi kapatabilir veya yeniden oynamak için sayfayı yenileyebilirsiniz. +[campaign editor save failed] +Kayıt onaylanmadı. Tamam yeniden dener; İptal düzenleyici menüsüne döner. diff --git a/data/texts.uk.txt b/data/texts.uk.txt index 7599531a6..1fec6c088 100644 --- a/data/texts.uk.txt +++ b/data/texts.uk.txt @@ -1762,3 +1762,81 @@ OpenGL недоступний у цій збірці. Автоматичний вигляд тора [settings Automatically show the torus overview while moving around the map (OpenGL).] Автоматично показувати огляд карти у формі тора під час переміщення по карті (OpenGL). +[ERROR_CANT_SAVE_MAP] +Не вдалося зберегти карту. Перевірте місце призначення та вільний простір. Ваші зміни досі відкриті. +[Loading headers] +Завантаження заголовків... +[Loading teams] +Завантаження команд... +[Loading terrain] +Завантаження місцевості... +[Building gradients] +Побудова градієнтів... +[Loading players] +Завантаження гравців... +[Loading scripts] +Завантаження скриптів... +[Generating map] +Створення карти... +[ERROR_CANT_GENERATE_MAP] +Не вдалося створити карту. Спробуйте інші параметри генерації. +[Loading units] +Завантаження підрозділів... +[Loading buildings] +Завантаження будівель... +[Resolving team links] +Визначення зв'язків команд... +[saving to storage] +Збереження... +[save failed retry] +Помилка збереження. Повторіть спробу. +[export save] +Експортувати збереження +[storage restore failed] +Не вдалося відновити сховище браузера. Наявні збережені дані не змінено. Ви можете продовжити цей сеанс, але локальні зміни неможливо буде зберегти. Експортуйте важливі збереження з вікна помилки збереження. Перезавантажте сторінку, щоб повторити спробу відновлення сховища. +[continue] +Продовжити +[export file] +Експортувати файл +[export failed] +Не вдалося експортувати файл +[import file] +Імпорт +[select import file] +Виберіть файл для імпорту +[validating import] +Перевірка файлу... +[import succeeded] +Файл імпортовано +[import cancelled] +Імпорт скасовано +[import failed] +Помилка імпорту: недійсний або непідтримуваний файл +[import persistence failed] +Не збережено: виберіть «Імпорт», щоб повторити спробу, або «Експортувати файл» +[import progress] +Хід імпорту +[export progress] +Хід експорту +[retry save] +Повторити збереження +[campaign save failed] +Прогрес кампанії не збережено. Повторіть спробу або експортуйте резервну копію. +[leave without saving] +Вийти без збереження +[campaign import failed] +Недійсний файл прогресу або інша версія кампанії +[network release mismatch] +Протоколи клієнта й сервера відрізняються. Установіть таку саму версію Glob2, як на сервері. +[settings continue] +Продовжити +[settings save failed] +Збереження налаштувань не підтверджено. Повторіть спробу або продовжте. +[shutdown save failed] +Підсумкове збереження не підтверджено. Повторіть спробу або вийдіть без збереження. +[quit without saving] +Вийти без збереження +[game closed] +Гру закрито. Можете закрити це вікно або перезавантажити сторінку, щоб зіграти знову. +[campaign editor save failed] +Збереження не підтверджено. «Гаразд» повторює спробу; «Скасувати» повертає до меню редактора. diff --git a/data/texts.vi.txt b/data/texts.vi.txt index 7051e8f5c..99ac665cc 100644 --- a/data/texts.vi.txt +++ b/data/texts.vi.txt @@ -1762,3 +1762,81 @@ Hình ảnh được áp dụng khi tải ván chơi hoặc trình chỉnh sửa Chế độ xem hình xuyến tự động [settings Automatically show the torus overview while moving around the map (OpenGL).] Tự động hiển thị tổng quan bản đồ hình xuyến khi di chuyển xung quanh bản đồ (OpenGL). +[ERROR_CANT_SAVE_MAP] +Không thể lưu bản đồ. Hãy kiểm tra đích lưu và dung lượng trống. Các chỉnh sửa của bạn vẫn còn mở. +[Loading headers] +Đang tải phần đầu... +[Loading teams] +Đang tải đội... +[Loading terrain] +Đang tải địa hình... +[Building gradients] +Đang dựng gradient... +[Loading players] +Đang tải người chơi... +[Loading scripts] +Đang tải kịch bản... +[Generating map] +Đang tạo bản đồ... +[ERROR_CANT_GENERATE_MAP] +Không thể tạo bản đồ. Hãy thử các thiết lập tạo khác. +[Loading units] +Đang tải đơn vị... +[Loading buildings] +Đang tải công trình... +[Resolving team links] +Đang phân giải liên kết đội... +[saving to storage] +Đang lưu... +[save failed retry] +Lưu thất bại. Hãy thử lại. +[export save] +Xuất bản lưu +[storage restore failed] +Không thể khôi phục bộ nhớ trình duyệt. Dữ liệu đã lưu hiện có không thay đổi. Bạn có thể tiếp tục phiên này, nhưng các thay đổi cục bộ sẽ không được lưu giữ. Hãy xuất các bản lưu quan trọng từ hộp thoại lỗi lưu. Tải lại trang để thử khôi phục bộ nhớ lần nữa. +[continue] +Tiếp tục +[export file] +Xuất tệp +[export failed] +Xuất tệp thất bại +[import file] +Nhập +[select import file] +Chọn tệp để nhập +[validating import] +Đang kiểm tra tệp... +[import succeeded] +Đã nhập tệp +[import cancelled] +Đã hủy nhập +[import failed] +Nhập thất bại: tệp không hợp lệ hoặc không được hỗ trợ +[import persistence failed] +Chưa lưu: chọn Nhập để thử lại, hoặc Xuất tệp +[import progress] +Tiến trình nhập +[export progress] +Tiến trình xuất +[retry save] +Thử lưu lại +[campaign save failed] +Tiến trình chiến dịch chưa được lưu. Hãy thử lại hoặc xuất bản sao lưu. +[leave without saving] +Thoát mà không lưu +[campaign import failed] +Tệp tiến trình không hợp lệ hoặc khác phiên bản chiến dịch +[network release mismatch] +Giao thức máy khách và máy chủ khác nhau. Hãy cài phiên bản Glob2 giống với máy chủ. +[settings continue] +Tiếp tục +[settings save failed] +Chưa xác nhận lưu thiết lập. Hãy thử lại, hoặc tiếp tục. +[shutdown save failed] +Chưa xác nhận lưu lần cuối. Hãy thử lại, hoặc thoát mà không lưu. +[quit without saving] +Thoát mà không lưu +[game closed] +Trò chơi đã đóng. Bạn có thể đóng cửa sổ này hoặc tải lại để chơi tiếp. +[campaign editor save failed] +Chưa xác nhận lưu. Đồng ý sẽ thử lại; Hủy sẽ quay về menu trình chỉnh sửa. diff --git a/data/texts.zh-cn.txt b/data/texts.zh-cn.txt index 728759744..6e1848d15 100644 --- a/data/texts.zh-cn.txt +++ b/data/texts.zh-cn.txt @@ -1762,3 +1762,81 @@ OpenGL 在此版本中不可用。 自动环面视图 [settings Automatically show the torus overview while moving around the map (OpenGL).] 在地图上移动时自动显示圆环形状的地图概览 (OpenGL)。 +[ERROR_CANT_SAVE_MAP] +无法保存地图。请检查目标位置和可用空间。您的编辑内容仍处于打开状态。 +[Loading headers] +正在加载头信息... +[Loading teams] +正在加载队伍... +[Loading terrain] +正在加载地形... +[Building gradients] +正在生成梯度... +[Loading players] +正在加载玩家... +[Loading scripts] +正在加载脚本... +[Generating map] +正在生成地图... +[ERROR_CANT_GENERATE_MAP] +无法生成地图。请尝试其他生成设置。 +[Loading units] +正在加载单位... +[Loading buildings] +正在加载建筑... +[Resolving team links] +正在解析队伍链接... +[saving to storage] +正在保存... +[save failed retry] +保存失败,请重试。 +[export save] +导出存档 +[storage restore failed] +无法恢复浏览器存储。现有已保存数据未被更改。您可以继续本次会话,但本地更改将无法保留。请从保存失败对话框导出重要存档。重新加载以再次尝试恢复存储。 +[continue] +继续 +[export file] +导出文件 +[export failed] +文件导出失败 +[import file] +导入 +[select import file] +选择要导入的文件 +[validating import] +正在验证文件... +[import succeeded] +文件已导入 +[import cancelled] +导入已取消 +[import failed] +导入失败:文件无效或不受支持 +[import persistence failed] +未保存:选择“导入”重试,或“导出文件” +[import progress] +导入进度 +[export progress] +导出进度 +[retry save] +重试保存 +[campaign save failed] +战役进度未保存。请重试或导出备份。 +[leave without saving] +不保存退出 +[campaign import failed] +进度文件无效或战役版本不同 +[network release mismatch] +客户端与服务器协议不一致。请安装与服务器相同的 Glob2 版本。 +[settings continue] +继续 +[settings save failed] +设置保存尚未确认。请重试,或继续。 +[shutdown save failed] +最终保存尚未确认。请重试,或不保存退出。 +[quit without saving] +不保存退出 +[game closed] +游戏已关闭。您可以关闭此窗口,或重新加载以再次游戏。 +[campaign editor save failed] +尚未确认保存。“确定”重试;“取消”返回编辑器菜单。 diff --git a/data/texts.zh-tw.txt b/data/texts.zh-tw.txt index b47c4de3b..cddc7fbf1 100644 --- a/data/texts.zh-tw.txt +++ b/data/texts.zh-tw.txt @@ -1838,3 +1838,81 @@ OpenGL 在此版本中不可用。 自動環面視圖 [settings Automatically show the torus overview while moving around the map (OpenGL).] 在地圖上移動時自動顯示圓環形狀的地圖概覽 (OpenGL)。 +[ERROR_CANT_SAVE_MAP] +無法儲存地圖。請檢查目的位置與可用空間。您的編輯內容仍為開啟狀態。 +[Loading headers] +正在載入標頭... +[Loading teams] +正在載入隊伍... +[Loading terrain] +正在載入地形... +[Building gradients] +正在建立漸層... +[Loading players] +正在載入玩家... +[Loading scripts] +正在載入腳本... +[Generating map] +正在產生地圖... +[ERROR_CANT_GENERATE_MAP] +無法產生地圖。請嘗試其他產生設定。 +[Loading units] +正在載入單位... +[Loading buildings] +正在載入建築... +[Resolving team links] +正在解析隊伍連結... +[saving to storage] +正在儲存... +[save failed retry] +儲存失敗,請重試。 +[export save] +匯出存檔 +[storage restore failed] +無法還原瀏覽器儲存空間。現有已儲存的資料未被變更。您可以繼續這個工作階段,但本機變更將無法保留。請從儲存失敗對話方塊匯出重要存檔。重新載入以再次嘗試還原儲存空間。 +[continue] +繼續 +[export file] +匯出檔案 +[export failed] +檔案匯出失敗 +[import file] +匯入 +[select import file] +選擇要匯入的檔案 +[validating import] +正在驗證檔案... +[import succeeded] +檔案已匯入 +[import cancelled] +已取消匯入 +[import failed] +匯入失敗:檔案無效或不受支援 +[import persistence failed] +未儲存:選擇「匯入」重試,或「匯出檔案」 +[import progress] +匯入進度 +[export progress] +匯出進度 +[retry save] +重試儲存 +[campaign save failed] +戰役進度未儲存。請重試或匯出備份。 +[leave without saving] +不儲存離開 +[campaign import failed] +進度檔案無效或戰役版本不同 +[network release mismatch] +用戶端與伺服器通訊協定不一致。請安裝與伺服器相同的 Glob2 版本。 +[settings continue] +繼續 +[settings save failed] +設定儲存尚未確認。請重試,或繼續。 +[shutdown save failed] +最終儲存尚未確認。請重試,或不儲存離開。 +[quit without saving] +不儲存離開 +[game closed] +遊戲已結束。您可以關閉此視窗,或重新載入以再次遊玩。 +[campaign editor save failed] +尚未確認儲存。「確定」重試;「取消」返回編輯器選單。 diff --git a/debian/control b/debian/control index 77571b1ff..b656b0ab7 100644 --- a/debian/control +++ b/debian/control @@ -2,7 +2,7 @@ Source: glob2 Section: games Priority: optional Maintainer: Stephane Magnenat -Build-Depends: debhelper (>> 6.0.0), quilt (>= 0.40), scons, libsdl2-dev (>=2.0.0), libsdl2-image-dev (>=2.0.0), libsdl2-net-dev (>=2.0.0), libsdl2-ttf-dev, libglu1-mesa-dev | libglu-dev, libvorbis-dev, libspeex-dev, libfreetype6-dev, libboost-dev, libboost-thread-dev, libboost-date-time-dev, libfribidi-dev, portaudio19-dev, libboost-math-dev, libepoxy-dev +Build-Depends: debhelper (>> 6.0.0), quilt (>= 0.40), scons, libsdl2-dev (>=2.0.0), libsdl2-image-dev (>=2.0.0), libsdl2-net-dev (>=2.0.0), libsdl2-ttf-dev, libglu1-mesa-dev | libglu-dev, libvorbis-dev, libspeex-dev, libfreetype6-dev, libboost-dev, libboost-thread-dev, libboost-date-time-dev, libfribidi-dev, portaudio19-dev, libboost-math-dev, libepoxy-dev, libssl-dev Standards-Version: 3.8.1 Homepage: http://globulation2.org diff --git a/deploy/Caddyfile b/deploy/Caddyfile new file mode 100644 index 000000000..96d4a5d83 --- /dev/null +++ b/deploy/Caddyfile @@ -0,0 +1,12 @@ +{$GLOB2_SITE:http://localhost} { + @gameSockets path /yog /router + reverse_proxy @gameSockets gateway:8080 + @private path /metrics /healthz + respond @private 404 + root * /srv + header Cache-Control "no-cache" + file_server +} +http://127.0.0.1:2015 { + respond /healthz 200 +} diff --git a/deploy/Dockerfile b/deploy/Dockerfile new file mode 100644 index 000000000..818df298a --- /dev/null +++ b/deploy/Dockerfile @@ -0,0 +1,29 @@ +FROM ubuntu:24.04 AS build +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates g++ python3 scons pkg-config libsdl2-dev libsdl2-image-dev \ + libsdl2-net-dev libsdl2-ttf-dev libvorbis-dev libogg-dev libspeex-dev \ + libboost-date-time-dev libboost-thread-dev libboost-system-dev zlib1g-dev \ + libfribidi-dev libpcre3-dev libgl1-mesa-dev libglu1-mesa-dev libepoxy-dev \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /source +COPY . . +ARG JOBS=4 +RUN scons role=server release=1 -j${JOBS} && \ + scons role=router release=1 -j${JOBS} && \ + scons role=gateway release=1 -j${JOBS} + +FROM ubuntu:24.04 +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates python3 libsdl2-2.0-0 libsdl2-image-2.0-0 libsdl2-net-2.0-0 \ + libsdl2-ttf-2.0-0 libvorbisfile3 libspeex1 libboost-thread1.83.0 \ + libboost-date-time1.83.0 libboost-system1.83.0 libfribidi0 libpcre3 libepoxy0 \ + && rm -rf /var/lib/apt/lists/* \ + && useradd --uid 10001 --create-home glob2 +COPY --from=build /source/build/linux/server/release/src/glob2-server /usr/local/bin/ +COPY --from=build /source/build/linux/router/release/src/glob2-router /usr/local/bin/ +COPY --from=build /source/build/linux/gateway/release/glob2-ws-gateway /usr/local/bin/ +COPY data /opt/glob2/data +COPY deploy/healthcheck.py /usr/local/bin/glob2-healthcheck +USER glob2 +WORKDIR /opt/glob2 +CMD ["glob2-server"] diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 000000000..15fb5d632 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,86 @@ +# Self-hosting development package + +This package runs the existing YOG lobby and router behind a fixed-route +WebSocket gateway and Caddy. It is experimental: the upgraded compatibility +handshake, modern account hashing, invitations, recovery, immutable release +artifacts, and upgrade/rollback qualification remain release gates. + +## Local development + +Build the browser using the pinned dependency setup in `browser/README.md`, then: + +```sh +scons target=web release=1 -j4 +docker compose -f deploy/compose.yaml build lobby +docker compose -f deploy/compose.yaml up -d --wait +``` + +Open http://localhost:8080. The game uses same-origin `/yog` and `/router` routes. +Only the proxy publishes ports. The lobby runs with `GLOB2_EXTERNAL_ROUTER=1`; +ordinary desktop/LAN callers continue to get an embedded router by default. +Stop with `docker compose -f deploy/compose.yaml down`. Named volumes survive. +Do not add `--volumes` unless you intend to erase this deployment's data. + +The server image contains the lobby, router, and gateway from the same source +build. `GLOB2_SERVER_IMAGE` selects its image tag; the development default is +`glob2-server:development`. Browser files come from +`build/emscripten/client/release`; `GLOB2_ASSETS` overrides that directory. Keep +server and browser builds from the same revision. Release image publication and +content-addressed assets are still pending; these mutable development defaults +are not a versioned release package. + +## HTTPS on a public host + +Set these variables in `deploy/.env` (and pass `--env-file deploy/.env` to each +Compose command) after pointing your hostname at the server: + +```dotenv +GLOB2_SITE=games.example.org +GLOB2_ORIGIN=https://games.example.org +GLOB2_BIND=0.0.0.0 +GLOB2_HTTP_PORT=80 +GLOB2_HTTPS_PORT=443 +``` + +Caddy obtains and renews certificates. Its certificate/configuration volumes +persist across recreation. Allow incoming TCP 80/443 and outgoing certificate +issuance traffic. Do not expose internal TCP 7489/7490/7491 or gateway 8080. +Caddy forwards WebSocket upgrades using its [reverse proxy](https://caddyserver.com/docs/caddyfile/directives/reverse_proxy). +The gateway checks the exact browser Origin. No passwords or session credentials +belong in the URL or configuration script. The legacy account registry still +requires the planned password migration before supported internet deployment. + +## Data and operation + +- `lobby-data` contains `/home/glob2/.glob2`, including `beta4/registry`, account + metadata, uploaded maps, configuration, and logs. +- `router-data` contains router configuration and logs. Active matches are memory + only and do not survive a backend restart. +- `caddy-data` and `caddy-config` contain proxy state, including private keys. +- Container health checks test listening sockets/process responsiveness. They do + not prove match recovery or end-to-end readiness. Room creation is refused when + no router is available. +- Gateway `/metrics` is reachable only on the private network. Public requests + for `/metrics` and `/healthz` return 404. + +Before backup or replacing server images, stop admitting play operationally and +wait for matches to finish, then stop the services. Archive all named volumes +and record the exact image IDs and browser revision. Copying live registry/map +files is not a qualified consistent backup. Restore into a separate deployment +and verify login and maps before switching traffic. Automated draining, schema +migration, and a tested cross-version rollback command remain outstanding; do +not assume a newer registry can be read by an older server. + +## Deployment regression + +With the server image and browser output built: + +```sh +python3 -m unittest discover -s tests/deployment -v +``` + +The test creates an isolated Compose project and volumes, uses ephemeral host +ports, and removes only its own project afterwards. It verifies HTTPS with the +local Caddy CA (certificate verification stays enabled), WSS forwarding, private +route denial, registration/login, persistence across container recreation, and +room refusal/recovery after router loss. It does not contact a public YOG server. diff --git a/deploy/compose.yaml b/deploy/compose.yaml new file mode 100644 index 000000000..ee330b912 --- /dev/null +++ b/deploy/compose.yaml @@ -0,0 +1,92 @@ +name: glob2 +x-server: &server + image: ${GLOB2_SERVER_IMAGE:-glob2-server:development} + build: + context: .. + dockerfile: deploy/Dockerfile + restart: unless-stopped + init: true + networks: [backend] + security_opt: [no-new-privileges:true] + cap_drop: [ALL] + healthcheck: + interval: 10s + timeout: 5s + retries: 6 + start_period: 10s +services: + lobby: + <<: *server + command: [glob2-server] + environment: + GLOB2_EXTERNAL_ROUTER: '1' + volumes: [lobby-data:/home/glob2] + healthcheck: + test: [CMD, python3, /usr/local/bin/glob2-healthcheck, lobby] + interval: 10s + timeout: 5s + retries: 6 + router: + <<: *server + command: [glob2-router] + environment: + GLOB2_YOG_HOST: lobby + volumes: [router-data:/home/glob2] + depends_on: + lobby: {condition: service_healthy} + healthcheck: + test: [CMD, python3, /usr/local/bin/glob2-healthcheck, router] + interval: 10s + timeout: 5s + retries: 6 + gateway: + <<: *server + command: + - glob2-ws-gateway + - --listen + - 0.0.0.0 + - --port + - '8080' + - --origin + - ${GLOB2_ORIGIN:-http://localhost:8080} + - --lobby-host + - lobby + - --router-host + - router + depends_on: + lobby: {condition: service_healthy} + router: {condition: service_healthy} + healthcheck: + test: [CMD, python3, /usr/local/bin/glob2-healthcheck, gateway] + interval: 10s + timeout: 5s + retries: 6 + web: + image: caddy:2.10.2-alpine + restart: unless-stopped + environment: + GLOB2_SITE: ${GLOB2_SITE:-http://localhost} + ports: + - '${GLOB2_BIND:-127.0.0.1}:${GLOB2_HTTP_PORT:-8080}:80' + - '${GLOB2_BIND:-127.0.0.1}:${GLOB2_HTTPS_PORT:-8443}:443' + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - ${GLOB2_ASSETS:-../build/emscripten/client/release}:/srv:ro + - caddy-data:/data + - caddy-config:/config + networks: [backend, public] + depends_on: + gateway: {condition: service_healthy} + healthcheck: + test: [CMD, wget, -q, -O, /dev/null, 'http://127.0.0.1:2015/healthz'] + interval: 10s + timeout: 5s + retries: 6 +networks: + backend: {internal: true} + public: {} +volumes: + lobby-data: {} + router-data: {} + caddy-data: {} + caddy-config: {} diff --git a/deploy/healthcheck.py b/deploy/healthcheck.py new file mode 100644 index 000000000..35db479ee --- /dev/null +++ b/deploy/healthcheck.py @@ -0,0 +1,13 @@ +#!/usr/bin/python3 +"""Container liveness checks; match admission remains a YOG protocol decision.""" +import socket +import sys +import urllib.request + +if sys.argv[1] == 'gateway': + with urllib.request.urlopen('http://127.0.0.1:8080/healthz', timeout=2) as response: + assert response.status == 200 +else: + for port in ([7489, 7490] if sys.argv[1] == 'lobby' else [7491]): + with socket.create_connection(('127.0.0.1', port), timeout=2): + pass diff --git a/docs/browser/adr-001-build-isolation.md b/docs/browser/adr-001-build-isolation.md new file mode 100644 index 000000000..4e128ef00 --- /dev/null +++ b/docs/browser/adr-001-build-isolation.md @@ -0,0 +1,39 @@ +# ADR 001: one source manifest, independent build identities + +Status: implemented; platform-wide release validation remains in progress. + +The experiment parsed SConscript text and used host Boost headers. Native +configuration wrote a root header and options cache. Running different +toolchains in the same checkout could therefore change another build's inputs. + +SCons now imports plain Python source manifests from `scons/sources.py`. +Native and Emscripten code paths branch before dependency discovery. Every +toolchain/role/configuration owns a directory, signature database, generated +header, compilation database, compiler outputs, and temporary directory. +Browser ports and compiled SDK libraries use that identity's cache. + +Source includes name `glob2/BuildConfig.h`, resolved through the target's +generated include directory. An unrelated root `config.h` cannot satisfy this +include. Generated headers are atomically replaced only when contents change. +An identity marker rejects reuse of an output directory by an incompatible +configuration. An OS file lock rejects simultaneous writers of the same +directory; different identities can build concurrently. + +Options are explicit on each command. The emitted options record is not loaded +by another invocation. Existing native selectors remain available, including +`server=1`, `mingw=1`, `mingwcross=1`, and `--build=PATH`. Their default output +paths move under the identity directory. macOS packaging is an explicit +`package` target rather than a side effect of compiling release objects. + +The browser SDK revision/version and Boost port version are recorded in +`browser/toolchain.json`. Emscripten verifies port archive checksums. A complete +release dependency lock covering SDK archive digests and native gateway +dependencies is still required before reproducible release status. + +CI validates the identity rules directly and builds native and WebAssembly +outputs in their own jobs. `tests/build_system/coexistence.py` remains an +explicit diagnostic for concurrent and alternating builds; it verifies that +object and artifact contents, timestamps, and tracked source files remain +unchanged. Running that full native build again in the browser job would +duplicate the Linux lanes without improving routine pull-request coverage. +These checks do not replace platform-specific runtime and determinism tests. diff --git a/docs/browser/adr-002-host-migration.md b/docs/browser/adr-002-host-migration.md new file mode 100644 index 000000000..efbdd473a --- /dev/null +++ b/docs/browser/adr-002-host-migration.md @@ -0,0 +1,37 @@ +# ADR 002: platform host boundary + +Status: accepted. + +The browser port originally replaced `SDL_Delay` across the program and waited +inside `GraphicContext::nextFrame`. That coupled scheduling to presentation and +also changed unrelated network waits. + +Platform services now live behind `GAGCore::ApplicationHost`. The shared +application supplies a `Loop` with `frame` and `delay`; the desktop host polls +SDL until that loop completes, while the browser host schedules one callback at +a time and returns to JavaScript after every frame. Rendering does not wait or +own application scheduling. + +The same boundary supplies viewport and visibility changes, file selection, +export, durable-storage requests, and optional diagnostics. Browser interop is +implemented under `browser/`; shared game, AI, UI, and renderer code does not +include Emscripten APIs. Static dependency tests enforce that boundary. + +`glob2Diagnostics` is a versioned, read-only browser test interface. It reports +screen, simulation, rendering, persistence, audio, save, and multiplayer state. +It cannot issue orders, advance simulation, alter files, or navigate menus. + +Legacy synchronous `Screen::execute`, `OverlayScreen::execute`, message-box, and +engine adapters remain for native command-line and desktop call sites. The web +entry points use `Application`, `ScreenStack`, and cooperative jobs and are +tested to ensure the linked runtime contains no Asyncify instrumentation. + +## Consequences + +- Browser callbacks always return to the event loop. +- Native behavior can retain synchronous compatibility hosts. +- New browser-reachable work must expose a scheduled screen or cooperative job. +- Browser-specific storage, input, and diagnostics remain isolated from shared + simulation code. + +See [ADR 003](adr-003-screen-execution.md) for screen and session ownership. diff --git a/docs/browser/adr-003-screen-execution.md b/docs/browser/adr-003-screen-execution.md new file mode 100644 index 000000000..f3b717663 --- /dev/null +++ b/docs/browser/adr-003-screen-execution.md @@ -0,0 +1,121 @@ +# ADR 003: scheduled screen and session execution + +Status: accepted. + +Interactive browser execution uses explicit phases and owned navigation. No +browser callback retains a suspended C++ call stack. + +## Screen lifecycle + +`Screen` exposes `beginExecution`, `updateExecution`, `handleExecutionEvent`, +`drawExecution`, and `finishExecution`. The host supplies input batches and timer +values. Completion stops further dispatch, and destruction runs once after the +owner has observed the result. + +`ScreenStack` owns screens with `unique_ptr`. Push, completion, and replacement +requests are deferred to frame boundaries. A parent stops receiving input as soon +as it requests a child, remains alive while that child runs, and receives the +result before the child is destroyed. Application quit unwinds the stack without +running continuations that could reopen navigation. + +`Application` owns the stack and the top-level flows. Its `frame(tick, events)` +and `delay(now)` methods are the common native/browser update interface. Native +`ApplicationHost::run` polls SDL around that interface; the browser host schedules +one callback at a time. + +The synchronous `Screen::execute` compatibility host remains for native-only +callers. Browser entry points do not call it. + +## Game sessions + +`GameSessionScreen` owns an `Engine` and drives its begin, step, draw, delay, and +finish operations. `Engine::stepSession` consumes host-supplied input without +polling SDL. Gameplay owns held-key and modifier state derived from those events; +focus loss clears held input, scrolling, and drag state. + +Campaigns, tutorials, custom games, replays, and multiplayer matches all use the +same game-session screen. The engine remains alive while the end-game screen is +open so statistics and replay export cannot outlive game state. In-game load and +replay requests finish the current session, transfer ownership through a +`GameLoadScreen`, and either resume the retained session or return an error to its +parent. + +Native command-line and headless drivers retain synchronous engine adapters over +the same session operations. + +## Cooperative work + +Browser-reachable map parsing, game initialization, map generation, and fertility +calculation run as bounded cooperative tasks. Each task owns temporary state, +publishes results only after successful completion, and can be cancelled by +releasing it. Failed or cancelled work leaves the caller's prior state intact. + +The map editor itself is a scheduled `MapEditorScreen`. Quit confirmation, +generation, loading, fertility calculation, and save results are owned child +screens, so cancelling a dialog resumes the same editor and its unsaved map. +Campaign-editor entry drafts are owned by their completion callbacks and outlive +screens that borrow them. + +## YOG and LAN ownership + +YOG login, registration, lobby tabs, room setup, map transfer, and matches are +owned by the application stack. Network callbacks record state; transitions are +requested only after the current client update returns. A pending match launch +queues cooperative game loading and then the shared `GameSessionScreen`. + +Orders received during loading remain queued until the engine attaches. Engine +teardown detaches the borrowed network pointer. Transfer-screen destruction +cancels active transfers, and lobby teardown breaks client/game ownership cycles. +Desktop LAN discovery, admission, lobby, and match execution follow the same +stack ownership; discovery resumes after a join session returns. + +## Overlay ownership + +Features that retain their own drawing surface, such as end-game replay saving, +drive their overlay's events, timers, drawing, persistence result, resizing, and +destruction from the parent screen. They do not enter nested polling loops or +store a captured background image. + +## Compatibility boundary + +Legacy synchronous screen, overlay, message-box, and engine APIs still serve +native call sites that are outside the browser application. The Emscripten link +does not enable Asyncify, and a browser call to `ApplicationHost::wait` fails +rather than blocking or spinning. Tests verify that browser-reachable flows stay +inside the scheduled stack. + +The retained compatibility surface is deliberately finite: + +- `Screen::execute`, `Glob2Screen::execute`, and `Glob2TabScreen::execute` run a + native polling loop for older native callers. +- `ScreenStack::execute` is the native adapter around the same frame-driven + stack used by the browser. +- `OverlayScreen::execute`, `OverlayScreen::executeModal`, and the message-box + modal helper remain for native-only dialogs. Browser-reachable game, editor, + replay, and persistence overlays are driven by their owning screen instead. +- `Engine::run` and `Engine::runOneGameSession` remain for command-line and + native synchronous entry points. `GameSessionScreen` owns interactive browser + sessions and their end screens. +- `ApplicationHost::wait` maps to `SDL_Delay` only in the native host. It is a + hard error in the browser host. + +This list is the migration inventory. New interactive flows must use +`ScreenStack` and host-supplied frames; they must not call one of these adapters +from a scheduled callback. Removing an adapter requires migrating its listed +native caller and retaining the native lifecycle tests. Other methods named +`execute`, such as server administrator commands, dispatch commands and are not +screen execution APIs. + +## Verification + +The native screen and session harnesses cover lifecycle ordering, deferred +navigation, cancellation, quit propagation, deterministic simulation under +varied callback schedules, input/focus cleanup, editor ownership, load failure, +and repeated interpreter use. Browser tests exercise the same flows with real +input across Chromium, Firefox, and WebKit, including repeated loads, editor +cancellation, replay saving, YOG navigation, match startup, and clean shutdown. + +Interpreter lifetime fixes discovered by repeated loading are recorded in +[ADR 007](adr-007-script-lifetimes.md). Cooperative generation decisions are +recorded in [ADR 004](adr-004-cooperative-loading.md) and +[ADR 005](adr-005-generation-randomness.md). diff --git a/docs/browser/adr-004-cooperative-loading.md b/docs/browser/adr-004-cooperative-loading.md new file mode 100644 index 000000000..430ff66ef --- /dev/null +++ b/docs/browser/adr-004-cooperative-loading.md @@ -0,0 +1,165 @@ +# ADR 004: explicit coroutine jobs for nested loading + +Status: accepted. + +Game loading is an ordered parser: teams precede the map, the map precedes +players, and scripts follow integrity checks. Streams, section guards, and +intermediate objects must survive between stages. `CooperativeTask` uses C++20 +coroutine frames to retain those objects across deliberate checkpoints. It has +no browser APIs, threads, wall clock, or Asyncify dependency. Existing native +callers can drain the same task through a synchronous adapter. + +Loading screens use the measured cooperative slice described below. Awaited children report checkpoints +to that root; completing a child resumes the parent until its next checkpoint. +Destroying the root destroys all suspended child frames. Exceptions propagate to +the root result. Job lifetimes must be shorter than the game and stream they +borrow. A task and its game must not be used concurrently or advanced reentrantly. + +## Lifetime and reentrancy contract + +These rules are part of the API contract rather than an implementation detail: + +- The owner destroys or completes the task before destroying any game, stream, + generator, editor, buffer, or output parameter borrowed by its coroutine. +- Coroutine parameters are copied when they must survive a screen transition; + references are used only for state owned by the task's enclosing screen. +- One host thread advances a task. `advance()` must finish before that task is + advanced again, and task-owned state must not be mutated concurrently. +- `result()` is read only after `advance()` reports completion. Cancelling means + destroying the task; callers do not publish partially prepared state. +- Parent tasks own awaited children through the shared coroutine chain. Destroy + the root first so child frames release their local resources before the + objects they borrow. + +When debugging a loader, first check owner destruction order and whether a +callback attempted to advance or mutate the same job. The alternative is an +explicit state object for every parser stage. Coroutines keep ordered parser +locals and cleanup in their lexical scopes, at the cost of these less familiar +lifetime rules. Cancellation, AddressSanitizer, and repeated-load tests guard +the contract. + +This differs from retaining arbitrary UI call stacks through Asyncify: all +suspension points are explicit in parser code and resource ownership follows +ordinary C++ lifetimes. The tradeoff is a small coroutine scheduler and a C++20 +compiler requirement (the existing native and Emscripten targets already use +C++20). Native and Wasm builds and lifecycle tests gate this choice. It should be +reviewed alongside the alternative of hand-written parser state machines. + +The editor entry flow owns a staging editor and its loading task in +`EditorLoadScreen`. Only successful completion transfers the editor to +`MapEditorScreen`. Cancellation/failure destroys partial data and restores the +previous global RNG state. Map cleanup releases each allocation independently; +it cannot assume every gradient array has been constructed. Error messages are +owned screens rather than modal calls inside the loader. + +Current checkpoints cover game stages, teams and players, chunks of 512 terrain +cells, gradient seeding, individual propagation sweeps, and bounded fertility +work. A checkpoint count is a scheduling contract rather than a hard real-time +guarantee: decompression, allocation, and other individual operations run until +the next explicit checkpoint. In-session reload uses the same owned loading flow. +Map generation also uses owned cooperative jobs (ADR 005). Editor sprites remain +owned by the toolkit cache so destroying a staging editor cannot invalidate +another editor's sprite. + +Tests cover nested suspension/completion, exception propagation, cancellation +cleanup, partial gradient allocation cleanup, RNG restoration, and equality of +scheduled and synchronous loading. Browser tests exercise cancellation/restart +with real menu input. The coroutine lifecycle tests also pass under AddressSanitizer. Existing simulation +checksums remain regression gates. + +## Single-player startup ownership + +`GameGUI` and `Engine` now expose cooperative startup tasks that await the shared +parser. Headers and filenames are copied into the job so a completed selection +screen can be destroyed safely. Stream ownership follows the suspended GUI loader. +Custom games, saved games, replays, and campaign missions use `GameLoadScreen`, +which owns the engine until initialization succeeds. The campaign screen remains +alive while its mission loads and runs. + +Cancellation destroys the task before the engine, clears pending replay/network +initialization, and restores the prior RNG state. The same task is used when replacing a live session. The old session is finalized +before loading begins because replay globals are shared. Engine file/replay +writers are initialized at the final step, with no intervening UI checkpoint. +Loader errors and campaign-save errors return to owned message screens. + +Synchronous adapters remain for command-line and native headless callers. +Replay indexing, AI initialization, serialization, and music loading can each run +until their next explicit checkpoint, so cancellation tests do not certify a +maximum frame duration. + +## Replacing a map inside the editor + +The editor's load selector now emits a replacement request. `MapEditorScreen` +keeps the current editor alive while an `EditorLoadScreen` builds a separate one. +Only successful completion swaps ownership. Cancellation and failure retain the +old map and its unsaved-edit state; failures display an owned error screen. +The existing loader's RNG rollback applies to this transaction too. + +Opening a child clears held keys, modifiers, active drags, and edge-scroll input +without changing window focus or the camera. This prevents controls released in +a child screen from becoming stuck when its parent resumes. Button events still +supply their own hit-test coordinates. + +Native tests compare the retained map checksum/RNG after cancellation and a +missing-file failure, then use input to verify the unsaved-edit prompt remains. +Browser tests cancel a replacement, resume the original map, then successfully +replace it and verify that it is unmodified. Live game replacement uses the engine-owned transaction described in ADR 003. + +## Gradients during loading + +The rebase preserves upstream's lazy weighted resource and area gradients. +Loading no longer eagerly allocates and propagates those fields. The shared +8-bit AI helper gradient retains a cooperative adapter with checkpoints every +16 rows of each forward/backward sweep; simulation callers drain it synchronously. +The task borrows its map and buffers, which must remain private while suspended. + +An independent priority-queue relaxation oracle verifies exact gradient values +for an empty source set, toroidal wrapping, barriers with gaps, and multiple +source strengths. Synchronous and scheduled sweeps match this oracle. Destroying +an interrupted sweep and restarting from its partial monotonic result converges +to the same values. Startup tests retain their existing callback completion limit; +bounded batching avoids a browser callback for every new gradient checkpoint. + +The time budget is checked at explicit checkpoints, not a preemption boundary. Memory +allocation, team construction, and remaining parser/terrain operations still need +latency qualification. Gradient changes also run the native speed/replay suite +because simulation callers share the synchronous implementation. + +## Team setup during generation + +`Game::addTeamTask` awaits `Map::addTeamTask`, preserving team masks, colors, +header count, prestige limits, and script initialization order. Upstream's lazy +gradients mean this nested task can now finish without suspending. + +The asynchronous API is for a privately owned preparation game. Cancellation +discards that game and restores RNG state. Tests exercise partial team parsing +and editor preparation cancellation; they do not require obsolete eager-gradient +checkpoints. Construction and allocation still need loading latency qualification. + +## Host work budgets and clock injection + +`CooperativeSlice` is a host pacing policy shared by game loading, editor loading, +and generation. It advances a root until completion, four milliseconds of steady +clock time, or 64 checkpoints, whichever is observed first. Cheap steps can be +batched without paying browser callback and repaint overhead for each small piece +of work. The checkpoint cap also protects against a frozen or coarse clock. + +The clock is injectable. Unit tests charge a fake clock for each step and verify +elapsed-time stopping, a fresh budget on the next callback, completion without an +extra callback, the checkpoint cap, and an oversized step stopping immediately +at its next checkpoint. Lifecycle fixtures inject a frozen clock with an explicit +eight-checkpoint cap so cancellation depth is reproducible; production uses the +steady clock and the normal budgets. No test-only behavior switches are present. + +This does not guarantee that a frame lasts four milliseconds: a single unfinished +parser/constructor operation can exceed the budget, and input/rendering cost is +outside it. Such operations still require subdivision and measured latency gates. +The clock controls pacing only; job results, simulation, and synchronous adapters +remain independent of wall time. + +Saved-team parsing now yields between batches of unit slots, building slots, and +cross-reference resolution. The preparation game owns each team before parsing +starts, so cancellation destroys partially loaded objects with their owner. +The synchronous adapter drains the same parser and preserves serialization order. +Native tests cancel in each stage, including a fixture containing real units and +buildings, and check scheduled-load continuation against the session checksum. diff --git a/docs/browser/adr-005-generation-randomness.md b/docs/browser/adr-005-generation-randomness.md new file mode 100644 index 000000000..4f98c343b --- /dev/null +++ b/docs/browser/adr-005-generation-randomness.md @@ -0,0 +1,125 @@ +# ADR 005: deterministic randomness for cooperative generation + +Status: accepted. + +Generation previously mixed the synchronized generator with libc `rand`, +time-based reseeding, and shared static Perlin lookup tables. Consequently, +creating/reseeding a noise helper could change existing noise objects, including +cloud rendering. A caller's seed alone could not reproduce a generated map. +Yielding this work would expose it to still more unrelated RNG activity. + +Each `PerlinNoise` now owns its lookup tables and a standard MT19937 generator. +Explicit reseeding is repeatable and does not touch libc RNG state or another +noise object. The zero-length normalization case produces a finite unit vector. +Height-map generation seeds its own noise objects from the synchronized stream; +terrain/resource placement uses that stream too, without internal time reseeding. + +`MapGenerator::generateMap` accepts an explicit seed, applied before generation +work begins. The existing overload chooses a time-based seed for ordinary user +requests. `EditorGenerateScreen` retains its chosen seed and restores the prior synchronized +RNG state on cancellation, just as loading screens do. + +This intentionally changes newly generated layouts: old generation did not have +an isolated, reproducible seed-to-map contract. Existing map/save files and their +simulation rules are unchanged. This does not yet establish bit-exact generation +across native/Wasm platforms; floating-point height-map calculations still need +cross-platform qualification. Recorded maps remain the simulation fixtures. + +YOG matches do not ask each client to generate the selected map. The host sends +the map header and file ID, and clients that do not already have that exact map +download the map file before starting. Native/browser players therefore consume +the same map bytes in one match; the floating-point qualification above applies +to independently generating a new map from the same seed on different platforms. + +Native tests verify noise-instance independence, explicit reseeding, finite +noise samples, and no libc RNG mutation. All nine generation methods repeat their game checksum and final synchronized RNG +state when synchronous execution is compared with scheduled execution interleaved +with unrelated noise construction and libc RNG draws. Cancellation at several +checkpoints restores RNG state; invalid dimensions fail without publishing a map. + +## Cooperative ownership and remaining latency work + +`MapGenerator::generateMapTask` and its terrain/team subtasks yield through the +shared cooperative-task abstraction. Existing synchronous APIs drain those same +jobs. Callers must retain the generator, game, and descriptor until completion or +destruction. `EditorGenerateScreen` owns the descriptor in its coroutine frame and +the partial editor through the preparation screen; only successful completion +transfers that editor into an editing session. Cancelling destroys the suspended +job before the partial editor and restores the prior RNG state. Generation errors +return to an owned in-game message and the new-map flow. + +Checkpoints cover generation stages, selected terrain loops, and terrain sprite +regeneration columns, preserving traversal and RNG order. Some gradient and allocation helpers run until the next explicit checkpoint, so +the scheduling contract does not claim a hard maximum callback duration. + +Fruit placement now limits random retries, scans for an eligible tile, and fails +if none remains, avoiding an endless loop. The expanded fixtures also exposed an +old-islands building-placement call using team ID -1; it now uses the actual team. +These fixes apply to native and browser execution alike. + +## Height-map jobs + +Height-map filling, noise, stamp construction/application, island-position +searches, and normalization now yield after at most 1,024 counted loop iterations +per pass. The terrain generator awaits these nested jobs. Synchronous entry +points drain the same jobs. This bounds those loops' work between checkpoints, +not wall-clock frame duration or every generation helper. + +Island coordinates use coroutine-owned vectors so destroying a suspended job +releases temporary arrays. Height maps cannot be copied implicitly. A task borrows +its height map; callers must retain that instance and must not run simultaneous +jobs against it. Cancelling leaves partial private heights, which can be discarded +or replaced by a fresh generation; they are never published by the editor. + +Stamp caches no longer cross instance boundaries. Repeated river lowering uses +an instance-local cache invalidated when filling or replacing its stamp. Difference +stamps apply explicitly. A one-crater repeat fixture catches the former cross-map +skip; native checks also verify finite normalized values and cancellation/reuse +during nested passes. Crater RNG draws have explicit x-then-y ordering instead of +relying on compiler argument evaluation order. Newly generated layouts can change; +existing saves and simulation rules remain unchanged. + +## Partition and distance jobs + +Concrete-islands and isles generation now await cooperative distance propagation, +noise adjustment, point spacing, weighted area expansion, and player-land +partitioning. Their full-map scans, flood queues, relaxation loops, and list walks +checkpoint every 1,024 counted iterations. Local vectors, queues, and iterators +remain in their owning coroutine frames. Cancelling the editor's root job destroys +these nested frames before destroying the partially generated game. + +Synchronous helper adapters drain the same implementations. Point spacing returns +success through the job and, when requested, writes its numeric minimum distance +to a caller-owned output retained until completion. This preserves the original +isles calculation without converting a numeric result to a boolean. + +Native fixtures compare all nine complete generated-map checksums and final RNG +states under synchronous and scheduled execution. Additional editor tests cancel +concrete-island generation after 2, 8, and 20 callbacks and verify RNG recovery. +Browser tests exercise retry into both swamp and concrete-island generation. +This remains local scheduling evidence, not native/Wasm simulation parity. + +Runtime map gradients, container allocation, and some terrain operations can run +until the next explicit checkpoint. Large-map fixtures measure the complete job; +they do not turn the cooperative budget into preemption. + +The editor generation screen shares the four-millisecond/64-checkpoint host +slice with game and editor loading (ADR 004). Timing controls pacing only and does +not change RNG ordering or generated results. Individual synchronous operations +can still exceed the slice's time target. + +## Point and scoring passes + +Point collection, wrapped line tracing, border detection, random selection, +building/unit candidate filters, resource filling, oval creation, and average-area +distance scoring now expose cooperative tasks. The concrete-island/isles callers +await these tasks rather than draining their synchronous adapters. Iteration and +RNG order are retained; candidate filtering swaps its staged vector into the +result after the pass instead of copying the full vector. + +Like the partition jobs, these tasks borrow their game and vector arguments and +yield within their loops. Average-distance scoring writes a caller-owned result +only at completion. Pending vectors and outputs belong to the suspended parent +job and are discarded with the partial editor on cancellation. Full-map seeded +fixtures and editor cancellation scenarios cover their integration; per-tick +cross-platform simulation qualification is still separate work. diff --git a/docs/browser/adr-006-webgl2-rendering.md b/docs/browser/adr-006-webgl2-rendering.md new file mode 100644 index 000000000..cbfc12fd1 --- /dev/null +++ b/docs/browser/adr-006-webgl2-rendering.md @@ -0,0 +1,73 @@ +# ADR 006: reuse the 2D GPU renderer for WebGL2 + +Status: accepted. + +Glob2 already has GPU implementations of its 2D drawing operations. The browser +build compiles those implementations with the pinned Emscripten SDK's OpenGL +compatibility layer and targets WebGL2. Sprites, fonts, terrain, overlays, and +primitives are drawn by the GPU rather than uploading a software-rendered frame. + +This preserves the shared drawing interface and desktop behavior. The +compatibility layer uses SDK internals during context restoration, so SDK upgrades +must run the rendering and recovery suite. A direct GLES3 implementation could +replace it behind the same interface if browser compatibility or measured +performance requires that later. + +## Ownership and lifecycle + +The browser host uses WebGL2 by default when the browser provides a +hardware-accelerated context, and software rendering otherwise. The host probes a +scratch canvas: a context the browser flags with a major performance caveat, or +one drawn by a CPU rasterizer such as SwiftShader or llvmpipe, counts as +unavailable, because emulated WebGL2 costs several CPU cores and drops frames +where the software renderer does not. `?renderer=webgl2` forces WebGL2 even when +emulated, and `?renderer=software` selects software. Failure to create WebGL2 +falls back to software. Native builds retain their existing OpenGL dependencies. + +The renderer keeps CPU surfaces, including sprite atlases, as texture-restoration +sources. Texture names begin at zero and cannot be used before allocation. Atlas +coordinates use the atlas's actual normalization, including power-of-two textures. + +Viewport changes update the drawable, projection, clipping, and screen layouts at +a frame boundary without replacing the context. One CSS pixel maps to one drawing +buffer pixel. + +On context loss, the browser host suspends application execution. Restoration +recreates compatibility shaders and streaming buffers, then asks the renderer to +rebuild textures and projection from retained CPU state. The stack resets its +timing baseline before execution resumes; game state is retained in memory. + +## Validation + +`browser/tests/rendering.spec.js` drives real controls, verifies the actual WebGL2 +context and drawing-buffer dimensions, exercises software selection, and loses +and restores the context repeatedly during a match. It also verifies retained +settings, editor, and confirmation controls after restoration. Playwright runs +across Chromium, Firefox, and WebKit, and a local run can select WebGL2 for all +applicable scenarios with: + +```sh +GLOB2_TEST_RENDERER=webgl2 npx playwright test +``` + +Viewport tests inspect presented screenshots because WebGL may clear its drawing +buffer after presentation. Multiplayer tests disable continuous trace readback +and capture explicit screenshots so two clients can share a headless GPU without +changing simulation behavior. + +## Performance measurement + +`browser/benchmarks/rendering.cjs` compares WebGL2 and software rendering against +a locally served build. Results depend on browser, GPU, driver, and concurrent +load, so machine-specific output stays outside the repository. + +From `browser/`, run: + +```sh +GLOB2_TEST_URL=http://127.0.0.1:8770 GLOB2_ANGLE=metal node benchmarks/rendering.cjs webgl2 +GLOB2_TEST_URL=http://127.0.0.1:8770 GLOB2_ANGLE=metal node benchmarks/rendering.cjs software +``` + +Omit `GLOB2_ANGLE` to use Chromium's default hardware backend. The benchmark +starts a custom map and samples six seconds after warmup; it is a local comparison, +not a portable release threshold. diff --git a/docs/browser/adr-007-script-lifetimes.md b/docs/browser/adr-007-script-lifetimes.md new file mode 100644 index 000000000..d6ca045d3 --- /dev/null +++ b/docs/browser/adr-007-script-lifetimes.md @@ -0,0 +1,42 @@ +# Script interpreter lifetimes during reload + +## Problem + +In-game save/replay loading reuses a game engine after simulation has run. The +browser regression found a use-after-free in `MapScriptUSL::compileCode`, when +looking up the existing `gui` bridge constant. Initial loads and recompilation +before simulation did not reproduce it. + +The interpreter's root and prototype are owned outside the GC heap, so sweeping +did not clear their marks. Later collections skipped root traversal and freed +still-referenced constants. Active thread frames were not marked, and marking +did not traverse all prototype, closure and bytecode references. Native method +tables were static across interpreters but contained heap-owned methods. + +## Decision + +Keep the existing interpreter and mark/sweep design. Reset external root marks +for each collection; trace live frames, prototypes, closure environments, +locals, constants and bytecode-created prototypes. Cache native method tables +per heap and retain that cache during collection. Destroy all remaining heap +values when their interpreter is destroyed. No gameplay or browser-specific +conditionals control collection. + +This supports repeated game loads and independent validation/editor interpreters +without retaining an entire interpreter forever or borrowing another one's +method table. Script execution rules and serialized formats are unchanged. + +## Verification + +`EngineSessionHarness` checks repeated collection of roots, reclamation of dead +values, preservation of active stacks and pending bytecode constants, independent +native method tables, and final heap cleanup. The focused GC block also passed +AddressSanitizer and UndefinedBehaviorSanitizer. Browser tests exercise repeated +in-game save loads, replay reloads and failure recovery after real simulation. +Native/browser matches check that the shared runtime still agrees on checksums. + +The public MapScriptUSL header now forward-declares its owned interpreter. USL and +interpreter definitions are included only by MapScriptUSL.cpp. This prevents +unrelated game-header consumers in headless servers from instantiating prototype +code or acquiring a link dependency on the script engine. GCC exposed this +boundary issue where the macOS compiler had discarded unused definitions. diff --git a/docs/browser/gateway.md b/docs/browser/gateway.md new file mode 100644 index 000000000..1349c9c82 --- /dev/null +++ b/docs/browser/gateway.md @@ -0,0 +1,160 @@ +# Development WebSocket gateway + +This is transport infrastructure, not yet a supported multiplayer release. +The browser YOG entry now uses WebSocket transport and the same message codecs +as native TCP clients. The legacy YOG handshake, successful account login, and lobby exit are exercised +against a native server through the gateway. Browser chat stays within YOG; +the optional native IRC bridge is disabled. Exact protocol-version admission and +short matching-checksum browser/browser and browser/native matches are tested. +Complete-match qualification and safe handling remain release gates. Account +modernization, invitation rooms and coordinated recovery are deferred under the +[amended delivery scope](implementation.md); refresh/disconnect can end participation. + +## Build and run + +Install a C++20 compiler and Boost development headers (Beast and Asio). + +```sh +scons role=gateway release=1 -j4 +scons role=server release=1 -j4 +scons role=router release=1 -j4 +``` + +On Linux, the gateway executable is +`build/linux/gateway/release/glob2-ws-gateway`; replace `linux` with `darwin` +on macOS. The lobby and router executables are respectively +`build/linux/server/release/src/glob2-server` and +`build/linux/router/release/src/glob2-router`. + +```sh +build/linux/gateway/release/glob2-ws-gateway \ + --listen 127.0.0.1 --port 8080 \ + --origin http://127.0.0.1:8765 \ + --lobby-host 127.0.0.1 --lobby-port 7489 \ + --router-host 127.0.0.1 --router-port 7491 +python3 -m unittest discover -s tests/gateway -v +``` + +The test runner starts its own gateway on an ephemeral port and two gateway +routes backed by an isolated TCP echo service. `GLOB2_GATEWAY` overrides the +binary location. It does not connect to a public YOG server. + +The headless router connects to the lobby on loopback by default. Set +`GLOB2_YOG_HOST` to the private lobby hostname for a distributed deployment. +The lobby/router control connection uses the existing internal TCP port 7490. + +## Transport contract + +- `/yog` and `/router` upgrade to binary WebSockets. They forward only to the + configured lobby and router. URLs cannot choose arbitrary TCP destinations. +- WebSocket messages carry bytes of the TCP stream. Neither WebSocket messages + nor TCP reads define game-protocol message boundaries. Clients must buffer + and decode the game framing independently. +- A WebSocket message is limited to 64 KiB. A TCP read is limited to 16 KiB. + Each direction has one read/write chain, so a blocked destination stops + further reads. Large protocol transfers must be chunked above this layer. +- The gateway accepts at most 256 connections, including HTTP requests. + HTTP headers are limited to 8 KiB. Initial HTTP and backend connection + operations have ten-second timeouts; established WebSockets use Beast's + server timeout policy. +- A present browser Origin must match `--origin` exactly. Origin is not + authentication; native clients may omit it. Credentials belong in the + versioned YOG protocol, never in a gateway URL. +- `/healthz` checks process responsiveness; it does not assert backend health. + `/metrics` provides connection, admission and forwarded-byte counters. + +The listener is plain HTTP/WebSocket on loopback by default. Public deployment +requires a TLS reverse proxy, private backend ports, and restricted metrics +routing. The [development Compose package](../../deploy/README.md) runs the +proxy, assets, gateway, lobby, and router together. Release packaging and upgrade +qualification remain outstanding. + +The gateway neither owns rooms nor simulates a match. Losing the gateway +currently closes the corresponding TCP connections; reconnect semantics must +be implemented in YOG and the clients before this is advertised for play. + +## Browser client routing + +By default the browser connects to `/yog` and `/router` on its own origin, using +WSS for HTTPS pages and WS for localhost HTTP development. Configure the reverse +proxy to serve game assets and forward those two paths to the gateway. Internal +lobby/router TCP addresses sent by the legacy server are not browser destinations. + +For a separate gateway origin, set this deployment configuration before the game +starts (for example in a script loaded by the page): + +```js +globalThis.glob2Config = {websocketBase: 'wss://games.example.org'}; +``` + +The gateway still requires the page's origin through `--origin`. The setting +contains no password, session, or invitation credentials. + +`NetConnection` owns the shared two-byte big-endian frame prefix and message +codecs. `NetTransport` implementations exchange bounded byte chunks; WebSocket +messages may split or combine game frames. Inbound/outbound transport buffering +is capped at 1 MiB, and the decoded-message queue at 256 messages. Oversized, +empty, unknown, truncated, or trailing-data messages close the connection. The +initial greeting is retained while the asynchronous connection opens. + +Run `scons release=1 transport-test`, then the `net-connection-test` executable +with an unused local TCP port to cover framing, rejected packets, queue limits, +outbound limits, credential-log redaction, and a real TCP round trip. The Playwright multiplayer test uses that executable's +isolated YOG fixture and the actual gateway, with real login controls and observed +wire messages. It also enters and leaves the lobby with an isolated fixture +account and saves a lobby screenshot. Match tests create a room through the +actual controls, join from a second browser, ready both players, and compare +checksums from their outgoing orders. A native headless peer also joins through +YOG and records at least 250 simulation ticks; its checksums are compared with the browser +at the negotiated command cadence. The browser then resigns through the game +menu, the native player finishes through victory, and the browser returns to YOG. +Browser/browser fixtures also exercise the end-game screens and return to YOG +instead of stopping by closing live browser contexts. This uses the native game implementation, +not a second simulation model. + +Run `cd browser && npx playwright test multiplayer.spec.js` for the cross-browser +multiplayer suite. The default browser/browser cases use no AI and Cortex; +`GLOB2_ALL_AIS=1` covers all six shipped AIs for an extended local run. Native +cross-play currently tests a two-human match on the build host. These short +matches do not qualify sustained platform parity, account migration, or recovery. + +Native TCP currently runs SDL networking on a worker. SDL's connect/send calls +still need bounded cancellation/deadline handling before release qualification; +native WSS is described below. The browser transport is +callback driven and does not create a worker thread or use Asyncify itself. + +## Native secure gateway connections + +Desktop client builds enable native WSS by default and therefore use OpenSSL +development headers/libraries alongside Boost. Pass `wss=0` to build a TCP-only +desktop client without OpenSSL or Boost.Beast; WSS addresses then fail closed. +Headless lobby/router builds and Emscripten do not use this dependency. +Set `GLOB2_YOG_URL=wss://games.example.org` when launching the desktop client to +use that gateway for login, registration, and matches. Supply an origin only, +with an optional port; paths, query strings, and embedded credentials are rejected. +The native client uses `/yog` and `/router` and keeps match traffic on the same +configured gateway even when legacy YOG packets advertise a private router IP. + +The WSS transport pumps asynchronous Beast/Asio operations from the application +thread. It verifies the certificate chain and hostname, supplies SNI, and requires +TLS 1.2 or later. OpenSSL's default trust paths are used; `SSL_CERT_FILE` can select +an explicit CA bundle for a private deployment. There is no skip-verification +switch. Qualification of native OS trust-store packaging, especially Windows, +remains required before release. + +Connection establishment and writes have ten-second deadlines; WebSocket idle +checking uses a thirty-second timeout with keepalive. Outbound/inbound payload +queues are bounded by 1 MiB, incoming messages by 64 KiB, and incoming queued +messages by 256. Outbound WebSocket chunks are at most 16 KiB. Closing cancels +socket operations; stalled TLS cancellation is tested. System DNS resolver +cancellation and the older SDL TCP worker still need platform-wide qualification. +The legacy default YOG endpoint has not yet been migrated to a TLS-only connection +policy; explicitly configure WSS for the secure self-hosted path. + +Run `scons release=1 transport-test`, then +`python3 -m unittest discover -s tests/transport -v` for actual TLS peers covering +trusted echo, fixed routes, untrusted certificates, hostname mismatch, text and +oversized frames, rejected credential/path URLs, cancellation, and timeout. +Playwright's native cross-play cases run with both TCP and WSS native peers. The +WSS case uses an isolated test CA and TLS terminator in front of the real gateway; +production proxy routing is covered separately by the Compose suite. diff --git a/docs/browser/implementation.md b/docs/browser/implementation.md new file mode 100644 index 000000000..955c5ef59 --- /dev/null +++ b/docs/browser/implementation.md @@ -0,0 +1,69 @@ +# Browser platform implementation + +The browser target provides desktop-browser single-player and existing YOG +multiplayer: login, lobbies, room setup, joining, browser/browser matches, and +matching-release browser/native matches. It shares game logic, deterministic +simulation, save/map formats, and the YOG wire protocol with native builds. + +Guest identities, private invitations, cloud saves, late joining, backend restart +recovery, mobile UI, voice chat, and rankings are outside this change. Refreshing +or disconnecting during a match ends that player's participation. + +LAN is compiled out of the WebAssembly client because browser sandboxing cannot +provide Glob2's direct TCP listener and discovery model. The longer-term browser +multiplayer direction is a web entry flow over YOG: shareable match links, +lightweight or guest identity, and instant matchmaking. This PR provides the +cross-play transport and existing lobby flow; it does not implement that product +experience or publish a Play button on the project website. + +## Architecture + +- SCons is the source of truth for every toolchain. Plain Python source manifests + are shared by native and web targets; target identities keep generated output + isolated. +- `Application` and `ScreenStack` own interactive navigation. The browser host + schedules frames; browser-reachable loading and generation use cooperative + jobs rather than suspended C++ stacks. +- Game and AI code do not call browser APIs. The browser platform owns frame + scheduling, viewport and visibility events, file selection, storage, audio + activation, transport, and read-only diagnostics. +- Browser storage acknowledges a write only after `FS.syncfs` succeeds. Import, + export, retry, rollback, restore failure, and shutdown use the same durable + storage path. +- WebGL2 and software rendering share the renderer interfaces. Context recovery + rebuilds renderer resources while retaining the current application state. +- YOG continues to own identities, rooms, and match lifecycle. The WebSocket + gateway relays framed bytes to a fixed native backend and does not participate + in simulation. + +## Build identity + +Default outputs are `build///`. `--build=PATH` overrides +that path only when it belongs to the same identity. `identity.json` records +ownership, and generated configuration is written to +`include/glob2/BuildConfig.h`. + +Existing native commands retain their roles. For example, `scons release=1` +builds the desktop client, while `scons target=web release=1` writes the browser +application to `build/emscripten/client/release`. The compatibility command +`python3 browser/build.py` delegates to SCons. + +Browser and native multiplayer clients must use the same protocol version. +Update the client, YOG services, and gateway deployment together. See the +[protocol contract](protocol.md) and [gateway guide](gateway.md). + +## Verification + +CI builds native client/server, router, gateway, and browser identities. Native +harnesses cover screen/session ownership, loading and generation cancellation, +save safety, transports, and deterministic replay. Chromium runs the complete +browser behavior suite; Firefox and WebKit run focused startup, gameplay, and +viewport compatibility checks. Focused Chromium runs cover WebGL2 and real-window +visibility in addition to the software-renderer suite. Persistence, import/export, +context recovery, YOG, and browser/native cross-play remain in the complete suite. +Manual workflow runs accept `browser_only` when a follow-up changes only the web +host or its tests; ordinary pull requests and pushes still run every platform job. + +The operational commands live in [the browser README](../../browser/README.md). +WebKit automation is not a substitute for manual testing in shipping Safari, and +Chromium automation is not a substitute for shipping Edge qualification. diff --git a/docs/browser/protocol.md b/docs/browser/protocol.md new file mode 100644 index 000000000..2f81081cb --- /dev/null +++ b/docs/browser/protocol.md @@ -0,0 +1,69 @@ +# YOG admission and compatibility + +YOG owns admission for both native TCP and browser/native WebSocket clients. The +gateway forwards framed bytes to its configured backend; it does not maintain a +second authentication state machine or grant room membership. + +## Admission order + +| Server state | Accepted incoming messages | +| --- | --- | +| Waiting for greeting | Client information, ping reply | +| Waiting for credentials | Login attempt, registration request, ping reply | +| Authenticated | Existing lobby/room/file messages, ping reply | +| Incompatible | Ping reply; other messages close the connection | + +A greeting is accepted only once. Login and registration cannot precede it or +replace an authenticated identity. Room and file operations cannot run before +successful authentication. Invalid transitions close the connection. A rejected +password leaves the client able to retry; rejection is not authentication. + +Protocol version 29 identifies the updated browser/desktop simulation and +admission contract. Both older and newer protocol numbers are refused before +server information and before a legitimate client sends credentials. Server information now carries the server protocol as well. Updated clients +decode the shorter legacy greeting as version zero solely to report the +mismatch; they do not submit credentials to it. The legacy stable refusal +opcode and reason are retained so mismatches can be reported. +The UI asks users to install the same release as the server. A minimum-version +check is insufficient for client-simulated lockstep: newer is not equivalent to +compatible. + +The client also validates the order of server information and acceptance +messages. It publishes its next connection state before notifying listeners; +listeners can submit credentials synchronously without their new state being +overwritten afterward. Credentials submitted through the client API before +server information or after authentication are ignored. + +## Greeting wire contract + +Both greetings retain their stable opcode and the existing unsigned 16-bit +big-endian frame length (which includes the opcode). Client information is +opcode 9 followed by the unsigned 16-bit protocol number. Server information is +opcode 10 followed by login policy (8 bits), game policy (8 bits), player ID +(16 bits), and protocol number (16 bits), all multibyte fields big-endian. +The server-information body is seven bytes including its opcode. Its legacy +five-byte form is recognized only to produce the compatibility error. A partial +version field, trailing bytes or malformed frame is rejected by shared framing. + +Keep this initial version exchange small and stable. Capability and data +negotiation should use explicit typed messages after it, so recognizing an +incompatible release does not require parsing that release's entire handshake. + +## Evidence and next protocol work + +The maintained browser multiplayer suite sends invalid greeting/login/room +sequences through the real WebSocket gateway and native lobby. It tests both +version directions, duplicate greetings/logins, valid password retries and +normal lobby entry. A transport fault changes the actual browser greeting to an +incompatible version and verifies that no login or registration bytes follow. +Normal browser/browser and browser/native checksum scenarios remain regression +gates. Framing tests independently cover truncated messages and bounded queues. + +Exact protocol admission is the first compatibility boundary, not the complete +release handshake. Explicit capability negotiation, simulation compatibility +identifiers and required game-data hashes remain to be added, including +validation of the data actually loaded at runtime. Matching a protocol number +alone does not establish deterministic equivalence or authenticate a client. +Room-specific authorization, guest/session credentials and reconnect epochs also +remain separate requirements. These checks do not make the lobby ready for an +unqualified public deployment. diff --git a/docs/browser/storage.md b/docs/browser/storage.md new file mode 100644 index 000000000..99d7de6fb --- /dev/null +++ b/docs/browser/storage.md @@ -0,0 +1,181 @@ +# Browser files + +Saves, replays, custom maps and campaign progress live in the browser profile's +storage for this site's origin. Clearing site data removes them. Changing the +hostname, port or protocol uses a different store. Keep exported backups outside +the browser; these files are not cloud saves. + +## Import and export + +The load-game chooser has an **Import** button below its file list. Select games +or replays before choosing a file. The custom-game and editor map choosers have +Import and Export controls below their lists. Export downloads the selected file +in its existing Glob2 format. The page has no permanent wrapper controls. + +Imports accept `.game`, `.map` or `.replay` according to the visible list, up to +64 MiB. The browser adapter validates names, types and sizes before transferring +bytes. Shared C++ validation then reads the complete game/map state, saved local +UI fields and, for replays, the complete command stream through its terminator. +It rejects unsupported versions, incomplete fields and trailing data. Replay +import is stricter than playback's partial-corruption recovery. + +Validation is a cooperative job: Cancel releases partial state and restores the +simulation RNG. It uses a temporary GameGUI with preference persistence disabled; +validating a file must not save preferences. These jobs run from menu choosers, +without an active simulation. Native gameplay uses the same parsers. Reusing the complete loader avoids a +second implementation of the save format, but allocates temporary simulation +and UI objects. Extracting the saved UI-state codec could reduce that cost and +remove the dependency on GameGUI; this interface is open to that change. + +An import creates a new file. If its name is already present, it receives a +numbered suffix, for example `Original_(1).game`; existing files are never +replaced. The importer writes the bytes atomically, then waits for durable +browser persistence before showing **File imported** and selecting the new copy. +While persistence is pending, the chooser does not allow navigation away. + +On persistence failure, the chooser explains that the file was not saved. Select +Import again to retry, or Export file to download its bytes. Leaving the chooser +abandons and removes the unsuccessful local copy. Validation failures never +write the selected bytes. An unsuccessful import cannot overwrite an existing +save. A failed initial storage restore also prevents importing into that store. + +Durable persistence for every legacy writer and wider malformed-file fuzzing +remain release work. The current parser checks +and regression fixtures are not a claim that every possible malformed legacy +file has been qualified. + +## Tests + +`browser/tests/import.spec.js` drives real file choosers, game controls and +browser database failures. It checks byte digests, duplicate-name preservation, +corrupt/truncated input rejection, loading imported games/maps/replays, and +quota failure followed by export and retry. Read-only diagnostics expose import +state and local file digests; tests do not directly write the virtual filesystem. + +`SavegameSafetyHarness` exercises the same import service with injected +persistence completion, cancellation at a cooperative checkpoint, invalid names, +malformed player records, truncated files, duplicate names and abandoned writes. +It verifies RNG restoration and preservation of previous files. + +## Campaign progress + +The campaign and tutorial menus provide Import progress and Export progress. +Imports merge into the current campaign: completed missions remain completed, +and unlocked missions remain unlocked. The backup's player name is restored. +Mission names, map paths and prerequisite lists must match the current campaign; +the import cannot change those definitions. Starting a new campaign still has +its existing reset behavior; use the loaded campaign to merge with its progress. + +On-disk campaign saves retain the existing `.txt` format. Their writes are now +atomic. The menu waits for browser persistence before completing a save or +leaving after edits. On failure it offers Retry save, Export progress and Leave +without saving. Discard restores the previous file's exact bytes, or removes the +new file if none existed, so a later unrelated storage flush cannot commit the +abandoned change. Normal application shutdown retains best-effort saving for +active missions; abrupt browser termination cannot wait for pending I/O. + +Progress backups use `.campaign`, a bounded, versioned data-only format. This +avoids importing a full campaign definition containing new map paths. Native +and browser builds share the codec and continue reading legacy campaign saves. +The format is intentionally small and open to revision during review: + +| Field | Encoding | +|---|---| +| Signature / version | `G2CP`, then big-endian uint32 `1` | +| Campaign / player name | Length-prefixed byte strings using the game's UTF-8 convention | +| Mission count | Big-endian uint32, at most 1024 | +| Each mission | Name, map path, prerequisite count and prerequisite strings, then unlocked and completed uint8 flags | + +String lengths are big-endian uint32. Player names are limited to 512 bytes and +exclude control characters; flags must be 0 or 1. Imports are limited to 1 MiB, +require the complete format without trailing bytes, and reject mismatched +versions or definitions before modifying progress. The file-picker basename +never becomes a save destination. The format is separate from simulation saves +and does not carry accounts or credentials. + +`browser/tests/campaign-progress.spec.js` covers round trips, merging an older +backup, malformed files, persistence failure, retry and discard, including an +absent previous file. Recovery is verified from another browser page and after +reload. The native safety harness also checks every truncated backup, version +and definition mismatches, legacy text round trips and injected write failures. + +## Editor saves + +Map-editor saves use the same checked atomic file replacement as game saves. +Serialization, flush, close or replacement failure leaves the previous file +intact and does not publish a new editor map name. After a local write, the save +dialog stays open until the storage service confirms durable persistence. This +also applies to Save before quit: the editor retains its pending quit decision +and modified state until persistence succeeds. + +Quota and transaction failures retain the dialog with retry and file export. +Cancel returns to the editor with unsaved changes still marked; it does not +promise to roll back an already written local file. As with game-save retries, +the local replacement may be persisted by a later successful storage sync. +Export provides a backup independent of that browser store. Failed initial +storage restoration prevents the editor from overwriting stored maps. + +The implementation reuses LoadSaveScreen's owned persistence operation and the +shared FileManager writer. It does not introduce browser APIs into the editor. + +## Preferences and keyboard bindings + +Settings now checks atomic replacement of `preferences.txt` and both keyboard +layout files, then waits for the shared storage service before closing. Native +and browser use the same screen transition. A browser transaction failure or +quota exhaustion retains Settings with Retry and Continue and a visible failure +message. Continue retains the live changes without claiming a durable save; +it does not roll back files already written to the local filesystem, and later +background persistence may save them. While a flush is pending, Settings ignores +completion/cancellation actions. + +Each local file replacement is atomic; the three local files are not a single +filesystem transaction. The durable IndexedDB flush uses the existing storage +transaction. Orderly application Quit now performs a checked final preferences write and +waits for browser durability after the gameplay screens have been destroyed. +This also flushes their successful destructor writes. Abrupt tab/process closure +cannot perform that handshake. Diagnostics expose only graphics +flags from preferences, never saved account fields. + +## Orderly Quit and campaign authoring + +Quit keeps a shared application screen alive while the final preferences write +and storage flush complete. Repeated close requests and Escape cannot skip that +pending operation. A failed write offers Retry save and Quit without saving; +only that explicit latter choice leaves after failure. The final screen says +Game closed and does not leave a frozen Saving message. This is shared with the +native application host; browser persistence requires no gameplay JavaScript. + +Closing/reloading the browser tab itself is abrupt and cannot be made to wait +for asynchronous storage. Use the game's completed save operations before doing +so. The shutdown flush cannot repair an earlier failed local campaign/replay +write; it confirms the files successfully present in the local filesystem. + +The campaign authoring editor now also waits after its existing atomic campaign +write. Pending saves hide editing/navigation controls. On failure the editor +remains open with an error and OK to retry. Cancel returns to the editor menu; +as with settings, it does not roll back a local replacement already made, and a +later successful flush may persist it. Campaign-definition backup/import/export +is still separate release work from the implemented campaign-progress backups. + +`shutdown-storage.spec.js` covers delayed completion, resize, Escape, quota +failure, retry and explicit exit after failure. `campaign-editor-storage.spec.js` +covers delayed completion, quota failure, retry and durable bytes after reload. +The native session harness checks orderly application Quit through the same +application API, including repeated close events. + +## End-game replay export + +Save Replay on the end-game screen is an owned, scheduled overlay. It stays +responsive during resize and waits for durable browser persistence before closing. +A failed write retains the dialog and offers export/retry using the same controls +as game saves. Exporting the replay uses the checked atomic writer, retaining the +previous destination on failure and restoring the live recording's file position +before a retry. The replay format is unchanged. + +`browser/tests/replay-save.spec.js` covers real name entry, resize, delayed +persistence, ignored cancellation while writing, quota failure, file export, +retry, refresh and replay playback. The native session harness covers an invalid +destination and byte-identical retry. Automatic `last_game.replay` recording and +periodic autosaves remain background writes; abrupt tab/process termination is +not a successful save acknowledgment. Use explicit saves/exports for backups. diff --git a/docs/browser/viewport.md b/docs/browser/viewport.md new file mode 100644 index 000000000..2b83b2b5f --- /dev/null +++ b/docs/browser/viewport.md @@ -0,0 +1,78 @@ +# Viewport resize contract + +The browser host retains the newest viewport dimensions until the application +consumes them at a frame boundary. Both software and WebGL2 use this event. +WebGL2 updates its drawable and projection without replacing the context. The +software renderer allocates a replacement +surface before releasing its previous surface; nonpositive dimensions and failed +allocations leave the old target intact. Desktop hosts do not request automatic +resolution changes, preserving their existing video settings. + +A successful resize updates the target, clipping and all retained or pending +screens before processing that frame's input. Overlay screens recenter. Game and +editor screens move their camera to preserve the center map tile, update minimap +drawing and hit regions, and clear held gestures. The game session retains its +simulation and selection. Editor controls anchored to the right or bottom move +with their respective edges. The session resets its timing baseline across +viewport changes so resizing does not become simulation catch-up work. + +Rendering uses one pixel per CSS pixel, including displays with a device scale +factor of two. The browser does not impose a minimum viewport: the renderer and +active screen receive every positive CSS viewport size. Very small windows may +show less of a fixed-size dialog, but gameplay continues and enlarging the window +reveals the full layout again. The page has no permanent wrapper controls. + +## Verification + +`browser/tests/viewport.spec.js` uses real browser resize and mouse/keyboard input +in Chromium, Firefox and WebKit. Set `GLOB2_TEST_RENDERER=webgl2` to run these +same scenarios against the GPU backend. It checks canvas dimensions and its page bounds, +nonblack rendered pixels, menu hit positions, ongoing simulation through a small +viewport, editor discard dialogs, and high-density displays. Screenshots capture the +resized game menu and small-viewport rendering. The native engine-session harness checks actual +software presentation, rejected zero-sized targets, camera-center and simulation +checksum preservation, and minimap hit regions after resizing. + +## Visibility lifecycle + +The browser retains visibility edges until the application consumes them, even +when callbacks did not run while hidden. Scheduled screens suspend input on both +edges; the application skips their update while hidden. A session resumes on its +logical clock, preserving the pending tick deadline and excluding elapsed hidden +time. The native regression inserts a 60-second gap and verifies the resulting +50-tick session. `browser/visibility.config.js` runs a separate real-window +Chromium regression without Playwright focus overrides; on Linux run it under +`xvfb-run -a`. Ordinary headless tests do not prove document visibility behavior. +The normal cross-browser suite also exercises visibility bookkeeping. The +real-window Chromium regression verifies that actual background throttling pauses +single-player without producing overdue ticks on resume. + +## Button coordinates in the pinned SDL browser backend + +SDL 2.32.8's Emscripten mouse-button callback uses SDL's last motion position, +rather than the button event's coordinates. A missing/coalesced motion can +therefore make a valid click hit the old position. The browser shell synchronizes +absolute motion from each button event before SDL's button listener runs. It +handles releases outside the canvas for a canvas-started press and skips relative +pointer-lock input. This SDK adaptation stays in the browser platform layer. + +`browser/tests/input.spec.js` suppresses trusted motion delivery while keeping +real button input, then selects a map, starts a match, resizes and quits it. +The regression fails before the adapter in Chromium and passes afterward in +Chromium, Firefox and WebKit. The real-window Chromium checks run with both +renderers. + +## Browser navigation shortcuts + +The browser shell reserves Ctrl/Cmd+R (including Shift for reload variants) and +Ctrl/Cmd+L for the browser while the canvas has focus. Capture-phase handlers +stop these keydown/keypress events before SDL can cancel their default action. +Key releases still reach SDL, avoiding a stuck game key if it was already held +before the modifier was pressed. Alt-modified combinations remain game input. +Desktop input is unchanged. + +The input suite checks default-action cancellation against the real SDL +listeners in each browser engine, then verifies ordinary menu input. Synthetic +keyboard events cannot trigger browser chrome, so actual shortcut navigation +also requires manual browser verification. Reload is ordinary page navigation; +it does not promise to save an unsaved match or finish a pending storage write. diff --git a/docs/development-notes.md b/docs/development-notes.md index 901377b1d..40ed975bc 100644 --- a/docs/development-notes.md +++ b/docs/development-notes.md @@ -21,22 +21,26 @@ scons -C test # rebuild the separate test suite - Top-level `scons` does not rebuild the separate `test/` suite. Rebuild there before trusting its binaries; explicit real-engine harness targets are listed in `test/README.md` and CI. Extend an existing relevant harness where practical. -- `options_cache.py` retains build options: specify `release` and `server` when - switching configurations. A bare `build/src/glob2-server` target does not enable - `YOG_SERVER_ONLY`; use `server=1`. Use `release=1` for headless measurements: the - unoptimized build can be substantially slower. Use `release=0` for debugging. - `scons -c` cleans; `--build=/tmp/out` selects an out-of-source build directory; - `BINDIR=/path/bin INSTALLDIR=/path/share` selects installation locations. +- Options are explicit on each invocation; there is no cross-invocation + `options_cache.py`. Outputs and generated configuration are isolated under + `build///`. A bare `build/src/glob2-server` target does + not enable `YOG_SERVER_ONLY`; use `server=1`. Use `release=1` for headless + measurements: the unoptimized build can be substantially slower. Use `release=0` + for debugging. `scons -c` cleans; `--build=/tmp/out` selects an out-of-source + build directory; `BINDIR=/path/bin INSTALLDIR=/path/share` selects installation + locations. - `mingw=1` builds natively on Windows; `mingwcross=1` cross-compiles. Dependencies are in `vcpkg.json` and CI. Check the affected platform jobs rather than assuming a successful local build covers another compiler or operating system. +- `scons target=web release=1` builds the WebAssembly browser client; see + `docs/browser/adr-001-build-isolation.md` for the toolchain isolation this relies on. - Dependencies include SDL2/net/ttf/image, Vorbis/Ogg, Speex, OpenGL/GLU, libepoxy, Boost date_time, zlib, fribidi and pcre; PortAudio is optional. - `CCACHE=1` opts into the shared compiler cache. Unset it when generating `compile_commands.json`; do not add `CCACHE_SLOPPINESS` settings that weaken content or time-macro validation (`include_file_mtime`, `include_file_ctime`, - `time_macros`). `scons/ccache.py` is used by both build entry points; the environment - opt-in is not persisted in `options_cache.py`. + `time_macros`). `scons/ccache.py` is used by both build entry points; it is an + environment opt-in, not a scons option, so it never sticks between invocations. - Keep harness runs out of personal profiles: use the existing disposable-profile runners and retain fixtures, seeds, logs and checksums needed to reproduce a result. diff --git a/libgag/include/AlphaMapRender.h b/libgag/include/AlphaMapRender.h index d8b5b71ab..48ad72719 100644 --- a/libgag/include/AlphaMapRender.h +++ b/libgag/include/AlphaMapRender.h @@ -27,7 +27,9 @@ inline void drawAlphaMapBatched(const std::valarray &map, int map std::vector row((mapW - 1) * 12 * rowsPerBatch); GLint oldBuffer; glGetIntegerv(GL_ARRAY_BUFFER_BINDING, &oldBuffer); +#ifndef GLOB2_WEBGL2 glPushClientAttrib(GL_CLIENT_VERTEX_ARRAY_BIT); +#endif glBindBuffer(GL_ARRAY_BUFFER, 0); glEnableClientState(GL_VERTEX_ARRAY); glEnableClientState(GL_COLOR_ARRAY); @@ -60,7 +62,14 @@ inline void drawAlphaMapBatched(const std::valarray &map, int map if ((dy + 1) % rowsPerBatch == 0 || dy == mapH - 2) glDrawArrays(GL_TRIANGLES, 0, ((dy % rowsPerBatch) + 1) * (mapW - 1) * 12); } +#ifdef GLOB2_WEBGL2 + // Emscripten's legacy-GL bridge does not provide client attribute stacks. + // Glob2's other batched paths also leave client arrays disabled. + glDisableClientState(GL_VERTEX_ARRAY); + glDisableClientState(GL_COLOR_ARRAY); +#else glPopClientAttrib(); +#endif glBindBuffer(GL_ARRAY_BUFFER, oldBuffer); } } // namespace GAGCore diff --git a/libgag/include/ApplicationHost.h b/libgag/include/ApplicationHost.h new file mode 100644 index 000000000..f687705da --- /dev/null +++ b/libgag/include/ApplicationHost.h @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once +#include +#include +#include +#include +#include +#include + +namespace GAGCore::ApplicationHost +{ +class Loop +{ +public: + virtual ~Loop() = default; + virtual bool frame(std::uint32_t tick, const std::vector& events) = 0; + virtual std::uint32_t delay(std::uint32_t now) = 0; +}; +// Own the loop until it completes; destroy it before the completion callback. +// Native hosts return after completion; browser hosts return after scheduling. +void run(std::unique_ptr loop, std::function complete); + +// Compatibility wait for native-only modal loops. Browser-reachable code must +// use scheduled screens or cooperative tasks; the browser implementation rejects it. +void wait(std::uint32_t milliseconds); + +// Consume the newest host viewport request at an application frame boundary. +bool takeViewportSize(int& width, int& height); +// Visibility edges are retained even when no frame ran while hidden. +bool takeVisibilityChange(bool& hidden); + +enum class FileSelectionState { Pending, Selected, Cancelled, Failed }; +struct SelectedFile { std::string name; std::vector bytes; }; +class FileSelection { +public: + virtual ~FileSelection() = default; + virtual FileSelectionState state() const = 0; + virtual SelectedFile takeFile() = 0; +}; +bool canImportFiles(); +std::unique_ptr selectFile(const std::string& extension); + +bool storageRestoreFailed(); +bool canExportFiles(); +bool exportLocalFile(const std::string& path); +bool exportFile(const std::string& name, const std::vector& bytes); + +// Persistence completion is owned by the caller; releasing it is safe while pending. +enum class PersistenceState { Pending, Succeeded, Failed }; +class Persistence { +public: + virtual ~Persistence() = default; + virtual PersistenceState state() const = 0; +}; +std::unique_ptr persistStorage(); + +// Read-only diagnostics; hosts decide whether and how to publish them. +void screenChanged(const char* name); +void importChanged(const char* state); +void simulationAdvanced(std::uint32_t tick); +void matchFrame(bool paused); +// Whether the torus overview replaced the flat map on the latest match frame. +void overviewDrawn(bool drawn); +// Read-only presentation diagnostic for the active multiplayer room. +void roomReady(bool canStart); +void exited(int result); +} diff --git a/libgag/include/CooperativeSlice.h b/libgag/include/CooperativeSlice.h new file mode 100644 index 000000000..c9c98d3ef --- /dev/null +++ b/libgag/include/CooperativeSlice.h @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once +#include "CooperativeTask.h" +#include +#include +namespace GAGCore { +// Host-side pacing for explicit jobs. A checkpoint may exceed the time budget; +// this never preempts a job or changes its result. The cap also bounds work when +// the clock has insufficient resolution. Simulation uses synchronous adapters. +class CooperativeSlice { +public: + using Time = std::chrono::steady_clock::time_point; + using Clock = std::function; + explicit CooperativeSlice(Clock clock = std::chrono::steady_clock::now, + std::chrono::steady_clock::duration budget = std::chrono::milliseconds(4), + unsigned checkpointLimit = 64) + : clock(std::move(clock)), budget(budget), checkpointLimit(checkpointLimit) + { + if (!this->clock || budget <= decltype(budget)::zero() || !checkpointLimit) + throw std::invalid_argument("Cooperative slice requires a clock and positive budgets"); + } + bool advance(CooperativeTask& task) const { + const auto start = clock(); + for (unsigned step = 0; step < checkpointLimit; ++step) { + if (task.advance()) return true; + if (clock() - start >= budget) return false; + } + return false; + } +private: + Clock clock; + std::chrono::steady_clock::duration budget; + unsigned checkpointLimit; +}; +} diff --git a/libgag/include/CooperativeTask.h b/libgag/include/CooperativeTask.h new file mode 100644 index 000000000..bcfc3f87c --- /dev/null +++ b/libgag/include/CooperativeTask.h @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once +#include +#include +#include +#include +#include +namespace GAGCore +{ +// A move-only boolean job. advance() runs to the next explicit checkpoint, +// including checkpoints inside awaited child jobs. Destroying the root destroys +// every suspended child and its local resources. All use stays on one host thread. +class CooperativeTask +{ + struct State { std::coroutine_handle<> leaf; const char* stage = ""; }; +public: + struct promise_type; + using Handle = std::coroutine_handle; + struct promise_type + { + std::shared_ptr state = std::make_shared(); + std::coroutine_handle<> continuation = std::noop_coroutine(); + std::exception_ptr error; + bool value = false; + CooperativeTask get_return_object() { + auto handle = Handle::from_promise(*this); state->leaf = handle; + return CooperativeTask(handle); + } + std::suspend_always initial_suspend() noexcept { return {}; } + struct Final { + bool await_ready() noexcept { return false; } + std::coroutine_handle<> await_suspend(Handle handle) noexcept { + auto& p = handle.promise(); p.state->leaf = p.continuation; + return p.continuation; + } + void await_resume() noexcept {} + }; + Final final_suspend() noexcept { return {}; } + void return_value(bool result) noexcept { value = result; } + void unhandled_exception() noexcept { error = std::current_exception(); } + }; + explicit CooperativeTask(Handle handle) : handle(handle) {} + CooperativeTask(CooperativeTask&& other) noexcept : handle(std::exchange(other.handle, {})) {} + CooperativeTask(const CooperativeTask&) = delete; + ~CooperativeTask() { if (handle) handle.destroy(); } + bool advance() { + if (!handle.done()) handle.promise().state->leaf.resume(); + return handle.done(); + } + bool result() const { + if (!handle.done()) throw std::logic_error("Job is not complete"); + if (handle.promise().error) std::rethrow_exception(handle.promise().error); + return handle.promise().value; + } + const char* stage() const { return handle.promise().state->stage; } + bool run() { while (!advance()) {} return result(); } + struct Checkpoint { + const char* stage; + bool await_ready() noexcept { return false; } + void await_suspend(Handle handle) noexcept { + handle.promise().state->leaf = handle; + if (stage) handle.promise().state->stage = stage; + } + void await_resume() noexcept {} + }; + static Checkpoint checkpoint(const char* stage = nullptr) { return {stage}; } + struct Awaiter { + Handle child; + bool await_ready() noexcept { return child.done(); } + std::coroutine_handle<> await_suspend(Handle parent) noexcept { + child.promise().state = parent.promise().state; + child.promise().continuation = parent; + child.promise().state->leaf = child; + return child; + } + bool await_resume() { + if (child.promise().error) std::rethrow_exception(child.promise().error); + return child.promise().value; + } + }; + Awaiter operator co_await() && noexcept { return {handle}; } +private: + Handle handle; +}; +} diff --git a/libgag/include/GAGSys.h b/libgag/include/GAGSys.h index a5dbc50d6..ec4e0f91b 100644 --- a/libgag/include/GAGSys.h +++ b/libgag/include/GAGSys.h @@ -30,7 +30,7 @@ #endif #ifdef HAVE_CONFIG_H -#include +#include #endif // This is the only one which should be left... In theory :-) // Remove this comment once all other SDL deps have been removed. diff --git a/libgag/include/GUIBase.h b/libgag/include/GUIBase.h index fc298fb67..e63347449 100644 --- a/libgag/include/GUIBase.h +++ b/libgag/include/GUIBase.h @@ -292,6 +292,7 @@ namespace GAGGUI //! true while execution is running, no need for serialisation bool run; + bool executionActive; //! the return code, no need for serialisation Sint32 returnCode; @@ -325,6 +326,19 @@ namespace GAGGUI //! Full screen paint, call paint(0, 0, gfx->getW(), gfx->getH()) virtual void paint(void); + //! Nonblocking lifecycle. The host supplies time and already-polled input. + void beginExecution(GAGCore::DrawableSurface *surface); + virtual void updateExecution(Uint32 tick); + virtual void suspendExecution() {} + virtual void viewportResized(int oldWidth, int oldHeight, int width, int height) {} + virtual void handleExecutionEvent(SDL_Event event); + virtual void drawExecution(); + virtual Uint32 executionDelay(Uint32 now, Uint32 fallback) { return fallback; } + bool isExecutionRunning() const { return run; } + //! Complete once stopped; repeated calls do not repeat destruction callbacks. + int finishExecution(); + + //! Compatibility host loop for callers not yet migrated to a screen stack. //! Run the screen until someone call endExecute(returnCode). Return returnCode virtual int execute(GAGCore::DrawableSurface *gfx, int stepLength); //! Call this method to stop the execution of the screen @@ -367,6 +381,7 @@ namespace GAGGUI //! Destructor virtual ~OverlayScreen(); void updateLayout(void) override; + void viewportResized(int, int, int width, int height) override { decX = (width - getW()) / 2; decY = (height - getH()) / 2; } //! Run the OverlayScreen, call Screen::execute with the correct DrawableSurface virtual int execute(GAGCore::DrawableSurface *gfx, int stepLength); diff --git a/libgag/include/InputState.h b/libgag/include/InputState.h new file mode 100644 index 000000000..2497f75e2 --- /dev/null +++ b/libgag/include/InputState.h @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once +#include +#include + +namespace GAGCore +{ +// Held input belongs to the processed event stream, not SDL's latest poll. +class InputState +{ +public: + void observe(const SDL_Event& event) + { + if (event.type == SDL_WINDOWEVENT) { + if (event.window.event == SDL_WINDOWEVENT_FOCUS_LOST) { + clearHeld(); focused = false; + } else if (event.window.event == SDL_WINDOWEVENT_FOCUS_GAINED) focused = true; + } + if (focused && (event.type == SDL_KEYDOWN || event.type == SDL_KEYUP)) { + const auto code = event.key.keysym.scancode == SDL_SCANCODE_UNKNOWN + ? SDL_GetScancodeFromKey(event.key.keysym.sym) : event.key.keysym.scancode; + if (code > SDL_SCANCODE_UNKNOWN && code < SDL_NUM_SCANCODES) + keys[code] = event.type == SDL_KEYDOWN; + mods = static_cast(event.key.keysym.mod); + } + } + void clearHeld() { keys.fill(0); mods = KMOD_NONE; } + const Uint8* keyboard() const { return keys.data(); } + SDL_Keymod modifiers() const { return mods; } + bool hasFocus() const { return focused; } +private: + std::array keys{}; + SDL_Keymod mods = KMOD_NONE; + bool focused = true; +}; +} diff --git a/libgag/include/SDLGraphicContext.h b/libgag/include/SDLGraphicContext.h index 92b256423..4a7f98525 100644 --- a/libgag/include/SDLGraphicContext.h +++ b/libgag/include/SDLGraphicContext.h @@ -396,6 +396,11 @@ namespace GAGCore // modifiers virtual bool setRes(int w, int h, Uint32 flags); + // Resize a software render target without replacing its window or assets. + bool resizeViewport(int w, int h); +#ifdef GLOB2_WEBGL2 + static void restoreBrowserContext(); +#endif virtual void setRes(int w, int h) { setRes(w, h, optionFlags); } //! true when the window pixel size differs from the logical resolution, so output is scaled bool isScalingActive(void); diff --git a/libgag/include/ScreenStack.h b/libgag/include/ScreenStack.h new file mode 100644 index 000000000..762b5caf8 --- /dev/null +++ b/libgag/include/ScreenStack.h @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once +#include +#include +#include +#include + +namespace GAGGUI +{ +// Owns screens through completion. Mutations requested by callbacks become +// visible at the next frame boundary; a child never receives its opening input. +class ScreenStack +{ +public: + using Completion = std::function; + explicit ScreenStack(GAGCore::DrawableSurface& surface) : surface(surface) {} + ~ScreenStack(); + void push(std::unique_ptr screen, Completion completed = {}); + void frame(Uint32 tick, const std::vector& events); + bool running() const { return !stopped && (!screens.empty() || !pending.empty()); } + Uint32 delay(Uint32 now, Uint32 fallback); + int result() const { return lastResult; } + // Transitional SDL host. Browser scheduling will call frame directly. + int execute(unsigned stepLength = 40); + void stop(); + void suspendExecution(); + void viewportResized(int oldWidth, int oldHeight, int width, int height); +private: + struct Entry { Completion completed; std::unique_ptr screen; Screen* owner; }; + GAGCore::DrawableSurface& surface; + std::vector screens, pending; + int lastResult = 0; + bool stopped = false, dispatching = false; + void boundary(); +}; +} diff --git a/libgag/src/ApplicationHost.cpp b/libgag/src/ApplicationHost.cpp new file mode 100644 index 000000000..9333a1778 --- /dev/null +++ b/libgag/src/ApplicationHost.cpp @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include +#include + +namespace GAGCore::ApplicationHost +{ +void run(std::unique_ptr loop, std::function complete) +{ + for (;;) { + std::vector events; + SDL_Event event; + while (SDL_PollEvent(&event)) events.push_back(event); + if (!loop->frame(SDL_GetTicks(), events)) break; + wait(loop->delay(SDL_GetTicks())); + } + loop.reset(); + complete(); +} + +void wait(std::uint32_t milliseconds) +{ + if (milliseconds) SDL_Delay(milliseconds); +} +bool takeVisibilityChange(bool&) { return false; } +bool takeViewportSize(int&, int&) { return false; } +bool canImportFiles() { return false; } +std::unique_ptr selectFile(const std::string&) { return {}; } +bool storageRestoreFailed() { return false; } +bool canExportFiles() { return false; } +bool exportFile(const std::string&, const std::vector&) { return false; } +namespace { +class NativePersistence : public Persistence { + PersistenceState state() const override { return PersistenceState::Succeeded; } +}; +} +std::unique_ptr persistStorage() { return std::make_unique(); } +void importChanged(const char*) {} +void screenChanged(const char*) {} +void simulationAdvanced(std::uint32_t) {} +void matchFrame(bool) {} +void overviewDrawn(bool) {} +void roomReady(bool) {} +void exited(int) {} +} diff --git a/libgag/src/BinaryStream.cpp b/libgag/src/BinaryStream.cpp index 6f0ab44cf..220e5cdff 100644 --- a/libgag/src/BinaryStream.cpp +++ b/libgag/src/BinaryStream.cpp @@ -118,6 +118,7 @@ namespace GAGCore read(&buffer[0], len, ""); buffer[len] = 0; - return std::string(&buffer[0]); + // Length-prefixed fields can contain zero bytes (for example password hashes). + return std::string(&buffer[0], len); } } diff --git a/libgag/src/DrawableSurface.cpp b/libgag/src/DrawableSurface.cpp index d2c324a87..ad586e821 100644 --- a/libgag/src/DrawableSurface.cpp +++ b/libgag/src/DrawableSurface.cpp @@ -9,20 +9,22 @@ #include #include #include +#ifdef GLOB2_WEBGL2 +#include +#endif namespace GAGCore { +#ifdef GLOB2_WEBGL2 + namespace { std::set gpuSurfaces; } +#endif SDL_Surface *DrawableSurface::convertForUpload(SDL_Surface *source) { - SDL_Surface *dest; - if (_gc->sdlsurface->format->BitsPerPixel == 32) - { - dest = SDL_ConvertSurfaceFormat(source, SDL_PIXELFORMAT_BGRA32, 0); - } - else - { - dest = SDL_ConvertSurface(source, &_glFormat, 0); - } + // Color::pack/unpack and software drawing use _glFormat. A 32-bit + // display is not necessarily BGRA (the browser uses RGBA), so loaded + // and cloned sprites must use the same format as generated surfaces. + // WebGL converts to RGBA separately at the texture upload boundary. + SDL_Surface *dest = SDL_ConvertSurface(source, &_glFormat, 0); assert(dest); return dest; } @@ -94,6 +96,9 @@ namespace GAGCore { glGenTextures(1, reinterpret_cast(&texture)); glState.allocatedTextureCount++; +#ifdef GLOB2_WEBGL2 + gpuSurfaces.insert(this); +#endif initTextureSize(); } #endif @@ -101,6 +106,7 @@ namespace GAGCore void DrawableSurface::initTextureSize(void) { + if (!texture || textureInfo) return; #ifdef HAVE_OPENGL if (_gc->optionFlags & GraphicContext::USEGPU) { @@ -116,7 +122,7 @@ namespace GAGCore int h = getMinPowerOfTwo(sdlsurface->h); glState.allocatedTextureBytes-=gpuBytes;gpuBytes=w*h*4;glState.allocatedTextureBytes+=gpuBytes; std::valarray zeroBuffer((char)0, w * h * 4); - glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, w, h, 0, GL_BGRA, GL_UNSIGNED_BYTE, &zeroBuffer[0]); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, w, h, 0, GL_RGBA, GL_UNSIGNED_BYTE, &zeroBuffer[0]); texMultX = 1.0f / static_cast(w); texMultY = 1.0f / static_cast(h); @@ -143,7 +149,13 @@ namespace GAGCore void *pixelsPtr; GLenum pixelFormat; - #if SDL_BYTEORDER == SDL_BIG_ENDIAN + #if defined(GLOB2_WEBGL2) + std::unique_ptr rgba( + SDL_ConvertSurfaceFormat(sdlsurface, SDL_PIXELFORMAT_RGBA32, 0), SDL_FreeSurface); + if (!rgba) return; + pixelsPtr = rgba->pixels; + pixelFormat = GL_RGBA; + #elif SDL_BYTEORDER == SDL_BIG_ENDIAN std::valarray tempPixels(sdlsurface->w * sdlsurface->h); Uint32 *sourcePtr = static_cast(sdlsurface->pixels); for (size_t i=0; ioptionFlags & GraphicContext::USEGPU)) { @@ -338,3 +354,39 @@ namespace GAGCore dirty = true; } } + +#ifdef GLOB2_WEBGL2 +namespace GAGCore { +void GraphicContext::restoreBrowserContext() +{ + if (!_gc || !(_gc->optionFlags & USEGPU)) return; + // GL objects owned outside libgag, such as the torus overview's, belong to + // the lost context; a new generation tells them to recreate, not reuse. + ++_gc->glContextGeneration; + glState.resetCache(); + glDisable(GL_BLEND); + glDisable(GL_SCISSOR_TEST); + glDisable(GL_TEXTURE_2D); + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + glMatrixMode(GL_PROJECTION); + glLoadIdentity(); + glOrtho(0, _gc->getW(), _gc->getH(), 0, -1, 1); + glMatrixMode(GL_MODELVIEW); + glLoadIdentity(); + _gc->applyGLViewport(); + // CPU surfaces, including sprite atlases, remain the source of truth. + // Rebuild GPU objects without touching game, camera, or UI state. + glState.allocatedTextureCount = 0; + for (auto* surface : gpuSurfaces) { + glDeleteTextures(1, &surface->texture); + surface->texture = 0; + if (surface->textureInfo) continue; + glGenTextures(1, &surface->texture); + ++glState.allocatedTextureCount; + surface->initTextureSize(); + surface->uploadToTexture(); + } + _gc->setClipRect(); +} +} +#endif diff --git a/libgag/src/DrawableSurfaceCompound.cpp b/libgag/src/DrawableSurfaceCompound.cpp index e650694af..afce1f565 100644 --- a/libgag/src/DrawableSurfaceCompound.cpp +++ b/libgag/src/DrawableSurfaceCompound.cpp @@ -124,11 +124,16 @@ namespace GAGCore glGetIntegerv(GL_VIEWPORT, viewport); const int fbW = viewport[2], fbH = viewport[3]; std::valarray tempPixels(4*fbW*fbH); - #if SDL_BYTEORDER == SDL_BIG_ENDIAN + #if SDL_BYTEORDER == SDL_BIG_ENDIAN || defined(GLOB2_WEBGL2) glReadPixels(viewport[0], viewport[1], fbW, fbH, GL_RGBA, GL_UNSIGNED_BYTE, &tempPixels[0]); #else glReadPixels(viewport[0], viewport[1], fbW, fbH, GL_BGRA, GL_UNSIGNED_BYTE, &tempPixels[0]); #endif +#ifdef GLOB2_WEBGL2 + // WebGL readback is RGBA; the retained software surface is BGRA. + for (size_t pixel = 0; pixel < tempPixels.size(); pixel += 4) + std::swap(tempPixels[pixel], tempPixels[pixel + 2]); +#endif if (fbW == sw && fbH == sh) { // same size: plain row copy, flipping GL's bottom-up rows @@ -280,7 +285,23 @@ namespace GAGCore void DrawableSurface::drawSurface(int x, int y, int w, int h, DrawableSurface *surface, int sx, int sy, int sw, int sh, Uint8 alpha) { - // TODO : Implement + if ((w <= 0) || (h <= 0) || (sw <= 0) || (sh <= 0)) + return; + if ((w == sw) && (h == sh)) + { + drawSurface(x, y, surface, sx, sy, sw, sh, alpha); + return; + } + // Stretch nearest-neighbour, as the GPU path samples. SDL clips to + // clipRect and blends the source's per-pixel alpha, modulated by alpha. + SDL_Rect sr = {sx, sy, sw, sh}; + SDL_Rect dr = {x, y, w, h}; + if (alpha != Color::ALPHA_OPAQUE) + SDL_SetSurfaceAlphaMod(surface->sdlsurface, alpha); + SDL_BlitScaled(surface->sdlsurface, &sr, sdlsurface, &dr); + if (alpha != Color::ALPHA_OPAQUE) + SDL_SetSurfaceAlphaMod(surface->sdlsurface, Color::ALPHA_OPAQUE); + dirty = true; } void DrawableSurface::drawSurface(float x, float y, float w, float h, DrawableSurface *surface, int sx, int sy, int sw, int sh, Uint8 alpha) diff --git a/libgag/src/FileManager.cpp b/libgag/src/FileManager.cpp index 0b2e6de87..9c59c012b 100644 --- a/libgag/src/FileManager.cpp +++ b/libgag/src/FileManager.cpp @@ -16,7 +16,7 @@ // here we handle compile time options #ifdef HAVE_CONFIG_H -# include "config.h" +# include #else # ifdef WIN32 # define PACKAGE_DATA_DIR ".." diff --git a/libgag/src/FileManagerAtomic.cpp b/libgag/src/FileManagerAtomic.cpp index cb99d3408..ad8c65c16 100644 --- a/libgag/src/FileManagerAtomic.cpp +++ b/libgag/src/FileManagerAtomic.cpp @@ -1,5 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include +#include +#include #include #include #include @@ -133,3 +135,20 @@ namespace GAGCore } } + +bool GAGCore::ApplicationHost::exportLocalFile(const std::string& exportPath) +{ + try { + BinaryInputStream stream(Toolkit::getFileManager()->openInputStreamBackend(exportPath)); + if (!stream.isValid()) throw std::runtime_error("Save unavailable"); + stream.seekFromEnd(0); + const size_t size = stream.getPosition(); + if (!size || size > 64u * 1024u * 1024u) throw std::runtime_error("Save exceeds export limit"); + stream.seekFromStart(0); + std::vector bytes(size); + stream.read(bytes.data(), size, "export"); + const auto slash = exportPath.find_last_of("/\\"); + const auto name = exportPath.substr(slash == std::string::npos ? 0 : slash + 1); + return exportFile(name, bytes); + } catch (const std::exception&) { return false; } +} diff --git a/libgag/src/GUIBase.cpp b/libgag/src/GUIBase.cpp index 5d17c6f01..5cdd599cb 100644 --- a/libgag/src/GUIBase.cpp +++ b/libgag/src/GUIBase.cpp @@ -1,6 +1,9 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Copyright (C) 2001-2004 Stephane Magnenat & Luc-Olivier de Charrière +#include +#include +#include #include #include #include @@ -393,6 +396,7 @@ namespace GAGGUI gfx = NULL; returnCode = 0; run = false; + executionActive = false; } Screen::~Screen() @@ -403,110 +407,107 @@ namespace GAGGUI } } - int Screen::execute(DrawableSurface *gfx, int stepLength) + void Screen::beginExecution(DrawableSurface *surface) { - Uint64 frameStartTime; - Sint64 frameWaitTime; - - this->gfx = gfx; - - // init widgets + if (executionActive) throw std::logic_error("Screen execution is already active"); + if (!surface) throw std::invalid_argument("Screen execution requires a surface"); + gfx = surface; + returnCode = 0; + run = true; + executionActive = true; + ApplicationHost::screenChanged(typeid(*this).name()); dispatchInit(); - - // create screen event onAction(NULL, SCREEN_CREATED, 0, 0); - - // draw screen - dispatchPaint(); - run=true; - - while (run) + } + + void Screen::updateExecution(Uint32 tick) + { + if (run) dispatchTimer(tick); + } + + void Screen::handleExecutionEvent(SDL_Event event) + { + if (!run) return; + GraphicContext::translateMouseEvent(&event); + if (event.type == SDL_QUIT) { - // get first timer - frameStartTime=SDL_GetTicks64(); - - // send timer - dispatchTimer(frameStartTime); - - // send events - SDL_Event lastMouseMotion, windowEvent, event; - bool hadLastMouseMotion=false; - bool wasWindowEvent=false; - while (GAGCore::GraphicContext::pollEvent(&event)) + endExecute(QUIT_APPLICATION); + return; + } + if (event.type == SDL_KEYDOWN) + { +#ifdef USE_OSX + if (event.key.keysym.sym == SDLK_q && (event.key.keysym.mod & KMOD_GUI)) { - GAGCore::GraphicContext::translateMouseEvent(&event); - switch (event.type) - { - case SDL_QUIT: - { - run=false; - returnCode=QUIT_APPLICATION; - break; - } - break; - case SDL_MOUSEMOTION: - { - hadLastMouseMotion=true; - lastMouseMotion=event; - } - break; - case SDL_WINDOWEVENT: - { - windowEvent=event; - wasWindowEvent=true; - } - break; - case SDL_KEYDOWN: - { - //Manual integration of cmd+q and alt f4 -# ifdef USE_OSX - if(event.key.keysym.sym == SDLK_q && SDL_GetModState() & KMOD_GUI) - { - run=false; - returnCode=QUIT_APPLICATION; - break; - } -# endif -# ifdef USE_WIN32 - if(event.key.keysym.sym == SDLK_F4 && SDL_GetModState() & KMOD_ALT) - { - run=false; - returnCode=QUIT_APPLICATION; - break; - } -# endif - dispatchEvents(&event); - } - break; + endExecute(QUIT_APPLICATION); + return; + } +#endif +#ifdef USE_WIN32 + if (event.key.keysym.sym == SDLK_F4 && (event.key.keysym.mod & KMOD_ALT)) + { + endExecute(QUIT_APPLICATION); + return; + } +#endif + } + dispatchEvents(&event); + } - default: - { - dispatchEvents(&event); - } - break; + void Screen::drawExecution() + { + if (run) dispatchPaint(); + } + + int Screen::finishExecution() + { + if (run) throw std::logic_error("Cannot finish a running screen"); + const int result = returnCode; + if (executionActive) + { + executionActive = false; + onAction(NULL, SCREEN_DESTROYED, 0, 0); + } + return result; + } + + int Screen::execute(DrawableSurface *surface, int stepLength) + { + beginExecution(surface); + drawExecution(); + while (isExecutionRunning()) + { + const Uint64 frameStart = SDL_GetTicks64(); + updateExecution(static_cast(frameStart)); + SDL_Event lastMouseMotion{}, windowEvent{}, event{}; + bool hadLastMouseMotion = false; + bool hadWindowEvent = false; + while (isExecutionRunning() && GraphicContext::pollEvent(&event)) + { + if (event.type == SDL_MOUSEMOTION) + { + lastMouseMotion = event; + hadLastMouseMotion = true; + } + else if (event.type == SDL_WINDOWEVENT) + { + windowEvent = event; + hadWindowEvent = true; } + else handleExecutionEvent(event); + } + if (hadLastMouseMotion) handleExecutionEvent(lastMouseMotion); + if (hadWindowEvent) handleExecutionEvent(windowEvent); + drawExecution(); + if (isExecutionRunning()) + { + const Sint64 elapsed = static_cast(SDL_GetTicks64() - frameStart); + ApplicationHost::wait(std::max(stepLength - elapsed, 0)); } - if (hadLastMouseMotion) - dispatchEvents(&lastMouseMotion); - if (wasWindowEvent) - dispatchEvents(&windowEvent); - - // draw - dispatchPaint(); - - // wait timer - frameWaitTime=static_cast(SDL_GetTicks64())-static_cast(frameStartTime); - frameWaitTime=stepLength-frameWaitTime; - if (frameWaitTime>0) - SDL_Delay(frameWaitTime); } - - // destroy screen event - onAction(NULL, SCREEN_DESTROYED, 0, 0); - - return returnCode; + return finishExecution(); } - + void Screen::endExecute(int returnCode) { run=false; @@ -737,14 +738,14 @@ namespace GAGGUI if (event.type == SDL_KEYDOWN) { # ifdef USE_OSX - if (event.key.keysym.sym == SDLK_q && SDL_GetModState() & KMOD_GUI) + if (event.key.keysym.sym == SDLK_q && (event.key.keysym.mod & KMOD_GUI)) { quitApplication = true; break; } # endif # ifdef USE_WIN32 - if (event.key.keysym.sym == SDLK_F4 && SDL_GetModState() & KMOD_ALT) + if (event.key.keysym.sym == SDLK_F4 && (event.key.keysym.mod & KMOD_ALT)) { quitApplication = true; break; @@ -771,7 +772,7 @@ namespace GAGGUI const Uint64 frameEnd = SDL_GetTicks64(); const Sint64 elapsed = static_cast(frameEnd) - static_cast(frameStart); - SDL_Delay(static_cast(std::max(40 - elapsed, 0))); + GAGCore::ApplicationHost::wait(static_cast(std::max(40 - elapsed, 0))); } delete background; diff --git a/libgag/src/GUIMessageBox.cpp b/libgag/src/GUIMessageBox.cpp index 98372f6e3..3f4682a11 100644 --- a/libgag/src/GUIMessageBox.cpp +++ b/libgag/src/GUIMessageBox.cpp @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Copyright (C) 2001-2004 Stephane Magnenat & Luc-Olivier de Charrière +#include #include #include #include @@ -132,7 +133,7 @@ namespace GAGGUI parentCtx->drawSurface(mbs->decX, mbs->decY, mbs->getSurface()); parentCtx->nextFrame(); Uint64 newTime = SDL_GetTicks64(); - SDL_Delay(std::max(40ll - static_cast(newTime) + static_cast(time), 0)); + GAGCore::ApplicationHost::wait(std::max(40ll - static_cast(newTime) + static_cast(time), 0)); } int retVal; diff --git a/libgag/src/GUITabScreen.cpp b/libgag/src/GUITabScreen.cpp index ca2fc21a0..e51dd62f0 100644 --- a/libgag/src/GUITabScreen.cpp +++ b/libgag/src/GUITabScreen.cpp @@ -131,6 +131,9 @@ namespace GAGGUI void TabScreen::removeGroup(int group_n) { + // A completed tab's widgets may already have been removed by onTimer + // before its owning session destroys the tab object. + if(groupButtons.find(group_n) == groupButtons.end()) return; for(std::vector::iterator j = groups[group_n].begin(); j!=groups[group_n].end(); ++j) { removeWidget(*j); diff --git a/libgag/src/GraphicContext.cpp b/libgag/src/GraphicContext.cpp index a598eca91..4538a6ba9 100644 --- a/libgag/src/GraphicContext.cpp +++ b/libgag/src/GraphicContext.cpp @@ -164,7 +164,9 @@ namespace GAGCore { minW = w; minH = h; + #ifndef GLOB2_WEBGL2 if (window) SDL_SetWindowMinimumSize(window, minW, minH); + #endif } VideoModes GraphicContext::listVideoModes() const @@ -327,7 +329,7 @@ namespace GAGCore { glMatrixMode(GL_PROJECTION); glLoadIdentity(); - gluOrtho2D(0, getW(), getH(), 0); + glOrtho(0, getW(), getH(), 0, -1, 1); glMatrixMode(GL_MODELVIEW); glLoadIdentity(); } @@ -391,8 +393,10 @@ namespace GAGCore _gc->windowToLogical(event->button.x, event->button.y); break; case SDL_WINDOWEVENT: + #ifndef GLOB2_WEBGL2 if (event->window.event == SDL_WINDOWEVENT_SIZE_CHANGED) _gc->updateWindowSize(); + #endif break; default: break; @@ -410,6 +414,36 @@ namespace GAGCore } } + bool GraphicContext::resizeViewport(int w, int h) + { + if (!window || !sdlsurface || w <= 0 || h <= 0) return false; + if (w == getW() && h == getH()) return true; + const auto& format = *sdlsurface->format; + SDL_Surface* replacement = SDL_CreateRGBSurface(0, w, h, 32, + format.Rmask, format.Gmask, format.Bmask, format.Amask); + if (!replacement) return false; + // SDL may invalidate its borrowed window surface when changing size. + freeOwnedSurface(); + SDL_SetWindowSize(window, w, h); + sdlsurface = replacement; + ownsSurface = true; + SDL_GetWindowSize(window, &windowW, &windowH); + drawableW = windowW; drawableH = windowH; +#ifdef HAVE_OPENGL + if (optionFlags & USEGPU) { + SDL_GL_GetDrawableSize(window, &drawableW, &drawableH); + glMatrixMode(GL_PROJECTION); + glLoadIdentity(); + glOrtho(0, w, h, 0, -1, 1); + glMatrixMode(GL_MODELVIEW); + glLoadIdentity(); + applyGLViewport(); + } +#endif + setClipRect(); + return true; + } + bool GraphicContext::setRes(int w, int h, Uint32 flags) { // check dimension @@ -439,6 +473,11 @@ namespace GAGCore { SDL_GL_SetAttribute( SDL_GL_DOUBLEBUFFER, 1 ); SDL_GL_SetAttribute(SDL_GL_DEPTH_SIZE, 24); +#ifdef GLOB2_WEBGL2 + SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_ES); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 0); +#endif sdlFlags |= SDL_WINDOW_OPENGL | SDL_WINDOW_ALLOW_HIGHDPI; } #else @@ -470,7 +509,9 @@ namespace GAGCore SDL_GetWindowSize(window, &windowW, &windowH); drawableW = windowW; drawableH = windowH; - SDL_SetWindowMinimumSize(window, std::max(1, minW), std::max(1, minH)); + #ifndef GLOB2_WEBGL2 + SDL_SetWindowMinimumSize(window, std::max(1, minW), std::max(1, minH)); + #endif // Own the drawing surface: SDL invalidates its window surface during resizing. sdlsurface = SDL_CreateRGBSurface(0, w, h, 32, 0x00ff0000, 0x0000ff00, 0x000000ff, 0xff000000); @@ -576,7 +617,7 @@ namespace GAGCore { glMatrixMode(GL_PROJECTION); glLoadIdentity(); - gluOrtho2D(0, w, h, 0); + glOrtho(0, w, h, 0, -1, 1); glMatrixMode(GL_MODELVIEW); glLoadIdentity(); glGetIntegerv(GL_MAX_TEXTURE_SIZE, &frameCache.maximumTextureSize); diff --git a/libgag/src/GraphicContextCompound.cpp b/libgag/src/GraphicContextCompound.cpp index 6861d2077..19eaf1293 100644 --- a/libgag/src/GraphicContextCompound.cpp +++ b/libgag/src/GraphicContextCompound.cpp @@ -82,6 +82,7 @@ namespace GAGCore const float v1 = static_cast(sy + sh) * surface->texMultY + biasY; // draw + if (!surface->textureInfo) glState.setTexture(surface->texture); if (surface->textureInfo && surface->textureInfo->sprite) { Sprite* sprite = surface->textureInfo->sprite; @@ -164,13 +165,20 @@ namespace GAGCore glEnableClientState(GL_TEXTURE_COORD_ARRAY); glColor4ub(255, 255, 255, alpha); glState.setTexture(sprite->atlas->texture); +#ifdef GLOB2_WEBGL2 + // The compatibility renderer packs client arrays into its own GPU buffer. + glVertexPointer(2, GL_FLOAT, 0, sprite->vertices.data()); + glTexCoordPointer(2, GL_FLOAT, 0, sprite->texCoords.data()); +#else glBindBuffer(GL_ARRAY_BUFFER, sprite->vbo); glBufferData(GL_ARRAY_BUFFER, sprite->vertices.size() * sizeof(float), sprite->vertices.data(), GL_STREAM_DRAW); glVertexPointer(2, GL_FLOAT, 0, 0); glBindBuffer(GL_ARRAY_BUFFER, sprite->texCoordBuffer); glBufferData(GL_ARRAY_BUFFER, sprite->texCoords.size() * sizeof(float), sprite->texCoords.data(), GL_STREAM_DRAW); glTexCoordPointer(2, GL_FLOAT, 0, 0); - ++drawCalls;glDrawArrays(GL_QUADS, 0, sprite->vertices.size() / 2); +#endif + ++drawCalls; + glDrawArrays(GL_QUADS, 0, sprite->vertices.size() / 2); sprite->vertices.clear(); sprite->texCoords.clear(); diff --git a/libgag/src/GraphicContextDraw.cpp b/libgag/src/GraphicContextDraw.cpp index f22d698b1..15f550a4f 100644 --- a/libgag/src/GraphicContextDraw.cpp +++ b/libgag/src/GraphicContextDraw.cpp @@ -153,11 +153,12 @@ namespace GAGCore setScaledLineWidth(1.0f); // draw - ++drawCalls;glBegin(GL_LINES); + ++drawCalls; if (color.a < 255) glColor4ub(color.r, color.g, color.b, color.a); else glColor3ub(color.r, color.g, color.b); + glBegin(GL_LINES); glVertex2f(x, y); glVertex2f(x+w, y); glVertex2f(x+w, y); glVertex2f(x+w, y+h); glVertex2f(x+w, y+h); glVertex2f(x, y+h); @@ -193,11 +194,12 @@ namespace GAGCore glState.doTexture(false); // draw - ++drawCalls;glBegin(GL_QUADS); + ++drawCalls; if (color.a < 255) glColor4ub(color.r, color.g, color.b, color.a); else glColor3ub(color.r, color.g, color.b); + glBegin(GL_QUADS); glVertex2f(x, y); glVertex2f(x+w, y); glVertex2f(x+w, y+h); @@ -248,7 +250,7 @@ namespace GAGCore glLineWidth(1.0f); // draw - ++drawCalls;glBegin(GL_LINES); + ++drawCalls; if (color.a < 255) { // the passes overlap, so each carries the alpha that composes back to the requested one @@ -257,6 +259,7 @@ namespace GAGCore } else glColor3ub(color.r, color.g, color.b); + glBegin(GL_LINES); for (int i = 0; i < passes; ++i) { // offsets in window pixels from -(scale-1)/2 to +(scale-1)/2, mapped back to logical units @@ -296,17 +299,18 @@ namespace GAGCore double fy = y; double fray = radius; - ++drawCalls;glBegin(GL_LINES); + ++drawCalls; if (color.a < 255) glColor4ub(color.r, color.g, color.b, color.a); else glColor3ub(color.r, color.g, color.b); + glBegin(GL_LINES); for (int i=0; i #ifdef HAVE_CONFIG_H -#include +#include #endif #ifdef HAVE_OPENGL -#if defined(__APPLE__) +#if defined(GLOB2_WEBGL2) +#define GL_GLEXT_PROTOTYPES +#include +#include +#elif defined(__APPLE__) #include #include #include @@ -29,7 +33,7 @@ #endif // defined(__APPLE__) #endif // HAVE_OPENGL -#ifdef HAVE_OPENGL +#if defined(HAVE_OPENGL) && !defined(GLOB2_WEBGL2) #define GL_GLEXT_PROTOTYPES #if defined(__APPLE__) || defined(OPENGL_HEADER_DIRECTORY_OPENGL) #include diff --git a/libgag/src/GraphicContextResize.cpp b/libgag/src/GraphicContextResize.cpp index c9681a0dc..ea1d92c85 100644 --- a/libgag/src/GraphicContextResize.cpp +++ b/libgag/src/GraphicContextResize.cpp @@ -62,7 +62,12 @@ namespace GAGCore void GraphicContext::cacheFrame() { - #ifdef HAVE_OPENGL + #ifdef GLOB2_WEBGL2 + // Browser frames are driven continuously by the application loop, so the + // native exposed-window cache is unnecessary for the WebGL renderer. + if (optionFlags & USEGPU) return; + #endif + #if defined(HAVE_OPENGL) && !defined(GLOB2_WEBGL2) if (optionFlags & USEGPU) { int w, h; @@ -149,7 +154,7 @@ namespace GAGCore { if (!frameCache.valid || presenting || (SDL_GetWindowFlags(window) & SDL_WINDOW_MINIMIZED)) return; FlagScope scope(presenting); - #ifdef HAVE_OPENGL + #if defined(HAVE_OPENGL) && !defined(GLOB2_WEBGL2) if (optionFlags & USEGPU) { int w, h; diff --git a/libgag/src/SConscript b/libgag/src/SConscript index 67cfb1344..1e11cdda8 100644 --- a/libgag/src/SConscript +++ b/libgag/src/SConscript @@ -1,23 +1,8 @@ -libgag_sources = Split(""" -BinaryStream.cpp CursorManager.cpp FileManager.cpp FileManagerAtomic.cpp FormatableString.cpp -GraphicContext.cpp GraphicContextResize.cpp GraphicContextDraw.cpp GraphicContextCompound.cpp -DrawableSurface.cpp DrawableSurfaceDraw.cpp DrawableSurfaceCompound.cpp -GUIDropdown.cpp GUIAnimation.cpp GUIBase.cpp GUIButton.cpp -GUIFileList.cpp GUIKeySelector.cpp GUIList.cpp GUIMessageBox.cpp -GUINumber.cpp GUIRatio.cpp GUISelector.cpp GUIStyle.cpp -GUITextArea.cpp GUITextAreaLayout.cpp GUITextAreaInput.cpp -GUIText.cpp GUITextInput.cpp GUIImage.cpp -GUIProgressBar.cpp KeyPress.cpp Sprite.cpp StreamBackend.cpp -Stream.cpp StringTable.cpp SupportFunctions.cpp -TextStream.cpp Toolkit.cpp TrueTypeFont.cpp win32_dirent.cpp -GUITabScreen.cpp GUITabScreenWindow.cpp TextSort.cpp GUICheckList.cpp -""") +from sources import GAG_SOURCES +libgag_sources = list(GAG_SOURCES) -libgag_just_server = Split(""" - -BinaryStream.cpp Stream.cpp FileManager.cpp FileManagerAtomic.cpp FormatableString.cpp -TextStream.cpp StreamBackend.cpp StringTable.cpp Toolkit.cpp -""") +from sources import GAG_SERVER_SOURCES +libgag_just_server = list(GAG_SERVER_SOURCES) Import("env") @@ -36,6 +21,9 @@ if not env['server']: resize_test = aspect_env.Program('WindowResizeHarness', [aspect_env.Object('WindowResizeHarness.o', '#test/WindowResizeHarness.cpp'), l1]) env.Alias('resize-test', resize_test) + screen_test = env.Program('ScreenExecutionHarness', + [env.Object('ScreenExecutionHarness.o', '#test/ScreenExecutionHarness.cpp'), l1]) + env.Alias('screen-test', screen_test) else: Default(l2) diff --git a/libgag/src/ScreenStack.cpp b/libgag/src/ScreenStack.cpp new file mode 100644 index 000000000..c9b26d316 --- /dev/null +++ b/libgag/src/ScreenStack.cpp @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include +#include +#include +#include +#include + +namespace GAGGUI +{ +ScreenStack::~ScreenStack() +{ + stop(); + boundary(); +} + +void ScreenStack::push(std::unique_ptr screen, Completion completed) +{ + if (!screen) throw std::invalid_argument("Cannot push a null screen"); + if (stopped) throw std::logic_error("Cannot push onto a stopped screen stack"); + pending.push_back({std::move(completed), std::move(screen), + screens.empty() ? nullptr : screens.back().screen.get()}); +} + +void ScreenStack::suspendExecution() +{ + for (auto& entry : screens) entry.screen->suspendExecution(); + for (auto& entry : pending) entry.screen->suspendExecution(); +} + +void ScreenStack::viewportResized(int oldWidth, int oldHeight, int width, int height) +{ + for (auto& entry : screens) entry.screen->viewportResized(oldWidth, oldHeight, width, height); + for (auto& entry : pending) entry.screen->viewportResized(oldWidth, oldHeight, width, height); +} + +void ScreenStack::stop() +{ + stopped = true; + // Destruction is deferred until outside screen callbacks. +} + +void ScreenStack::boundary() +{ + if (!screens.empty() && !screens.back().screen->isExecutionRunning()) { + Entry completed = std::move(screens.back()); + screens.pop_back(); + // A parent that completes before its queued child is admitted cancels + // that child, including continuations capturing the parent. + std::erase_if(pending, [&](const Entry& entry) { return entry.owner == completed.screen.get(); }); + lastResult = completed.screen->finishExecution(); + if (lastResult == Screen::QUIT_APPLICATION) stopped = true; + if (!stopped && completed.completed) completed.completed(*completed.screen, lastResult); + if (!screens.empty()) { + Screen& resumed = *screens.back().screen; + GAGCore::ApplicationHost::screenChanged(typeid(resumed).name()); + } + } + if (stopped) { + pending.clear(); + while (!screens.empty()) { + screens.back().screen->endExecute(Screen::QUIT_APPLICATION); + screens.back().screen->finishExecution(); + screens.pop_back(); + } + lastResult = Screen::QUIT_APPLICATION; + return; + } + // Creation callbacks can queue another child, but cannot recurse into it. + auto additions = std::move(pending); + pending.clear(); + for (auto& entry : additions) { + screens.push_back(std::move(entry)); + screens.back().screen->beginExecution(&surface); + } +} + +void ScreenStack::frame(Uint32 tick, const std::vector& events) +{ + if (dispatching) throw std::logic_error("Screen stack frames cannot recurse"); + struct Guard { bool& flag; Guard(bool& f): flag(f) { flag = true; } ~Guard() { flag = false; } } guard(dispatching); + if (std::any_of(events.begin(), events.end(), [](const SDL_Event& e) { return e.type == SDL_QUIT; })) stop(); + boundary(); + if (screens.empty() || stopped) return; + Screen& screen = *screens.back().screen; + // Pending child transitions suspend the parent immediately. + if (pending.empty()) screen.updateExecution(tick); + for (const auto& event : events) { + if (event.type == SDL_QUIT) { stop(); break; } + if (stopped || !pending.empty() || !screen.isExecutionRunning()) break; + screen.handleExecutionEvent(event); + } + if (!stopped) screen.drawExecution(); + if (stopped) boundary(); +} + +Uint32 ScreenStack::delay(Uint32 now, Uint32 fallback) +{ + return screens.empty() ? fallback : screens.back().screen->executionDelay(now, fallback); +} + +int ScreenStack::execute(unsigned stepLength) +{ + while (running()) { + const Uint64 start = SDL_GetTicks64(); + std::vector events; + SDL_Event event; + while (SDL_PollEvent(&event)) events.push_back(event); + frame(static_cast(start), events); + if (running()) { + const Uint64 elapsed = SDL_GetTicks64() - start; + const Uint32 fallback = elapsed < stepLength ? stepLength - elapsed : 0; + GAGCore::ApplicationHost::wait(delay(static_cast(SDL_GetTicks64()), fallback)); + } + } + return result(); +} +} diff --git a/libgag/src/Sprite.cpp b/libgag/src/Sprite.cpp index 8713ae563..e94dbcfe2 100644 --- a/libgag/src/Sprite.cpp +++ b/libgag/src/Sprite.cpp @@ -31,7 +31,10 @@ using boost::make_unique; #define GL_GLEXT_PROTOTYPES #ifdef HAVE_OPENGL -#if defined(__APPLE__) || defined(OPENGL_HEADER_DIRECTORY_OPENGL) +#if defined(GLOB2_WEBGL2) +#include +#include +#elif defined(__APPLE__) || defined(OPENGL_HEADER_DIRECTORY_OPENGL) #include #include #include @@ -223,8 +226,10 @@ namespace GAGCore } atlas->uploadToTexture(); this->atlas = std::move(atlas); +#ifndef GLOB2_WEBGL2 glGenBuffers(1, &vbo); glGenBuffers(1, &texCoordBuffer); +#endif return true; // Success #else return false; diff --git a/libusl/src/SConscript b/libusl/src/SConscript index 59dc46f17..e6853ff5c 100644 --- a/libusl/src/SConscript +++ b/libusl/src/SConscript @@ -1,8 +1,5 @@ -usl_sources = Split(""" -code.cpp debug.cpp interpreter.cpp lexer.cpp -parser.cpp position.cpp token.cpp -tree.cpp types.cpp usl.cpp -""") +from sources import USL_SOURCES +usl_sources = list(USL_SOURCES) Import("env") Import("PackTar") diff --git a/libusl/src/code.cpp b/libusl/src/code.cpp index d48218b26..96adb97f9 100644 --- a/libusl/src/code.cpp +++ b/libusl/src/code.cpp @@ -231,6 +231,11 @@ void NativeCode::dumpSpecific(std::ostream &stream) const } +void ConstCode::markForGC() { if (value) value->markForGC(); } + +template +void CreateCode::markForGC() { if (prototype) prototype->markForGC(); } + template CreateCode::CreateCode(typename ThunkType::Prototype* prototype): prototype(prototype) @@ -263,4 +268,3 @@ void CreateCode::dumpSpecific(std::ostream &stream) const template struct CreateCode; template struct CreateCode; template struct CreateCode; - diff --git a/libusl/src/code.h b/libusl/src/code.h index 4d0a4ed0e..2cda7a6fe 100644 --- a/libusl/src/code.h +++ b/libusl/src/code.h @@ -16,6 +16,7 @@ struct Code { virtual ~Code() { } virtual void execute(Thread* thread) = 0; + virtual void markForGC() {} void dump(std::ostream &stream) const; virtual void dumpSpecific(std::ostream &stream) const {}; }; @@ -25,6 +26,7 @@ struct ConstCode: Code ConstCode(Value* value); virtual void execute(Thread* thread); + void markForGC() override; virtual void dumpSpecific(std::ostream &stream) const; Value* value; @@ -111,8 +113,8 @@ struct CreateCode: Code CreateCode(typename ThunkType::Prototype* prototype); virtual void execute(Thread* thread); + void markForGC() override; virtual void dumpSpecific(std::ostream &stream) const; typename ThunkType::Prototype* prototype; }; - diff --git a/libusl/src/interpreter.cpp b/libusl/src/interpreter.cpp index 65c06735d..db540a593 100644 --- a/libusl/src/interpreter.cpp +++ b/libusl/src/interpreter.cpp @@ -79,13 +79,13 @@ size_t Thread::run() return steps; } -void Thread::markForGC() +void Thread::markForGC() const { // mark all frames in stack for_each(frames.begin(), frames.end(), [](auto& frame) {frame.markForGC(); }); } -void Thread::Frame::markForGC() +void Thread::Frame::markForGC() const { // mark all variables in frame for_each(stack.begin(), stack.end(), [](auto& value) {value->markForGC(); }); diff --git a/libusl/src/interpreter.h b/libusl/src/interpreter.h index 2f75b1092..989b6caea 100644 --- a/libusl/src/interpreter.h +++ b/libusl/src/interpreter.h @@ -24,7 +24,7 @@ struct Thread nextInstr = 0; } - void markForGC(); + void markForGC() const; }; enum State { @@ -49,6 +49,5 @@ struct Thread size_t run(size_t steps); bool step(); - void markForGC(); + void markForGC() const; }; - diff --git a/libusl/src/native.h b/libusl/src/native.h index bb1bc5a1f..34f9f3155 100644 --- a/libusl/src/native.h +++ b/libusl/src/native.h @@ -28,8 +28,8 @@ struct NativeFunction: NativeCode template struct NativeValuePrototype: Prototype { - NativeValuePrototype(): - Prototype(nullptr) // heap is set later by NativeValue ctor; can't use the inherited member here, it's not constructed yet + NativeValuePrototype(Heap* heap): + Prototype(heap) { } @@ -51,14 +51,21 @@ struct NativeValuePrototype: Prototype template struct NativeValue: Value { - static NativeValuePrototype prototype; + static NativeValuePrototype* prototypeFor(Heap* heap) { + assert(heap); + const auto key = std::type_index(typeid(This)); + auto found = heap->nativePrototypes.find(key); + if (found != heap->nativePrototypes.end()) return static_cast*>(found->second); + auto* prototype = new NativeValuePrototype(heap); + heap->nativePrototypes.emplace(key, prototype); + prototype->initialize(); + return prototype; + } NativeValue(Heap* heap, const This& value): - Value(heap, &prototype), + Value(heap, prototypeFor(heap)), value(value) { - prototype.heap = heap; - prototype.initialize(); } const This value; @@ -70,7 +77,6 @@ struct NativeValue: Value stream << "= " << value; } }; -template NativeValuePrototype NativeValue::prototype; template @@ -313,4 +319,3 @@ inline void NativeValuePrototype::initialize() addMethod("=" , boost::lambda::_1 == boost::lambda::_2); addMethod("!=", boost::lambda::_1 != boost::lambda::_2); } - diff --git a/libusl/src/types.cpp b/libusl/src/types.cpp index 65517b3d9..b3feb8c86 100644 --- a/libusl/src/types.cpp +++ b/libusl/src/types.cpp @@ -10,10 +10,31 @@ #include +Heap::~Heap() +{ + for (auto* value : values) delete value; +} + +void Value::markForGC() +{ + if (marked) return; + marked = true; + if (prototype) prototype->markForGC(); + propagateMarkForGC(); +} + +void ThunkPrototype::propagateMarkForGC() +{ + if (outer) outer->markForGC(); + Prototype::propagateMarkForGC(); + for (auto* instruction : body) instruction->markForGC(); +} + void Heap::collectGarbage() { using std::for_each; using std::mem_fn; + for (const auto& entry : nativePrototypes) entry.second->markForGC(); // filter copy, delete unrefs Values marked; @@ -104,4 +125,3 @@ MetaPrototype::MetaPrototype(Heap* heap, Prototype* prototype, Value* outer): Function::Function(Heap* heap, Prototype* prototype, Value* outer): MetaPrototype(heap, prototype, outer) {} - diff --git a/libusl/src/types.h b/libusl/src/types.h index c15109759..0dc391cb4 100644 --- a/libusl/src/types.h +++ b/libusl/src/types.h @@ -8,14 +8,19 @@ #include #include #include +#include struct Value; +struct Prototype; struct Heap { typedef std::vector Values; Values values; + // Native method tables belong to this interpreter, never to another heap. + std::map nativePrototypes; + ~Heap(); void collectGarbage(); }; @@ -44,14 +49,7 @@ struct Value virtual void propagateMarkForGC() { } - void markForGC() - { - if (!marked) - { - marked = true; - propagateMarkForGC(); - } - } + void markForGC(); void clearGCMark() { marked = false; } }; @@ -81,9 +79,7 @@ struct Prototype: Value transform(members.begin(), members.end(), ostream_iterator(stream, " "), [](auto& member) {return member.first; }); } - // Defined out-of-line below ThunkPrototype: the lambda dynamic_cast's a - // ThunkPrototype* (Members::value_type::second_type), which C++17+ requires - // to be a complete type at the point the body is parsed. + // Defined below ThunkPrototype so its member pointers are complete types. virtual void propagateMarkForGC(); virtual ThunkPrototype* lookup(const std::string& name) const @@ -112,18 +108,13 @@ struct ThunkPrototype: Prototype stream << body.size() << " codes"; } - virtual void propagateMarkForGC() - { - if (outer != 0) - outer->markForGC(); - Prototype::propagateMarkForGC(); - } + void propagateMarkForGC() override; }; inline void Prototype::propagateMarkForGC() { using std::for_each; - for_each(members.begin(), members.end(), [](auto& member) {dynamic_cast(member.second)->markForGC(); }); + for_each(members.begin(), members.end(), [](auto& member) { if (member.second) member.second->markForGC(); }); } struct Thunk: Value @@ -140,6 +131,7 @@ struct Thunk: Value { return static_cast(prototype); } + void propagateMarkForGC() override { if (outer) outer->markForGC(); } }; struct ScopePrototype: ThunkPrototype @@ -174,9 +166,8 @@ struct Scope: Thunk virtual void propagateMarkForGC() { - using std::for_each; - using std::mem_fn; - for_each(locals.begin(), locals.end(), mem_fn(&Value::markForGC)); + Thunk::propagateMarkForGC(); + for (auto* local : locals) if (local) local->markForGC(); } ScopePrototype* scopePrototype() const @@ -193,6 +184,10 @@ struct MetaPrototype: Value Prototype* prototype; // this is the prototype of the target, not of this meta object Value* outer; + void propagateMarkForGC() override { + if (prototype) prototype->markForGC(); + if (outer) outer->markForGC(); + } }; struct Function: MetaPrototype @@ -201,4 +196,3 @@ struct Function: MetaPrototype Function(Heap* heap, Prototype* prototype, Value* outer); }; - diff --git a/libusl/src/usl.cpp b/libusl/src/usl.cpp index 96f6b2032..e2b82cab3 100644 --- a/libusl/src/usl.cpp +++ b/libusl/src/usl.cpp @@ -95,19 +95,21 @@ Usl::Usl() } } -Usl::~Usl() { - collectGarbage(); -} +Usl::~Usl() = default; void Usl::markGarbage() const { root->markForGC(); -// for_each(threads.begin(), threads.end(), mem_fun_ref(&Thread::markForGC)); + for (const auto& thread : threads) thread.markForGC(); } void Usl::collectGarbage() { // mark + // These two objects are uniquely owned, outside heap.values. The sweep + // cannot reset their marks, so reset them before each root traversal. + root->clearGCMark(); + prototype->clearGCMark(); markGarbage(); // sweep diff --git a/scons/build_layout.py b/scons/build_layout.py new file mode 100644 index 000000000..b9cec6d5c --- /dev/null +++ b/scons/build_layout.py @@ -0,0 +1,96 @@ +"""Build identities: no host probes and no persistent global option state.""" +from pathlib import Path +import json +import os +import platform +import tempfile + + +def enabled(value): + return str(value).lower() in ('1', 'true', 'yes', 'on') + + +def build_identity(arguments, host=None): + target = arguments.get('target', 'native') + if target not in ('native', 'web'): + raise ValueError('target must be native or web') + role = arguments.get('role', 'server' if enabled(arguments.get('server', 0)) else 'client') + if role not in ('client', 'server', 'router', 'gateway'): + raise ValueError('role must be client, server, router, or gateway') + if target == 'web' and (role != 'client' or enabled(arguments.get('mingw', 0)) or enabled(arguments.get('mingwcross', 0))): + raise ValueError('web supports only the client role and cannot use a native cross compiler') + toolchain = 'mingwcross' if enabled(arguments.get('mingwcross', 0)) else ('mingw' if enabled(arguments.get('mingw', 0)) else (host or platform.system().lower())) + if target == 'web': + toolchain = 'emscripten' + mode = 'profile' if enabled(arguments.get('profile', 0)) else ('release' if enabled(arguments.get('release', 0)) else 'debug') + native_wss = target == 'native' and role == 'client' and enabled(arguments.get('wss', 1)) + return {'target': target, 'role': role, 'toolchain': toolchain, 'mode': mode, + 'native_wss': native_wss} + + +def default_directory(identity): + role = identity['role'] + if role == 'client' and identity['target'] == 'native' and not identity['native_wss']: + role += '-tcp' + return Path('build') / identity['toolchain'] / role / identity['mode'] + + +def write_if_changed(path, content): + path = Path(path) + if path.exists() and path.read_text() == content: + return + path.parent.mkdir(parents=True, exist_ok=True) + fd, temporary = tempfile.mkstemp(dir=path.parent, prefix=path.name + '.') + try: + with os.fdopen(fd, 'w') as output: + output.write(content) + os.replace(temporary, path) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + + +def prepare_directory(path, identity): + path = Path(path) + path.mkdir(parents=True, exist_ok=True) + marker = path / 'identity.json' + content = json.dumps(identity, sort_keys=True, indent=2) + '\n' + # Exclusive creation prevents incompatible simultaneous configurations sharing outputs. + try: + with marker.open('x') as output: + output.write(content) + except FileExistsError: + if marker.read_text() != content: + raise ValueError(f'{path} belongs to another build configuration; choose a different --build directory') + return path + + +class BuildLock: + """Serialize writers of one identity while allowing other identities to build.""" + def __init__(self, directory): + self.file = (Path(directory) / '.build-lock').open('a+b') + try: + if os.name == 'nt': + import msvcrt + self.file.seek(0) + if not self.file.read(1): + self.file.write(b'0') + self.file.flush() + self.file.seek(0) + msvcrt.locking(self.file.fileno(), msvcrt.LK_NBLCK, 1) + else: + import fcntl + fcntl.flock(self.file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + self.file.close() + raise ValueError(f'Another build is using {directory}; wait or choose a different --build directory') from None + + def close(self): + if not self.file.closed: + self.file.close() + + def __enter__(self): + return self + + def __exit__(self, *args): + self.close() diff --git a/scons/gateway_build.py b/scons/gateway_build.py new file mode 100644 index 000000000..0cdb6ba35 --- /dev/null +++ b/scons/gateway_build.py @@ -0,0 +1,34 @@ +from pathlib import Path +import os +import sys +import subprocess +from SCons.Script import Environment, Default, Tool + + +def build_gateway(directory, identity, arguments): + env=Environment(ENV=dict(os.environ)) + temporary = str((Path(directory) / 'tmp').resolve()) + env['ENV'].update(TMPDIR=temporary, TMP=temporary, TEMP=temporary) + if identity['toolchain'] in ('mingw', 'mingwcross'): + Tool('mingw')(env) + if identity['toolchain'] == 'mingwcross': + env.Replace(CC='x86_64-w64-mingw32-gcc', CXX='x86_64-w64-mingw32-g++', + LINK='x86_64-w64-mingw32-g++') + env.Append(CXXFLAGS=['-std=c++20','-Wall','-Wextra','-O2' if identity['mode']=='release' else '-g']) + if sys.platform=='darwin' and identity['toolchain'] == 'darwin': + try: + prefix=subprocess.check_output(['brew','--prefix'],text=True).strip() + env.Append(CPPPATH=[prefix+'/include']) + except (FileNotFoundError, subprocess.CalledProcessError): + pass + env.Append(CPPDEFINES=['BOOST_ERROR_CODE_HEADER_ONLY']) + if sys.platform=='win32' or identity['toolchain'] in ('mingw', 'mingwcross'): + env.Append(LIBS=['ws2_32','mswsock']) + else: + env.Append(LIBS=['pthread']) + env.Tool('compilation_db') + obj=env.Object(directory+'/obj/Gateway.o','src/net/gateway/Gateway.cpp') + program=env.Program(directory+'/glob2-ws-gateway',obj) + database=env.CompilationDatabase(directory+'/compile_commands.json') + env.Alias('compile_commands.json',database) + Default(program,database) diff --git a/scons/sources.py b/scons/sources.py new file mode 100644 index 000000000..84d10c1ab --- /dev/null +++ b/scons/sources.py @@ -0,0 +1,560 @@ +"""Source manifests shared by every Glob2 toolchain. Paths are relative to each library.""" + +CLIENT_SOURCES = ( + 'Application.cpp', + 'MapEditorScreen.cpp', + 'MessageScreen.cpp', + 'ai/castor/Control.cpp', + 'ai/castor/GetOrder.cpp', + 'ai/castor/Lifecycle.cpp', + 'ai/castor/Maps.cpp', + 'ai/castor/Placement.cpp', + 'ai/castor/Projects.cpp', + 'ai/castor/State.cpp', + 'ai/AI.cpp', + 'ai/AIDescriptionScreen.cpp', + 'ai/echo/BuildingOrder.cpp', + 'ai/echo/BuildingRegister.cpp', + 'ai/echo/Conditions.cpp', + 'ai/echo/ConditionsBuilding.cpp', + 'ai/echo/ConditionsPopulation.cpp', + 'ai/echo/ConditionsTracker.cpp', + 'ai/echo/Construction.cpp', + 'ai/echo/ConstructionConstraints.cpp', + 'ai/echo/Echo.cpp', + 'ai/echo/EchoSerialization.cpp', + 'ai/echo/Entities.cpp', + 'ai/echo/EntitiesBuilding.cpp', + 'ai/echo/EntitiesResource.cpp', + 'ai/echo/EntitiesTerrain.cpp', + 'ai/echo/Gradient.cpp', + 'ai/echo/GradientBFS.cpp', + 'ai/echo/Management.cpp', + 'ai/echo/ManagementFlag.cpp', + 'ai/echo/ManagementMisc.cpp', + 'ai/echo/ManagementOrderBase.cpp', + 'ai/echo/ManagementTracker.cpp', + 'ai/echo/MapInfo.cpp', + 'ai/echo/Econo.cpp', + 'ai/echo/EconoBuilding.cpp', + 'ai/echo/EconoFlags.cpp', + 'ai/echo/SearchTools.cpp', + 'ai/AINames.cpp', + 'ai/nicowar/Attack.cpp', + 'ai/nicowar/Buildings.cpp', + 'ai/nicowar/Farming.cpp', + 'ai/nicowar/Flags.cpp', + 'ai/nicowar/Lifecycle.cpp', + 'ai/nicowar/Phases.cpp', + 'ai/nicowar/Strategy.cpp', + 'ai/nicowar/Upgrade.cpp', + 'ai/AINull.cpp', + 'ai/AINumbi.cpp', + 'ai/AINumbiEconomy.cpp', + 'ai/AINumbiMilitary.cpp', + 'ai/AINumbiPlacement.cpp', + 'ai/AIWarrush.cpp', + 'ai/cortex/CortexObservation.cpp', + 'ai/cortex/CortexObservationObserve.cpp', + 'ai/cortex/CortexPlacement.cpp', + 'ai/cortex/CortexPlacementCandidates.cpp', + 'ai/cortex/CortexPlacementGeo.cpp', + 'ai/cortex/CortexPolicy.cpp', + 'ai/cortex/CortexPolicyEconomy.cpp', + 'ai/cortex/CortexPolicyTech.cpp', + 'ai/cortex/CortexPolicyCombat.cpp', + 'ai/cortex/CortexTuning.cpp', + 'ai/cortex/CortexWheat.cpp', + 'ai/cortex/CortexWater.cpp', + 'ai/cortex/CortexNet.cpp', + 'ai/cortex/AICortex.cpp', + 'ai/cortex/AICortexFlags.cpp', + 'ai/cortex/AICortexTranslate.cpp', + 'ai/cortex/AICortexDebug.cpp', + 'BasePlayer.cpp', + 'BaseTeam.cpp', + 'BitArray.cpp', + 'Brush.cpp', + 'building/Lifecycle.cpp', + 'building/Construction.cpp', + 'building/Update.cpp', + 'building/Step.cpp', + 'building/TypeSteps.cpp', + 'building/Misc.cpp', + 'game/entities/Buildings.cpp', + 'game/entities/BuildingTypesColony.cpp', + 'game/entities/BuildingTypesDefence.cpp', + 'game/entities/BuildingTypesFlags.cpp', + 'game/entities/BuildingTypesUpgrade.cpp', + 'ChecksumSidecar.cpp', + 'DatasetWriter.cpp', + 'building/BuildingUtils.cpp', + 'Bullet.cpp', + 'Campaign.cpp', + 'CampaignEditor.cpp', + 'CampaignMainMenu.cpp', + 'CampaignMenuScreen.cpp', + 'CampaignSelectorScreen.cpp', + 'ChooseMapScreen.cpp', + 'CreditScreen.cpp', + 'CustomGameOtherOptions.cpp', + 'CustomGameScreen.cpp', + 'DynamicClouds.cpp', + 'EditorMainMenu.cpp', + 'EditorLoadScreen.cpp', + 'EditorGenerateScreen.cpp', + 'EndGameScreen.cpp', + 'Engine.cpp', + 'GameSessionScreen.cpp', + 'GameLoadScreen.cpp', + 'FileImport.cpp', + 'SinglePlayerFlow.cpp', + 'EngineInit.cpp', + 'EngineLoaders.cpp', + 'EngineRun.cpp', + 'FertilityCalculator.cpp', + 'FertilityScreen.cpp', + 'Game.cpp', + 'Game_orders.cpp', + 'Game_io.cpp', + 'Game_sync.cpp', + 'Game_editor.cpp', + 'render/GameRender.cpp', + 'render/GameRenderUnits.cpp', + 'render/GameRenderBuildings.cpp', + 'render/GameRenderTerrain.cpp', + 'render/GameRenderOverlay.cpp', + 'render/GameAnimations.cpp', + 'GameEvent.cpp', + 'gui/BuildingGuiState.cpp', + 'gui/GameGUI.cpp', + 'gui/GameGUIDefaultAssignManager.cpp', + 'gui/GameGUIDialog.cpp', + 'gui/GameGUIDraw.cpp', + 'gui/GameGUIDrawChoice.cpp', + 'gui/GameGUIDrawUnitInfos.cpp', + 'gui/GameGUIDrawBuildingInfos.cpp', + 'gui/GameGUIDrawBuildingHelpers.cpp', + 'gui/GameGUIDrawMiscPanels.cpp', + 'gui/GameGUIGhostBuildingManager.cpp', + 'gui/GameGUIInput.cpp', + 'gui/GameGUIInputKey.cpp', + 'gui/GameGUIInputMenu.cpp', + 'gui/GameGUIInputMenuClick.cpp', + 'gui/GameGUIInputMenuClickBuilding.cpp', + 'gui/GameGUIInputMouse.cpp', + 'gui/GameGUIKeyActions.cpp', + 'gui/GameGUILoadSave.cpp', + 'gui/GameGUIMessageManager.cpp', + 'gui/GameGUIOrders.cpp', + 'gui/GameMusicController.cpp', + 'gui/GameGUIParticles.cpp', + 'gui/GameGUIPersistence.cpp', + 'gui/GameGUIScript.cpp', + 'gui/GameGUISelection.cpp', + 'gui/GameGUIStep.cpp', + 'gui/GameGUIToolManager.cpp', + 'gui/TeamDisplay.cpp', + 'gui/UnitDisplayNames.cpp', + 'GameHeader.cpp', + 'GameHints.cpp', + 'GameObjectives.cpp', + 'GameUtilities.cpp', + 'Glob2.cpp', + 'Glob2Screen.cpp', + 'Glob2Style.cpp', + 'FrontendTheme.cpp', + 'MenuColony.cpp', + 'GlobalContainer.cpp', + 'GlobalContainerArgs.cpp', + 'GUIGlob2FileList.cpp', + 'GUIMapPreview.cpp', + 'map/generator/HeightMapGenerator.cpp', + 'building/IntBuildingType.cpp', + 'net/irc/IRC.cpp', + 'net/irc/IRCTextMessageHandler.cpp', + 'net/irc/IRCThread.cpp', + 'net/irc/IRCThreadMessage.cpp', + 'KeyboardManager.cpp', + 'LANFindScreen.cpp', + 'LANGameInformation.cpp', + 'LANMenuScreen.cpp', + 'LANSessionScreen.cpp', + 'MainMenuScreen.cpp', + 'map/Map.cpp', + 'map/gradient/MapGradientArea.cpp', + 'map/gradient/MapGradientBuilding.cpp', + 'map/gradient/MapGradientGlobal.cpp', + 'map/gradient/MapGradientField.cpp', + 'map/io/MapExploredAreaIO.cpp', + 'map/io/MapIO.cpp', + 'map/MapMisc.cpp', + 'map/pathfind/MapPathfindArea.cpp', + 'map/pathfind/MapPathfindBuilding.cpp', + 'map/pathfind/MapPathfindRessource.cpp', + 'map/MapQuery.cpp', + 'map/MapResources.cpp', + 'map/MapStep.cpp', + 'map/MapTerrain.cpp', + 'render/MapView.cpp', + 'map/edit/Widgets.cpp', + 'map/edit/WidgetsTools.cpp', + 'map/edit/WidgetsUnit.cpp', + 'map/edit/WidgetsBuilding.cpp', + 'map/edit/MapEditCtor.cpp', + 'map/edit/MapEditIO.cpp', + 'map/edit/MapEditDraw.cpp', + 'map/edit/MapEditEvents.cpp', + 'map/edit/MapEditAction.cpp', + 'map/edit/MapEditActionView.cpp', + 'map/edit/MapEditActionTerrain.cpp', + 'map/edit/MapEditActionUnit.cpp', + 'map/edit/MapEditActionBuilding.cpp', + 'map/edit/MapEditDelegate.cpp', + 'map/edit/MapEditClicks.cpp', + 'map/edit/MapEditDialog.cpp', + 'map/edit/MapEditKeyActions.cpp', + 'map/generator/MapGenerationDescriptor.cpp', + 'map/generator/Generator.cpp', + 'map/generator/GeneratorDivide.cpp', + 'map/generator/GeneratorSplit.cpp', + 'map/generator/GeneratorPoints.cpp', + 'map/generator/GeneratorHeightmap.cpp', + 'map/generator/MapHomogen.cpp', + 'map/generator/MapOldRandom.cpp', + 'map/generator/MapRandom.cpp', + 'map/generator/MapOldIslands.cpp', + 'map/generator/GameMaps.cpp', + 'map/io/MapHeader.cpp', + 'MapScript.cpp', + 'MapScriptError.cpp', + 'MapScriptUSL.cpp', + 'map/io/MapThumbnail.cpp', + 'MarkManager.cpp', + 'render/Minimap.cpp', + 'MultiplayerGame.cpp', + 'MultiplayerGameEvent.cpp', + 'MultiplayerGameEventListener.cpp', + 'MultiplayerGameScreen.cpp', + 'net/NetBroadcaster.cpp', + 'net/NetBroadcastListener.cpp', + 'net/NetConnection.cpp', + 'net/NetTransport.cpp', + 'net/WssTransport.cpp', + 'net/NetEngine.cpp', + 'net/NetGamePlayerManager.cpp', + 'net/NetListener.cpp', + 'net/message/NetMessage.cpp', + 'net/message/AuthMessages.cpp', + 'net/message/FileTransferMessages.cpp', + 'net/message/GameCreateMessages.cpp', + 'net/message/GameHeaderMessages.cpp', + 'net/message/GameJoinMessages.cpp', + 'net/message/GameLaunchMessages.cpp', + 'net/message/GameTeamMessages.cpp', + 'net/message/LobbyMessages.cpp', + 'net/message/MapDatabaseMessages.cpp', + 'net/message/MapUploadMessages.cpp', + 'net/message/MessageRecipients.cpp', + 'net/message/OrderMessages.cpp', + 'net/message/RegistrationMessages.cpp', + 'net/message/RouterAdminMessages.cpp', + 'net/message/RouterMessages.cpp', + 'net/NetReteamingInformation.cpp', + 'net/NetTestSuite.cpp', + 'NewMapScreen.cpp', + 'Order.cpp', + 'OrderBuilding.cpp', + 'OrderModify.cpp', + 'OrderMisc.cpp', + 'OverlayAreas.cpp', + 'OverlayFill.cpp', + 'PerlinNoise.cpp', + 'Player.cpp', + 'game/entities/Race.cpp', + 'ReplayReader.cpp', + 'ReplayWriter.cpp', + 'Ressource.cpp', + 'game/entities/Resources.cpp', + 'ScriptEditorScreen.cpp', + 'Sector.cpp', + 'Settings.cpp', + 'TorusView.cpp', + 'TorusViewRender.cpp', + 'gui/GameGUITorus.cpp', + 'SettingsScreen.cpp', + 'SettingsScreenLayout.cpp', + 'SettingsScreenInput.cpp', + 'SettingsScreenGeneral.cpp', + 'SettingsScreenBuildings.cpp', + 'SettingsScreenKeyboard.cpp', + 'sgsl/Lexer.cpp', + 'sgsl/Parser.cpp', + 'sgsl/ParserSummon.cpp', + 'sgsl/ParserWait.cpp', + 'sgsl/SGSL.cpp', + 'sgsl/StoryActions.cpp', + 'sgsl/StoryConditions.cpp', + 'sgsl/StoryExecute.cpp', + 'SimplexNoise.cpp', + 'PlayerVoice.cpp', + 'SoundMixer.cpp', + 'team/Team.cpp', + 'team/TeamSerialization.cpp', + 'team/TeamLists.cpp', + 'team/TeamRouting.cpp', + 'team/TeamStep.cpp', + 'TeamStat.cpp', + 'unit/Unit.cpp', + 'unit/UnitAction.cpp', + 'unit/UnitActivity.cpp', + 'unit/UnitDisplacement.cpp', + 'unit/UnitGeometry.cpp', + 'unit/UnitMedical.cpp', + 'unit/UnitMovement.cpp', + 'unit/UnitSerialization.cpp', + 'render/UnitSkin.cpp', + 'unit/UnitStats.cpp', + 'game/entities/UnitType.cpp', + 'unit/UnitUtils.cpp', + 'Utilities.cpp', + 'VoiceRecorder.cpp', + 'WinningConditions.cpp', + 'yog/YOGAfterJoinGameInformation.cpp', + 'yog/YOGClientBlockedList.cpp', + 'yog/YOGClientChatChannel.cpp', + 'yog/YOGClientChatListener.cpp', + 'yog/YOGClientCommandManager.cpp', + 'yog/YOGClientCommands.cpp', + 'yog/YOGClient.cpp', + 'yog/YOGClientDownloadableMapList.cpp', + 'yog/YOGClientDownloadableMapListener.cpp', + 'yog/YOGClientDownloadingMapScreen.cpp', + 'yog/YOGClientEvent.cpp', + 'yog/YOGClientEventListener.cpp', + 'yog/YOGClientFileAssembler.cpp', + 'yog/YOGClientGameConnectionDialog.cpp', + 'yog/YOGClientGameListListener.cpp', + 'yog/YOGClientGameListManager.cpp', + 'yog/YOGClientLobbyScreen.cpp', + 'yog/YOGClientMapDownloader.cpp', + 'yog/YOGClientMapDownloadScreen.cpp', + 'yog/YOGClientMapUploader.cpp', + 'yog/YOGClientMapUploadScreen.cpp', + 'yog/YOGClientOptionsScreen.cpp', + 'yog/YOGClientPlayerListListener.cpp', + 'yog/YOGClientPlayerListManager.cpp', + 'yog/YOGClientRatedMapList.cpp', + 'yog/YOGClientRouterAdministrator.cpp', + 'yog/YOGConnectionScreen.cpp', + 'yog/YOGConsts.cpp', + 'yog/YOGDownloadableMapInfo.cpp', + 'yog/YOGGameInfo.cpp', + 'yog/YOGGameResults.cpp', + 'yog/YOGLoginScreen.cpp', + 'yog/YOGMessage.cpp', + 'yog/YOGPlayerSessionInfo.cpp', + 'yog/YOGPlayerStoredInfo.cpp', + 'yog/YOGRegisterScreen.cpp', + 'yog/YOGServerAdministratorCommands.cpp', + 'yog/YOGServerAdministrator.cpp', + 'yog/YOGServerAdministratorList.cpp', + 'yog/YOGServerBannedIPListManager.cpp', + 'yog/YOGServerChatChannel.cpp', + 'yog/YOGServerChatChannelManager.cpp', + 'yog/YOGServer.cpp', + 'yog/YOGServerFileDistributationManager.cpp', + 'yog/YOGServerFileDistributor.cpp', + 'yog/YOGServerGame.cpp', + 'yog/YOGServerGameLog.cpp', + 'yog/YOGServerGameRouter.cpp', + 'yog/YOGServerMapDatabank.cpp', + 'yog/YOGServerPasswordRegistry.cpp', + 'yog/YOGServerPlayer.cpp', + 'yog/YOGServerPlayerScoreCalculator.cpp', + 'yog/YOGServerPlayerStoredInfoManager.cpp', + 'yog/YOGServerRouterAdministratorCommands.cpp', + 'yog/YOGServerRouterAdministrator.cpp', + 'yog/YOGServerRouter.cpp', + 'yog/YOGServerRouterManager.cpp', + 'yog/YOGServerRouterPlayer.cpp', +) + +SERVER_SOURCES = ( + 'ai/AINames.cpp', + 'BasePlayer.cpp', + 'BaseTeam.cpp', + 'BitArray.cpp', + 'GameHeader.cpp', + 'LANGameInformation.cpp', + 'map/io/MapHeader.cpp', + 'net/NetBroadcaster.cpp', + 'net/NetConnection.cpp', + 'net/NetTransport.cpp', + 'net/NetGamePlayerManager.cpp', + 'net/NetListener.cpp', + 'net/message/NetMessage.cpp', + 'net/message/AuthMessages.cpp', + 'net/message/FileTransferMessages.cpp', + 'net/message/GameCreateMessages.cpp', + 'net/message/GameHeaderMessages.cpp', + 'net/message/GameJoinMessages.cpp', + 'net/message/GameLaunchMessages.cpp', + 'net/message/GameTeamMessages.cpp', + 'net/message/LobbyMessages.cpp', + 'net/message/MapDatabaseMessages.cpp', + 'net/message/MapUploadMessages.cpp', + 'net/message/MessageRecipients.cpp', + 'net/message/OrderMessages.cpp', + 'net/message/RegistrationMessages.cpp', + 'net/message/RouterAdminMessages.cpp', + 'net/message/RouterMessages.cpp', + 'net/NetReteamingInformation.cpp', + 'net/NetTestSuite.cpp', + 'Order.cpp', + 'OrderBuilding.cpp', + 'OrderModify.cpp', + 'OrderMisc.cpp', + 'game/entities/Race.cpp', + 'game/entities/UnitType.cpp', + 'Utilities.cpp', + 'yog/YOGConsts.cpp', + 'yog/YOGGameInfo.cpp', + 'yog/YOGGameResults.cpp', + 'yog/YOGMessage.cpp', + 'yog/YOGPlayerSessionInfo.cpp', + 'yog/YOGPlayerStoredInfo.cpp', + 'building/BuildingUtils.cpp', + 'Bullet.cpp', + 'game/entities/Resources.cpp', + 'Glob2.cpp', + 'GlobalContainer.cpp', + 'GlobalContainerArgs.cpp', + 'map/Map.cpp', + 'map/gradient/MapGradientArea.cpp', + 'map/gradient/MapGradientBuilding.cpp', + 'map/gradient/MapGradientGlobal.cpp', + 'map/gradient/MapGradientField.cpp', + 'map/io/MapExploredAreaIO.cpp', + 'map/io/MapIO.cpp', + 'map/MapMisc.cpp', + 'map/pathfind/MapPathfindArea.cpp', + 'map/pathfind/MapPathfindRessource.cpp', + 'map/MapQuery.cpp', + 'map/MapResources.cpp', + 'map/MapStep.cpp', + 'map/MapTerrain.cpp', + 'render/MapView.cpp', + 'map/io/MapThumbnail.cpp', + 'Sector.cpp', + 'Settings.cpp', + 'unit/UnitUtils.cpp', + 'yog/YOGAfterJoinGameInformation.cpp', + 'yog/YOGDownloadableMapInfo.cpp', + 'WinningConditions.cpp', + 'yog/YOGServerAdministratorCommands.cpp', + 'yog/YOGServerAdministrator.cpp', + 'yog/YOGServerAdministratorList.cpp', + 'yog/YOGServerBannedIPListManager.cpp', + 'yog/YOGServerChatChannel.cpp', + 'yog/YOGServerChatChannelManager.cpp', + 'yog/YOGServer.cpp', + 'yog/YOGServerFileDistributationManager.cpp', + 'yog/YOGServerFileDistributor.cpp', + 'yog/YOGServerGame.cpp', + 'yog/YOGServerGameLog.cpp', + 'yog/YOGServerGameRouter.cpp', + 'yog/YOGServerMapDatabank.cpp', + 'yog/YOGServerPasswordRegistry.cpp', + 'yog/YOGServerPlayer.cpp', + 'yog/YOGServerPlayerScoreCalculator.cpp', + 'yog/YOGServerPlayerStoredInfoManager.cpp', + 'yog/YOGServerRouterAdministratorCommands.cpp', + 'yog/YOGServerRouterAdministrator.cpp', + 'yog/YOGServerRouter.cpp', + 'yog/YOGServerRouterManager.cpp', + 'yog/YOGServerRouterPlayer.cpp', +) + +GAG_SOURCES = ( + 'ApplicationHost.cpp', + 'BinaryStream.cpp', + 'CursorManager.cpp', + 'FileManager.cpp', + 'FileManagerAtomic.cpp', + 'FormatableString.cpp', + 'GraphicContext.cpp', + 'GraphicContextResize.cpp', + 'GraphicContextDraw.cpp', + 'GraphicContextCompound.cpp', + 'DrawableSurface.cpp', + 'DrawableSurfaceDraw.cpp', + 'DrawableSurfaceCompound.cpp', + 'GUIDropdown.cpp', + 'GUIAnimation.cpp', + 'GUIBase.cpp', + 'ScreenStack.cpp', + 'GUIButton.cpp', + 'GUIFileList.cpp', + 'GUIKeySelector.cpp', + 'GUIList.cpp', + 'GUIMessageBox.cpp', + 'GUINumber.cpp', + 'GUIRatio.cpp', + 'GUISelector.cpp', + 'GUIStyle.cpp', + 'GUITextArea.cpp', + 'GUITextAreaLayout.cpp', + 'GUITextAreaInput.cpp', + 'GUIText.cpp', + 'GUITextInput.cpp', + 'GUIImage.cpp', + 'GUIProgressBar.cpp', + 'KeyPress.cpp', + 'Sprite.cpp', + 'StreamBackend.cpp', + 'Stream.cpp', + 'StringTable.cpp', + 'SupportFunctions.cpp', + 'TextStream.cpp', + 'Toolkit.cpp', + 'TrueTypeFont.cpp', + 'win32_dirent.cpp', + 'GUITabScreen.cpp', + 'GUITabScreenWindow.cpp', + 'TextSort.cpp', + 'GUICheckList.cpp', +) + +GAG_SERVER_SOURCES = ( + 'ApplicationHost.cpp', + 'BinaryStream.cpp', + 'Stream.cpp', + 'FileManager.cpp', + 'FileManagerAtomic.cpp', + 'FormatableString.cpp', + 'TextStream.cpp', + 'StreamBackend.cpp', + 'StringTable.cpp', + 'Toolkit.cpp', +) + +USL_SOURCES = ( + 'code.cpp', + 'debug.cpp', + 'interpreter.cpp', + 'lexer.cpp', + 'parser.cpp', + 'position.cpp', + 'token.cpp', + 'tree.cpp', + 'types.cpp', + 'usl.cpp', +) + +INCLUDE_DIRECTORIES = ( + 'libgag/include', '.', 'libusl/src', 'src', 'src/yog', 'src/ai', + 'src/building', 'src/game/entities', 'src/gui', 'src/map', 'src/map/edit', + 'src/map/generator', 'src/map/gradient', 'src/map/io', 'src/map/pathfind', + 'src/net', 'src/net/irc', 'src/net/message', 'src/sgsl', 'src/team', 'src/unit', +) diff --git a/scons/web_build.py b/scons/web_build.py new file mode 100644 index 000000000..bf5ca896b --- /dev/null +++ b/scons/web_build.py @@ -0,0 +1,83 @@ +"""Emscripten toolchain, independent of native configuration and SDK discovery.""" +from pathlib import Path +import json +import os +import subprocess +from SCons.Script import Environment, Default, Value, GetOption, Action +from build_layout import write_if_changed +from sources import CLIENT_SOURCES, GAG_SOURCES, USL_SOURCES, INCLUDE_DIRECTORIES + +PORTS = ['--use-port=sdl2', '--use-port=sdl2_image:formats=png,jpg', + '--use-port=sdl2_ttf', '--use-port=sdl2_net', '--use-port=vorbis', + '--use-port=zlib', '--use-port=boost_headers'] + + +def build_web(directory, identity, arguments): + root = Path.cwd() + output = Path(directory).resolve() + sdk = Path(arguments.get('emsdk', os.environ.get('EMSDK', root / 'tools/browser-emsdk'))).resolve() + compiler = sdk / 'upstream/emscripten/em++' + lock = json.loads((root / 'browser/toolchain.json').read_text()) + if not compiler.exists(): + raise ValueError('Emscripten SDK not found; run python3 browser/setup.py or pass emsdk=/path/to/emsdk') + if not GetOption('clean'): + version = subprocess.check_output([str(compiler), '--version'], text=True) + if lock['emscripten'] not in version.splitlines()[0]: + raise ValueError('This target requires Emscripten ' + lock['emscripten']) + emscripten = compiler.parent + build_environment = dict(os.environ) + # Cache is target/config-specific, including port downloads and compiled system libraries. + build_environment['EM_CACHE'] = str(output / 'cache') + build_environment['EM_PORTS'] = str(output / 'ports') + build_environment.update(TMPDIR=str(output / 'tmp'), TMP=str(output / 'tmp'), TEMP=str(output / 'tmp')) + env = Environment(platform='posix', tools=['gcc', 'g++', 'ar', 'gnulink', 'compilation_db'], + ENV=build_environment, CC=str(emscripten / 'emcc'), CXX=str(compiler), + LINK=str(compiler), AR=str(emscripten / 'emar'), RANLIB=str(emscripten / 'emranlib')) + env['PROGSUFFIX'] = '.html' + config = output / 'include/glob2/BuildConfig.h' + write_if_changed(config, '''#pragma once +#define HAVE_OPENGL 1 +#define GLOB2_WEBGL2 1 +#define PACKAGE "glob2" +#define PACKAGE_NAME "Globulation 2" +#define PACKAGE_VERSION "Browser development" +#define PACKAGE_DATA_DIR "/" +#define PACKAGE_SOURCE_DIR "/" +#define PRIMARY_FONT "sans.ttf" +''') + include_paths = [str(output / 'include')] + list(INCLUDE_DIRECTORIES) + env.Append(CPPPATH=include_paths, CPPDEFINES=['HAVE_CONFIG_H'], + CXXFLAGS=['-std=gnu++20', '-fexceptions', '-g2', '-O2' if identity['mode']=='release' else '-O0'] + PORTS) + env.Append(LINKFLAGS=['-fexceptions', '-O2' if identity['mode']=='release' else '-O0', + '-sLEGACY_GL_EMULATION=1', '-sMIN_WEBGL_VERSION=2', '-sMAX_WEBGL_VERSION=2', + '-sALLOW_MEMORY_GROWTH', + '-sINITIAL_MEMORY=134217728', '-sSTACK_SIZE=8388608', '-sASSERTIONS=1', + '-sFORCE_FILESYSTEM', '-lidbfs.js', '-lwebsocket.js', + "'-sEXPORTED_RUNTIME_METHODS=[\"callMain\",\"FS\"]'", + '--shell-file', 'browser/shell.html', '--pre-js', 'browser/storage.js', '--pre-js', 'browser/file-selection.js', '--pre-js', 'browser/audio.js'] + PORTS) + for asset_directory in ('data', 'maps', 'campaigns', 'scripts'): + env.Append(LINKFLAGS=['--preload-file', asset_directory + '@/' + asset_directory]) + env['LINKCOM'] = '${TEMPFILE("$LINK -o $TARGET $LINKFLAGS $__RPATH $SOURCES $_LIBDIRFLAGS $_LIBFLAGS", "$LINKCOMSTR")}' + def prepare_ports(target, source, env): + return subprocess.run( + [str(compiler), *PORTS, '-x', 'c++', '-c', '-o', str(target[0]), '-'], + input='', text=True, env=env['ENV']).returncode + ports = env.Command(str(output / 'ports-ready.o'), [Value(lock), Value(PORTS)], + Action(prepare_ports, 'Preparing pinned Emscripten ports')) + files = ['src/' + s for s in CLIENT_SOURCES if s not in ('VoiceRecorder.cpp', 'net/NetTransport.cpp', 'net/WssTransport.cpp', 'net/irc/IRCTextMessageHandler.cpp')] + files += ['libgag/src/' + s for s in GAG_SOURCES if s != 'ApplicationHost.cpp'] + files += ['libusl/src/' + s for s in USL_SOURCES] + files += ['browser/VoiceRecorder.cpp', 'browser/ApplicationHost.cpp', 'browser/NetTransport.cpp', 'browser/IRCTextMessageHandler.cpp'] + objects = [env.Object(str(output / 'obj' / (f + '.o')), f) for f in files] + env.Requires(objects, ports) + env.Depends(objects, str(config)) + program = env.Program(str(output / 'index.html'), objects) + env.Depends(program, ['browser/shell.html', 'browser/storage.js', 'browser/file-selection.js', 'browser/audio.js', 'browser/toolchain.json']) + env.Depends(program, [str(p) for directory in ('data','maps','campaigns','scripts') + for p in Path(directory).rglob('*') if p.is_file()]) + env.SideEffect([str(output / ('index.'+ext)) for ext in ('js','wasm','data')], program) + env.Clean(program, [str(output / ('index.'+ext)) for ext in ('js','wasm','data')]) + database = env.CompilationDatabase(str(output / 'compile_commands.json')) + env.Alias('compile_commands.json', database) + Default(program, database) + write_if_changed(output / 'options.json', json.dumps(dict(arguments), sort_keys=True, indent=2)+'\n') diff --git a/src/Application.cpp b/src/Application.cpp new file mode 100644 index 000000000..6ad6b92d5 --- /dev/null +++ b/src/Application.cpp @@ -0,0 +1,165 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "Application.h" +#include "FrontendTheme.h" +#include +#include +#include "GlobalContainer.h" +#include "MainMenuScreen.h" +#include "MessageScreen.h" +#include "CampaignMainMenu.h" +#include "CampaignMenuScreen.h" +#include "SettingsScreen.h" +#include "CreditScreen.h" +#include "EditorMainMenu.h" +#include "LANMenuScreen.h" +#include "YOGLoginScreen.h" +#include "YOGClient.h" +#include +#include + +namespace { +// Keep graphics and the host alive until final writes have reached storage. +// The gameplay stack is destroyed first, so its destructor writes are included. +class ShutdownScreen : public Glob2Screen +{ + GAGGUI::Text* status; + GAGGUI::TextButton* retry; + GAGGUI::TextButton* leave; + std::unique_ptr persistence; + bool closing = false; + void close() { + persistence.reset(); + closing = true; + retry->visible = leave->visible = false; + status->setText(GAGCore::Toolkit::getStringTable()->getString("[game closed]")); + } + void failed() { + persistence.reset(); + status->setText(GAGCore::Toolkit::getStringTable()->getString("[shutdown save failed]")); + retry->visible = leave->visible = true; + } + void save() { + retry->visible = leave->visible = false; + status->setText(GAGCore::Toolkit::getStringTable()->getString("[saving to storage]")); + try { + if (GAGCore::ApplicationHost::storageRestoreFailed() || !globalContainer->settings.save()) { + failed(); return; + } + persistence = GAGCore::ApplicationHost::persistStorage(); + if (!persistence) failed(); + } catch (const std::exception&) { failed(); } + } +public: + ShutdownScreen() { + auto& strings = *GAGCore::Toolkit::getStringTable(); + status = new GAGGUI::Text(20, 230, ALIGN_SCREEN_CENTERED, ALIGN_SCREEN_CENTERED, + "standard", strings.getString("[saving to storage]")); + retry = new GAGGUI::TextButton(20, 340, 280, 40, ALIGN_SCREEN_CENTERED, ALIGN_SCREEN_CENTERED, + "menu", strings.getString("[retry save]"), 0); + leave = new GAGGUI::TextButton(330, 340, 280, 40, ALIGN_SCREEN_CENTERED, ALIGN_SCREEN_CENTERED, + "menu", strings.getString("[quit without saving]"), 1); + addWidget(status); addWidget(retry); addWidget(leave); + save(); + } + void onAction(GAGGUI::Widget*, GAGGUI::Action action, int choice, int) override { + if (persistence || closing || action != GAGGUI::BUTTON_RELEASED) return; + if (choice == 0) save(); + else if (choice == 1) close(); + } + void onTimer(Uint32) override { + // Present the final message for one frame before releasing graphics. + if (closing) { endExecute(0); return; } + if (!persistence) return; + const auto state = persistence->state(); + if (state == GAGCore::ApplicationHost::PersistenceState::Failed) failed(); + else if (state == GAGCore::ApplicationHost::PersistenceState::Succeeded) close(); + } +}; + +} + +Application::Application() + : frontend(std::make_unique()), screens(*globalContainer->gfx), + shutdownScreens(*globalContainer->gfx), singlePlayer(screens) +{ + if (GAGCore::ApplicationHost::storageRestoreFailed()) { + auto& strings = *GAGCore::Toolkit::getStringTable(); + screens.push(std::make_unique(strings.getString("[storage restore failed]"), + std::vector{strings.getString("[continue]")}), + [this](GAGGUI::Screen&, int) { mainMenu(); }); + } else if (globalContainer->replaying) singlePlayer.replay(globalContainer->replayFileName); + else mainMenu(); +} + +Application::~Application() = default; + +void Application::mainMenu() +{ + // Rebuild translated labels after returning from settings. + screens.push(std::make_unique(), [this](GAGGUI::Screen&, int choice) { choose(choice); }); +} + +void Application::choose(int choice) +{ + switch (choice) { + case MainMenuScreen::CAMPAIGN: + screens.push(std::make_unique(screens)); break; + case MainMenuScreen::TUTORIAL: { + Campaign campaign; + const bool saved = campaign.load("games/Tutorial_Campaign.txt"); + auto menu = std::make_unique(saved ? "games/Tutorial_Campaign.txt" : "campaigns/Tutorial_Campaign.txt", screens); + if (!saved) menu->setNewCampaign(); + screens.push(std::move(menu)); + break; + } + case MainMenuScreen::CUSTOM: singlePlayer.custom(); break; + case MainMenuScreen::LOAD_GAME: singlePlayer.load(); break; + case MainMenuScreen::GAME_SETUP: screens.push(std::make_unique()); break; + case MainMenuScreen::CREDITS: screens.push(std::make_unique()); break; + case MainMenuScreen::EDITOR: screens.push(std::make_unique(screens)); break; + case MainMenuScreen::MULTIPLAYERS_LAN: screens.push(std::make_unique(screens)); break; + case MainMenuScreen::MULTIPLAYERS_YOG: + screens.push(std::make_unique(screens, std::make_shared())); break; + case MainMenuScreen::QUIT: screens.stop(); break; + } +} + +bool Application::frame(std::uint32_t tick, const std::vector& events) +{ + lastFrame = tick; + if (GAGCore::ApplicationHost::takeVisibilityChange(hidden)) screens.suspendExecution(); + if (hidden) return true; + int width, height; + if (GAGCore::ApplicationHost::takeViewportSize(width, height)) { + const int oldWidth = globalContainer->gfx->getW(), oldHeight = globalContainer->gfx->getH(); + if (globalContainer->gfx->resizeViewport(width, height)) { + screens.viewportResized(oldWidth, oldHeight, width, height); + shutdownScreens.viewportResized(oldWidth, oldHeight, width, height); + } + } + if (quitting) { + // Repeated window-close events must not bypass a pending write or its + // explicit failure decision. Closing a browser tab remains abrupt. + auto input = events; + std::erase_if(input, [](const SDL_Event& event) { return event.type == SDL_QUIT; }); + shutdownScreens.frame(tick, input); + return shutdownScreens.running(); + } + screens.frame(tick, events); + if (!screens.running()) { + if (screens.result() == GAGGUI::Screen::QUIT_APPLICATION) { + quitting = true; + shutdownScreens.push(std::make_unique()); + return true; + } + mainMenu(); + } + return true; +} + +std::uint32_t Application::delay(std::uint32_t now) +{ + if (hidden) return 100; + const auto elapsed = static_cast(now - lastFrame); + return (quitting ? shutdownScreens : screens).delay(now, elapsed < 40 ? 40 - elapsed : 0); +} diff --git a/src/Application.h b/src/Application.h new file mode 100644 index 000000000..da87d1508 --- /dev/null +++ b/src/Application.h @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once +#include +#include +#include "SinglePlayerFlow.h" + +class FrontendTheme; + +// Shared application state. Scheduling/event polling belong to platform hosts. +class Application : public GAGCore::ApplicationHost::Loop +{ +public: + Application(); + ~Application() override; + bool frame(std::uint32_t tick, const std::vector& events) override; + std::uint32_t delay(std::uint32_t now) override; +private: + std::unique_ptr frontend; + GAGGUI::ScreenStack screens; + GAGGUI::ScreenStack shutdownScreens; + SinglePlayerFlow singlePlayer; + std::uint32_t lastFrame = 0; + bool hidden = false; + bool quitting = false; + void mainMenu(); + void choose(int choice); +}; diff --git a/src/Campaign.cpp b/src/Campaign.cpp index fc8badcaa..013d3f8f1 100644 --- a/src/Campaign.cpp +++ b/src/Campaign.cpp @@ -3,11 +3,14 @@ #include "Campaign.h" #include "TextStream.h" +#include +#include #include "Version.h" #include "Toolkit.h" #include "FileManager.h" #include #include +#include // Defined in map/io/MapHeader.cpp. Forward-declared here to avoid pulling // MapHeader.h, which transitively includes Team.h / WinningConditions.h / @@ -245,22 +248,10 @@ bool Campaign::save(bool isGameSave) else filename = glob2NameToFilename("games", name.c_str(), "txt"); - // openOutputStreamBackend never returns nullptr; on fopen failure it - // returns a backend wrapping NULL, which fails isValid() and would crash - // (assert(fp) in debug, raw fwrite(NULL) UB in release) on the first - // write. Mirrors the pattern in Campaign::load and MapEdit::save. - std::unique_ptr backend( - Toolkit::getFileManager()->openOutputStreamBackend(filename)); - if (!backend->isValid()) - { - std::cerr << "Campaign::save(\"" << filename << "\") : error, can't open file." << std::endl; - return false; - } - - // TextOutputStream takes ownership of the backend and frees it in its - // destructor, so release() at the point of handoff. unique_ptr on the - // stream itself protects against leak-on-throw from any future write. - auto stream = std::make_unique(backend.release()); + // Keep the established text format, but serialize before replacing the + // destination. The shared writer checks short writes, flush and close. + auto* backend = new MemoryStreamBackend(); + auto stream = std::make_unique(backend); stream->writeUint32(VERSION_MINOR, "versionMinor"); stream->writeText(name, "campaignName"); stream->writeText(playerName, "playerName"); @@ -274,11 +265,70 @@ bool Campaign::save(bool isGameSave) } stream->writeLeaveSection(); stream->writeText(description, "description"); - return true; + stream->flush(); + return Toolkit::getFileManager()->writeAtomically(filename, [backend](OutputStream& output) { + output.write(backend->getBuffer(), backend->getPosition(), "campaign"); + }); +} + + + +std::vector Campaign::exportProgress() +{ + if (maps.size() > 1024 || playerName.size() > 512) throw std::length_error("Campaign progress exceeds format limits"); + for (unsigned char c : playerName) if (c < 32 || c == 127) throw std::invalid_argument("Invalid player name"); + auto* backend = new MemoryStreamBackend(); + BinaryOutputStream output(backend); + output.write("G2CP", 4, "signature"); + output.writeUint32(1, "version"); + output.writeText(name, "campaign"); + output.writeText(playerName, "player"); + output.writeUint32(maps.size(), "missions"); + for (auto& map : maps) { + output.writeText(map.getMapName(), "mission"); + output.writeText(map.getMapFileName(), "map"); + const auto& prerequisites = map.getUnlockedByMaps(); + output.writeUint32(prerequisites.size(), "prerequisites"); + for (const auto& prerequisite : prerequisites) output.writeText(prerequisite, "prerequisite"); + output.writeUint8(map.isUnlocked(), "unlocked"); + output.writeUint8(map.isCompleted(), "completed"); + } + if (backend->getPosition() > 1024*1024) throw std::length_error("Campaign progress exceeds size limit"); + const auto* begin = reinterpret_cast(backend->getBuffer()); + return {begin, begin + backend->getPosition()}; +} + +bool Campaign::importProgress(const std::vector& bytes) +{ + if (bytes.size() < 8 || bytes.size() > 1024*1024 || maps.size() > 1024) return false; + try { + BinaryInputStream input(new MemoryStreamBackend(bytes.data(), bytes.size())); + input.seekFromStart(0); + BinaryInputStream::CheckedReads checked(&input); + char signature[4]; input.read(signature, 4, "signature"); + if (std::memcmp(signature, "G2CP", 4) || input.readUint32("version") != 1 || input.readText("campaign") != name) return false; + Campaign candidate = *this; + candidate.playerName = input.readText("player"); + if (candidate.playerName.size() > 512) return false; + for (unsigned char c : candidate.playerName) if (c < 32 || c == 127) return false; + if (input.readUint32("missions") != maps.size()) return false; + for (auto& map : candidate.maps) { + if (input.readText("mission") != map.getMapName() || input.readText("map") != map.getMapFileName()) return false; + const auto& prerequisites = map.getUnlockedByMaps(); + if (input.readUint32("prerequisites") != prerequisites.size()) return false; + for (const auto& prerequisite : prerequisites) + if (input.readText("prerequisite") != prerequisite) return false; + const auto unlocked = input.readUint8("unlocked"), completed = input.readUint8("completed"); + if (unlocked > 1 || completed > 1 || (completed && !unlocked)) return false; + if (unlocked) map.unlockMap(); + if (completed) map.setCompleted(true); + } + if (input.getPosition() != bytes.size()) return false; + *this = std::move(candidate); + return true; + } catch (const std::exception&) { return false; } } - - size_t Campaign::getMapCount() const { return maps.size(); diff --git a/src/Campaign.h b/src/Campaign.h index add7fb712..796ab8e59 100644 --- a/src/Campaign.h +++ b/src/Campaign.h @@ -73,6 +73,11 @@ class Campaign ///(read-only directory, full disk, missing path); callers should react instead ///of silently dropping the user's progress / edits. bool save(bool isGameSave=false); + /// Versioned progress backup; contains mission identity and progress only. + std::vector exportProgress(); + /// Validate against this campaign, then merge progress without relocking + /// missions or removing completions. Invalid input leaves this unchanged. + bool importProgress(const std::vector& bytes); ///Gets the number of maps in this campaign size_t getMapCount() const; ///Returns the entry for map n. Precondition: n < getMapCount(); violating diff --git a/src/CampaignEditor.cpp b/src/CampaignEditor.cpp index 90c6baf2d..788615099 100644 --- a/src/CampaignEditor.cpp +++ b/src/CampaignEditor.cpp @@ -6,13 +6,12 @@ #include "StringTable.h" #include "ChooseMapScreen.h" #include "GlobalContainer.h" -#include "GUIMessageBox.h" #include #include #include "GUICheckList.h" -CampaignEditor::CampaignEditor(const std::string& name) +CampaignEditor::CampaignEditor(const std::string& name, GAGGUI::ScreenStack& screens) : screens(screens) { if (name != "" && !campaign.load(name)) campaign.setName(name); @@ -35,6 +34,9 @@ CampaignEditor::CampaignEditor(const std::string& name) addWidget(ok); addWidget(cancel); addWidget(description); + saveStatus = new TextArea(320, 330, 310, 80, ALIGN_SCREEN_CENTERED, ALIGN_SCREEN_CENTERED, + "standard", true, ""); + addWidget(saveStatus); syncMapList(); } @@ -43,16 +45,12 @@ CampaignEditor::CampaignEditor(const std::string& name) void CampaignEditor::onAction(Widget *source, Action action, int par1, int par2) { + if (persistence) return; if ((action == BUTTON_RELEASED) || (action == BUTTON_SHORTCUT)) { if (source == ok) { - if (campaign.save()) - endExecute(OK); - else - GAGGUI::MessageBox(globalContainer->gfx, "standard", GAGGUI::MB_ONEBUTTON, - Toolkit::getStringTable()->getString("[ERROR_CANT_SAVE_CAMPAIGN]"), - Toolkit::getStringTable()->getString("[ok]")); + saveCampaign(); } else if (source == cancel) { @@ -60,58 +58,36 @@ void CampaignEditor::onAction(Widget *source, Action action, int par1, int par2) } else if (source == addMap) { - ChooseMapScreen cms("campaigns", "map", false); - int rcms=cms.execute(gfx, 40); - if(rcms==ChooseMapScreen::OK) - { - MapHeader& mapHeader = cms.getMapHeader(); - CampaignMapEntry cme(mapHeader.getMapName(), glob2NameToFilename("campaigns", mapHeader.getMapName(), "map")); - CampaignMapEntryEditor cmee(campaign, cme); - int rcmee = cmee.execute(gfx, 40); - if(rcmee==CampaignMapEntryEditor::OK) - { - campaign.appendMap(cme); - mapList->addText(mapHeader.getMapName()); - } - else if(rcmee==CampaignMapEntryEditor::CANCEL) - { - - } - else if(rcmee == -1) - { - endExecute(-1); - } - } - else if(rcms==ChooseMapScreen::CANCEL) - { - } - else if(rcms==-1) - { - endExecute(-1); - } - } - else if (source == editMap) - { - auto sel = mapList->selection(); - if (sel) - { - for(unsigned i=0; iget()) - { - CampaignMapEntryEditor cmee(campaign, campaign.getMap(i)); - int rcmee = cmee.execute(gfx, 40); - if(rcmee==CampaignMapEntryEditor::OK) - { - mapList->setText(*sel, campaign.getMap(i).getMapName()); - } - else if(rcmee==CampaignMapEntryEditor::CANCEL) - { - } - } - } - } - } + screens.push(std::make_unique("campaigns", "map", false), + [this](Screen& screen, int result) { + if (result != ChooseMapScreen::OK) return; + const auto name = static_cast(screen).getMapHeader().getMapName(); + auto draft = std::make_shared(name, glob2NameToFilename("campaigns", name, "map")); + screens.push(std::make_unique(campaign, *draft), + [this, draft](Screen&, int result) { + if (result == CampaignMapEntryEditor::OK) { + campaign.appendMap(*draft); + mapList->addText(draft->getMapName()); + } + }); + }); + } + else if (source == editMap) + { + auto selected = mapList->selection(); + if (selected) { + for (unsigned i = 0; i < campaign.getMapCount(); ++i) { + if (campaign.getMap(i).getMapName() == mapList->get()) { + screens.push(std::make_unique(campaign, campaign.getMap(i)), + [this, i, index = *selected](Screen&, int result) { + if (result == CampaignMapEntryEditor::OK) + mapList->setText(index, campaign.getMap(i).getMapName()); + }); + break; + } + } + } + } else if (source == removeMap) { auto sel = mapList->selection(); @@ -145,6 +121,36 @@ void CampaignEditor::onAction(Widget *source, Action action, int par1, int par2) +void CampaignEditor::saveFailed() +{ + persistence.reset(); + for (Widget* widget : std::initializer_list{ok, cancel, addMap, editMap, removeMap, nameEditor, description, mapList}) widget->visible = true; + saveStatus->setText(Toolkit::getStringTable()->getString("[campaign editor save failed]")); +} + +void CampaignEditor::saveCampaign() +{ + try { + if (GAGCore::ApplicationHost::storageRestoreFailed() || !campaign.save()) { saveFailed(); return; } + persistence = GAGCore::ApplicationHost::persistStorage(); + if (!persistence) { saveFailed(); return; } + for (Widget* widget : std::initializer_list{ok, cancel, addMap, editMap, removeMap, nameEditor, description, mapList}) widget->visible = false; + saveStatus->setText(Toolkit::getStringTable()->getString("[saving to storage]")); + } catch (const std::exception&) { saveFailed(); } +} + +void CampaignEditor::onTimer(Uint32 tick) +{ + Glob2Screen::onTimer(tick); + if (!persistence) return; + const auto state = persistence->state(); + if (state == GAGCore::ApplicationHost::PersistenceState::Failed) saveFailed(); + else if (state == GAGCore::ApplicationHost::PersistenceState::Succeeded) { + persistence.reset(); + endExecute(OK); + } +} + void CampaignEditor::syncMapList() { for(unsigned n=0; ndeactivate(); } } - - - diff --git a/src/CampaignEditor.h b/src/CampaignEditor.h index 10ed0332f..bd0a64801 100644 --- a/src/CampaignEditor.h +++ b/src/CampaignEditor.h @@ -5,6 +5,9 @@ #include "Glob2Screen.h" #include "Campaign.h" +#include "FrontendTheme.h" +#include +#include #include "GUIText.h" #include "GUIButton.h" #include "GUIList.h" @@ -15,8 +18,9 @@ class CampaignEditor : public Glob2Screen { public: - CampaignEditor(const std::string& name); + CampaignEditor(const std::string& name, GAGGUI::ScreenStack& screens); void onAction(Widget *source, Action action, int par1, int par2); + void onTimer(Uint32 tick) override; enum { ADDMAP, @@ -26,7 +30,9 @@ class CampaignEditor : public Glob2Screen CANCEL, }; private: + FrontendScope theme{false}; Campaign campaign; + GAGGUI::ScreenStack& screens; /// Title of the screen, depends on the directory given in parameter Text *title; /// The ok button @@ -45,6 +51,10 @@ class CampaignEditor : public Glob2Screen TextInput* nameEditor; /// Text editor for description TextArea* description; + TextArea* saveStatus; + std::unique_ptr persistence; + void saveCampaign(); + void saveFailed(); ///Adds all of the maps in the campaign to the mapList void syncMapList(); @@ -88,4 +98,3 @@ class CampaignMapEntryEditor : public Glob2Screen /// The label for isUnlocked Text *isUnlockedLabel; }; - diff --git a/src/CampaignMainMenu.cpp b/src/CampaignMainMenu.cpp index f6f2681c5..00eddcfdf 100644 --- a/src/CampaignMainMenu.cpp +++ b/src/CampaignMainMenu.cpp @@ -10,7 +10,7 @@ #include "CampaignMenuScreen.h" #include "GlobalContainer.h" -CampaignMainMenu::CampaignMainMenu() +CampaignMainMenu::CampaignMainMenu(GAGGUI::ScreenStack& screens) : screens(screens) { newCampaign = new TextButton(0, 70, 300, 40, ALIGN_CENTERED, ALIGN_SCREEN_CENTERED, "menu", Toolkit::getStringTable()->getString("[start new campaign]"), NEWCAMPAIGN); addWidget(newCampaign); @@ -42,26 +42,12 @@ void CampaignMainMenu::onAction(Widget *source, Action action, int par1, int par void CampaignMainMenu::runCampaignSelection(bool newCampaign) { - // When loading, the selector lists the player's saved campaign games - // instead of the fresh campaign definitions - CampaignSelectorScreen css(!newCampaign); - int rc_css=css.execute(globalContainer->gfx, 40); - if(rc_css==CampaignSelectorScreen::OK) - { - CampaignMenuScreen cms(css.getCampaignName()); - if(newCampaign) - cms.setNewCampaign(); - int rc_cms=cms.execute(globalContainer->gfx, 40); - if(rc_cms == Screen::QUIT_APPLICATION) - { - endExecute(QUIT_APPLICATION); - } - // CampaignMenuScreen::EXIT: stay on this menu - } - else if(rc_css == Screen::QUIT_APPLICATION) - { - endExecute(QUIT_APPLICATION); - } - // CampaignSelectorScreen::CANCEL: stay on this menu + screens.push(std::make_unique(!newCampaign), + [this, newCampaign](Screen& selected, int result) { + if (result != CampaignSelectorScreen::OK) return; + auto menu = std::make_unique( + static_cast(selected).getCampaignName(), screens); + if (newCampaign) menu->setNewCampaign(); + screens.push(std::move(menu)); + }); } - diff --git a/src/CampaignMainMenu.h b/src/CampaignMainMenu.h index 2c8201d9e..4afa09e29 100644 --- a/src/CampaignMainMenu.h +++ b/src/CampaignMainMenu.h @@ -5,12 +5,13 @@ #include "Glob2Screen.h" #include "GUIButton.h" +#include ///This is the screen that provides the player with the choice of loading a campaign or starting a new one class CampaignMainMenu : public Glob2Screen { public: - CampaignMainMenu(); + explicit CampaignMainMenu(GAGGUI::ScreenStack& screens); void onAction(Widget *source, Action action, int par1, int par2); //! Widget return codes, delivered to onAction as par1. These identify //! buttons only; they are never used as execute() return values. @@ -29,6 +30,7 @@ class CampaignMainMenu : public Glob2Screen CANCELLED = 1, }; private: + GAGGUI::ScreenStack& screens; //! Shared flow for the "new campaign" and "load campaign" buttons: //! pick a campaign with CampaignSelectorScreen, then run it in //! CampaignMenuScreen. Propagates application quit to our caller. diff --git a/src/CampaignMenuScreen.cpp b/src/CampaignMenuScreen.cpp index 74d5d6338..7c23a92f6 100644 --- a/src/CampaignMenuScreen.cpp +++ b/src/CampaignMenuScreen.cpp @@ -5,11 +5,16 @@ #include "Toolkit.h" #include "StringTable.h" #include "Engine.h" +#include "GameSessionScreen.h" +#include "GameLoadScreen.h" +#include "MessageScreen.h" #include "GlobalContainer.h" #include "GUIMapPreview.h" #include "GUIMessageBox.h" +#include +#include -CampaignMenuScreen::CampaignMenuScreen(const std::string& name) +CampaignMenuScreen::CampaignMenuScreen(const std::string& name, GAGGUI::ScreenStack& screens) : screens(screens) { if (!campaign.load(name)) campaign.setName(name); @@ -31,46 +36,84 @@ CampaignMenuScreen::CampaignMenuScreen(const std::string& name) description = new TextArea(10, 260, 620, 160, ALIGN_SCREEN_CENTERED, ALIGN_SCREEN_CENTERED, "standard", true); addWidget(description); + if (GAGCore::ApplicationHost::canImportFiles()) { + importButton = new TextButton(10, 480, 145, 30, ALIGN_SCREEN_CENTERED, ALIGN_SCREEN_CENTERED, + "standard", Toolkit::getStringTable()->getString("[import progress]"), 2); + exportButton = new TextButton(165, 480, 145, 30, ALIGN_SCREEN_CENTERED, ALIGN_SCREEN_CENTERED, + "standard", Toolkit::getStringTable()->getString("[export progress]"), 3); + addWidget(importButton); addWidget(exportButton); + } + retryButton = new TextButton(330, 185, 145, 30, ALIGN_SCREEN_CENTERED, ALIGN_SCREEN_CENTERED, + "standard", Toolkit::getStringTable()->getString("[retry save]"), 4); + retryButton->visible = false; + addWidget(retryButton); } void CampaignMenuScreen::onAction(Widget *source, Action action, int par1, int par2) { + if (action == SCREEN_DESTROYED) { + // Also persist progress when application quit unwinds the stack and + // suppresses normal continuation callbacks. + if (saveFailed) restorePrevious(); + else if (dirty && !persistence && !GAGCore::ApplicationHost::storageRestoreFailed()) campaign.save(true); + return; + } + if (persistence) { + if (action == TEXT_MODIFIED && source == playerName) playerName->setText(campaign.getPlayerName()); + return; + } + if (fileSelection) { + if (action == TEXT_MODIFIED && source == playerName) playerName->setText(campaign.getPlayerName()); + if ((action == BUTTON_RELEASED || action == BUTTON_SHORTCUT) && source == exitButton) { + fileSelection.reset(); + title->setText(campaign.getName()); + GAGCore::ApplicationHost::importChanged("cancelled"); + } + return; + } + if (action == BUTTON_RELEASED || action == BUTTON_SHORTCUT) { + if (importButton && source == importButton) { + fileSelection = GAGCore::ApplicationHost::selectFile("campaign"); + title->setText(Toolkit::getStringTable()->getString("[select import file]")); + GAGCore::ApplicationHost::importChanged("selecting"); + return; + } + if (exportButton && source == exportButton) { exportProgress(); return; } + if (source == retryButton) { saveProgress(leaveAfterSave); return; } + } if ((action==BUTTON_RELEASED) || (action==BUTTON_SHORTCUT)) { if (par1==EXIT) { - // Player is leaving the campaign menu; if the save fails (read-only - // dir, full disk) we still proceed with the exit but the stderr log - // in Campaign::save records why progress wasn't persisted. - campaign.save(true); - endExecute(par1); + if (saveFailed) { if (restorePrevious()) endExecute(par1); } + else if (!dirty) endExecute(par1); + else saveProgress(true); } else if(par1==START) { CampaignMapEntry* selected = getSelectedMission(); if (selected) { - Engine engine; - int rc_e = engine.initCampaign(selected->getMapFileName(), campaign, selected->getMapName()); - if (rc_e == Engine::EE_NO_ERROR) - { - int rcr = engine.run(); - if(rcr == -1) - endExecute(-1); - } - else if(rc_e == -1) - { - endExecute(-1); - } - repopulateAvailableMissions(); - // Post-mission save persists completion / unlock state. If it - // silently dropped, the player would re-launch a "completed" - // mission or find the next one still locked, so surface the - // failure instead of swallowing it. - if (!campaign.save(true)) - GAGGUI::MessageBox(globalContainer->gfx, "standard", GAGGUI::MB_ONEBUTTON, - Toolkit::getStringTable()->getString("[ERROR_CANT_SAVE_CAMPAIGN]"), - Toolkit::getStringTable()->getString("[ok]")); + // A session may complete a mission even when application quit + // suppresses its normal return callback. + dirty = true; + const auto filename = selected->getMapFileName(), mission = selected->getMapName(); + screens.push(std::make_unique([this, filename, mission](Engine& engine) { + return engine.initCampaignTask(filename, &campaign, mission); + }), [this](Screen& loading, int result) { + if (result == 1) { + screens.push(std::make_unique(screens, static_cast(loading).takeEngine()), + [this](Screen&, int) { + repopulateAvailableMissions(); + dirty = true; + saveProgress(); + }); + } else if (result == 2) { + auto& strings = *Toolkit::getStringTable(); + screens.push(std::make_unique(strings.getString("[ERROR_CANT_LOAD_MAP]"), + std::vector{strings.getString("[ok]")})); + } + }); } } } @@ -79,6 +122,7 @@ void CampaignMenuScreen::onAction(Widget *source, Action action, int par1, int p if(source==playerName) { campaign.setPlayerName(playerName->getText()); + dirty = true; } } else if (action == LIST_ELEMENT_SELECTED) @@ -120,7 +164,107 @@ void CampaignMenuScreen::repopulateAvailableMissions() void CampaignMenuScreen::setNewCampaign() { + dirty = true; campaign.setPlayerName(globalContainer->settings.getUsername()); playerName->setText(globalContainer->settings.getUsername()); } + +void CampaignMenuScreen::saveFailure() +{ + persistence.reset(); saveFailed = true; + retryButton->visible = true; + title->setText(Toolkit::getStringTable()->getString("[campaign save failed]")); + exitButton->setText(Toolkit::getStringTable()->getString("[leave without saving]")); + GAGCore::ApplicationHost::importChanged("failed"); +} +void CampaignMenuScreen::saveProgress(bool leave) +{ + leaveAfterSave = leave; + try { + if (GAGCore::ApplicationHost::storageRestoreFailed()) { saveFailure(); return; } + capturePrevious(); + if (!campaign.save(true)) { saveFailure(); return; } + persistence = GAGCore::ApplicationHost::persistStorage(); + if (!persistence) { saveFailure(); return; } + saveFailed = false; retryButton->visible = false; + title->setText(Toolkit::getStringTable()->getString("[saving to storage]")); + GAGCore::ApplicationHost::importChanged("persisting"); + } catch (const std::exception&) { saveFailure(); } +} +void CampaignMenuScreen::exportProgress() +{ + try { + if (GAGCore::ApplicationHost::exportFile("campaign-progress.campaign", campaign.exportProgress())) return; + } catch (const std::exception&) {} + title->setText(Toolkit::getStringTable()->getString("[export failed]")); +} +void CampaignMenuScreen::onTimer(Uint32) +{ + if (fileSelection) { + const auto state = fileSelection->state(); + if (state == GAGCore::ApplicationHost::FileSelectionState::Pending) return; + if (state == GAGCore::ApplicationHost::FileSelectionState::Selected && campaign.importProgress(fileSelection->takeFile().bytes)) { + fileSelection.reset(); + playerName->setText(campaign.getPlayerName()); + repopulateAvailableMissions(); + dirty = true; + saveProgress(); + } else { + title->setText(Toolkit::getStringTable()->getString(state == GAGCore::ApplicationHost::FileSelectionState::Cancelled ? "[import cancelled]" : "[campaign import failed]")); + GAGCore::ApplicationHost::importChanged(state == GAGCore::ApplicationHost::FileSelectionState::Cancelled ? "cancelled" : "invalid"); + fileSelection.reset(); + } + } + if (persistence) { + const auto state = persistence->state(); + if (state == GAGCore::ApplicationHost::PersistenceState::Pending) return; + if (state == GAGCore::ApplicationHost::PersistenceState::Failed) { saveFailure(); return; } + persistence.reset(); dirty = false; saveFailed = false; + previousCaptured = false; previousFile.clear(); + title->setText(campaign.getName()); + exitButton->setText(Toolkit::getStringTable()->getString("[goto main menu]")); + GAGCore::ApplicationHost::importChanged("succeeded"); + if (leaveAfterSave) endExecute(EXIT); + } +} + +std::string CampaignMenuScreen::progressPath() const +{ + return glob2NameToFilename("games", campaign.getName(), "txt"); +} +bool CampaignMenuScreen::readProgressFile(std::vector& bytes) const +{ + auto& files = *Toolkit::getFileManager(); + if (!files.exists(progressPath())) return false; + GAGCore::BinaryInputStream input(files.openInputStreamBackend(progressPath())); + if (!input.isValid()) throw std::ios_base::failure("Cannot read prior campaign progress"); + input.seekFromEnd(0); + const auto size = input.getPosition(); + if (size > 64u*1024u*1024u) throw std::ios_base::failure("Campaign file exceeds backup limit"); + input.seekFromStart(0); bytes.resize(size); + GAGCore::BinaryInputStream::CheckedReads checked(&input); + input.read(bytes.data(), size, "campaign"); + return true; +} +void CampaignMenuScreen::capturePrevious() +{ + if (previousCaptured) return; + previousFile.clear(); + previousExisted = readProgressFile(previousFile); + previousCaptured = true; +} +bool CampaignMenuScreen::restorePrevious() +{ + if (!previousCaptured) return true; + try { + auto& files = *Toolkit::getFileManager(); + std::vector current; + const bool exists = readProgressFile(current); + if (exists == previousExisted && current == previousFile) return true; + if (!previousExisted) { files.remove(progressPath()); return !files.exists(progressPath()); } + return files.writeAtomically(progressPath(), [this](GAGCore::OutputStream& output) { + output.write(previousFile.data(), previousFile.size(), "campaign rollback"); + }); + } catch (const std::exception&) { return false; } +} diff --git a/src/CampaignMenuScreen.h b/src/CampaignMenuScreen.h index 7ad14f706..dece0fcee 100644 --- a/src/CampaignMenuScreen.h +++ b/src/CampaignMenuScreen.h @@ -4,6 +4,8 @@ #pragma once #include "Campaign.h" +#include +#include #include "Glob2Screen.h" #include "GUIButton.h" #include "GUICheckList.h" @@ -17,9 +19,10 @@ class MapPreview; class CampaignMenuScreen : public Glob2Screen { public: - CampaignMenuScreen(const std::string& name); + CampaignMenuScreen(const std::string& name, GAGGUI::ScreenStack& screens); void onAction(Widget *source, Action action, int par1, int par2); void setNewCampaign(); + void onTimer(Uint32) override; enum { EXIT, @@ -27,11 +30,25 @@ class CampaignMenuScreen : public Glob2Screen }; private: Campaign campaign; + bool dirty = false, saveFailed = false, leaveAfterSave = false; + std::unique_ptr persistence; + std::unique_ptr fileSelection; + TextButton *importButton = nullptr, *exportButton = nullptr, *retryButton = nullptr; + void saveProgress(bool leave = false); + void saveFailure(); + void exportProgress(); + std::vector previousFile; + bool previousCaptured = false, previousExisted = false; + std::string progressPath() const; + bool readProgressFile(std::vector& bytes) const; + void capturePrevious(); + bool restorePrevious(); + GAGGUI::ScreenStack& screens; /// Title of the screen Text* title; /// The exit to menu screen button - Button* exitButton; + TextButton* exitButton; /// The "start mission" button Button* startMission; diff --git a/src/ChooseMapScreen.cpp b/src/ChooseMapScreen.cpp index b66469843..15dd492a8 100644 --- a/src/ChooseMapScreen.cpp +++ b/src/ChooseMapScreen.cpp @@ -2,12 +2,13 @@ // Copyright (C) 2001-2004 Stephane Magnenat & Luc-Olivier de Charrière #include "ChooseMapScreen.h" +#include "FileImport.h" +#include #include "GUIGlob2FileList.h" #include "GUIMapPreview.h" #include "GlobalContainer.h" #include #include -#include #include #include #include @@ -49,6 +50,11 @@ ChooseMapScreen::ChooseMapScreen(const char *directory, const char *extension, b title = new Text(0, 18, ALIGN_FILL, ALIGN_SCREEN_CENTERED, "menu", Toolkit::getStringTable()->getString("[choose game]")); deleteMap = new TextButton(250, 360, 180, 40, ALIGN_SCREEN_CENTERED, ALIGN_SCREEN_CENTERED, "menu", Toolkit::getStringTable()->getString("[delete]"), DELETEGAME); addWidget(deleteMap); + if (GAGCore::ApplicationHost::canExportFiles()) { + exportButton = new TextButton(250, 300, 180, 40, ALIGN_SCREEN_CENTERED, ALIGN_SCREEN_CENTERED, + "menu", Toolkit::getStringTable()->getString("[export file]"), 5); + addWidget(exportButton); + } } else { @@ -81,6 +87,16 @@ ChooseMapScreen::ChooseMapScreen(const char *directory, const char *extension, b alternateFileList->visible=false; } + if (GAGCore::ApplicationHost::canImportFiles()) { + importButton = new TextButton(20, 470, 85, 30, ALIGN_SCREEN_CENTERED, ALIGN_SCREEN_CENTERED, + "standard", Toolkit::getStringTable()->getString("[import file]"), 6); + addWidget(importButton); + if (!exportButton) { + exportButton = new TextButton(115, 470, 85, 30, ALIGN_SCREEN_CENTERED, ALIGN_SCREEN_CENTERED, + "standard", Toolkit::getStringTable()->getString("[export file]"), 5); + addWidget(exportButton); + } + } validMapSelected = false; selectedType = NONE; } @@ -89,11 +105,48 @@ ChooseMapScreen::~ChooseMapScreen() { } +bool ChooseMapScreen::importBusy() const +{ + return fileSelection || (fileImport && (fileImport->state() == FileImport::State::Validating || fileImport->state() == FileImport::State::Persisting)); +} + void ChooseMapScreen::onAction(Widget *source, Action action, int par1, int par2) { + const bool button = action == BUTTON_RELEASED || action == BUTTON_SHORTCUT; + if (importBusy()) { + if (button && source == cancel && (!fileImport || fileImport->state() != FileImport::State::Persisting)) { + fileSelection.reset(); fileImport.reset(); + GAGCore::ApplicationHost::importChanged("cancelled"); + title->setText(Toolkit::getStringTable()->getString("[import cancelled]")); + } + return; + } + if (button && importButton && source == importButton) { + if (fileImport && fileImport->canRetry()) { fileImport->retryPersistence(); updateImportStatus(); return; } + fileImport.reset(); + importExtension = activeType() == MAP ? "map" : activeType() == REPLAY ? "replay" : "game"; + fileSelection = GAGCore::ApplicationHost::selectFile(importExtension); + GAGCore::ApplicationHost::importChanged("selecting"); + title->setText(Toolkit::getStringTable()->getString("[select import file]")); + return; + } + if (button && source == exportButton && fileImport && fileImport->canRetry()) { + if (!fileImport->exportFile()) title->setText(Toolkit::getStringTable()->getString("[export failed]")); + return; + } if (action == LIST_ELEMENT_SELECTED) { Glob2FileList* active = activeFileList(); + // Invalidate the old selection before attempting any fallible file reads. + validMapSelected = false; + selectedType = NONE; + mapDate->setText(""); + mapVersion->setText(""); + mapInfo->setText(""); + mapSize->setText(""); + mapName->setText(""); + mapPreview->setMapThumbnail(MapThumbnail()); + title->setText(Toolkit::getStringTable()->getString(type1 == MAP ? "[choose map]" : "[choose game]")); if (active->selection()) { std::string mapFileName = active->listToFile(active->getText(par1).c_str()); @@ -131,26 +184,25 @@ void ChooseMapScreen::onAction(Widget *source, Action action, int par1, int par2 } catch (std::exception &e) { - // Show error message - GAGGUI::MessageBox(globalContainer->gfx, "standard", GAGGUI::MB_ONEBUTTON, Toolkit::getStringTable()->getString("[ERROR_CANT_LOAD_MAP]"), Toolkit::getStringTable()->getString("[ok]")); - + std::cerr << "ChooseMapScreen: " << e.what() << std::endl; validMapSelected = false; + selectedType = NONE; + } + if (!validMapSelected) + { + mapPreview->setMapThumbnail(MapThumbnail()); + title->setText(Toolkit::getStringTable()->getString("[Damaged Map]")); } - } - else - { - mapDate->setText(""); - mapVersion->setText(""); - mapInfo->setText(""); - mapSize->setText(""); - mapName->setText(""); - mapPreview->setMapThumbnail(""); - validMapSelected = false; } } else if ((action == BUTTON_RELEASED) || (action == BUTTON_SHORTCUT)) { - if (source == ok) + if (exportButton && source == exportButton) { + auto* active = activeFileList(); + if (active->selection() && !GAGCore::ApplicationHost::exportLocalFile(active->listToFile(active->get()))) + title->setText(Toolkit::getStringTable()->getString("[export failed]")); + } + else if (source == ok) { // we accept only if a valid map is selected if (validMapSelected) @@ -184,6 +236,46 @@ void ChooseMapScreen::onAction(Widget *source, Action action, int par1, int par2 } +void ChooseMapScreen::updateImportStatus() +{ + const auto state = fileImport->state(); + GAGCore::ApplicationHost::importChanged(state == FileImport::State::Validating ? "validating" : + state == FileImport::State::Persisting ? "persisting" : state == FileImport::State::Succeeded ? "succeeded" : + fileImport->canRetry() ? "failed" : "invalid"); + const char* message = state == FileImport::State::Validating ? "[validating import]" : + state == FileImport::State::Persisting ? "[saving to storage]" : + state == FileImport::State::Succeeded ? "[import succeeded]" : + fileImport->canRetry() ? "[import persistence failed]" : "[import failed]"; + title->setText(Toolkit::getStringTable()->getString(message)); +} + +void ChooseMapScreen::onTimer(Uint32) +{ + if (fileSelection) { + const auto state = fileSelection->state(); + if (state == GAGCore::ApplicationHost::FileSelectionState::Pending) return; + if (state == GAGCore::ApplicationHost::FileSelectionState::Selected) { + try { fileImport = std::make_unique(fileSelection->takeFile(), importExtension); } + catch (const std::exception&) { title->setText(Toolkit::getStringTable()->getString("[import failed]")); } + } else { + GAGCore::ApplicationHost::importChanged(state == GAGCore::ApplicationHost::FileSelectionState::Cancelled ? "cancelled" : "invalid"); + title->setText(Toolkit::getStringTable()->getString(state == GAGCore::ApplicationHost::FileSelectionState::Cancelled ? "[import cancelled]" : "[import failed]")); + } + fileSelection.reset(); + } + if (!fileImport) return; + fileImport->advance(); + updateImportStatus(); + if (fileImport->state() == FileImport::State::Succeeded) { + auto* active = activeFileList(); + active->generateList(); + const auto name = glob2FilenameToName(fileImport->path()); + for (unsigned i = 0; i < active->getCount(); ++i) + if (active->getText(i) == name) { active->setSelection(i); active->selectionChanged(); break; } + fileImport.reset(); + } +} + void ChooseMapScreen::updateMapInformation() { // update map name & info diff --git a/src/ChooseMapScreen.h b/src/ChooseMapScreen.h index 25d18b998..0da0e2485 100644 --- a/src/ChooseMapScreen.h +++ b/src/ChooseMapScreen.h @@ -8,6 +8,9 @@ #include "Glob2Screen.h" #include #include +#include +#include +class FileImport; namespace GAGGUI { @@ -31,6 +34,7 @@ class ChooseMapScreen : public Glob2Screen //! Destructor virtual ~ChooseMapScreen(); virtual void onAction(Widget *source, Action action, int par1, int par2); + void onTimer(Uint32) override; /// Returns the mapHeader of the map that is currently selected MapHeader& getMapHeader(); @@ -63,6 +67,7 @@ class ChooseMapScreen : public Glob2Screen LoadableType getSelectedType(); protected: + bool importBusy() const; /// Handle called when a valid map has been selected. /// This is to be overwritten by the derived class. virtual void validMapSelectedhandler(void) { } @@ -92,6 +97,12 @@ class ChooseMapScreen : public Glob2Screen Button *deleteMap; //! the switch type button TextButton *switchType = nullptr; + TextButton *exportButton = nullptr; + TextButton *importButton = nullptr; + std::unique_ptr fileSelection; + std::unique_ptr fileImport; + std::string importExtension; + void updateImportStatus(); //! The list of maps or games Glob2FileList *fileList; //! The alternate list of maps or games diff --git a/src/CustomGameScreen.cpp b/src/CustomGameScreen.cpp index 99e839b17..084690417 100644 --- a/src/CustomGameScreen.cpp +++ b/src/CustomGameScreen.cpp @@ -208,7 +208,7 @@ std::string colorName(Color c) } } // namespace -CustomGameScreen::CustomGameScreen() : Glob2TabScreen(false, true) +CustomGameScreen::CustomGameScreen(GAGGUI::ScreenStack& screens) : Glob2TabScreen(false, true), screens(screens) { gfx = globalContainer->gfx; username = globalContainer->settings.getUsername(); @@ -286,6 +286,19 @@ CustomGameScreen::~CustomGameScreen() std::filesystem::remove_all(std::filesystem::path(snapshot).parent_path(), error); } } +std::shared_ptr CustomGameScreen::releaseSnapshot() +{ + if (snapshot.empty()) + return nullptr; + const auto directory = std::filesystem::path(snapshot).parent_path(); + snapshot.clear(); + return std::shared_ptr(nullptr, + [directory](void *) + { + std::error_code error; + std::filesystem::remove_all(directory, error); + }); +} void CustomGameScreen::savePreferences() { CustomGamePreferences preferences; @@ -301,15 +314,6 @@ void CustomGameScreen::savePreferences() else preferencesRetryAt = SDL_GetTicks() + 5000; } -int CustomGameScreen::choose(const std::string &title, const std::vector &values, - int selected, bool profiles, const std::vector &enabled) -{ - CustomGameChoiceScreen screen(title, values, selected, profiles, enabled); - int result = screen.execute(globalContainer->gfx, 40); - if (result == QUIT_APPLICATION) - endExecute(QUIT_APPLICATION); - return result; -} void CustomGameScreen::onGroupActivated(int group) { currentTab = group; } void CustomGameScreen::invalidate() { @@ -552,10 +556,17 @@ void CustomGameScreen::showAIProfile(int colony) std::vector labels; for (int i : AINames::selectionOrder()) labels.push_back(AINames::getAISelectorText(i)); - int result = choose(colonyLabel(colony) + " / " + tr("AI strategy & counterplay"), labels, - AINames::selectionIndex(setup.colonies[colony].ai), true); - if (result >= 0) - setup.colonies[colony].ai = (AI::ImplementationID)AINames::selectionOrder()[result]; + // CustomGameChoiceScreen must be pushed, not blocking-executed: the + // browser host has no Asyncify and ApplicationHost::wait is a hard + // error there (docs/browser/adr-003-screen-execution.md). + screens.push(std::make_unique( + colonyLabel(colony) + " / " + tr("AI strategy & counterplay"), labels, + AINames::selectionIndex(setup.colonies[colony].ai), true, std::vector{}), + [this, colony](GAGGUI::Screen &, int result) + { + if (result >= 0) + setup.colonies[colony].ai = (AI::ImplementationID)AINames::selectionOrder()[result]; + }); } void CustomGameScreen::renderLobby() diff --git a/src/CustomGameScreen.h b/src/CustomGameScreen.h index 8158bebdc..b4a720b24 100644 --- a/src/CustomGameScreen.h +++ b/src/CustomGameScreen.h @@ -3,6 +3,7 @@ #include "CustomGameSetup.h" #include "Glob2Screen.h" #include "MapHeader.h" +#include #include #include class LobbyControls; @@ -32,7 +33,7 @@ class CustomGameScreen : public Glob2TabScreen OK = 1, CANCEL = 2 }; - CustomGameScreen(); + explicit CustomGameScreen(GAGGUI::ScreenStack& screens); ~CustomGameScreen() override; void onAction(Widget *, Action, int, int) override; void onGroupActivated(int) override; @@ -43,6 +44,9 @@ class CustomGameScreen : public Glob2TabScreen GameHeader &getGameHeader(); int getSelectedColor(int) { return setup.humanColony().value_or(0); } const std::string &sourceFile() const { return source; } + // Hands over a generated map, which this screen would otherwise delete when + // destroyed. Releasing the returned owner removes it. + std::shared_ptr releaseSnapshot(); void launchFailed() { message = "Could not launch this map. Your setup is retained; try again."; @@ -51,6 +55,7 @@ class CustomGameScreen : public Glob2TabScreen private: friend struct CustomGameSetupHarness; + GAGGUI::ScreenStack& screens; CustomGameSetup setup; MapHeader mapHeader; GameHeader gameHeader; @@ -79,8 +84,6 @@ class CustomGameScreen : public Glob2TabScreen void listMaps(); bool loadMap(const std::string &path); bool generateMap(); - int choose(const std::string &, const std::vector &, int, bool profiles = false, - const std::vector &enabled = {}); void invalidate(); std::string colonyLabel(int) const; }; diff --git a/src/EditorGenerateScreen.cpp b/src/EditorGenerateScreen.cpp new file mode 100644 index 000000000..310477046 --- /dev/null +++ b/src/EditorGenerateScreen.cpp @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "EditorGenerateScreen.h" +#include "MapEdit.h" +#include "MapGenerator.h" +namespace { +GAGCore::CooperativeTask generate(MapEdit& editor, MapGenerationDescriptor descriptor, Uint32 seed) +{ + MapGenerator generator; + if (!(co_await generator.generateMapTask(editor.game, descriptor, seed))) co_return false; + editor.mapHasBeenModified(); + editor.regenerateGameHeader(); + co_return true; +} +} +EditorGenerateScreen::EditorGenerateScreen(MapGenerationDescriptor descriptor, Uint32 seed, GAGCore::CooperativeSlice slice) + : EditorLoadScreen([descriptor, seed](MapEdit& editor) { return generate(editor, descriptor, seed); }, "[Generating map]", std::move(slice)) {} diff --git a/src/EditorGenerateScreen.h b/src/EditorGenerateScreen.h new file mode 100644 index 000000000..c4168a847 --- /dev/null +++ b/src/EditorGenerateScreen.h @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once +#include "EditorLoadScreen.h" +#include "MapGenerationDescriptor.h" +class EditorGenerateScreen : public EditorLoadScreen +{ +public: + EditorGenerateScreen(MapGenerationDescriptor descriptor, Uint32 seed, GAGCore::CooperativeSlice slice = GAGCore::CooperativeSlice()); +}; diff --git a/src/EditorLoadScreen.cpp b/src/EditorLoadScreen.cpp new file mode 100644 index 000000000..0ba7df6c1 --- /dev/null +++ b/src/EditorLoadScreen.cpp @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "EditorLoadScreen.h" +#include "MapEdit.h" +#include "Utilities.h" +#include +#include +#include +#include +#include +EditorLoadScreen::EditorLoadScreen(const std::string& filename, GAGCore::CooperativeSlice slice) + : EditorLoadScreen([filename](MapEdit& editor) { return editor.loadTask(filename); }, "[Loading headers]", std::move(slice)) {} +EditorLoadScreen::EditorLoadScreen(Initializer initialize, const char* caption, GAGCore::CooperativeSlice slice) + : slice(std::move(slice)), previousRng(getSyncRandState()), editor(std::make_unique()) +{ + auto& strings = *GAGCore::Toolkit::getStringTable(); + status = new GAGGUI::Text(0, 180, ALIGN_FILL, ALIGN_SCREEN_CENTERED, "standard", + strings.getString(caption)); + addWidget(status); + addWidget(new GAGGUI::TextButton(230, 340, 180, 40, ALIGN_SCREEN_CENTERED, + ALIGN_SCREEN_CENTERED, "menu", strings.getString("[Cancel]"), 0, 27)); + task.emplace(initialize(*editor)); +} +EditorLoadScreen::~EditorLoadScreen() +{ + task.reset(); + editor.reset(); + if (!accepted) setSyncRandState(previousRng); +} +std::unique_ptr EditorLoadScreen::takeEditor() +{ + if (!task->result()) throw std::logic_error("Cannot accept failed editor load"); + accepted = true; + task.reset(); + return std::move(editor); +} +void EditorLoadScreen::onTimer(Uint32) +{ + try { + if (slice.advance(*task)) { endExecute(task->result() ? 1 : 2); return; } + status->setText(GAGCore::Toolkit::getStringTable()->getString(task->stage())); + } catch (const std::exception& error) { + std::cerr << "Editor preparation failed: " << error.what() << '\n'; + endExecute(2); + } +} +void EditorLoadScreen::onAction(GAGGUI::Widget*, GAGGUI::Action action, int, int) +{ + if (action == GAGGUI::BUTTON_RELEASED || action == GAGGUI::BUTTON_SHORTCUT) endExecute(0); +} diff --git a/src/EditorLoadScreen.h b/src/EditorLoadScreen.h new file mode 100644 index 000000000..ce7de39a4 --- /dev/null +++ b/src/EditorLoadScreen.h @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once +#include "Glob2Screen.h" +#include +#include +#include +#include +class MapEdit; +namespace GAGGUI { class Text; } +class EditorLoadScreen : public Glob2Screen +{ +public: + explicit EditorLoadScreen(const std::string& filename, GAGCore::CooperativeSlice slice = GAGCore::CooperativeSlice()); + ~EditorLoadScreen() override; + std::unique_ptr takeEditor(); + void onTimer(Uint32) override; + void onAction(GAGGUI::Widget*, GAGGUI::Action, int, int) override; + Uint32 executionDelay(Uint32, Uint32) override { return 1; } +protected: + using Initializer = std::function; + EditorLoadScreen(Initializer initialize, const char* caption, GAGCore::CooperativeSlice slice = GAGCore::CooperativeSlice()); +private: + GAGCore::CooperativeSlice slice; + std::string previousRng; + std::unique_ptr editor; + std::optional task; + GAGGUI::Text* status; + bool accepted = false; +}; diff --git a/src/EditorMainMenu.cpp b/src/EditorMainMenu.cpp index 22d591942..6a9b39700 100644 --- a/src/EditorMainMenu.cpp +++ b/src/EditorMainMenu.cpp @@ -6,11 +6,15 @@ #include "CampaignSelectorScreen.h" #include "ChooseMapScreen.h" #include "EditorMainMenu.h" -#include "FrontendTheme.h" #include "GlobalContainer.h" #include #include #include "MapEdit.h" +#include "MapEditorScreen.h" +#include "EditorLoadScreen.h" +#include "EditorGenerateScreen.h" +#include +#include "MessageScreen.h" #include "MapGenerator.h" #include "NewMapScreen.h" #include @@ -21,7 +25,7 @@ using namespace GAGGUI; -EditorMainMenu::EditorMainMenu() +EditorMainMenu::EditorMainMenu(GAGGUI::ScreenStack& screens) : screens(screens) { addWidget(new TextButton(0, 70, 300, 40, ALIGN_CENTERED, ALIGN_SCREEN_CENTERED, "menu", Toolkit::getStringTable()->getString("[new map]"), NEWMAP, 13)); addWidget(new TextButton(0, 130, 300, 40, ALIGN_CENTERED, ALIGN_SCREEN_CENTERED, "menu", Toolkit::getStringTable()->getString("[load map]"), LOADMAP)); @@ -31,98 +35,52 @@ EditorMainMenu::EditorMainMenu() addWidget(new Text(0, 18, ALIGN_FILL, ALIGN_SCREEN_CENTERED, "menu", Toolkit::getStringTable()->getString("[editor]"))); } -void EditorMainMenu::onAction(Widget *source, Action action, int par1, int par2) +void EditorMainMenu::newMap() { - if ((action==BUTTON_RELEASED) || (action==BUTTON_SHORTCUT)) - { - if (par1==NEWMAP) - { - bool retryNewMapScreen=true; - while (retryNewMapScreen) - { - NewMapScreen newMapScreen; - int rc_nms = newMapScreen.execute(globalContainer->gfx, 40); - if (rc_nms==NewMapScreen::OK) - { - MapEdit mapEdit; - MapGenerator generator; - setRandomSyncRandSeed(); - if (generator.generateMap(mapEdit.game, newMapScreen.descriptor)) - { - mapEdit.mapHasBeenModified(); // make all map as modified by default - mapEdit.regenerateGameHeader(); - if (mapEdit.run()==-1) - endExecute(-1); - retryNewMapScreen=false; - } - else - { - //TODO: popup a widow to explain that the generateMap() has failed. - retryNewMapScreen=true; - } - } - else if(rc_nms == -1) - { - endExecute(-1); - retryNewMapScreen=false; - } - else - { - retryNewMapScreen=false; - } - } - } - else if (par1==LOADMAP) - { - ChooseMapScreen chooseMapScreen("maps", "map", false, "games", "game", false); - int rc=chooseMapScreen.execute(globalContainer->gfx, 40); - if (rc==ChooseMapScreen::OK) - { - MapEdit mapEdit; - std::string filename = chooseMapScreen.getMapHeader().getFileName(); - mapEdit.load(filename.c_str()); - if (mapEdit.run()==-1) - endExecute(-1); - } - else if (rc==-1) - endExecute(-1); - } - else if (par1==NEWCAMPAIGN) - { - FrontendScope editor(false); - CampaignEditor ce(""); - int rc=ce.execute(globalContainer->gfx, 40); - if(rc == -1) - endExecute(-1); - - } - else if (par1==LOADCAMPAIGN) - { - CampaignSelectorScreen css; - int rc_css=css.execute(globalContainer->gfx, 40); - if(rc_css==CampaignSelectorScreen::OK) - { - FrontendScope editor(false); - CampaignEditor ce(css.getCampaignName()); - int rc_ce=ce.execute(globalContainer->gfx, 40); - if(rc_ce == -1) - { - endExecute(-1); - } - } - else if(rc_css==CampaignSelectorScreen::CANCEL) - { - } - else if(rc_css == -1) - { - endExecute(-1); - } - } - else if(par1 == CANCEL) - { - endExecute(CANCEL); - } - } + screens.push(std::make_unique(), [this](Screen& screen, int result) { + if (result != NewMapScreen::OK) return; + screens.push(std::make_unique(static_cast(screen).descriptor, + static_cast(std::time(nullptr))), [this](Screen& generated, int result) { + if (result == 1) + screens.push(std::make_unique(screens, static_cast(generated).takeEditor())); + else if (result == 2) { + auto& strings = *Toolkit::getStringTable(); + screens.push(std::make_unique(strings.getString("[ERROR_CANT_GENERATE_MAP]"), + std::vector{strings.getString("[ok]")}), [this](Screen&, int) { newMap(); }); + } else newMap(); + }); + }); } - +void EditorMainMenu::onAction(Widget*, Action action, int choice, int) +{ + if (action != BUTTON_RELEASED && action != BUTTON_SHORTCUT) return; + switch (choice) { + case NEWMAP: newMap(); break; + case LOADMAP: + screens.push(std::make_unique("maps", "map", false, "games", "game", false), + [this](Screen& screen, int result) { + if (result != ChooseMapScreen::OK) return; + const auto filename = static_cast(screen).getMapHeader().getFileName(); + screens.push(std::make_unique(filename), [this](Screen& loading, int result) { + if (result == 1) + screens.push(std::make_unique(screens, + static_cast(loading).takeEditor())); + else if (result == 2) { + auto& strings = *Toolkit::getStringTable(); + screens.push(std::make_unique(strings.getString("[ERROR_CANT_LOAD_MAP]"), + std::vector{strings.getString("[ok]")})); + } + }); + }); + break; + case NEWCAMPAIGN: screens.push(std::make_unique("", screens)); break; + case LOADCAMPAIGN: + screens.push(std::make_unique(), [this](Screen& screen, int result) { + if (result == CampaignSelectorScreen::OK) + screens.push(std::make_unique(static_cast(screen).getCampaignName(), screens)); + }); + break; + case CANCEL: endExecute(CANCEL); break; + } +} diff --git a/src/EditorMainMenu.h b/src/EditorMainMenu.h index 2d6e309ef..7fc37aaa0 100644 --- a/src/EditorMainMenu.h +++ b/src/EditorMainMenu.h @@ -5,6 +5,7 @@ #pragma once #include "Glob2Screen.h" +#include namespace GAGGUI { @@ -25,11 +26,14 @@ class EditorMainMenu : public Glob2Screen public: //! Constructor - EditorMainMenu(); + explicit EditorMainMenu(GAGGUI::ScreenStack& screens); //! Destructor virtual ~EditorMainMenu() { } //! Action handler void onAction(Widget *source, Action action, int par1, int par2); +private: + GAGGUI::ScreenStack& screens; + void newMap(); }; diff --git a/src/EndGameScreen.cpp b/src/EndGameScreen.cpp index 8a480165c..989ee4cd4 100644 --- a/src/EndGameScreen.cpp +++ b/src/EndGameScreen.cpp @@ -2,6 +2,7 @@ // Copyright (C) 2001-2004 Stephane Magnenat & Luc-Olivier de Charrière // Copyright (C) 2006 Bradley Arsenault +#include #include "EndGameScreen.h" #include #include @@ -12,6 +13,7 @@ #include #include #include +#include #include "GlobalContainer.h" #include "Team.h" #include "TeamDisplay.h" @@ -524,48 +526,49 @@ std::string replayFilenameToName(const std::string& fullfilename) return filename; } +EndGameScreen::~EndGameScreen() = default; + void EndGameScreen::saveReplay(const char *dir, const char *ext) { - // create dialog box - LoadSaveScreen *loadSaveScreen=new LoadSaveScreen(dir, ext, false, std::string(Toolkit::getStringTable()->getString("[save replay]")), "", replayFilenameToName, glob2NameToFilename); - loadSaveScreen->dispatchPaint(); + replaySave = std::make_unique(dir, ext, false, + Toolkit::getStringTable()->getString("[save replay]"), "", + replayFilenameToName, glob2NameToFilename); + GAGCore::ApplicationHost::screenChanged(typeid(*replaySave).name()); +} - // save screen - globalContainer->gfx->setClipRect(); - - DrawableSurface *background = new DrawableSurface(globalContainer->gfx->getW(), globalContainer->gfx->getH()); - background->drawSurface(0, 0, globalContainer->gfx); +void EndGameScreen::updateExecution(Uint32 tick) +{ + if (!replaySave) { Glob2Screen::updateExecution(tick); return; } + replaySave->dispatchTimer(tick); + if (replaySave->pollPersistence() || replaySave->endValue == LoadSaveScreen::CANCEL) { + replaySave.reset(); + GAGCore::ApplicationHost::screenChanged(typeid(*this).name()); + } else if (replaySave->endValue == LoadSaveScreen::OK) { + if (!globalContainer->replayWriter || + !globalContainer->replayWriter->write(replaySave->getFileName())) { + replaySave->showSaveFailure(); + } else replaySave->beginPersistence(GAGCore::ApplicationHost::persistStorage()); + } +} - SDL_Event event; - while(loadSaveScreen->endValue<0) - { - Uint64 time = SDL_GetTicks64(); - while (GAGCore::GraphicContext::pollEvent(&event)) - { - GAGCore::GraphicContext::translateMouseEvent(&event); - loadSaveScreen->translateAndProcessEvent(&event); - } - loadSaveScreen->dispatchPaint(); - - if (Style::style->usesThemeTextColor()) dispatchPaint(false); - else globalContainer->gfx->drawSurface(0, 0, background); - globalContainer->gfx->drawSurface(loadSaveScreen->decX, loadSaveScreen->decY, loadSaveScreen->getSurface()); - globalContainer->gfx->nextFrame(); - Uint64 ntime = SDL_GetTicks64(); - SDL_Delay(std::max(0, 40ll - static_cast(ntime) + static_cast(time))); - } +void EndGameScreen::handleExecutionEvent(SDL_Event event) +{ + if (!replaySave) { Glob2Screen::handleExecutionEvent(event); return; } + GAGCore::GraphicContext::translateMouseEvent(&event); + replaySave->translateAndProcessEvent(&event); +} - if (loadSaveScreen->endValue==0) - { - // Write the replay to the file - assert(globalContainer->replayWriter); - assert(globalContainer->replayWriter->isValid()); - globalContainer->replayWriter->write(loadSaveScreen->getFileName()); - } +void EndGameScreen::drawExecution() +{ + Glob2Screen::drawExecution(); + if (replaySave) { + replaySave->dispatchPaint(); + gfx->drawSurface(replaySave->decX, replaySave->decY, replaySave->getSurface()); + } +} - // clean up - delete loadSaveScreen; - - // destroy temporary surface - delete background; +void EndGameScreen::viewportResized(int oldWidth, int oldHeight, int width, int height) +{ + Glob2Screen::viewportResized(oldWidth, oldHeight, width, height); + if (replaySave) replaySave->viewportResized(oldWidth, oldHeight, width, height); } diff --git a/src/EndGameScreen.h b/src/EndGameScreen.h index 2a5fbdd1f..503e0a9ef 100644 --- a/src/EndGameScreen.h +++ b/src/EndGameScreen.h @@ -6,6 +6,9 @@ #include "GameGUI.h" #include "Glob2Screen.h" +#include "FrontendTheme.h" + +class LoadSaveScreen; namespace GAGGUI { @@ -94,10 +97,15 @@ class EndGameScreen : public Glob2Screen public: EndGameScreen(GameGUI *gui); - virtual ~EndGameScreen() { } + ~EndGameScreen() override; + void updateExecution(Uint32 tick) override; + void handleExecutionEvent(SDL_Event event) override; + void drawExecution() override; + void viewportResized(int oldWidth, int oldHeight, int width, int height) override; virtual void onAction(Widget *source, Action action, int par1, int par2); private: + FrontendScope theme{true}; + std::unique_ptr replaySave; void saveReplay(const char *dir, const char *ext); }; - diff --git a/src/Engine.cpp b/src/Engine.cpp index 28607ce8b..568002991 100644 --- a/src/Engine.cpp +++ b/src/Engine.cpp @@ -6,7 +6,6 @@ #include "EndGameScreen.h" #include "Engine.h" -#include "FrontendTheme.h" #include "EngineTiming.h" #include "GlobalContainer.h" #include "ReplayWriter.h" @@ -25,6 +24,7 @@ Engine::~Engine() // In-game options may have persisted the temporary match speed. globalContainer->settings.save(); } + if (multiplayer) multiplayer->setNetEngine(nullptr); // Finalize the replay of the session this Engine ran, if any. // initGame allocated the writer; destroying it (ReplayWriter::finish) // writes the NullOrder terminator and flushes the replay file. This must @@ -33,10 +33,8 @@ Engine::~Engine() globalContainer->replayWriter.reset(); } -int Engine::run(void) +void Engine::prepareRun() { - FrontendScope gameplay(false); - bool doRunOnceAgain=true; if (globalContainer->runNoX) { assert(globalContainer->mix==nullptr); @@ -78,35 +76,29 @@ int Engine::run(void) globalContainer->gfx->cursorManager.setDrawColor(gui.getLocalTeam()->color); } - while (doRunOnceAgain) - { - runOneGameSession(doRunOnceAgain); - } - - if (gui.exitGlobCompletely) - return -1; // There is no bypass for the "close window button" - - if (globalContainer->runNoX || globalContainer->automaticEndingGame) - { - if(!globalContainer->runNoX) - globalContainer->gfx->cursorManager.setDefaultColor(); - return -1; - } - else - { - // Restart menu music - assert(globalContainer->mix); - globalContainer->mix->setNextTrack(MusicTrack::Menu, true); +} - // Display End Game Screen - FrontendScope results(true); - EndGameScreen endGameScreen(&gui); - int result = endGameScreen.execute(globalContainer->gfx, GAME_TICK_MS); +std::unique_ptr Engine::endRunScreen() +{ + if (gui.exitGlobCompletely || globalContainer->runNoX || globalContainer->automaticEndingGame) + return {}; + assert(globalContainer->mix); + globalContainer->mix->setNextTrack(MusicTrack::Menu, true); + return std::make_unique(&gui); +} - // Return to default color - globalContainer->gfx->cursorManager.setDefaultColor(); +void Engine::restoreCursor() +{ + if (!globalContainer->runNoX) globalContainer->gfx->cursorManager.setDefaultColor(); +} - // Return - return (result == -1) ? -1 : EE_NO_ERROR; - } +int Engine::run(void) +{ + prepareRun(); + bool doRunOnceAgain = true; + while (doRunOnceAgain) runOneGameSession(doRunOnceAgain); + auto endScreen = endRunScreen(); + const int result = endScreen ? endScreen->execute(globalContainer->gfx, GAME_TICK_MS) : -1; + restoreCursor(); + return result == -1 ? -1 : EE_NO_ERROR; } diff --git a/src/Engine.h b/src/Engine.h index d69cd44eb..8dbd7d006 100644 --- a/src/Engine.h +++ b/src/Engine.h @@ -51,17 +51,24 @@ class Engine /// is a lone map that runs with campaign semantics int initCampaign(const std::string &mapName); - /// Displays the CustomMap dialogue, and initiates a game from the settings it receives - int initCustom(); + /// Initialize a custom game from the selected map, players and local team. + int initCustom(MapHeader& map, GameHeader& players, int localTeam, const std::string& sourceFileName = {}); /// Initiate a custom game from the provided game, without adjusting settings from the user int initCustom(const std::string &gameName); + GAGCore::CooperativeTask initCustomTask(MapHeader map, GameHeader players, int localTeam, int speed = -1, std::string sourceFileName = {}); + GAGCore::CooperativeTask initCustomTask(std::string filename); + GAGCore::CooperativeTask initCampaignTask(std::string filename, Campaign* campaign = nullptr, std::string mission = {}); + GAGCore::CooperativeTask loadReplayTask(std::string filename); + void cancelInitialization(); + void suspendInput() { gui.suspendInput(); } + void viewportResized(int oldWidth, int oldHeight, int width, int height) { gui.viewportResized(oldWidth, oldHeight, width, height); } + - /// Show the load/save dialog, and use initCustom(gameName) to load the game - int initLoadGame(); /// Initiate a game with the given MultiplayerGame int initMultiplayer(std::shared_ptr multiplayerGame, std::shared_ptr client, int localPlayer); + GAGCore::CooperativeTask initMultiplayerTask(std::shared_ptr multiplayerGame, std::shared_ptr client, int localPlayer); //! This function creates a game with a random map and random AI for every team void createRandomGame(); @@ -78,6 +85,24 @@ class Engine //! Run game. A valid gui and netGame must exists int run(); + void prepareRun(); + std::unique_ptr endRunScreen(); + void restoreCursor(); + + // Incremental session API. Requires an initialized game; the host owns + // scheduling. GUI input and modal flows remain transitional legacy code. + void beginSession(Uint64 now); + bool stepSession(Uint64 now); + bool stepSession(Uint64 now, const std::vector& events); + void drawSession(); + Uint32 sessionDelay(Uint64 now); + struct PendingLoad { std::string filename; bool replay; }; + // Finalize without loading another game or entering a UI loop. The host + // schedules a returned request, or presents the end screen when absent. + std::optional finishSessionForHost(); + // Synchronous adapter for native command-line/headless hosts. + bool finishSession(); + //! Type of error the engine init function can return enum EngineError @@ -105,6 +130,7 @@ class Engine /// GameGUI data in the file, such as viewport position and localTeam. This is /// needed for when your loading a save game over the internet int initGame(MapHeader& mapHeader, GameHeader& gameHeader, bool setGameHeader=true, bool ignoreGUIData=false, bool saveAI=false, const std::string& sourceFileName=std::string()); + GAGCore::CooperativeTask initGameTask(MapHeader mapHeader, GameHeader gameHeader, bool setGameHeader=true, bool ignoreGUIData=false, bool saveAI=false, std::string sourceFileName=std::string()); /// Reset globalContainer's replay state (replaying flag, replay file name, /// replay reader) so the next game session starts as a normal game. @@ -153,12 +179,12 @@ class Engine bool adjustableGameSpeed; ///< Speed presets apply; live network games stay at GAME_TICK_MS }; - void updateTickSpeedAndDrawCadence(MainLoopState& st); + void updateTickSpeedAndDrawCadence(MainLoopState& st, Uint64 now); /// Headless / scripted-test polling: under --nox automaticEndingGame, flip /// gui.isRunning=false once a local end condition fires. Records /// automaticGameEndTick. - void pollAutomaticEndingConditions(); + void pollAutomaticEndingConditions(Uint64 now); /// Push this tick's local + AI orders into the net layer and (if the /// previous tick committed) call advanceStep + write the checksum sidecar. @@ -170,7 +196,10 @@ class Engine /// game.syncStep. Called only from inside the !hardPause branch. void executeOrdersAndStep(bool readyNow); - void drawAndPaceFrame(MainLoopState& st, bool readyNow); + void drawFrame(MainLoopState& st); + std::optional session; + int sessionEndingTarget = 0; + std::vector sessionInput; /// If the GUI requested a clean exit, drain remaining local orders and /// flush the net layer. Returns true if the engine loop should break. @@ -192,14 +221,9 @@ class Engine /// Close cross-replay sinks (sidecar, dataset) and tear down the network /// + multiplayer state. The Engine itself stays alive for a possible - /// reload (see prepareNextGameSession). + /// reload (see finishSessionForHost). void teardownSession(); - /// Decide whether run() should loop back into runOneGameSession (a - /// load-game request was armed in the GUI) or return to the menu. Always - /// clears toLoadGameFileName so a follow-up pass doesn't re-trigger it. - void prepareNextGameSession(bool& doRunOnceAgain); - //! The GUI, contains the whole game also GameGUI gui; //! The netGame, take care of order queuing and dispatching diff --git a/src/EngineInit.cpp b/src/EngineInit.cpp index 770c010eb..ad25a1977 100644 --- a/src/EngineInit.cpp +++ b/src/EngineInit.cpp @@ -9,8 +9,6 @@ #include "AINames.h" #include "ChecksumSidecar.h" -#include "CustomGameScreen.h" -#include "ChooseMapScreen.h" #include "DatasetWriter.h" #include "Engine.h" #include "EngineTiming.h" @@ -24,128 +22,88 @@ #include -int Engine::initCampaign(const std::string &mapName, Campaign& campaign, const std::string& missionName) +int Engine::initCampaign(const std::string& filename, Campaign& campaign, const std::string& mission) { - MapHeader mapHeader = loadMapHeader(mapName); - GameHeader gameHeader = loadGameHeader(mapName); - if(gameHeader.getNumberOfPlayers() == 0) - { - gameHeader = prepareCampaign(mapHeader, gui.localPlayer, gui.localTeamNo); - } - else - { - gui.localPlayer = 0; - gui.localTeamNo = gameHeader.getBasePlayer(0).teamNumber; - } - - gameHeader.getBasePlayer(0).name = campaign.getPlayerName(); - - int end=initGame(mapHeader, gameHeader); - gui.setCampaignGame(campaign, missionName); - return end; + const bool loaded = initCampaignTask(filename, &campaign, mission).run(); + if (!loaded) showMapLoadError(); + return loaded ? EE_NO_ERROR : EE_CANT_LOAD_MAP; } - - - -int Engine::initCampaign(const std::string &mapName) +int Engine::initCampaign(const std::string& filename) { - MapHeader mapHeader = loadMapHeader(mapName); - GameHeader gameHeader = loadGameHeader(mapName); - if(gameHeader.getNumberOfPlayers() == 0) - { - gameHeader = prepareCampaign(mapHeader, gui.localPlayer, gui.localTeamNo); - } - else - { - gui.localPlayer = 0; - gui.localTeamNo = gameHeader.getBasePlayer(0).teamNumber; - } - int end=initGame(mapHeader, gameHeader); - return end; + const bool loaded = initCampaignTask(filename).run(); + if (!loaded) showMapLoadError(); + return loaded ? EE_NO_ERROR : EE_CANT_LOAD_MAP; } - - - -int Engine::initCustom(void) +GAGCore::CooperativeTask Engine::initCampaignTask(std::string filename, Campaign* campaign, std::string mission) { - CustomGameScreen customGameScreen; - - for (;;) { - int result=customGameScreen.execute(globalContainer->gfx, GAME_TICK_MS); - if (result==CustomGameScreen::CANCEL) return EE_CANCEL; - if (result==-1) return -1; - gui.localPlayer=0; - gui.localTeamNo=customGameScreen.getSelectedColor(0); - int loaded=initGame(customGameScreen.getMapHeader(), customGameScreen.getGameHeader(), - true, false, false, customGameScreen.sourceFile()); - if (loaded==-1) return -1; - if (loaded==EE_NO_ERROR) break; - customGameScreen.launchFailed(); + co_await GAGCore::CooperativeTask::checkpoint("[Loading headers]"); + auto map = loadMapHeader(filename); + auto players = loadGameHeader(filename); + if (players.getNumberOfPlayers() == 0) players = prepareCampaign(map, gui.localPlayer, gui.localTeamNo); + else { gui.localPlayer = 0; gui.localTeamNo = players.getBasePlayer(0).teamNumber; } + if (campaign) players.getBasePlayer(0).name = campaign->getPlayerName(); + const bool loaded = co_await initGameTask(map, players); + if (loaded && campaign) gui.setCampaignGame(*campaign, mission); + co_return loaded; +} +int Engine::initCustom(MapHeader& map, GameHeader& players, int localTeam, const std::string& sourceFileName) +{ + const bool loaded = initCustomTask(map, players, localTeam, -1, sourceFileName).run(); + if (!loaded) showMapLoadError(); + return loaded ? EE_NO_ERROR : EE_CANT_LOAD_MAP; +} +GAGCore::CooperativeTask Engine::initCustomTask(MapHeader map, GameHeader players, int localTeam, int speed, std::string sourceFileName) +{ + gui.localPlayer = 0; + gui.localTeamNo = localTeam; + // Restored by ~Engine(); a negative speed means the caller doesn't offer + // a match-speed choice (e.g. the sync MapHeader/GameHeader overload). + if (speed >= 0) + { + previousCustomSpeed = globalContainer->settings.gameSpeed; + globalContainer->settings.gameSpeed = speed; } - - previousCustomSpeed=globalContainer->settings.gameSpeed; - globalContainer->settings.gameSpeed=customGameScreen.selectedSpeed(); - return EE_NO_ERROR; + // Without this, a generated map falls back to a name-based library + // lookup ("Random map" -> maps/Random_map.map) that never exists; a + // premade map's on-disk path can also legitimately differ from its + // declared map name (user libraries, duplicate names). + co_return co_await initGameTask(map, players, true, false, false, sourceFileName); } - -int Engine::initCustom(const std::string &gameName) +int Engine::initCustom(const std::string& filename) { - MapHeader mapHeader = loadMapHeader(gameName); - GameHeader gameHeader = loadGameHeader(gameName); - - // If the game is a network saved game, we need to toggle net players to ai players: - for (int p=0; pgfx, GAME_TICK_MS); - if (lgs == ChooseMapScreen::CANCEL) - return EE_CANCEL; - else if(lgs == -1) - return -1; - - assert(loadGameScreen.getSelectedType() != ChooseMapScreen::NONE); - assert(loadGameScreen.getSelectedType() != ChooseMapScreen::MAP); - - if (loadGameScreen.getSelectedType() == ChooseMapScreen::GAME) - return initCustom(loadGameScreen.getMapHeader().getFileName()); - else if (loadGameScreen.getSelectedType() == ChooseMapScreen::REPLAY) - return loadReplay(loadGameScreen.getMapHeader().getFileName(false,true)); - else - assert(false); + co_await GAGCore::CooperativeTask::checkpoint("[Loading headers]"); + auto map = loadMapHeader(filename); + auto players = loadGameHeader(filename); + for (int p = 0; p < players.getNumberOfPlayers(); ++p) + if (players.getBasePlayer(p).type == BasePlayer::P_IP) players.getBasePlayer(p).makeItAI(AI::toggleAI); + co_return co_await initGameTask(map, players, true, false, true, filename); } + int Engine::initMultiplayer(std::shared_ptr multiplayerGame, std::shared_ptr client, int localPlayer) { + const bool loaded = initMultiplayerTask(multiplayerGame, client, localPlayer).run(); + if (!loaded) showMapLoadError(); + return loaded ? EE_NO_ERROR : EE_CANT_LOAD_MAP; +} + +GAGCore::CooperativeTask Engine::initMultiplayerTask(std::shared_ptr multiplayerGame, std::shared_ptr client, int localPlayer) +{ + if (localPlayer < 0 || localPlayer >= multiplayerGame->getGameHeader().getNumberOfPlayers()) co_return false; gui.localPlayer = localPlayer; gui.localTeamNo = multiplayerGame->getGameHeader().getBasePlayer(localPlayer).teamNumber; // On failure, initGame has not created `net`; propagate the error before // touching it, and leave `multiplayer` unset so the engine is not left // half-initialised (mirrors the clean state teardownSession leaves). - int ret = initGame(multiplayerGame->getMapHeader(), multiplayerGame->getGameHeader(), true, true); - if (ret != EE_NO_ERROR) - return ret; + const bool loaded = co_await initGameTask(multiplayerGame->getMapHeader(), multiplayerGame->getGameHeader(), true, true); + if (!loaded) co_return false; multiplayer = multiplayerGame; multiplayer->setNetEngine(net.get()); @@ -160,7 +118,7 @@ int Engine::initMultiplayer(std::shared_ptr multiplayerGame, st net->setNetworkInfo(multiplayerGame->getGameHeader().getOrderRate(), client->getGameConnection()); - return Engine::EE_NO_ERROR; + co_return true; } @@ -312,11 +270,18 @@ bool Engine::haveMap(const MapHeader& mapHeader) int Engine::initGame(MapHeader& mapHeader, GameHeader& gameHeader, bool setGameHeader, bool ignoreGUIData, bool saveAI, const std::string& sourceFileName) +{ + const bool loaded = initGameTask(mapHeader, gameHeader, setGameHeader, ignoreGUIData, saveAI, sourceFileName).run(); + if (!loaded) showMapLoadError(); + return loaded ? EE_NO_ERROR : EE_CANT_LOAD_MAP; +} + +GAGCore::CooperativeTask Engine::initGameTask(MapHeader mapHeader, GameHeader gameHeader, bool setGameHeader, bool ignoreGUIData, bool saveAI, std::string sourceFileName) { bool error = false; try { - error = !gui.loadFromHeaders(mapHeader, gameHeader, setGameHeader, ignoreGUIData, saveAI, sourceFileName); + error = !(co_await gui.loadFromHeadersTask(mapHeader, gameHeader, setGameHeader, ignoreGUIData, saveAI, sourceFileName)); } catch (std::exception &e) { @@ -324,8 +289,7 @@ int Engine::initGame(MapHeader& mapHeader, GameHeader& gameHeader, bool setGameH error = true; } if (error) { - showMapLoadError(); - return EE_CANT_LOAD_MAP; + co_return false; } @@ -397,7 +361,7 @@ int Engine::initGame(MapHeader& mapHeader, GameHeader& gameHeader, bool setGameH } } - return EE_NO_ERROR; + co_return true; } @@ -463,8 +427,16 @@ bool Engine::loadGame(const std::string &filename) -int Engine::loadReplay(const std::string &fileName) +int Engine::loadReplay(const std::string& filename) { + const bool loaded = loadReplayTask(filename).run(); + if (!loaded) showMapLoadError(); + return loaded ? EE_NO_ERROR : EE_CANT_LOAD_MAP; +} + +GAGCore::CooperativeTask Engine::loadReplayTask(std::string fileName) +{ + co_await GAGCore::CooperativeTask::checkpoint("[Loading headers]"); // Parse the replay file before committing any global state, so a failed // load leaves globalContainer as if no replay had been requested. auto replayReader = std::make_unique(); @@ -472,9 +444,8 @@ int Engine::loadReplay(const std::string &fileName) if (!replayLoaded) { - showMapLoadError(); clearReplayState(); - return EE_CANT_LOAD_MAP; + co_return false; } assert(replayReader->isValid()); @@ -504,14 +475,14 @@ int Engine::loadReplay(const std::string &fileName) // Finally, initialise the Game. If the map embedded in the replay fails // to load, drop the replay state committed above so the next game // session starts as a normal game. - int ret = initGame(mapHeader, gameHeader, true, false, true); - if(ret != EE_NO_ERROR) + bool loaded = co_await initGameTask(mapHeader, gameHeader, true, false, true); + if (!loaded) { clearReplayState(); - return EE_CANT_LOAD_MAP; + co_return false; } - return EE_NO_ERROR; + co_return true; } void Engine::clearReplayState() @@ -536,3 +507,9 @@ void Engine::finalAdjustments(void) } gui.game.setAlliances(); } + +void Engine::cancelInitialization() +{ + teardownSession(); + clearReplayState(); +} diff --git a/src/EngineRun.cpp b/src/EngineRun.cpp index 3a735247e..8e435a61d 100644 --- a/src/EngineRun.cpp +++ b/src/EngineRun.cpp @@ -1,12 +1,14 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Copyright (C) 2001-2004 Stephane Magnenat & Luc-Olivier de Charrière +#include #include #include "AINames.h" #include "ChecksumSidecar.h" #include "DatasetWriter.h" #include "Engine.h" +#include #include "EngineTiming.h" #include "Game.h" #include "GlobalContainer.h" @@ -20,11 +22,12 @@ #include "unit/UnitConsts.h" #include +#include using std::shared_ptr; -void Engine::updateTickSpeedAndDrawCadence(MainLoopState& st) +void Engine::updateTickSpeedAndDrawCadence(MainLoopState& st, Uint64 now) { const int previousSpeed = st.speed; int renderInterval = st.adjustableGameSpeed ? globalContainer->settings.getGameSpeedRenderInterval() : 1; @@ -50,21 +53,21 @@ void Engine::updateTickSpeedAndDrawCadence(MainLoopState& st) // A preset change or pause starts a fresh timing budget. if (st.speed != previousSpeed) - st.needToBeTime = static_cast(SDL_GetTicks64() - st.startTime); + st.needToBeTime = static_cast(now - st.startTime); } // Headless / scripted-test polling: under --nox automaticEndingGame, flip // gui.isRunning=false once a local end condition fires (local team dead, local // team won, total-prestige reached, game ended). Records automaticGameEndTick. -void Engine::pollAutomaticEndingConditions() +void Engine::pollAutomaticEndingConditions(Uint64 now) { if (!globalContainer->automaticEndingGame) return; - auto endGame = [this](const char* reason) { + auto endGame = [this, now](const char* reason) { printf("nox::%s\n", reason); gui.isRunning = false; - automaticGameEndTick = SDL_GetTicks64(); + automaticGameEndTick = now; }; if (!gui.getLocalTeam()->isAlive && !globalContainer->automaticGameGlobalEndConditions) @@ -112,8 +115,6 @@ void Engine::gatherAndAdvanceOrders(bool wasReadyLastTick) } } - gui.game.setWaitingOnMask(net->getWaitingOnMask()); - if (multiplayer) multiplayer->update(); @@ -127,6 +128,9 @@ void Engine::gatherAndAdvanceOrders(bool wasReadyLastTick) if (checksumSidecar) checksumSidecar->writeTick(gui.game.stepCounter, checksum, gui.game); } + // advanceStep inserts the local order. Measuring earlier leaves a stale + // waiting flag; replays filter null orders and cannot clear it by executing one. + gui.game.setWaitingOnMask(net->getWaitingOnMask()); } // Once allOrdersReceived() is true for this tick, commit the tick: validate @@ -198,11 +202,13 @@ void Engine::executeOrdersAndStep(bool readyNow) } gui.game.syncStep(gui.localTeamNo); + GAGCore::ApplicationHost::simulationAdvanced(gui.game.stepCounter); } } -void Engine::drawAndPaceFrame(MainLoopState& st, bool readyNow) +void Engine::drawFrame(MainLoopState& st) { + GAGCore::ApplicationHost::matchFrame(gui.gamePaused); const bool renderedFrame = st.nextGuiStep == 0; if (renderedFrame) { @@ -220,9 +226,22 @@ void Engine::drawAndPaceFrame(MainLoopState& st, bool readyNow) globalContainer->gfx->printScreen(fileName.c_str()); } +} + +void Engine::drawSession() +{ + if (!session) throw std::logic_error("No active engine session"); + if (!globalContainer->runNoX) drawFrame(*session); +} + +Uint32 Engine::sessionDelay(Uint64 now) +{ + if (!session) throw std::logic_error("No active engine session"); + if (globalContainer->runNoX) return 0; + auto& st = *session; // we compute timing - st.needToBeTime += st.speed; - Sint64 currentTime = static_cast(SDL_GetTicks64()) - static_cast(st.startTime); + + Sint64 currentTime = static_cast(now) - static_cast(st.startTime); //if we are more than MAX_CATCHUP_MS milliseconds behind where we should be, //then truncate it. This is to avoid playing "catchup" for long //periods of time if Glob2 received allmost no cpu time @@ -231,10 +250,7 @@ void Engine::drawAndPaceFrame(MainLoopState& st, bool readyNow) //Any inconsistancies in the delays will be smoothed throughout the following frames, Uint64 delay = std::max(0, st.needToBeTime - currentTime); - if (delay > 0) - SDL_Delay(delay); - else if (!readyNow) - SDL_Delay(1); + // we set CPU stats // Convert slept time into CPU load for one game tick. @@ -242,6 +258,7 @@ void Engine::drawAndPaceFrame(MainLoopState& st, bool readyNow) ? static_cast((std::max(0, static_cast(st.speed) - static_cast(delay)) * 100) / st.speed) : 100; gui.setCpuLoad(loadPercent); + return delay > 0 ? delay : (!st.wasReadyLastTick ? 1 : 0); } // If the GUI requested a clean exit, drain remaining local orders into the @@ -436,42 +453,11 @@ void Engine::teardownSession() globalContainer->datasetWriter.reset(); } + if (multiplayer) multiplayer->setNetEngine(nullptr); net.reset(); multiplayer.reset(); } -// Decide whether run() should loop back into runOneGameSession (e.g. the GUI -// armed a load-game request) or return to the menu. Always clears -// toLoadGameFileName afterwards so the next pass doesn't re-trigger it. -void Engine::prepareNextGameSession(bool& doRunOnceAgain) -{ - if (gui.exitGlobCompletely) - { - doRunOnceAgain = false; - return; // There is no bypass for the "close window button" - } - - doRunOnceAgain = false; - - if (!gui.toLoadGameFileName.empty()) - { - int rv; - - // A new game session is starting, so no EndGameScreen will be shown - // for the finished one: finalize its replay now (ReplayWriter::finish - // writes the NullOrder terminator and flushes). initGame requires the - // writer slot to be empty before it allocates the next session's. - globalContainer->replayWriter.reset(); - - if (globalContainer->replaying) rv = loadReplay(gui.toLoadGameFileName); - else rv = initCustom(gui.toLoadGameFileName); - - if (rv == EE_NO_ERROR) - doRunOnceAgain = true; - gui.toLoadGameFileName.clear(); // Avoid the communication system between GameGUI and Engine to loop. - } -} - // Body of the outer "play one game and possibly load another" loop in run(). // On entry: the game has been initialised (initGame) and audio/cursor set up. // On exit: doRunOnceAgain==true means run() should call this again @@ -485,77 +471,99 @@ void Engine::prepareNextGameSession(bool& doRunOnceAgain) // 5. (gate flip) readyNow = net->allOrdersReceived() // 6. executeOrdersAndStep - run matched orders, replay reader, sim syncStep // 7. automatic-ending step-count check -// 8. drawAndPaceFrame - draw, videoshot, sleep +// 8. drawSession / sessionDelay - draw, videoshot, host pacing // 9. handleExitRequest - drain on exit request // // Track order readiness separately for the previous and current ticks. -void Engine::runOneGameSession(bool& doRunOnceAgain) +void Engine::beginSession(Uint64 now) { - MainLoopState st; - st.adjustableGameSpeed = gui.canChangeGameSpeed(); - st.speed = st.adjustableGameSpeed ? globalContainer->settings.getGameSpeedStepDuration() : GAME_TICK_MS; - st.wasReadyLastTick = true; - // At higher game-speed presets (and during replay fast-forward), render - // less frequently so simulation can use the available CPU. - st.nextGuiStep = 1; - st.needToBeTime = 0; - st.startTime = SDL_GetTicks64(); - st.frameNumber = 0; - - while (gui.isRunning) - { - st.nextGuiStep--; - updateTickSpeedAndDrawCadence(st); - - pollAutomaticEndingConditions(); - - if (!globalContainer->runNoX && st.nextGuiStep == 0) - gui.step(); - - // Hard pause skips the readiness update, so carry the previous value forward. - bool readyNow = st.wasReadyLastTick; - - if (!gui.hardPause) - { - if (multiplayer && multiplayer->getMultiplayerMode() == MultiplayerGame::NoMode) - gui.isRunning = false; - - gatherAndAdvanceOrders(st.wasReadyLastTick); - - // Gate flip: from "previous tick committed" to "all orders for - // this tick are now in." Downstream helpers take readyNow, not - // wasReadyLastTick. - readyNow = net->allOrdersReceived(); - - executeOrdersAndStep(readyNow); - } - - if (globalContainer->automaticEndingGame) - { - if ((int)gui.game.stepCounter == globalContainer->automaticEndingSteps) - { - gui.isRunning = false; - automaticGameEndTick = SDL_GetTicks64(); - printf("nox::gui.game.checkSum() = %08x\n", gui.game.checkSum()); - } - } - - if (!globalContainer->runNoX) - drawAndPaceFrame(st, readyNow); - - if (handleExitRequest()) - break; + if (session) throw std::logic_error("Engine session is already active"); + if (!net) throw std::logic_error("Engine session requires an initialized game"); + sessionEndingTarget = globalContainer->automaticEndingSteps; + MainLoopState st{}; + st.adjustableGameSpeed = gui.canChangeGameSpeed(); + st.speed = st.adjustableGameSpeed ? globalContainer->settings.getGameSpeedStepDuration() : GAME_TICK_MS; + st.wasReadyLastTick = true; + st.nextGuiStep = 1; + st.startTime = now; + session = st; + automaticGameStartTick = now; +} - st.wasReadyLastTick = readyNow; - } +bool Engine::stepSession(Uint64 now) +{ + std::vector events; + SDL_Event event; + if (!globalContainer->runNoX) + while (SDL_PollEvent(&event)) events.push_back(event); + return stepSession(now, events); +} - if (globalContainer->automaticEndingGame) - printAutomaticEndingSummary(); +bool Engine::stepSession(Uint64 now, const std::vector& events) +{ + if (!session) throw std::logic_error("No active engine session"); + if (!gui.isRunning) return false; + auto& st = *session; + --st.nextGuiStep; + updateTickSpeedAndDrawCadence(st, now); + pollAutomaticEndingConditions(now); + sessionInput.insert(sessionInput.end(), events.begin(), events.end()); + if (!globalContainer->runNoX && st.nextGuiStep == 0) { + gui.step(sessionInput, now); + sessionInput.clear(); + } + + bool readyNow = st.wasReadyLastTick; + if (!gui.hardPause) { + if (multiplayer && multiplayer->getMultiplayerMode() == MultiplayerGame::NoMode) + gui.isRunning = false; + gatherAndAdvanceOrders(st.wasReadyLastTick); + readyNow = net->allOrdersReceived(); + executeOrdersAndStep(readyNow); + } + if (globalContainer->automaticEndingGame && (int)gui.game.stepCounter == sessionEndingTarget) { + gui.isRunning = false; + automaticGameEndTick = now; + printf("nox::gui.game.checkSum() = %08x\n", gui.game.checkSum()); + } + st.wasReadyLastTick = readyNow; + if (!globalContainer->runNoX) st.needToBeTime += st.speed; + handleExitRequest(); + return gui.isRunning; +} - if (multiplayer) - reportMultiplayerResult(); +std::optional Engine::finishSessionForHost() +{ + if (!session) throw std::logic_error("No active engine session"); + if (gui.isRunning) throw std::logic_error("Cannot finish a running engine session"); + if (globalContainer->automaticEndingGame) printAutomaticEndingSummary(); + if (multiplayer) reportMultiplayerResult(); + teardownSession(); + session.reset(); + sessionInput.clear(); + const auto filename = std::exchange(gui.toLoadGameFileName, {}); + if (gui.exitGlobCompletely || filename.empty()) return std::nullopt; + // The outgoing end screen will not be shown; finalize its replay before + // a cooperative initializer creates the next session's writer. + globalContainer->replayWriter.reset(); + return PendingLoad{filename, globalContainer->replaying}; +} - teardownSession(); +bool Engine::finishSession() +{ + const auto request = finishSessionForHost(); + if (!request) return false; + return (request->replay ? loadReplay(request->filename) : initCustom(request->filename)) == EE_NO_ERROR; +} - prepareNextGameSession(doRunOnceAgain); +void Engine::runOneGameSession(bool& doRunOnceAgain) +{ + beginSession(SDL_GetTicks64()); + while (gui.isRunning) { + stepSession(SDL_GetTicks64()); + drawSession(); + if (!globalContainer->runNoX) + GAGCore::ApplicationHost::wait(sessionDelay(SDL_GetTicks64())); + } + doRunOnceAgain = finishSession(); } diff --git a/src/FertilityCalculator.cpp b/src/FertilityCalculator.cpp index 04ef1631a..acd1f830e 100644 --- a/src/FertilityCalculator.cpp +++ b/src/FertilityCalculator.cpp @@ -13,6 +13,7 @@ #include #include #include +#include namespace { @@ -51,95 +52,100 @@ namespace return kernel; } - /// 8-connected BFS from every takeable corn/wood tile, traversing only grass - /// cells. Cells that are unreachable (or non-grass and not seeded) stay nullopt. - DistanceMap computeResourceDistance(const Map& map) - { - DistanceMap distance(static_cast(map.getW()) * map.getH()); - std::queue> frontier; - - for (int x = 0; x < map.getW(); ++x) - { - for (int y = 0; y < map.getH(); ++y) - { - if (map.isResourceTakeable(x, y, CORN) - || map.isResourceTakeable(x, y, WOOD)) - { - distance[map.coordToIndex(x, y)] = 0; - frontier.emplace(x, y); - } - } - } - - while (!frontier.empty()) - { - const auto [px, py] = frontier.front(); - frontier.pop(); - const Uint16 nextDepth = - static_cast(*distance[map.coordToIndex(px, py)] + 1); - - for (const auto [dx, dy] : kBfsNeighbors) - { - const int nx = map.normalizeX(px + dx); - const int ny = map.normalizeY(py + dy); - auto& cell = distance[map.coordToIndex(nx, ny)]; - if (!cell.has_value() && map.isGrass(nx, ny)) - { - cell = nextDepth; - frontier.emplace(nx, ny); - } - } - } - return distance; - } } namespace FertilityCalculator { - void compute(Map& map, const ProgressCallback& progress) - { - // BFS is fast relative to the kernel pass, so it isn't progress-reported. - const DistanceMap reachable = computeResourceDistance(map); - const auto& kernel = fertilityKernel(); - - std::vector fertility( - static_cast(map.getW()) * map.getH(), 0); - Uint16 fertilityMax = 0; - - for (int x = 0; x < map.getW(); ++x) - { - if (progress) - progress(static_cast(x) / static_cast(map.getW())); - - for (int y = 0; y < map.getH(); ++y) - { - if (!map.isGrass(x, y)) - continue; - if (!reachable[map.coordToIndex(x, y)].has_value()) - continue; - - Uint16 total = 0; - for (int ny = -kFertilityRadius; ny <= kFertilityRadius; ++ny) - { - for (int nx = -kFertilityRadius; nx <= kFertilityRadius; ++nx) - { - // Map::isWater wraps coords via coordToIndex; no normalize needed. - if (map.isWater(x + nx, y + ny)) - { - const int kIdx = (ny + kFertilityRadius) * kKernelSide - + (nx + kFertilityRadius); - total += kernel[kIdx]; - } - } - } - fertilityMax = std::max(fertilityMax, total); - fertility[map.coordToIndex(x, y)] = total; - } - } - - for (int x = 0; x < map.getW(); ++x) - for (int y = 0; y < map.getH(); ++y) - map.getTile(x, y).fertility = fertility[map.coordToIndex(x, y)]; - map.fertilityMaximum = fertilityMax; - } + struct Job::State + { + Map& map; + const std::size_t size; + DistanceMap distance; + std::queue> frontier; + std::vector fertility; + enum Phase { Seed, Reach, Kernel, Ready, Committed } phase = Seed; + std::size_t cursor = 0, visited = 0; + int kernelOffset = 0; + int kernelX = 0, kernelY = 0; + std::size_t kernelIndex = 0; + Uint16 total = 0, maximum = 0; + explicit State(Map& map) : map(map), size(static_cast(map.getW()) * map.getH()), + distance(size), fertility(size, 0) {} + std::pair coordinate() const + { return {static_cast(cursor / map.getH()), static_cast(cursor % map.getH())}; } + }; + Job::Job(Map& map) : state(std::make_unique(map)) {} + Job::~Job() = default; + bool Job::ready() const { return state->phase >= State::Ready; } + bool Job::advance(std::size_t operations) + { + auto& s = *state; + const auto& kernel = fertilityKernel(); + while (operations-- && !ready()) { + if (s.phase == State::Seed) { + if (s.cursor == s.size) { s.cursor = 0; s.phase = State::Reach; continue; } + const auto [x, y] = s.coordinate(); + if (s.map.isResourceTakeable(x, y, CORN) || s.map.isResourceTakeable(x, y, WOOD)) { + s.distance[s.map.coordToIndex(x, y)] = 0; + s.frontier.emplace(x, y); + } + ++s.cursor; + } else if (s.phase == State::Reach) { + if (s.frontier.empty()) { s.phase = State::Kernel; continue; } + const auto [x, y] = s.frontier.front(); s.frontier.pop(); ++s.visited; + const Uint16 depth = static_cast(*s.distance[s.map.coordToIndex(x, y)] + 1); + for (const auto [dx, dy] : kBfsNeighbors) { + const int nx = s.map.normalizeX(x + dx), ny = s.map.normalizeY(y + dy); + auto& cell = s.distance[s.map.coordToIndex(nx, ny)]; + if (!cell && s.map.isGrass(nx, ny)) { cell = depth; s.frontier.emplace(nx, ny); } + } + } else { + if (s.cursor == s.size) { s.phase = State::Ready; continue; } + if (s.kernelOffset == 0) { + const auto [x, y] = s.coordinate(); + const auto index = s.map.coordToIndex(x, y); + if (!s.map.isGrass(x, y) || !s.distance[index]) { ++s.cursor; continue; } + s.kernelX = x; + s.kernelY = y; + s.kernelIndex = index; + } + const int nx = s.kernelOffset % kKernelSide - kFertilityRadius; + const int ny = s.kernelOffset / kKernelSide - kFertilityRadius; + if (s.map.isWater(s.kernelX + nx, s.kernelY + ny)) s.total += kernel[s.kernelOffset]; + if (++s.kernelOffset == kKernelSide * kKernelSide) { + s.fertility[s.kernelIndex] = s.total; + s.maximum = std::max(s.maximum, s.total); + s.total = 0; s.kernelOffset = 0; ++s.cursor; + } + } + } + return ready(); + } + float Job::progress() const + { + const auto& s = *state; + if (ready()) return 1.f; + if (!s.size) return 0.f; + if (s.phase == State::Seed) return .1f * s.cursor / s.size; + if (s.phase == State::Reach) return .1f + .1f * s.visited / s.size; + return .2f + .8f * s.cursor / s.size; + } + void Job::commit() + { + auto& s = *state; + if (!ready()) throw std::logic_error("Cannot commit incomplete fertility"); + if (s.phase == State::Committed) return; + for (int x = 0; x < s.map.getW(); ++x) + for (int y = 0; y < s.map.getH(); ++y) + s.map.getTile(x, y).fertility = s.fertility[s.map.coordToIndex(x, y)]; + s.map.fertilityMaximum = s.maximum; + s.phase = State::Committed; + } + void compute(Map& map, const ProgressCallback& progress) + { + Job job(map); + while (!job.advance(16384)) if (progress) progress(job.progress()); + job.commit(); + if (progress) progress(1.f); + } } diff --git a/src/FertilityCalculator.h b/src/FertilityCalculator.h index 5091c473c..4643576a8 100644 --- a/src/FertilityCalculator.h +++ b/src/FertilityCalculator.h @@ -2,18 +2,29 @@ // Copyright (C) 2007-2008 Bradley Arsenault #pragma once - #include - +#include +#include class Map; - namespace FertilityCalculator { - /// Reports compute progress in [0, 1]. Invoked from the worker thread. - using ProgressCallback = std::function; - - /// Computes per-tile fertility, writes it into map.getTile(x,y).fertility, - /// and updates map.fertilityMaximum. The optional progress callback is - /// invoked once per column. May be called from a worker thread. - void compute(Map& map, const ProgressCallback& progress); + /// Reports compute progress in [0, 1]. + using ProgressCallback = std::function; + // The map must remain alive and unchanged until commit or cancellation. + // Work is staged privately. Destruction/cancellation never changes the map. + class Job + { + public: + explicit Job(Map& map); + ~Job(); + bool advance(std::size_t operations); + float progress() const; + bool ready() const; + void commit(); + private: + struct State; + std::unique_ptr state; + }; + /// Computes and commits fertility synchronously for native callers. + void compute(Map& map, const ProgressCallback& progress); } diff --git a/src/FertilityCalculatorDialog.cpp b/src/FertilityCalculatorDialog.cpp deleted file mode 100644 index 73511694e..000000000 --- a/src/FertilityCalculatorDialog.cpp +++ /dev/null @@ -1,69 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// Copyright (C) 2007-2008 Bradley Arsenault - -#include "FertilityCalculatorDialog.h" - -#include "FertilityCalculator.h" -#include "GUIProgressBar.h" -#include "GUIText.h" -#include "Map.h" -#include "StringTable.h" -#include "Toolkit.h" - -#include -#include - -using namespace GAGCore; -using namespace GAGGUI; - -namespace -{ - constexpr int kProgressResolution = 1000; -} - -FertilityCalculatorDialog::FertilityCalculatorDialog(GraphicContext* parentCtx, Map& map) - : OverlayScreen(parentCtx, 200, 100), map(map), parentCtx(parentCtx) -{ - addWidget(new Text(0, 20, ALIGN_FILL, ALIGN_LEFT, "standard", - Toolkit::getStringTable()->getString("[Computing Fertility]"))); - percentDone = new Text(0, 40, ALIGN_FILL, ALIGN_LEFT, "menu"); - progress = new ProgressBar(0, 70, 0, ALIGN_FILL, ALIGN_TOP, kProgressResolution); - addWidget(percentDone); - addWidget(progress); - dispatchInit(); -} - -void FertilityCalculatorDialog::onAction(Widget*, Action, int, int) -{ -} - -void FertilityCalculatorDialog::onTimer(Uint32) -{ - refreshProgressDisplay(); - if (computeDone.load(std::memory_order_acquire)) - endValue = 1; -} - -void FertilityCalculatorDialog::runModal() -{ - computeThread = std::thread([this]() { - FertilityCalculator::compute(map, [this](float p) { - progressFraction.store(p, std::memory_order_relaxed); - }); - computeDone.store(true, std::memory_order_release); - }); - - executeModal(parentCtx); - - if (computeThread.joinable()) - computeThread.join(); -} - -void FertilityCalculatorDialog::refreshProgressDisplay() -{ - const float p = progressFraction.load(std::memory_order_relaxed); - std::stringstream s; - s << std::setprecision(3) << (p * 100.0) << "%"; - percentDone->setText(s.str()); - progress->setValue(static_cast(p * kProgressResolution)); -} diff --git a/src/FertilityCalculatorDialog.h b/src/FertilityCalculatorDialog.h deleted file mode 100644 index b4ae8235c..000000000 --- a/src/FertilityCalculatorDialog.h +++ /dev/null @@ -1,46 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// Copyright (C) 2007-2008 Bradley Arsenault - -#pragma once - -#include "GUIBase.h" -#include -#include - -class Map; -namespace GAGGUI -{ - class Text; - class ProgressBar; -} -namespace GAGCore -{ - class DrawableSurface; -} - -/// Modal dialog that shows fertility-computation progress while the work runs -/// on a background thread. -class FertilityCalculatorDialog : public GAGGUI::OverlayScreen -{ -public: - FertilityCalculatorDialog(GAGCore::GraphicContext* parentCtx, Map& map); - ~FertilityCalculatorDialog() override = default; - void onAction(GAGGUI::Widget* source, GAGGUI::Action action, int par1, int par2) override; - void onTimer(Uint32 tick) override; - - /// Modal: blocks until the background computation finishes. - void runModal(); - -private: - void refreshProgressDisplay(); - - Map& map; - GAGCore::GraphicContext* parentCtx; - - GAGGUI::Text* percentDone; - GAGGUI::ProgressBar* progress; - - std::thread computeThread; - std::atomic progressFraction{0.f}; - std::atomic computeDone{false}; -}; diff --git a/src/FertilityScreen.cpp b/src/FertilityScreen.cpp new file mode 100644 index 000000000..e72702df4 --- /dev/null +++ b/src/FertilityScreen.cpp @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "FertilityScreen.h" +#include +#include +#include +#include +#include +FertilityScreen::FertilityScreen(Map& map) : job(map) +{ + auto& strings = *GAGCore::Toolkit::getStringTable(); + addWidget(new GAGGUI::Text(0, 160, ALIGN_FILL, ALIGN_SCREEN_CENTERED, "standard", + strings.getString("[Computing Fertility]"))); + progress = new GAGGUI::ProgressBar(120, 220, 400, ALIGN_SCREEN_CENTERED, ALIGN_SCREEN_CENTERED, 1000); + addWidget(progress); + addWidget(new GAGGUI::TextButton(230, 340, 180, 40, ALIGN_SCREEN_CENTERED, + ALIGN_SCREEN_CENTERED, "menu", strings.getString("[Cancel]"), 0, 27)); +} +void FertilityScreen::onTimer(Uint32) +{ + if (job.advance(65536)) { job.commit(); endExecute(1); } + progress->setValue(static_cast(job.progress() * 1000)); +} +void FertilityScreen::onAction(GAGGUI::Widget*, GAGGUI::Action action, int, int) +{ + if (action == GAGGUI::BUTTON_RELEASED || action == GAGGUI::BUTTON_SHORTCUT) endExecute(0); +} diff --git a/src/FertilityScreen.h b/src/FertilityScreen.h new file mode 100644 index 000000000..0e5a05cde --- /dev/null +++ b/src/FertilityScreen.h @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once +#include "Glob2Screen.h" +#include "FertilityCalculator.h" +namespace GAGGUI { class ProgressBar; } +class FertilityScreen : public Glob2Screen +{ +public: + explicit FertilityScreen(Map& map); + void onTimer(Uint32) override; + void onAction(GAGGUI::Widget*, GAGGUI::Action, int, int) override; + Uint32 executionDelay(Uint32, Uint32) override { return 1; } +private: + FertilityCalculator::Job job; + GAGGUI::ProgressBar* progress; +}; diff --git a/src/FileImport.cpp b/src/FileImport.cpp new file mode 100644 index 000000000..f22d4664b --- /dev/null +++ b/src/FileImport.cpp @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "FileImport.h" +#include "GameGUI.h" +#include "OrderMessages.h" +#include "Order.h" +#include "ReplayReader.h" +#include "Utilities.h" +#include "Version.h" +#include +#include +#include +#include +#include +#include + +using namespace GAGCore; +namespace { +std::string lower(std::string value) { + for (auto& c : value) c = static_cast(std::tolower(static_cast(c))); + return value; +} +bool validName(const std::string& name, const std::string& extension) { + if (name.empty() || name.size() > 512 || name.front() == '.' || name.back() == ' ' || name.back() == '.') return false; + for (unsigned char c : name) + if (c < 32 || c == 127 || std::string("/\\<>:\"|?*").find(c) != std::string::npos) return false; + const auto dot = name.rfind('.'); + if (dot == std::string::npos || lower(name.substr(dot + 1)) != extension) return false; + const auto stem = lower(name.substr(0, name.find('.'))); + if (stem == "con" || stem == "prn" || stem == "aux" || stem == "nul") return false; + if (stem.size() == 4 && (stem.substr(0,3) == "com" || stem.substr(0,3) == "lpt") && stem[3] >= '1' && stem[3] <= '9') return false; + return true; +} +struct RestoreRng { + std::string previous = getSyncRandState(); + ~RestoreRng() { setSyncRandState(previous); } +}; +} +FileImport::FileImport(ApplicationHost::SelectedFile file, std::string extension, Persist persist, CooperativeSlice slice) + : file(std::move(file)), extension(std::move(extension)), persist(std::move(persist)), slice(std::move(slice)) { + task.emplace(validate()); +} +FileImport::~FileImport() { + task.reset(); + // Imports always create a new name. An abandoned failed persistence must + // not become a silently successful import during a later unrelated sync. + if (!destination.empty() && current != State::Succeeded) + Toolkit::getFileManager()->remove(destination); +} +CooperativeTask FileImport::validate() { + if ((extension != "game" && extension != "map" && extension != "replay") || + !validName(file.name, extension) || file.bytes.empty() || file.bytes.size() > 64u*1024u*1024u) + co_return false; + BinaryInputStream input(new MemoryStreamBackend(file.bytes.data(), file.bytes.size())); + input.seekFromStart(0); + BinaryInputStream::CheckedReads checked(&input); + MapHeader header; + if (!header.load(&input) || header.getMapOffset() < input.getPosition() || header.getMapOffset() > file.bytes.size() - 4 || + header.getIsSavedGame() != (extension != "map")) co_return false; + const auto mapStart = file.bytes.begin() + header.getMapOffset(); + if (!std::equal(mapStart, mapStart + 4, "MapB")) co_return false; + input.seekFromStart(0); + RestoreRng rng; + GameGUI gui(false); + if (!(co_await gui.loadTask(&input))) co_return false; + if (extension != "map" && (gui.localPlayer < 0 || gui.localPlayer >= gui.game.gameHeader.getNumberOfPlayers() || + gui.localTeamNo < 0 || gui.localTeamNo >= gui.game.mapHeader.getNumberOfTeams())) co_return false; + if (extension == "replay") { + const auto major = input.readUint16("versionMajor"); + const auto minor = input.readUint16("versionMinor"); + if (major != VERSION_MAJOR || minor < REPLAY_MINIMUM_VERSION_MINOR || minor > VERSION_MINOR) co_return false; + Uint64 ticks = 0; + for (;;) { + // Every replay version still accepted by ReplayReader uses the + // widened inter-order step counter. + ticks += input.readUint32("steps"); + if (ticks > std::numeric_limits::max()) co_return false; + NetSendOrder message; + message.setDecodeVersionMinor(minor); + message.decodeData(&input); + if (message.getOrder()->getOrderType() == ORDER_NULL) break; + co_await CooperativeTask::checkpoint(); + } + // ReplayWriter::write() appends a complete terminator to the live + // recording. Older recordings may already contain one, so accept any + // additional complete terminators while still rejecting trailing or + // truncated command data. + while (input.getPosition() < file.bytes.size()) { + if (input.readUint32("steps") != 0) co_return false; + NetSendOrder message; + message.setDecodeVersionMinor(minor); + message.decodeData(&input); + if (message.getOrder()->getOrderType() != ORDER_NULL) co_return false; + } + } + // The complete supported format must be consumed, including the replay + // terminator. Unlike playback recovery, importing never truncates corruption. + co_return input.getPosition() == file.bytes.size(); +} +void FileImport::advance() { + try { + if (current == State::Validating) { + if (!slice.advance(*task)) return; + const bool valid = task->result(); + task.reset(); + if (!valid || ApplicationHost::storageRestoreFailed()) { current = State::Failed; return; } + auto& files = *Toolkit::getFileManager(); + const auto directory = extension == "game" ? "games" : extension == "map" ? "maps" : "replays"; + const auto name = file.name.substr(0, file.name.rfind('.')); + for (unsigned suffix = 0; suffix < 10000; ++suffix) { + const auto candidate = glob2NameToFilename(directory, name + (suffix ? " (" + std::to_string(suffix) + ")" : ""), extension); + if (files.exists(candidate)) continue; + if (!files.writeAtomically(candidate, [this](OutputStream& output) { + output.write(file.bytes.data(), file.bytes.size(), "import"); + })) { current = State::Failed; return; } + destination = candidate; + current = State::Failed; + retryPersistence(); + return; + } + current = State::Failed; + } else if (current == State::Persisting) { + const auto result = persistence->state(); + if (result == ApplicationHost::PersistenceState::Pending) return; + current = result == ApplicationHost::PersistenceState::Succeeded ? State::Succeeded : State::Failed; + } + } catch (const std::exception&) { + task.reset(); + current = State::Failed; + } +} +void FileImport::retryPersistence() { + if (!canRetry()) return; + try { + persistence = persist(); + current = persistence ? State::Persisting : State::Failed; + } catch (const std::exception&) { current = State::Failed; } +} +bool FileImport::exportFile() const { return ApplicationHost::exportFile(file.name, file.bytes); } diff --git a/src/FileImport.h b/src/FileImport.h new file mode 100644 index 000000000..8aaad3be7 --- /dev/null +++ b/src/FileImport.h @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once +#include +#include +#include + +// A menu-owned import. No active game may share its simulation RNG while the +// validation job runs. Cancellation destroys the job and restores that RNG. +class FileImport { +public: + enum class State { Validating, Persisting, Succeeded, Failed }; + using Persist = std::function()>; + FileImport(GAGCore::ApplicationHost::SelectedFile file, std::string extension, + Persist persist = GAGCore::ApplicationHost::persistStorage, + GAGCore::CooperativeSlice slice = GAGCore::CooperativeSlice()); + ~FileImport(); + void advance(); + void retryPersistence(); + bool exportFile() const; + State state() const { return current; } + bool canRetry() const { return current == State::Failed && !destination.empty(); } + const std::string& path() const { return destination; } +private: + GAGCore::CooperativeTask validate(); + GAGCore::ApplicationHost::SelectedFile file; + std::string extension, destination; + Persist persist; + State current = State::Validating; + GAGCore::CooperativeSlice slice; + std::optional task; + std::unique_ptr persistence; +}; diff --git a/src/FrontendTheme.cpp b/src/FrontendTheme.cpp index db43cf89f..8615be77e 100644 --- a/src/FrontendTheme.cpp +++ b/src/FrontendTheme.cpp @@ -5,12 +5,18 @@ #include #include #include +#include using namespace GAGCore; using namespace GAGGUI; FrontendTheme* FrontendTheme::current = nullptr; bool FrontendTheme::allowed = true; -namespace { const char* fontNames[] = {"menu", "standard", "little"}; } +namespace +{ +const char* fontNames[] = {"menu", "standard", "little"}; +// Live scopes in creation order. +std::vector liveScopes; +} FrontendTheme::FrontendTheme() : original(Style::style) { @@ -27,27 +33,30 @@ FrontendTheme::FrontendTheme() : original(Style::style) } FrontendTheme::~FrontendTheme() { current = nullptr; } -FrontendScope::FrontendScope(bool enabled) : previous(Style::style), previousAllowed(FrontendTheme::allowed) +FrontendScope::FrontendScope(bool enabled) : enabled(enabled) { - if (!FrontendTheme::current) return; - auto& theme = *FrontendTheme::current; - FrontendTheme::allowed = enabled; - Style::style = enabled ? &theme : theme.original; - for (int i=0;i<3;++i) - { - auto* font = Toolkit::getFont(fontNames[i]); - fonts[i] = font->getStyle(); - font->setStyle(enabled ? Font::Style(Font::STYLE_NORMAL, theme.textColor) : theme.originalFonts[i]); - } - if (!enabled) theme.colony->pause(); + liveScopes.push_back(this); + apply(); + if (!enabled && FrontendTheme::current) FrontendTheme::current->colony->pause(); } FrontendScope::~FrontendScope() { + liveScopes.erase(std::find(liveScopes.begin(), liveScopes.end(), this)); + apply(); + if (FrontendTheme::current) FrontendTheme::current->colony->pause(); +} +void FrontendScope::apply() +{ + // With no live scope, restore the presentation the theme was created over, so + // teardown never finds Style::style pointing at a destroyed theme. + const bool anyScope = !liveScopes.empty(); + FrontendTheme::allowed = !anyScope || liveScopes.back()->enabled; if (!FrontendTheme::current) return; - Style::style = previous; - FrontendTheme::allowed = previousAllowed; - for (int i=0;i<3;++i) Toolkit::getFont(fontNames[i])->setStyle(fonts[i]); - FrontendTheme::current->colony->pause(); + auto& theme = *FrontendTheme::current; + const bool themed = anyScope && liveScopes.back()->enabled; + Style::style = themed ? &theme : theme.original; + for (int i=0;i<3;++i) + Toolkit::getFont(fontNames[i])->setStyle(themed ? Font::Style(Font::STYLE_NORMAL, theme.textColor) : theme.originalFonts[i]); } void FrontendTheme::rounded(DrawableSurface* s,int x,int y,int w,int h,int r,Color c) diff --git a/src/FrontendTheme.h b/src/FrontendTheme.h index 4a11b11f2..f4c28be79 100644 --- a/src/FrontendTheme.h +++ b/src/FrontendTheme.h @@ -36,6 +36,9 @@ class FrontendTheme : public GAGGUI::Style }; // Also used to suspend front-end presentation around gameplay/editor loops. +// Screens hold scopes for their whole lifetime, and a screen stack creates the +// next screen before destroying the finished one, so scopes need not end in +// reverse order: the most recently created live scope decides the presentation. class FrontendScope { public: @@ -44,7 +47,6 @@ class FrontendScope FrontendScope(const FrontendScope&) = delete; FrontendScope& operator=(const FrontendScope&) = delete; private: - GAGGUI::Style* previous; - bool previousAllowed; - GAGCore::Font::Style fonts[3]; + static void apply(); + bool enabled; }; diff --git a/src/GUIMapPreview.cpp b/src/GUIMapPreview.cpp index b7701e032..7053008eb 100644 --- a/src/GUIMapPreview.cpp +++ b/src/GUIMapPreview.cpp @@ -48,10 +48,9 @@ bool MapPreview::isThumbnailLoaded() void MapPreview::setMapThumbnail(const std::string& mapName) { - MapThumbnail *n = new MapThumbnail(); - n->loadFromMap(mapName); - setMapThumbnail(*n); - delete n; + MapThumbnail next; + next.loadFromMap(mapName); + setMapThumbnail(next); } diff --git a/src/Game.cpp b/src/Game.cpp index 3ab783956..8b1b94265 100644 --- a/src/Game.cpp +++ b/src/Game.cpp @@ -28,7 +28,6 @@ #include "Brush.h" #include "Bullet.h" #include "TextStream.h" -#include "FertilityCalculatorDialog.h" #include "ReplayWriter.h" diff --git a/src/Game.h b/src/Game.h index 8eced3411..29bb7df04 100644 --- a/src/Game.h +++ b/src/Game.h @@ -3,6 +3,7 @@ // Copyright (C) 2007 Bradley Arsenault #pragma once +#include #include #include @@ -137,6 +138,7 @@ class Game ///Loads data from a stream bool load(GAGCore::InputStream *stream); + GAGCore::CooperativeTask loadTask(GAGCore::InputStream *stream); //! Check some available integrity constraints bool integrity(void); @@ -205,6 +207,8 @@ class Game // Editor stuff // add & remove teams, used by the map editor and the random map generator void addTeam(int pos=TEAM_POS_END); + // Preparation only: a cancelled task leaves a partial game to discard. + GAGCore::CooperativeTask addTeamTask(int pos=TEAM_POS_END); void removeTeam(int pos=TEAM_POS_END); //! If a team is uncontrolled (playerMask == 0), remove units and buildings from map void clearingUncontrolledTeams(void); @@ -415,7 +419,9 @@ class Game public: bool oldMakeIslandsMap(MapGenerationDescriptor &descriptor); + GAGCore::CooperativeTask oldMakeIslandsMapTask(MapGenerationDescriptor &descriptor); bool makeRandomMap(MapGenerationDescriptor &descriptor); + GAGCore::CooperativeTask makeRandomMapTask(MapGenerationDescriptor &descriptor); bool generateMap(MapGenerationDescriptor &descriptor); protected: diff --git a/src/GameHeader.cpp b/src/GameHeader.cpp index 683472573..e9903fdfc 100644 --- a/src/GameHeader.cpp +++ b/src/GameHeader.cpp @@ -63,7 +63,7 @@ bool GameHeader::load(GAGCore::InputStream *stream, Sint32 versionMinor) gameLatency = stream->readSint32("gameLatency"); orderRate = stream->readUint8("orderRate"); numberOfPlayers = stream->readSint32("numberOfPlayers"); - if (numberOfPlayers > Team::MAX_COUNT) + if (numberOfPlayers < 0 || numberOfPlayers > Team::MAX_COUNT) { return false; } diff --git a/src/GameLoadScreen.cpp b/src/GameLoadScreen.cpp new file mode 100644 index 000000000..1e13e02ab --- /dev/null +++ b/src/GameLoadScreen.cpp @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "GameLoadScreen.h" +#include "Engine.h" +#include "Utilities.h" +#include +#include +#include +#include +#include +GameLoadScreen::GameLoadScreen(Initializer initialize, GAGCore::CooperativeSlice slice) + : GameLoadScreen(std::make_unique(), std::move(initialize), std::move(slice)) {} +GameLoadScreen::GameLoadScreen(std::unique_ptr engine, Initializer initialize, GAGCore::CooperativeSlice slice) + : slice(std::move(slice)), previousRng(getSyncRandState()), engine(std::move(engine)) +{ + if (!this->engine) throw std::invalid_argument("A loader requires an engine"); + auto& strings = *GAGCore::Toolkit::getStringTable(); + status = new GAGGUI::Text(0, 180, ALIGN_FILL, ALIGN_SCREEN_CENTERED, "standard", strings.getString("[Loading headers]")); + addWidget(status); + addWidget(new GAGGUI::TextButton(230, 340, 180, 40, ALIGN_SCREEN_CENTERED, + ALIGN_SCREEN_CENTERED, "menu", strings.getString("[Cancel]"), 0, 27)); + task.emplace(initialize(*this->engine)); +} +GameLoadScreen::~GameLoadScreen() +{ + task.reset(); + if (!accepted) engine->cancelInitialization(); + engine.reset(); + if (!accepted) setSyncRandState(previousRng); +} +std::unique_ptr GameLoadScreen::takeEngine() +{ + if (!task->result()) throw std::logic_error("Cannot accept a failed game load"); + accepted = true; + task.reset(); + return std::move(engine); +} +void GameLoadScreen::onTimer(Uint32) +{ + try { + if (slice.advance(*task)) { endExecute(task->result() ? 1 : 2); return; } + const char* stage = task->stage(); + if (*stage) status->setText(GAGCore::Toolkit::getStringTable()->getString(stage)); + } catch (const std::exception& error) { + std::cerr << "Game initialization failed: " << error.what() << '\n'; + endExecute(2); + } +} +void GameLoadScreen::onAction(GAGGUI::Widget*, GAGGUI::Action action, int, int) +{ + if (action == GAGGUI::BUTTON_RELEASED || action == GAGGUI::BUTTON_SHORTCUT) endExecute(0); +} diff --git a/src/GameLoadScreen.h b/src/GameLoadScreen.h new file mode 100644 index 000000000..9c9b5a7fb --- /dev/null +++ b/src/GameLoadScreen.h @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once +#include "Glob2Screen.h" +#include +#include +#include +#include +class Engine; +namespace GAGGUI { class Text; } +// Cooperative loading: a reused engine must have finalized its active session. +class GameLoadScreen : public Glob2Screen +{ +public: + using Initializer = std::function; + explicit GameLoadScreen(Initializer initialize, GAGCore::CooperativeSlice slice = GAGCore::CooperativeSlice()); + GameLoadScreen(std::unique_ptr engine, Initializer initialize, + GAGCore::CooperativeSlice slice = GAGCore::CooperativeSlice()); + ~GameLoadScreen() override; + std::unique_ptr takeEngine(); + void onTimer(Uint32) override; + void onAction(GAGGUI::Widget*, GAGGUI::Action, int, int) override; + Uint32 executionDelay(Uint32, Uint32) override { return 1; } +private: + GAGCore::CooperativeSlice slice; + std::string previousRng; + std::unique_ptr engine; + std::optional task; + GAGGUI::Text* status; + bool accepted = false; +}; diff --git a/src/GameSessionScreen.cpp b/src/GameSessionScreen.cpp new file mode 100644 index 000000000..16836210e --- /dev/null +++ b/src/GameSessionScreen.cpp @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "GameSessionScreen.h" +#include "Engine.h" +#include "GameLoadScreen.h" +#include "MessageScreen.h" +#include "GlobalContainer.h" +#include "SoundMixer.h" +#include +#include +#include + +GameSessionScreen::GameSessionScreen(GAGGUI::ScreenStack& stack, std::unique_ptr engine) + : stack(stack), engine(std::move(engine)) +{ + if (!this->engine) throw std::invalid_argument("A game screen requires an initialized engine"); +} +GameSessionScreen::~GameSessionScreen() { if (started && engine) engine->restoreCursor(); } + +void GameSessionScreen::updateExecution(Uint32 tick) +{ + if (!isExecutionRunning() || finished) return; + if (!started) { + clock = lastTick = tick; + engine->prepareRun(); + engine->beginSession(clock); + nextTick = clock; + started = true; + } else { + if (resetClock) { lastTick = tick; nextTick = clock; resetClock = false; } + clock += static_cast(tick - lastTick); + lastTick = tick; + } + if (clock < nextTick) return; + const bool running = engine->stepSession(clock, input); + input.clear(); + nextTick = clock + engine->sessionDelay(clock); + if (!running) { + if (auto request = engine->finishSessionForHost()) { + engine->restoreCursor(); + started = false; + finished = true; + stack.push(std::make_unique(std::move(engine), [request = *request](Engine& next) { + return request.replay ? next.loadReplayTask(request.filename) : next.initCustomTask(request.filename); + }), [this](GAGGUI::Screen& loading, int result) { + if (result == 1) { + engine = static_cast(loading).takeEngine(); + finished = false; + resetClock = false; + input.clear(); + } else { + if (globalContainer->mix) globalContainer->mix->setNextTrack(MusicTrack::Menu, true); + if (result == 2) { + auto& strings = *GAGCore::Toolkit::getStringTable(); + stack.push(std::make_unique(strings.getString("[ERROR_CANT_LOAD_MAP]"), + std::vector{strings.getString("[ok]")}), + [this](GAGGUI::Screen&, int choice) { endExecute(choice); }); + } else endExecute(result); + } + }); + return; + } + finished = true; + auto endScreen = engine->endRunScreen(); + if (!endScreen) endExecute(QUIT_APPLICATION); + else stack.push(std::move(endScreen), [this](GAGGUI::Screen&, int result) { endExecute(result); }); + } +} + +void GameSessionScreen::handleExecutionEvent(SDL_Event event) +{ + // Engine/GameGUI translates native coordinates once, at consumption. + if (isExecutionRunning() && !finished) input.push_back(event); +} + +void GameSessionScreen::drawExecution() +{ + // The engine owns presentation, including nextFrame; don't also present + // through Screen::dispatchPaint. + if (started && !finished && isExecutionRunning()) engine->drawSession(); +} + +Uint32 GameSessionScreen::executionDelay(Uint32 now, Uint32 fallback) +{ + if (!started || finished) return 0; + return engine->sessionDelay(clock + static_cast(now - lastTick)); +} + +void GameSessionScreen::viewportResized(int oldWidth, int oldHeight, int width, int height) +{ + if (engine) engine->viewportResized(oldWidth, oldHeight, width, height); + input.clear(); + resetClock = true; +} + +void GameSessionScreen::suspendExecution() +{ + if (engine) engine->suspendInput(); + input.clear(); + resetClock = true; +} diff --git a/src/GameSessionScreen.h b/src/GameSessionScreen.h new file mode 100644 index 000000000..a8f533501 --- /dev/null +++ b/src/GameSessionScreen.h @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once +#include +#include +#include "FrontendTheme.h" +#include +#include +class Engine; + +// Retains the initialized engine through gameplay and the end-game screen. +// In-game load/replay requests transfer the finalized engine to a loader child. +class GameSessionScreen : public GAGGUI::Screen +{ +public: + GameSessionScreen(GAGGUI::ScreenStack& stack, std::unique_ptr engine); + ~GameSessionScreen() override; + void onAction(GAGGUI::Widget*, GAGGUI::Action, int, int) override {} + void updateExecution(Uint32 tick) override; + void suspendExecution() override; + void viewportResized(int oldWidth, int oldHeight, int width, int height) override; + void handleExecutionEvent(SDL_Event event) override; + void drawExecution() override; + Uint32 executionDelay(Uint32 now, Uint32 fallback) override; +private: + FrontendScope theme{false}; + GAGGUI::ScreenStack& stack; + std::unique_ptr engine; + std::vector input; + bool started = false, finished = false, resetClock = false; + Uint32 lastTick = 0; + Uint64 clock = 0, nextTick = 0; +}; diff --git a/src/Game_editor.cpp b/src/Game_editor.cpp index a1646435d..69a643dd8 100644 --- a/src/Game_editor.cpp +++ b/src/Game_editor.cpp @@ -23,7 +23,6 @@ #include "Brush.h" -#include "FertilityCalculatorDialog.h" #define BULLET_IMGID 0 @@ -80,6 +79,11 @@ int Game::buildingsCount(int team, int type, int level) void Game::addTeam(int pos) +{ + addTeamTask(pos).run(); +} + +GAGCore::CooperativeTask Game::addTeamTask(int pos) { assert(mapHeader.getNumberOfTeams()readUint32("stepCounter"); @@ -184,12 +189,12 @@ bool Game::load(GAGCore::InputStream *stream) stream->readUint32("SyncRandSeedC"); if (!readMatchingSignature(stream, FILE_SIG_GAME_SYNC, "signatureAfterSyncRand")) - return false; + co_return false; } else { if (!readMatchingSignature(stream, FILE_SIG_GAME_BUILT, "signatureBeforeTeams")) - return false; + co_return false; } ///Load teams @@ -197,33 +202,38 @@ bool Game::load(GAGCore::InputStream *stream) for (int i=0; ireadEnterSection(i); - teams[i]=new Team(stream, this, versionMinor); + co_await GAGCore::CooperativeTask::checkpoint("[Loading teams]"); + teams[i]=new Team(this); + if (!(co_await teams[i]->loadTask(stream, &globalContainer->buildingsTypes, versionMinor))) + co_return false; stream->readLeaveSection(); } stream->readLeaveSection(); if (!readMatchingSignature(stream, FILE_SIG_GAME_TEAM, "signatureAfterTeams")) - return false; + co_return false; // Load the map. Team has to be saved and loaded first. - if(!map.load(stream, mapHeader, this)) - return false; + if(!(co_await map.loadTask(stream, mapHeader, this))) + co_return false; if (!readMatchingSignature(stream, FILE_SIG_GAME_MAP, "signatureAfterMap")) - return false; + co_return false; // Load the players. Both Map and Team must be loaded first. stream->readEnterSection("players"); for (int i=0; ireadEnterSection(i); - players[i]=new Player(stream, teams, versionMinor); + co_await GAGCore::CooperativeTask::checkpoint("[Loading players]"); + players[i]=new Player(); + if (!players[i]->load(stream, teams, versionMinor)) co_return false; stream->readLeaveSection(); } stream->readLeaveSection(); if (!readMatchingSignature(stream, FILE_SIG_GAME_PLAYER, "signatureAfterPlayers")) - return false; + co_return false; // We have to finish Team's loading for (int i=0; i= FILE_FORMAT_VERSION_USL_MAPSCRIPT) { // This is the new map script system if (!mapscript.decodeData(stream, mapHeader.getVersionMinor())) - return false; + co_return false; } ///Load the campaign text for the game. @@ -283,7 +294,7 @@ bool Game::load(GAGCore::InputStream *stream) stream->readLeaveSection(); std::istringstream input(state.str()); input.imbue(std::locale::classic()); - if (!(input >> savedRandom)) return false; + if (!(input >> savedRandom)) co_return false; map.loadRuntimeState(stream); } gameSection.commit(); @@ -292,15 +303,10 @@ bool Game::load(GAGCore::InputStream *stream) ///compute it now if(mapHeader.getVersionMinor() < FILE_FORMAT_VERSION_PRE_FERTILITY) { - if(globalContainer->runNoX) - { - FertilityCalculator::compute(map, {}); - } - else - { - FertilityCalculatorDialog dialog(globalContainer->gfx, map); - dialog.runModal(); - } + FertilityCalculator::Job fertility(map); + while (!fertility.advance(65536)) + co_await GAGCore::CooperativeTask::checkpoint("[Computing Fertility]"); + fertility.commit(); } if (versionMinor >= FILE_FORMAT_VERSION_CONTINUATION_STATE && mapHeader.getIsSavedGame()) @@ -309,7 +315,7 @@ bool Game::load(GAGCore::InputStream *stream) hasSavedRandomState = true; } - return true; + co_return true; } bool Game::checkBuildingsDoNotOverlapAndHealMissing() { diff --git a/src/Game_orders.cpp b/src/Game_orders.cpp index 483debf39..66e44a5f1 100644 --- a/src/Game_orders.cpp +++ b/src/Game_orders.cpp @@ -26,7 +26,6 @@ #include "Brush.h" -#include "FertilityCalculatorDialog.h" #include "ReplayWriter.h" diff --git a/src/Game_sync.cpp b/src/Game_sync.cpp index 77619bcd1..aaaa11633 100644 --- a/src/Game_sync.cpp +++ b/src/Game_sync.cpp @@ -21,7 +21,6 @@ #include "Brush.h" -#include "FertilityCalculatorDialog.h" #include "ReplayWriter.h" diff --git a/src/Glob2.cpp b/src/Glob2.cpp index 0e5bded0f..86ece08f2 100644 --- a/src/Glob2.cpp +++ b/src/Glob2.cpp @@ -1,9 +1,14 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Copyright (C) 2001-2004 Stephane Magnenat & Luc-Olivier de Charrière +#include +#include #include "Glob2.h" #include "GlobalContainer.h" #include "YOGServer.h" +#ifdef GLOB2_ROUTER_ONLY +#include "YOGServerRouter.h" +#endif #ifndef YOG_SERVER_ONLY @@ -12,6 +17,8 @@ #include "CreditScreen.h" #include "EditorMainMenu.h" #include "Engine.h" +#include "Application.h" +#include "SinglePlayerFlow.h" #include "Game.h" #include "LANMenuScreen.h" #include "MainMenuScreen.h" @@ -75,43 +82,6 @@ GlobalContainer *globalContainer=NULL; #ifndef YOG_SERVER_ONLY -void Glob2::drawYOGSplashScreen(void) -{ - int w, h; - w=globalContainer->gfx->getW(); - h=globalContainer->gfx->getH(); - globalContainer->gfx->drawFilledRect(0, 0, w, h, 0, 0, 0); - std::string text[3]; - text[0]=Toolkit::getStringTable()->getString("[connecting to]"); - text[1]=Toolkit::getStringTable()->getString("[yog]"); - text[2]=Toolkit::getStringTable()->getString("[please wait]"); - for (int i=0; i<3; ++i) - { - int size=globalContainer->menuFont->getStringWidth(text[i]); - int dec=(w-size)>>1; - globalContainer->gfx->drawString(dec, 150+i*50, globalContainer->menuFont, text[i]); - } - globalContainer->gfx->nextFrame(); -} - -void Glob2::multiplayerYOG(void) -{ - if (verbose) - printf("Glob2:: starting YOGLoginScreen...\n"); - shared_ptr client(new YOGClient); - YOGLoginScreen yogLoginScreen(client); - int yogReturnCode=yogLoginScreen.execute(globalContainer->gfx, 40); - if (yogReturnCode==YOGLoginScreen::Cancelled) - return; - if (yogReturnCode==-1) - { - isRunning=false; - return; - } - if (verbose) - printf("Glob2::YOGLoginScreen has ended ...\n"); -} - int Glob2::runNoX() { printf("nox::running %d times %d steps:\n", globalContainer->runNoXCountRuns, globalContainer->automaticEndingSteps); @@ -454,9 +424,19 @@ int Glob2::run(int argc, char *argv[]) } atexit(SDLNet_Quit); + +#ifdef GLOB2_ROUTER_ONLY + const char* lobbyHost = std::getenv("GLOB2_YOG_HOST"); + YOGServerRouter router(lobbyHost ? lobbyHost : "127.0.0.1"); + int routerResult = router.run(); + delete globalContainer; + return routerResult; +#endif if (globalContainer->hostServer) { - YOGServer server(YOGRequirePassword, YOGMultipleGames); + const char* externalRouter = std::getenv("GLOB2_EXTERNAL_ROUTER"); + YOGServer server(YOGRequirePassword, YOGMultipleGames, + !(externalRouter && std::string(externalRouter) == "1")); int rc = server.run(); delete globalContainer; return rc; @@ -496,149 +476,13 @@ int Glob2::run(int argc, char *argv[]) return ret; } - isRunning=true; - - auto frontend = std::make_unique(); - // Replay the game specified by the command line - if (globalContainer->replaying) - { - Engine engine; - int rc_e = engine.loadReplay(globalContainer->replayFileName); - if (rc_e == Engine::EE_NO_ERROR) - isRunning = (engine.run() != -1); - else if(rc_e == -1) - isRunning = false; - } - - while (isRunning) - { - switch (MainMenuScreen::menu()) - { - case -1: - { - isRunning = false; - } - break; - case MainMenuScreen::CAMPAIGN: - { - CampaignMainMenu ccs; - int rccs=ccs.execute(globalContainer->gfx, 40); - if(rccs == Screen::QUIT_APPLICATION) - { - isRunning = false; - } - } - break; - case MainMenuScreen::TUTORIAL: - { - Campaign campaign; - if(campaign.load("games/Tutorial_Campaign.txt")) - { - CampaignMenuScreen cms("games/Tutorial_Campaign.txt"); - int rc_cms=cms.execute(globalContainer->gfx, 40); - if(rc_cms == -1) - { - isRunning = false; - } - } - else - { - CampaignMenuScreen cms("campaigns/Tutorial_Campaign.txt"); - cms.setNewCampaign(); - int rc_cms=cms.execute(globalContainer->gfx, 40); - if(rc_cms == -1) - { - isRunning = false; - } - } - } - break; - case MainMenuScreen::LOAD_GAME: - { - Engine engine; - int rc_e = engine.initLoadGame(); - if (rc_e == Engine::EE_NO_ERROR) - isRunning = (engine.run() != -1); - else if(rc_e == -1) - isRunning = false; - } - break; - case MainMenuScreen::CUSTOM: - { - bool cont=true; - while(cont && isRunning) - { - Engine engine; - int rc_e = engine.initCustom(); - if (rc_e == Engine::EE_NO_ERROR) - { - isRunning = (engine.run() != -1); - } - else if(rc_e == -1) - { - isRunning = false; - } - else - { - cont=false; - } - } - } - break; - case MainMenuScreen::MULTIPLAYERS_YOG: - { - multiplayerYOG(); - } - break; - case MainMenuScreen::MULTIPLAYERS_LAN: - { - LANMenuScreen lanms; - int rc_lms = lanms.execute(globalContainer->gfx, 40); - if(rc_lms == -1) - isRunning=false; - } - break; - case MainMenuScreen::GAME_SETUP: - { - SettingsScreen settingsScreen; - int rc_ss = settingsScreen.execute(globalContainer->gfx, 40); - if( rc_ss == -1) - { - isRunning=false; - } - } - break; - case MainMenuScreen::EDITOR: - { - EditorMainMenu editorMainMenu; - int rc=editorMainMenu.execute(globalContainer->gfx, 40); - if (rc==-1) - { - isRunning=false; - } - } - break; - case MainMenuScreen::CREDITS: - { - CreditScreen creditScreen; - if (creditScreen.execute(globalContainer->gfx, 40)==-1) - isRunning=false; - } - break; - case MainMenuScreen::QUIT: - { - isRunning=false; - } - break; - default: - break; - } - } - - frontend.reset(); - // This is for the text shot code - GAGCore::DrawableSurface::printFinishingText(); - delete globalContainer; + GAGCore::ApplicationHost::run(std::make_unique(), [] { + GAGCore::DrawableSurface::printFinishingText(); + delete globalContainer; + globalContainer = nullptr; + GAGCore::ApplicationHost::exited(0); + }); + return HOSTED_RUN; #endif // !YOG_SERVER_ONLY @@ -674,5 +518,7 @@ int main(int argc, char *argv[]) #endif Glob2 glob2; - return glob2.run(argc, argv); + int result = glob2.run(argc, argv); + if (result != Glob2::HOSTED_RUN) GAGCore::ApplicationHost::exited(result); + return result == Glob2::HOSTED_RUN ? 0 : result; } diff --git a/src/Glob2.h b/src/Glob2.h index 5a0c6733a..fa8f12a1c 100644 --- a/src/Glob2.h +++ b/src/Glob2.h @@ -8,12 +8,10 @@ class Glob2 { static const bool verbose = false; public: - //! true while the game is running - bool isRunning; + // Graphical completion is reported by the application host. + static constexpr int HOSTED_RUN = -1000; public: - void drawYOGSplashScreen(); - void multiplayerYOG(); int runNoX(); ///Runs random games non stop until the game crashes int runTestGames(); diff --git a/src/Glob2Screen.h b/src/Glob2Screen.h index b751ad5d8..1599a64c8 100644 --- a/src/Glob2Screen.h +++ b/src/Glob2Screen.h @@ -5,6 +5,7 @@ #include #include +#include "FrontendTheme.h" using namespace GAGCore; using namespace GAGGUI; @@ -16,8 +17,15 @@ class Glob2Screen : public Screen virtual ~Glob2Screen(); void paint(void) override; int execute(GAGCore::DrawableSurface* gfx, int stepLength) override; - + private: + // execute() only covers the old blocking loop; screens driven through + // ScreenStack::push() (browser cooperative scheduling) never call it, so + // they'd never get the theme's widget colors otherwise. A member (alive + // for the whole screen, not just one paint() call) covers both paths, + // same as GameSessionScreen/MapEditorScreen/CampaignEditor already do to + // opt out. Declared first so it's active before anything else runs. + FrontendScope theme; unsigned getNextTerrain(void); Uint32 randomSeed; // Background LCG intentionally wraps modulo 2^32. }; @@ -29,8 +37,10 @@ class Glob2TabScreen : public TabScreen virtual ~Glob2TabScreen(); void paint(void) override; int execute(GAGCore::DrawableSurface* gfx, int stepLength) override; - + private: + // See Glob2Screen for why this needs to be a long-lived member. + FrontendScope theme; unsigned getNextTerrain(void); Uint32 randomSeed; // Background LCG intentionally wraps modulo 2^32. }; diff --git a/src/GlobalContainer.cpp b/src/GlobalContainer.cpp index 2431f6aaa..8c6898761 100644 --- a/src/GlobalContainer.cpp +++ b/src/GlobalContainer.cpp @@ -49,6 +49,11 @@ GlobalContainer::GlobalContainer(const char *profileName) fileManager->addWriteSubdir("scripts"); fileManager->addWriteSubdir("videoshots"); +#ifdef __EMSCRIPTEN__ + // Start browser profiles quietly and without clouds. Saved preferences win. + settings.optionFlags |= OPTION_LOW_SPEED_GFX; + settings.mute = 1; +#endif // load user preference settings.load(); diff --git a/src/GlobalContainerArgs.cpp b/src/GlobalContainerArgs.cpp index dbb0601df..9cda2bb38 100644 --- a/src/GlobalContainerArgs.cpp +++ b/src/GlobalContainerArgs.cpp @@ -15,7 +15,7 @@ // version related stuff #ifdef HAVE_CONFIG_H - #include + #include #endif #ifndef PACKAGE_VERSION #define PACKAGE_VERSION "System Specific - not using autoconf" diff --git a/src/KeyboardManager.cpp b/src/KeyboardManager.cpp index a2d143e89..9a99c4f14 100644 --- a/src/KeyboardManager.cpp +++ b/src/KeyboardManager.cpp @@ -257,14 +257,13 @@ bool KeyboardManager::saveKeyboardLayout() const file = MapEditKeyActions::getConfigurationFile(); - std::string contents; - for(std::list::const_iterator i = shortcuts.begin(); i!=shortcuts.end(); ++i) - { - if(i->isShortcutValid()) - contents += i->format(mode) + "\n"; - } - - return Toolkit::getFileManager()->writeFileAtomic(file, contents); + return Toolkit::getFileManager()->writeAtomically(file, [this](OutputStream& output) { + for (const auto& shortcut : shortcuts) { + if (!shortcut.isShortcutValid()) continue; + const auto line = shortcut.format(mode) + "\n"; + output.write(line.data(), line.size(), "shortcut"); + } + }); } diff --git a/src/KeyboardManager.h b/src/KeyboardManager.h index 0c47fb912..f72e99649 100644 --- a/src/KeyboardManager.h +++ b/src/KeyboardManager.h @@ -72,7 +72,7 @@ class KeyboardManager ///Returns the integer action associated with the provided key. Uint32 getAction(const KeyPress& key); - ///Saves the keyboard layout + ///Atomically saves the layout locally; returns false without replacing it on failure. bool saveKeyboardLayout() const; ///Loads the keyboard layout, returns false in unsuccessful @@ -94,4 +94,3 @@ class KeyboardManager std::vector lastPresses; ShortcutMode mode; }; - diff --git a/src/LANFindScreen.cpp b/src/LANFindScreen.cpp index f03ca9fb8..19ab21fd2 100644 --- a/src/LANFindScreen.cpp +++ b/src/LANFindScreen.cpp @@ -6,19 +6,20 @@ #include "GlobalContainer.h" #include #include -#include +#include "LANSessionScreen.h" +#include #include #include #include #include #include "MultiplayerGameScreen.h" -#include "YOGClientBringup.h" +#include "YOGClient.h" #include "YOGClientGameListManager.h" using namespace GAGGUI; using std::shared_ptr; -LANFindScreen::LANFindScreen() +LANFindScreen::LANFindScreen(ScreenStack& screens) : screens(screens) { serverName=new TextInput(20, 170, 280, 30, ALIGN_SCREEN_CENTERED, ALIGN_SCREEN_CENTERED, "standard", "localhost", true); addWidget(serverName); @@ -82,57 +83,14 @@ void LANFindScreen::onAction(Widget *source, Action action, int par1, int par2) { if (par1==CONNECT) { - shared_ptr client(new YOGClient); - client->connect(serverName->getText()); - while(client->isConnecting()) - { - client->update(); - SDL_Delay(50); - } - - if(!client->isConnected()) - { - MessageBox(globalContainer->gfx, "standard", MB_ONEBUTTON, Toolkit::getStringTable()->getString("[Can't connect, can't find host]"), Toolkit::getStringTable()->getString("[ok]")); - return; - } - if(LANBringup::waitForConnectionState(*client, YOGClient::WaitingForLoginInformation) != LANBringup::Result::Reached) - { - MessageBox(globalContainer->gfx, "standard", MB_ONEBUTTON, Toolkit::getStringTable()->getString("[Can't connect, can't find host]"), Toolkit::getStringTable()->getString("[ok]")); - return; - } - client->attemptLogin(playerName->getText()); - if(LANBringup::waitForConnectionState(*client, YOGClient::ClientOnStandby) != LANBringup::Result::Reached) - { - MessageBox(globalContainer->gfx, "standard", MB_ONEBUTTON, Toolkit::getStringTable()->getString("[Can't connect, can't find host]"), Toolkit::getStringTable()->getString("[ok]")); - return; - } - - std::shared_ptr game(new MultiplayerGame(client)); - client->setMultiplayerGame(game); - - if(LANBringup::waitForGameList(*client) != LANBringup::Result::Reached) - { - MessageBox(globalContainer->gfx, "standard", MB_ONEBUTTON, Toolkit::getStringTable()->getString("[Can't connect, can't find host]"), Toolkit::getStringTable()->getString("[ok]")); - return; - } - - if((*client->getGameListManager()->getGameList().begin()).getGameState()==YOGGameInfo::GameRunning) - { - MessageBox(globalContainer->gfx, "standard", MB_ONEBUTTON, Toolkit::getStringTable()->getString("[Can't join game, game has started]"), Toolkit::getStringTable()->getString("[ok]")); - return; - } - - game->joinGame((*client->getGameListManager()->getGameList().begin()).getGameID()); - - Glob2TabScreen screen(true); - MultiplayerGameScreen lobby(&screen, game, client); - - listener.disableListening(); - int rc = screen.execute(globalContainer->gfx, 40); - listener.enableListening(); - client->setMultiplayerGame(std::shared_ptr()); - if(rc == -1) - endExecute(-1); + auto client = std::make_shared(); + client->connect(serverName->getText()); + listener.disableListening(); + screens.push(std::make_unique(screens, client, playerName->getText()), + [this](Screen&, int result) { + listener.enableListening(); + if (result == Screen::QUIT_APPLICATION) endExecute(result); + }); } else if (par1==QUIT) { diff --git a/src/LANFindScreen.h b/src/LANFindScreen.h index 132b9498e..08bf0c079 100644 --- a/src/LANFindScreen.h +++ b/src/LANFindScreen.h @@ -9,6 +9,7 @@ namespace GAGGUI { + class ScreenStack; class Text; class TextInput; class List; @@ -18,7 +19,7 @@ class LANFindScreen : public Glob2Screen { public: ///Construct a LANFindScreen - LANFindScreen(); + LANFindScreen(GAGGUI::ScreenStack& screens); virtual ~LANFindScreen(); void onTimer(Uint32 tick); @@ -34,6 +35,7 @@ class LANFindScreen : public Glob2Screen }; private: + GAGGUI::ScreenStack& screens; Text *serverText; TextInput *serverName; Text *playerText; diff --git a/src/LANMenuScreen.cpp b/src/LANMenuScreen.cpp index 5ff4ce56d..aa77a5ebb 100644 --- a/src/LANMenuScreen.cpp +++ b/src/LANMenuScreen.cpp @@ -6,19 +6,21 @@ #include "FormatableString.h" #include "GlobalContainer.h" #include -#include "GUIMessageBox.h" +#include "MessageScreen.h" +#include "LANSessionScreen.h" +#include #include #include "LANFindScreen.h" #include "LANMenuScreen.h" #include "MultiplayerGameScreen.h" #include #include -#include "YOGClientBringup.h" + #include "YOGServer.h" using std::shared_ptr; -LANMenuScreen::LANMenuScreen() +LANMenuScreen::LANMenuScreen(GAGGUI::ScreenStack& screens) : screens(screens) { addWidget(new TextButton(0, 70, 300, 40, ALIGN_CENTERED, ALIGN_SCREEN_CENTERED, "menu", Toolkit::getStringTable()->getString("[host]"), HOST)); addWidget(new TextButton(0, 130, 300, 40, ALIGN_CENTERED, ALIGN_SCREEN_CENTERED, "menu", Toolkit::getStringTable()->getString("[join a game]"), JOIN)); @@ -36,68 +38,29 @@ void LANMenuScreen::onAction(Widget *source, Action action, int par1, int par2) { if(par1 == JOIN) { - LANFindScreen lanfs; - int rc = lanfs.execute(globalContainer->gfx, 40); - if(rc==-1) - endExecute(-1); - else - endExecute(JoinedGame); - } - else if(par1 == HOST) - { - ChooseMapScreen cms("maps", "map", false, "games", "game", false); - int rc = cms.execute(globalContainer->gfx, 40); - if(rc == ChooseMapScreen::OK) - { - shared_ptr client(new YOGClient); - shared_ptr server(new YOGServer(YOGAnonymousLogin, YOGSingleGame)); - if(!server->isListening()) - { - MessageBox(globalContainer->gfx, "standard", MB_ONEBUTTON, FormattableString(Toolkit::getStringTable()->getString("[Can't host game, port %0 in use]")).arg(YOG_SERVER_PORT).c_str(), Toolkit::getStringTable()->getString("[ok]")); - endExecute(QuitMenu); - } - else - { - server->enableLANBroadcasting(); - client->attachGameServer(server); - client->connect("127.0.0.1"); - if(LANBringup::waitForConnectionState(*client, YOGClient::WaitingForLoginInformation) != LANBringup::Result::Reached) - { - MessageBox(globalContainer->gfx, "standard", MB_ONEBUTTON, Toolkit::getStringTable()->getString("[Can't connect, can't find host]"), Toolkit::getStringTable()->getString("[ok]")); - endExecute(QuitMenu); - return; - } - client->attemptLogin(globalContainer->settings.getUsername()); - if(LANBringup::waitForConnectionState(*client, YOGClient::ClientOnStandby) != LANBringup::Result::Reached) - { - MessageBox(globalContainer->gfx, "standard", MB_ONEBUTTON, Toolkit::getStringTable()->getString("[Can't connect, can't find host]"), Toolkit::getStringTable()->getString("[ok]")); - endExecute(QuitMenu); - return; - } - - std::shared_ptr game(new MultiplayerGame(client)); - client->setMultiplayerGame(game); - std::string name = FormattableString(Toolkit::getStringTable()->getString("[%0's game]")).arg(globalContainer->settings.getUsername()); - game->createNewGame(name); - game->setMapHeader(cms.getMapHeader()); - - ///Fix this! While this is technically right, the chat channel should be given by the server - Glob2TabScreen screen(true); - MultiplayerGameScreen* mgs = new MultiplayerGameScreen(&screen, game, client); - int rc = screen.execute(globalContainer->gfx, 40); - client->setMultiplayerGame(std::shared_ptr()); - if(rc == -1) - endExecute(-1); - else - endExecute(HostedGame); - delete mgs; - } - } - else if(rc == -1) - { - endExecute(-1); - } - } + screens.push(std::make_unique(screens), + [this](GAGGUI::Screen&, int) { endExecute(JoinedGame); }); + } + else if(par1 == HOST) + { + screens.push(std::make_unique("maps", "map", false, "games", "game", false), + [this](GAGGUI::Screen& selection, int result) { + if (result != ChooseMapScreen::OK) return; + auto client = std::make_shared(); + auto server = std::make_shared(YOGAnonymousLogin, YOGSingleGame); + if (!server->isListening()) { + screens.push(std::make_unique(FormattableString(Toolkit::getStringTable()->getString("[Can't host game, port %0 in use]")).arg(YOG_SERVER_PORT), + std::vector{Toolkit::getStringTable()->getString("[ok]")})); + return; + } + server->enableLANBroadcasting(); + client->attachGameServer(server); + client->connect("127.0.0.1"); + screens.push(std::make_unique(screens, client, globalContainer->settings.getUsername(), + static_cast(selection).getMapHeader()), + [this](GAGGUI::Screen&, int) { endExecute(HostedGame); }); + }); + } else if(par1 == QUIT) { endExecute(QuitMenu); diff --git a/src/LANMenuScreen.h b/src/LANMenuScreen.h index 647f789f1..40b575648 100644 --- a/src/LANMenuScreen.h +++ b/src/LANMenuScreen.h @@ -6,12 +6,14 @@ #include "Glob2Screen.h" +namespace GAGGUI { class ScreenStack; } + class LANMenuScreen : public Glob2Screen { public: ///Constructs a LAN menu screen - LANMenuScreen(); + LANMenuScreen(GAGGUI::ScreenStack& screens); virtual ~LANMenuScreen(); void onAction(Widget *source, Action action, int par1, int par2); @@ -21,8 +23,9 @@ class LANMenuScreen : public Glob2Screen JoinedGame, QuitMenu }; - - + +private: + GAGGUI::ScreenStack& screens; public: enum diff --git a/src/LANSessionScreen.cpp b/src/LANSessionScreen.cpp new file mode 100644 index 000000000..24a5c14bb --- /dev/null +++ b/src/LANSessionScreen.cpp @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "LANSessionScreen.h" +#include "MultiplayerGameScreen.h" +#include "MessageScreen.h" +#include "YOGClientGameListManager.h" +#include +#include +#include +#include +#include +#include + +using namespace GAGGUI; +using namespace GAGCore; +namespace { +class LANGameScreen final : public Glob2TabScreen { + std::shared_ptr client; + std::shared_ptr game; + MultiplayerGameScreen lobby; +public: + LANGameScreen(ScreenStack& screens, std::shared_ptr client, std::shared_ptr game) + : Glob2TabScreen(true), client(client), game(game), lobby(this, screens, game, client) {} + ~LANGameScreen() override { + if (game->getMultiplayerMode() != MultiplayerGame::NoMode) game->leaveGame(); + client->setMultiplayerGame({}); + } +}; +} +LANSessionScreen::LANSessionScreen(ScreenStack& screens, std::shared_ptr client, + std::string username, std::optional hostedMap) + : screens(screens), client(std::move(client)), username(std::move(username)), hostedMap(std::move(hostedMap)) +{ + addWidget(new Text(0, 200, ALIGN_FILL, ALIGN_SCREEN_CENTERED, "standard", + Toolkit::getStringTable()->getString("[connecting to game]"))); + addWidget(new TextButton(240, 280, 160, 35, ALIGN_SCREEN_CENTERED, ALIGN_SCREEN_CENTERED, "standard", + Toolkit::getStringTable()->getString("[Cancel]"), 0, 27)); +} +LANSessionScreen::~LANSessionScreen() { + if (auto game = client->getMultiplayerGame()) game->leaveGame(); + client->setMultiplayerGame({}); + client->disconnect(); +} +void LANSessionScreen::onAction(Widget*, Action action, int, int) { + if (action == BUTTON_RELEASED || action == BUTTON_SHORTCUT) endExecute(0); +} +void LANSessionScreen::fail(const char* message) { + stage = Stage::Failed; + screens.push(std::make_unique(Toolkit::getStringTable()->getString(message), + std::vector{Toolkit::getStringTable()->getString("[ok]")}), + [this](Screen&, int) { endExecute(1); }); +} +void LANSessionScreen::onTimer(Uint32 tick) { + if (stage == Stage::Lobby || stage == Stage::Failed) return; + if (!stageStarted) stageStarted = tick; + client->update(); + if ((!client->isConnecting() && !client->isConnected()) || Uint32(tick - *stageStarted) >= 10000) { + fail("[Can't connect, can't find host]"); return; + } + const auto connection = client->getConnectionState(); + if (stage == Stage::Greeting && connection == YOGClient::WaitingForLoginInformation) { + client->attemptLogin(username); + stage = Stage::Login; stageStarted = tick; + } else if (stage == Stage::Login) { + if (connection == YOGClient::WaitingForLoginInformation) { + fail("[Can't connect, can't find host]"); return; + } + if (connection == YOGClient::ClientOnStandby) { + stage = Stage::GameList; stageStarted = tick; + if (hostedMap) enterLobby(); + } + } else if (stage == Stage::GameList && !client->getGameListManager()->getGameList().empty()) { + if (client->getGameListManager()->getGameList().front().getGameState() == YOGGameInfo::GameRunning) + fail("[Can't join game, game has started]"); + else enterLobby(); + } +} +void LANSessionScreen::enterLobby() { + auto game = std::make_shared(client); + client->setMultiplayerGame(game); + if (hostedMap) { + game->createNewGame(FormattableString(Toolkit::getStringTable()->getString("[%0's game]")).arg(username)); + game->setMapHeader(*hostedMap); + } else game->joinGame(client->getGameListManager()->getGameList().front().getGameID()); + stage = Stage::Lobby; + screens.push(std::make_unique(screens, client, game), + [this](Screen&, int result) { endExecute(result); }); +} diff --git a/src/LANSessionScreen.h b/src/LANSessionScreen.h new file mode 100644 index 000000000..8e3315334 --- /dev/null +++ b/src/LANSessionScreen.h @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once +#include "Glob2Screen.h" +#include "MapHeader.h" +#include +#include +class YOGClient; +namespace GAGGUI { class ScreenStack; } + +// Owns an asynchronous LAN handshake and its lobby. The client is already +// connecting and may own an attached in-process server for hosting. +class LANSessionScreen : public Glob2Screen { +public: + LANSessionScreen(GAGGUI::ScreenStack& screens, std::shared_ptr client, + std::string username, std::optional hostedMap = {}); + ~LANSessionScreen() override; + void onTimer(Uint32 tick) override; + void onAction(GAGGUI::Widget*, GAGGUI::Action, int, int) override; +private: + enum class Stage { Greeting, Login, GameList, Lobby, Failed }; + void fail(const char* message); + void enterLobby(); + GAGGUI::ScreenStack& screens; + std::shared_ptr client; + std::string username; + std::optional hostedMap; + Stage stage = Stage::Greeting; + std::optional stageStarted; +}; diff --git a/src/MainMenuScreen.cpp b/src/MainMenuScreen.cpp index 048849c2c..0310f1198 100644 --- a/src/MainMenuScreen.cpp +++ b/src/MainMenuScreen.cpp @@ -11,7 +11,7 @@ #include #ifdef HAVE_CONFIG_H -#include + #include #endif #ifndef PACKAGE_VERSION #define PACKAGE_VERSION "Globulation 2" @@ -96,12 +96,20 @@ class MainMenuButton : public TextButton pressed = false; TextButton::onSDLMouseButtonUp(event); } + + void setGeometry(int nx, int ny, int nw, int nh) + { + x = nx; + y = ny; + w = nw; + h = nh; + } }; MainMenuScreen::MainMenuScreen() { const int width = globalContainer->gfx->getW(), height = globalContainer->gfx->getH(); - const bool compact = height < 640; + compact = height < 640; panelX = std::clamp(width / 20, 20, 72); panelH = std::min(height - 40, 620); panelY = (height - panelH) / 2; @@ -169,7 +177,9 @@ MainMenuScreen::MainMenuScreen() y += compact ? 12 : 18; add("[yog]", MULTIPLAYERS_YOG, compact ? 28 : 34, "front-small"); y += 4; +#ifndef __EMSCRIPTEN__ add("[lan]", MULTIPLAYERS_LAN, compact ? 28 : 34, "front-small"); +#endif y += compact ? 12 : 18; const char* keys[] = {"[settings]", "[editor]", "[credits]", "[quit]"}; const int actions[] = {GAME_SETUP, EDITOR, CREDITS, QUIT}; @@ -184,6 +194,45 @@ MainMenuScreen::MainMenuScreen() } } +void MainMenuScreen::layout(int width, int height) +{ + panelX = std::clamp(width / 20, 20, 72); + panelH = std::min(height - 40, 620); + panelY = (height - panelH) / 2; + panelW = compact ? 312 : 368; + if (buttons.empty()) return; + + const int x = panelX + 24, w = panelW - 48; + int y = panelY + (compact ? 74 : 110); + size_t index = 0; + auto place = [&](int height) { + buttons[index++]->setGeometry(x, y, w, height); + y += height; + }; + place(compact ? 38 : 46); + y += 8; + for (int i = 0; i < 3; ++i) { + place(compact ? 30 : 38); + y += 6; + } + y += compact ? 6 : 12; + place(compact ? 28 : 34); + y += 4; +#ifndef __EMSCRIPTEN__ + place(compact ? 28 : 34); +#endif + y += compact ? 12 : 18; + const int utilityH = compact ? 28 : 32; + for (int i = 0; i < 4; ++i) + buttons[index++]->setGeometry(x + (i % 2) * (w / 2 + 4), + y + (i / 2) * (utilityH + 4), w / 2 - 4, utilityH); +} + +void MainMenuScreen::viewportResized(int, int, int width, int height) +{ + layout(width, height); +} + MainMenuScreen::~MainMenuScreen() { for (const char* name : {"front-title", "front-action", "front-small", "front-caption"}) @@ -235,8 +284,3 @@ void MainMenuScreen::onAction(Widget* source, Action action, int par1, int par2) if (action == BUTTON_RELEASED || action == BUTTON_SHORTCUT) endExecute(par1); } - -int MainMenuScreen::menu() -{ - return MainMenuScreen().execute(globalContainer->gfx, 40); -} diff --git a/src/MainMenuScreen.h b/src/MainMenuScreen.h index 0b31a65ef..666e087b4 100644 --- a/src/MainMenuScreen.h +++ b/src/MainMenuScreen.h @@ -30,13 +30,15 @@ class MainMenuScreen:public Glob2Screen MainMenuScreen(); ~MainMenuScreen() override; void onAction(Widget *source, Action action, int par1, int par2) override; - static int menu(void); void paint(void) override; void onSDLEvent(SDL_Event *event) override; + void viewportResized(int oldWidth, int oldHeight, int width, int height) override; private: std::vector buttons; std::unique_ptr wordmark; int focusedButton = -1; int panelX, panelY, panelW, panelH; + bool compact = false; + void layout(int width, int height); }; diff --git a/src/MapEditorScreen.cpp b/src/MapEditorScreen.cpp new file mode 100644 index 000000000..56aba1c61 --- /dev/null +++ b/src/MapEditorScreen.cpp @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "MapEditorScreen.h" +#include "MapEdit.h" +#include "MessageScreen.h" +#include "FertilityScreen.h" +#include "EditorLoadScreen.h" +#include +#include +#include + +MapEditorScreen::MapEditorScreen(GAGGUI::ScreenStack& screens, std::unique_ptr editor) + : screens(screens), editor(std::move(editor)) +{ + if (!this->editor) throw std::invalid_argument("Map editor screen requires an editor"); +} +MapEditorScreen::~MapEditorScreen() = default; +void MapEditorScreen::updateExecution(Uint32 tick) +{ + if (!isExecutionRunning()) return; + lastFrame = tick; + if (!started) { editor->beginEditing(); started = true; } + const bool running = editor->advanceEditing(input, tick); + input.clear(); + if (!running) { endExecute(editor->editingReturnCode()); return; } + const auto replacement = editor->takeLoadRequest(); + if (!replacement.empty()) { + editor->suspendInput(); + screens.push(std::make_unique(replacement), [this](GAGGUI::Screen& loading, int result) { + if (result == 1) { + editor = static_cast(loading).takeEditor(); + started = false; + input.clear(); + } else if (result == 2) { + auto& strings = *GAGCore::Toolkit::getStringTable(); + screens.push(std::make_unique(strings.getString("[ERROR_CANT_LOAD_MAP]"), + std::vector{strings.getString("[ok]")})); + } + }); + } else if (editor->needsFertility()) { + editor->suspendInput(); + screens.push(std::make_unique(editor->game.map), + [this](GAGGUI::Screen&, int result) { + if (!editor->finishFertility(result == 1)) { + auto& strings = *GAGCore::Toolkit::getStringTable(); + screens.push(std::make_unique(strings.getString("[ERROR_CANT_SAVE_MAP]"), + std::vector{strings.getString("[ok]")})); + } + }); + } else if (editor->needsQuitDecision()) { + editor->suspendInput(); + auto& strings = *GAGCore::Toolkit::getStringTable(); + screens.push(std::make_unique(strings.getString("[save before quit?]"), + std::vector{strings.getString("[Yes]"), strings.getString("[No]"), strings.getString("[Cancel]")}), + [this](GAGGUI::Screen&, int choice) { editor->resolveQuitDecision(choice); }); + } +} +void MapEditorScreen::handleExecutionEvent(SDL_Event event) +{ + if (isExecutionRunning()) input.push_back(event); +} +void MapEditorScreen::drawExecution() +{ + if (started && isExecutionRunning()) editor->drawEditing(); +} +Uint32 MapEditorScreen::executionDelay(Uint32 now, Uint32) +{ + const Uint32 elapsed = now - lastFrame; + return elapsed < 33 ? 33 - elapsed : 0; +} + +void MapEditorScreen::viewportResized(int oldWidth, int oldHeight, int width, int height) +{ + editor->suspendInput(); + input.clear(); + editor->viewportResized(oldWidth, oldHeight, width, height); +} + +void MapEditorScreen::suspendExecution() +{ + editor->suspendInput(); + input.clear(); +} diff --git a/src/MapEditorScreen.h b/src/MapEditorScreen.h new file mode 100644 index 000000000..9a3884e2a --- /dev/null +++ b/src/MapEditorScreen.h @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once +#include +#include +#include "FrontendTheme.h" +#include +class MapEdit; +class MapEditorScreen : public GAGGUI::Screen +{ +public: + MapEditorScreen(GAGGUI::ScreenStack& screens, std::unique_ptr editor); + ~MapEditorScreen() override; + void onAction(GAGGUI::Widget*, GAGGUI::Action, int, int) override {} + void updateExecution(Uint32 tick) override; + void suspendExecution() override; + void viewportResized(int oldWidth, int oldHeight, int width, int height) override; + void handleExecutionEvent(SDL_Event event) override; + void drawExecution() override; + Uint32 executionDelay(Uint32 now, Uint32) override; +private: + FrontendScope theme{false}; + GAGGUI::ScreenStack& screens; + std::unique_ptr editor; + std::vector input; + bool started = false; + Uint32 lastFrame = 0; +}; diff --git a/src/MapScriptUSL.cpp b/src/MapScriptUSL.cpp index 62490b718..8785e0df5 100644 --- a/src/MapScriptUSL.cpp +++ b/src/MapScriptUSL.cpp @@ -6,6 +6,8 @@ using namespace GAGCore; #include "MapScriptUSL.h" +#include "usl.h" +#include "interpreter.h" #include "GameGUI.h" #include "position.h" diff --git a/src/MapScriptUSL.h b/src/MapScriptUSL.h index 76a6d718b..bf6f1113e 100644 --- a/src/MapScriptUSL.h +++ b/src/MapScriptUSL.h @@ -3,8 +3,6 @@ #pragma once -#include "usl.h" -#include "interpreter.h" #include "MapScriptError.h" #include "SDL.h" @@ -17,6 +15,7 @@ namespace GAGCore } class GameGUI; +struct Usl; ///This represents a USL based map script class MapScriptUSL diff --git a/src/MessageScreen.cpp b/src/MessageScreen.cpp new file mode 100644 index 000000000..458ee9bb6 --- /dev/null +++ b/src/MessageScreen.cpp @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "MessageScreen.h" +#include +#include +#include + +MessageScreen::MessageScreen(const std::string& message, const std::vector& captions) +{ + if (captions.empty() || captions.size() > 3) throw std::invalid_argument("Messages need one to three choices"); + addWidget(new GAGGUI::TextArea(20, 100, 600, 200, ALIGN_SCREEN_CENTERED, ALIGN_SCREEN_CENTERED, "standard", true, message.c_str())); + for (unsigned i = 0; i < captions.size(); ++i) + addWidget(new GAGGUI::TextButton(20 + i * 210, 340, 180, 40, + ALIGN_SCREEN_CENTERED, ALIGN_SCREEN_CENTERED, "menu", captions[i], i, + i == captions.size() - 1 ? 27 : 0)); +} +void MessageScreen::onAction(GAGGUI::Widget*, GAGGUI::Action action, int choice, int) +{ + if (action == GAGGUI::BUTTON_RELEASED || action == GAGGUI::BUTTON_SHORTCUT) endExecute(choice); +} diff --git a/src/MessageScreen.h b/src/MessageScreen.h new file mode 100644 index 000000000..2c98ea754 --- /dev/null +++ b/src/MessageScreen.h @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once +#include "Glob2Screen.h" +#include +#include + +// An in-game decision with explicit completion (caption index, or app quit). +class MessageScreen : public Glob2Screen +{ +public: + MessageScreen(const std::string& message, const std::vector& captions); + void onAction(GAGGUI::Widget*, GAGGUI::Action action, int choice, int) override; +}; diff --git a/src/MultiplayerGame.cpp b/src/MultiplayerGame.cpp index ebdc45b68..b7c2730cb 100644 --- a/src/MultiplayerGame.cpp +++ b/src/MultiplayerGame.cpp @@ -62,7 +62,7 @@ void MultiplayerGame::update() if(state == ConnectingToGameRouter) { //This is a special case, it means the router ip is the same as the yog ip - if(gameRouterIP == "YOGIP") + if(gameRouterIP == "YOGIP" || client->getIPAddress().rfind("wss://", 0) == 0) { gameRouterIP = client->getIPAddress(); } @@ -162,7 +162,9 @@ void MultiplayerGame::joinGame(Uint16 ngameID) void MultiplayerGame::leaveGame() -{ +{ + startRequested = false; + waitingForEngine = false; shared_ptr message(new NetLeaveGame); client->sendNetMessage(message); @@ -251,6 +253,7 @@ void MultiplayerGame::updatePlayerChanges() void MultiplayerGame::setNetEngine(NetEngine* nnetEngine) { netEngine = nnetEngine; + if (netEngine) waitingForEngine = false; } @@ -484,7 +487,8 @@ void MultiplayerGame::receiveMessage(std::shared_ptr message) } if(type==MNetStartGame) { - startEngine(); + startRequested = true; + waitingForEngine = true; } if(type==MNetRefuseGameStart) { @@ -602,33 +606,34 @@ void MultiplayerGame::receiveMessage(std::shared_ptr message) -void MultiplayerGame::startEngine() +bool MultiplayerGame::takeStartRequest() { - Engine engine; - // host game and wait for players. This clever trick is meant to get a proper shared_ptr - // to (this), because shared_ptr's must be copied from the original - int rc=engine.initMultiplayer(client->getMultiplayerGame(), client, getLocalPlayer()); - // execute game - if (rc==Engine::EE_NO_ERROR) - { - shared_ptr event(new MGGameStarted); - sendToListeners(event); + const bool requested = startRequested; + startRequested = false; + return requested; +} - if (engine.run()==-1) - { - shared_ptr event(new MGGameExitEvent); - sendToListeners(event); - } - else - { - shared_ptr event(new MGGameEndedNormallyEvent); - sendToListeners(event); - } - } - // redraw all stuff - netEngine = NULL; +void MultiplayerGame::sessionStarted() +{ + sendToListeners(std::make_shared()); } +void MultiplayerGame::sessionEnded(bool quitApplication) +{ + waitingForEngine = false; + netEngine = nullptr; + if (quitApplication) sendToListeners(std::make_shared()); + else sendToListeners(std::make_shared()); +} + +void MultiplayerGame::startEngine() +{ + Engine engine; + if (engine.initMultiplayer(client->getMultiplayerGame(), client, getLocalPlayer()) == Engine::EE_NO_ERROR) { + sessionStarted(); + sessionEnded(engine.run() == -1); + } else sessionEnded(false); +} void MultiplayerGame::setDefaultGameHeaderValues() diff --git a/src/MultiplayerGame.h b/src/MultiplayerGame.h index fb79815c3..d2f658221 100644 --- a/src/MultiplayerGame.h +++ b/src/MultiplayerGame.h @@ -169,14 +169,20 @@ class MultiplayerGame ///This is true if the map and game headers have been received and the game is connected to the game router bool isFullyInGame(); + // Network dispatch only records a start request. Hosts consume it after update. + bool takeStartRequest(); + bool isWaitingForEngine() const { return waitingForEngine; } + int getLocalPlayer(); + void sessionStarted(); + void sessionEnded(bool quitApplication); + // Explicit synchronous host for native headless callers. + void startEngine(); protected: friend class YOGClient; ///This receives a message that is sent to the game void receiveMessage(std::shared_ptr message); - ///This will start the game - void startEngine(); ///Sets the default values for latency and order frame rate in the game header for a YOG game void setDefaultGameHeaderValues(); @@ -187,7 +193,6 @@ class MultiplayerGame ///Puts together reteaming information from the game header in the file NetReteamingInformation constructReteamingInformation(const std::string& file); - int getLocalPlayer(); private: std::shared_ptr client; @@ -215,6 +220,8 @@ class MultiplayerGame //Miscellaneous bool isStarting; + bool startRequested = false; + bool waitingForEngine = false; bool needToSendMapHeader; Uint8 previousPercentage; Uint8 numberOfConnectionAttempts; diff --git a/src/MultiplayerGameScreen.cpp b/src/MultiplayerGameScreen.cpp index 78562b102..084813da0 100644 --- a/src/MultiplayerGameScreen.cpp +++ b/src/MultiplayerGameScreen.cpp @@ -20,11 +20,17 @@ #include "YOGMessage.h" #include "CustomGameOtherOptions.h" +#include "GameLoadScreen.h" +#include "GameSessionScreen.h" +#include "MessageScreen.h" +#include "Engine.h" +#include +#include using std::static_pointer_cast; -MultiplayerGameScreen::MultiplayerGameScreen(TabScreen* parent, std::shared_ptr game, std::shared_ptr client, std::shared_ptr ircChat) - : TabScreenWindow(parent, Toolkit::getStringTable()->getString("[Game]")), game(game), gameChat(new YOGClientChatChannel(YOG_CHAT_CHANNEL_NONE, client)), ircChat(ircChat) +MultiplayerGameScreen::MultiplayerGameScreen(TabScreen* parent, ScreenStack& screens, std::shared_ptr game, std::shared_ptr client, std::shared_ptr ircChat) + : TabScreenWindow(parent, Toolkit::getStringTable()->getString("[Game]")), screens(screens), client(client), game(game), gameChat(new YOGClientChatChannel(YOG_CHAT_CHANNEL_NONE, client)), ircChat(ircChat) { // we don't want to add AI_NONE for (size_t i=1; iremoveEventListener(this); gameChat->removeListener(this); } @@ -127,6 +134,7 @@ void MultiplayerGameScreen::onTimer(Uint32 tick) { TabScreenWindow::onTimer(tick); game->update(); + if (game->takeStartRequest()) launchScheduledGame(); if(ircChat) ircChat->update(); } @@ -162,9 +170,8 @@ void MultiplayerGameScreen::onAction(Widget *source, Action action, int par1, in bool readOnly = true; if(game->getMultiplayerMode() == MultiplayerGame::HostingGame) readOnly = false; - CustomGameOtherOptions settings(game->getGameHeader(), game->getMapHeader(), readOnly); - settings.execute(globalContainer->gfx, 40); - game->updateGameHeader(); + screens.push(std::make_unique(game->getGameHeader(), game->getMapHeader(), readOnly), + [this](Screen&, int) { game->updateGameHeader(); }); } } else if (action==BUTTON_STATE_CHANGED) @@ -417,6 +424,7 @@ void MultiplayerGameScreen::updateVisibleButtons() percentDownloaded->visible=false; } } + GAGCore::ApplicationHost::roomReady(startButton->visible); } @@ -426,3 +434,23 @@ void MultiplayerGameScreen::onActivated() updateVisibleButtons(); } + +void MultiplayerGameScreen::launchScheduledGame() +{ + GAGCore::ApplicationHost::roomReady(false); + screens.push(std::make_unique([game = game, client = client](Engine& engine) { + return engine.initMultiplayerTask(game, client, game->getLocalPlayer()); + }), [this](Screen& load, int result) { + if (result != 1) { + game->sessionEnded(false); + if (result == 2) screens.push(std::make_unique( + Toolkit::getStringTable()->getString("[ERROR_CANT_LOAD_MAP]"), + std::vector{Toolkit::getStringTable()->getString("[ok]")})); + return; + } + auto engine = static_cast(load).takeEngine(); + game->sessionStarted(); + screens.push(std::make_unique(screens, std::move(engine)), + [this](Screen&, int result) { game->sessionEnded(result == Screen::QUIT_APPLICATION); }); + }); +} diff --git a/src/MultiplayerGameScreen.h b/src/MultiplayerGameScreen.h index 477971aa3..fa7465ca3 100644 --- a/src/MultiplayerGameScreen.h +++ b/src/MultiplayerGameScreen.h @@ -16,6 +16,7 @@ namespace GAGGUI { + class ScreenStack; class Text; class TextArea; class TextInput; @@ -35,7 +36,7 @@ class MultiplayerGameScreen : public TabScreenWindow, public YOGClientChatListen { public: ///The screen must be provided with the client, the irc connection and the multiplayer game - MultiplayerGameScreen(TabScreen* parent, std::shared_ptr game, std::shared_ptr client, std::shared_ptr ircChat = std::shared_ptr()); + MultiplayerGameScreen(TabScreen* parent, ScreenStack& screens, std::shared_ptr game, std::shared_ptr client, std::shared_ptr ircChat = std::shared_ptr()); virtual ~MultiplayerGameScreen(); enum @@ -49,6 +50,9 @@ class MultiplayerGameScreen : public TabScreenWindow, public YOGClientChatListen }; private: + ScreenStack& screens; + std::shared_ptr client; + void launchScheduledGame(); enum { START = 1, diff --git a/src/Order.h b/src/Order.h index 61e4981d9..07627e1b2 100644 --- a/src/Order.h +++ b/src/Order.h @@ -37,7 +37,7 @@ static constexpr Sint32 ORDER_CREATE_NO_FLAG_RADIUS = -1; //! Length, in bytes, of the big-endian length prefix that precedes every //! framed network message (TCP and UDP alike). See -//! NetConnectionThread.cpp:111-115, 182, 192-194; NetBroadcaster.cpp:53-55; +//! NetConnection.cpp; NetBroadcaster.cpp:53-55; //! NetBroadcastListener.cpp:38. static constexpr int NET_FRAME_LENGTH_PREFIX_BYTES = 2; diff --git a/src/PerlinNoise.cpp b/src/PerlinNoise.cpp index 8d6fb30fc..7feedfaca 100644 --- a/src/PerlinNoise.cpp +++ b/src/PerlinNoise.cpp @@ -39,18 +39,10 @@ PerlinNoise::PerlinNoise() { PerlinNoise::~PerlinNoise() { } -// initialize static variables - -unsigned PerlinNoise::initialized = 0; -unsigned PerlinNoise::permutationTable[ NOISE_WRAP_INDEX*2 + 2 ] = { 0 }; -float PerlinNoise::gradientTable1d[ NOISE_WRAP_INDEX*2 + 2 ] = { 0 }; -float PerlinNoise::gradientTable2d[ NOISE_WRAP_INDEX*2 + 2 ][ 2 ] = { { 0 } }; -float PerlinNoise::gradientTable3d[ NOISE_WRAP_INDEX*2 + 2 ][ 3 ] = { { 0 } }; - // return a random float in [-1,1] inline float PerlinNoise::randNoiseFloat() { - return ( float ) ( ( rand() % ( NOISE_WRAP_INDEX + NOISE_WRAP_INDEX ) ) - + return ( float ) ( ( int(random() % ( NOISE_WRAP_INDEX + NOISE_WRAP_INDEX )) ) - NOISE_WRAP_INDEX ) / NOISE_WRAP_INDEX; }; @@ -58,6 +50,7 @@ inline float PerlinNoise::randNoiseFloat() { void PerlinNoise::normalize2d( float vector[ 2 ] ) { float length = sqrt( ( vector[ 0 ] * vector[ 0 ] ) + ( vector[ 1 ] * vector[ 1 ] ) ); + if (length == 0.f) { vector[0] = 1.f; return; } vector[ 0 ] /= length; vector[ 1 ] /= length; } @@ -68,6 +61,7 @@ void PerlinNoise::normalize3d( float vector[ 3 ] ) { float length = sqrt( ( vector[ 0 ] * vector[ 0 ] ) + ( vector[ 1 ] * vector[ 1 ] ) + ( vector[ 2 ] * vector[ 2 ] ) ); + if (length == 0.f) { vector[0] = 1.f; return; } vector[ 0 ] /= length; vector[ 1 ] /= length; vector[ 2 ] /= length; @@ -223,14 +217,14 @@ float PerlinNoise::Noise( float x, float y, float z ) { // reinitialize with new, random values. void PerlinNoise::reseed() { - srand( ( unsigned int ) ( time( NULL ) + rand() ) ); + random.seed(random()); generateLookupTables(); } // reinitialize using a user-specified random seed. void PerlinNoise::reseed( unsigned int rSeed ) { - srand( rSeed ); + random.seed(rSeed); generateLookupTables(); } @@ -256,7 +250,7 @@ void PerlinNoise::generateLookupTables() { // Shuffle permutation table up to NOISE_WRAP_INDEX for ( i = 0; i < NOISE_WRAP_INDEX; i++ ) { - j = rand() & NOISE_MOD_MASK; + j = random() & NOISE_MOD_MASK; temp = permutationTable[ i ]; permutationTable[ i ] = permutationTable[ j ]; permutationTable[ j ] = temp; diff --git a/src/PerlinNoise.h b/src/PerlinNoise.h index ce9618b00..62ab824e4 100644 --- a/src/PerlinNoise.h +++ b/src/PerlinNoise.h @@ -1,6 +1,6 @@ #pragma once -#include +#include // It must be true that (x % NOISE_WRAP_INDEX) == (x & NOISE_MOD_MASK) // so NOISE_WRAP_INDEX must be a power of two, and NOISE_MOD_MASK must be @@ -8,25 +8,26 @@ // NOISE_WRAP_INDEX should be less than or equal to 256. // There's no good reason to change it from 256, really. -#define NOISE_WRAP_INDEX 256 -#define NOISE_MOD_MASK 255 +#define NOISE_WRAP_INDEX 256 +#define NOISE_MOD_MASK 255 #define NOISE_LARGE_PWR2 4096 class PerlinNoise { private: - static unsigned initialized; + unsigned initialized = 0; + std::mt19937 random; - static unsigned permutationTable[ NOISE_WRAP_INDEX*2 + 2 ]; - static float gradientTable1d[ NOISE_WRAP_INDEX*2 + 2 ]; - static float gradientTable2d[ NOISE_WRAP_INDEX*2 + 2 ][ 2 ]; - static float gradientTable3d[ NOISE_WRAP_INDEX*2 + 2 ][ 3 ]; + unsigned permutationTable[ NOISE_WRAP_INDEX*2 + 2 ]; + float gradientTable1d[ NOISE_WRAP_INDEX*2 + 2 ]; + float gradientTable2d[ NOISE_WRAP_INDEX*2 + 2 ][ 2 ]; + float gradientTable3d[ NOISE_WRAP_INDEX*2 + 2 ][ 3 ]; - static float randNoiseFloat(); - static void normalize2d( float vector[ 2 ] ); - static void normalize3d( float vector[ 3 ] ); - static void generateLookupTables(); + float randNoiseFloat(); + static void normalize2d( float vector[ 2 ] ); + static void normalize3d( float vector[ 3 ] ); + void generateLookupTables(); public: @@ -34,16 +35,16 @@ class PerlinNoise { PerlinNoise( unsigned int rSeed ) { reseed(rSeed); } ~PerlinNoise(); - static void reseed(); - static void reseed( unsigned int rSeed ); + void reseed(); + void reseed( unsigned int rSeed ); - float Noise1d( float pos[ 1 ] ); - float Noise2d( float pos[ 2 ] ); - float Noise3d( float pos[ 3 ] ); + float Noise1d( float pos[ 1 ] ); + float Noise2d( float pos[ 2 ] ); + float Noise3d( float pos[ 3 ] ); - float Noise( float ); - float Noise( float, float ); - float Noise( float, float, float ); + float Noise( float ); + float Noise( float, float ); + float Noise( float, float, float ); }; diff --git a/src/Player.cpp b/src/Player.cpp index 0cb42e19f..142b0398c 100644 --- a/src/Player.cpp +++ b/src/Player.cpp @@ -127,6 +127,7 @@ bool Player::load(GAGCore::InputStream *stream, Team *teams[Team::MAX_COUNT], Si // player startPositionX = stream->readSint32("startPositionX"); startPositionY = stream->readSint32("startPositionY"); + if (!teams[teamNumber]) return false; setTeam(teams[teamNumber]); if (type >= P_AI) { diff --git a/src/ReplayWriter.cpp b/src/ReplayWriter.cpp index a97cdeb0d..20bfe7190 100644 --- a/src/ReplayWriter.cpp +++ b/src/ReplayWriter.cpp @@ -137,41 +137,20 @@ bool ReplayWriter::write(const std::string &filename) const // Make sure the buffer is flushed buffer->flush(); - // Open the file as a backend - StreamBackend* fileBackend = Toolkit::getFileManager()->openOutputStreamBackend(filename); - assert(fileBackend->isValid()); - - // Open the file as an OutputStream - OutputStream* file = new BinaryOutputStream(fileBackend); - - // Save the current position in the buffer - size_t pos = bufferBackend->getPosition(); - - // Go back to the beginning of the buffer - bufferBackend->seekFromStart(0); - - // Copy the buffer to the file - while (!bufferBackend->isEndOfStream()) - { - int c = bufferBackend->getChar(); - if (bufferBackend->isEndOfStream()) break; - fileBackend->putc(c); - } - - // Write the number of steps since last order to the end of the replay - file->writeUint32(0, "replayStepsSinceLastOrder"); - - // Write a NullOrder to the file to make sure it's a NullOrder-terminated replay - writeOrder(file, std::shared_ptr(new NullOrder()), 0); - - // Flush the file - file->flush(); - delete file; - - // Go back to the right position in the buffer - buffer->seekFromStart(pos); - - return true; + const size_t pos = bufferBackend->getPosition(); + const bool saved = Toolkit::getFileManager()->writeAtomically(filename, [&](GAGCore::OutputStream& file) { + bufferBackend->seekFromStart(0); + while (!bufferBackend->isEndOfStream()) { + const int c = bufferBackend->getChar(); + if (bufferBackend->isEndOfStream()) break; + file.writeUint8(static_cast(c), "replayByte"); + } + file.writeUint32(0, "replayStepsSinceLastOrder"); + writeOrder(&file, std::shared_ptr(new NullOrder()), 0); + }); + // Atomic writer failures must not leave the live recording's cursor moved. + bufferBackend->seekFromStart(pos); + return saved; } GAGCore::OutputStream* ReplayWriter::getBuffer() const diff --git a/src/SConscript b/src/SConscript index 1cfb2b1e5..11645ae55 100644 --- a/src/SConscript +++ b/src/SConscript @@ -1,470 +1,10 @@ -source_files = Split(""" -ai/castor/Control.cpp -ai/castor/GetOrder.cpp -ai/castor/Lifecycle.cpp -ai/castor/Maps.cpp -ai/castor/Placement.cpp -ai/castor/Projects.cpp -ai/castor/State.cpp -ai/AI.cpp -ai/AIDescriptionScreen.cpp -ai/echo/BuildingOrder.cpp -ai/echo/BuildingRegister.cpp -ai/echo/Conditions.cpp -ai/echo/ConditionsBuilding.cpp -ai/echo/ConditionsPopulation.cpp -ai/echo/ConditionsTracker.cpp -ai/echo/Construction.cpp -ai/echo/ConstructionConstraints.cpp -ai/echo/Echo.cpp -ai/echo/EchoSerialization.cpp -ai/echo/Entities.cpp -ai/echo/EntitiesBuilding.cpp -ai/echo/EntitiesResource.cpp -ai/echo/EntitiesTerrain.cpp -ai/echo/Gradient.cpp -ai/echo/GradientBFS.cpp -ai/echo/Management.cpp -ai/echo/ManagementFlag.cpp -ai/echo/ManagementMisc.cpp -ai/echo/ManagementOrderBase.cpp -ai/echo/ManagementTracker.cpp -ai/echo/MapInfo.cpp -ai/echo/Econo.cpp -ai/echo/EconoBuilding.cpp -ai/echo/EconoFlags.cpp -ai/echo/SearchTools.cpp -ai/AINames.cpp -ai/nicowar/Attack.cpp -ai/nicowar/Buildings.cpp -ai/nicowar/Farming.cpp -ai/nicowar/Flags.cpp -ai/nicowar/Lifecycle.cpp -ai/nicowar/Phases.cpp -ai/nicowar/Strategy.cpp -ai/nicowar/Upgrade.cpp -ai/AINull.cpp -ai/AINumbi.cpp -ai/AINumbiEconomy.cpp -ai/AINumbiMilitary.cpp -ai/AINumbiPlacement.cpp -ai/AIWarrush.cpp -ai/cortex/CortexObservation.cpp -ai/cortex/CortexObservationObserve.cpp -ai/cortex/CortexPlacement.cpp -ai/cortex/CortexPlacementCandidates.cpp -ai/cortex/CortexPlacementGeo.cpp -ai/cortex/CortexPolicy.cpp -ai/cortex/CortexPolicyEconomy.cpp -ai/cortex/CortexPolicyTech.cpp -ai/cortex/CortexPolicyCombat.cpp -ai/cortex/CortexTuning.cpp -ai/cortex/CortexWheat.cpp -ai/cortex/CortexWater.cpp -ai/cortex/CortexNet.cpp -ai/cortex/AICortex.cpp -ai/cortex/AICortexFlags.cpp -ai/cortex/AICortexTranslate.cpp -ai/cortex/AICortexDebug.cpp -BasePlayer.cpp -BaseTeam.cpp -BitArray.cpp -Brush.cpp -building/Lifecycle.cpp -building/Construction.cpp -building/Update.cpp -building/Step.cpp -building/TypeSteps.cpp -building/Misc.cpp -game/entities/Buildings.cpp -game/entities/BuildingTypesColony.cpp -game/entities/BuildingTypesDefence.cpp -game/entities/BuildingTypesFlags.cpp -game/entities/BuildingTypesUpgrade.cpp -ChecksumSidecar.cpp -DatasetWriter.cpp -building/BuildingUtils.cpp -Bullet.cpp -Campaign.cpp -CampaignEditor.cpp -CampaignMainMenu.cpp -CampaignMenuScreen.cpp -CampaignSelectorScreen.cpp -ChooseMapScreen.cpp -CreditScreen.cpp -CustomGameOtherOptions.cpp -CustomGameScreen.cpp -DynamicClouds.cpp -EditorMainMenu.cpp -EndGameScreen.cpp -Engine.cpp -EngineInit.cpp -EngineLoaders.cpp -EngineRun.cpp -FertilityCalculator.cpp -FertilityCalculatorDialog.cpp -Game.cpp -Game_orders.cpp -Game_io.cpp -Game_sync.cpp -Game_editor.cpp -render/GameRender.cpp -render/GameRenderUnits.cpp -render/GameRenderBuildings.cpp -render/GameRenderTerrain.cpp -render/GameRenderOverlay.cpp -render/GameAnimations.cpp -GameEvent.cpp -gui/BuildingGuiState.cpp -gui/GameGUI.cpp -gui/GameGUIDefaultAssignManager.cpp -gui/GameGUIDialog.cpp -gui/GameGUIDraw.cpp -gui/GameGUIDrawChoice.cpp -gui/GameGUIDrawUnitInfos.cpp -gui/GameGUIDrawBuildingInfos.cpp -gui/GameGUIDrawBuildingHelpers.cpp -gui/GameGUIDrawMiscPanels.cpp -gui/GameGUIGhostBuildingManager.cpp -gui/GameGUIInput.cpp -gui/GameGUIInputKey.cpp -gui/GameGUIInputMenu.cpp -gui/GameGUIInputMenuClick.cpp -gui/GameGUIInputMenuClickBuilding.cpp -gui/GameGUIInputMouse.cpp -gui/GameGUIKeyActions.cpp -gui/GameGUILoadSave.cpp -gui/GameGUIMessageManager.cpp -gui/GameGUIOrders.cpp -gui/GameMusicController.cpp -gui/GameGUIParticles.cpp -gui/GameGUIPersistence.cpp -gui/GameGUIScript.cpp -gui/GameGUISelection.cpp -gui/GameGUIStep.cpp -gui/GameGUIToolManager.cpp -gui/TeamDisplay.cpp -gui/UnitDisplayNames.cpp -GameHeader.cpp -GameHints.cpp -GameObjectives.cpp -GameUtilities.cpp -Glob2.cpp -Glob2Screen.cpp -Glob2Style.cpp -FrontendTheme.cpp -MenuColony.cpp -GlobalContainer.cpp -GlobalContainerArgs.cpp -GUIGlob2FileList.cpp -GUIMapPreview.cpp -map/generator/HeightMapGenerator.cpp -building/IntBuildingType.cpp -net/irc/IRC.cpp -net/irc/IRCTextMessageHandler.cpp -net/irc/IRCThread.cpp -net/irc/IRCThreadMessage.cpp -KeyboardManager.cpp -LANFindScreen.cpp -LANGameInformation.cpp -LANMenuScreen.cpp -MainMenuScreen.cpp -map/Map.cpp -map/gradient/MapGradientArea.cpp -map/gradient/MapGradientBuilding.cpp -map/gradient/MapGradientGlobal.cpp -map/gradient/MapGradientField.cpp -map/io/MapExploredAreaIO.cpp -map/io/MapIO.cpp -map/MapMisc.cpp -map/pathfind/MapPathfindArea.cpp -map/pathfind/MapPathfindBuilding.cpp -map/pathfind/MapPathfindRessource.cpp -map/MapQuery.cpp -map/MapResources.cpp -map/MapStep.cpp -map/MapTerrain.cpp -render/MapView.cpp -map/edit/Widgets.cpp -map/edit/WidgetsTools.cpp -map/edit/WidgetsUnit.cpp -map/edit/WidgetsBuilding.cpp -map/edit/MapEditCtor.cpp -map/edit/MapEditIO.cpp -map/edit/MapEditDraw.cpp -map/edit/MapEditEvents.cpp -map/edit/MapEditAction.cpp -map/edit/MapEditActionView.cpp -map/edit/MapEditActionTerrain.cpp -map/edit/MapEditActionUnit.cpp -map/edit/MapEditActionBuilding.cpp -map/edit/MapEditDelegate.cpp -map/edit/MapEditClicks.cpp -map/edit/MapEditDialog.cpp -map/edit/MapEditKeyActions.cpp -map/generator/MapGenerationDescriptor.cpp -map/generator/Generator.cpp -map/generator/GeneratorDivide.cpp -map/generator/GeneratorSplit.cpp -map/generator/GeneratorPoints.cpp -map/generator/GeneratorHeightmap.cpp -map/generator/MapHomogen.cpp -map/generator/MapOldRandom.cpp -map/generator/MapRandom.cpp -map/generator/MapOldIslands.cpp -map/generator/GameMaps.cpp -map/io/MapHeader.cpp -MapScript.cpp -MapScriptError.cpp -MapScriptUSL.cpp -map/io/MapThumbnail.cpp -MarkManager.cpp -render/Minimap.cpp -MultiplayerGame.cpp -MultiplayerGameEvent.cpp -MultiplayerGameEventListener.cpp -MultiplayerGameScreen.cpp -net/NetBroadcaster.cpp -net/NetBroadcastListener.cpp -net/NetConnection.cpp -net/NetConnectionThread.cpp -net/NetConnectionThreadMessage.cpp -net/NetEngine.cpp -net/NetGamePlayerManager.cpp -net/NetListener.cpp -net/message/NetMessage.cpp -net/message/AuthMessages.cpp -net/message/FileTransferMessages.cpp -net/message/GameCreateMessages.cpp -net/message/GameHeaderMessages.cpp -net/message/GameJoinMessages.cpp -net/message/GameLaunchMessages.cpp -net/message/GameTeamMessages.cpp -net/message/LobbyMessages.cpp -net/message/MapDatabaseMessages.cpp -net/message/MapUploadMessages.cpp -net/message/MessageRecipients.cpp -net/message/OrderMessages.cpp -net/message/RegistrationMessages.cpp -net/message/RouterAdminMessages.cpp -net/message/RouterMessages.cpp -net/NetReteamingInformation.cpp -net/NetTestSuite.cpp -NewMapScreen.cpp -Order.cpp -OrderBuilding.cpp -OrderModify.cpp -OrderMisc.cpp -OverlayAreas.cpp -OverlayFill.cpp -PerlinNoise.cpp -Player.cpp -game/entities/Race.cpp -ReplayReader.cpp -ReplayWriter.cpp -Ressource.cpp -game/entities/Resources.cpp -ScriptEditorScreen.cpp -Sector.cpp -Settings.cpp -TorusView.cpp -TorusViewRender.cpp -gui/GameGUITorus.cpp -SettingsScreen.cpp -SettingsScreenLayout.cpp -SettingsScreenInput.cpp -SettingsScreenGeneral.cpp -SettingsScreenBuildings.cpp -SettingsScreenKeyboard.cpp -sgsl/Lexer.cpp -sgsl/Parser.cpp -sgsl/ParserSummon.cpp -sgsl/ParserWait.cpp -sgsl/SGSL.cpp -sgsl/StoryActions.cpp -sgsl/StoryConditions.cpp -sgsl/StoryExecute.cpp -SimplexNoise.cpp -PlayerVoice.cpp -SoundMixer.cpp -team/Team.cpp -team/TeamSerialization.cpp -team/TeamLists.cpp -team/TeamRouting.cpp -team/TeamStep.cpp -TeamStat.cpp -unit/Unit.cpp -unit/UnitAction.cpp -unit/UnitActivity.cpp -unit/UnitDisplacement.cpp -unit/UnitGeometry.cpp -unit/UnitMedical.cpp -unit/UnitMovement.cpp -unit/UnitSerialization.cpp -render/UnitSkin.cpp -unit/UnitStats.cpp -game/entities/UnitType.cpp -unit/UnitUtils.cpp -Utilities.cpp -VoiceRecorder.cpp -WinningConditions.cpp -yog/YOGAfterJoinGameInformation.cpp -yog/YOGClientBlockedList.cpp -yog/YOGClientBringup.cpp -yog/YOGClientChatChannel.cpp -yog/YOGClientChatListener.cpp -yog/YOGClientCommandManager.cpp -yog/YOGClientCommands.cpp -yog/YOGClient.cpp -yog/YOGClientDownloadableMapList.cpp -yog/YOGClientDownloadableMapListener.cpp -yog/YOGClientDownloadingMapScreen.cpp -yog/YOGClientEvent.cpp -yog/YOGClientEventListener.cpp -yog/YOGClientFileAssembler.cpp -yog/YOGClientGameConnectionDialog.cpp -yog/YOGClientGameListListener.cpp -yog/YOGClientGameListManager.cpp -yog/YOGClientLobbyScreen.cpp -yog/YOGClientMapDownloader.cpp -yog/YOGClientMapDownloadScreen.cpp -yog/YOGClientMapUploader.cpp -yog/YOGClientMapUploadScreen.cpp -yog/YOGClientOptionsScreen.cpp -yog/YOGClientPlayerListListener.cpp -yog/YOGClientPlayerListManager.cpp -yog/YOGClientRatedMapList.cpp -yog/YOGClientRouterAdministrator.cpp -yog/YOGConnectionScreen.cpp -yog/YOGConsts.cpp -yog/YOGDownloadableMapInfo.cpp -yog/YOGGameInfo.cpp -yog/YOGGameResults.cpp -yog/YOGLoginScreen.cpp -yog/YOGMessage.cpp -yog/YOGPlayerSessionInfo.cpp -yog/YOGPlayerStoredInfo.cpp -yog/YOGRegisterScreen.cpp -yog/YOGServerAdministratorCommands.cpp -yog/YOGServerAdministrator.cpp -yog/YOGServerAdministratorList.cpp -yog/YOGServerBannedIPListManager.cpp -yog/YOGServerChatChannel.cpp -yog/YOGServerChatChannelManager.cpp -yog/YOGServer.cpp -yog/YOGServerFileDistributationManager.cpp -yog/YOGServerFileDistributor.cpp -yog/YOGServerGame.cpp -yog/YOGServerGameLog.cpp -yog/YOGServerGameRouter.cpp -yog/YOGServerMapDatabank.cpp -yog/YOGServerPasswordRegistry.cpp -yog/YOGServerPlayer.cpp -yog/YOGServerPlayerScoreCalculator.cpp -yog/YOGServerPlayerStoredInfoManager.cpp -yog/YOGServerRouterAdministratorCommands.cpp -yog/YOGServerRouterAdministrator.cpp -yog/YOGServerRouter.cpp -yog/YOGServerRouterManager.cpp -yog/YOGServerRouterPlayer.cpp -""") -server_source_files=Split(""" -ai/AINames.cpp -BasePlayer.cpp -BaseTeam.cpp -BitArray.cpp -GameHeader.cpp -LANGameInformation.cpp -map/io/MapHeader.cpp -net/NetBroadcaster.cpp -net/NetConnection.cpp -net/NetConnectionThread.cpp -net/NetConnectionThreadMessage.cpp -net/NetGamePlayerManager.cpp -net/NetListener.cpp -net/message/NetMessage.cpp -net/message/AuthMessages.cpp -net/message/FileTransferMessages.cpp -net/message/GameCreateMessages.cpp -net/message/GameHeaderMessages.cpp -net/message/GameJoinMessages.cpp -net/message/GameLaunchMessages.cpp -net/message/GameTeamMessages.cpp -net/message/LobbyMessages.cpp -net/message/MapDatabaseMessages.cpp -net/message/MapUploadMessages.cpp -net/message/MessageRecipients.cpp -net/message/OrderMessages.cpp -net/message/RegistrationMessages.cpp -net/message/RouterAdminMessages.cpp -net/message/RouterMessages.cpp -net/NetReteamingInformation.cpp -net/NetTestSuite.cpp -Order.cpp -OrderBuilding.cpp -OrderModify.cpp -OrderMisc.cpp -game/entities/Race.cpp -game/entities/UnitType.cpp -Utilities.cpp -yog/YOGConsts.cpp -yog/YOGGameInfo.cpp -yog/YOGGameResults.cpp -yog/YOGMessage.cpp -yog/YOGPlayerSessionInfo.cpp -yog/YOGPlayerStoredInfo.cpp -building/BuildingUtils.cpp -Bullet.cpp -game/entities/Resources.cpp -Glob2.cpp -GlobalContainer.cpp -GlobalContainerArgs.cpp -map/Map.cpp -map/gradient/MapGradientArea.cpp -map/gradient/MapGradientBuilding.cpp -map/gradient/MapGradientGlobal.cpp -map/gradient/MapGradientField.cpp -map/io/MapExploredAreaIO.cpp -map/io/MapIO.cpp -map/MapMisc.cpp -map/pathfind/MapPathfindArea.cpp -map/pathfind/MapPathfindRessource.cpp -map/MapQuery.cpp -map/MapResources.cpp -map/MapStep.cpp -map/MapTerrain.cpp -render/MapView.cpp -map/io/MapThumbnail.cpp -Sector.cpp -Settings.cpp -unit/UnitUtils.cpp -yog/YOGAfterJoinGameInformation.cpp -yog/YOGDownloadableMapInfo.cpp -WinningConditions.cpp -yog/YOGServerAdministratorCommands.cpp -yog/YOGServerAdministrator.cpp -yog/YOGServerAdministratorList.cpp -yog/YOGServerBannedIPListManager.cpp -yog/YOGServerChatChannel.cpp -yog/YOGServerChatChannelManager.cpp -yog/YOGServer.cpp -yog/YOGServerFileDistributationManager.cpp -yog/YOGServerFileDistributor.cpp -yog/YOGServerGame.cpp -yog/YOGServerGameLog.cpp -yog/YOGServerGameRouter.cpp -yog/YOGServerMapDatabank.cpp -yog/YOGServerPasswordRegistry.cpp -yog/YOGServerPlayer.cpp -yog/YOGServerPlayerScoreCalculator.cpp -yog/YOGServerPlayerStoredInfoManager.cpp -yog/YOGServerRouterAdministratorCommands.cpp -yog/YOGServerRouterAdministrator.cpp -yog/YOGServerRouter.cpp -yog/YOGServerRouterManager.cpp -yog/YOGServerRouterPlayer.cpp -""") +from sources import CLIENT_SOURCES +source_files = list(CLIENT_SOURCES) +from sources import SERVER_SOURCES +server_source_files = list(SERVER_SOURCES) Import('env') +if not env.get('wss', True): + source_files.remove('net/WssTransport.cpp') local = env.Clone() #Add libgag and USL, not as a library, but as an object source_files.append("../libgag/src/libgag.a") @@ -553,6 +93,10 @@ if not env['server']: render_test = savegame_env.Program('MapRenderResizeHarness', render_sources) local.Alias('map-render-resize-test', render_test) + session_sources = [source for source in source_files if source != 'Glob2.cpp'] + session_sources += local.Object('EngineSessionHarness.o', '#test/EngineSessionHarness.cpp') + session_test = local.Program('engine-session-test', session_sources) + local.Alias('session-test', session_test) if not env['server']: # Compile with local, not trapped_env, to avoid rebuilding shared objects under a second environment. @@ -566,9 +110,24 @@ if not env['server']: trapped_test = trapped_env.Program('TrappedUnitLifecycleTest', trapped_sources) local.Alias('trapped-unit-test', trapped_test) +if not env['server']: + transport_sources = [source for source in source_files if source != 'Glob2.cpp'] + transport_sources += local.Object('NetConnectionHarness.o', '#test/NetConnectionHarness.cpp') + transport_test = local.Program('net-connection-test', transport_sources) + local.Alias('transport-test', transport_test) + peer_sources = [source for source in source_files if source != 'Glob2.cpp'] + peer_sources += local.Object('NativeMultiplayerPeer.o', '#test/NativeMultiplayerPeer.cpp') + peer_test = local.Program('native-multiplayer-peer', peer_sources) + local.Alias('transport-test', peer_test) + if env.get('wss', True): + wss_test = local.Program('wss-transport-test', [ + 'net/NetTransport.cpp', 'net/WssTransport.cpp', + local.Object('WssTransportHarness.o', '#test/WssTransportHarness.cpp')]) + local.Alias('transport-test', wss_test) + #Add libgag, not as a library, but as an object server_source_files.append("../libgag/src/libgag_server.a") -p2 = local.Program("glob2-server", server_source_files) +p2 = local.Program("glob2-router" if env.get("role") == "router" else "glob2-server", server_source_files) if not env['server']: local.Default(p1) @@ -577,16 +136,16 @@ else: Import('env') Import("PackTar") - + if 'dist' in COMMAND_LINE_TARGETS or 'install' in COMMAND_LINE_TARGETS: env.Install(env["BINDIR"], "glob2") env.Alias("install", env["BINDIR"]) - + import os for file in os.listdir("."): if file.find(".cpp") != -1 or file.find(".h") != -1 or file.find(".py") != -1: PackTar(env["TARFILE"], file) - + PackTar(env["TARFILE"], "SConscript") # Real engine save/load regression, built only by its explicit target. diff --git a/src/ScriptEditorScreen.cpp b/src/ScriptEditorScreen.cpp index 2e48f703d..63e7dea31 100644 --- a/src/ScriptEditorScreen.cpp +++ b/src/ScriptEditorScreen.cpp @@ -503,7 +503,27 @@ void ScriptEditorScreen::onSDLEvent(SDL_Event *event) void ScriptEditorScreen::onTimer(Uint32 timer) { - changeTabAgain=true; + if (fileDialog) fileDialog->dispatchTimer(timer); + else changeTabAgain=true; +} + +ScriptEditorScreen::~ScriptEditorScreen() = default; + +void ScriptEditorScreen::translateAndProcessEvent(SDL_Event *event) +{ + if (!fileDialog) { + OverlayScreen::translateAndProcessEvent(event); + return; + } + fileDialog->translateAndProcessEvent(event); + if (fileDialog->endValue >= 0) finishFileDialog(); +} + +void ScriptEditorScreen::drawFileDialog() +{ + if (!fileDialog) return; + fileDialog->dispatchPaint(); + globalContainer->gfx->drawSurface(fileDialog->decX, fileDialog->decY, fileDialog->getSurface()); } @@ -524,34 +544,18 @@ std::string filenameToName(const std::string& fullfilename) void ScriptEditorScreen::loadSave(bool isLoad, const char *dir, const char *ext) { - // create dialog box - std::string title=Toolkit::getStringTable()->getString(isLoad ? "[load script]" : "[save script]"); - LoadSaveScreen *loadSaveScreen=new LoadSaveScreen(dir, ext, isLoad, title, game->mapHeader.getMapName().c_str(), filenameToName, glob2NameToFilename); - loadSaveScreen->dispatchPaint(); - - // save screen - globalContainer->gfx->setClipRect(); - - DrawableSurface *background = new DrawableSurface(globalContainer->gfx->getW(), globalContainer->gfx->getH()); - background->drawSurface(0, 0, globalContainer->gfx); + if (fileDialog) return; + loadingScript = isLoad; + const std::string title = Toolkit::getStringTable()->getString(isLoad ? "[load script]" : "[save script]"); + fileDialog = std::make_unique(dir, ext, isLoad, title, + game->mapHeader.getMapName().c_str(), filenameToName, glob2NameToFilename); +} - SDL_Event event; - while(loadSaveScreen->endValue<0) - { - Uint64 time = SDL_GetTicks64(); - while (GAGCore::GraphicContext::pollEvent(&event)) - { - GAGCore::GraphicContext::translateMouseEvent(&event); - loadSaveScreen->translateAndProcessEvent(&event); - } - loadSaveScreen->dispatchPaint(); - - globalContainer->gfx->drawSurface(0, 0, background); - globalContainer->gfx->drawSurface(loadSaveScreen->decX, loadSaveScreen->decY, loadSaveScreen->getSurface()); - globalContainer->gfx->nextFrame(); - Uint64 ntime = SDL_GetTicks64(); - SDL_Delay(std::max(0, 40ll - static_cast(ntime) + static_cast(time))); - } +void ScriptEditorScreen::finishFileDialog() +{ + // The editor stays alive and suspended while its owned child handles input. + auto loadSaveScreen = std::move(fileDialog); + const bool isLoad = loadingScript; if (loadSaveScreen->endValue==0) { @@ -578,9 +582,4 @@ void ScriptEditorScreen::loadSave(bool isLoad, const char *dir, const char *ext) } } - // clean up - delete loadSaveScreen; - - // destroy temporary surface - delete background; } diff --git a/src/ScriptEditorScreen.h b/src/ScriptEditorScreen.h index e66ace158..5db6101d7 100644 --- a/src/ScriptEditorScreen.h +++ b/src/ScriptEditorScreen.h @@ -4,6 +4,7 @@ #pragma once #include +#include namespace GAGGUI { class TextArea; @@ -13,6 +14,7 @@ namespace GAGGUI } using namespace GAGGUI; class Game; +class LoadSaveScreen; class MapScript; class MapScriptSGSL; @@ -62,12 +64,17 @@ class ScriptEditorScreen:public OverlayScreen public: ScriptEditorScreen(Game *game); - virtual ~ScriptEditorScreen() { } + ~ScriptEditorScreen() override; + void translateAndProcessEvent(SDL_Event *event) override; + void drawFileDialog(); virtual void onAction(Widget *source, Action action, int par1, int par2); virtual void onSDLEvent(SDL_Event *event); virtual void onTimer(Uint32 tick); private: void loadSave(bool isLoad, const char *dir, const char *ext); + void finishFileDialog(); + std::unique_ptr fileDialog; + bool loadingScript = false; }; diff --git a/src/Settings.cpp b/src/Settings.cpp index f1ca3bbcc..ca50450d6 100644 --- a/src/Settings.cpp +++ b/src/Settings.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -167,10 +168,8 @@ void Settings::load(std::string filename) */ bool Settings::save(std::string filename) { - auto* buffer = new MemoryStreamBackend(); - OutputStream *stream = new BinaryOutputStream(buffer); - // Memory output starts at EOF; it is writable regardless of its read position. - { + return Toolkit::getFileManager()->writeAtomically(filename, [this](OutputStream& output) { + OutputStream* stream = &output; Utilities::streamprintf(stream, "username=%s\n", username.c_str()); Utilities::streamprintf(stream, "password=%s\n", password.c_str()); Utilities::streamprintf(stream, "screenWidth=%d\n", screenWidth); @@ -210,10 +209,7 @@ bool Settings::save(std::string filename) Utilities::streamprintf(stream, "cloudSize=%d\n", cloudSize); Utilities::streamprintf(stream, "cloudHeight=%d\n", cloudHeight); Utilities::streamprintf(stream, "version=%d\n", SETTINGS_VERSION); - } - const std::string contents(buffer->getBuffer(), buffer->getPosition()); - delete stream; - return Toolkit::getFileManager()->writeFileAtomic(filename, contents); + }); } diff --git a/src/Settings.h b/src/Settings.h index 10cb8d2a6..36c88dcc4 100644 --- a/src/Settings.h +++ b/src/Settings.h @@ -14,6 +14,7 @@ class Settings public: Settings(); void load(const std::string filename="preferences.txt"); + // Checked atomic local replacement; callers separately await host persistence. bool save(const std::string filename="preferences.txt"); /** @@ -111,4 +112,3 @@ class Settings //Version 1 - Resets default units assigned and keyboard shortcuts #define SETTINGS_VERSION 1 - diff --git a/src/SettingsScreen.cpp b/src/SettingsScreen.cpp index fa4017eda..1ee245e25 100644 --- a/src/SettingsScreen.cpp +++ b/src/SettingsScreen.cpp @@ -93,10 +93,27 @@ void SettingsScreen::commit(bool defer) bool SettingsScreen::persist() { saveAt=0; - if(settingsDirty && globalContainer->settings.save()) settingsDirty=false; - if(keyboardDirty[0] && gameKeys.saveKeyboardLayout()) keyboardDirty[0]=false; - if(keyboardDirty[1] && editorKeys.saveKeyboardLayout()) keyboardDirty[1]=false; - failed=settingsDirty || keyboardDirty[0] || keyboardDirty[1]; + try { + // Write unconditionally, not only when locally dirty: a close with + // nothing edited must still flush to durable storage (a prior browser + // storage-restore failure can leave already-committed settings + // unflushed), and every other caller only reaches persist() with at + // least one dirty flag already set, so this adds no redundant I/O there. + if(globalContainer->settings.save()) settingsDirty=false; + if(gameKeys.saveKeyboardLayout()) keyboardDirty[0]=false; + if(editorKeys.saveKeyboardLayout()) keyboardDirty[1]=false; + failed=settingsDirty || keyboardDirty[0] || keyboardDirty[1]; + // The write above is already durable on native builds. In the browser it + // lands in Emscripten's virtual filesystem first and needs this separate + // flush to survive a reload; poll it from onTimer rather than block here. + if(!failed && !persistence) { + if(GAGCore::ApplicationHost::storageRestoreFailed()) failed=true; + else { + persistence=GAGCore::ApplicationHost::persistStorage(); + if(!persistence) failed=true; + } + } + } catch(const std::exception&) { failed=true; persistence.reset(); } return !failed; } void SettingsScreen::finishInteraction() { dragging.clear(); if(settingsDirty || keyboardDirty[0] || keyboardDirty[1]) persist(); } @@ -112,9 +129,30 @@ void SettingsScreen::done() { dropdown.close(); if(modal==Modal::Display) confirmDisplay(false); - commitText();finishInteraction(); + commitText(); + dragging.clear(); + // Always confirm durability on close, not just when something in this + // session is dirty: a prior browser storage-restore failure can leave + // already-committed settings unflushed. + persist(); // Keep Retry available instead of silently losing local shortcut edits. - if(!failed)endExecute(1); + if(!failed) + { + if(persistence) closing=true; + else endExecute(1); + } +} +void SettingsScreen::abandon() +{ + // Always closes in one click, whether or not anything is dirty or + // failed: every change is already live and auto-saved as it's made, so + // there is nothing to discard, and this never retries persist() or + // claims a pending durable write succeeded. + dropdown.close(); + if(modal==Modal::Display) confirmDisplay(false); + commitText(); + dragging.clear(); + endExecute(1); } void SettingsScreen::onAction(Widget*,Action action,int,int) { @@ -127,6 +165,22 @@ void SettingsScreen::onTimer(Uint32 tick) { if(modal==Modal::Display && Sint32(tick-displayDeadline)>=0) confirmDisplay(false); if(saveAt && dragging.empty() && Sint32(tick-saveAt)>=0)persist(); + if(persistence) { + const auto state=persistence->state(); + if(state!=GAGCore::ApplicationHost::PersistenceState::Pending) { + if(state==GAGCore::ApplicationHost::PersistenceState::Failed) { + failed=true; + closing=false; + // Retry the whole write, not just the flush: the file itself + // may also need rewriting if this state was reached because + // the browser evicted storage mid-session. + settingsDirty=keyboardDirty[0]=keyboardDirty[1]=true; + saveAt=tick+300; + } + persistence.reset(); + if(closing && !failed) { closing=false; endExecute(1); } + } + } } bool SettingsScreen::displayConfirmationPending() const { return modal==Modal::Display; } bool SettingsScreen::restartRequired() const diff --git a/src/SettingsScreen.h b/src/SettingsScreen.h index 3ddf8f3a0..6e94848ab 100644 --- a/src/SettingsScreen.h +++ b/src/SettingsScreen.h @@ -6,9 +6,11 @@ #include "Glob2Screen.h" #include "Settings.h" #include "KeyboardManager.h" +#include #include #include #include +#include #include // Native, settings-local form. A single screen owns layout, clipping and focus, @@ -54,6 +56,7 @@ class SettingsScreen : public Glob2Screen bool displayConfirmationPending() const; void confirmDisplay(bool keep); void done(); + void abandon(); protected: virtual bool applyDisplayMode(int width,int height,Uint32 flags); @@ -76,6 +79,11 @@ class SettingsScreen : public Glob2Screen bool failed=false, settingsDirty=false; std::array keyboardDirty{}; Uint32 saveAt=0, displayDeadline=0; + // Background flush to durable browser storage; a native build's writes are + // already durable, so this stays unset there. See persist() in the .cpp. + std::unique_ptr persistence; + // done() was called and is waiting on persistence to resolve before endExecute(). + bool closing=false; bool displayError=false; Settings previousDisplay; KeyboardManager gameKeys, editorKeys; diff --git a/src/SettingsScreenInput.cpp b/src/SettingsScreenInput.cpp index 6daca56c6..c7f6c4bfa 100644 --- a/src/SettingsScreenInput.cpp +++ b/src/SettingsScreenInput.cpp @@ -50,7 +50,7 @@ void SettingsScreen::focusNext(bool backward) else for(int i=0;i<6;++i)ids.push_back("nav."+std::to_string(i)); } for(const auto& r:form)if(!r.id.empty() && r.enabled){ids.push_back(r.id);if(!r.extraId.empty())ids.push_back(r.extraId);} - if(failed && modal==Modal::None)ids.push_back("retry");ids.push_back("done"); + if(modal==Modal::None)ids.push_back("cancel");ids.push_back("done"); auto it=std::find(ids.begin(),ids.end(),focus);int at=it==ids.end()?(backward?0:-1):int(it-ids.begin()); at=(at+int(ids.size())+(backward?-1:1))%int(ids.size());focus=ids[at];ensureFocusVisible(); } @@ -101,7 +101,7 @@ void SettingsScreen::onSDLEvent(SDL_Event* event) if(y>=footer.y && y=footer.x+footer.w-112){dismiss();return;} - if(failed && modal==Modal::None && x>=footer.x+footer.w-208){persist();return;} + if(modal==Modal::None && x>=footer.x+footer.w-208){abandon();return;} } if(modal==Modal::None && compactNavigation && categoryControl.contains(x,y)){focus="nav.current";openCategoryPicker();return;} if(modal==Modal::None && !compactNavigation && x>=panel.x && xdrawFilledRect(doneRect.x,doneRect.y,doneRect.w,doneRect.h,gold); gfx->drawRect(doneRect.x,doneRect.y,doneRect.w,doneRect.h,focus=="done"?ink:line); drawText(doneRect.x+12,doneRect.y+10,tr(modal==Modal::None?"Done":modal==Modal::Display?"Revert":"Cancel")); - if(failed && modal==Modal::None){gfx->drawRect(doneRect.x-96,doneRect.y,88,40,focus=="retry"?ink:line);drawText(doneRect.x-88,doneRect.y+10,tr("Retry"));} + // Always available, and always closes in one click: every change is + // already live and auto-saved, so there is nothing left to discard. Done + // retries the durable flush on every click while failed; this is the + // escape hatch for leaving without insisting that retry succeed first. + if(modal==Modal::None){gfx->drawRect(doneRect.x-96,doneRect.y,88,40,focus=="cancel"?ink:line);drawText(doneRect.x-84,doneRect.y+10,tr(failed?"continue":"Cancel"));} } diff --git a/src/SinglePlayerFlow.cpp b/src/SinglePlayerFlow.cpp new file mode 100644 index 000000000..570fa3f6c --- /dev/null +++ b/src/SinglePlayerFlow.cpp @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "SinglePlayerFlow.h" +#include "Engine.h" +#include "CustomGameScreen.h" +#include "ChooseMapScreen.h" +#include "GameSessionScreen.h" +#include "MessageScreen.h" +#include +#include + +void SinglePlayerFlow::launch(GameLoadScreen::Initializer initialize, bool repeatCustom, std::shared_ptr mapFile) +{ + // The stack destroys the choosing screen before this loader reads its map, + // so mapFile lives until the loader's own entry is released. + screens.push(std::make_unique(std::move(initialize)), + [this, repeatCustom, mapFile = std::move(mapFile)](GAGGUI::Screen& screen, int result) { + if (result == 1) + screens.push(std::make_unique(screens, static_cast(screen).takeEngine()), + [this, repeatCustom](GAGGUI::Screen&, int) { if (repeatCustom) custom(); }); + else if (result == 2) { + auto& strings = *GAGCore::Toolkit::getStringTable(); + screens.push(std::make_unique(strings.getString("[ERROR_CANT_LOAD_MAP]"), + std::vector{strings.getString("[ok]")}), + [this, repeatCustom](GAGGUI::Screen&, int) { if (repeatCustom) custom(); }); + } else if (repeatCustom) custom(); + }); +} + +void SinglePlayerFlow::custom() +{ + screens.push(std::make_unique(screens), [this](GAGGUI::Screen& screen, int result) { + if (result != CustomGameScreen::OK) return; + auto& selected = static_cast(screen); + launch([map = selected.getMapHeader(), players = selected.getGameHeader(), team = selected.getSelectedColor(0), + speed = selected.selectedSpeed(), source = selected.sourceFile()](Engine& engine) { + return engine.initCustomTask(map, players, team, speed, source); + }, true, selected.releaseSnapshot()); + }); +} + +void SinglePlayerFlow::load() +{ + screens.push(std::make_unique("games", "game", true, "replays", "replay", false), + [this](GAGGUI::Screen& screen, int result) { + if (result != ChooseMapScreen::OK) return; + auto& selected = static_cast(screen); + const bool replay = selected.getSelectedType() == ChooseMapScreen::REPLAY; + const auto filename = replay ? selected.getMapHeader().getFileName(false, true) : selected.getMapHeader().getFileName(); + launch([filename, replay](Engine& engine) { + return replay ? engine.loadReplayTask(filename) : engine.initCustomTask(filename); + }, false); + }); +} + +void SinglePlayerFlow::replay(const std::string& filename) +{ + launch([filename](Engine& engine) { return engine.loadReplayTask(filename); }, false); +} diff --git a/src/SinglePlayerFlow.h b/src/SinglePlayerFlow.h new file mode 100644 index 000000000..ed43f4c2f --- /dev/null +++ b/src/SinglePlayerFlow.h @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once +#include +#include "GameLoadScreen.h" +#include +#include +class Engine; + +// Lives alongside its stack until the flow finishes. Owns navigation only; +// simulation and file formats remain in Engine. +class SinglePlayerFlow +{ +public: + explicit SinglePlayerFlow(GAGGUI::ScreenStack& screens) : screens(screens) {} + void custom(); + void load(); + void replay(const std::string& filename); +private: + GAGGUI::ScreenStack& screens; + void launch(GameLoadScreen::Initializer initialize, bool repeatCustom, std::shared_ptr mapFile = nullptr); +}; diff --git a/src/SoundMixer.cpp b/src/SoundMixer.cpp index a8861b984..e2f0050a0 100644 --- a/src/SoundMixer.cpp +++ b/src/SoundMixer.cpp @@ -10,10 +10,12 @@ using namespace GAGCore; #include #ifdef HAVE_CONFIG_H - #include + #include #endif +#ifndef __EMSCRIPTEN__ #include +#endif #include @@ -242,6 +244,7 @@ void SoundMixer::openAudio(void) mode = MODE_STOPPED; } +#ifndef __EMSCRIPTEN__ // Open Speex decoder #ifdef _MSC_VER // workaround for vcpkg bug #2292 which seems to be broken again. @@ -252,6 +255,7 @@ void SoundMixer::openAudio(void) #endif int tmp = 1; speex_decoder_ctl(speexDecoderState, SPEEX_SET_ENH, &tmp); +#endif } @@ -280,7 +284,9 @@ SoundMixer::~SoundMixer() { SDL_PauseAudio(1); SDL_CloseAudio(); +#ifndef __EMSCRIPTEN__ speex_decoder_destroy(speexDecoderState); +#endif } for (size_t i=0; i order) { +#ifndef __EMSCRIPTEN__ if (soundEnabled) { SDL_LockAudio(); @@ -451,4 +458,5 @@ void SoundMixer::addVoiceData(std::shared_ptr order) SDL_UnlockAudio(); } +#endif } diff --git a/src/TorusView.cpp b/src/TorusView.cpp index 9b5a34536..53abd3c09 100644 --- a/src/TorusView.cpp +++ b/src/TorusView.cpp @@ -7,6 +7,13 @@ #include #include +#ifdef HAVE_CONFIG_H +#include +#endif +#if defined(HAVE_OPENGL) +#define GLOB2_TORUS_OPENGL +#endif + namespace { float clamp(float x, float a, float b) { return std::max(a, std::min(b, x)); } @@ -35,7 +42,7 @@ void TorusView::reset() bool TorusView::available() const { -#ifdef HAVE_OPENGL +#ifdef GLOB2_TORUS_OPENGL if (!globalContainer->gfx || !(globalContainer->gfx->getOptionFlags() & GAGCore::GraphicContext::USEGPU) || !SDL_GL_GetCurrentContext()) diff --git a/src/TorusViewRender.cpp b/src/TorusViewRender.cpp index 07cf0c191..5d2b00837 100644 --- a/src/TorusViewRender.cpp +++ b/src/TorusViewRender.cpp @@ -12,8 +12,19 @@ #include #include #include -#ifdef HAVE_OPENGL -#ifdef __APPLE__ +#ifdef HAVE_CONFIG_H +#include +#endif +#if defined(HAVE_OPENGL) +#define GLOB2_TORUS_OPENGL +#endif + +#ifdef GLOB2_TORUS_OPENGL +#if defined(GLOB2_WEBGL2) +#define GL_GLEXT_PROTOTYPES +#include +#include +#elif defined(__APPLE__) #include #include #define glGenFramebuffers glGenFramebuffersEXT @@ -38,7 +49,83 @@ float smooth(float x) return x * x * (3 - 2 * x); } float mix(float a, float b, float t) { return a + (b - a) * t; } -#ifdef HAVE_OPENGL +#ifdef GLOB2_TORUS_OPENGL +#ifdef GLOB2_WEBGL2 +// GLSL ES 1.00 has no version line. Emscripten's legacy-GL bridge rewrites +// ftransform() and the gl_* vertex inputs, and adds the fragment precision. +#define TORUS_GLSL_VERSION "" +// WebGL has no attribute stacks and the bridge does not emulate them. Save +// exactly what each torus block changes, so the 2D renderer's GL state cache +// still matches when the HUD resumes drawing. +struct TextureState +{ + GLint texture = 0, alignment = 1; + TextureState() + { + glGetIntegerv(GL_TEXTURE_BINDING_2D, &texture); + glGetIntegerv(GL_UNPACK_ALIGNMENT, &alignment); + } + void restore() const + { + glBindTexture(GL_TEXTURE_2D, texture); + glPixelStorei(GL_UNPACK_ALIGNMENT, alignment); + } +}; +void setCapability(GLenum capability, bool enabled) +{ + if (enabled) + glEnable(capability); + else + glDisable(capability); +} +struct RingState +{ + bool scissor = glIsEnabled(GL_SCISSOR_TEST), depth = glIsEnabled(GL_DEPTH_TEST), + blend = glIsEnabled(GL_BLEND), cull = glIsEnabled(GL_CULL_FACE); + GLboolean depthMask = GL_TRUE; + GLint scissorBox[4] = {}, depthFunc = GL_LESS, blendFactors[4] = {}, textureEnvironment = GL_MODULATE; + GLfloat clearColor[4] = {}; + TextureState texture; + RingState() + { + glGetBooleanv(GL_DEPTH_WRITEMASK, &depthMask); + glGetIntegerv(GL_SCISSOR_BOX, scissorBox); + glGetIntegerv(GL_DEPTH_FUNC, &depthFunc); + glGetIntegerv(GL_BLEND_SRC_RGB, &blendFactors[0]); + glGetIntegerv(GL_BLEND_DST_RGB, &blendFactors[1]); + glGetIntegerv(GL_BLEND_SRC_ALPHA, &blendFactors[2]); + glGetIntegerv(GL_BLEND_DST_ALPHA, &blendFactors[3]); + glGetTexEnviv(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, &textureEnvironment); + glGetFloatv(GL_COLOR_CLEAR_VALUE, clearColor); + } + void restore() const + { + setCapability(GL_SCISSOR_TEST, scissor); + setCapability(GL_DEPTH_TEST, depth); + setCapability(GL_BLEND, blend); + setCapability(GL_CULL_FACE, cull); + glDepthMask(depthMask); + glDepthFunc(depthFunc); + glScissor(scissorBox[0], scissorBox[1], scissorBox[2], scissorBox[3]); + glBlendFuncSeparate(blendFactors[0], blendFactors[1], blendFactors[2], blendFactors[3]); + glTexEnvi(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, textureEnvironment); + glClearColor(clearColor[0], clearColor[1], clearColor[2], clearColor[3]); + // The bridge cannot report point size or current color; return them to + // their defaults. + glPointSize(1); + glColor4f(1, 1, 1, 1); + texture.restore(); + } +}; +// The bridge draws emulated client-array elements as GL_UNSIGNED_SHORT, +// whatever type is requested; the torus mesh fits in 16 bits. +using MeshIndex = GLushort; +const GLenum meshIndexType = GL_UNSIGNED_SHORT; +#else +#define TORUS_GLSL_VERSION "#version 120\n" +using MeshIndex = GLuint; +const GLenum meshIndexType = GL_UNSIGNED_INT; +#endif struct SkyPoint { float x, y, z, brightness; @@ -125,13 +212,13 @@ void drawSky(float yaw, float pitch, float fade, float sx, float sy, float dista GLuint createMaterial() { const char *vertex = - "#version 120\n" + TORUS_GLSL_VERSION "varying vec2 uv; varying vec3 light; varying vec3 normal;\n" "uniform vec2 mapOffset;\n" "void main(){gl_Position=ftransform();uv=gl_MultiTexCoord0.xy+mapOffset;light=gl_Color.rgb;normal=gl_Normal;}\n"; // A sun off to the left: its highlight lies on the ring's left flank, where // the surface normal bisects the sun and the eye, never on the front face. - const char *fragment = "#version 120\n" + const char *fragment = TORUS_GLSL_VERSION "uniform sampler2D world; uniform vec3 sunHalf; uniform float specular;\n" "varying vec2 uv; varying vec3 light; varying vec3 normal;\n" "void main(){\n" @@ -165,7 +252,7 @@ GLuint createMaterial() void TorusView::releaseResources() { -#ifdef HAVE_OPENGL +#ifdef GLOB2_TORUS_OPENGL if (graphicsContext && graphicsContext == SDL_GL_GetCurrentContext() && graphicsGeneration == globalContainer->gfx->getGLContextGeneration()) { @@ -198,7 +285,7 @@ void TorusView::releaseResources() bool TorusView::prepareRenderTarget() { -#ifdef HAVE_OPENGL +#ifdef GLOB2_TORUS_OPENGL // Resolution/fullscreen changes can replace SDL's GL context. Object names // belong to their creating context; never delete or reuse them in another. if (graphicsContext != SDL_GL_GetCurrentContext() || @@ -222,7 +309,11 @@ bool TorusView::prepareRenderTarget() glDeleteFramebuffers(1, &framebuffer); atlasW = nextW; atlasH = nextH; +#ifdef GLOB2_WEBGL2 + const TextureState textureState; +#else glPushAttrib(GL_TEXTURE_BIT); +#endif glGenTextures(1, &texture); glBindTexture(GL_TEXTURE_2D, texture); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); @@ -241,7 +332,11 @@ bool TorusView::prepareRenderTarget() fprintf(stderr, "Torus view: offscreen framebuffer is unavailable\n"); } glBindFramebuffer(GL_FRAMEBUFFER, 0); +#ifdef GLOB2_WEBGL2 + textureState.restore(); +#else glPopAttrib(); +#endif if (!failed && !material) material = createMaterial(); if (!material) @@ -257,11 +352,15 @@ bool TorusView::prepareRenderTarget() // same world-anchored field as the shadows the atlas already carries. void TorusView::updateClouds(int time) { -#ifdef HAVE_OPENGL +#ifdef GLOB2_TORUS_OPENGL int gridW, gridH; clouds.computeWorld(worldW, worldH, time, cloudPixels, gridW, gridH, cloudGridLimit); +#ifdef GLOB2_WEBGL2 + const TextureState textureState; +#else glPushAttrib(GL_TEXTURE_BIT); glPushClientAttrib(GL_CLIENT_PIXEL_STORE_BIT); +#endif if (!cloudTexture || gridW != cloudW || gridH != cloudH) { if (cloudTexture) @@ -284,14 +383,18 @@ void TorusView::updateClouds(int time) glPixelStorei(GL_UNPACK_ALIGNMENT, 1); glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, cloudW, cloudH, GL_ALPHA, GL_UNSIGNED_BYTE, &cloudPixels[0]); } +#ifdef GLOB2_WEBGL2 + textureState.restore(); +#else glPopClientAttrib(); glPopAttrib(); #endif +#endif } bool TorusView::draw(Game &game, int team, unsigned options, int &vx, int &vy, int width, int height, float flatZoom, float fractionX, float fractionY) { -#ifdef HAVE_OPENGL +#ifdef GLOB2_TORUS_OPENGL if (!active() || !available()) { reset(); @@ -416,7 +519,11 @@ bool TorusView::draw(Game &game, int team, unsigned options, int &vx, int &vy, i // Save GL state AFTER the game renderer: its state cache must still match // the restored state when the ordinary HUD resumes drawing. +#ifdef GLOB2_WEBGL2 + const RingState ringState; +#else glPushAttrib(GL_ALL_ATTRIB_BITS); +#endif glViewport(oldViewport[0], oldViewport[1], oldViewport[2], oldViewport[3]); glEnable(GL_SCISSOR_TEST); // The sidebar is translucent: render beneath it, while retaining the @@ -498,6 +605,9 @@ bool TorusView::draw(Game &game, int team, unsigned options, int &vx, int &vy, i glUniform1f(glGetUniformLocation(material, "specular"), 0.18f * roll); } const int U = meshColumns, V = meshRows; +#ifdef GLOB2_WEBGL2 + static_assert((meshColumns + 1) * (meshRows + 1) <= 65536, "WebGL torus indices are 16-bit"); +#endif using MeshVertex = TorusPicking::Vertex; pickU = anchorU; pickV = anchorV; @@ -507,7 +617,9 @@ bool TorusView::draw(Game &game, int team, unsigned options, int &vx, int &vy, i GLint oldArrayBuffer, oldIndexBuffer; glGetIntegerv(GL_ARRAY_BUFFER_BINDING, &oldArrayBuffer); glGetIntegerv(GL_ELEMENT_ARRAY_BUFFER_BINDING, &oldIndexBuffer); +#ifndef GLOB2_WEBGL2 glPushClientAttrib(GL_CLIENT_VERTEX_ARRAY_BIT); +#endif if (!meshBuffer || std::memcmp(key, meshKey, sizeof(key)) != 0) { if (!meshBuffer) @@ -561,17 +673,17 @@ bool TorusView::draw(Game &game, int team, unsigned options, int &vx, int &vy, i } if (!indexBuffer) { - std::vector indices; + std::vector indices; indices.reserve(U * V * 6); for (int j = 0; j < V; ++j) for (int i = 0; i < U; ++i) { - unsigned a = j * (U + 1) + i, b = a + U + 1; - indices.insert(indices.end(), {a, b, a + 1, a + 1, b, b + 1}); + const MeshIndex a = MeshIndex(j * (U + 1) + i), b = MeshIndex(a + U + 1); + indices.insert(indices.end(), {a, b, MeshIndex(a + 1), MeshIndex(a + 1), b, MeshIndex(b + 1)}); } glGenBuffers(1, &indexBuffer); glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, indexBuffer); - glBufferData(GL_ELEMENT_ARRAY_BUFFER, indices.size() * sizeof(unsigned), indices.data(), + glBufferData(GL_ELEMENT_ARRAY_BUFFER, indices.size() * sizeof(MeshIndex), indices.data(), GL_STATIC_DRAW); } glBindBuffer(GL_ARRAY_BUFFER, meshBuffer); @@ -588,7 +700,7 @@ bool TorusView::draw(Game &game, int team, unsigned options, int &vx, int &vy, i glNormalPointer(GL_FLOAT, sizeof(MeshVertex), reinterpret_cast(offsetof(MeshVertex, normal))); // Both navigation axes only change texture offsets. Unfolding or resizing // rebuilds the shared surface and cloud geometry. - glDrawElements(GL_TRIANGLES, U * V * 6, GL_UNSIGNED_INT, nullptr); + glDrawElements(GL_TRIANGLES, U * V * 6, meshIndexType, nullptr); if (drawClouds && cloudTexture) { // White clouds lit like the ground, blended over it without writing depth. @@ -609,17 +721,30 @@ bool TorusView::draw(Game &game, int team, unsigned options, int &vx, int &vy, i reinterpret_cast(offsetof(MeshVertex, position))); glColorPointer(3, GL_FLOAT, sizeof(MeshVertex), reinterpret_cast(offsetof(MeshVertex, color))); glTexCoordPointer(2, GL_FLOAT, sizeof(MeshVertex), reinterpret_cast(offsetof(MeshVertex, uv))); - glDrawElements(GL_TRIANGLES, U * V * 6, GL_UNSIGNED_INT, nullptr); + glDrawElements(GL_TRIANGLES, U * V * 6, meshIndexType, nullptr); glPopMatrix(); glMatrixMode(GL_MODELVIEW); glDepthMask(GL_TRUE); glDisable(GL_BLEND); } +#ifdef GLOB2_WEBGL2 + // The bridge has no client attribute stacks; Glob2's other batched paths + // leave client arrays disabled too (see AlphaMapRender). + glDisableClientState(GL_VERTEX_ARRAY); + glDisableClientState(GL_COLOR_ARRAY); + glDisableClientState(GL_TEXTURE_COORD_ARRAY); + glDisableClientState(GL_NORMAL_ARRAY); +#else glPopClientAttrib(); +#endif glBindBuffer(GL_ARRAY_BUFFER, oldArrayBuffer); glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, oldIndexBuffer); glUseProgram(oldProgram); +#ifdef GLOB2_WEBGL2 + ringState.restore(); +#else glPopAttrib(); +#endif glMatrixMode(GL_MODELVIEW); glPopMatrix(); glMatrixMode(GL_PROJECTION); diff --git a/src/Utilities.cpp b/src/Utilities.cpp index 68631ebf7..8b992295b 100644 --- a/src/Utilities.cpp +++ b/src/Utilities.cpp @@ -15,6 +15,7 @@ using ssize_t = SSIZE_T; #include #include #include +#include #include "Utilities.h" #include @@ -51,6 +52,28 @@ void setRandomSyncRandSeed() randomGenerator.seed(std::random_device{}()); } +std::string getSyncRandState() +{ + std::ostringstream stream; + stream<>restored; + if(stream.fail()) + return false; + randomGenerator=restored; + return true; +} + namespace Utilities { void HSVtoRGB( float *r, float *g, float *b, float h, float s, float v ) diff --git a/src/Utilities.h b/src/Utilities.h index 83d773c9b..5c3c05413 100644 --- a/src/Utilities.h +++ b/src/Utilities.h @@ -46,6 +46,9 @@ inline Uint32 rotl1(Uint32 x) { return (x << 1) | (x >> 31); } void setSyncRandSeed(); void setSyncRandSeed(Uint32 seed); void setRandomSyncRandSeed(); +// Preserve synchronized randomness when a private loading/generation job is cancelled. +std::string getSyncRandState(); +bool setSyncRandState(const std::string& state); int distSquare(int x1, int y1, int x2, int y2); #define SIGN(s) ((s) == 0 ? 0 : ((s)>0 ? 1 : -1) ) diff --git a/src/Version.h b/src/Version.h index 42976942f..8ad34f49a 100644 --- a/src/Version.h +++ b/src/Version.h @@ -104,8 +104,7 @@ //This must be updated when there are changes to YOG, MapHeader, GameHeader, BasePlayer, BaseTeam, //NetMessage, and the likes, in parallel to change of the VERSION_MINOR above #define NET_PROTOCOL_VERSION 29 -//Clients with older versions than this will be rejected -#define YOG_MIN_CLIENT_NET_PROTOCOL_VERSION 29 +// Client and server require this exact version before authentication. // version 21 changed OrderModifyWarFlag to more generic OrderModifyMinLevelToFlag // version 22 added ConfigCheckSum to check if all use has the same file config. // version 23 updated to allow custom prestige settings @@ -116,3 +115,5 @@ // version 28 Nicowar's behavior was changed // version 29 the pathfinding simulation changed (#184); older clients would desync, so they are refused + +// version 29 identifies the updated simulation and adds symmetric protocol admission. diff --git a/src/VoiceRecorder.cpp b/src/VoiceRecorder.cpp index 6016271f2..eb37ec8d0 100644 --- a/src/VoiceRecorder.cpp +++ b/src/VoiceRecorder.cpp @@ -10,7 +10,7 @@ #include "Utilities.h" #ifdef HAVE_CONFIG_H - #include + #include #endif #include diff --git a/src/VoiceRecorder.h b/src/VoiceRecorder.h index 66ff29cc9..3c5fe239c 100644 --- a/src/VoiceRecorder.h +++ b/src/VoiceRecorder.h @@ -9,13 +9,15 @@ #include #include #include -#include "config.h" +#include #ifdef HAVE_PORTAUDIO #include "portaudio.h" #endif +#ifndef __EMSCRIPTEN__ #include +#endif class OrderVoiceData; @@ -27,7 +29,9 @@ class VoiceRecorder //! pointer to the structure holding the speex encoder void *speexEncoderState; // Bits for speex encoding +#ifndef __EMSCRIPTEN__ SpeexBits bits; +#endif //! Size of one frame of encoding int frameSize; //! thread used for recording diff --git a/src/add_net_thread_message.py b/src/add_net_thread_message.py deleted file mode 100644 index 5a7f6adb3..000000000 --- a/src/add_net_thread_message.py +++ /dev/null @@ -1,103 +0,0 @@ -from add_stuff_base import * - -backup("NetConnectionThreadMessage.h") -backup("NetConnectionThreadMessage.cpp") - -print("Name? ") -name = input() -tname = "NTM"+name.replace("NT", "") - -variables = assemble_variables(False, False) -vn = len(variables) - -constructor=assemble_constructor_define(variables) - -declare_functions=assemble_declare_get_functions(variables) -declare_variables=assemble_declare_variables(variables) - - -initialize_variables="" -if vn: - initialize_variables=" : " - initialize_variables+=assemble_initialize_variables(variables) - initialize_variables+="\n" - - -format_variables = assemble_format_variables(variables) -compare_variables = assemble_compare_variables(variables) -get_function_defines = assemble_get_function_definitions(variables) - -hcode = """ -///mname -class mname : public NetConnectionThreadMessage -{ -public: - ///Creates a mname event - """ + constructor + """; - - ///Returns tname - Uint8 getMessageType() const; - - ///Returns a formatted version of the event - std::string format() const; - - ///Compares two IRCThreadMessage - bool operator==(const NetConnectionThreadMessage& rhs) const; -""" -hcode+=declare_functions -hcode+=declare_variables -hcode+="""}; - - - -""" - -scode="" - -scode+="mname::%s\n" % constructor -scode+=initialize_variables -scode+="{\n}\n\n\n\n" -scode+="""Uint8 mname::getMessageType() const -{ - return tname; -} - - - -std::string mname::format() const -{ -""" + format_variables + """ - return s.str(); -} - - - -bool mname::operator==(const NetConnectionThreadMessage& rhs) const -{ - if(typeid(rhs)==typeid(mname)) - { -""" + compare_variables + """ - } - return false; -} - - -""" - -scode += get_function_defines - - -lines = readLines("NetConnectionThreadMessage.h") -i = findMarker(lines,"type_append_marker") -lines.insert(i, " %s,\n" % tname) - - -i = findMarker(lines,"event_append_marker") -lines.insert(i, hcode.replace("mname", name).replace("tname", tname)) -writeLines("NetConnectionThreadMessage.h", lines) - -lines = readLines("NetConnectionThreadMessage.cpp") -i = findMarker(lines, "code_append_marker") -lines.insert(i, scode.replace("mname", name).replace("tname", tname)) -writeLines("NetConnectionThreadMessage.cpp", lines) - diff --git a/src/gui/GameGUI.cpp b/src/gui/GameGUI.cpp index 437e678c2..d6ae71285 100644 --- a/src/gui/GameGUI.cpp +++ b/src/gui/GameGUI.cpp @@ -23,7 +23,7 @@ #include "Player.h" #include "ReplayReader.h" #include "ReplayWriter.h" -#include "config.h" +#include #include "Order.h" @@ -46,7 +46,7 @@ void InGameTextInput::onAction(Widget *source, Action action, int par1, int par2 } } -GameGUI::GameGUI() +GameGUI::GameGUI(bool persistPreferences) : keyboardManager(GameGUIShortcuts), game(this), toolManager(game, brush, defaultAssign, ghostManager), minimap(globalContainer->runNoX, RIGHT_MENU_WIDTH, // width of the menu @@ -59,6 +59,7 @@ GameGUI::GameGUI() ghostManager(game) { + this->persistPreferences = persistPreferences; } GameGUI::~GameGUI() @@ -66,7 +67,7 @@ GameGUI::~GameGUI() if (!globalContainer->runNoX) Sprite::setHighResolution(false); for (ParticleSet::iterator it = particles.begin(); it != particles.end(); ++it) delete *it; - if (globalContainer->settings.rememberUnit) + if (persistPreferences && globalContainer->settings.rememberUnit) globalContainer->settings.save(); } @@ -292,6 +293,8 @@ void GameGUI::publishMessageHistoryLines(const std::string& text, HistoryList ta void GameGUI::addMessage(const GAGCore::Color& color, const std::string &msgText, bool chat) { + // Headless simulations execute the order but have no font or message UI. + if (globalContainer->runNoX) return; // Wrap-measure the text in bold so the line breaks match the bold // rendering used by InGameMessage::draw. The font color pushed here is // irrelevant to glyph widths but matches the historical call site. diff --git a/src/gui/GameGUI.h b/src/gui/GameGUI.h index f392da353..f725775a0 100644 --- a/src/gui/GameGUI.h +++ b/src/gui/GameGUI.h @@ -5,6 +5,7 @@ #pragma once #include +#include #include #include #include @@ -66,7 +67,7 @@ class GameGUI public: void drawTorusMap(int originX, int originY, int team, unsigned options, int cloudGridLimit); ///Constructs a GameGUI - GameGUI(); + explicit GameGUI(bool persistPreferences = true); ///Destroys the GameGUI ~GameGUI(); @@ -78,11 +79,15 @@ class GameGUI void adjustLocalTeam(); //! Handle mouse, keyboard and window resize inputs, and stats void step(void); + // Host-supplied events and monotonic time; no event polling in this phase. + void step(const std::vector& events, Uint64 now); //! Get order from gui, return NullOrder if std::shared_ptr getOrder(void); void configureLiveSpectatorView(); //! Return position on x int getViewportX() { return viewportX; } + void suspendInput(); + void viewportResized(int oldWidth, int oldHeight, int width, int height); //! Return position on y int getViewportY() { return viewportY; } @@ -92,8 +97,10 @@ class GameGUI /// If setGameHeader is true, then the given gameHeader will replace the one loaded with /// the map, otherwise it will be ignored bool loadFromHeaders(MapHeader& mapHeader, GameHeader& gameHeader, bool setGameHeader, bool ignoreGUIData=false, bool saveAI=false, const std::string& sourceFileName=std::string()); + GAGCore::CooperativeTask loadFromHeadersTask(MapHeader mapHeader, GameHeader gameHeader, bool setGameHeader, bool ignoreGUIData=false, bool saveAI=false, std::string sourceFileName=std::string()); //! bool load(GAGCore::InputStream *stream, bool ignoreGUIData=false); + GAGCore::CooperativeTask loadTask(GAGCore::InputStream *stream, bool ignoreGUIData=false); void save(GAGCore::OutputStream *stream, const std::string name); void processEvent(SDL_Event *event); @@ -237,6 +244,7 @@ class GameGUI friend class GameGUISelectionHarness; friend class TorusRenderIntegrationTest; friend class TorusRenderBenchmark; + bool persistPreferences; // Helper function for key and menu void repairAndUpgradeBuilding(Building *building, bool repair, bool upgrade); @@ -504,8 +512,6 @@ class GameGUI bool panPushed; //! Coordinate of mouse when began panning int panMouseX, panMouseY; - int lastMouseX = 0, lastMouseY = 0; - Uint16 lastMouseButtonState = 0; //! Coordinate of viewport when began panning int panViewX, panViewY; @@ -530,6 +536,9 @@ class GameGUI //! for mouse motion int viewportSpeedX, viewportSpeedY; Uint64 lastViewportStep; + GAGCore::InputState inputState; + int lastMouseX = 0, lastMouseY = 0; + Uint32 lastMouseButtonState = 0; // menu related functions enum InGameMenu @@ -689,4 +698,3 @@ class GameGUI void viewportChanged(int oldViewportX, int viewportX, int oldViewportY, int viewportY); }; - diff --git a/src/gui/GameGUIDraw.cpp b/src/gui/GameGUIDraw.cpp index 3ac44b866..3262c1312 100644 --- a/src/gui/GameGUIDraw.cpp +++ b/src/gui/GameGUIDraw.cpp @@ -5,6 +5,7 @@ #include "../render/MapCopies.h" #include +#include #include #include #include @@ -370,12 +371,12 @@ void GameGUI::drawOverlayInfos(void) if (selectionMode==TOOL_SELECTION) { globalContainer->gfx->setClipRect(0, 0, globalContainer->gfx->getW()-RIGHT_MENU_WIDTH, globalContainer->gfx->getH()); - globalContainer->gfx->drawMapCopies(game.map.getW()*32,game.map.getH()*32,game.map.displayViewportW,game.map.displayViewportH,[&](){ toolManager.drawTool(int(MapCamera::wrap(mapMouseX(mouseX),game.map.getW()*32)), int(MapCamera::wrap(mapMouseY(mouseY),game.map.getH()*32)), localTeamNo, viewportX, viewportY); }); + globalContainer->gfx->drawMapCopies(game.map.getW()*32,game.map.getH()*32,game.map.displayViewportW,game.map.displayViewportH,[&](){ toolManager.drawTool(int(MapCamera::wrap(mapMouseX(mouseX),game.map.getW()*32)), int(MapCamera::wrap(mapMouseY(mouseY),game.map.getH()*32)), localTeamNo, viewportX, viewportY, inputState.modifiers()); }); } else if (selectionMode==BRUSH_SELECTION) { globalContainer->gfx->setClipRect(0, 0, globalContainer->gfx->getW()-RIGHT_MENU_WIDTH, globalContainer->gfx->getH()); - globalContainer->gfx->drawMapCopies(game.map.getW()*32,game.map.getH()*32,game.map.displayViewportW,game.map.displayViewportH,[&](){ toolManager.drawTool(int(MapCamera::wrap(mapMouseX(mouseX),game.map.getW()*32)), int(MapCamera::wrap(mapMouseY(mouseY),game.map.getH()*32)), localTeamNo, viewportX, viewportY); }); + globalContainer->gfx->drawMapCopies(game.map.getW()*32,game.map.getH()*32,game.map.displayViewportW,game.map.displayViewportH,[&](){ toolManager.drawTool(int(MapCamera::wrap(mapMouseX(mouseX),game.map.getW()*32)), int(MapCamera::wrap(mapMouseY(mouseY),game.map.getH()*32)), localTeamNo, viewportX, viewportY, inputState.modifiers()); }); } else if (selectionMode==BUILDING_SELECTION) { @@ -650,6 +651,7 @@ void GameGUI::drawAll(int team) torusView.draw(game, localTeamNo, drawOptions, viewportX, viewportY, globalContainer->gfx->getW()-RIGHT_MENU_WIDTH, globalContainer->gfx->getH(), camera.zoom, camera.fractionX(), camera.fractionY()); + GAGCore::ApplicationHost::overviewDrawn(drewTorus); if (!drewTorus) { globalContainer->gfx->beginMapTransform(camera.zoom, camera.offsetX-camera.fractionX()*camera.zoom, camera.offsetY-camera.fractionY()*camera.zoom, camera.offsetX, std::max(16, int(camera.offsetY)), camera.visibleW()*camera.zoom, camera.visibleH()*camera.zoom-std::max(0,16-int(camera.offsetY))); diff --git a/src/gui/GameGUIInput.cpp b/src/gui/GameGUIInput.cpp index 970f82b2b..086329240 100644 --- a/src/gui/GameGUIInput.cpp +++ b/src/gui/GameGUIInput.cpp @@ -117,21 +117,25 @@ bool GameGUI::processTypingInput(SDL_Event *event) void GameGUI::processEvent(SDL_Event *event) { + inputState.observe(*event); if ((event->type == SDL_MOUSEBUTTONUP && event->button.button == SDL_BUTTON_MIDDLE) || (event->type == SDL_WINDOWEVENT && event->window.event == SDL_WINDOWEVENT_FOCUS_LOST)) - { panPushed = false; - } if (event->type == SDL_WINDOWEVENT && event->window.event == SDL_WINDOWEVENT_FOCUS_LOST) { + lastMouseButtonState = 0; viewportSpeedX = viewportSpeedY = 0; + selectionPushed = false; torusView.stopMoving(); - if (torusPointerDown) toolManager.finishPointerGesture(localTeamNo); + toolManager.cancelDrag(localTeamNo); torusPointerDown = false; torusView.setPointerHeld(false); } if (event->type == SDL_MOUSEBUTTONUP && event->button.button == SDL_BUTTON_LEFT) torusView.setPointerHeld(false); + if (!inputState.hasFocus() && (event->type == SDL_KEYDOWN || event->type == SDL_KEYUP || + event->type == SDL_MOUSEBUTTONDOWN || event->type == SDL_MOUSEBUTTONUP || + event->type == SDL_MOUSEMOTION || event->type == SDL_MOUSEWHEEL)) return; if (!typingInputScreen && inGameMenu == IGM_NONE && !scrollableText) { int width = globalContainer->gfx->getW()-RIGHT_MENU_WIDTH; @@ -140,7 +144,6 @@ void GameGUI::processEvent(SDL_Event *event) if (torusView.active() && handleTorusPointer(*event)) return; } - // handle typing if (processTypingInput(event)) return; @@ -222,7 +225,7 @@ void GameGUI::processEvent(SDL_Event *event) void GameGUI::accumulateScrollWheelDelta(int delta) { if(globalContainer->liveSpectating) return; - SDL_Keymod mod = SDL_GetModState(); + SDL_Keymod mod = inputState.modifiers(); switch (scrollWheelTarget(mod & KMOD_SHIFT, mod & KMOD_CTRL, globalContainer->settings.scrollWheelEnabled, mod & KMOD_ALT)) { @@ -380,7 +383,7 @@ void GameGUI::handleMouseButtonUp(SDL_MouseButtonEvent mouseEvent) // We send the order else if (selectionMode==BRUSH_SELECTION || selectionMode==TOOL_SELECTION) { - toolManager.handleMouseUp(mapMouseX(mouseEvent.x), mapMouseY(mouseEvent.y), localTeamNo, viewportX, viewportY); + toolManager.handleMouseUp(mapMouseX(mouseEvent.x), mapMouseY(mouseEvent.y), localTeamNo, viewportX, viewportY, inputState.modifiers()); } } miniMapPushed=false; diff --git a/src/gui/GameGUIInputKey.cpp b/src/gui/GameGUIInputKey.cpp index f6686b5c4..6b6807072 100644 --- a/src/gui/GameGUIInputKey.cpp +++ b/src/gui/GameGUIInputKey.cpp @@ -431,11 +431,11 @@ void GameGUI::handleKey(SDL_Keysym key, bool pressed, bool repeat) void GameGUI::handleKeyAlways(void) { - SDL_PumpEvents(); - const Uint8 *keystate = SDL_GetKeyboardState(NULL); + if (!inputState.hasFocus()) return; + const Uint8 *keystate = inputState.keyboard(); if (notmenu == false) { - SDL_Keymod modState = SDL_GetModState(); + SDL_Keymod modState = inputState.modifiers(); updateCamera(); double xMotion = 1/camera.zoom; double yMotion = 1/camera.zoom; diff --git a/src/gui/GameGUIInputMenu.cpp b/src/gui/GameGUIInputMenu.cpp index c309b37c6..0b809cda2 100644 --- a/src/gui/GameGUIInputMenu.cpp +++ b/src/gui/GameGUIInputMenu.cpp @@ -170,19 +170,17 @@ bool GameGUI::processGameMenu(SDL_Event *event) } else { - defaultGameSaveName=((LoadSaveScreen *)gameMenuScreen.get())->getName(); - OutputStream *stream = new BinaryOutputStream(Toolkit::getFileManager()->openOutputStreamBackend(locationName)); - if (stream->isEndOfStream()) - { - std::cerr << "GGU : Can't save map " << locationName << std::endl; - } - else - { - const std::string name = ((LoadSaveScreen *)gameMenuScreen.get())->getName(); - assert(name.size()); - save(stream, name); - } - delete stream; + const std::string name = static_cast(gameMenuScreen.get())->getName(); + if (!Toolkit::getFileManager()->writeAtomically(locationName, [&](OutputStream& stream) { + save(&stream, name); + })) { + std::cerr << "GGU: Save failed; previous file retained: " << locationName << std::endl; + static_cast(gameMenuScreen.get())->showSaveFailure(); + return true; + } + defaultGameSaveName = name; + static_cast(gameMenuScreen.get())->beginPersistence(GAGCore::ApplicationHost::persistStorage()); + return true; } } diff --git a/src/gui/GameGUIInputMouse.cpp b/src/gui/GameGUIInputMouse.cpp index 62da23bcf..66b7e9a06 100644 --- a/src/gui/GameGUIInputMouse.cpp +++ b/src/gui/GameGUIInputMouse.cpp @@ -130,7 +130,7 @@ void GameGUI::handleMapClick(int mx, int my, int button) setSelection(UNIT_SELECTION, view.mouseUnit); selectionPushed = true; // handle dump of unit characteristics - if ((SDL_GetModState() & KMOD_SHIFT) != 0) + if ((inputState.modifiers() & KMOD_SHIFT) != 0) { OutputStream *stream = new TextOutputStream(Toolkit::getFileManager()->openOutputStreamBackend("unit.dump.txt")); if (stream->isEndOfStream()) @@ -168,7 +168,7 @@ void GameGUI::handleMapClick(int mx, int my, int button) selectionPushed=true; // showUnitWorkingToBuilding=true; // handle dump of building characteristics - if ((SDL_GetModState() & KMOD_SHIFT) != 0) + if ((inputState.modifiers() & KMOD_SHIFT) != 0) { OutputStream *stream = new TextOutputStream(Toolkit::getFileManager()->openOutputStreamBackend("building.dump.txt")); if (stream->isEndOfStream()) diff --git a/src/gui/GameGUILoadSave.cpp b/src/gui/GameGUILoadSave.cpp index 18a087120..cfd8f5dab 100644 --- a/src/gui/GameGUILoadSave.cpp +++ b/src/gui/GameGUILoadSave.cpp @@ -8,6 +8,8 @@ #include #include #include +#include +#include #include class FuncFileList: public FileList @@ -85,7 +87,12 @@ LoadSaveScreen::LoadSaveScreen(const char *directory, const char *extension, boo addWidget(new TextButton(10, 225, 135, 40, ALIGN_LEFT, ALIGN_LEFT, "menu", Toolkit::getStringTable()->getString("[ok]"), OK, 13)); addWidget(new TextButton(155, 225, 135, 40, ALIGN_LEFT, ALIGN_LEFT, "menu", Toolkit::getStringTable()->getString("[Cancel]"), CANCEL, 27)); - addWidget(new Text(0, 5, ALIGN_FILL, ALIGN_LEFT, "menu", title)); + exportButton = new TextButton(50, 90, 200, 40, ALIGN_LEFT, ALIGN_LEFT, "menu", + Toolkit::getStringTable()->getString("[export save]"), EXPORT); + exportButton->visible = false; + addWidget(exportButton); + caption = new Text(0, 5, ALIGN_FILL, ALIGN_LEFT, "menu", title); + addWidget(caption); generateFileName(); dispatchInit(); @@ -98,8 +105,10 @@ LoadSaveScreen::~LoadSaveScreen() void LoadSaveScreen::onAction(Widget *source, Action action, int par1, int par2) { + if (persistence) return; if ((action==BUTTON_RELEASED) || (action==BUTTON_SHORTCUT)) { + if (par1 == EXPORT) { exportSave(); return; } if (par1 == OK) { if (fileName.size()) @@ -141,3 +150,37 @@ const char *LoadSaveScreen::getName(void) { return fileNameEntry->getText().c_str(); } + +void LoadSaveScreen::showSaveFailure() +{ + endValue = -1; + caption->setText(Toolkit::getStringTable()->getString("[save failed retry]")); + exportButton->visible = !exportPath.empty() && GAGCore::ApplicationHost::canExportFiles(); + if (exportButton->visible) fileList->visible = false; +} + +void LoadSaveScreen::beginPersistence(std::unique_ptr operation) +{ + endValue = -1; + caption->setText(Toolkit::getStringTable()->getString("[saving to storage]")); + exportPath = fileName; + exportButton->visible = false; + persistence = std::move(operation); +} +bool LoadSaveScreen::pollPersistence() +{ + if (!persistence) return false; + const auto state = persistence->state(); + if (state == GAGCore::ApplicationHost::PersistenceState::Pending) return false; + persistence.reset(); + if (state == GAGCore::ApplicationHost::PersistenceState::Failed) { + showSaveFailure(); + return false; + } + return true; +} + +void LoadSaveScreen::exportSave() +{ + if (!GAGCore::ApplicationHost::exportLocalFile(exportPath)) showSaveFailure(); +} diff --git a/src/gui/GameGUILoadSave.h b/src/gui/GameGUILoadSave.h index 8e49247c6..09e86079b 100644 --- a/src/gui/GameGUILoadSave.h +++ b/src/gui/GameGUILoadSave.h @@ -4,6 +4,7 @@ #pragma once #include +#include using namespace GAGGUI; #include "GameGUIDialog.h" #include @@ -12,6 +13,8 @@ namespace GAGGUI { class List; class TextInput; + class Text; + class TextButton; } class LoadSaveScreen:public OverlayScreen @@ -20,11 +23,17 @@ class LoadSaveScreen:public OverlayScreen enum { OK = 0, - CANCEL = 1 + CANCEL = 1, + EXPORT = 2 }; private: List *fileList; + Text *caption; + TextButton *exportButton; + std::string exportPath; + void exportSave(); + std::unique_ptr persistence; TextInput *fileNameEntry; bool isLoad; std::string extension; @@ -49,6 +58,9 @@ class LoadSaveScreen:public OverlayScreen std::string (*filenameToNameFunc)(const std::string& filename)=NULL, std::string (*nameToFilenameFunc)(const std::string& dir, const std::string& name, const std::string& extension)=NULL); virtual ~LoadSaveScreen(); + void showSaveFailure(); + void beginPersistence(std::unique_ptr operation); + bool pollPersistence(); virtual void onAction(Widget *source, Action action, int par1, int par2); virtual void onSDLEvent(SDL_Event *event); const char *getFileName(void); diff --git a/src/gui/GameGUIOrders.cpp b/src/gui/GameGUIOrders.cpp index d8706d752..03d4bfd36 100644 --- a/src/gui/GameGUIOrders.cpp +++ b/src/gui/GameGUIOrders.cpp @@ -23,7 +23,7 @@ #include "Player.h" #include "ReplayReader.h" #include "ReplayWriter.h" -#include "config.h" +#include #include "Order.h" #include "net/message/MessageRecipients.h" diff --git a/src/gui/GameGUIPersistence.cpp b/src/gui/GameGUIPersistence.cpp index 80b05b85b..53376c136 100644 --- a/src/gui/GameGUIPersistence.cpp +++ b/src/gui/GameGUIPersistence.cpp @@ -22,35 +22,40 @@ #include "Player.h" #include "ReplayReader.h" #include "ReplayWriter.h" -#include "config.h" +#include bool GameGUI::loadFromHeaders(MapHeader& mapHeader, GameHeader& gameHeader, bool setGameHeader, bool ignoreGUIData, bool saveAI, const std::string& sourceFileName) +{ + return loadFromHeadersTask(mapHeader, gameHeader, setGameHeader, ignoreGUIData, saveAI, sourceFileName).run(); +} +bool GameGUI::load(GAGCore::InputStream *stream, bool ignoreGUIData) +{ + return loadTask(stream, ignoreGUIData).run(); +} + +GAGCore::CooperativeTask GameGUI::loadFromHeadersTask(MapHeader mapHeader, GameHeader gameHeader, bool setGameHeader, bool ignoreGUIData, bool saveAI, std::string sourceFileName) { init(); - InputStream *stream = new BinaryInputStream(Toolkit::getFileManager()->openInputStreamBackend(sourceFileName.empty()?mapHeader.getFileName():sourceFileName)); - if (stream->isEndOfStream() && !sourceFileName.empty()) { delete stream; return false; } + auto stream = std::make_unique(Toolkit::getFileManager()->openInputStreamBackend(sourceFileName.empty()?mapHeader.getFileName():sourceFileName)); if (stream->isEndOfStream()) { - delete stream; - stream = new BinaryInputStream(Toolkit::getFileManager()->openInputStreamBackend(mapHeader.getFileName(true))); + if(!sourceFileName.empty()) co_return false; + stream = std::make_unique(Toolkit::getFileManager()->openInputStreamBackend(mapHeader.getFileName(true))); if(stream->isEndOfStream()) { - delete stream; - stream = new BinaryInputStream(Toolkit::getFileManager()->openInputStreamBackend(mapHeader.getFileName(false,true))); + stream = std::make_unique(Toolkit::getFileManager()->openInputStreamBackend(mapHeader.getFileName(false,true))); if(stream->isEndOfStream()) { std::cerr << "GameGUI::loadFromHeaders() : error, can't open file " << mapHeader.getFileName() << ", " << mapHeader.getFileName(true) << " or " << mapHeader.getFileName(false,true) << std::endl; - delete stream; - return false; + co_return false; } } } - bool res = load(stream, ignoreGUIData); - delete stream; + bool res = co_await loadTask(stream.get(), ignoreGUIData); if (!res) - return false; + co_return false; // Intentionally keep the map header loaded from the file rather than the // one sent across the network: the network header is in the latest format @@ -58,19 +63,19 @@ bool GameGUI::loadFromHeaders(MapHeader& mapHeader, GameHeader& gameHeader, bool if(setGameHeader) game.setGameHeader(gameHeader, saveAI); - return true; + co_return true; } -bool GameGUI::load(GAGCore::InputStream *stream, bool ignoreGUIData) +GAGCore::CooperativeTask GameGUI::loadTask(GAGCore::InputStream *stream, bool ignoreGUIData) { init(); - bool result = game.load(stream); + bool result = co_await game.loadTask(stream); if (result == false) { std::cerr << "GameGUI::load : can't load game" << std::endl; - return false; + co_return false; } defaultGameSaveName = game.mapHeader.getMapName(); if (game.mapHeader.getIsSavedGame()) @@ -124,7 +129,7 @@ bool GameGUI::load(GAGCore::InputStream *stream, bool ignoreGUIData) minimap.setGame(game); - return true; + co_return true; } void GameGUI::save(GAGCore::OutputStream *stream, const std::string name) @@ -163,3 +168,25 @@ void GameGUI::save(GAGCore::OutputStream *stream, const std::string name) defaultAssign.save(stream); stream->writeLeaveSection(); } + +void GameGUI::viewportResized(int oldWidth, int oldHeight, int width, int height) +{ + if (!game.map.getW() || !game.map.getH()) return; + // Cameras use whole map tiles. Keep the tile at the view's center fixed. + minimap.resizeViewport(width); + suspendInput(); + const int oldX = viewportX, oldY = viewportY; + viewportX = (viewportX + (oldWidth - 160) / 64 - (width - 160) / 64) & game.map.wMask; + viewportY = (viewportY + oldHeight / 64 - height / 64) & game.map.hMask; + viewportChanged(oldX, viewportX, oldY, viewportY); + if (gameMenuScreen) gameMenuScreen->viewportResized(oldWidth, oldHeight, width, height); +} + +void GameGUI::suspendInput() +{ + inputState.clearHeld(); + viewportSpeedX = viewportSpeedY = 0; + lastMouseButtonState = 0; + miniMapPushed = selectionPushed = false; + toolManager.cancelDrag(localTeamNo); +} diff --git a/src/gui/GameGUIStep.cpp b/src/gui/GameGUIStep.cpp index cdc598ebb..f8510962a 100644 --- a/src/gui/GameGUIStep.cpp +++ b/src/gui/GameGUIStep.cpp @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Copyright (C) 2001-2004 Stephane Magnenat & Luc-Olivier de Charrière +#include "GameGUILoadSave.h" #include #include #include @@ -30,7 +31,7 @@ #include "Player.h" #include "ReplayReader.h" #include "ReplayWriter.h" -#include "config.h" +#include #include "Order.h" #include @@ -111,16 +112,27 @@ void GameGUI::dragStep(int mx, int my, int button) information, because we need the information as it was in the middle of the event stream. (There may be many later events we have not yet processed.) */ - - void GameGUI::step(void) { - SDL_Event event, mouseMotionEvent, windowEvent; + std::vector events; + SDL_Event event; + while (GAGCore::GraphicContext::pollEvent(&event)) events.push_back(event); + step(events, SDL_GetTicks64()); +} + +void GameGUI::step(const std::vector& events, Uint64 now) +{ + if (inGameMenu == IGM_SAVE && gameMenuScreen && + static_cast(gameMenuScreen.get())->pollPersistence()) { + gameMenuScreen.reset(); + inGameMenu = IGM_NONE; + } + SDL_Event mouseMotionEvent; bool wasMouseMotion=false; - bool wasWindowEvent=false; + int oldMouseMapX = -1, oldMouseMapY = -1; // hopefully the values here will never matter // we get all pending events but for mouse motion we only keep the last one - while (GAGCore::GraphicContext::pollEvent(&event)) + for (auto event : events) { GAGCore::GraphicContext::translateMouseEvent(&event); if (event.type==SDL_MOUSEMOTION) @@ -167,14 +179,14 @@ void GameGUI::step(void) wasMouseMotion=true; } # ifdef USE_OSX - else if(event.type == SDL_KEYDOWN && event.key.keysym.sym == SDLK_q && SDL_GetModState() & KMOD_GUI) + else if(event.type == SDL_KEYDOWN && event.key.keysym.sym == SDLK_q && (event.key.keysym.mod & KMOD_GUI)) { isRunning=false; exitGlobCompletely=true; } # endif # ifdef USE_WIN32 - else if(event.type == SDL_KEYDOWN && event.key.keysym.sym == SDLK_F4 && SDL_GetModState() & KMOD_ALT) + else if(event.type == SDL_KEYDOWN && event.key.keysym.sym == SDLK_F4 && (event.key.keysym.mod & KMOD_ALT)) { isRunning=false; exitGlobCompletely=true; @@ -182,24 +194,20 @@ void GameGUI::step(void) # endif else if ((event.type == SDL_MOUSEBUTTONDOWN) || (event.type == SDL_MOUSEBUTTONUP)) { - lastMouseButtonState = SDL_GetMouseState (&lastMouseX, &lastMouseY); - /* We ignore what SDL_GetMouseState does to - lastMouseX and lastMouseY, because that may - reflect many subsequent events that we have not - yet processed. Technically, we shouldn't use - SDL_GetMouseState at all but should calculate the - button state by keeping track of what has - happened. However, I haven't had the programming - energy to do this, so I am cheating in the line - above. */ + if (wasMouseMotion) { processEvent(&mouseMotionEvent); wasMouseMotion = false; } + if (event.button.button > 0 && event.button.button <= 32) { + const Uint32 mask = SDL_BUTTON(event.button.button); + if (event.type == SDL_MOUSEBUTTONDOWN) lastMouseButtonState |= mask; + else lastMouseButtonState &= ~mask; + } lastMouseX = event.button.x; lastMouseY = event.button.y; processEvent (&event); } else if (event.type==SDL_WINDOWEVENT) { - windowEvent=event; - wasWindowEvent=true; + if (wasMouseMotion) { processEvent(&mouseMotionEvent); wasMouseMotion = false; } + processEvent(&event); } else { @@ -208,8 +216,7 @@ void GameGUI::step(void) } if (wasMouseMotion) processEvent(&mouseMotionEvent); - if (wasWindowEvent) - processEvent(&windowEvent); + flushScrollWheelOrders(); @@ -219,7 +226,8 @@ void GameGUI::step(void) viewportX += game.map.getW(); viewportY += game.map.getH(); // Continuous scrolling keeps its normal 25 Hz cadence at every game speed. - const Uint64 now=SDL_GetTicks64(); + + if (now < lastViewportStep) lastViewportStep = now; const unsigned viewportSteps=std::min((now-lastViewportStep)/GAME_TICK_MS, 5); if(viewportSteps) lastViewportStep=now-(now-lastViewportStep)%GAME_TICK_MS; @@ -237,7 +245,7 @@ void GameGUI::step(void) updateCamera(); if ((viewportX!=oldViewportX) || (viewportY!=oldViewportY)) { - dragStep(lastMouseX, lastMouseY, lastMouseButtonState); + if (inputState.hasFocus()) dragStep(lastMouseX, lastMouseY, lastMouseButtonState); viewportChanged(oldViewportX, viewportX, oldViewportY, viewportY); } diff --git a/src/gui/GameGUIToolManager.cpp b/src/gui/GameGUIToolManager.cpp index 3fee4f149..a7d15772a 100644 --- a/src/gui/GameGUIToolManager.cpp +++ b/src/gui/GameGUIToolManager.cpp @@ -65,7 +65,7 @@ void GameGUIToolManager::deactivateTool() -void GameGUIToolManager::drawTool(int mouseX, int mouseY, int localteam, int viewportX, int viewportY) +void GameGUIToolManager::drawTool(int mouseX, int mouseY, int localteam, int viewportX, int viewportY, int modifiers) { if(mode == PlaceBuilding) { @@ -79,7 +79,7 @@ void GameGUIToolManager::drawTool(int mouseX, int mouseY, int localteam, int vie game.map.cursorToBuildingPos(mouseX, mouseY, bt->width, bt->height, &mapX, &mapY, viewportX, viewportY); - SDL_Keymod modState = SDL_GetModState(); + const int modState = modifiers; if(!(modState & KMOD_CTRL || modState & KMOD_SHIFT) || !firstPlacement) { drawBuildingAt(mapX, mapY, localteam, viewportX, viewportY); @@ -185,7 +185,7 @@ void GameGUIToolManager::handleMouseDown(int mouseX, int mouseY, int localteam, -void GameGUIToolManager::handleMouseUp(int mouseX, int mouseY, int localteam, int viewportX, int viewportY) +void GameGUIToolManager::handleMouseUp(int mouseX, int mouseY, int localteam, int viewportX, int viewportY, int modifiers) { if(mode == PlaceZone) { @@ -200,7 +200,7 @@ void GameGUIToolManager::handleMouseUp(int mouseX, int mouseY, int localteam, in int mapX, mapY; game.map.cursorToBuildingPos(mouseX, mouseY, bt->width, bt->height, &mapX, &mapY, viewportX, viewportY); - SDL_Keymod modState = SDL_GetModState(); + const int modState = modifiers; if(!(modState & KMOD_CTRL || modState & KMOD_SHIFT) || !firstPlacement) { placeBuildingAt(mapX, mapY, localteam); @@ -622,6 +622,11 @@ void GameGUIToolManager::computeBuildingBox(int sx, int sy, int ex, int ey, int } void GameGUIToolManager::finishPointerGesture(int localteam) +{ + cancelDrag(localteam); +} + +void GameGUIToolManager::cancelDrag(int localteam) { if (mode == PlaceZone) flushBrushOrders(localteam); firstPlacement.reset(); diff --git a/src/gui/GameGUIToolManager.h b/src/gui/GameGUIToolManager.h index 47521474a..307585e2c 100644 --- a/src/gui/GameGUIToolManager.h +++ b/src/gui/GameGUIToolManager.h @@ -56,7 +56,7 @@ class GameGUIToolManager void deactivateTool(); ///Draws the tool on the map - void drawTool(int mouseX, int mouseY, int localteam, int viewportX, int viewportY); + void drawTool(int mouseX, int mouseY, int localteam, int viewportX, int viewportY, int modifiers); ///Returns the name of the current building std::string getBuildingName() const; @@ -68,7 +68,8 @@ class GameGUIToolManager void handleMouseDown(int mouseX, int mouseY, int localteam, int viewportX, int viewportY); ///Handles a mouse up - void handleMouseUp(int mouseX, int mouseY, int localteam, int viewportX, int viewportY); + void handleMouseUp(int mouseX, int mouseY, int localteam, int viewportX, int viewportY, int modifiers); + void cancelDrag(int localteam); ///Ends a pointer gesture without placing a building; keeps painted zones. void finishPointerGesture(int localteam); diff --git a/src/gui/GameGUITorus.cpp b/src/gui/GameGUITorus.cpp index 4e5f0bd37..e45315cc2 100644 --- a/src/gui/GameGUITorus.cpp +++ b/src/gui/GameGUITorus.cpp @@ -61,7 +61,7 @@ bool GameGUI::handleTorusPointer(const SDL_Event &event) else if (selectionMode == BRUSH_SELECTION || selectionMode == TOOL_SELECTION) { if (hit) - toolManager.handleMouseUp(mx, my, localTeamNo, viewportX, viewportY); + toolManager.handleMouseUp(mx, my, localTeamNo, viewportX, viewportY, inputState.modifiers()); else toolManager.finishPointerGesture(localTeamNo); } @@ -85,7 +85,7 @@ void GameGUI::drawTorusMap(int originX, int originY, int team, unsigned options, { int mx = (px - originX * 32) & (game.map.getW() * 32 - 1); int my = (py - originY * 32) & (game.map.getH() * 32 - 1); - toolManager.drawTool(mx, my, localTeamNo, originX, originY); + toolManager.drawTool(mx, my, localTeamNo, originX, originY, inputState.modifiers()); } if (selectionMode == BUILDING_SELECTION && view.selectedBuilding) { diff --git a/src/map/Map.h b/src/map/Map.h index 6132d9380..b553a1ca4 100644 --- a/src/map/Map.h +++ b/src/map/Map.h @@ -3,6 +3,7 @@ // Copyright (C) 2006 Bradley Arsenault #pragma once +#include #include #include @@ -100,6 +101,16 @@ class Map static constexpr Uint16 ASTAR_COST_INFINITY = static_cast(-1); public: + static constexpr int MIN_SUPPORTED_SIZE_EXPONENT = 4; + static constexpr int MAX_SUPPORTED_SIZE_EXPONENT = 9; + static constexpr bool supportedDimensions(int widthExponent, int heightExponent) + { + return widthExponent >= MIN_SUPPORTED_SIZE_EXPONENT && + widthExponent <= MAX_SUPPORTED_SIZE_EXPONENT && + heightExponent >= MIN_SUPPORTED_SIZE_EXPONENT && + heightExponent <= MAX_SUPPORTED_SIZE_EXPONENT; + } + //! Map constructor Map(); //! Map destructor @@ -113,6 +124,7 @@ class Map void setGame(Game *game); //! Load a map from a stream and relink with associated game bool load(GAGCore::InputStream *stream, MapHeader& header, Game *game=NULL); + GAGCore::CooperativeTask loadTask(GAGCore::InputStream *stream, MapHeader& header, Game *game); //! Save a map void save(GAGCore::OutputStream *stream); //! Write the per-team explored area. Saved games only; save() decides. @@ -125,6 +137,7 @@ class Map // add & remove teams, used by the map editor and the random map generator // Have to be called *after* session.numberOfTeam has been changed. void addTeam(void); + GAGCore::CooperativeTask addTeamTask(void); void removeTeam(void); //! Grow resources on map @@ -629,6 +642,7 @@ class Map // the pathfinding gradients are built by propagateGradient. Defined in // MapGradientGlobal.cpp. void updateGlobalGradient(Uint8 *gradient); + GAGCore::CooperativeTask updateGlobalGradientTask(Uint8 *gradient); //! Dijkstra on a freshly seeded field (see MapInternal.h). Seed costs must be //! between 0 and the largest terrain step (currently 42); do not pass a completed //! field. Uses shared scratch storage: calls across all Maps must be serial and @@ -816,11 +830,14 @@ class Map public: void makeHomogenMap(TerrainType terrainType); + GAGCore::CooperativeTask makeHomogenMapTask(TerrainType terrainType); void controlSand(void); void smoothResources(int times); bool makeRandomMap(MapGenerationDescriptor &descriptor); + GAGCore::CooperativeTask makeRandomMapTask(MapGenerationDescriptor &descriptor); bool oldMakeRandomMap(MapGenerationDescriptor &descriptor); + GAGCore::CooperativeTask oldMakeRandomMapTask(MapGenerationDescriptor &descriptor); bool oldMakeIslandsMap(MapGenerationDescriptor &descriptor); + GAGCore::CooperativeTask oldMakeIslandsMapTask(MapGenerationDescriptor &descriptor); }; - diff --git a/src/map/MapStep.cpp b/src/map/MapStep.cpp index 5ad90f9db..3459ec150 100644 --- a/src/map/MapStep.cpp +++ b/src/map/MapStep.cpp @@ -94,8 +94,9 @@ void Map::syncStep(Uint32 stepCounter) } // We only update one gradient per step, round robin over the gradients in use: - bool updated=false; - while (!updated) + // A freshly loaded map may have no lazily allocated fields yet. Scan once, + // reset the round-robin flags, then scan once more; an empty set is done. + for (int pass = 0; pass < 2; ++pass) { int numberOfTeam=game->mapHeader.getNumberOfTeams(); for (int t=0; t +#include +#include #include "Brush.h" #include "GAGSys.h" @@ -364,19 +366,36 @@ class Checkbox : public MapEditorWidget class MapEdit { friend class HighResolutionIntegrationHarness; + bool editing = false, quitDecision = false; + int editingResult = 0; + GAGCore::InputState inputState; + bool fertilityRequested = false; + std::string pendingSaveFilename, pendingSaveName, pendingLoadFilename; public: MapEdit(); ~MapEdit(); ///Loads the game given by a particular file name bool load(const std::string filename); - ///Saves the game to a particular file name + GAGCore::CooperativeTask loadTask(std::string filename); + ///Writes a map after the owned fertility job has committed its results bool save(const std::string filename, const std::string name); - ///This function sets the map a particular size and uniform terrain type, then goes into the main loop - int run(int sizeX, int sizeY, TerrainType terrainType); - ///This is the main loop function. It "ticks" every 33 miliseconds, handling events and drawing as it goes. - int run(void); - + ///Updates the editor after map generation + void update(); + + void beginEditing(); + void viewportResized(int oldWidth, int oldHeight, int width, int height); + void requestLoad(std::string filename) { pendingLoadFilename = std::move(filename); } + std::string takeLoadRequest() { return std::exchange(pendingLoadFilename, {}); } + void suspendInput(); + bool advanceEditing(const std::vector& events, Uint32 tick); + void drawEditing(); + bool needsFertility() const { return fertilityRequested; } + bool finishFertility(bool completed); + bool needsQuitDecision() const { return quitDecision; } + void resolveQuitDecision(int choice); + int editingReturnCode() const { return editingResult; } + void mapHasBeenModified(void) { hasMapBeenModified=true; } ///This function regenerates a game header for use in campaigns diff --git a/src/map/edit/MapEditActionTerrain.cpp b/src/map/edit/MapEditActionTerrain.cpp index 0fe647ce6..feb00f5fb 100644 --- a/src/map/edit/MapEditActionTerrain.cpp +++ b/src/map/edit/MapEditActionTerrain.cpp @@ -6,7 +6,6 @@ #include "ScriptEditorScreen.h" #include "Unit.h" #include "Utilities.h" -#include "FertilityCalculatorDialog.h" #include "SDLCompat.h" void MapEdit::beginZonePlacement(BrushType type) diff --git a/src/map/edit/MapEditActionView.cpp b/src/map/edit/MapEditActionView.cpp index 08fb55bd3..4b01bfcbc 100644 --- a/src/map/edit/MapEditActionView.cpp +++ b/src/map/edit/MapEditActionView.cpp @@ -7,7 +7,6 @@ #include "MapEdit.h" #include "ScriptEditorScreen.h" #include "Utilities.h" -#include "FertilityCalculatorDialog.h" #include "SDLCompat.h" bool MapEdit::performViewAction(const std::string& action, int relMouseX, int relMouseY) @@ -203,10 +202,7 @@ bool MapEdit::performViewAction(const std::string& action, int relMouseX, int re //Only compute when its x'ed in, not otherwise if(isFertilityOn) { - FertilityCalculatorDialog dialog(globalContainer->gfx, game.map); - dialog.runModal(); - overlay.forceRecompute(); - overlay.compute(game, OverlayArea::Fertility, team); + fertilityRequested = true; } } else if(action=="quit editor") diff --git a/src/map/edit/MapEditClicks.cpp b/src/map/edit/MapEditClicks.cpp index 225a6218e..ecb4b4c82 100644 --- a/src/map/edit/MapEditClicks.cpp +++ b/src/map/edit/MapEditClicks.cpp @@ -9,7 +9,6 @@ #include "ScriptEditorScreen.h" #include "Unit.h" #include "Utilities.h" -#include "FertilityCalculatorDialog.h" #include "SDLCompat.h" void MapEdit::addWidget(MapEditorWidget* widget) diff --git a/src/map/edit/MapEditCtor.cpp b/src/map/edit/MapEditCtor.cpp index 69cf0dba1..e118e0212 100644 --- a/src/map/edit/MapEditCtor.cpp +++ b/src/map/edit/MapEditCtor.cpp @@ -308,7 +308,7 @@ MapEdit::MapEdit() MapEdit::~MapEdit() { Sprite::setHighResolution(false); - Toolkit::releaseSprite("data/gui/editor"); + // The toolkit owns this shared cache entry; other staging editors may use it. for(std::vector::iterator i=mew.begin(); i!=mew.end(); ++i) { delete *i; diff --git a/src/map/edit/MapEditDelegate.cpp b/src/map/edit/MapEditDelegate.cpp index 3a730b167..1020721c2 100644 --- a/src/map/edit/MapEditDelegate.cpp +++ b/src/map/edit/MapEditDelegate.cpp @@ -9,7 +9,6 @@ #include "ScriptEditorScreen.h" #include #include "Utilities.h" -#include "FertilityCalculatorDialog.h" #include "SDLCompat.h" void MapEdit::delegateMenu(SDL_Event& event) @@ -63,7 +62,7 @@ void MapEdit::delegateMenu(SDL_Event& event) { case LoadSaveScreen::OK: { - load(loadSaveScreen->getFileName()); + requestLoad(loadSaveScreen->getFileName()); performAction("close load screen"); } break; @@ -81,11 +80,15 @@ void MapEdit::delegateMenu(SDL_Event& event) { case LoadSaveScreen::OK: { - save(loadSaveScreen->getFileName(), loadSaveScreen->getName()); - performAction("close save screen"); - } + pendingSaveFilename = loadSaveScreen->getFileName(); + pendingSaveName = loadSaveScreen->getName(); + fertilityRequested = true; + loadSaveScreen->endValue = -1; + } + break; case LoadSaveScreen::CANCEL: { + doQuitAfterLoadSave = false; performAction("close save screen"); } } @@ -134,9 +137,9 @@ void MapEdit::handleMapScroll() ySpeed = 0; int scrollAreaWidth=10; // if the cursor is that close to the border the viewport will scroll - SDL_PumpEvents(); - const Uint8 *keystate = SDL_GetKeyboardState(NULL); - SDL_Keymod modState = SDL_GetModState(); + if (!inputState.hasFocus()) return; + const Uint8 *keystate = inputState.keyboard(); + SDL_Keymod modState = inputState.modifiers(); int xMotion = 1; int yMotion = 1; /* We check that only Control is held to avoid accidentally diff --git a/src/map/edit/MapEditDraw.cpp b/src/map/edit/MapEditDraw.cpp index 1bf73fe01..091c9a994 100644 --- a/src/map/edit/MapEditDraw.cpp +++ b/src/map/edit/MapEditDraw.cpp @@ -13,7 +13,6 @@ #include "Unit.h" #include "UnitType.h" #include "Utilities.h" -#include "FertilityCalculatorDialog.h" #include "SDLCompat.h" void MapEdit::draw(Uint64 frameTick) diff --git a/src/map/edit/MapEditEvents.cpp b/src/map/edit/MapEditEvents.cpp index f7fd28316..e843c8b00 100644 --- a/src/map/edit/MapEditEvents.cpp +++ b/src/map/edit/MapEditEvents.cpp @@ -12,18 +12,26 @@ void MapEdit::processEvent(SDL_Event& event) { updateCamera(); + inputState.observe(event); + if (event.type == SDL_WINDOWEVENT && event.window.event == SDL_WINDOWEVENT_FOCUS_LOST) { + suspendInput(); + } + if (!inputState.hasFocus() && (event.type == SDL_KEYDOWN || event.type == SDL_KEYUP || + event.type == SDL_MOUSEBUTTONDOWN || event.type == SDL_MOUSEBUTTONUP || + event.type == SDL_MOUSEMOTION || event.type == SDL_MOUSEWHEEL)) return; + if (event.type==SDL_QUIT) { doFullQuit=true; } # ifdef USE_OSX - else if(event.type == SDL_KEYDOWN && event.key.keysym.sym == SDLK_q && SDL_GetModState() & KMOD_GUI) + else if(event.type == SDL_KEYDOWN && event.key.keysym.sym == SDLK_q && (event.key.keysym.mod & KMOD_GUI)) { doFullQuit=true; } # endif # ifdef USE_WIN32 - else if(event.type == SDL_KEYDOWN && event.key.keysym.sym == SDLK_F4 && SDL_GetModState() & KMOD_ALT) + else if(event.type == SDL_KEYDOWN && event.key.keysym.sym == SDLK_F4 && (event.key.keysym.mod & KMOD_ALT)) { doFullQuit=true; } @@ -34,7 +42,7 @@ void MapEdit::processEvent(SDL_Event& event) delegateMenu(event); return; } - else if(event.type==SDL_MOUSEWHEEL && (SDL_GetModState() & KMOD_ALT)) + else if(event.type==SDL_MOUSEWHEEL && (inputState.modifiers() & KMOD_ALT)) { double delta=event.wheel.y; #if SDL_VERSION_ATLEAST(2,0,18) @@ -292,3 +300,16 @@ void MapEdit::handleKeyPressed(SDL_Keysym key, bool pressed) } + +void MapEdit::suspendInput() +{ + inputState.clearHeld(); + xSpeed = ySpeed = 0; + isDraggingMinimap = isScrollDragging = false; + isDraggingZone = isDraggingTerrain = isDraggingDelete = false; + isDraggingArea = isDraggingNoResourceGrowthArea = false; + // The parent will not receive pointer motion while its child is active. + // Neutralize edge scrolling until a new motion event arrives. + mouseX = globalContainer->gfx->getW() / 2; + mouseY = globalContainer->gfx->getH() / 2; +} diff --git a/src/map/edit/MapEditIO.cpp b/src/map/edit/MapEditIO.cpp index d978175ec..f7d9910aa 100644 --- a/src/map/edit/MapEditIO.cpp +++ b/src/map/edit/MapEditIO.cpp @@ -2,6 +2,7 @@ // Copyright (C) 2001-2004 Stephane Magnenat & Luc-Olivier de Charrière // Copyright (C) 2006 Bradley Arsenault +#include #include #include #include "GameGUILoadSave.h" @@ -14,113 +15,54 @@ #include "Unit.h" #include "UnitType.h" #include "Utilities.h" -#include "FertilityCalculatorDialog.h" #include "GUIMessageBox.h" #include "SDLCompat.h" bool MapEdit::load(const std::string filename) { - assert(filename.size()); - - InputStream *stream = new BinaryInputStream(Toolkit::getFileManager()->openInputStreamBackend(filename)); - if (stream->isEndOfStream()) - { - std::cerr << "MapEdit::load(\"" << filename << "\") : error, can't open file." << std::endl; - delete stream; - return false; - } - else - { - bool rv; - - try - { - rv = game.load(stream); - } - catch (std::exception &e) - { - std::cerr << "Failed to open map: bad format." << std::endl; - - if (!globalContainer->runNoX) - { - // Display an error message - GAGGUI::MessageBox(globalContainer->gfx, "standard", GAGGUI::MB_ONEBUTTON, Toolkit::getStringTable()->getString("[ERROR_CANT_LOAD_MAP]"), Toolkit::getStringTable()->getString("[ok]")); - } - - // We can't recover from this, so we quit - doQuitAfterLoadSave = true; - - return false; - } - - delete stream; - if (!rv) - return false; - - camera=MapCamera();viewportX=viewportY=0; - // set the editor default values - team = 0; - - areaNameLabel->setLabel(game.map.getAreaName(areaNumber->getIndex())); - - minimap.resetMinimapDrawing(); - - game.map.computeDisplayedForbidden(team); - game.map.computeDisplayedClearArea(team); - game.map.computeDisplayedGuardArea(team); - - hasMapBeenModified = false; - return true; - } - return false; + return loadTask(filename).run(); } +GAGCore::CooperativeTask MapEdit::loadTask(std::string filename) +{ + auto stream = std::make_unique(Toolkit::getFileManager()->openInputStreamBackend(filename)); + if (stream->isEndOfStream()) co_return false; + try { + if (!(co_await game.loadTask(stream.get()))) { doQuitAfterLoadSave = true; co_return false; } + } catch (const std::exception&) { + doQuitAfterLoadSave = true; + co_return false; + } + team = 0; + areaNameLabel->setLabel(game.map.getAreaName(areaNumber->getIndex())); + minimap.resetMinimapDrawing(); + game.map.computeDisplayedForbidden(team); + game.map.computeDisplayedClearArea(team); + game.map.computeDisplayedGuardArea(team); + hasMapBeenModified = false; + co_return true; +} bool MapEdit::save(const std::string filename, const std::string name) { - FertilityCalculatorDialog dialog(globalContainer->gfx, game.map); - dialog.runModal(); - assert(filename.size()); assert(name.size()); - hasMapBeenModified = false; - - OutputStream *stream = new BinaryOutputStream(Toolkit::getFileManager()->openOutputStreamBackend(filename)); - if (stream->isEndOfStream()) - { - std::cerr << "MapEdit::save(\"" << filename << "\",\"" << name << "\") : error, can't open file." << std::endl; - delete stream; - return false; - } - else - { - game.save(stream, true, name); - delete stream; - - // Game::save() now restores mapHeader.mapName/isSavedGame so that - // in-game saves don't permanently clobber the live map name. The - // editor relies on the post-save mutation for its "current name" - // UI (the LoadSaveScreen default), so re-apply explicitly. - game.mapHeader.setMapName(name); - game.mapHeader.setIsSavedGame(false); - return true; - } -} - + if (!Toolkit::getFileManager()->writeAtomically(filename, [&](OutputStream& stream) { + game.save(&stream, true, name); + })) return false; - -int MapEdit::run(int sizeX, int sizeY, TerrainType terrainType) -{ - game.map.setSize(sizeX, sizeY, terrainType); - game.map.setGame(&game); - return run(); + // Only publish the new editor name after the complete file was replaced. + hasMapBeenModified = false; + game.mapHeader.setMapName(name); + game.mapHeader.setIsSavedGame(false); + return true; } -int MapEdit::run(void) +void MapEdit::beginEditing() { FrontendScope editor(false); minimap.setGame(game); @@ -128,114 +70,174 @@ int MapEdit::run(void) drawMap(0, 0, globalContainer->gfx->getW()-RIGHT_MENU_WIDTH, globalContainer->gfx->getH()); drawMiniMap(); drawMenu(); - - + + if(game.gameHeader.getNumberOfPlayers() == 0) regenerateGameHeader(); - bool isRunning=true; - int returnCode=0; - Uint64 startTick, endTick, deltaTick; - while (isRunning) + editing = true; + editingResult = 0; +} + +bool MapEdit::advanceEditing(const std::vector& events, Uint32 tick) +{ + if (!editing || quitDecision || fertilityRequested || !pendingLoadFilename.empty()) return editing; + if (showingSave && loadSaveScreen->pollPersistence()) { + hasMapBeenModified = false; + performAction("close save screen"); + } + for (auto event : events) { + GAGCore::GraphicContext::translateMouseEvent(&event); + processEvent(event); + if (doFullQuit || doQuit || fertilityRequested || !pendingLoadFilename.empty() || (doQuitAfterLoadSave && !showingSave)) break; + } + if (doFullQuit) { editingResult = -1; editing = false; return false; } + if (fertilityRequested || !pendingLoadFilename.empty()) return true; + // While processing events the user could've tried to load a map that failed. + // Then we can't go through drawing everything because that would segfault. + if(doQuitAfterLoadSave && !showingSave) + { + editing = false; + return false; + } + + if(!showingMenuScreen && !showingLoad && !showingSave && !showingScriptEditor && !showingTeamsEditor) + { + handleMapScroll(); + updateCamera(); + camera.originX+=xSpeed*32/camera.zoom; + camera.originY+=ySpeed*32/camera.zoom; + camera.normalize(); + viewportX=camera.tileX(); + viewportY=camera.tileY(); + viewportX&=game.map.getMaskW(); + viewportY&=game.map.getMaskH(); + } + + //special overrides here to allow for scrolling and painting terrain at the same time + if(xSpeed!=0 || ySpeed!=0) + { + if(isDraggingZone) + performAction("zone drag motion"); + else if(isDraggingTerrain) + performAction("terrain drag motion"); + else if(isDraggingDelete) + performAction("delete drag motion"); + else if(isDraggingArea) + performAction("area drag motion"); + else if(isDraggingNoResourceGrowthArea) + performAction("no ressource growth area drag motion"); + } + + if (showingMenuScreen) menuScreen->dispatchTimer(tick); + if (showingLoad || showingSave) loadSaveScreen->dispatchTimer(tick); + if (showingScriptEditor) scriptEditor->dispatchTimer(tick); + if (showingTeamsEditor) teamsEditor->dispatchTimer(tick); + if (isShowingAreaName) areaName->dispatchTimer(tick); + if (doFullQuit) { editingResult = -1; editing = false; } + else if (doQuit) { + doQuit = false; + if (hasMapBeenModified) quitDecision = true; + else editing = false; + } + return editing; +} + +void MapEdit::drawEditing() +{ + if (!editing) return; + drawMap(0, 0, globalContainer->gfx->getW()-0, globalContainer->gfx->getH()); + + drawMenu(); + drawMiniMap(); + wasMinimapRendered=false; + drawWidgets(); + if(showingMenuScreen) + { + globalContainer->gfx->setClipRect(); + menuScreen->dispatchPaint(); + globalContainer->gfx->drawSurface((int)menuScreen->decX, (int)menuScreen->decY, menuScreen->getSurface()); + } + if(showingLoad || showingSave) + { + globalContainer->gfx->setClipRect(); + loadSaveScreen->dispatchPaint(); + globalContainer->gfx->drawSurface((int)loadSaveScreen->decX, (int)loadSaveScreen->decY, loadSaveScreen->getSurface()); + } + if(showingScriptEditor) { - startTick=SDL_GetTicks64(); - - // we get all pending events but for mouse motion we only keep the last one - SDL_Event event; - while (GAGCore::GraphicContext::pollEvent(&event)) - { - GAGCore::GraphicContext::translateMouseEvent(&event); - processEvent(event); - } - - // While processing events the user could've tried to load a map that failed. - // Then we can't go through drawing everything because that would segfault. - if(doQuitAfterLoadSave && !showingSave) - { - isRunning = false; - break; - } - - if(!showingMenuScreen && !showingLoad && !showingSave && !showingScriptEditor && !showingTeamsEditor) - { - handleMapScroll(); - updateCamera(); - camera.originX+=xSpeed*32/camera.zoom; - camera.originY+=ySpeed*32/camera.zoom; - camera.normalize();viewportX=camera.tileX();viewportY=camera.tileY(); - viewportX&=game.map.getMaskW(); - viewportY&=game.map.getMaskH(); - } - - //special overrides here to allow for scrolling and painting terrain at the same time - if(xSpeed!=0 || ySpeed!=0) - { - if(isDraggingZone) - performAction("zone drag motion"); - else if(isDraggingTerrain) - performAction("terrain drag motion"); - else if(isDraggingDelete) - performAction("delete drag motion"); - else if(isDraggingArea) - performAction("area drag motion"); - else if(isDraggingNoResourceGrowthArea) - performAction("no ressource growth area drag motion"); - } - - draw(startTick); - - globalContainer->gfx->nextFrame(); - - - endTick=SDL_GetTicks64(); - deltaTick=std::max(0, static_cast(endTick) - static_cast(startTick)); - if (deltaTick<33) - SDL_Delay(33-deltaTick); - if (returnCode==-1) - { - isRunning=false; - } - if(doQuitAfterLoadSave && !showingSave) - { - isRunning=false; - } - if(doQuit) - { - if(hasMapBeenModified) - { - int ret = GAGGUI::MessageBox(globalContainer->gfx, "standard", GAGGUI::MB_THREEBUTTONS, Toolkit::getStringTable()->getString("[save before quit?]"), Toolkit::getStringTable()->getString("[Yes]"), Toolkit::getStringTable()->getString("[No]"), Toolkit::getStringTable()->getString("[Cancel]")); - if(ret == 0) - { - doQuit=false; - doQuitAfterLoadSave=true; - performAction("open save screen"); - } - else if(ret == 1) - { - isRunning=false; - } - else - { - doQuit=false; - } - } - else - { - isRunning=false; - } - } - if(doFullQuit) - { - returnCode = -1; - } - if(!isRunning) - { - SDL_Event event; - while (GAGCore::GraphicContext::pollEvent(&event)); - } + globalContainer->gfx->setClipRect(); + scriptEditor->dispatchPaint(); + globalContainer->gfx->drawSurface((int)scriptEditor->decX, (int)scriptEditor->decY, scriptEditor->getSurface()); + scriptEditor->drawFileDialog(); } + if(showingTeamsEditor) + { + globalContainer->gfx->setClipRect(); + teamsEditor->dispatchPaint(); + globalContainer->gfx->drawSurface((int)teamsEditor->decX, (int)teamsEditor->decY, teamsEditor->getSurface()); + } + if(isShowingAreaName) + { + globalContainer->gfx->setClipRect(); + areaName->dispatchPaint(); + globalContainer->gfx->drawSurface((int)areaName->decX, (int)areaName->decY, areaName->getSurface()); + } + + + globalContainer->gfx->nextFrame(); + - return returnCode; } +void MapEdit::resolveQuitDecision(int choice) +{ + if (!quitDecision) return; + quitDecision = false; + if (choice == 0) { + doQuitAfterLoadSave = true; + performAction("open save screen"); + } else if (choice == 1) editing = false; +} +bool MapEdit::finishFertility(bool completed) +{ + fertilityRequested = false; + if (!pendingSaveFilename.empty()) { + if (completed) { + try { + if (GAGCore::ApplicationHost::storageRestoreFailed() || !save(pendingSaveFilename, pendingSaveName)) + loadSaveScreen->showSaveFailure(); + else loadSaveScreen->beginPersistence(GAGCore::ApplicationHost::persistStorage()); + } catch (const std::exception&) { loadSaveScreen->showSaveFailure(); } + // A local write is not a durable browser save. Keep the editor and + // its quit intent until the shared save dialog acknowledges it. + hasMapBeenModified = true; + } else { + doQuitAfterLoadSave = false; + performAction("close save screen"); + } + pendingSaveFilename.clear(); pendingSaveName.clear(); + } else if (completed) { + overlay.forceRecompute(); + overlay.compute(game, OverlayArea::Fertility, team); + } else isFertilityOn = false; + return true; +} + +void MapEdit::viewportResized(int oldWidth, int oldHeight, int width, int height) +{ + minimap.resizeViewport(width); + viewportX = (viewportX + (oldWidth - RIGHT_MENU_WIDTH) / 64 - (width - RIGHT_MENU_WIDTH) / 64) & game.map.wMask; + viewportY = (viewportY + oldHeight / 64 - height / 64) & game.map.hMask; + for (auto* widget : mew) widget->area.x += width - oldWidth; + for (MapEditorWidget* widget : std::initializer_list{mapCoordinatesLabel, building_view_tcs, + building_view_level1, building_view_level2, building_view_level3, flag_view_tcs, + flag_view_level1, flag_view_level2, flag_view_level3, flag_view_level4}) + widget->area.y += height - oldHeight; + if (showingMenuScreen) menuScreen->viewportResized(oldWidth, oldHeight, width, height); + if (showingLoad || showingSave) loadSaveScreen->viewportResized(oldWidth, oldHeight, width, height); + if (showingScriptEditor) scriptEditor->viewportResized(oldWidth, oldHeight, width, height); + if (showingTeamsEditor) teamsEditor->viewportResized(oldWidth, oldHeight, width, height); + if (isShowingAreaName) areaName->viewportResized(oldWidth, oldHeight, width, height); +} diff --git a/src/map/generator/GameMaps.cpp b/src/map/generator/GameMaps.cpp index debf00b9a..917116e77 100644 --- a/src/map/generator/GameMaps.cpp +++ b/src/map/generator/GameMaps.cpp @@ -5,7 +5,6 @@ #include #include -//also the Perlin Noise stuff uses random that is not based on syncRand #include "Game.h" #include "GlobalContainer.h" #include "MapGenerationDescriptor.h" @@ -16,20 +15,28 @@ bool Game::oldMakeIslandsMap(MapGenerationDescriptor &descriptor) { + return oldMakeIslandsMapTask(descriptor).run(); +} + +GAGCore::CooperativeTask Game::oldMakeIslandsMapTask(MapGenerationDescriptor &descriptor) +{ + unsigned work = 0; + co_await GAGCore::CooperativeTask::checkpoint("[Generating map]"); for (int s=0; sbuildingsTypes.getTypeNum("swarm", 0, false); - if (!checkRoomForBuilding(descriptor.bootX[s], descriptor.bootY[s], globalContainer->buildingsTypes.get(typeNum), -1, false)) + if (!checkRoomForBuilding(descriptor.bootX[s], descriptor.bootY[s], globalContainer->buildingsTypes.get(typeNum), s, false)) { if (verbose) printf("Failed to add swarm of team %d\n", s); - return false; + co_return false; } teams[s]->startPosX=descriptor.bootX[s]; teams[s]->startPosY=descriptor.bootY[s]; @@ -40,21 +47,29 @@ bool Game::oldMakeIslandsMap(MapGenerationDescriptor &descriptor) { if (verbose) printf("Failed to add unit %d of team %d\n", i, s); - return false; + co_return false; } teams[s]->createLists(); } map.smoothResources(descriptor.oldIslandSize/10); - return true; + co_return true; } bool Game::makeRandomMap(MapGenerationDescriptor &descriptor) { + return makeRandomMapTask(descriptor).run(); +} + +GAGCore::CooperativeTask Game::makeRandomMapTask(MapGenerationDescriptor &descriptor) +{ + unsigned work = 0; + co_await GAGCore::CooperativeTask::checkpoint("[Generating map]"); for (int s=0; sstartPosX=descriptor.bootX[s]; teams[s]->startPosY=descriptor.bootY[s]; @@ -77,10 +92,10 @@ bool Game::makeRandomMap(MapGenerationDescriptor &descriptor) { if (verbose) printf("Failed to add unit %d of team %d\n", i, s); - return false; + co_return false; } teams[s]->createLists(); } - return true; + co_return true; } diff --git a/src/map/generator/Generator.cpp b/src/map/generator/Generator.cpp index 2e32ce26b..c42ab7605 100644 --- a/src/map/generator/Generator.cpp +++ b/src/map/generator/Generator.cpp @@ -5,7 +5,7 @@ #include #include -//also the Perlin Noise stuff uses random that is not based on syncRand +// Generation randomness is drawn from the explicitly seeded synchronized stream. #include "Game.h" #include "MapGenerationDescriptor.h" #include "MapGenerator.h" @@ -13,48 +13,63 @@ #include "Unit.h" #include "Utilities.h" -bool MapGenerator::generateMap(Game& game, MapGenerationDescriptor &descriptor) +bool MapGenerator::generateMap(Game& game, MapGenerationDescriptor& descriptor) { + return generateMap(game, descriptor, static_cast(time(nullptr))); +} + +bool MapGenerator::generateMap(Game& game, MapGenerationDescriptor& descriptor, Uint32 seed) +{ + return generateMapTask(game, descriptor, seed).run(); +} + +GAGCore::CooperativeTask MapGenerator::generateMapTask(Game& game, MapGenerationDescriptor& descriptor, Uint32 seed) +{ + co_await GAGCore::CooperativeTask::checkpoint("[Generating map]"); + if (!Map::supportedDimensions(descriptor.wDec, descriptor.hDec) || + descriptor.method < MapGenerationDescriptor::eUNIFORM || descriptor.method > MapGenerationDescriptor::eOLDISLANDS || + (descriptor.method != MapGenerationDescriptor::eUNIFORM && + (descriptor.nbTeams < 1 || descriptor.nbTeams > Team::MAX_COUNT))) co_return false; + setSyncRandSeed(seed); if (verbose) printf("Generating map, please wait ....\n"); game.map.setSize(descriptor.wDec, descriptor.hDec); game.map.setGame(&game); - setRandomSyncRandSeed(); switch (descriptor.method) { case MapGenerationDescriptor::eUNIFORM: - game.map.makeHomogenMap(descriptor.terrainType); - game.addTeam(); + co_await game.map.makeHomogenMapTask(descriptor.terrainType); + co_await game.addTeamTask(); break; case MapGenerationDescriptor::eSWAMP: case MapGenerationDescriptor::eISLANDS: case MapGenerationDescriptor::eRIVER: case MapGenerationDescriptor::eCRATERLAKES: - if (!game.map.makeRandomMap(descriptor)) - return false; - if (!game.makeRandomMap(descriptor)) - return false; + if (!(co_await game.map.makeRandomMapTask(descriptor))) + co_return false; + if (!(co_await game.makeRandomMapTask(descriptor))) + co_return false; break; case MapGenerationDescriptor::eCONCRETEISLANDS: - if (!computeConcreteIslands(game, descriptor)) - return false; + if (!(co_await computeConcreteIslandsTask(game, descriptor))) + co_return false; break; case MapGenerationDescriptor::eISLES: - if (!computeIsles(game, descriptor)) - return false; + if (!(co_await computeIslesTask(game, descriptor))) + co_return false; break; case MapGenerationDescriptor::eOLDRANDOM: - if (!game.map.oldMakeRandomMap(descriptor)) - return false; - if (!game.makeRandomMap(descriptor)) - return false; + if (!(co_await game.map.oldMakeRandomMapTask(descriptor))) + co_return false; + if (!(co_await game.makeRandomMapTask(descriptor))) + co_return false; break; case MapGenerationDescriptor::eOLDISLANDS: - if (!game.map.oldMakeIslandsMap(descriptor)) - return false; - if (!game.oldMakeIslandsMap(descriptor)) - return false; + if (!(co_await game.map.oldMakeIslandsMapTask(descriptor))) + co_return false; + if (!(co_await game.oldMakeIslandsMapTask(descriptor))) + co_return false; break; default: @@ -66,15 +81,22 @@ bool MapGenerator::generateMap(Game& game, MapGenerationDescriptor &descriptor) if (verbose) printf(".... map generated.\n"); - return true; + co_return true; } bool MapGenerator::computeConcreteIslands(Game& game, MapGenerationDescriptor& descriptor) { - game.map.makeHomogenMap(descriptor.terrainType); + return computeConcreteIslandsTask(game, descriptor).run(); +} + +GAGCore::CooperativeTask MapGenerator::computeConcreteIslandsTask(Game& game, MapGenerationDescriptor& descriptor) +{ + unsigned work = 0; + co_await GAGCore::CooperativeTask::checkpoint("[Generating map]"); + co_await game.map.makeHomogenMapTask(descriptor.terrainType); for(int i=0; i heights(game.map.getW() * game.map.getH(), 75); - adjustHeightmapFromPerlinNoise(game, heights, 15); + co_await adjustHeightmapFromPerlinNoiseTask(game, heights, 15); // Compute the distance of every square from the border std::vector sources; - findBorderPoints(game, grid, sources); + co_await findBorderPointsTask(game, grid, sources); std::vector obstacles; std::vector distances; - computeDistances(game, sources, obstacles, distances); + co_await computeDistancesTask(game, sources, obstacles, distances); // Locations near the border are deaper, thus causing more water for(int x=0; x areaWeights; std::vector areaNumbers; @@ -191,18 +220,18 @@ bool MapGenerator::computeConcreteIslands(Game& game, MapGenerationDescriptor& d } // Divide the area. Its possible the area will be so small it can't be used - if(divideUpArea(game, grid, islandAreaNumbers[i], areaWeights, areaNumbers)) + if(co_await divideUpAreaTask(game, grid, islandAreaNumbers[i], areaWeights, areaNumbers)) { // Fill in wheat std::vector points; - getAllPoints(game, grid, areaNumbers[0], points); - fillInResource(game, points, CORN, 2); + co_await getAllPointsTask(game, grid, areaNumbers[0], points); + co_await fillInResourceTask(game, points, CORN, 2); points.clear(); // Place some fruit int fruit_n = syncRand()%6+1; - getAllPoints(game, grid, areaNumbers[1], points); - chooseRandomPoints(game, points, fruit_n); + co_await getAllPointsTask(game, grid, areaNumbers[1], points); + co_await chooseRandomPointsTask(game, points, fruit_n); for(unsigned int j=0; jcreateLists(); } - return true; + co_return true; } bool MapGenerator::computeIsles(Game& game, MapGenerationDescriptor& descriptor) { - game.map.makeHomogenMap(descriptor.terrainType); + return computeIslesTask(game, descriptor).run(); +} + +GAGCore::CooperativeTask MapGenerator::computeIslesTask(Game& game, MapGenerationDescriptor& descriptor) +{ + unsigned work = 0; + co_await GAGCore::CooperativeTask::checkpoint("[Generating map]"); + co_await game.map.makeHomogenMapTask(descriptor.terrainType); for(int i=0; i grid(game.map.getW() * game.map.getH(), 0); @@ -238,31 +275,35 @@ bool MapGenerator::computeIsles(Game& game, MapGenerationDescriptor& descriptor) std::vector teamAreaNumbers; for(int i=0; i heightmap(game.map.getW() * game.map.getH(), 50); std::vector teamAreaPoints; - getAllOtherPoints(game, grid, 0, teamAreaPoints); + co_await getAllOtherPointsTask(game, grid, 0, teamAreaPoints); std::vector obstacles; std::vector distances; - computeDistances(game, teamAreaPoints, obstacles, distances); + co_await computeDistancesTask(game, teamAreaPoints, obstacles, distances); // Stamp out the team areas for(int x=0; x teamI; std::vector teamJ; - getAllPoints(game, grid, teamAreaNumbers[i], teamI); - getAllPoints(game, grid, teamAreaNumbers[j], teamJ); - chooseRandomPoints(game, teamI, 1); - chooseRandomPoints(game, teamJ, 1); + co_await getAllPointsTask(game, grid, teamAreaNumbers[i], teamI); + co_await getAllPointsTask(game, grid, teamAreaNumbers[j], teamJ); + co_await chooseRandomPointsTask(game, teamI, 1); + co_await chooseRandomPointsTask(game, teamJ, 1); // Traverse between the two points std::vector linePoints; - getAllPointsLine(game, teamI[0].x, teamI[0].y, teamJ[0].x, teamJ[0].y, linePoints); + co_await getAllPointsLineTask(game, teamI[0].x, teamI[0].y, teamJ[0].x, teamJ[0].y, linePoints); // If a connection can be made without going through another teams area, then do it bool failed=false; for(unsigned int p=0; p connectorDistances = distances; // For each team, find a point just off the coast and place algae there for(int i=0; i sources; - getAllPoints(game, grid, teamAreaNumbers[i], sources); - computeDistances(game, sources, obstacles, distances); + co_await getAllPointsTask(game, grid, teamAreaNumbers[i], sources); + co_await computeDistancesTask(game, sources, obstacles, distances); std::vector possible; for(int x=0; xcreateLists(); } - return true; + co_return true; } - diff --git a/src/map/generator/GeneratorDivide.cpp b/src/map/generator/GeneratorDivide.cpp index f6a094430..0d95dee49 100644 --- a/src/map/generator/GeneratorDivide.cpp +++ b/src/map/generator/GeneratorDivide.cpp @@ -16,6 +16,13 @@ bool MapGenerator::divideUpPlayerLands(Game& game, MapGenerationDescriptor& descriptor, std::vector& grid, std::vector& teamAreaNumbers, int& areaNumber) { + return divideUpPlayerLandsTask(game, descriptor, grid, teamAreaNumbers, areaNumber).run(); +} + +GAGCore::CooperativeTask MapGenerator::divideUpPlayerLandsTask(Game& game, MapGenerationDescriptor& descriptor, std::vector& grid, std::vector& teamAreaNumbers, int& areaNumber) +{ + unsigned operations = 0; + co_await GAGCore::CooperativeTask::checkpoint("[Generating map]"); int typeNum=globalContainer->buildingsTypes.getTypeNum("swarm", 0, false); BuildingType *swarm = globalContainer->buildingsTypes.get(typeNum); @@ -24,16 +31,18 @@ bool MapGenerator::divideUpPlayerLands(Game& game, MapGenerationDescriptor& desc std::vector obstacles; std::vector distances; obstacles.clear(); - getAllPoints(game, grid, 0, sources); - computeDistances(game, sources, obstacles, distances); + co_await getAllPointsTask(game, grid, 0, sources); + co_await computeDistancesTask(game, sources, obstacles, distances); //Create a new heightmap from noise and distance to water std::vector heightmap(game.map.getW() * game.map.getH(), 50); - adjustHeightmapFromPerlinNoise(game, heightmap, 5); + co_await adjustHeightmapFromPerlinNoiseTask(game, heightmap, 5); for(int x=0; x areaWeights; std::vector areaNumbers; for(int j=0; j<12; ++j) { + if (++operations % 1024 == 0) co_await GAGCore::CooperativeTask::checkpoint(); areaWeights.push_back(1); areaNumbers.push_back(areaNumber); areaNumber+=1; } // Divide the area. Its possible the area will be so small it can't be used - if(divideUpArea(game, grid, teamAreaNumbers[i], areaWeights, areaNumbers)) + if(co_await divideUpAreaTask(game, grid, teamAreaNumbers[i], areaWeights, areaNumbers)) { // Sort the list of areas based on how close they are to water std::vector areaDistances(areaNumbers.size()); std::vector areaIndexes(areaNumbers.size()); for(unsigned int j=0; j wheatWoodPoints; std::vector wheatPoints; - getAllPoints(game, grid, areaNumbers[3], wheatWoodPoints); - getAllPoints(game, grid, areaNumbers[4], wheatWoodPoints); - getAllPoints(game, grid, areaNumbers[5], wheatWoodPoints); - adjustHeightmapFromPoints(game, wheatWoodPoints, heightmap, 10); + co_await getAllPointsTask(game, grid, areaNumbers[3], wheatWoodPoints); + co_await getAllPointsTask(game, grid, areaNumbers[4], wheatWoodPoints); + co_await getAllPointsTask(game, grid, areaNumbers[5], wheatWoodPoints); + co_await adjustHeightmapFromPointsTask(game, wheatWoodPoints, heightmap, 10); for(unsigned int j=0; j 50) { @@ -88,12 +102,13 @@ bool MapGenerator::divideUpPlayerLands(Game& game, MapGenerationDescriptor& desc wheatWoodPoints.clear(); // Place wheat - getAllPoints(game, grid, areaNumbers[0], wheatWoodPoints); - getAllPoints(game, grid, areaNumbers[1], wheatWoodPoints); - getAllPoints(game, grid, areaNumbers[2], wheatWoodPoints); - adjustHeightmapFromPoints(game, wheatWoodPoints, heightmap, 10); + co_await getAllPointsTask(game, grid, areaNumbers[0], wheatWoodPoints); + co_await getAllPointsTask(game, grid, areaNumbers[1], wheatWoodPoints); + co_await getAllPointsTask(game, grid, areaNumbers[2], wheatWoodPoints); + co_await adjustHeightmapFromPointsTask(game, wheatWoodPoints, heightmap, 10); for(unsigned int j=0; j 50) { @@ -105,45 +120,49 @@ bool MapGenerator::divideUpPlayerLands(Game& game, MapGenerationDescriptor& desc // These are all points in the base std::vector baseLocations; - getAllPoints(game, grid, areaNumbers[6], baseLocations); - getAllPoints(game, grid, areaNumbers[7], baseLocations); - getAllPoints(game, grid, areaNumbers[8], baseLocations); - getAllPoints(game, grid, areaNumbers[9], baseLocations); - getAllPoints(game, grid, areaNumbers[10], baseLocations); - getAllPoints(game, grid, areaNumbers[11], baseLocations); + co_await getAllPointsTask(game, grid, areaNumbers[6], baseLocations); + co_await getAllPointsTask(game, grid, areaNumbers[7], baseLocations); + co_await getAllPointsTask(game, grid, areaNumbers[8], baseLocations); + co_await getAllPointsTask(game, grid, areaNumbers[9], baseLocations); + co_await getAllPointsTask(game, grid, areaNumbers[10], baseLocations); + co_await getAllPointsTask(game, grid, areaNumbers[11], baseLocations); // Place stone int numberOfStone = 6; std::vector stoneLocations = baseLocations; - chooseRandomPoints(game, stoneLocations, numberOfStone); + co_await chooseRandomPointsTask(game, stoneLocations, numberOfStone); for(unsigned int j=0; j wheatDistance; - computeDistances(game, wheatPoints, obstacles, wheatDistance); + co_await computeDistancesTask(game, wheatPoints, obstacles, wheatDistance); // Only consider points between 1 and 4 squares from wheat std::vector startingLocations; for(unsigned int j=0; j unitLocations = baseLocations; - chooseFreeForGroundUnits(game, unitLocations, i); - chooseTouchingBuilding(game, unitLocations, b); - chooseRandomPoints(game, unitLocations, descriptor.nbWorkers); + co_await chooseFreeForGroundUnitsTask(game, unitLocations, i); + co_await chooseTouchingBuildingTask(game, unitLocations, b); + co_await chooseRandomPointsTask(game, unitLocations, descriptor.nbWorkers); for(unsigned int n=0; n& grid, int areaN, std::vector& weights, std::vector& areaNumbers) { + return divideUpAreaTask(game, grid, areaN, weights, areaNumbers).run(); +} + +GAGCore::CooperativeTask MapGenerator::divideUpAreaTask(Game& game, std::vector& grid, int areaN, std::vector& weights, std::vector& areaNumbers) +{ + unsigned operations = 0; + co_await GAGCore::CooperativeTask::checkpoint("[Generating map]"); std::vector points; std::vector splitWeights; for(unsigned int i=0; i& grid, int areaN, int x, int y, int width, int height) { + createOvalTask(game, grid, areaN, x, y, width, height).run(); +} + +GAGCore::CooperativeTask MapGenerator::createOvalTask(Game& game, std::vector& grid, int areaN, int x, int y, int width, int height) +{ + unsigned operations = 0; + co_await GAGCore::CooperativeTask::checkpoint("[Generating map]"); int h2 = (height/2) * (height/2); int w2 = (width/2) * (width/2); int t2 = h2 * w2; for(int px = -(width/2); px < (width/2); ++px) { + if (++operations % 1024 == 0) co_await GAGCore::CooperativeTask::checkpoint(); int nx = game.map.normalizeX(x + px); int px2 = px*px*h2; for(int py = -(height/2); py < (height/2); ++py) { + if (++operations % 1024 == 0) co_await GAGCore::CooperativeTask::checkpoint(); int ny = game.map.normalizeY(y + py); int py2 = py*py*w2; if(px2 + py2 < t2) @@ -231,6 +268,7 @@ void MapGenerator::createOval(Game& game, std::vector& grid, int areaN, int } } } + co_return true; } diff --git a/src/map/generator/GeneratorHeightmap.cpp b/src/map/generator/GeneratorHeightmap.cpp index 5f37f4d1c..12d7dba9a 100644 --- a/src/map/generator/GeneratorHeightmap.cpp +++ b/src/map/generator/GeneratorHeightmap.cpp @@ -17,41 +17,69 @@ void MapGenerator::adjustHeightmapFromPoints(Game& game, std::vector& points, std::vector& heightmap, int value) { + adjustHeightmapFromPointsTask(game, points, heightmap, value).run(); +} + +GAGCore::CooperativeTask MapGenerator::adjustHeightmapFromPointsTask(Game& game, std::vector& points, std::vector& heightmap, int value) +{ + unsigned operations = 0; + co_await GAGCore::CooperativeTask::checkpoint("[Generating map]"); for(unsigned int i=0; i& heights, int spread) { + adjustHeightmapFromPerlinNoiseTask(game, heights, spread).run(); +} + +GAGCore::CooperativeTask MapGenerator::adjustHeightmapFromPerlinNoiseTask(Game& game, std::vector& heights, int spread) +{ + unsigned operations = 0; + co_await GAGCore::CooperativeTask::checkpoint("[Generating map]"); HeightMap noise(game.map.getW(), game.map.getH()); - noise.makePlain(4); + co_await noise.makePlainTask(4); for(int x=0; x& sources, std::vector& obstacles, std::vector& heightmap) { + computeDistancesTask(game, sources, obstacles, heightmap).run(); +} + +GAGCore::CooperativeTask MapGenerator::computeDistancesTask(Game& game, std::vector& sources, std::vector& obstacles, std::vector& heightmap) +{ + unsigned operations = 0; + co_await GAGCore::CooperativeTask::checkpoint("[Generating map]"); std::queue places; heightmap.clear(); heightmap.resize(game.map.getW() * game.map.getH(), 0); for(unsigned int i=0; i& Uint32 wMask = game.map.wMask; while (!places.empty()) { + if (++operations % 1024 == 0) co_await GAGCore::CooperativeTask::checkpoint(); int deltaAddrG = places.front(); places.pop(); @@ -96,18 +125,29 @@ void MapGenerator::computeDistances(Game& game, std::vector& } } } + co_return true; } int MapGenerator::computeAverageDistance(Game& game, std::vector& grid, int areaN, const std::vector& heightmap) { + int result = 0; + computeAverageDistanceTask(game, grid, areaN, heightmap, result).run(); + return result; +} + +GAGCore::CooperativeTask MapGenerator::computeAverageDistanceTask(Game& game, std::vector& grid, int areaN, const std::vector& heightmap, int& result) +{ + unsigned operations = 0; + co_await GAGCore::CooperativeTask::checkpoint("[Generating map]"); long total = 0; int count = 0; for(int x=0; x& grid, int } } } - return count > 0 ? total/count : 0; + result = count > 0 ? total/count : 0; + co_return true; } diff --git a/src/map/generator/GeneratorPoints.cpp b/src/map/generator/GeneratorPoints.cpp index b1ae5f083..312a00655 100644 --- a/src/map/generator/GeneratorPoints.cpp +++ b/src/map/generator/GeneratorPoints.cpp @@ -14,34 +14,61 @@ void MapGenerator::getAllPoints(Game& game, std::vector& grid, int areaN, std::vector& points) { + getAllPointsTask(game, grid, areaN, points).run(); +} + +GAGCore::CooperativeTask MapGenerator::getAllPointsTask(Game& game, std::vector& grid, int areaN, std::vector& points) +{ + unsigned operations = 0; + co_await GAGCore::CooperativeTask::checkpoint("[Generating map]"); for(int x=0; x& grid, int areaN, std::vector& points) { + getAllOtherPointsTask(game, grid, areaN, points).run(); +} + +GAGCore::CooperativeTask MapGenerator::getAllOtherPointsTask(Game& game, std::vector& grid, int areaN, std::vector& points) +{ + unsigned operations = 0; + co_await GAGCore::CooperativeTask::checkpoint("[Generating map]"); for(int x=0; x& points) { + getAllPointsLineTask(game, x1, y1, x2, y2, points).run(); +} + +GAGCore::CooperativeTask MapGenerator::getAllPointsLineTask(Game& game, int x1, int y1, int x2, int y2, std::vector& points) +{ + unsigned operations = 0; + co_await GAGCore::CooperativeTask::checkpoint("[Generating map]"); int startX = x1; int endX = x2; int startY = y1; @@ -70,6 +97,7 @@ void MapGenerator::getAllPointsLine(Game& game, int x1, int y1, int x2, int y2, int y = startY; for(int x=startX; x!=endX;) { + if (++operations % 1024 == 0) co_await GAGCore::CooperativeTask::checkpoint(); px+=1; points.push_back(MapGeneratorPoint(x, y)); if(std::abs(px * distY - py * distX) > std::abs(px * distY - (py+1) * distX)) @@ -88,6 +116,7 @@ void MapGenerator::getAllPointsLine(Game& game, int x1, int y1, int x2, int y2, int x = startX; for(int y=startY; y!=endY;) { + if (++operations % 1024 == 0) co_await GAGCore::CooperativeTask::checkpoint(); py+=1; points.push_back(MapGeneratorPoint(x, y)); if(std::abs(py * distX - px * distY) > std::abs(py * distX - (px+1) * distY)) @@ -99,21 +128,33 @@ void MapGenerator::getAllPointsLine(Game& game, int x1, int y1, int x2, int y2, y=game.map.normalizeY(y+dirY); } } + co_return true; } void MapGenerator::findBorderPoints(Game& game, std::vector& grid, std::vector& points) { + findBorderPointsTask(game, grid, points).run(); +} + +GAGCore::CooperativeTask MapGenerator::findBorderPointsTask(Game& game, std::vector& grid, std::vector& points) +{ + unsigned operations = 0; + co_await GAGCore::CooperativeTask::checkpoint("[Generating map]"); for(int x=0; x& grid, std::vec points.push_back(MapGeneratorPoint(x, y)); } } + co_return true; } void MapGenerator::fillInResource(Game& game, std::vector& points, int resourceType, int maxFillSize) { + fillInResourceTask(game, points, resourceType, maxFillSize).run(); +} + +GAGCore::CooperativeTask MapGenerator::fillInResourceTask(Game& game, std::vector& points, int resourceType, int maxFillSize) +{ + unsigned operations = 0; + co_await GAGCore::CooperativeTask::checkpoint("[Generating map]"); for(unsigned int n=0; n& points, int n) { + chooseRandomPointsTask(game, points, n).run(); +} + +GAGCore::CooperativeTask MapGenerator::chooseRandomPointsTask(Game& game, std::vector& points, int n) +{ + unsigned operations = 0; + co_await GAGCore::CooperativeTask::checkpoint("[Generating map]"); n = std::min(int(points.size()), n); for(int i=0; i& points, BuildingType* type, int team) { + chooseFreeForBuildingSquaresTask(game, points, type, team).run(); +} + +GAGCore::CooperativeTask MapGenerator::chooseFreeForBuildingSquaresTask(Game& game, std::vector& points, BuildingType* type, int team) +{ + unsigned operations = 0; + co_await GAGCore::CooperativeTask::checkpoint("[Generating map]"); std::vector newPoints; for(unsigned int n=0; n& points, int team) { + chooseFreeForGroundUnitsTask(game, points, team).run(); +} + +GAGCore::CooperativeTask MapGenerator::chooseFreeForGroundUnitsTask(Game& game, std::vector& points, int team) +{ + unsigned operations = 0; + co_await GAGCore::CooperativeTask::checkpoint("[Generating map]"); std::vector newPoints; for(unsigned int n=0; n& points, Building* building) { + chooseTouchingBuildingTask(game, points, building).run(); +} + +GAGCore::CooperativeTask MapGenerator::chooseTouchingBuildingTask(Game& game, std::vector& points, Building* building) +{ + unsigned operations = 0; + co_await GAGCore::CooperativeTask::checkpoint("[Generating map]"); std::vector newPoints; for(unsigned int n=0; ngid)) { newPoints.push_back(MapGeneratorPoint(points[n].x, points[n].y)); } } - points = newPoints; + points.swap(newPoints); + co_return true; } diff --git a/src/map/generator/GeneratorSplit.cpp b/src/map/generator/GeneratorSplit.cpp index bad31db31..a57edd038 100644 --- a/src/map/generator/GeneratorSplit.cpp +++ b/src/map/generator/GeneratorSplit.cpp @@ -15,11 +15,23 @@ int MapGenerator::splitUpPoints(Game& game, std::vector& grid, int areaN, std::vector& points, std::vector& weights) { + int result = 0; + splitUpPointsTask(game, grid, areaN, points, weights, &result).run(); + return result; +} + +GAGCore::CooperativeTask MapGenerator::splitUpPointsTask(Game& game, std::vector& grid, int areaN, std::vector& points, std::vector& weights, int* minimumDistance) +{ + unsigned operations = 0; + if (minimumDistance) *minimumDistance = 0; + co_await GAGCore::CooperativeTask::checkpoint("[Generating map]"); std::vector startingPoints; for(int x=0; x& grid, int areaN, s } if(startingPoints.empty()) - return 0; + co_return false; Uint32 n = syncRand() % startingPoints.size(); std::vector obstacles; - getAllOtherPoints(game, grid, areaN, obstacles); + co_await getAllOtherPointsTask(game, grid, areaN, obstacles); std::vector sources; sources.push_back(startingPoints[n]); std::vector heights; - computeDistances(game, sources, obstacles, heights); + co_await computeDistancesTask(game, sources, obstacles, heights); sources.clear(); for(unsigned int i=0; i possible; for(int x=0; x max) { @@ -63,7 +78,7 @@ int MapGenerator::splitUpPoints(Game& game, std::vector& grid, int areaN, s int n = syncRand() % possible.size(); points[i] = possible[n]; sources.push_back(points[i]); - computeDistances(game, sources, obstacles, heights); + co_await computeDistancesTask(game, sources, obstacles, heights); } startingPoints.clear(); heights.clear(); @@ -74,13 +89,16 @@ int MapGenerator::splitUpPoints(Game& game, std::vector& grid, int areaN, s int minDist = boost::integer_traits::const_max; while(cont) { + if (++operations % 1024 == 0) co_await GAGCore::CooperativeTask::checkpoint(); minDist = boost::integer_traits::const_max; bool changed=false; for(unsigned int i=0; i::const_max; for(unsigned int j=0; j& grid, int areaN, s int best_y = -1; for(int dx=-3; dx<=3; ++dx) { + if (++operations % 1024 == 0) co_await GAGCore::CooperativeTask::checkpoint(); for(int dy=-3; dy<=3; ++dy) { + if (++operations % 1024 == 0) co_await GAGCore::CooperativeTask::checkpoint(); if(dx==0 && dy==0) continue; int nx = game.map.normalizeX(points[i].x + dx); @@ -104,6 +124,7 @@ int MapGenerator::splitUpPoints(Game& game, std::vector& grid, int areaN, s bool invalid=false; for(unsigned int j=0; j& grid, int areaN, s for(unsigned int i=0; i(0, i)(randomGenerator); if (i != j) std::swap(points[i], points[j]); } - return int(std::sqrt(double(minDist))); + const int result = int(std::sqrt(double(minDist))); + if (minimumDistance) *minimumDistance = result; + co_return result != 0; } @@ -163,6 +189,13 @@ int MapGenerator::splitUpPoints(Game& game, std::vector& grid, int areaN, s void MapGenerator::splitUpArea(Game& game, std::vector& grid, int areaN, std::vector& points, std::vector& weights, std::vector& areaNumbers, bool grassOnly) { + splitUpAreaTask(game, grid, areaN, points, weights, areaNumbers, grassOnly).run(); +} + +GAGCore::CooperativeTask MapGenerator::splitUpAreaTask(Game& game, std::vector& grid, int areaN, std::vector& points, std::vector& weights, std::vector& areaNumbers, bool grassOnly) +{ + unsigned operations = 0; + co_await GAGCore::CooperativeTask::checkpoint("[Generating map]"); std::vector gradient(game.map.getW() * game.map.getH(), 0); Uint32 wDec = game.map.wDec; @@ -176,6 +209,7 @@ void MapGenerator::splitUpArea(Game& game, std::vector& grid, int areaN, st for(unsigned int i=0; i& grid, int areaN, st bool cont=true; while(cont) { + if (++operations % 1024 == 0) co_await GAGCore::CooperativeTask::checkpoint(); bool found=false; for(unsigned int p=0; p 0 && !squares[p].empty()) { + if (++operations % 1024 == 0) co_await GAGCore::CooperativeTask::checkpoint(); Uint32 deltaAddrG = squares[p].back(); squares[p].erase(--squares[p].end()); @@ -247,7 +284,10 @@ void MapGenerator::splitUpArea(Game& game, std::vector& grid, int areaN, st Uint32 randLocation = syncRand() % count[p]; std::list::iterator i = squares[p].begin(); - std::advance(i, randLocation); + for (Uint32 step = 0; step < randLocation; ++step) { + ++i; + if (++operations % 1024 == 0) co_await GAGCore::CooperativeTask::checkpoint(); + } squares[p].insert(i, deltaAddrC[ci]); } } @@ -256,6 +296,7 @@ void MapGenerator::splitUpArea(Game& game, std::vector& grid, int areaN, st if(!found) cont = false; } + co_return true; } diff --git a/src/map/generator/HeightMapGenerator.cpp b/src/map/generator/HeightMapGenerator.cpp index f1c52f4fe..07de1d505 100644 --- a/src/map/generator/HeightMapGenerator.cpp +++ b/src/map/generator/HeightMapGenerator.cpp @@ -4,10 +4,13 @@ #include "GlobalContainer.h" #include "HeightMapGenerator.h" #include +#include +#include #include #include #include #include "PerlinNoise.h" +#include "Utilities.h" /// these faders are factors to be applicable to heightfields. they map (0,0)-(w,h) to [0..1] @@ -45,19 +48,24 @@ void HeightMap::init(unsigned int width, unsigned int height) _w=width; _h=height; _map=new float[_w*_h]; _stamp=NULL; - _pn.reseed(); + _pn.reseed(syncRand()); } -void HeightMap::makeStamp(unsigned int radius) +GAGCore::CooperativeTask HeightMap::makeStampTask(unsigned int radius) { + unsigned operations = 0; + co_await GAGCore::CooperativeTask::checkpoint("[Generating map]"); _r=radius; - if(_stamp) - delete [] _stamp; - _stamp=new float[(2*_r+1)*(2*_r+1)]; + auto replacement = new float[(2*_r+1)*(2*_r+1)]; + delete [] _stamp; + _stamp = replacement; + lowerValid = false; for(unsigned int x=0; x<2*_r+1; x++) { + if (++operations % 1024 == 0) co_await GAGCore::CooperativeTask::checkpoint(); for(unsigned int y=0; y<2*_r+1;y++) { + if (++operations % 1024 == 0) co_await GAGCore::CooperativeTask::checkpoint(); unsigned int dSquare=(x-_r)*(x-_r)+(y-_r)*(y-_r); if(dSquare<_r*_r) _stamp[x+y*(2*_r+1)]=(1.0-cos(sqrt(dSquare)*3.14159265/(float)_r))/2.0; @@ -65,56 +73,61 @@ void HeightMap::makeStamp(unsigned int radius) _stamp[x+y*(2*_r+1)]=.9999; } } + co_return true; } -inline void HeightMap::lower(unsigned int coordX, unsigned int coordY) +GAGCore::CooperativeTask HeightMap::lowerTask(unsigned int coordX, unsigned int coordY) { - static unsigned int oldX=(unsigned int)-1; - static unsigned int oldY=(unsigned int)-1; - if((coordX!=oldX) || (coordY!=oldY)) //don't stamp the same spot again. if stamp is moved like in rivermaps this saves a lot of time + if (lowerValid && coordX == lowerX && coordY == lowerY) co_return true; + lowerValid = true; lowerX = coordX; lowerY = coordY; + unsigned operations = 0; { assert(_stamp); for(unsigned int x=0; x<2*_r+1;x++) { + if (++operations % 1024 == 0) co_await GAGCore::CooperativeTask::checkpoint(); /// this loop can be replaced by a somehow complicated memcpy for(unsigned int y=0; y<2*_r+1;y++) { + if (++operations % 1024 == 0) co_await GAGCore::CooperativeTask::checkpoint(); unsigned int coord1d=(unsigned int)(_w+x-_r+coordX)%_w+((unsigned int)(_h+y-_r+coordY)%_h)*_w; if(_map[coord1d]>_stamp[x+y*(2*_r+1)]) _map[coord1d]=_stamp[x+y*(2*_r+1)]; } } - oldX=coordX; - oldY=coordY; } + co_return true; } -inline void HeightMap::differenceStamp(unsigned int coordX, unsigned int coordY) +GAGCore::CooperativeTask HeightMap::differenceStampTask(unsigned int coordX, unsigned int coordY) { - static unsigned int oldX=(unsigned int)-1; - static unsigned int oldY=(unsigned int)-1; - if((coordX!=oldX) || (coordY!=oldY)) //don't stamp the same spot again. if stamp is moved like in rivermaps this saves a lot of time + unsigned operations = 0; { assert(_stamp); for(unsigned int x=0; x<2*_r+1;x++) { + if (++operations % 1024 == 0) co_await GAGCore::CooperativeTask::checkpoint(); for(unsigned int y=0; y<2*_r+1;y++) { + if (++operations % 1024 == 0) co_await GAGCore::CooperativeTask::checkpoint(); unsigned int coord1d=(unsigned int)(_w+x-_r+coordX)%_w+((unsigned int)(_h+y-_r+coordY)%_h)*_w; _map[coord1d]=fabs((1.0-_stamp[x+y*(2*_r+1)])-_map[coord1d]); } } - oldX=coordX; - oldY=coordY; } + co_return true; } -inline void HeightMap::addNoise(float weight, float smoothingFactor) +GAGCore::CooperativeTask HeightMap::addNoiseTask(float weight, float smoothingFactor) { + unsigned operations = 0; + co_await GAGCore::CooperativeTask::checkpoint("[Generating map]"); assert((weight>0) && (weight<=1.0)); for (int x=0; (unsigned int)x<_w; x++) { + if (++operations % 1024 == 0) co_await GAGCore::CooperativeTask::checkpoint(); for (int y=0; (unsigned int)y<_h; y++) { + if (++operations % 1024 == 0) co_await GAGCore::CooperativeTask::checkpoint(); _map[x+_w*y] = _map[x+_w*y]*(1.0-weight)+ (faderCenter(x,y,_w,_h) *_pn.Noise((float)( x)/smoothingFactor,(float)( y)/smoothingFactor)+ faderLeftRight(x,y,_w,_h)*_pn.Noise((float)((x+_w/2)%_w+_w)/smoothingFactor,(float)( y+_h)/smoothingFactor)+ @@ -123,32 +136,42 @@ inline void HeightMap::addNoise(float weight, float smoothingFactor) +4.0)/8.0*weight; } } + co_return true; } void HeightMap::makeIslands(unsigned int count, float smoothingFactor) { + makeIslandsTask(count, smoothingFactor).run(); +} + +GAGCore::CooperativeTask HeightMap::makeIslandsTask(unsigned int count, float smoothingFactor) +{ + unsigned operations = 0; + co_await GAGCore::CooperativeTask::checkpoint("[Generating map]"); assert (count); - PerlinNoise pn; - pn.reseed(); - int * centerX = new int[count]; - int * centerY = new int[count]; + _pn.reseed(syncRand()); + std::vector centerX(count); + std::vector centerY(count); float mindist=sqrt(_w*_h/count)/2.0; assert(mindist>0); - makeStamp((unsigned int)(mindist*2)); - centerX[0]=rand()%_w;centerY[0]=rand()%_h; + co_await makeStampTask((unsigned int)(mindist*2)); + centerX[0]=syncRand()%_w;centerY[0]=syncRand()%_h; /// find spots with distance>min. distance for (unsigned int i=1; i0?_w:0); targetPointY=startingPointY+_h-(tmprand%2)*_h; } else if (_w>_h) { targetPointX=startingPointX+_w; - targetPointY=startingPointY+(rand()%(_w/_h))*_h; + targetPointY=startingPointY+(syncRand()%(_w/_h))*_h; } else { - targetPointX=startingPointX+(rand()%(_h/_w))*_w; + targetPointX=startingPointX+(syncRand()%(_h/_w))*_w; targetPointY=startingPointY+_h; } float targetDirection=asin((targetPointY-startingPointY)/sqrt(pow(targetPointX-startingPointX,2)+pow(targetPointY-startingPointY,2))); @@ -211,6 +241,7 @@ void HeightMap::makeRiver(unsigned int maxDiameter, float smoothingFactor) float straightRiverLength=sqrt(pow(targetPointX-startingPointX,2)+pow(targetPointY-startingPointY,2)); for(float t=0; tmax?_map[i]:max; } min-=.01; max+=.01; float range=max-min; - for(unsigned int i=0; i<_w*_h; i++) - _map[i]=(_map[i]-min)/range; + for(unsigned int i=0; i<_w*_h; i++) { + if (++operations % 1024 == 0) co_await GAGCore::CooperativeTask::checkpoint(); + _map[i]=(_map[i]-min)/range; + } + co_return true; +} + +GAGCore::CooperativeTask HeightMap::fillTask(float value) +{ + lowerValid = false; + for (unsigned i = 0; i < _w * _h; ++i) { + _map[i] = value; + if (i % 1024 == 0) co_await GAGCore::CooperativeTask::checkpoint("[Generating map]"); + } + co_return true; } diff --git a/src/map/generator/HeightMapGenerator.h b/src/map/generator/HeightMapGenerator.h index 61524c3df..ff0809318 100644 --- a/src/map/generator/HeightMapGenerator.h +++ b/src/map/generator/HeightMapGenerator.h @@ -4,9 +4,12 @@ #pragma once #include "PerlinNoise.h" +#include class HeightMap /// class to generate heightmaps to decide where to put resources, water, sand and grass later { + bool lowerValid = false; + unsigned lowerX = 0, lowerY = 0; float * _map; /// height values are always [0,1]. unsigned int _w, _h; /// map size float * _stamp; /// smooth 0 to 1 gradient lookup to generate craters, islands and rivers @@ -17,6 +20,8 @@ class HeightMap /// class to generate heightmaps to decide where to put resource HeightMap(unsigned int width, unsigned int height); ~HeightMap(); + HeightMap(const HeightMap&) = delete; + HeightMap& operator=(const HeightMap&) = delete; inline unsigned int uiLevel(unsigned int i, unsigned int scale) { return (unsigned int)(_map[i]*scale); } @@ -31,22 +36,26 @@ class HeightMap /// class to generate heightmaps to decide where to put resource } void mapOutput(char * filename); /// generates the file ~/.glob2/filename and writes the raw 0..255 values of map to it. to see it, use convert -size [width]x[height] -depth 8 gray:[filename] test.png + // Tasks borrow this height map. Keep it alive and run at most one task per + // instance. Cancellation leaves partial heights; start a new generation + // before reading them. The editor publishes only a completed map. void makePlain(float smoothingFactor); /// a plain perlin height field + GAGCore::CooperativeTask makePlainTask(float smoothingFactor); void makeSwamp(float smoothingFactor); /// a plain perlin height field + GAGCore::CooperativeTask makeSwampTask(float smoothingFactor); void makeIslands(unsigned int count, float smoothingFactor); /// generates a 'swamp' with count hills + GAGCore::CooperativeTask makeIslandsTask(unsigned int count, float smoothingFactor); void makeRiver(unsigned int maxDiameter, float smoothingFactor); /// generates a 'swamp' with a river based on a random walk. + GAGCore::CooperativeTask makeRiverTask(unsigned int maxDiameter, float smoothingFactor); void makeCraters(unsigned int craterCount, unsigned int craterRadius, float smoothingFactor); /// generates a 'swamp' with craterCount craters + GAGCore::CooperativeTask makeCratersTask(unsigned int craterCount, unsigned int craterRadius, float smoothingFactor); private: - void operator+(HeightMap hm){for(unsigned int i=0; i<_w*_h; i++)_map[i]+=hm(i);}; - void operator*(const float m){for(unsigned int i=0; i<_w*_h; _map[i++]*=m);}; - void operator/(const float d){for(unsigned int i=0; i<_w*_h; _map[i++]/=d);}; - void operator=(const float h) {for(unsigned int i=0; i<_w*_h; _map[i++]=h);} - void operator=(HeightMap hm) {for(unsigned int i=0; i<_w*_h; i++)_map[i]=hm(i);} + GAGCore::CooperativeTask fillTask(float value); void init(unsigned int width, unsigned int height); - void makeStamp(unsigned int radius); /// generates the stamp (smooth 0 to 1 gradient lookup to generate craters, islands and rivers) - inline void lower(unsigned int coordX, unsigned int coordY); /// lower lowers the region around (coordX,coordY) to min(stamp,map) - inline void differenceStamp(unsigned int coordX, unsigned int coordY); /// multiplyStamp sets the region around (coordX,coordY) to (1-stamp)*map if map>0 and to 1-stamp else to bias away from other hills. - inline void addNoise(float weight, float smoothingFactor); /// adds noise to the map: map=noise*weight+map*(1-weight) + GAGCore::CooperativeTask makeStampTask(unsigned int radius); + GAGCore::CooperativeTask lowerTask(unsigned int coordX, unsigned int coordY); + GAGCore::CooperativeTask differenceStampTask(unsigned int coordX, unsigned int coordY); + GAGCore::CooperativeTask addNoiseTask(float weight, float smoothingFactor); void stampOutput(char * filename); /// generates the file ~/.glob2/filename and writes the raw 0..255 values of stamp to it. to see it, use convert -size [width]x[height] -depth 8 gray:[filename] test.png where with==height as _stamp is always a square - void normalize(); /// fits the values of _map to [0, 1] + GAGCore::CooperativeTask normalizeTask(); }; diff --git a/src/map/generator/MapGenerationDescriptor.cpp b/src/map/generator/MapGenerationDescriptor.cpp index 069e0f415..5d54e494d 100644 --- a/src/map/generator/MapGenerationDescriptor.cpp +++ b/src/map/generator/MapGenerationDescriptor.cpp @@ -5,6 +5,7 @@ #include #include "MapGenerationDescriptor.h" +#include "Map.h" #include "Marshaling.h" #include "Utilities.h" @@ -128,9 +129,7 @@ bool MapGenerationDescriptor::setData(const Uint8 *data, int dataLength) bool good=true; if (getDataLength()!=dataLength) good=false; - if (wDec>=32) - good=false; - if (hDec>=32) + if (!Map::supportedDimensions(wDec, hDec)) good=false; if (terrainType>GRASS) good=false; @@ -216,4 +215,3 @@ Uint32 MapGenerationDescriptor::checkSum() return cs; } - diff --git a/src/map/generator/MapGenerator.h b/src/map/generator/MapGenerator.h index f45bdcc98..c4feafc8f 100644 --- a/src/map/generator/MapGenerator.h +++ b/src/map/generator/MapGenerator.h @@ -3,6 +3,7 @@ // Copyright (C) 2001-2004 Stephane Magnenat & Luc-Olivier de Charrière #pragma once +#include #include "MapGenerationDescriptor.h" @@ -15,73 +16,97 @@ class MapGenerator public: ///Generates a map from the given generation description bool generateMap(Game& game, MapGenerationDescriptor& descriptor); + bool generateMap(Game& game, MapGenerationDescriptor& descriptor, Uint32 seed); + GAGCore::CooperativeTask generateMapTask(Game& game, MapGenerationDescriptor& descriptor, Uint32 seed); ///This performs the concrete islands generator bool computeConcreteIslands(Game& game, MapGenerationDescriptor& descriptor); + GAGCore::CooperativeTask computeConcreteIslandsTask(Game& game, MapGenerationDescriptor& descriptor); ///This performs the isles generator bool computeIsles(Game& game, MapGenerationDescriptor& descriptor); + GAGCore::CooperativeTask computeIslesTask(Game& game, MapGenerationDescriptor& descriptor); ///This function divides up the player lands using the standard method bool divideUpPlayerLands(Game& game, MapGenerationDescriptor& descriptor, std::vector& grid, std::vector& teamAreaNumbers, int& areaNumber); + GAGCore::CooperativeTask divideUpPlayerLandsTask(Game& game, MapGenerationDescriptor& descriptor, std::vector& grid, std::vector& teamAreaNumbers, int& areaNumber); private: ///This is a function that takes an area and divides it up into several smaller areas using the ///given area numbers and weights. This is basically a combination of splitUpPoints and splitUpArea bool divideUpArea(Game& game, std::vector& grid, int areaN, std::vector& weights, std::vector& areaNumbers); + GAGCore::CooperativeTask divideUpAreaTask(Game& game, std::vector& grid, int areaN, std::vector& weights, std::vector& areaNumbers); ///This creates an area with the shape of an oval with the given width and height void createOval(Game& game, std::vector& grid, int areaN, int x, int y, int width, int height); + GAGCore::CooperativeTask createOvalTask(Game& game, std::vector& grid, int areaN, int x, int y, int width, int height); ///This function takes the grid, an area number, and places points in it such that the points are spaced ///as far from each other as possible, bearing in mind weights. Returns 0 if failure, otherwise returns ///the minimum distance between points that was accomplished int splitUpPoints(Game& game, std::vector& grid, int areaN, std::vector& points, std::vector& weights); + // Optional minimumDistance is borrowed until completion; read it only after + // success. The caller retains all referenced inputs through cancellation. + GAGCore::CooperativeTask splitUpPointsTask(Game& game, std::vector& grid, int areaN, std::vector& points, std::vector& weights, int* minimumDistance=nullptr); ///This function takes a grid and an area number, and divides that area into more areas void splitUpArea(Game& game, std::vector& grid, int areaN, std::vector& points, std::vector& weights, std::vector& areaNumbers, bool grassOnly=false); + GAGCore::CooperativeTask splitUpAreaTask(Game& game, std::vector& grid, int areaN, std::vector& points, std::vector& weights, std::vector& areaNumbers, bool grassOnly=false); ///This function fills the vector with all of the points in a specific area void getAllPoints(Game& game, std::vector& grid, int areaN, std::vector& points); + GAGCore::CooperativeTask getAllPointsTask(Game& game, std::vector& grid, int areaN, std::vector& points); ///This function fills the vector with all of points except those in a specific area void getAllOtherPoints(Game& game, std::vector& grid, int areaN, std::vector& points); + GAGCore::CooperativeTask getAllOtherPointsTask(Game& game, std::vector& grid, int areaN, std::vector& points); ///This function gets all of the points in a straight line from x1,y1 to x2,y2 void getAllPointsLine(Game& game, int x1, int y1, int x2, int y2, std::vector& points); + GAGCore::CooperativeTask getAllPointsLineTask(Game& game, int x1, int y1, int x2, int y2, std::vector& points); ///This function computes all of points that are borders void findBorderPoints(Game& game, std::vector& grid, std::vector& points); + GAGCore::CooperativeTask findBorderPointsTask(Game& game, std::vector& grid, std::vector& points); ///This function fills all given points area with a certain resource. It will fill in a randomly sized ///square over each grid space no larger than maxFillSize void fillInResource(Game& game, std::vector& points, int resourceType, int maxFillSize); + GAGCore::CooperativeTask fillInResourceTask(Game& game, std::vector& points, int resourceType, int maxFillSize); ///This chooses n-random squares from a the points vector, and eliminates the rest void chooseRandomPoints(Game& game, std::vector& points, int n); + GAGCore::CooperativeTask chooseRandomPointsTask(Game& game, std::vector& points, int n); ///This function chooses all points that would be free for the building to build on, eliminates the rest void chooseFreeForBuildingSquares(Game& game, std::vector& points, BuildingType* type, int team); + GAGCore::CooperativeTask chooseFreeForBuildingSquaresTask(Game& game, std::vector& points, BuildingType* type, int team); ///This function chooses all the points that would be free for ground units void chooseFreeForGroundUnits(Game& game, std::vector& points, int team); + GAGCore::CooperativeTask chooseFreeForGroundUnitsTask(Game& game, std::vector& points, int team); ///This function chooses all the points that are bordering on the given building void chooseTouchingBuilding(Game& game, std::vector& points, Building* building); + GAGCore::CooperativeTask chooseTouchingBuildingTask(Game& game, std::vector& points, Building* building); ///This function adjusts the heightmap value of point given by the given value void adjustHeightmapFromPoints(Game& game, std::vector& points, std::vector& heightmap, int value); + GAGCore::CooperativeTask adjustHeightmapFromPointsTask(Game& game, std::vector& points, std::vector& heightmap, int value); ///This function adjusts the heightmap values from a standard perlin noise. Spread is how much the value can go up or down void adjustHeightmapFromPerlinNoise(Game& game, std::vector& heights, int spread); + GAGCore::CooperativeTask adjustHeightmapFromPerlinNoiseTask(Game& game, std::vector& heights, int spread); ///This function computes the distance of every point from the given points, putting these distances ///into the given heightmap. The points given as sources are considered to be a distance of 1. The points ///given as obstacles are considered to be a distance of -1 void computeDistances(Game& game, std::vector& sources, std::vector& obstacles, std::vector& heightmap); + GAGCore::CooperativeTask computeDistancesTask(Game& game, std::vector& sources, std::vector& obstacles, std::vector& heightmap); ///Computes the average height/distance of a area on a heightmap int computeAverageDistance(Game& game, std::vector& grid, int areaN, const std::vector& heightmap); + GAGCore::CooperativeTask computeAverageDistanceTask(Game& game, std::vector& grid, int areaN, const std::vector& heightmap, int& result); ///Adds a building to the map with the given typenum, level, under construction, team and location. ///Returns the pointer if it could, NULL otherwise diff --git a/src/map/generator/MapHomogen.cpp b/src/map/generator/MapHomogen.cpp index 384f8212e..99e60e3a0 100644 --- a/src/map/generator/MapHomogen.cpp +++ b/src/map/generator/MapHomogen.cpp @@ -14,10 +14,19 @@ ///generates a map that is of one terrain type only void Map::makeHomogenMap(TerrainType terrainType) { - for (int y=0; y0); int* bootX=descriptor.bootX; int* bootY=descriptor.bootY; @@ -375,6 +384,7 @@ bool Map::oldMakeRandomMap(MapGenerationDescriptor &descriptor) //TODO: First pass to find the number of available places. for (int team=0; team #include -//also the Perlin Noise stuff uses random that is not based on syncRand +// Generation randomness is drawn from the explicitly seeded synchronized stream. #include "Game.h" #include "HeightMapGenerator.h" #include "MapGenerationDescriptor.h" #include "Map.h" +#include "Utilities.h" /// This random map generator generates a height field and then chooses levels separating water, sand, grass, and desert. bool Map::makeRandomMap(MapGenerationDescriptor &descriptor) { + return makeRandomMapTask(descriptor).run(); +} + +GAGCore::CooperativeTask Map::makeRandomMapTask(MapGenerationDescriptor &descriptor) +{ + unsigned work = 0; + co_await GAGCore::CooperativeTask::checkpoint("[Generating map]"); /// all under waterLevel is water, under sandLevel is beach, under grassLevel is grass and above grasslevel is desert float waterLevel, sandLevel, grassLevel, wheatWoodLevel, algaeLevel, stoneLevel; /// to influence the roughness @@ -42,25 +50,25 @@ bool Map::makeRandomMap(MapGenerationDescriptor &descriptor) switch (descriptor.method) { case MapGenerationDescriptor::eSWAMP: - hm.makeSwamp(smoothingFactor); + co_await hm.makeSwampTask(smoothingFactor); waterTiles=(unsigned int)((float)descriptor.waterRatio*wHeightMap*hHeightMap/(float)tmpTotal); sandTiles=0; grassTiles=wHeightMap*hHeightMap-waterTiles; break; case MapGenerationDescriptor::eRIVER: - hm.makeRiver(descriptor.riverDiameter*(wHeightMap+hHeightMap)/2/100,smoothingFactor); + co_await hm.makeRiverTask(descriptor.riverDiameter*(wHeightMap+hHeightMap)/2/100,smoothingFactor); waterTiles=(unsigned int)((float)descriptor.waterRatio/(float)totalGSWFromUI*wHeightMap*hHeightMap); sandTiles=(unsigned int)((float)descriptor.sandRatio/(float)totalGSWFromUI*wHeightMap*hHeightMap); grassTiles =(unsigned int)((float)descriptor.grassRatio /(float)totalGSWFromUI*wHeightMap*hHeightMap); break; case MapGenerationDescriptor::eCRATERLAKES: - hm.makeCraters(wHeightMap*hHeightMap*descriptor.craterDensity/30000, 30, smoothingFactor); + co_await hm.makeCratersTask(wHeightMap*hHeightMap*descriptor.craterDensity/30000, 30, smoothingFactor); waterTiles=(unsigned int)((float)descriptor.waterRatio/(float)totalGSWFromUI*wHeightMap*hHeightMap); sandTiles=(unsigned int)((float)descriptor.sandRatio/(float)totalGSWFromUI*wHeightMap*hHeightMap); grassTiles =(unsigned int)((float)descriptor.grassRatio /(float)totalGSWFromUI*wHeightMap*hHeightMap); break; case MapGenerationDescriptor::eISLANDS: - hm.makeIslands(sectionIslandCount, smoothingFactor); + co_await hm.makeIslandsTask(sectionIslandCount, smoothingFactor); waterTiles=(unsigned int)((float)descriptor.waterRatio/(float)totalGSWFromUI*wHeightMap*hHeightMap); sandTiles=(unsigned int)((float)descriptor.sandRatio/(float)totalGSWFromUI*wHeightMap*hHeightMap); grassTiles =(unsigned int)((float)descriptor.grassRatio /(float)totalGSWFromUI*wHeightMap*hHeightMap); @@ -78,6 +86,7 @@ bool Map::makeRandomMap(MapGenerationDescriptor &descriptor) for (unsigned i=0; i= algaeTiles) algaeLevel = (float)(i-1)/2048.0; @@ -98,12 +108,14 @@ bool Map::makeRandomMap(MapGenerationDescriptor &descriptor) } while ((sandLevel==0) && (i<2048)) { + if (++work % 64 == 0) co_await GAGCore::CooperativeTask::checkpoint(); accumulatedHistogram+=histogram[i++]; if (accumulatedHistogram >= waterTiles+sandTiles) sandLevel = (float)(i-1)/2048.0; } while ((grassLevel==0) && (i<2048)) { + if (++work % 64 == 0) co_await GAGCore::CooperativeTask::checkpoint(); accumulatedHistogram+=histogram[i++]; if (wheatWoodLevel==0 && accumulatedHistogram >= waterTiles+sandTiles+wheatWoodTiles) wheatWoodLevel = (float)(i-1)/2048.0; @@ -135,7 +147,7 @@ bool Map::makeRandomMap(MapGenerationDescriptor &descriptor) int minDistSquare=(int)((double)w*h/(double)nbTeams/5); if (minDistSquare<=0) { - return false; + co_return false; } assert(minDistSquare>0); int* bootX=descriptor.bootX; @@ -144,6 +156,7 @@ bool Map::makeRandomMap(MapGenerationDescriptor &descriptor) //TODO: First pass to find the number of available places. for (int team=0; team 0) { @@ -261,7 +275,7 @@ bool Map::makeRandomMap(MapGenerationDescriptor &descriptor) { //choose fruit int fruit; - switch (rand()%3) + switch (syncRand()%3) { case 0: fruit = CHERRY; break; case 1: fruit = ORANGE; break; @@ -269,14 +283,26 @@ bool Map::makeRandomMap(MapGenerationDescriptor &descriptor) default: fruit = PRUNE; break; } //choose coordinate where there is grass but no resource yet - int x, y; - do - { - x=(rand()%wHeightMap); - y=(rand()%hHeightMap); + int x, y; + std::size_t attempts = 0; + do + { + if (++work % 64 == 0) co_await GAGCore::CooperativeTask::checkpoint(); + if (++attempts > std::size_t(wHeightMap) * hHeightMap * 8) { + bool found = false; + for (unsigned candidateY = 0; candidateY < hHeightMap && !found; ++candidateY) + for (unsigned candidateX = 0; candidateX < wHeightMap && !found; ++candidateX) + if (getUMTerrain(candidateX, candidateY) == GRASS && !isResource(candidateX, candidateY)) { + x = candidateX; y = candidateY; found = true; + } + if (!found) co_return false; + break; + } + x=(syncRand()%wHeightMap); + y=(syncRand()%hHeightMap); } while (getUMTerrain(x, y)!=GRASS || isResource(x,y)); //choose size of grove (tree count) - int grovesize=(rand()%10)+1; + int grovesize=(syncRand()%10)+1; for (int i=0; i= 3; })) - return; + co_return true; int passes = 0; bool changed; @@ -52,6 +57,7 @@ void Map::updateGlobalGradient(Uint8 *gradient) // Keep the in-place sweep order, including reads across the toroidal seams. for (size_t y = 0; y < (size_t)h; y++) { + if ((y & 15) == 0) co_await GAGCore::CooperativeTask::checkpoint("[Building gradients]"); Uint8* row = gradient + (y << wDec); const Uint8* previousRow = gradient + (((y - 1) & hMask) << wDec); for (size_t x = 0; x < (size_t)w; x++) @@ -75,6 +81,7 @@ void Map::updateGlobalGradient(Uint8 *gradient) for (size_t y = (size_t)h; y-- > 0; ) { + if ((y & 15) == 0) co_await GAGCore::CooperativeTask::checkpoint("[Building gradients]"); Uint8* row = gradient + (y << wDec); const Uint8* nextRow = gradient + (((y + 1) & hMask) << wDec); for (size_t x = (size_t)w; x-- > 0; ) @@ -103,6 +110,7 @@ void Map::updateGlobalGradient(Uint8 *gradient) abort(); } } while (changed); + co_return true; } diff --git a/src/map/io/MapHeader.cpp b/src/map/io/MapHeader.cpp index fe2dfd50b..4318c417a 100644 --- a/src/map/io/MapHeader.cpp +++ b/src/map/io/MapHeader.cpp @@ -3,9 +3,11 @@ #include "Version.h" #include "MapHeader.h" -#include "Game.h" #include +#include +#include #include "FileManager.h" +#include MapHeader::MapHeader() { @@ -28,10 +30,21 @@ void MapHeader::reset() bool MapHeader::load(GAGCore::InputStream *stream) +{ + // The same header is read from local files, imports and network messages. + // Do not expose partially read fields if validation fails. + GAGCore::BinaryInputStream::CheckedReads checked(stream); + MapHeader candidate; + if (!candidate.loadFields(stream)) return false; + *this = candidate; + return true; +} + +bool MapHeader::loadFields(GAGCore::InputStream *stream) { ///First, check if its an old format map Uint32 pos = stream->getPosition(); - char* signature[4]; + char signature[4]; stream->read(signature, 4, "signature"); if(memcmp(signature, "SEGb",4) == 0) { @@ -43,12 +56,16 @@ bool MapHeader::load(GAGCore::InputStream *stream) mapName = stream->readText("mapName"); versionMajor = stream->readSint32("versionMajor"); versionMinor = stream->readSint32("versionMinor"); + if (versionMajor != VERSION_MAJOR || versionMinor < MINIMUM_VERSION_MINOR || versionMinor > VERSION_MINOR) + return false; numberOfTeams = stream->readSint32("numberOfTeams"); mapOffset = stream->readUint32("mapOffset"); - isSavedGame = stream->readUint8("isSavedGame"); + const Uint8 saved = stream->readUint8("isSavedGame"); + if (saved > 1) return false; + isSavedGame = saved != 0; - if(numberOfTeams > Team::MAX_COUNT) + if(numberOfTeams < 0 || numberOfTeams > Team::MAX_COUNT) { return false; } diff --git a/src/map/io/MapHeader.h b/src/map/io/MapHeader.h index 02f333bca..c9333fcb0 100644 --- a/src/map/io/MapHeader.h +++ b/src/map/io/MapHeader.h @@ -90,6 +90,7 @@ class MapHeader bool operator!=(const MapHeader& rhs) const; bool operator==(const MapHeader& rhs) const; private: + bool loadFields(GAGCore::InputStream *stream); /// Major map version. Changes only with structural modification Sint32 versionMajor; /// Minor map version. Changes each time something has been changed in serializations diff --git a/src/map/io/MapIO.cpp b/src/map/io/MapIO.cpp index d820b98e7..6c98eefed 100644 --- a/src/map/io/MapIO.cpp +++ b/src/map/io/MapIO.cpp @@ -20,6 +20,11 @@ bool Map::load(GAGCore::InputStream *stream, MapHeader& header, Game *game) +{ + return loadTask(stream, header, game).run(); +} + +GAGCore::CooperativeTask Map::loadTask(GAGCore::InputStream *stream, MapHeader& header, Game *game) try { GAGCore::BinaryInputStream::CheckedReads checked(stream); @@ -28,6 +33,7 @@ try Sint32 versionMinor = header.getVersionMinor(); clear(); + co_await GAGCore::CooperativeTask::checkpoint("[Loading terrain]"); stream->readEnterSection("Map"); @@ -36,15 +42,14 @@ try if (memcmp(signature, "MapB", 4)!=0) { fprintf(stderr, "Map:: Failed to find signature at the beginning of Map.\n"); - return false; + co_return false; } // We load and compute size: wDec = stream->readSint32("wDec"); hDec = stream->readSint32("hDec"); - if (wDec < 0 || hDec < 0 || wDec >= std::numeric_limits::digits || - hDec >= std::numeric_limits::digits || wDec + hDec >= std::numeric_limits::digits) - return false; + if (!supportedDimensions(wDec, hDec)) + co_return false; w = 1<readEnterSection("cases"); for (size_t i=0; ireadEnterSection(i); mapDiscovered[i] = stream->readUint32("mapDiscovered"); tiles[i].terrain = stream->readUint16("terrain"); tiles[i].building = stream->readUint16("building"); if (tiles[i].building != NOGBID && tiles[i].building >= Building::MAX_COUNT * header.getNumberOfTeams()) - return false; + co_return false; stream->read(&(tiles[i].resource), 4, "ressource"); tiles[i].groundUnit = stream->readUint16("groundUnit"); @@ -114,7 +120,7 @@ try wSector = stream->readSint32("wSector"); hSector = stream->readSint32("hSector"); if (wSector < 0 || hSector < 0 || wSector > w || hSector > h) - return false; + co_return false; sizeSector = wSector*hSector; assert(sectors == NULL); sectors = new Sector[sizeSector]; @@ -132,11 +138,12 @@ try stream->readEnterSection("sectors"); for (int i=0; ireadEnterSection(i); if (!sectors[i].load(stream, this->game, versionMinor)) { stream->readLeaveSection(3); - return false; + co_return false; } stream->readLeaveSection(); } @@ -148,7 +155,7 @@ try if (memcmp(signature, "MapE", 4)!=0) { fprintf(stderr, "Map:: Failed to find signature at the end of Map.\n"); - return false; + co_return false; } if (game) @@ -175,13 +182,13 @@ try } } - return true; + co_return true; } catch (const std::ios_base::failure& error) { std::cerr << "Map::load: " << error.what() << std::endl; clear(); - return false; + co_return false; } @@ -249,6 +256,11 @@ void Map::save(GAGCore::OutputStream *stream) void Map::addTeam(void) +{ + addTeamTask().run(); +} + +GAGCore::CooperativeTask Map::addTeamTask(void) { int numberOfTeam=game->mapHeader.getNumberOfTeams(); int oldNumberOfTeam=numberOfTeam-1; @@ -266,6 +278,7 @@ void Map::addTeam(void) assert(clearingAreaClaims[t] == NULL); clearingAreaClaims[t] = new Uint16[size]; memset(clearingAreaClaims[t], NOGUID, size*sizeof(Uint16)); + co_return true; } void Map::removeTeam(void) diff --git a/src/map/io/MapThumbnail.cpp b/src/map/io/MapThumbnail.cpp index 5760f21b8..22f7c4a0b 100644 --- a/src/map/io/MapThumbnail.cpp +++ b/src/map/io/MapThumbnail.cpp @@ -12,6 +12,7 @@ #include "Toolkit.h" #include "Utilities.h" #include "zlib.h" +#include using namespace GAGCore; @@ -30,23 +31,16 @@ void MapThumbnail::loadFromMap(const std::string& map) return; } - loaded = true; + loaded = false; - InputStream *stream = new BinaryInputStream(Toolkit::getFileManager()->openInputStreamBackend(map)); - if (stream->isEndOfStream()) - { - delete stream; - } - else + auto stream = std::make_unique(Toolkit::getFileManager()->openInputStreamBackend(map)); + if (!stream->isEndOfStream()) { // read header MapHeader header; - bool good = header.load(stream); + bool good = header.load(stream.get()); if (!good) - { - delete stream; return; - } // read map if (stream->canSeek()) @@ -56,8 +50,7 @@ void MapThumbnail::loadFromMap(const std::string& map) Map map; - good = map.load(stream, header); - delete stream; + good = map.load(stream.get(), header); if (!good) return; @@ -138,6 +131,7 @@ void MapThumbnail::loadFromMap(const std::string& map) buffer[(dx+decX) * 128 * 3 + (dy+decY) * 3 + 2] = b; } } + loaded = true; } } @@ -217,4 +211,3 @@ bool MapThumbnail::isLoaded() { return loaded; } - diff --git a/src/net/NetConnection.cpp b/src/net/NetConnection.cpp index f62b42e24..ab4fd29e7 100644 --- a/src/net/NetConnection.cpp +++ b/src/net/NetConnection.cpp @@ -2,167 +2,108 @@ // Copyright (C) 2007 Bradley Arsenault #include "NetConnection.h" -#include -#include #include "NetMessage.h" - -using namespace GAGCore; -using std::static_pointer_cast; -using std::shared_ptr; - - - -NetConnection::NetConnection(const std::string& naddress, Uint16 port) - : connect(incoming, incomingMutex) -{ - connectThread = std::thread(std::ref(connect)); - connecting=false; - openConnection(naddress, port); -} - - - -NetConnection::NetConnection() - : connect(incoming, incomingMutex) -{ - connectThread = std::thread(std::ref(connect)); +#include +#include +#include + +namespace { +// Legacy MemoryStreamBackend supplies zeros on overread. Network payloads must +// fail closed instead of allowing truncated messages to acquire default fields. +class PacketInput final : public GAGCore::MemoryStreamBackend { + size_t length; +public: + PacketInput(const void* bytes, size_t size) : MemoryStreamBackend(bytes, size), length(size) { seekFromStart(0); } + void read(void* bytes, size_t size) override { + if (size > length - getPosition()) throw std::runtime_error("Truncated network message"); + MemoryStreamBackend::read(bytes, size); + } +}; } - - - -NetConnection::~NetConnection() -{ - std::shared_ptr exitthread(new NTExitThread); - connect.sendMessage(exitthread); - if (connectThread.joinable()) - connectThread.join(); +NetConnection::NetConnection() : NetConnection(makeNetTransport()) {} +NetConnection::NetConnection(std::unique_ptr selected) : transport(std::move(selected)) { + if (!transport) throw std::invalid_argument("Network transport is required"); } - - - -void NetConnection::openConnection(const std::string& connectaddress, Uint16 port) -{ - address = connectaddress; - connecting=true; - std::shared_ptr toconnect(new NTConnect(connectaddress, port)); - connect.sendMessage(toconnect); -} - - - -void NetConnection::closeConnection() -{ - std::shared_ptr close(new NTCloseConnection); - connect.sendMessage(close); -} - - - -bool NetConnection::isConnected() -{ - return connect.isConnected(); +NetConnection::NetConnection(const std::string& host, Uint16 port) : NetConnection() { openConnection(host, port); } +NetConnection::~NetConnection() = default; +void NetConnection::openConnection(const std::string& host, Uint16 port) { + closeConnection(); + address = host; + transport->open(host, port); } - - - -bool NetConnection::isConnecting() -{ - return connecting; +void NetConnection::closeConnection() { + transport->close(); + pending.clear(); + received = {}; + outgoing = {}; outgoingBytes = 0; } - - - -void NetConnection::update() -{ - std::lock_guard lock(incomingMutex); - while(!incoming.empty()) - { - std::shared_ptr message = incoming.front(); - incoming.pop(); - Uint8 type = message->getMessageType(); - switch(type) - { - case NTMCouldNotConnect: - { - std::shared_ptr info = static_pointer_cast(message); - connecting=false; - } - break; - case NTMConnected: - { - std::shared_ptr info = static_pointer_cast(message); - address = info->getIPAddress(); - connecting=false; - } - break; - case NTMLostConnection: - { - std::shared_ptr info = static_pointer_cast(message); - } - break; - case NTMReceivedMessage: - { - std::shared_ptr info = static_pointer_cast(message); - received.push(info->getMessage()); - } - break; - } - } +bool NetConnection::isConnected() { return transport->state() == NetTransport::State::Connected; } +bool NetConnection::isConnecting() { return transport->state() == NetTransport::State::Connecting; } +void NetConnection::flushOutgoing() { + while (isConnected() && !outgoing.empty()) { + auto bytes = std::move(outgoing.front()); outgoing.pop(); + outgoingBytes -= bytes.size(); + if (!transport->send(std::move(bytes))) { closeConnection(); break; } + } } - - - -shared_ptr NetConnection::getMessage() -{ - update(); - - //Check if there are messages in the queue. - //If so, return one, else, return NULL - if(received.size()) - { - shared_ptr message = received.front(); - received.pop(); - return message; - } - else - { - return shared_ptr(); - } +void NetConnection::update() { + flushOutgoing(); + std::vector bytes; + try { + while (transport->receive(bytes)) { + if (bytes.size() > NetTransport::queueLimit - pending.size()) throw std::runtime_error("Network input overflow"); + pending.insert(pending.end(), bytes.begin(), bytes.end()); + size_t offset = 0; + while (pending.size() - offset >= 2) { + const size_t length = (size_t(pending[offset]) << 8) | pending[offset + 1]; + if (!length) throw std::runtime_error("Empty network frame"); + if (pending.size() - offset - 2 < length) break; + auto* backend = new PacketInput(pending.data() + offset + 2, length); + GAGCore::BinaryInputStream stream(backend); + auto message = NetMessage::getNetMessage(&stream); + if (!message || backend->getPosition() != length || received.size() >= 256) + throw std::runtime_error("Invalid network message or queue overflow"); + received.push(std::move(message)); + offset += length + 2; + } + pending.erase(pending.begin(), pending.begin() + offset); + } + } catch (const std::exception&) { + closeConnection(); + } } - - - -void NetConnection::sendMessage(shared_ptr message) -{ - std::shared_ptr close(new NTSendMessage(message)); - connect.sendMessage(close); +std::shared_ptr NetConnection::getMessage() { + update(); + if (received.empty()) return {}; + auto message = std::move(received.front()); received.pop(); + return message; } - - - -const std::string& NetConnection::getIPAddress() const -{ - return address; +void NetConnection::sendMessage(std::shared_ptr message) { + if (!message || (!isConnected() && !isConnecting())) return; + auto* backend = new GAGCore::MemoryStreamBackend; + GAGCore::BinaryOutputStream stream(backend); + stream.writeUint8(message->getMessageType(), "messageType"); + message->encodeData(&stream); + const size_t length = backend->getPosition(); + if (!length || length > 65535) { closeConnection(); return; } + std::vector bytes(length + 2); + bytes[0] = length >> 8; bytes[1] = length & 255; + backend->seekFromStart(0); + backend->read(bytes.data() + 2, length); + if (bytes.size() > NetTransport::queueLimit - outgoingBytes) { closeConnection(); return; } + outgoingBytes += bytes.size(); + outgoing.push(std::move(bytes)); + flushOutgoing(); } - - - -bool NetConnection::attemptConnection(TCPsocket& serverSocket) -{ - TCPsocket socket=NULL; - socket=SDLNet_TCP_Accept(serverSocket); - if(socket) - { - IPaddress ip = *SDLNet_TCP_GetPeerAddress(socket); - address = std::to_string((ip.host >> 0 ) & 0xff) + "." + - std::to_string((ip.host >> 8 ) & 0xff) + "." + - std::to_string((ip.host >> 16) & 0xff) + "." + - std::to_string((ip.host >> 24) & 0xff); - std::shared_ptr accept(new NTAcceptConnection(socket)); - connect.sendMessage(accept); - while(connect.isConnected() == false) - SDL_Delay(5); - return true; - } - return false; +const std::string& NetConnection::getIPAddress() const { return address; } +bool NetConnection::attemptConnection(TCPsocket& listener) { + TCPsocket socket = SDLNet_TCP_Accept(listener); + if (!socket) return false; + const auto* peer = SDLNet_TCP_GetPeerAddress(socket); + const auto* ip = reinterpret_cast(&peer->host); + address = std::to_string(ip[0]) + "." + std::to_string(ip[1]) + "." + std::to_string(ip[2]) + "." + std::to_string(ip[3]); + closeConnection(); + if (transport->accept(socket)) return true; + SDLNet_TCP_Close(socket); + return false; } diff --git a/src/net/NetConnection.h b/src/net/NetConnection.h index 97f7bc664..e1f1df45c 100644 --- a/src/net/NetConnection.h +++ b/src/net/NetConnection.h @@ -2,74 +2,51 @@ // Copyright (C) 2007 Bradley Arsenault #pragma once - -#include "SDL_net.h" -#include "NetConnectionThread.h" +#include "NetTransport.h" #include -#include -#include using std::shared_ptr; class NetListener; class NetMessage; -///NetConnection represents a low level wrapper around SDL. -///It queues Message(s) it receives from the connection. -class NetConnection -{ +/// Owns platform transport while keeping message framing and decoding shared. +class NetConnection { public: - ///Attempts to form a connection with the given address and the given port - NetConnection(const std::string& address, Uint16 port); - - ///Initiates the NetConnection as blank - NetConnection(); - - ///Closes the NetConnection down. - ~NetConnection(); - - ///Opens a new connection. - void openConnection(const std::string& address, Uint16 port); - - ///Closes the current connection. - void closeConnection(); - - ///Returns true if this object is connected - bool isConnected(); - - ///Returns whether this object is in the proccess of connecting - bool isConnecting(); - - ///Updates messages from the thread - void update(); - - ///Pops the top-most message in the queue of received messages. - ///When there are no messages, it will poll SDL for more packets. - ///The caller assumes ownership of the NetMessage. - shared_ptr getMessage(); - - ///Sends a message across the connection. - void sendMessage(shared_ptr message); - - ///Returns the IP address - const std::string& getIPAddress() const; + /// Starts connecting to the given address and port. + NetConnection(const std::string& address, Uint16 port); + /// Creates a disconnected connection with the default platform transport. + NetConnection(); + /// Creates a connection with an injected transport. + explicit NetConnection(std::unique_ptr transport); + /// Closes the transport and releases queued messages. + ~NetConnection(); + /// Replaces any current connection and starts connecting. + void openConnection(const std::string& address, Uint16 port); + /// Closes the current connection and clears its queues. + void closeConnection(); + /// Returns whether the transport is connected. + bool isConnected(); + /// Returns whether the transport is still connecting. + bool isConnecting(); + /// Transfers available transport bytes into complete messages. + void update(); + /// Returns the next received message, or an empty pointer when none is ready. + std::shared_ptr getMessage(); + /// Queues one framed message for ordered delivery. + void sendMessage(std::shared_ptr message); + /// Returns the configured peer address. + const std::string& getIPAddress() const; protected: - friend class NetListener; - - ///This function attempts a connection using the provided TCP server socket. - ///One can use isConnected to test for success. - bool attemptConnection(TCPsocket& serverSocket); - + friend class NetListener; + /// Accepts one connection from the native SDL listener when available. + bool attemptConnection(TCPsocket& serverSocket); private: - NetConnectionThread connect; - std::thread connectThread; - - std::queue > incoming; - std::recursive_mutex incomingMutex; - std::queue > received; - - std::string address; - bool connecting; + std::unique_ptr transport; + std::queue> received; + std::vector pending; + std::string address; + std::queue> outgoing; + size_t outgoingBytes = 0; + void flushOutgoing(); }; - - diff --git a/src/net/NetConnectionThread.cpp b/src/net/NetConnectionThread.cpp deleted file mode 100644 index fbd75eb47..000000000 --- a/src/net/NetConnectionThread.cpp +++ /dev/null @@ -1,236 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// Copyright (C) 2008 Bradley Arsenault - -#include "NetConnectionThread.h" -#include "Order.h" -#include "StreamBackend.h" -#include "BinaryStream.h" -#include "NetMessage.h" -#include "SDLCompat.h" -#include - -using namespace GAGCore; -using std::static_pointer_cast; - -NetConnectionThread::NetConnectionThread(std::queue >& outgoing, std::recursive_mutex& outgoingMutex) - : ThreadMessageQueues(outgoing, outgoingMutex) -{ - set=SDLNet_AllocSocketSet(1); - connected=false; -} - - - -NetConnectionThread::~NetConnectionThread() -{ - SDLNet_FreeSocketSet(set); -} - - - -void NetConnectionThread::operator()() -{ - while(true) - { - SDL_Delay(20); - { - //First parse incoming thread messages - while(true) - { - std::shared_ptr message; - { - std::lock_guard lock(incomingMutex); - if(!incoming.empty()) - { - message = incoming.front(); - incoming.pop(); - } - else - { - break; - } - } - Uint8 type = message->getMessageType(); - switch(type) - { - case NTMConnect: - { - std::shared_ptr info = static_pointer_cast(message); - if(!connected) - { - //Resolve the address - if(SDLNet_ResolveHost(&address, info->getServer().c_str(), info->getPort()) == -1) - { - std::shared_ptr error(new NTCouldNotConnect(SDLNet_GetError())); - sendToMainThread(error); - } - else - { - //Open the connection - socket=SDLNet_TCP_Open(&address); - if(!socket) - { - std::shared_ptr error(new NTCouldNotConnect(SDLNet_GetError())); - sendToMainThread(error); - } - else - { - SDLNet_TCP_AddSocket(set, socket); - connected=true; - std::shared_ptr connected(new NTConnected(info->getServer())); - sendToMainThread(connected); - } - } - } - } - break; - case NTMCloseConnection: - { - std::shared_ptr info = static_pointer_cast(message); - if(connected) - { - closeConnection(); - } - } - break; - case NTMSendMessage: - { - std::shared_ptr info = static_pointer_cast(message); - if(connected) - { - std::shared_ptr message = info->getMessage(); - MemoryStreamBackend* msb = new MemoryStreamBackend; - BinaryOutputStream* bos = new BinaryOutputStream(msb); - bos->writeUint8(message->getMessageType(), "messageType"); - message->encodeData(bos); - - msb->seekFromEnd(0); - Uint32 length = msb->getPosition(); - msb->seekFromStart(0); - - Uint8* newData = new Uint8[length+NET_FRAME_LENGTH_PREFIX_BYTES]; - SDLNet_Write16(length, newData); - msb->read(newData+NET_FRAME_LENGTH_PREFIX_BYTES, length); - - Uint32 result=SDLNet_TCP_Send(socket, newData, length+NET_FRAME_LENGTH_PREFIX_BYTES); - if(result<(length+NET_FRAME_LENGTH_PREFIX_BYTES)) - { - std::shared_ptr error(new NTLostConnection(SDLNet_GetError())); - sendToMainThread(error); - closeConnection(); - } - - delete bos; - delete[] newData; - } - } - break; - case NTMAcceptConnection: - { - std::shared_ptr info = static_pointer_cast(message); - if(!connected) - { - connected=true; - socket=info->getSocket(); - address = *SDLNet_TCP_GetPeerAddress(socket); - SDLNet_TCP_AddSocket(set, socket); - } - } - break; - case NTMExitThread: - { - if(connected) - { - closeConnection(); - } - hasExited=true; - return; - } - break; - } - } - - while (connected) - { - SDL_Delay(50); - int numReady = SDLNet_CheckSockets(set, 0); - //This checks if there are any active sockets. - //SDLNet_CheckSockets is used because it is non-blocking - if(numReady==-1) - { - std::shared_ptr error(new NTLostConnection(SDLNet_GetError())); - sendToMainThread(error); - perror("SDLNet_CheckSockets"); - if(connected) - closeConnection(); - break; - } - else if(numReady) - { - //Read and interpret the length of the message - Uint8* lengthData = new Uint8[NET_FRAME_LENGTH_PREFIX_BYTES]; - int amount = SDLNet_TCP_Recv(socket, lengthData, NET_FRAME_LENGTH_PREFIX_BYTES); - if(amount <= 0) - { - std::shared_ptr error(new NTLostConnection(SDLNet_GetError())); - sendToMainThread(error); - closeConnection(); - } - else - { - Uint16 length = SDLNet_Read16(lengthData); - //Read in the data. - Uint8* data = new Uint8[length]; - - for(int i=0; i error(new NTLostConnection(SDLNet_GetError())); - sendToMainThread(error); - closeConnection(); - } - } - if(connected) - { - - MemoryStreamBackend* msb = new MemoryStreamBackend(data, length); - msb->seekFromStart(0); - BinaryInputStream* bis = new BinaryInputStream(msb); - - //Now interpret the message from the data, and add it to the queue - std::shared_ptr message = NetMessage::getNetMessage(bis); - std::shared_ptr received(new NTReceivedMessage(message)); - sendToMainThread(received); - - delete bis; - } - delete[] data; - } - delete[] lengthData; - } - else - { - break; - } - } - } - } -} - - - -bool NetConnectionThread::isConnected() -{ - return connected; -} - - - -void NetConnectionThread::closeConnection() -{ - SDLNet_TCP_DelSocket(set, socket); - SDLNet_TCP_Close(socket); - connected=false; -} diff --git a/src/net/NetConnectionThread.h b/src/net/NetConnectionThread.h deleted file mode 100644 index 64968cc7b..000000000 --- a/src/net/NetConnectionThread.h +++ /dev/null @@ -1,31 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// Copyright (C) 2008 Bradley Arsenault - -#pragma once - -#include "NetConnectionThreadMessage.h" -#include "ThreadMessageQueues.h" - -///Manages a single TCP connection on a worker thread -class NetConnectionThread : public ThreadMessageQueues -{ -public: - NetConnectionThread(std::queue >& outgoing, std::recursive_mutex& outgoingMutex); - - ~NetConnectionThread(); - - ///Runs the net thread - void operator()(); - - ///Returns true if this object is connected - bool isConnected(); - -private: - ///Closes the connection - void closeConnection(); - - IPaddress address; - TCPsocket socket; - SDLNet_SocketSet set; - bool connected; -}; diff --git a/src/net/NetConnectionThreadMessage.cpp b/src/net/NetConnectionThreadMessage.cpp deleted file mode 100644 index 82ac064a5..000000000 --- a/src/net/NetConnectionThreadMessage.cpp +++ /dev/null @@ -1,359 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// Copyright (C) 2008 Bradley Arsenault - -#include "NetConnectionThreadMessage.h" -#include -#include -#include "NetMessage.h" - - -NTConnect::NTConnect(std::string server, Uint16 port) - : server(server), port(port) -{ -} - - - -Uint8 NTConnect::getMessageType() const -{ - return NTMConnect; -} - - - -std::string NTConnect::format() const -{ - std::ostringstream s; - s<<"NTMConnect("<<"server="<(rhs); - if(r.server == server && r.port == port) - return true; - } - return false; -} - - -std::string NTConnect::getServer() const -{ - return server; -} - - - -Uint16 NTConnect::getPort() const -{ - return port; -} - - - -NTCouldNotConnect::NTCouldNotConnect(std::string error) - : error(error) -{ -} - - - -Uint8 NTCouldNotConnect::getMessageType() const -{ - return NTMCouldNotConnect; -} - - - -std::string NTCouldNotConnect::format() const -{ - std::ostringstream s; - s<<"NTCouldNotConnect("<<"error="<(rhs); - if(r.error == error) - return true; - } - return false; -} - - -NTConnected::NTConnected(const std::string& ip) - : ip(ip) -{ -} - - - -Uint8 NTConnected::getMessageType() const -{ - return NTMConnected; -} - - - -std::string NTConnected::format() const -{ - std::ostringstream s; - s<<"NTConnected(ip="<(rhs); - if(r.error == error) - return true; - } - return false; -} - - -NTReceivedMessage::NTReceivedMessage(std::shared_ptr message) - : message(message) -{ -} - - - -Uint8 NTReceivedMessage::getMessageType() const -{ - return NTMReceivedMessage; -} - - - -std::string NTReceivedMessage::format() const -{ - std::ostringstream s; - s<<"NTReceivedMessage("<<"message->format()="<format()<<"; "<<")"; - return s.str(); -} - - - -bool NTReceivedMessage::operator==(const NetConnectionThreadMessage& rhs) const -{ - if(typeid(rhs)==typeid(NTReceivedMessage)) - { - const NTReceivedMessage& r = dynamic_cast(rhs); - if(r.message == message) - return true; - } - return false; -} - - -std::shared_ptr NTReceivedMessage::getMessage() const -{ - return message; -} - - - -NTSendMessage::NTSendMessage(std::shared_ptr message) - : message(message) -{ -} - - - -Uint8 NTSendMessage::getMessageType() const -{ - return NTMSendMessage; -} - - - -std::string NTSendMessage::format() const -{ - std::ostringstream s; - s<<"NTSendMessage("<<"message->format()="<format()<<"; "<<")"; - return s.str(); -} - - - -bool NTSendMessage::operator==(const NetConnectionThreadMessage& rhs) const -{ - if(typeid(rhs)==typeid(NTSendMessage)) - { - const NTSendMessage& r = dynamic_cast(rhs); - if(r.message == message) - return true; - } - return false; -} - - -std::shared_ptr NTSendMessage::getMessage() const -{ - return message; -} - - - -NTAcceptConnection::NTAcceptConnection(TCPsocket& socket) - : socket(socket) -{ -} - - - -Uint8 NTAcceptConnection::getMessageType() const -{ - return NTMAcceptConnection; -} - - - -std::string NTAcceptConnection::format() const -{ - std::ostringstream s; - s<<"NTAcceptConnection()"; - return s.str(); -} - - - -bool NTAcceptConnection::operator==(const NetConnectionThreadMessage& rhs) const -{ - if(typeid(rhs)==typeid(NTAcceptConnection)) - { - const NTAcceptConnection& r = dynamic_cast(rhs); - if(r.socket == socket) - return true; - } - return false; -} - - -TCPsocket NTAcceptConnection::getSocket() const -{ - return socket; -} - - - -NTExitThread::NTExitThread() -{ -} - - - -Uint8 NTExitThread::getMessageType() const -{ - return NTMExitThread; -} - - - -std::string NTExitThread::format() const -{ - std::ostringstream s; - s<<"NTExitThread()"; - return s.str(); -} - - - -bool NTExitThread::operator==(const NetConnectionThreadMessage& rhs) const -{ - if(typeid(rhs)==typeid(NTExitThread)) - { - return true; - } - return false; -} - - -//code_append_marker diff --git a/src/net/NetConnectionThreadMessage.h b/src/net/NetConnectionThreadMessage.h deleted file mode 100644 index 636a87e58..000000000 --- a/src/net/NetConnectionThreadMessage.h +++ /dev/null @@ -1,261 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// Copyright (C) 2008 Bradley Arsenault - -#pragma once - -#include -#include "SDL_net.h" -#include -#include - -class NetMessage; - -enum NetConnectionThreadMessageType -{ - NTMConnect, - NTMCouldNotConnect, - NTMConnected, - NTMCloseConnection, - NTMLostConnection, - NTMReceivedMessage, - NTMSendMessage, - NTMAcceptConnection, - NTMExitThread, - //type_append_marker -}; - - -///This class represents a message sent between the main thread and the thread that manages IRC -class NetConnectionThreadMessage -{ -public: - ///Destructor - virtual ~NetConnectionThreadMessage() {} - - ///Returns the event type - virtual Uint8 getMessageType() const = 0; - - ///Returns a formatted version of the event - virtual std::string format() const = 0; - - ///Compares two NetConnectionThreadMessage - virtual bool operator==(const NetConnectionThreadMessage& rhs) const = 0; -}; - - - -///NTConnect -class NTConnect : public NetConnectionThreadMessage -{ -public: - ///Creates a NTMConnect event - NTConnect(std::string server, Uint16 port); - - ///Returns NTMMConnect - Uint8 getMessageType() const; - - ///Returns a formatted version of the event - std::string format() const; - - ///Compares two IRCThreadMessage - bool operator==(const NetConnectionThreadMessage& rhs) const; - - ///Retrieves server - std::string getServer() const; - - ///Retrieves port - Uint16 getPort() const; -private: - std::string server; - Uint16 port; -}; - - - - -///NTCouldNotConnect -class NTCouldNotConnect : public NetConnectionThreadMessage -{ -public: - ///Creates a NTCouldNotConnect event - NTCouldNotConnect(std::string error); - - ///Returns NTMCouldNotConnect - Uint8 getMessageType() const; - - ///Returns a formatted version of the event - std::string format() const; - - ///Compares two IRCThreadMessage - bool operator==(const NetConnectionThreadMessage& rhs) const; - -private: - std::string error; -}; - - - - -///NTConnected -class NTConnected : public NetConnectionThreadMessage -{ -public: - ///Creates a NTConnected event - NTConnected(const std::string& ip); - - ///Returns NTMConnected - Uint8 getMessageType() const; - - ///Returns a formatted version of the event - std::string format() const; - - ///Compares two IRCThreadMessage - bool operator==(const NetConnectionThreadMessage& rhs) const; - - ///Returns the ip address of the connection - const std::string& getIPAddress(); -private: - std::string ip; -}; - - - - -///NTCloseConnection -class NTCloseConnection : public NetConnectionThreadMessage -{ -public: - ///Creates a NTCloseConnection event - NTCloseConnection(); - - ///Returns NTMCloseConnection - Uint8 getMessageType() const; - - ///Returns a formatted version of the event - std::string format() const; - - ///Compares two IRCThreadMessage - bool operator==(const NetConnectionThreadMessage& rhs) const; -}; - - - - -///NTLostConnection -class NTLostConnection : public NetConnectionThreadMessage -{ -public: - ///Creates a NTLostConnection event - NTLostConnection(std::string error); - - ///Returns NTMLostConnection - Uint8 getMessageType() const; - - ///Returns a formatted version of the event - std::string format() const; - - ///Compares two IRCThreadMessage - bool operator==(const NetConnectionThreadMessage& rhs) const; - -private: - std::string error; -}; - - - - -///NTReceivedMessage -class NTReceivedMessage : public NetConnectionThreadMessage -{ -public: - ///Creates a NTReceivedMessage event - NTReceivedMessage(std::shared_ptr message); - - ///Returns NTMReceivedMessage - Uint8 getMessageType() const; - - ///Returns a formatted version of the event - std::string format() const; - - ///Compares two IRCThreadMessage - bool operator==(const NetConnectionThreadMessage& rhs) const; - - ///Retrieves message - std::shared_ptr getMessage() const; -private: - std::shared_ptr message; -}; - - - - -///NTSendMessage -class NTSendMessage : public NetConnectionThreadMessage -{ -public: - ///Creates a NTSendMessage event - NTSendMessage(std::shared_ptr message); - - ///Returns NTMSendMessage - Uint8 getMessageType() const; - - ///Returns a formatted version of the event - std::string format() const; - - ///Compares two IRCThreadMessage - bool operator==(const NetConnectionThreadMessage& rhs) const; - - ///Retrieves message - std::shared_ptr getMessage() const; -private: - std::shared_ptr message; -}; - - - - -///NTAcceptConnection -class NTAcceptConnection : public NetConnectionThreadMessage -{ -public: - ///Creates a NTAcceptConnection event - NTAcceptConnection(TCPsocket& socket); - - ///Returns NTMAcceptConnection - Uint8 getMessageType() const; - - ///Returns a formatted version of the event - std::string format() const; - - ///Compares two IRCThreadMessage - bool operator==(const NetConnectionThreadMessage& rhs) const; - - ///Retrieves socket - TCPsocket getSocket() const; -private: - TCPsocket socket; -}; - - - - -///NTExitThread -class NTExitThread : public NetConnectionThreadMessage -{ -public: - ///Creates a NTExitThread event - NTExitThread(); - - ///Returns NTMExitThread - Uint8 getMessageType() const; - - ///Returns a formatted version of the event - std::string format() const; - - ///Compares two IRCThreadMessage - bool operator==(const NetConnectionThreadMessage& rhs) const; -}; - - - -//event_append_marker diff --git a/src/net/NetTransport.cpp b/src/net/NetTransport.cpp new file mode 100644 index 000000000..621d69296 --- /dev/null +++ b/src/net/NetTransport.cpp @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "NetTransport.h" +#ifdef HAVE_CONFIG_H +#include +#endif +#include +#include +#include +#include +#include + +#if !defined(YOG_SERVER_ONLY) && defined(GLOB2_NATIVE_WSS) +std::unique_ptr makeWssTransport(); +#endif + +namespace { +class TcpTransport final : public NetTransport { + std::atomic status{State::Closed}; + std::atomic stop{false}; + std::thread worker; + std::mutex mutex; + std::deque> incoming, outgoing; + size_t incomingBytes = 0, outgoingBytes = 0; + + void run(TCPsocket socket, std::string host, uint16_t port) { + if (!socket) { + IPaddress address{}; + if (SDLNet_ResolveHost(&address, host.c_str(), port) == 0 && !stop) + socket = SDLNet_TCP_Open(&address); + } + auto set = SDLNet_AllocSocketSet(1); + if (socket && set && SDLNet_TCP_AddSocket(set, socket) >= 0 && !stop) { + status = State::Connected; + while (!stop) { + std::vector bytes; + { + std::lock_guard lock(mutex); + if (!outgoing.empty()) { + bytes = std::move(outgoing.front()); + outgoing.pop_front(); + outgoingBytes -= bytes.size(); + } + } + if (!bytes.empty() && SDLNet_TCP_Send(socket, bytes.data(), bytes.size()) != int(bytes.size())) break; + const int ready = SDLNet_CheckSockets(set, 10); + if (ready < 0) break; + if (ready && SDLNet_SocketReady(socket)) { + std::array buffer; + const int size = SDLNet_TCP_Recv(socket, buffer.data(), buffer.size()); + if (size <= 0) break; + std::lock_guard lock(mutex); + if (incomingBytes + size > queueLimit) break; + incoming.emplace_back(buffer.begin(), buffer.begin() + size); + incomingBytes += size; + } + } + } + if (socket) SDLNet_TCP_Close(socket); + if (set) SDLNet_FreeSocketSet(set); + status = State::Closed; + } +public: + ~TcpTransport() override { close(); } + void open(const std::string& host, uint16_t port) override { + close(); + stop = false; + status = State::Connecting; + worker = std::thread([this, host, port] { run(nullptr, host, port); }); + } + bool accept(TCPsocket socket) override { + close(); + stop = false; + // The accepted socket is already connected; callers can immediately + // queue the server greeting while the worker takes ownership. + status = State::Connected; + worker = std::thread([this, socket] { run(socket, {}, 0); }); + return true; + } + void close() override { + stop = true; + if (worker.joinable()) worker.join(); + status = State::Closed; + std::lock_guard lock(mutex); + incoming.clear(); outgoing.clear(); + incomingBytes = outgoingBytes = 0; + } + State state() const override { return status; } + bool send(std::vector bytes) override { + std::lock_guard lock(mutex); + if (status != State::Connected || bytes.size() > queueLimit - outgoingBytes) return false; + outgoingBytes += bytes.size(); + outgoing.push_back(std::move(bytes)); + return true; + } + bool receive(std::vector& bytes) override { + std::lock_guard lock(mutex); + if (incoming.empty()) return false; + bytes = std::move(incoming.front()); incoming.pop_front(); + incomingBytes -= bytes.size(); + return true; + } +}; +} +#ifndef YOG_SERVER_ONLY +namespace { +class NativeTransport final : public NetTransport { + std::unique_ptr selected; +public: + void open(const std::string& address, uint16_t port) override { + close(); + if (address.rfind("wss://", 0) == 0) { +#ifdef GLOB2_NATIVE_WSS + selected = makeWssTransport(); +#else + return; +#endif + } else { + selected = std::make_unique(); + } + selected->open(address, port); + } + void close() override { selected.reset(); } + State state() const override { return selected ? selected->state() : State::Closed; } + bool send(std::vector bytes) override { return selected && selected->send(std::move(bytes)); } + bool receive(std::vector& bytes) override { return selected && selected->receive(bytes); } + bool accept(TCPsocket socket) override { + close(); selected = std::make_unique(); return selected->accept(socket); + } +}; +} +#endif +std::unique_ptr makeNetTransport() { +#ifdef YOG_SERVER_ONLY + return std::make_unique(); +#else + return std::make_unique(); +#endif +} diff --git a/src/net/NetTransport.h b/src/net/NetTransport.h new file mode 100644 index 000000000..50674fa89 --- /dev/null +++ b/src/net/NetTransport.h @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once +#include +#include +#include +#include +#include + +// Byte-stream transport. Message framing and decoding belong to NetConnection. +class NetTransport { +public: + enum class State { Closed, Connecting, Connected }; + static constexpr size_t queueLimit = 1024 * 1024; + static constexpr size_t chunkLimit = 16 * 1024; + virtual ~NetTransport() = default; + virtual void open(const std::string& address, uint16_t port) = 0; + virtual void close() = 0; + virtual State state() const = 0; + virtual bool send(std::vector bytes) = 0; + virtual bool receive(std::vector& bytes) = 0; + // Ownership transfers only on success. Only native TCP supports accepting. + virtual bool accept(TCPsocket socket) { return false; } +}; +std::unique_ptr makeNetTransport(); diff --git a/src/net/WssTransport.cpp b/src/net/WssTransport.cpp new file mode 100644 index 000000000..5c3b767d8 --- /dev/null +++ b/src/net/WssTransport.cpp @@ -0,0 +1,168 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "NetTransport.h" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +namespace asio = boost::asio; +namespace ssl = asio::ssl; +namespace beast = boost::beast; +namespace ws = beast::websocket; +using tcp = asio::ip::tcp; +using Error = boost::system::error_code; + +// The same application thread that owns NetConnection pumps asynchronous I/O. +// No blocking connect, TLS handshake, read, or write runs on that thread. +class WssTransport final : public NetTransport { + struct Session { + asio::io_context io; + ssl::context tls{ssl::context::tls_client}; + beast::flat_buffer buffer{64 * 1024}; + ws::stream> socket{io, tls}; + tcp::resolver resolver{io}; + asio::steady_timer connectDeadline{io}, writeDeadline{io}; + State status = State::Connecting; + std::deque> incoming, outgoing; + size_t incomingBytes = 0, outgoingBytes = 0; + std::string host, authority, service, route; + bool writing = false; + + Session(const std::string& address, uint16_t port) { + // This is a gateway origin, never a credential-bearing URL or a + // backend chosen by an invitation/server packet. + if (address.rfind("wss://", 0) != 0) throw std::invalid_argument("Expected wss origin"); + authority = address.substr(6); + if (!authority.empty() && authority.back() == '/') authority.pop_back(); + if (authority.empty() || authority.find_first_of("/@?#\\ \t\r\n") != std::string::npos) + throw std::invalid_argument("Invalid wss origin"); + service = "443"; + if (authority.front() == '[') { + const auto end = authority.find(']'); + if (end == std::string::npos) throw std::invalid_argument("Invalid IPv6 origin"); + host = authority.substr(1, end - 1); + if (end + 1 < authority.size()) { + if (authority[end + 1] != ':') throw std::invalid_argument("Invalid origin port"); + service = authority.substr(end + 2); + } + } else { + const auto colon = authority.find(':'); + host = authority.substr(0, colon); + if (colon != std::string::npos) service = authority.substr(colon + 1); + } + if (host.empty() || service.empty() || service.find_first_not_of("0123456789") != std::string::npos + || std::stoul(service) == 0 || std::stoul(service) > 65535) + throw std::invalid_argument("Invalid origin host or port"); + route = port == 7491 ? "/router" : "/yog"; + SSL_set_min_proto_version(socket.next_layer().native_handle(), TLS1_2_VERSION); + tls.set_default_verify_paths(); + socket.next_layer().set_verify_mode(ssl::verify_peer); + socket.next_layer().set_verify_callback(ssl::host_name_verification(host)); + if (!SSL_set_tlsext_host_name(socket.next_layer().native_handle(), host.c_str())) + throw std::runtime_error("Could not set TLS server name"); + socket.read_message_max(64 * 1024); + socket.binary(true); + connectDeadline.expires_after(std::chrono::seconds(10)); + connectDeadline.async_wait([this](Error error) { if (!error) cancel(); }); + resolver.async_resolve(host, service, [this](Error error, tcp::resolver::results_type results) { + if (!active(error)) return; + beast::get_lowest_layer(socket).async_connect(results, + [this](Error error, const tcp::endpoint&) { + if (!active(error)) return; + socket.next_layer().async_handshake(ssl::stream_base::client, [this](Error error) { + if (!active(error)) return; + socket.set_option(ws::stream_base::timeout{std::chrono::seconds(10), + std::chrono::seconds(30), true}); + socket.async_handshake(authority, route, [this](Error error) { + if (!active(error)) return; + connectDeadline.cancel(); + status = State::Connected; + read(); + }); + }); + }); + }); + } + ~Session() { cancel(); io.stop(); } + bool active(Error error) { + if (error) cancel(); + return status != State::Closed; + } + void cancel() { + status = State::Closed; + resolver.cancel(); + connectDeadline.cancel(); writeDeadline.cancel(); + Error ignored; + beast::get_lowest_layer(socket).socket().close(ignored); + } + void poll() { + io.restart(); + for (unsigned i = 0; i < 16 && io.poll_one(); ++i) {} + } + void read() { + socket.async_read(buffer, [this](Error error, size_t size) { + if (!active(error)) return; + if (!socket.got_binary() || size > queueLimit - incomingBytes || incoming.size() >= 256) { cancel(); return; } + if (!size) { read(); return; } + std::vector bytes(size); + asio::buffer_copy(asio::buffer(bytes), buffer.data()); + buffer.consume(size); + incomingBytes += size; + incoming.push_back(std::move(bytes)); + read(); + }); + } + void write() { + if (writing || outgoing.empty() || status != State::Connected) return; + writing = true; + writeDeadline.expires_after(std::chrono::seconds(10)); + writeDeadline.async_wait([this](Error error) { if (!error) cancel(); }); + socket.async_write(asio::buffer(outgoing.front()), [this](Error error, size_t) { + if (!active(error)) return; + writeDeadline.cancel(); + outgoingBytes -= outgoing.front().size(); + outgoing.pop_front(); + writing = false; + write(); + }); + } + }; + std::unique_ptr session; +public: + void open(const std::string& address, uint16_t port) override { + close(); + try { session = std::make_unique(address, port); } + catch (const std::exception&) { session.reset(); } + } + void close() override { session.reset(); } + State state() const override { + if (!session) return State::Closed; + session->poll(); + return session->status; + } + bool send(std::vector bytes) override { + if (state() != State::Connected || bytes.size() > queueLimit - session->outgoingBytes) return false; + session->outgoingBytes += bytes.size(); + for (size_t offset = 0; offset < bytes.size(); offset += chunkLimit) { + const auto end = std::min(bytes.size(), offset + chunkLimit); + session->outgoing.emplace_back(bytes.begin() + offset, bytes.begin() + end); + } + session->write(); + return true; + } + bool receive(std::vector& bytes) override { + if (!session) return false; + session->poll(); + if (session->incoming.empty()) return false; + bytes = std::move(session->incoming.front()); session->incoming.pop_front(); + session->incomingBytes -= bytes.size(); + return true; + } +}; +} +std::unique_ptr makeWssTransport() { return std::make_unique(); } diff --git a/src/net/gateway/Gateway.cpp b/src/net/gateway/Gateway.cpp new file mode 100644 index 000000000..463b75d08 --- /dev/null +++ b/src/net/gateway/Gateway.cpp @@ -0,0 +1,231 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace asio = boost::asio; +namespace beast = boost::beast; +namespace http = beast::http; +namespace websocket = beast::websocket; +using tcp = asio::ip::tcp; +using Error = boost::system::error_code; + +struct Configuration +{ + std::string listen = "127.0.0.1"; + unsigned short port = 8080; + std::string lobbyHost = "127.0.0.1", lobbyPort = "7489"; + std::string routerHost = "127.0.0.1", routerPort = "7491"; + std::string origin = "http://127.0.0.1:8765"; + unsigned limit = 256; +}; + +struct Metrics +{ + unsigned active = 0; + unsigned long long accepted = 0, rejected = 0, bytesToBackend = 0, bytesToClient = 0; +}; + +class Session : public std::enable_shared_from_this +{ + websocket::stream ws; + beast::tcp_stream backend; + tcp::resolver resolver; + beast::flat_buffer input{65536}; + http::request_parser parser; + http::response response; + std::array backendBuffer{}; + const Configuration& config; + std::shared_ptr metrics; + bool stopped = false; + + void stop() + { + if (stopped) return; + stopped = true; + resolver.cancel(); + Error ignored; + backend.socket().cancel(ignored); + backend.socket().close(ignored); + beast::get_lowest_layer(ws).socket().cancel(ignored); + beast::get_lowest_layer(ws).socket().close(ignored); + } + + void reply(http::status status, std::string body) + { + response = {status, 11}; + response.set(http::field::content_type, "text/plain"); + response.keep_alive(false); + response.body() = std::move(body); + response.prepare_payload(); + http::async_write(ws.next_layer(), response, [self=shared_from_this()](Error, size_t) { self->stop(); }); + } + + void connectBackend(const std::string& host, const std::string& port) + { + backend.expires_after(std::chrono::seconds(10)); + resolver.async_resolve(host, port, [self=shared_from_this()](Error ec, tcp::resolver::results_type endpoints) { + if (ec) { self->reply(http::status::bad_gateway,"Backend unavailable\n"); return; } + self->backend.async_connect(endpoints, [self](Error error, const tcp::resolver::results_type::endpoint_type&) { + if (error) { self->reply(http::status::bad_gateway,"Backend unavailable\n"); return; } + self->backend.expires_never(); + beast::get_lowest_layer(self->ws).expires_never(); + self->ws.set_option(websocket::stream_base::timeout::suggested(beast::role_type::server)); + self->ws.read_message_max(65536); + self->ws.binary(true); + self->ws.async_accept(self->parser.get(), [self](Error accepted) { + if (accepted) { self->stop(); return; } + ++self->metrics->accepted; + self->readClient(); + self->readBackend(); + }); + }); + }); + } + + void readClient() + { + ws.async_read(input, [self=shared_from_this()](Error ec, size_t length) { + if (ec || !self->ws.got_binary()) { self->stop(); return; } + self->metrics->bytesToBackend += length; + // One write in flight: stalled TCP applies backpressure to WebSocket reads. + asio::async_write(self->backend.socket(), self->input.data(), [self](Error error, size_t) { + if (error) { self->stop(); return; } + self->input.consume(self->input.size()); + self->readClient(); + }); + }); + } + + void readBackend() + { + backend.async_read_some(asio::buffer(backendBuffer), [self=shared_from_this()](Error ec, size_t length) { + if (ec) { self->stop(); return; } + self->metrics->bytesToClient += length; + // TCP chunk boundaries are not protocol message boundaries. + self->ws.async_write(asio::buffer(self->backendBuffer.data(),length), [self](Error error, size_t) { + if (error) { self->stop(); return; } + self->readBackend(); + }); + }); + } + +public: + Session(tcp::socket socket, const Configuration& configuration, std::shared_ptr counters) + : ws(std::move(socket)), backend(ws.get_executor()), resolver(ws.get_executor()), config(configuration), metrics(counters) + { + ++metrics->active; + parser.header_limit(8192); + parser.body_limit(0); + } + ~Session() { --metrics->active; } + + void run() + { + beast::get_lowest_layer(ws).expires_after(std::chrono::seconds(10)); + http::async_read(ws.next_layer(), input, parser, [self=shared_from_this()](Error ec, size_t) { + if (ec) { self->stop(); return; } + const auto& request = self->parser.get(); + // A client must wait for the upgrade response before sending frames. + // Never mix unread HTTP bytes into the decoded WebSocket payload buffer. + if (self->input.size()) { + ++self->metrics->rejected; + self->reply(http::status::bad_request,"Pipelined input rejected\n"); return; + } + if (request.method() != http::verb::get) { + self->reply(http::status::method_not_allowed,"GET required\n"); return; + } + if (request.target() == "/healthz") { self->reply(http::status::ok,"ok\n"); return; } + if (request.target() == "/metrics") { + self->reply(http::status::ok, + "glob2_gateway_connections " + std::to_string(self->metrics->active) + "\n" + + "glob2_gateway_accepted_total " + std::to_string(self->metrics->accepted) + "\n" + + "glob2_gateway_rejected_total " + std::to_string(self->metrics->rejected) + "\n" + + "glob2_gateway_bytes_to_backend_total " + std::to_string(self->metrics->bytesToBackend) + "\n" + + "glob2_gateway_bytes_to_client_total " + std::to_string(self->metrics->bytesToClient) + "\n"); + return; + } + const auto origin = request[http::field::origin]; + if (!websocket::is_upgrade(request) || (!origin.empty() && origin != self->config.origin)) { + ++self->metrics->rejected; + self->reply(http::status::forbidden,"Upgrade or origin rejected\n"); return; + } + if (request.target() == "/yog") self->connectBackend(self->config.lobbyHost,self->config.lobbyPort); + else if (request.target() == "/router") self->connectBackend(self->config.routerHost,self->config.routerPort); + else { ++self->metrics->rejected; self->reply(http::status::not_found,"Unknown backend\n"); } + }); + } +}; + +class Listener +{ + tcp::acceptor acceptor; + const Configuration& config; + std::shared_ptr metrics = std::make_shared(); +public: + Listener(asio::io_context& context, const Configuration& configuration) + : acceptor(context), config(configuration) + { + tcp::endpoint endpoint(asio::ip::make_address(config.listen), config.port); + acceptor.open(endpoint.protocol()); + acceptor.set_option(asio::socket_base::reuse_address(true)); + acceptor.bind(endpoint); + acceptor.listen(); + std::cout << "gateway listening on " << acceptor.local_endpoint() << std::endl; + } + void run() + { + acceptor.async_accept([this](Error ec, tcp::socket socket) { + if (!ec) { + if (metrics->active < config.limit) std::make_shared(std::move(socket),config,metrics)->run(); + else { ++metrics->rejected; Error ignored; socket.close(ignored); } + } + if (acceptor.is_open()) run(); + }); + } +}; + +int main(int argc, char** argv) +{ + try { + Configuration config; + for (int i=1;i65535) throw std::runtime_error("Invalid port"); + config.port=static_cast(port); + } + else if(option=="--origin") config.origin=value; + else if(option=="--lobby-host") config.lobbyHost=value; + else if(option=="--lobby-port") config.lobbyPort=value; + else if(option=="--router-host") config.routerHost=value; + else if(option=="--router-port") config.routerPort=value; + else throw std::runtime_error("Unknown option: "+option); + } + asio::io_context context; + asio::signal_set signals(context,SIGINT,SIGTERM); + signals.async_wait([&](Error,int){context.stop();}); + Listener listener(context,config); + listener.run(); + context.run(); + } catch(const std::exception& error) { + std::cerr << "gateway: " << error.what() << '\n'; + return 1; + } +} diff --git a/src/net/irc/IRC.cpp b/src/net/irc/IRC.cpp index 660a78dfc..34e2d5250 100644 --- a/src/net/irc/IRC.cpp +++ b/src/net/irc/IRC.cpp @@ -17,7 +17,7 @@ // version related stuff #ifdef HAVE_CONFIG_H - #include + #include #endif #ifndef PACKAGE_TARNAME #define PACKAGE_TARNAME "glob2" diff --git a/src/net/message/AuthMessages.cpp b/src/net/message/AuthMessages.cpp index 138fc71b8..735a401cd 100644 --- a/src/net/message/AuthMessages.cpp +++ b/src/net/message/AuthMessages.cpp @@ -58,13 +58,13 @@ Uint16 NetSendClientInformation::getNetVersion() const } NetSendServerInformation::NetSendServerInformation(YOGLoginPolicy loginPolicy, YOGGamePolicy gamePolicy, YOGPlayerID playerID) - : loginPolicy(loginPolicy), gamePolicy(gamePolicy), playerID(playerID) + : loginPolicy(loginPolicy), gamePolicy(gamePolicy), playerID(playerID), netVersion(NET_PROTOCOL_VERSION) { } NetSendServerInformation::NetSendServerInformation() - : loginPolicy(YOGRequirePassword), gamePolicy(YOGSingleGame), playerID(0) + : loginPolicy(YOGRequirePassword), gamePolicy(YOGSingleGame), playerID(0), netVersion(NET_PROTOCOL_VERSION) { } @@ -80,6 +80,7 @@ void NetSendServerInformation::encodeData(GAGCore::OutputStream* stream) const stream->writeUint8(loginPolicy, "loginPolicy "); stream->writeUint8(gamePolicy, "gamePolicy "); stream->writeUint16(playerID, "playerID "); + stream->writeUint16(netVersion, "netVersion"); stream->writeLeaveSection(); } @@ -89,6 +90,9 @@ void NetSendServerInformation::decodeData(GAGCore::InputStream* stream) loginPolicy=static_cast(stream->readUint8("loginPolicy")); gamePolicy=static_cast(stream->readUint8("gamePolicy")); playerID=stream->readUint16("playerID"); + // Legacy servers omit this field. Decode their greeting only so the client + // can explain incompatibility before sending any credentials. + netVersion = stream->isEndOfStream() ? 0 : stream->readUint16("netVersion"); stream->readLeaveSection(); } @@ -106,7 +110,7 @@ std::string NetSendServerInformation::format() const else if(gamePolicy == YOGMultipleGames) s<<"gamePolicy=YOGMultipleGames; "; - s<<"playerID="<(rhs); - if(r.loginPolicy == loginPolicy && r.gamePolicy == gamePolicy) + if(r.loginPolicy == loginPolicy && r.gamePolicy == gamePolicy && r.playerID == playerID && r.netVersion == netVersion) { return true; } @@ -139,6 +143,8 @@ YOGPlayerID NetSendServerInformation::getPlayerID() const return playerID; } +Uint16 NetSendServerInformation::getNetVersion() const { return netVersion; } + NetAttemptLogin::NetAttemptLogin(const std::string& username, const std::string& password) : username(username), password(password) { @@ -174,7 +180,7 @@ void NetAttemptLogin::decodeData(GAGCore::InputStream* stream) std::string NetAttemptLogin::format() const { std::ostringstream s; - s<<"NetAttemptLogin("<<"username=\""< server login attempt with username and password. diff --git a/src/net/message/RegistrationMessages.cpp b/src/net/message/RegistrationMessages.cpp index e108ea323..747bfb224 100644 --- a/src/net/message/RegistrationMessages.cpp +++ b/src/net/message/RegistrationMessages.cpp @@ -41,7 +41,7 @@ void NetRegistrationRequest::decodeData(GAGCore::InputStream* stream) std::string NetRegistrationRequest::format() const { std::ostringstream s; - s<<"NetRegistrationRequest(username=\""<drawPixel(dx+decX, dy+decY, r, g, b, Color::ALPHA_OPAQUE); } } - diff --git a/src/render/Minimap.h b/src/render/Minimap.h index 48a778cd4..d04efd595 100644 --- a/src/render/Minimap.h +++ b/src/render/Minimap.h @@ -30,6 +30,7 @@ class Minimap ///Sets the game associated with the minimap void setGame(Game& game); + void resizeViewport(int width); ///Draws the minimap void draw(int localteam, int viewportX, int viewportY, int viewportW, int viewportH); @@ -79,7 +80,7 @@ class Minimap int mini_offset_y; MinimapMode minimapMode; - Game* game; + Game* game = nullptr; DrawableSurface *surface; }; diff --git a/src/team/Team.cpp b/src/team/Team.cpp index 8e1e51ae4..f17966d0e 100644 --- a/src/team/Team.cpp +++ b/src/team/Team.cpp @@ -25,16 +25,6 @@ Team::Team(Game *game) -Team::Team(GAGCore::InputStream *stream, Game *game, Sint32 versionMinor) -:Team(game) -{ - if (!load(stream, &(globalContainer->buildingsTypes), versionMinor)) - throw std::runtime_error("Failed to load team"); -} - - - - Team::~Team() { if (!disableRecursiveDestruction) diff --git a/src/team/Team.h b/src/team/Team.h index cccae5ce9..d9decd385 100644 --- a/src/team/Team.h +++ b/src/team/Team.h @@ -4,6 +4,7 @@ #pragma once #include +#include #include #include @@ -57,11 +58,12 @@ class Team:public BaseTeam }; Team(Game *game); - Team(GAGCore::InputStream *stream, Game *game, Sint32 versionMinor); virtual ~Team(void); bool load(GAGCore::InputStream *stream, BuildingsTypes *buildingstypes, Sint32 versionMinor); + // Borrows this private preparation team and stream; discard on cancellation. + GAGCore::CooperativeTask loadTask(GAGCore::InputStream *stream, BuildingsTypes *buildingstypes, Sint32 versionMinor); void save(GAGCore::OutputStream *stream); //! Rebuild the per-building lists from myBuildings (map generators, in place of load()). diff --git a/src/team/TeamSerialization.cpp b/src/team/TeamSerialization.cpp index 27cf63af4..6636debfe 100644 --- a/src/team/TeamSerialization.cpp +++ b/src/team/TeamSerialization.cpp @@ -9,6 +9,11 @@ #include "Utilities.h" bool Team::load(GAGCore::InputStream *stream, BuildingsTypes *buildingstypes, Sint32 versionMinor) +{ + return loadTask(stream, buildingstypes, versionMinor).run(); +} + +GAGCore::CooperativeTask Team::loadTask(GAGCore::InputStream *stream, BuildingsTypes *buildingstypes, Sint32 versionMinor) { assert(stream); assert(buildingsToBeDestroyed.size()==0); @@ -16,7 +21,7 @@ bool Team::load(GAGCore::InputStream *stream, BuildingsTypes *buildingstypes, Si // loading base team if(!BaseTeam::load(stream, versionMinor)) - return false; + co_return false; stream->readEnterSection("Team"); @@ -24,8 +29,10 @@ bool Team::load(GAGCore::InputStream *stream, BuildingsTypes *buildingstypes, Si stream->readEnterSection("myUnits"); for (int i=0; ireadEnterSection(i); Uint32 isUsed = stream->readUint32("isUsed"); @@ -47,8 +54,10 @@ bool Team::load(GAGCore::InputStream *stream, BuildingsTypes *buildingstypes, Si stream->readEnterSection("myBuildings"); for (int i=0; ireadEnterSection(i); Uint32 isUsed = stream->readUint32("isUsed"); @@ -76,6 +85,7 @@ bool Team::load(GAGCore::InputStream *stream, BuildingsTypes *buildingstypes, Si stream->readEnterSection("myUnits"); for (int i=0; ireadEnterSection(i); @@ -88,6 +98,7 @@ bool Team::load(GAGCore::InputStream *stream, BuildingsTypes *buildingstypes, Si stream->readEnterSection("myBuildings"); for (int i=0; ireadEnterSection(i); @@ -125,7 +136,7 @@ bool Team::load(GAGCore::InputStream *stream, BuildingsTypes *buildingstypes, Si if (!stats.load(stream, versionMinor)) { stream->readLeaveSection(); - return false; + co_return false; } if (versionMinor < FILE_FORMAT_VERSION_LIVE_TEAM_STATS) stats.step(this, true); @@ -135,7 +146,7 @@ bool Team::load(GAGCore::InputStream *stream, BuildingsTypes *buildingstypes, Si if(!race.load(stream, versionMinor)) { stream->readLeaveSection(); - return false; + co_return false; } } else @@ -146,7 +157,7 @@ bool Team::load(GAGCore::InputStream *stream, BuildingsTypes *buildingstypes, Si isAlive = true; stream->readLeaveSection(); - return true; + co_return true; } diff --git a/src/yog/YOGClient.cpp b/src/yog/YOGClient.cpp index b28256148..40587c1ec 100644 --- a/src/yog/YOGClient.cpp +++ b/src/yog/YOGClient.cpp @@ -4,6 +4,7 @@ #include #include "MultiplayerGame.h" #include "AuthMessages.h" +#include "Version.h" #include "FileTransferMessages.h" #include "GameCreateMessages.h" #include "LobbyMessages.h" @@ -65,7 +66,8 @@ void YOGClient::initialize() void YOGClient::connect(const std::string& server) { initialize(); - nc.openConnection(server, YOG_SERVER_PORT); + const char* gateway = std::getenv("GLOB2_YOG_URL"); + nc.openConnection(server == YOG_SERVER_IP && gateway ? gateway : server, YOG_SERVER_PORT); connectionState = NeedToSendClientInformation; wasConnecting=true; } @@ -131,19 +133,27 @@ void YOGClient::update() { Uint8 type = message->getMessageType(); //This receives the server information - if(type==MNetSendServerInformation) - { + if(type==MNetSendServerInformation) + { + if (connectionState != WaitingForServerInformation) { disconnect(); return; } shared_ptr info = static_pointer_cast(message); + if (info->getNetVersion() != NET_PROTOCOL_VERSION) { + loginState = YOGClientVersionTooOld; + disconnect(); + sendToListeners(std::make_shared(loginState)); + return; + } loginPolicy = info->getLoginPolicy(); gamePolicy = info->getGamePolicy(); playerID = info->getPlayerID(); - shared_ptr event(new YOGConnectedEvent); - sendToListeners(event); - connectionState = WaitingForLoginInformation; + connectionState = WaitingForLoginInformation; + shared_ptr event(new YOGConnectedEvent); + sendToListeners(event); } //This receives a login acceptance message - if(type==MNetLoginSuccessful) - { + if(type==MNetLoginSuccessful) + { + if (connectionState != WaitingForLoginReply) { disconnect(); return; } shared_ptr info = static_pointer_cast(message); connectionState = ClientOnStandby; loginState = YOGLoginSuccessful; @@ -156,14 +166,16 @@ void YOGClient::update() if(type==MNetRefuseLogin) { shared_ptr info = static_pointer_cast(message); - connectionState = WaitingForLoginInformation; - loginState = info->getRefusalReason(); - shared_ptr event(new YOGLoginRefusedEvent(info->getRefusalReason())); + connectionState = WaitingForLoginInformation; + loginState = info->getRefusalReason(); + if (loginState == YOGClientVersionTooOld) disconnect(); + shared_ptr event(new YOGLoginRefusedEvent(info->getRefusalReason())); sendToListeners(event); } //This receives a registration acceptance message - if(type==MNetRegistrationAccepted) - { + if(type==MNetRegistrationAccepted) + { + if (connectionState != WaitingForRegistrationReply) { disconnect(); return; } shared_ptr info = static_pointer_cast(message); connectionState = ClientOnStandby; loginState = YOGLoginSuccessful; @@ -176,9 +188,10 @@ void YOGClient::update() if(type==MNetRegistrationRefused) { shared_ptr info = static_pointer_cast(message); - connectionState = WaitingForLoginInformation; - loginState = info->getRefusalReason(); - shared_ptr event(new YOGLoginRefusedEvent(info->getRefusalReason())); + connectionState = WaitingForLoginInformation; + loginState = info->getRefusalReason(); + if (loginState == YOGClientVersionTooOld) disconnect(); + shared_ptr event(new YOGLoginRefusedEvent(info->getRefusalReason())); sendToListeners(event); } ///This receives a game list update message @@ -366,7 +379,9 @@ void YOGClient::update() message = nc.getMessage(); } - if(gameConnection) + // Keep router orders queued while the host cooperatively initializes its + // engine. Dropping them here would desynchronize peers with different load times. + if(gameConnection && (!joinedGame || !joinedGame->isWaitingForEngine())) { shared_ptr message = gameConnection->getMessage(); while(message) @@ -438,6 +453,7 @@ YOGPlayerID YOGClient::getPlayerID() const void YOGClient::attemptLogin(const std::string& nusername, const std::string& password) { + if (connectionState != WaitingForLoginInformation) return; username = nusername; shared_ptr message(new NetAttemptLogin(username, password)); nc.sendMessage(message); @@ -447,6 +463,7 @@ void YOGClient::attemptLogin(const std::string& nusername, const std::string& pa void YOGClient::attemptRegistration(const std::string& nusername, const std::string& password) { + if (connectionState != WaitingForLoginInformation) return; username = nusername; shared_ptr message(new NetRegistrationRequest(username, password)); nc.sendMessage(message); diff --git a/src/yog/YOGClientBringup.cpp b/src/yog/YOGClientBringup.cpp deleted file mode 100644 index 75cd1357b..000000000 --- a/src/yog/YOGClientBringup.cpp +++ /dev/null @@ -1,70 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// Copyright (C) 2001-2004 Stephane Magnenat & Luc-Olivier de Charrière - -#include "YOGClientBringup.h" -#include "YOGClientGameListManager.h" -#include -#include - -namespace -{ - /// Wall-clock ceiling for a blocking LAN bring-up wait. The in-process - /// loopback server normally replies within a few milliseconds; this only - /// fires when the handshake is wedged or the connection has silently died. - constexpr Uint32 HANDSHAKE_TIMEOUT_MS = 10000; - - /// Idle between client.update() polls so a blocking bring-up wait does not - /// peg a CPU core while it holds the menu thread. - constexpr Uint32 HANDSHAKE_POLL_DELAY_MS = 50; - - /// Shared poll skeleton for the bring-up waits. Calls client.update() - /// repeatedly until `done()` holds (-> Reached), an early failure predicate - /// fires, the connection drops, or HANDSHAKE_TIMEOUT_MS elapses (-> Failed). - /// `earlyFail` may be null when there is no condition-specific failure to - /// detect beyond a dropped connection. - LANBringup::Result pumpClient(YOGClient& client, - const std::function& done, - const std::function& earlyFail) - { - const Uint32 start = SDL_GetTicks(); - while (!done()) - { - client.update(); - if (done()) - break; - if (earlyFail && earlyFail()) - return LANBringup::Result::Failed; - // A dropped connection leaves the NetConnection neither connecting - // nor connected; without this check the wait could never terminate. - if (!client.isConnecting() && !client.isConnected()) - return LANBringup::Result::Failed; - if (SDL_GetTicks() - start >= HANDSHAKE_TIMEOUT_MS) - return LANBringup::Result::Failed; - SDL_Delay(HANDSHAKE_POLL_DELAY_MS); - } - return LANBringup::Result::Reached; - } -} - -namespace LANBringup -{ - Result waitForConnectionState(YOGClient& client, YOGClient::ConnectionState target) - { - return pumpClient(client, - [&]() { return client.getConnectionState() == target; }, - [&]() { - // Login refused: the server returned us to - // WaitingForLoginInformation instead of advancing to - // ClientOnStandby. Only meaningful while awaiting standby. - return target == YOGClient::ClientOnStandby && - client.getConnectionState() == YOGClient::WaitingForLoginInformation; - }); - } - - Result waitForGameList(YOGClient& client) - { - return pumpClient(client, - [&]() { return client.getGameListManager()->getGameList().size() != 0; }, - nullptr); - } -} diff --git a/src/yog/YOGClientBringup.h b/src/yog/YOGClientBringup.h deleted file mode 100644 index 31705b191..000000000 --- a/src/yog/YOGClientBringup.h +++ /dev/null @@ -1,41 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// Copyright (C) 2001-2004 Stephane Magnenat & Luc-Olivier de Charrière - -#pragma once - -#include "YOGClient.h" - -/// Blocking bring-up helpers shared by the synchronous LAN menu screens -/// (LANMenuScreen host path, LANFindScreen connect path). They pump -/// YOGClient::update() on the menu thread until a target condition is reached. -/// -/// Each replaces a raw `while (state != target) client->update();` busy-spin -/// that had no timeout, no sleep, and no failure exit, and therefore hung the -/// menu thread forever (pegging a CPU core) whenever the YOG handshake failed -/// (BH-110, BH-120). The helpers always terminate: they bail on a dropped -/// connection, on a login refusal, or after a fixed wall-clock timeout, and -/// they sleep between polls so the wait does not peg a core. -namespace LANBringup -{ - /// Outcome of a blocking bring-up wait. `Reached` means the awaited - /// condition was satisfied; `Failed` means the wait gave up (dropped - /// connection, login refused, or timeout) instead of hanging. The caller - /// is expected to surface a "can't connect" message on `Failed`. - enum class Result { Reached, Failed }; - - /// Pumps client.update() until the connection reaches `target`. - /// - /// Returns `Failed` (rather than spinning forever) when: - /// - the connection drops (NetConnection is neither connecting nor - /// connected), or - /// - the login is refused: while awaiting ClientOnStandby, the server - /// bounces the client back to WaitingForLoginInformation instead of - /// advancing (MNetRefuseLogin in YOGClient::update), or - /// - the fixed handshake timeout elapses. - Result waitForConnectionState(YOGClient& client, YOGClient::ConnectionState target); - - /// Pumps client.update() until the client's game list is non-empty. - /// Returns `Failed` on a dropped connection or after the handshake timeout - /// (e.g. a host that publishes no games), instead of hanging. - Result waitForGameList(YOGClient& client); -} diff --git a/src/yog/YOGClientDownloadingMapScreen.cpp b/src/yog/YOGClientDownloadingMapScreen.cpp index d8b47b895..2f9fce4d2 100644 --- a/src/yog/YOGClientDownloadingMapScreen.cpp +++ b/src/yog/YOGClientDownloadingMapScreen.cpp @@ -7,7 +7,6 @@ #include "GlobalContainer.h" #include #include "GUIMapPreview.h" -#include "GUIMessageBox.h" #include #include #include "MapHeader.h" @@ -16,11 +15,13 @@ #include "YOGClient.h" #include "YOGClientDownloadingMapScreen.h" #include "YOGClientDownloadableMapList.h" +#include +#include "MessageScreen.h" using namespace GAGCore; -YOGClientDownloadingMapScreen::YOGClientDownloadingMapScreen(std::shared_ptr client, const YOGDownloadableMapInfo& info) - : info(info), client(client), downloader(client) +YOGClientDownloadingMapScreen::YOGClientDownloadingMapScreen(ScreenStack& screens, std::shared_ptr client, const YOGDownloadableMapInfo& info) + : screens(screens), info(info), client(client), downloader(client) { addWidget(new Text(0, 10, ALIGN_FILL, ALIGN_SCREEN_CENTERED, "menu", Toolkit::getStringTable()->getString("[downloading map]"))); addWidget(new TextButton(440, 420, 180, 40, ALIGN_SCREEN_CENTERED, ALIGN_SCREEN_CENTERED, "menu", Toolkit::getStringTable()->getString("[Cancel]"), CANCEL, 27)); @@ -57,6 +58,8 @@ YOGClientDownloadingMapScreen::YOGClientDownloadingMapScreen(std::shared_ptrisConnected()) { - GAGGUI::MessageBox(globalContainer->gfx, "standard", GAGGUI::MB_ONEBUTTON, Toolkit::getStringTable()->getString("[Map download failure: lost connection]"), Toolkit::getStringTable()->getString("[ok]")); - endExecute(CONNECTIONLOST); + screens.push(std::make_unique(Toolkit::getStringTable()->getString("[Map download failure: lost connection]"), + std::vector{Toolkit::getStringTable()->getString("[ok]")}), + [this](Screen&, int) { endExecute(CONNECTIONLOST); }); + return; } downloadStatus->visible = false; @@ -107,4 +112,3 @@ void YOGClientDownloadingMapScreen::onTimer(Uint32 tick) } } } - diff --git a/src/yog/YOGClientDownloadingMapScreen.h b/src/yog/YOGClientDownloadingMapScreen.h index 64679aad3..cb8bf8803 100644 --- a/src/yog/YOGClientDownloadingMapScreen.h +++ b/src/yog/YOGClientDownloadingMapScreen.h @@ -20,6 +20,7 @@ namespace GAGGUI class Widget; class List; class ProgressBar; + class ScreenStack; } class YOGClient; @@ -33,7 +34,8 @@ class YOGClientDownloadingMapScreen : public Glob2Screen public: /// Constructor - YOGClientDownloadingMapScreen(std::shared_ptr client, const YOGDownloadableMapInfo& info); + YOGClientDownloadingMapScreen(ScreenStack& screens, std::shared_ptr client, const YOGDownloadableMapInfo& info); + ~YOGClientDownloadingMapScreen() override; ///Responds to widget events void onAction(Widget *source, Action action, int par1, int par2); @@ -47,6 +49,7 @@ class YOGClientDownloadingMapScreen : public Glob2Screen FINISHED, }; private: + ScreenStack& screens; YOGDownloadableMapInfo info; MapPreview* preview; std::shared_ptr client; @@ -59,5 +62,3 @@ class YOGClientDownloadingMapScreen : public Glob2Screen - - diff --git a/src/yog/YOGClientGameConnectionDialog.cpp b/src/yog/YOGClientGameConnectionDialog.cpp index 35469ed0c..7fc80209b 100644 --- a/src/yog/YOGClientGameConnectionDialog.cpp +++ b/src/yog/YOGClientGameConnectionDialog.cpp @@ -5,16 +5,18 @@ #include "GUIText.h" #include "StringTable.h" #include "Toolkit.h" +#include using namespace GAGCore; using namespace GAGGUI; -YOGClientGameConnectionDialog::YOGClientGameConnectionDialog(GraphicContext *parentCtx, std::shared_ptr game) - : OverlayScreen(parentCtx, 200, 100), parentCtx(parentCtx), game(game) +YOGClientGameConnectionDialog::YOGClientGameConnectionDialog(std::shared_ptr game) + : game(game) { - addWidget(new Text(0, 20, ALIGN_FILL, ALIGN_LEFT, "standard", Toolkit::getStringTable()->getString("[connecting to game]"))); + addWidget(new Text(0, 200, ALIGN_FILL, ALIGN_SCREEN_CENTERED, "standard", Toolkit::getStringTable()->getString("[connecting to game]"))); + addWidget(new TextButton(240, 280, 160, 35, ALIGN_SCREEN_CENTERED, ALIGN_SCREEN_CENTERED, "standard", + Toolkit::getStringTable()->getString("[Cancel]"), Cancelled, 27)); game->addEventListener(this); - dispatchInit(); } @@ -26,13 +28,7 @@ YOGClientGameConnectionDialog::~YOGClientGameConnectionDialog() void YOGClientGameConnectionDialog::onAction(Widget *source, Action action, int par1, int par2) { - -} - - -void YOGClientGameConnectionDialog::execute() -{ - executeModal(parentCtx); + if(action == BUTTON_RELEASED || action == BUTTON_SHORTCUT) endExecute(Cancelled); } @@ -46,7 +42,7 @@ void YOGClientGameConnectionDialog::updateGame() { game->update(); if(game->isFullyInGame()) - endValue = Success; + endExecute(Success); } @@ -56,10 +52,10 @@ void YOGClientGameConnectionDialog::handleMultiplayerGameEvent(std::shared_ptrgetEventType(); if(type == MGEGameRefused) { - endValue = Failed; + endExecute(Failed); } else if(type == MGEServerDisconnected) { - endValue = Failed; + endExecute(Failed); } } diff --git a/src/yog/YOGClientGameConnectionDialog.h b/src/yog/YOGClientGameConnectionDialog.h index 59a62e1a0..6f0296445 100644 --- a/src/yog/YOGClientGameConnectionDialog.h +++ b/src/yog/YOGClientGameConnectionDialog.h @@ -20,24 +20,21 @@ namespace GAGCore class DrawableSurface; } -///This dialog shows progress of the fertility computation -class YOGClientGameConnectionDialog:public GAGGUI::OverlayScreen, public MultiplayerGameEventListener +///Scheduled progress screen while joining a multiplayer game. +class YOGClientGameConnectionDialog:public GAGGUI::Screen, public MultiplayerGameEventListener { public: - YOGClientGameConnectionDialog(GAGCore::GraphicContext *parentCtx, std::shared_ptr game); + YOGClientGameConnectionDialog(std::shared_ptr game); virtual ~YOGClientGameConnectionDialog(); virtual void onAction(GAGGUI::Widget *source, GAGGUI::Action action, int par1, int par2); virtual void onTimer(Uint32 tick); - using OverlayScreen::execute; // keep base 2-arg execute visible alongside our no-arg overload - ///This screen is modal, this executes it - void execute(); - ///These are the possible end values enum EndValue { Success, Failed, + Cancelled, }; private: ///This function updates the multiplayer game @@ -45,8 +42,6 @@ class YOGClientGameConnectionDialog:public GAGGUI::OverlayScreen, public Multipl ///This handles an event from the multiplayer game void handleMultiplayerGameEvent(std::shared_ptr event); - GAGCore::GraphicContext *parentCtx; std::shared_ptr game; }; - diff --git a/src/yog/YOGClientLobbyScreen.cpp b/src/yog/YOGClientLobbyScreen.cpp index 624a1d508..ed5ab9c60 100644 --- a/src/yog/YOGClientLobbyScreen.cpp +++ b/src/yog/YOGClientLobbyScreen.cpp @@ -6,7 +6,6 @@ #include "GlobalContainer.h" #include #include -#include "GUIMessageBox.h" #include #include #include @@ -24,6 +23,8 @@ #include "YOGClientPlayerListManager.h" #include "YOGClientGameConnectionDialog.h" #include "YOGMessage.h" +#include +#include "MessageScreen.h" YOGClientPlayerList::YOGClientPlayerList(int x, int y, int w, int h, Uint32 hAlign, Uint32 vAlign, const std::string &font) : List(x, y, w, h, hAlign, vAlign, font) @@ -75,8 +76,8 @@ void YOGClientPlayerList::drawItem(int x, int y, size_t element) -YOGClientLobbyScreen::YOGClientLobbyScreen(TabScreen* parent, std::shared_ptr client) - : TabScreenWindow(parent, Toolkit::getStringTable()->getString("[Lobby]")), client(client) +YOGClientLobbyScreen::YOGClientLobbyScreen(TabScreen* parent, ScreenStack& screens, std::shared_ptr client) + : TabScreenWindow(parent, Toolkit::getStringTable()->getString("[Lobby]")), client(client), screens(screens) { addWidget(new Text(0, 10, ALIGN_FILL, ALIGN_TOP, "menu", Toolkit::getStringTable()->getString("[yog]"))); @@ -118,6 +119,8 @@ YOGClientLobbyScreen::YOGClientLobbyScreen(TabScreen* parent, std::shared_ptrsetMultiplayerGame({}); ircChat->removeTextMessageListener(this); ircChat->stopIRC(); @@ -221,6 +224,7 @@ void YOGClientLobbyScreen::onTimer(Uint32 tick) else if(game->getGameCreationState() == YOGCreateRefusalUnknown) receiveInternalMessage("Game was refused by server"); } + ownedGameScreen.reset(); client->setMultiplayerGame(std::shared_ptr()); gameScreen=-1; updateButtonVisibility(); @@ -243,16 +247,19 @@ void YOGClientLobbyScreen::handleYOGClientEvent(std::shared_ptr Uint8 type = event->getEventType(); if(type == YEConnectionLost) { - GAGGUI::MessageBox(globalContainer->gfx, "standard", GAGGUI::MB_ONEBUTTON, Toolkit::getStringTable()->getString("[YESTS_CONNECTION_LOST]"), Toolkit::getStringTable()->getString("[ok]")); - parent->completeEndExecute(ConnectionLost); + screens.push(std::make_unique(Toolkit::getStringTable()->getString("[YESTS_CONNECTION_LOST]"), + std::vector{Toolkit::getStringTable()->getString("[ok]")}), + [this](Screen&, int) { parent->completeEndExecute(ConnectionLost); }); } else if(type == YEPlayerBanned) { - GAGGUI::MessageBox(globalContainer->gfx, "standard", GAGGUI::MB_ONEBUTTON, Toolkit::getStringTable()->getString("[Your username was banned]"), Toolkit::getStringTable()->getString("[ok]")); + screens.push(std::make_unique(Toolkit::getStringTable()->getString("[Your username was banned]"), + std::vector{Toolkit::getStringTable()->getString("[ok]")})); } else if(type == YEIPBanned) { - GAGGUI::MessageBox(globalContainer->gfx, "standard", GAGGUI::MB_ONEBUTTON, Toolkit::getStringTable()->getString("[Your IP address was temporarily banned]"), Toolkit::getStringTable()->getString("[ok]")); + screens.push(std::make_unique(Toolkit::getStringTable()->getString("[Your IP address was temporarily banned]"), + std::vector{Toolkit::getStringTable()->getString("[ok]")})); } } @@ -307,23 +314,24 @@ void YOGClientLobbyScreen::playerListUpdated() void YOGClientLobbyScreen::hostGame() { - ChooseMapScreen cms("maps", "map", false, "games", "game", false); - int rc = cms.execute(globalContainer->gfx, 40); - if(rc == ChooseMapScreen::OK) - { - std::shared_ptr game(new MultiplayerGame(client)); - client->setMultiplayerGame(game); - std::string name = FormattableString(Toolkit::getStringTable()->getString("[%0's game]")).arg(client->getUsername()); - game->createNewGame(name); - - game->setMapHeader(cms.getMapHeader()); - MultiplayerGameScreen* mgs = new MultiplayerGameScreen(parent, game, client, ircChat); - gameScreen = mgs->getTabNumber(); - updateButtonVisibility(); - parent->activateGroup(gameScreen); - } - else if(rc == -1) - endExecute(-1); + screens.push(std::make_unique("maps", "map", false, "games", "game", false), + [this](Screen& selection, int rc) { + if(rc != ChooseMapScreen::OK) return; + auto game = std::make_shared(client); + client->setMultiplayerGame(game); + std::string name = FormattableString(Toolkit::getStringTable()->getString("[%0's game]")).arg(client->getUsername()); + game->createNewGame(name); + game->setMapHeader(static_cast(selection).getMapHeader()); + showGame(game); + }); +} + +void YOGClientLobbyScreen::showGame(std::shared_ptr game) +{ + ownedGameScreen = std::make_unique(parent, screens, game, client, ircChat); + gameScreen = ownedGameScreen->getTabNumber(); + updateButtonVisibility(); + parent->activateGroup(gameScreen); } @@ -345,16 +353,11 @@ void YOGClientLobbyScreen::joinGame() } } game->joinGame(id); - YOGClientGameConnectionDialog dialog(globalContainer->gfx, game); - dialog.execute(); - int result = dialog.endValue; - if(result == YOGClientGameConnectionDialog::Success) - { - MultiplayerGameScreen* mgs = new MultiplayerGameScreen(parent, game, client, ircChat); - gameScreen = mgs->getTabNumber(); - updateButtonVisibility(); - parent->activateGroup(gameScreen); - } + screens.push(std::make_unique(game), + [this, game](Screen&, int result) { + if(result == YOGClientGameConnectionDialog::Success) showGame(game); + else { game->leaveGame(); client->setMultiplayerGame({}); } + }); } } diff --git a/src/yog/YOGClientLobbyScreen.h b/src/yog/YOGClientLobbyScreen.h index 95cf17fbd..219e11473 100644 --- a/src/yog/YOGClientLobbyScreen.h +++ b/src/yog/YOGClientLobbyScreen.h @@ -20,6 +20,7 @@ namespace GAGGUI class TextButton; class TabScreen; class Widget; + class ScreenStack; } class YOGClient; @@ -69,7 +70,7 @@ class YOGClientLobbyScreen : public TabScreenWindow, public YOGClientEventListen { public: ///This takes a YOGClient. The client must be logged in when this is called. - YOGClientLobbyScreen(TabScreen* parent, std::shared_ptr client); + YOGClientLobbyScreen(TabScreen* parent, ScreenStack& screens, std::shared_ptr client); virtual ~YOGClientLobbyScreen(); @@ -113,6 +114,7 @@ class YOGClientLobbyScreen : public TabScreenWindow, public YOGClientEventListen void hostGame(); ///This launches the menu to join a game void joinGame(); + void showGame(std::shared_ptr game); ///This updates the list of games void updateGameList(); ///This updates the list of players @@ -141,6 +143,7 @@ class YOGClientLobbyScreen : public TabScreenWindow, public YOGClientEventListen std::shared_ptr ircChat; int gameScreen; + ScreenStack& screens; + std::unique_ptr ownedGameScreen; }; - diff --git a/src/yog/YOGClientMapDownloadScreen.cpp b/src/yog/YOGClientMapDownloadScreen.cpp index 039294e1f..b433f4574 100644 --- a/src/yog/YOGClientMapDownloadScreen.cpp +++ b/src/yog/YOGClientMapDownloadScreen.cpp @@ -19,11 +19,12 @@ #include "YOGClientDownloadableMapList.h" #include "YOGClientDownloadingMapScreen.h" #include "YOGClientRatedMapList.h" +#include using namespace GAGCore; -YOGClientMapDownloadScreen::YOGClientMapDownloadScreen(TabScreen* parent, std::shared_ptr client) - : TabScreenWindow(parent, Toolkit::getStringTable()->getString("[Download Maps]")), client(client) +YOGClientMapDownloadScreen::YOGClientMapDownloadScreen(TabScreen* parent, ScreenStack& screens, std::shared_ptr client) + : TabScreenWindow(parent, Toolkit::getStringTable()->getString("[Download Maps]")), client(client), screens(screens) { addWidget(new Text(0, 10, ALIGN_FILL, ALIGN_TOP, "menu", Toolkit::getStringTable()->getString("[Download Maps]"))); @@ -111,19 +112,13 @@ void YOGClientMapDownloadScreen::onAction(Widget *source, Action action, int par } else if(par1==ADDMAP) { - ChooseMapScreen cms("maps", "map", false); - int rc = cms.execute(globalContainer->gfx, 40); - if(rc == -1) - { - endExecute(-1); - parent->completeEndExecute(-1); - } - else if(rc == ChooseMapScreen::OK) - { - YOGClientMapUploadScreen upload(client, cms.getMapHeader().getFileName()); - upload.execute(globalContainer->gfx, 40); - requestMaps(); - } + screens.push(std::make_unique("maps", "map", false), + [this](Screen& selection, int rc) { + if(rc != ChooseMapScreen::OK) return; + const auto file = static_cast(selection).getMapHeader().getFileName(); + screens.push(std::make_unique(screens, client, file), + [this](Screen&, int) { requestMaps(); }); + }); } else if (par1==REFRESHMAPLIST) { @@ -133,17 +128,8 @@ void YOGClientMapDownloadScreen::onAction(Widget *source, Action action, int par { if(mapValid) { - YOGClientDownloadingMapScreen screen(client, client->getDownloadableMapList()->getMap(mapList->get())); - int rc = screen.execute(globalContainer->gfx, 40); - if(rc == -1) - { - endExecute(-1); - parent->completeEndExecute(-1); - } - else if(rc == YOGClientDownloadingMapScreen::FINISHED) - { - - } + screens.push(std::make_unique(screens, client, + client->getDownloadableMapList()->getMap(mapList->get()))); } } else if (par1==SUBMITRATING) diff --git a/src/yog/YOGClientMapDownloadScreen.h b/src/yog/YOGClientMapDownloadScreen.h index eaf6044e2..b8b2b62eb 100644 --- a/src/yog/YOGClientMapDownloadScreen.h +++ b/src/yog/YOGClientMapDownloadScreen.h @@ -16,6 +16,7 @@ namespace GAGGUI class Widget; class Number; class MultiTextButton; + class ScreenStack; } class YOGClient; @@ -28,7 +29,7 @@ using namespace GAGGUI; class YOGClientMapDownloadScreen : public TabScreenWindow, public YOGClientDownloadableMapListener { public: - YOGClientMapDownloadScreen(TabScreen* parent, std::shared_ptr client); + YOGClientMapDownloadScreen(TabScreen* parent, ScreenStack& screens, std::shared_ptr client); ~YOGClientMapDownloadScreen(); ///Responds to timer events virtual void onTimer(Uint32 tick); @@ -64,6 +65,7 @@ class YOGClientMapDownloadScreen : public TabScreenWindow, public YOGClientDownl std::shared_ptr client; + ScreenStack& screens; List* mapList; //! The widget that will show a preview of the selection map MapPreview *mapPreview; @@ -115,4 +117,3 @@ class MapListSorter SortMethod sortMethod; }; - diff --git a/src/yog/YOGClientMapUploadScreen.cpp b/src/yog/YOGClientMapUploadScreen.cpp index 7afc40545..0fa846ca5 100644 --- a/src/yog/YOGClientMapUploadScreen.cpp +++ b/src/yog/YOGClientMapUploadScreen.cpp @@ -6,7 +6,6 @@ #include "GlobalContainer.h" #include #include "GUIMapPreview.h" -#include "GUIMessageBox.h" #include #include #include @@ -15,15 +14,17 @@ #include "Toolkit.h" #include "YOGClient.h" #include "YOGClientMapUploadScreen.h" +#include +#include "MessageScreen.h" using namespace GAGCore; -YOGClientMapUploadScreen::YOGClientMapUploadScreen(std::shared_ptr client, const std::string mapFile) - : client(client), uploader(client), mapFile(mapFile) +YOGClientMapUploadScreen::YOGClientMapUploadScreen(ScreenStack& screens, std::shared_ptr client, const std::string mapFile) + : screens(screens), client(client), uploader(client), mapFile(mapFile) { addWidget(new Text(0, 10, ALIGN_FILL, ALIGN_SCREEN_CENTERED, "menu", Toolkit::getStringTable()->getString("[Upload Map]"))); addWidget(new TextButton(440, 420, 180, 40, ALIGN_SCREEN_CENTERED, ALIGN_SCREEN_CENTERED, "menu", Toolkit::getStringTable()->getString("[Cancel]"), CANCEL, 27)); - addWidget(new TextButton(440, 360, 180, 40, ALIGN_SCREEN_CENTERED, ALIGN_SCREEN_CENTERED, "menu", Toolkit::getStringTable()->getString("[Upload Map]"), UPLOAD, 27)); + addWidget(new TextButton(440, 360, 180, 40, ALIGN_SCREEN_CENTERED, ALIGN_SCREEN_CENTERED, "menu", Toolkit::getStringTable()->getString("[Upload Map]"), UPLOAD, 13)); preview = new MapPreview(20, 60, ALIGN_SCREEN_CENTERED, ALIGN_SCREEN_CENTERED); addWidget(preview); @@ -67,6 +68,8 @@ YOGClientMapUploadScreen::YOGClientMapUploadScreen(std::shared_ptr cl +YOGClientMapUploadScreen::~YOGClientMapUploadScreen() { uploader.cancelUpload(); } + void YOGClientMapUploadScreen::onAction(Widget *source, Action action, int par1, int par2) { if ((action==BUTTON_RELEASED) || (action==BUTTON_SHORTCUT)) @@ -97,14 +100,21 @@ void YOGClientMapUploadScreen::onAction(Widget *source, Action action, int par1, +void YOGClientMapUploadScreen::showError(const char* key, int result) +{ + screens.push(std::make_unique(Toolkit::getStringTable()->getString(key), + std::vector{Toolkit::getStringTable()->getString("[ok]")}), + [this, result](Screen&, int) { endExecute(result); }); +} + void YOGClientMapUploadScreen::onTimer(Uint32 tick) { client->update(); uploader.update(); if(!client->isConnected()) { - GAGGUI::MessageBox(globalContainer->gfx, "standard", GAGGUI::MB_ONEBUTTON, Toolkit::getStringTable()->getString("[Map upload failure: connection lost]"), Toolkit::getStringTable()->getString("[ok]")); - endExecute(CONNECTIONLOST); + showError("[Map upload failure: connection lost]", CONNECTIONLOST); + return; } uploadStatus->visible = false; @@ -114,13 +124,13 @@ void YOGClientMapUploadScreen::onTimer(Uint32 tick) { if(uploader.getRefusalReason() == YOGMapUploadReasonMapNameAlreadyExists) { - GAGGUI::MessageBox(globalContainer->gfx, "standard", GAGGUI::MB_ONEBUTTON, Toolkit::getStringTable()->getString("[Map upload failure: map name in use]"), Toolkit::getStringTable()->getString("[ok]")); + showError("[Map upload failure: map name in use]", UPLOADFAILED); } else { - GAGGUI::MessageBox(globalContainer->gfx, "standard", GAGGUI::MB_ONEBUTTON, Toolkit::getStringTable()->getString("[Map upload failure: unknown reason]"), Toolkit::getStringTable()->getString("[ok]")); + showError("[Map upload failure: unknown reason]", UPLOADFAILED); } - endExecute(UPLOADFAILED); + return; } else if(uploader.getUploadingState() == YOGClientMapUploader::WaitingForUploadReply) { @@ -138,5 +148,3 @@ void YOGClientMapUploadScreen::onTimer(Uint32 tick) } } } - - diff --git a/src/yog/YOGClientMapUploadScreen.h b/src/yog/YOGClientMapUploadScreen.h index c11181e81..6fc02f107 100644 --- a/src/yog/YOGClientMapUploadScreen.h +++ b/src/yog/YOGClientMapUploadScreen.h @@ -18,6 +18,7 @@ namespace GAGGUI class Widget; class List; class ProgressBar; + class ScreenStack; } class YOGClient; @@ -32,7 +33,8 @@ class YOGClientMapUploadScreen : public Glob2Screen public: /// Constructor - YOGClientMapUploadScreen(std::shared_ptr client, const std::string mapFile); + YOGClientMapUploadScreen(ScreenStack& screens, std::shared_ptr client, const std::string mapFile); + ~YOGClientMapUploadScreen() override; ///Responds to widget events void onAction(Widget *source, Action action, int par1, int par2); @@ -48,6 +50,8 @@ class YOGClientMapUploadScreen : public Glob2Screen CONNECTIONLOST, }; private: + void showError(const char* key, int result); + ScreenStack& screens; MapPreview* preview; std::shared_ptr client; YOGClientMapUploader uploader; @@ -61,4 +65,3 @@ class YOGClientMapUploadScreen : public Glob2Screen std::string mapFile; bool isUploading; }; - diff --git a/src/yog/YOGConsts.h b/src/yog/YOGConsts.h index b3b9739f1..db41ccb1e 100644 --- a/src/yog/YOGConsts.h +++ b/src/yog/YOGConsts.h @@ -92,6 +92,7 @@ enum YOGServerGameCreateRefusalReason { ///This represents internally an unknown reason YOGCreateRefusalUnknown, + YOGCreateRefusalNoRouter, }; ///This is used to represent the types of messages that can be sent through YOG diff --git a/src/yog/YOGLoginScreen.cpp b/src/yog/YOGLoginScreen.cpp index c4dbae5df..d03e851ba 100644 --- a/src/yog/YOGLoginScreen.cpp +++ b/src/yog/YOGLoginScreen.cpp @@ -20,11 +20,26 @@ #include "YOGClientOptionsScreen.h" #include "YOGLoginScreen.h" #include "YOGRegisterScreen.h" +#include using std::static_pointer_cast; -YOGLoginScreen::YOGLoginScreen(std::shared_ptr client) - : YOGConnectionScreen(client) +namespace { +// Tabs must outlive their asynchronous children and be destroyed before their +// parent removes the remaining widgets. No tab lives on a modal call stack. +class YOGSessionScreen final : public Glob2TabScreen { + YOGClientLobbyScreen lobby; + YOGClientOptionsScreen options; + YOGClientMapDownloadScreen maps; +public: + YOGSessionScreen(ScreenStack& screens, std::shared_ptr client) + : Glob2TabScreen(true), lobby(this, screens, client), options(this, client), + maps(this, screens, client) {} +}; +} + +YOGLoginScreen::YOGLoginScreen(ScreenStack& screens, std::shared_ptr client) + : YOGConnectionScreen(client), screens(screens) { addWidget(new TextButton(440, 420, 180, 40, ALIGN_SCREEN_CENTERED, ALIGN_SCREEN_CENTERED, "menu", Toolkit::getStringTable()->getString("[Cancel]"), CANCEL, 27)); addWidget(new TextButton(440, 360, 180, 40, ALIGN_SCREEN_CENTERED, ALIGN_SCREEN_CENTERED, "menu", Toolkit::getStringTable()->getString("[login]"), LOGIN, 13)); @@ -83,17 +98,11 @@ void YOGLoginScreen::onAction(Widget *source, Action action, int par1, int par2) else if (par1==REGISTER) { client->removeEventListener(this); - YOGRegisterScreen screen(client); - int rc = screen.execute(globalContainer->gfx, 40); - client->addEventListener(this); - if(rc == -1) - { - endExecute(-1); - } - else if(rc == YOGRegisterScreen::Connected) - { - showLobby(); - } + screens.push(std::make_unique(client), [this](Screen&, int rc) { + client->addEventListener(this); + if(rc == -1) endExecute(-1); + else if(rc == YOGRegisterScreen::Connected) showLobby(); + }); } } if (action==TEXT_ACTIVATED) @@ -129,6 +138,7 @@ void YOGLoginScreen::handleYOGClientEvent(std::shared_ptr event) } else if(type == YEConnectionLost) { + lobbyRequested = false; animation->visible=false; statusText->setText(Toolkit::getStringTable()->getString("[YESTS_CONNECTION_LOST]")); } @@ -156,7 +166,7 @@ void YOGLoginScreen::handleYOGClientEvent(std::shared_ptr event) } else if(reason == YOGClientVersionTooOld) { - statusText->setText(Toolkit::getStringTable()->getString("[YESTS_CONNECTION_REFUSED_PROTOCOL_TOO_OLD]")); + statusText->setText(Toolkit::getStringTable()->getString("[network release mismatch]")); } else if(reason == YOGAlreadyAuthenticated) { @@ -201,15 +211,20 @@ void YOGLoginScreen::submitLoginCredentials() void YOGLoginScreen::showLobby() { - Glob2TabScreen screen(true); - YOGClientLobbyScreen lobby(&screen, client); - YOGClientOptionsScreen options(&screen, client); - YOGClientMapDownloadScreen maps(&screen, client); - int rc = screen.execute(globalContainer->gfx, 40); - if(rc == YOGClientLobbyScreen::ConnectionLost) - endExecute(ConnectionLost); - else if(rc == -1) - endExecute(-1); - else - endExecute(LoggedIn); + lobbyRequested = true; +} + +void YOGLoginScreen::onTimer(Uint32 tick) +{ + YOGConnectionScreen::onTimer(tick); + if(!lobbyRequested) return; + lobbyRequested = false; + // Login acceptance arrives during listener iteration. Defer changing + // listeners and constructing lobby tabs until the network update returns. + client->removeEventListener(this); + screens.push(std::make_unique(screens, client), [this](Screen&, int rc) { + if(rc == YOGClientLobbyScreen::ConnectionLost) endExecute(ConnectionLost); + else if(rc == -1) endExecute(-1); + else endExecute(LoggedIn); + }); } diff --git a/src/yog/YOGLoginScreen.h b/src/yog/YOGLoginScreen.h index fb926f195..b26cd42b9 100644 --- a/src/yog/YOGLoginScreen.h +++ b/src/yog/YOGLoginScreen.h @@ -11,6 +11,7 @@ namespace GAGGUI class OnOffButton; class Text; class TextInput; + class ScreenStack; } ///This handles with connecting the user to YOG and logging them in. @@ -20,7 +21,7 @@ class YOGLoginScreen : public YOGConnectionScreen public: ///Construct with the given YOG client. ///The provided client should not yet be connected to YOG. - YOGLoginScreen(std::shared_ptr client); + YOGLoginScreen(GAGGUI::ScreenStack& screens, std::shared_ptr client); virtual ~YOGLoginScreen(); enum @@ -56,8 +57,11 @@ class YOGLoginScreen : public YOGConnectionScreen ///Show the lobby screen void showLobby(); + void onTimer(Uint32 tick) override; + bool lobbyRequested = false; TextInput *login, *password; OnOffButton *rememberYogPassword; Text *rememberYogPasswordText; + GAGGUI::ScreenStack& screens; }; diff --git a/src/yog/YOGRegisterScreen.cpp b/src/yog/YOGRegisterScreen.cpp index d16667ab5..c98813a4e 100644 --- a/src/yog/YOGRegisterScreen.cpp +++ b/src/yog/YOGRegisterScreen.cpp @@ -164,7 +164,7 @@ void YOGRegisterScreen::handleYOGClientEvent(std::shared_ptr eve } else if(reason == YOGClientVersionTooOld) { - statusText->setText(Toolkit::getStringTable()->getString("[YESTS_CONNECTION_REFUSED_PROTOCOL_TOO_OLD]")); + statusText->setText(Toolkit::getStringTable()->getString("[network release mismatch]")); } else if(reason == YOGAlreadyAuthenticated) { diff --git a/src/yog/YOGServer.cpp b/src/yog/YOGServer.cpp index 4269f345d..b56084b07 100644 --- a/src/yog/YOGServer.cpp +++ b/src/yog/YOGServer.cpp @@ -13,8 +13,8 @@ #include "YOGServerPlayer.h" #include "SDLCompat.h" -YOGServer::YOGServer(YOGLoginPolicy loginPolicy, YOGGamePolicy gamePolicy) - : loginPolicy(loginPolicy), gamePolicy(gamePolicy), administrator(this), playerInfos(this), routerManager(*this), router("localhost"), maps(this), scoreCalculator(this) +YOGServer::YOGServer(YOGLoginPolicy loginPolicy, YOGGamePolicy gamePolicy, bool embeddedRouter) + : loginPolicy(loginPolicy), gamePolicy(gamePolicy), administrator(this), playerInfos(this), routerManager(*this), router(embeddedRouter ? std::make_unique("localhost") : nullptr), maps(this), scoreCalculator(this) { isBroadcasting = false; nl.startListening(YOG_SERVER_PORT); @@ -99,7 +99,7 @@ void YOGServer::update() bannedIPs.update(); gameLog.update(); routerManager.update(); - router.update(); + if (router) router->update(); maps.update(); fileDistributionManager.update(); @@ -165,7 +165,7 @@ YOGGamePolicy YOGServer::getGamePolicy() const YOGLoginState YOGServer::verifyLoginInformation(const std::string& username, const std::string& password, const std::string& ip, Uint16 version) { - if(version < YOG_MIN_CLIENT_NET_PROTOCOL_VERSION) + if(version != NET_PROTOCOL_VERSION) return YOGClientVersionTooOld; if(loginPolicy == YOGAnonymousLogin) return YOGLoginSuccessful; @@ -199,7 +199,7 @@ YOGLoginState YOGServer::verifyLoginInformation(const std::string& username, con YOGLoginState YOGServer::registerInformation(const std::string& username, const std::string& password, const std::string& ip, Uint16 version) { - if(version < YOG_MIN_CLIENT_NET_PROTOCOL_VERSION) + if(version != NET_PROTOCOL_VERSION) return YOGClientVersionTooOld; if(loginPolicy == YOGAnonymousLogin) return YOGLoginSuccessful; @@ -275,8 +275,7 @@ YOGServerChatChannelManager& YOGServer::getChatChannelManager() YOGServerGameCreateRefusalReason YOGServer::canCreateNewGame(const std::string& game) { - //not implemented - return YOGCreateRefusalUnknown; + return routerManager.hasRouter() ? YOGCreateRefusalUnknown : YOGCreateRefusalNoRouter; } @@ -284,6 +283,8 @@ YOGServerGameCreateRefusalReason YOGServer::canCreateNewGame(const std::string& Uint16 YOGServer::createNewGame(const std::string& name) { + auto selectedRouter = routerManager.chooseYOGRouter(); + if (!selectedRouter) return 0; //choose the new game ID Uint16 newID=1; while(true) @@ -303,7 +304,7 @@ Uint16 YOGServer::createNewGame(const std::string& name) break; } Uint32 chatChannel = chatChannelManager.createNewChatChannel(); - std::string routerip = routerManager.chooseYOGRouter()->getIPAddress(); + std::string routerip = selectedRouter->getIPAddress(); if(routerip == "127.0.0.1") routerip = "YOGIP"; diff --git a/src/yog/YOGServer.h b/src/yog/YOGServer.h index a3a627977..21f5f8842 100644 --- a/src/yog/YOGServer.h +++ b/src/yog/YOGServer.h @@ -44,7 +44,7 @@ class YOGServer { public: ///Initiates the YOG Game Server and immediately begins listening on the YOG port. - YOGServer(YOGLoginPolicy loginPolicy, YOGGamePolicy gamePolicy); + YOGServer(YOGLoginPolicy loginPolicy, YOGGamePolicy gamePolicy, bool embeddedRouter = true); ///If the attempt to bind to the local port failed, this will be false bool isListening(); @@ -177,7 +177,7 @@ class YOGServer YOGServerBannedIPListManager bannedIPs; YOGServerGameLog gameLog; YOGServerRouterManager routerManager; - YOGServerRouter router; + std::unique_ptr router; YOGServerMapDatabank maps; YOGServerFileDistributionManager fileDistributionManager; YOGServerPlayerScoreCalculator scoreCalculator; diff --git a/src/yog/YOGServerPlayer.cpp b/src/yog/YOGServerPlayer.cpp index 7d5af782b..b59a31dd2 100644 --- a/src/yog/YOGServerPlayer.cpp +++ b/src/yog/YOGServerPlayer.cpp @@ -2,6 +2,7 @@ // Copyright (C) 2007 Bradley Arsenault #include "AuthMessages.h" +#include "Version.h" #include "FileTransferMessages.h" #include "GameCreateMessages.h" #include "GameHeaderMessages.h" @@ -64,12 +65,26 @@ void YOGServerPlayer::update() if(!message) return; Uint8 type = message->getMessageType(); + // Control messages cannot be used to skip or rewind admission. Lobby, + // room and file operations become available only after authentication. + const bool hello = type == MNetSendClientInformation; + const bool login = type == MNetAttemptLogin || type == MNetRegistrationRequest; + const bool allowed = type == MNetPingReply || + (hello && connectionState == WaitingForClientInformation) || + (login && connectionState == WaitingForLoginAttempt) || + (!hello && !login && connectionState == ClientOnStandby); + if (!allowed) { closeConnection(); return; } //This receives the client information if(type==MNetSendClientInformation) { shared_ptr info = static_pointer_cast(message); - netVersion = info->getNetVersion(); - connectionState = NeedToSendServerInformation; + netVersion = info->getNetVersion(); + if (netVersion != NET_PROTOCOL_VERSION) { + connection->sendMessage(std::make_shared(YOGClientVersionTooOld)); + connectionState = IncompatibleClient; + return; + } + connectionState = NeedToSendServerInformation; } //This receives a login attempt else if(type==MNetAttemptLogin) diff --git a/src/yog/YOGServerPlayer.h b/src/yog/YOGServerPlayer.h index f5576269e..08b255d77 100644 --- a/src/yog/YOGServerPlayer.h +++ b/src/yog/YOGServerPlayer.h @@ -69,6 +69,7 @@ class YOGServerPlayer { ///Means this is waiting for the client to send version information to the server. WaitingForClientInformation, + IncompatibleClient, ///Server information, such as the IRC server and server policies, needs to be sent NeedToSendServerInformation, ///Means its waiting for a login attempt by the client. diff --git a/src/yog/YOGServerRouterManager.cpp b/src/yog/YOGServerRouterManager.cpp index f32d2977e..88b5e7448 100644 --- a/src/yog/YOGServerRouterManager.cpp +++ b/src/yog/YOGServerRouterManager.cpp @@ -69,9 +69,14 @@ void YOGServerRouterManager::update() std::shared_ptr YOGServerRouterManager::chooseYOGRouter() { - n+=1; - if(n == (int)routers.size()) - n = 0; - return routers[n]; + if (routers.empty()) return {}; + n %= routers.size(); + auto selected = routers[n]; + n = (n + 1) % routers.size(); + return selected; } +bool YOGServerRouterManager::hasRouter() const +{ + return !routers.empty(); +} diff --git a/src/yog/YOGServerRouterManager.h b/src/yog/YOGServerRouterManager.h index c2490d9c3..52ec246bd 100644 --- a/src/yog/YOGServerRouterManager.h +++ b/src/yog/YOGServerRouterManager.h @@ -25,6 +25,7 @@ class YOGServerRouterManager ///This chooses a new yog router std::shared_ptr chooseYOGRouter(); + bool hasRouter() const; private: std::vector > routers; NetListener listener; diff --git a/test/CustomGameSetupHarness.cpp b/test/CustomGameSetupHarness.cpp index f7c8717e6..842781474 100644 --- a/test/CustomGameSetupHarness.cpp +++ b/test/CustomGameSetupHarness.cpp @@ -2,6 +2,7 @@ #include "AIImplementation.h" #include "AINames.h" #include "CustomGameScreen.h" +#include #include "CustomGameSetup.h" #include "CustomGamePreferences.h" #include "Engine.h" @@ -22,6 +23,7 @@ #include #include #include +#include #include GlobalContainer *globalContainer = nullptr; @@ -97,7 +99,8 @@ struct CustomGameSetupHarness if (write) files->remove(CustomGamePreferences::filename); if (write) { - CustomGameScreen screen; + GAGGUI::ScreenStack screens(*globalContainer->gfx); + CustomGameScreen screen(screens); assert(screen.validMap && screen.setup.capacity == 4); assert(screen.setup.setController(2, CustomGameSetup::Shared)); screen.setup.colonies[2].ai = AI::CORTEX; @@ -121,7 +124,8 @@ struct CustomGameSetupHarness { std::string premade; { - CustomGameScreen screen; + GAGGUI::ScreenStack screens(*globalContainer->gfx); + CustomGameScreen screen(screens); assert(screen.validMap && !screen.setup.random && screen.setup.capacity == 4); assert(screen.setup.colonies[2].controller == CustomGameSetup::Shared); assert(screen.setup.colonies[2].ai == AI::CORTEX); @@ -140,7 +144,8 @@ struct CustomGameSetupHarness assert(screen.previewPending); } { - CustomGameScreen screen; + GAGGUI::ScreenStack screens(*globalContainer->gfx); + CustomGameScreen screen(screens); assert(screen.setup.random && screen.previewPending && !screen.validMap); assert(screen.snapshot.empty() && screen.source.empty()); assert(screen.setup.premadeMap == premade); @@ -150,7 +155,8 @@ struct CustomGameSetupHarness screen.setup.premadeMap = "/missing/saved-map.map"; } { - CustomGameScreen screen; + GAGGUI::ScreenStack screens(*globalContainer->gfx); + CustomGameScreen screen(screens); assert(!screen.validMap && !screen.setup.random && !screen.message.empty()); assert(screen.setup.colonies[2].ai == AI::CORTEX && screen.setup.speed == 3); assert(screen.setup.premadeMap == "/missing/saved-map.map"); @@ -160,7 +166,8 @@ struct CustomGameSetupHarness out.write(truncated.data(), truncated.size(), "broken preferences"); }); { - CustomGameScreen screen; + GAGGUI::ScreenStack screens(*globalContainer->gfx); + CustomGameScreen screen(screens); assert(screen.validMap && screen.setup.capacity == 4 && screen.setup.speed == 0); } files->remove(CustomGamePreferences::filename); @@ -264,6 +271,22 @@ struct CustomGameSetupHarness globalContainer->settings.gameSpeed = 7; { Engine engine; + GAGGUI::ScreenStack screens(*globalContainer->gfx); + std::optional load; + std::shared_ptr mapFile; + std::string source; + screens.push(std::make_unique(screens), + [&](GAGGUI::Screen &screen, int result) + { + if (result != CustomGameScreen::OK) return; + auto &selected = static_cast(screen); + // Like SinglePlayerFlow, read the generated map only after the + // stack has destroyed this screen. + source = selected.sourceFile(); + load.emplace(engine.initCustomTask(selected.getMapHeader(), selected.getGameHeader(), + selected.getSelectedColor(0), selected.selectedSpeed(), source)); + mapFile = selected.releaseSnapshot(); + }); auto timer = SDL_AddTimer(500, Driver::tick, &driver); assert(timer); auto watchdog = SDL_AddTimer( @@ -276,10 +299,13 @@ struct CustomGameSetupHarness return 0; }, nullptr); - int result = engine.initCustom(); + screens.execute(); SDL_RemoveTimer(timer); SDL_RemoveTimer(watchdog); - assert(result == Engine::EE_NO_ERROR); + const bool loaded = load && load->run(); + mapFile.reset(); + assert(loaded); + assert(!std::filesystem::exists(std::filesystem::path(source).parent_path())); assert(driver.next == driver.steps.size()); assert(globalContainer->liveSpectating == (control == CustomGameSetup::Computer)); assert(globalContainer->settings.gameSpeed == 3); @@ -340,7 +366,8 @@ struct CustomGameSetupHarness assert(profile.returnCode == AINames::selectionIndex(AI::CORTEX)); } - CustomGameScreen screen; + GAGGUI::ScreenStack screens(*globalContainer->gfx); + CustomGameScreen screen(screens); screen.gfx = globalContainer->gfx; screen.dispatchInit(); assert(screen.validMap && screen.setup.capacity == 4); diff --git a/test/EngineSessionHarness.cpp b/test/EngineSessionHarness.cpp new file mode 100644 index 000000000..55ee6d957 --- /dev/null +++ b/test/EngineSessionHarness.cpp @@ -0,0 +1,782 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "Engine.h" +#include "Unit.h" +#include "Building.h" +#include "GameSessionScreen.h" +#include "GameGUILoadSave.h" +#include "GameUtilities.h" +#include "MapEdit.h" +#include "YOGLoginScreen.h" +#include "SettingsScreen.h" +#include "ChooseMapScreen.h" +#include "GUIGlob2FileList.h" +#include "GUIMapPreview.h" +#include +#include +#include +#include "Application.h" +#include "YOGClient.h" +#include "YOGClientEvent.h" +#include "GameLaunchMessages.h" +#include +#include +#include "FertilityCalculator.h" +#include "FertilityScreen.h" +#include "EditorLoadScreen.h" +#include "EditorGenerateScreen.h" +#include "GameLoadScreen.h" +#include "MapEditorScreen.h" +#include "MapGenerator.h" +#include "HeightMapGenerator.h" +#include "PerlinNoise.h" +#include +#include +#include "Utilities.h" +#include "LegacyFertilityReference.h" +#include "GlobalContainer.h" +#include "ReplayWriter.h" +#include "native.h" +#include "code.h" +#include +#include +#include +#include +#include + +GlobalContainer* globalContainer = nullptr; +void require(bool value, const char* message) { if (!value) throw std::runtime_error(message); } +GAGCore::CooperativeSlice fixedSlice() +{ + return GAGCore::CooperativeSlice([] { return GAGCore::CooperativeSlice::Time{}; }, + std::chrono::milliseconds(4), 8); +} +int main(int argc, char** argv) +{ + require(argc == 2, "A disposable profile is required"); + { + struct TrackedValue : Value { + bool& destroyed; + TrackedValue(Heap* heap, bool& destroyed) : Value(heap, nullptr), destroyed(destroyed) {} + ~TrackedValue() override { destroyed = true; } + }; + bool rootDestroyed = false, instructionDestroyed = false, garbageDestroyed = false; + { + Usl interpreter; + interpreter.setConstant("tracked", new TrackedValue(&interpreter.heap, rootDestroyed)); + auto* number = new NativeValue(&interpreter.heap, 42); + interpreter.setConstant("number", number); + new TrackedValue(&interpreter.heap, garbageDestroyed); + for (int round = 0; round < 5; ++round) { + interpreter.run(0); + require(!rootDestroyed && garbageDestroyed, "Script GC must retain roots and collect unreachable values on every pass"); + require(dynamic_cast*>(interpreter.getConstant("number"))->value == 42, + "Repeated script GC lost a native constant"); + Usl other; + auto* otherNumber = new NativeValue(&other.heap, round); + other.setConstant("number", otherNumber); + other.collectGarbage(); + require(number->prototype != otherNumber->prototype, "Native method tables must belong to their interpreter"); + } + auto* prototype = new ScopePrototype(&interpreter.heap, interpreter.root->prototype); + auto* literal = new TrackedValue(&interpreter.heap, instructionDestroyed); + prototype->body.push_back(new ConstCode(literal)); + prototype->body.push_back(new PopCode()); + prototype->body.push_back(new ConstCode(literal)); + interpreter.threads.emplace_back(&interpreter, new Scope(&interpreter.heap, prototype, interpreter.root.get())); + interpreter.run(1); + require(!instructionDestroyed, "Live thread stack was collected"); + interpreter.run(1); + require(!instructionDestroyed, "Pending bytecode constant was collected after leaving the stack"); + interpreter.run(1); + require(instructionDestroyed && !rootDestroyed, "Completed thread retained dead state or lost the root"); + } + require(rootDestroyed, "Destroying an interpreter must release its retained heap"); + std::cout << "PASS script GC roots, live frames, bytecode constants and interpreter isolation" << std::endl; + } + { + std::srand(17); const int expected = std::rand(); std::srand(17); + PerlinNoise first(123), second(987); + const float value = first.Noise(.125f, .75f); + second.reseed(456); + require(value == first.Noise(.125f, .75f), "Reseeding another noise instance changed existing noise"); + first.reseed(123); + require(value == first.Noise(.125f, .75f), "Explicit noise seeds must repeat"); + require(std::rand() == expected, "Noise must not mutate libc RNG state"); + for (unsigned seed = 0; seed < 128; ++seed) { + first.reseed(seed); + require(std::isfinite(first.Noise(.25f, .5f, .75f)), "Seeded noise must remain finite"); + } + } + + // A one-crater map repeats the exact first stamp location. Shared static + // stamp caches used to skip that stamp in the second map instance. + { + std::vector expected; + for (unsigned repeat = 0; repeat < 2; ++repeat) { + setSyncRandSeed(42); + HeightMap heights(128, 128); + if (!repeat) heights.makeCraters(1, 30, 24); + else { + auto task = heights.makeCratersTask(1, 30, 24); + unsigned frames = 0; + while (!task.advance()) { + require(++frames < 1000, "Height-map job exceeded fixture work budget"); + PerlinNoise unrelated(frames); unrelated.Noise(.25f, .5f); + } + require(task.result() && frames > 20, "Height-map work must yield within its passes"); + } + for (unsigned i = 0; i < 128 * 128; ++i) { + require(std::isfinite(heights(i)) && heights(i) >= 0 && heights(i) <= 1, + "Height-map normalization must remain finite and bounded"); + if (!repeat) expected.push_back(heights(i)); + else require(heights(i) == expected[i], "Stamp state must belong to each height map"); + } + } + // Destroy nested jobs during stamp construction, filling, and noise work. + // The next operation must be safe even after cancellation of partial work. + for (unsigned stop : {1u, 5u, 20u, 40u}) { + HeightMap partial(128, 128); + { + auto task = partial.makeIslandsTask(2, 24); + for (unsigned frame = 0; frame < stop; ++frame) + require(!task.advance(), "Cancellation fixture finished before its checkpoint"); + } + partial.makeSwamp(24); + for (unsigned i = 0; i < 128 * 128; ++i) + require(std::isfinite(partial(i)), "Cancelled height map could not be reused"); + } + } + + SDL_setenv("SDL_VIDEODRIVER", "dummy", 1); + SDL_setenv("SDL_AUDIODRIVER", "dummy", 1); + globalContainer = new GlobalContainer(argv[1]); + globalContainer->settings.screenWidth = 800; + globalContainer->settings.screenHeight = 600; + globalContainer->settings.screenFlags = 0; + globalContainer->settings.mute = true; + globalContainer->settings.gameSpeed = 0; + globalContainer->load(); + require(SDLNet_Init() == 0, "SDL networking init failed"); + { + struct SelectionProbe : ChooseMapScreen { + SelectionProbe() : ChooseMapScreen("maps", "map", false) {} + void select(const std::string& filename) { + for (auto* widget : widgets) if (auto* list = dynamic_cast(widget)) { + list->addText(list->fileToList(filename)); + list->setSelection(list->getCount() - 1); + list->selectionChanged(); + return; + } + require(false, "Map chooser has no file list"); + } + bool hasPreview() { + for (auto* widget : widgets) if (auto* preview = dynamic_cast(widget)) + return preview->isThumbnailLoaded(); + throw std::runtime_error("Map chooser has no preview"); + } + } chooser; + chooser.beginExecution(globalContainer->gfx); + for (const char* invalid : {"browser_missing_fixture.map", "browser_corrupt_fixture.map"}) { + if (std::string(invalid).find("corrupt") != std::string::npos) { + GAGCore::BinaryOutputStream output(GAGCore::Toolkit::getFileManager()->openOutputStreamBackend(std::string("maps/") + invalid)); + output.write("bad", 3, "truncated header"); + } + chooser.select("balanced.map"); + require(chooser.getSelectedType() == ChooseMapScreen::MAP && chooser.hasPreview(), "Valid map must be selectable"); + chooser.select(invalid); + require(chooser.getSelectedType() == ChooseMapScreen::NONE && !chooser.hasPreview(), "Failed map read retained the previous selection or preview"); + SDL_Event enter{}; enter.type = SDL_KEYDOWN; enter.key.keysym.sym = SDLK_RETURN; + chooser.handleExecutionEvent(enter); + require(chooser.isExecutionRunning(), "Invalid map was accepted by Enter"); + chooser.drawExecution(); + } + chooser.select("balanced.map"); + require(chooser.getSelectedType() == ChooseMapScreen::MAP, "Chooser did not recover after invalid files"); + chooser.endExecute(ChooseMapScreen::CANCEL); chooser.finishExecution(); + std::cout << "PASS map selection clears stale data and recovers from missing/corrupt files without a modal loop" << std::endl; + } + { + SettingsScreen settings; + settings.beginExecution(globalContainer->gfx); + settings.done(); + require(settings.isExecutionRunning(), "Settings must poll persistence before closing"); + settings.onTimer(SDL_GetTicks()); + require(!settings.isExecutionRunning(), "Durable native settings should complete"); + settings.finishExecution(); + std::cout << "PASS settings close only after persistence completion" << std::endl; + } + { + Application application; + SDL_Event quit{}; + quit.type = SDL_QUIT; + require(application.frame(SDL_GetTicks(), {quit}), "Quit must begin final persistence before returning"); + require(application.frame(SDL_GetTicks(), {quit}), "Repeated close must not bypass final persistence"); + require(application.frame(SDL_GetTicks(), {}), "Shutdown must present its completion before releasing graphics"); + require(!application.frame(SDL_GetTicks(), {}), "Native shutdown must complete after persistence"); + std::cout << "PASS application quit waits for final persistence" << std::endl; + } + { + struct LoginProbe : YOGLoginScreen { + using YOGLoginScreen::YOGLoginScreen; + std::string status() { return statusText->getText(); } + }; + GAGGUI::ScreenStack screens(*globalContainer->gfx); + LoginProbe login(screens, std::make_shared()); + login.beginExecution(globalContainer->gfx); + static_cast(login).handleYOGClientEvent(std::make_shared(YOGClientVersionTooOld)); + require(login.status().find("same Glob2 release") != std::string::npos, + "Protocol rejection must provide a translated actionable status"); + login.drawExecution(); + std::cout << "PASS protocol rejection provides an actionable translated status" << std::endl; + login.endExecute(0); login.finishExecution(); + } + + { + struct StartProbe : MultiplayerGame { + using MultiplayerGame::MultiplayerGame; + using MultiplayerGame::receiveMessage; + }; + auto client = std::make_shared(); + auto game = std::make_shared(client); + require(!game->takeStartRequest(), "A room cannot start before the server request"); + game->receiveMessage(std::make_shared()); + require(game->takeStartRequest() && !game->takeStartRequest(), + "Network dispatch must defer launch and the host must consume it once"); + require(game->isWaitingForEngine(), "Router orders must remain queued after the host consumes launch"); + game->sessionEnded(false); + require(!game->isWaitingForEngine(), "Cancelled initialization must release the router queue hold"); + Engine engine; + require(!engine.initMultiplayerTask(game, client, -1).run(), + "Multiplayer initialization must reject a missing local player"); + std::cout << "PASS deferred multiplayer launch and invalid local-player rejection" << std::endl; + } + + { + struct TabProbe : GAGGUI::TabScreenWindow { + using TabScreenWindow::TabScreenWindow; + using TabScreenWindow::endExecute; + }; + GAGGUI::TabScreen tabs(true); + auto first = std::make_unique(&tabs, "First"); + TabProbe remaining(&tabs, "Remaining"); + tabs.beginExecution(globalContainer->gfx); + const int firstID = first->getTabNumber(); + first->endExecute(7); + tabs.onTimer(SDL_GetTicks()); + require(tabs.getReturnCode(firstID) == 7 && remaining.isActivated(), + "Completing an owned tab must activate the remaining tab"); + first.reset(); + require(tabs.isExecutionRunning() && remaining.isActivated(), + "Destroying a completed tab must preserve the surviving session"); + tabs.drawExecution(); + tabs.endExecute(0); tabs.finishExecution(); + std::cout << "PASS owned tab destruction preserves surviving tabs" << std::endl; + } + + { + auto& gfx = *globalContainer->gfx; + SDL_Window* window = nullptr; + // GlobalContainer can recreate its initial window while applying settings. + // This isolated SDL2 fixture owns a single window; IDs need not start at one. + for (Uint32 id = 1; id < 100 && !window; ++id) window = SDL_GetWindowFromID(id); + require(window != nullptr, "No native test window"); + const auto windowID = SDL_GetWindowID(window); + require(gfx.resizeViewport(1200, 800), "Software viewport resize failed"); + require(gfx.getW() == 1200 && gfx.getH() == 800, "Logical resolution did not follow viewport"); + require(SDL_GetWindowFromID(windowID) == window, "Resize replaced the SDL window"); + gfx.drawFilledRect(0, 0, gfx.getW(), gfx.getH(), GAGCore::Color(255, 0, 0)); + gfx.nextFrame(); + auto* presented = SDL_GetWindowSurface(window); + require(presented && presented->pixels && presented->format->BytesPerPixel == 4, "No presented test surface"); + Uint8 red, green, blue; + SDL_GetRGB(*static_cast(presented->pixels), presented->format, &red, &green, &blue); + require(red == 255 && green == 0 && blue == 0, "Resized surface was not presented to the window"); + require(!gfx.resizeViewport(0, 0) && gfx.getW() == 1200, "Zero viewport invalidated the render target"); + require(gfx.resizeViewport(800, 600), "Could not restore test viewport"); + GameGUI view; + auto map = Engine::loadMapHeader("maps/balanced.map"); + GameHeader players; + players.setNumberOfPlayers(1); + players.getBasePlayer(0) = BasePlayer(0, "Viewport", 0, BasePlayer::P_LOCAL); + require(view.loadFromHeaders(map, players, true, true), "Viewport fixture failed to load"); + view.viewportX = 20; view.viewportY = 30; + const auto checksum = view.game.checkSum(); + require(gfx.resizeViewport(1200, 800), "Could not apply the gameplay viewport"); + view.viewportResized(800, 600, 1200, 800); + require(((view.viewportX + (1200-160)/64) & view.game.map.wMask) == ((20 + (800-160)/64) & view.game.map.wMask), "Resize changed center tile horizontally"); + require(((view.viewportY + 800/64) & view.game.map.hMask) == ((30 + 600/64) & view.game.map.hMask), "Resize changed center tile vertically"); + require(view.game.checkSum() == checksum, "Viewport resize changed simulation state"); + Minimap minimap(false, 160, 800, 20, 10, 128, 128, Minimap::ShowFOW); + minimap.setGame(view.game); + minimap.resizeViewport(1200); + require(minimap.insideMinimap(1100, 74) && !minimap.insideMinimap(700, 74), "Minimap hit area did not follow the viewport"); + require(gfx.resizeViewport(800, 600), "Could not restore the fixture viewport"); + } + + { + using namespace GAGCore::ApplicationHost; + struct ControlledPersistence : Persistence { + PersistenceState current = PersistenceState::Pending; + PersistenceState state() const override { return current; } + }; + LoadSaveScreen dialog("games", "game", false, "Save", "test", glob2FilenameToName, glob2NameToFilename); + auto operation = std::make_unique(); + auto* control = operation.get(); + dialog.beginPersistence(std::move(operation)); + dialog.onAction(nullptr, GAGGUI::BUTTON_RELEASED, LoadSaveScreen::CANCEL, 0); + require(dialog.endValue == -1 && !dialog.pollPersistence(), "Pending save must not close or claim completion"); + control->current = PersistenceState::Failed; + require(!dialog.pollPersistence() && dialog.endValue == -1, "Failed persistence must retain the dialog"); + operation = std::make_unique(); control = operation.get(); + dialog.beginPersistence(std::move(operation)); + control->current = PersistenceState::Succeeded; + require(dialog.pollPersistence(), "Successful persistence must complete the save dialog"); + } + { + Map map; + map.setSize(7, 6); + const unsigned width = 128, height = 64; + for (unsigned fixture = 0; fixture < 4; ++fixture) { + std::vector seed(width * height, 1); + if (fixture) seed[0] = 255; + if (fixture >= 2) { + for (unsigned y = 0; y < height; ++y) + for (unsigned x = 0; x < width; ++x) + if (x % 16 == 7 && y % 13 != 0) seed[y * width + x] = 0; + } + if (fixture == 3) { + seed[32 * width + 64] = 200; + seed[16 * width + 32] = 254; + } + // Independent queue relaxation oracle: unlike the production + // forward/backward sweeps it propagates strongest values first. + auto expected = seed; + std::priority_queue> pending; + for (unsigned i = 0; i < expected.size(); ++i) + if (expected[i] >= 3) pending.emplace(expected[i], i); + while (!pending.empty()) { + const auto [value, index] = pending.top(); pending.pop(); + if (value != expected[index] || value < 3) continue; + for (int dy = -1; dy <= 1; ++dy) + for (int dx = -1; dx <= 1; ++dx) { + const unsigned x = (index % width + dx) & (width - 1); + const unsigned y = (index / width + dy) & (height - 1); + auto& neighbor = expected[y * width + x]; + if (neighbor && neighbor < value - 1) { + neighbor = value - 1; + pending.emplace(neighbor, y * width + x); + } + } + } + auto regular = seed; + map.updateGlobalGradient(regular.data()); + require(regular == expected, "Synchronous gradient differs from queue oracle"); + auto scheduled = seed; + auto task = map.updateGlobalGradientTask(scheduled.data()); + unsigned slices = 0; + while (!task.advance()) require(++slices < 1000, "Gradient did not converge"); + require(task.result() && (!fixture || slices >= 8) && scheduled == expected, + "Scheduled gradient must yield and match the queue oracle"); + auto cancelled = seed; + { + auto partial = map.updateGlobalGradientTask(cancelled.data()); + if (fixture) require(!partial.advance() && !partial.advance(), "Gradient cancellation must precede completion"); + } + // Monotonic relaxation can resume from an interrupted sweep. + map.updateGlobalGradient(cancelled.data()); + require(cancelled == expected, "Interrupted gradient could not converge on restart"); + } + } + { + MapGenerator generator; + for (auto method : {MapGenerationDescriptor::eUNIFORM, MapGenerationDescriptor::eSWAMP, + MapGenerationDescriptor::eISLANDS, MapGenerationDescriptor::eCONCRETEISLANDS, + MapGenerationDescriptor::eRIVER, MapGenerationDescriptor::eCRATERLAKES, + MapGenerationDescriptor::eISLES, MapGenerationDescriptor::eOLDRANDOM, + MapGenerationDescriptor::eOLDISLANDS}) { + Uint32 checksum = 0; + std::string rng; + for (int repeat = 0; repeat < 2; ++repeat) { + MapGenerationDescriptor descriptor; + descriptor.method = method; + descriptor.nbTeams = 2; + Game generated(nullptr); + if (!repeat) require(generator.generateMap(generated, descriptor, 12345), "Seeded generation fixture failed"); + else { + auto job = generator.generateMapTask(generated, descriptor, 12345); + unsigned slices = 0; + while (!job.advance()) { + PerlinNoise interleaved(123 + slices); interleaved.Noise(.125f, .75f); + std::rand(); + require(++slices < 10000, "Generation job did not finish"); + } + require(slices > 1 && job.result(), "Generation must yield and succeed"); + } + if (!repeat) { checksum = generated.checkSum(); rng = getSyncRandState(); } + else require(checksum == generated.checkSum() && rng == getSyncRandState(), + "Seeded generation must repeat despite unrelated noise and libc RNG draws"); + PerlinNoise unrelated(999 + repeat); unrelated.Noise(.25f, .125f); + for (int i = 0; i < 100; ++i) std::rand(); + } + } + } + { + const auto rng = getSyncRandState(); + for (unsigned frames : {1u, 2u}) { + GAGGUI::ScreenStack screens(*globalContainer->gfx); + screens.push(std::make_unique(MapGenerationDescriptor(), 12345, fixedSlice())); + for (unsigned frame = 0; frame < frames; ++frame) screens.frame(frame, {}); + SDL_Event escape{}; escape.type = SDL_KEYDOWN; escape.key.keysym.sym = SDLK_ESCAPE; + screens.frame(frames, {escape}); screens.frame(frames + 1, {}); + require(!screens.running() && screens.result() == 0 && getSyncRandState() == rng, + "Cancelled generation must release partial state and restore RNG"); + } + // Concrete-island partitioning awaits distance floods, point searches, + // and weighted area expansion. Cancelling deep in this nested chain + // must destroy queues/vectors before the partial map and restore RNG. + for (unsigned frames : {2u, 8u, 20u}) { + MapGenerationDescriptor descriptor; + descriptor.method = MapGenerationDescriptor::eCONCRETEISLANDS; + descriptor.nbTeams = 2; + GAGGUI::ScreenStack screens(*globalContainer->gfx); + screens.push(std::make_unique(descriptor, 12345, fixedSlice())); + for (unsigned frame = 0; frame < frames; ++frame) { + require(screens.running(), "Partition cancellation fixture finished too early"); + screens.frame(frame, {}); + } + SDL_Event escape{}; escape.type = SDL_KEYDOWN; escape.key.keysym.sym = SDLK_ESCAPE; + screens.frame(frames, {escape}); screens.frame(frames + 1, {}); + require(!screens.running() && screens.result() == 0 && getSyncRandState() == rng, + "Cancelled partitioning must release its partial map and restore RNG"); + } + GAGGUI::ScreenStack failed(*globalContainer->gfx); + MapGenerationDescriptor invalid; invalid.wDec = -1; + failed.push(std::make_unique(invalid, 12345, fixedSlice())); + for (unsigned frame = 0; failed.running(); ++frame) { + require(frame < 10, "Invalid generation descriptor did not fail promptly"); failed.frame(frame, {}); + } + require(failed.result() == 2 && getSyncRandState() == rng, "Invalid generation must fail without changing RNG"); + } + { + Engine engine; + require(engine.initCampaign("maps/balanced.map") == Engine::EE_NO_ERROR, "Replay save fixture failed"); + auto& writer = *globalContainer->replayWriter; + const auto directory = std::filesystem::path(globalContainer->fileManager->getDir(0)) / "replays"; + const auto destination = directory / "Atomic.replay"; + const auto blocked = directory / "Blocked.replay"; + const auto position = writer.getBuffer()->getPosition(); + require(writer.write(destination.string()), "Initial replay save failed"); + const auto read = [](const std::filesystem::path& path) { + std::ifstream input(path, std::ios::binary); + return std::string(std::istreambuf_iterator(input), {}); + }; + const auto bytes = read(destination); + std::filesystem::create_directory(blocked); + require(!writer.write(blocked.string()), "Replay save accepted a directory"); + require(writer.getBuffer()->getPosition() == position, "Failed replay save moved the recording cursor"); + require(writer.write(destination.string()) && read(destination) == bytes && !bytes.empty(), + "Replay retry did not preserve the complete recording"); + globalContainer->replayWriter.reset(); + std::cout << "PASS atomic replay save, failed destination and retry" << std::endl; + } + for (int outcome : {0, 1, 2}) { + auto previous = std::make_unique(); + require(previous->initCampaign("maps/balanced.map") == Engine::EE_NO_ERROR, "Reload ownership fixture initialization failed"); + Engine* identity = previous.get(); + // Mirror session finalization before reusing the initialized game. + globalContainer->replayWriter.reset(); + const auto rng = getSyncRandState(); + std::unique_ptr accepted; + GAGGUI::ScreenStack reload(*globalContainer->gfx); + reload.push(std::make_unique(std::move(previous), [outcome](Engine& engine) { + return engine.initCampaignTask(outcome == 2 ? "maps/missing-reload-fixture.map" : "maps/balanced.map"); + }, fixedSlice()), [&](GAGGUI::Screen& loading, int result) { + require(result == outcome, "Reload returned the wrong completion state"); + if (result == 1) accepted = static_cast(loading).takeEngine(); + }); + unsigned frames = 0; + while (reload.running()) { + std::vector events; + if (outcome == 0 && frames == 10) { + reload.suspendExecution(); + reload.viewportResized(800,600,800,600); + SDL_Event escape{}; escape.type = SDL_KEYDOWN; escape.key.keysym.sym = SDLK_ESCAPE; + events.push_back(escape); + } + reload.frame(frames, events); + require(++frames < 2000, "Reused engine load did not complete"); + } + if (outcome == 1) require(accepted.get() == identity && globalContainer->replayWriter && frames > 20, + "Successful reload must return the same engine and retain its new replay writer"); + else require(!accepted && !globalContainer->replayWriter && !globalContainer->replayReader && getSyncRandState() == rng, + "Cancelled/failed reused-engine loading leaked replay state or changed RNG"); + } + std::cout << "PASS reused-engine cooperative loading, cancellation and failure ownership" << std::endl; + globalContainer->automaticEndingGame = true; + globalContainer->automaticEndingSteps = 50; + globalContainer->automaticGameGlobalEndConditions = true; + for (bool delayed : {false, true}) { + Engine engine; + require(engine.initCampaign("maps/balanced.map") == Engine::EE_NO_ERROR, "Map load failed"); + Uint64 now = 1000; + engine.beginSession(now); + bool rejected = false; + try { engine.beginSession(now); } catch (const std::logic_error&) { rejected = true; } + require(rejected, "Double session start must be rejected"); + rejected = false; + try { engine.finishSession(); } catch (const std::logic_error&) { rejected = true; } + require(rejected, "Running session cannot be finalized"); + int iterations = 0; + bool running; + do { + SDL_Event sentinel{}; + sentinel.type = SDL_USEREVENT; + sentinel.user.code = 7821; + require(SDL_PushEvent(&sentinel) == 1, "Cannot enqueue host event"); + running = engine.stepSession(now, {}); + SDL_Event retained{}; + require(SDL_PeepEvents(&retained, 1, SDL_GETEVENT, SDL_USEREVENT, SDL_USEREVENT) == 1 && + retained.user.code == 7821, "Explicit session input must not consume the host queue"); + engine.drawSession(); + const Uint32 delay = engine.sessionDelay(now); + require(delay == engine.sessionDelay(now), "Delay queries must not advance the timing budget"); + if (!delayed) require(delay == 40, "Regular callbacks must retain 25 Hz pacing"); + now += delayed ? 1000 : delay; + require(++iterations <= 100, "Session failed to terminate"); + } while (running); + require(iterations == 50, "Callback cadence changed simulation advancement"); + require(!engine.stepSession(now), "Completed session must not advance"); + require(!engine.finishSession(), "Finished fixture must not request another game"); + rejected = false; + try { engine.stepSession(now); } catch (const std::logic_error&) { rejected = true; } + require(rejected, "A finalized session must reject advancement"); + } + { + const auto rng = getSyncRandState(); + for (bool replay : {false, true}) { + GAGGUI::ScreenStack cancelled(*globalContainer->gfx); + cancelled.push(std::make_unique([replay](Engine& engine) { + return replay ? engine.loadReplayTask("replays/last_game.replay") : engine.initCampaignTask("maps/balanced.map"); + }, fixedSlice())); + for (unsigned frame = 0; frame < 20; ++frame) cancelled.frame(frame, {}); + SDL_Event escape{}; escape.type = SDL_KEYDOWN; escape.key.keysym.sym = SDLK_ESCAPE; + cancelled.frame(20, {escape}); cancelled.frame(21, {}); + require(!cancelled.running() && cancelled.result() == 0, "Game/replay load must accept cancellation"); + require(getSyncRandState() == rng && !globalContainer->replaying && !globalContainer->replayReader && + !globalContainer->replayWriter, "Cancelled startup must restore RNG and release replay state"); + } + { + GAGGUI::ScreenStack failed(*globalContainer->gfx); + failed.push(std::make_unique([](Engine& engine) { + return engine.loadReplayTask("replays/missing-initialization-fixture.replay"); + }, fixedSlice())); + unsigned attempts = 0; + while (failed.running()) { failed.frame(attempts++, {}); require(attempts < 10, "Failed load did not return"); } + require(failed.result() == 2 && getSyncRandState() == rng && !globalContainer->replaying, + "Failed startup must return an error and restore global state"); + } + GAGGUI::ScreenStack screens(*globalContainer->gfx); + unsigned frames = 0, loadingFrames = 0; + screens.push(std::make_unique([](Engine& engine) { return engine.initCampaignTask("maps/balanced.map"); }, fixedSlice()), + [&](GAGGUI::Screen& screen, int result) { + require(result == 1, "Scheduled game initialization failed"); + loadingFrames = frames; + screens.push(std::make_unique(screens, static_cast(screen).takeEngine())); + }); + bool suspended = false; + while (screens.running()) { + if (loadingFrames && frames == loadingFrames + 10) { + screens.suspendExecution(); + suspended = true; + } + screens.frame(1000 + frames * 40 + (suspended ? 60000 : 0), {}); + require(++frames <= 2000, "Stack-driven loading/session failed to finish"); + } + // Resumption keeps the pending 40ms tick deadline; hidden time is excluded. + require(loadingFrames > 20 && frames == loadingFrames + 52 && screens.result() == GAGGUI::Screen::QUIT_APPLICATION, + "Suspension must exclude hidden time and retain the pending tick deadline"); + } + for (bool cancel : {false, true}) { + auto editor = std::make_unique(); + require(editor->load("maps/balanced.map"), "Replacement fixture load failed"); + editor->mapHasBeenModified(); + MapEdit* original = editor.get(); + const auto checksum = original->game.checkSum(); + const auto rng = getSyncRandState(); + GAGGUI::ScreenStack screens(*globalContainer->gfx); + screens.push(std::make_unique(screens, std::move(editor))); + screens.frame(0, {}); + original->requestLoad(cancel ? "maps/balanced.map" : "maps/missing-replacement-fixture.map"); + screens.frame(33, {}); screens.frame(66, {}); + SDL_Event escape{}; escape.type = SDL_KEYDOWN; escape.key.keysym.sym = SDLK_ESCAPE; + screens.frame(99, {escape}); screens.frame(132, {}); + require(screens.running() && original->game.checkSum() == checksum && getSyncRandState() == rng, + "Failed or cancelled replacement must preserve the existing map and RNG"); + screens.frame(165, {escape}); + SDL_Event down{}; down.type = SDL_MOUSEBUTTONDOWN; down.button.button = SDL_BUTTON_LEFT; + down.button.x = 400; down.button.y = 375; + SDL_Event up = down; up.type = SDL_MOUSEBUTTONUP; + screens.frame(198, {down, up}); screens.frame(231, {}); + require(original->needsQuitDecision(), "Replacement failure/cancellation must preserve unsaved edits"); + screens.stop(); screens.frame(264, {}); + } + { + MapEdit editor; + require(editor.load("maps/balanced.map"), "Editor fixture load failed"); + const auto savedMap = std::filesystem::path(globalContainer->fileManager->getDir(0)) / "maps" / "Editor_atomic.map"; + require(editor.save(savedMap.string(), "Editor atomic"), "Editor atomic save failed"); + require(editor.game.mapHeader.getMapName() == "Editor atomic", "Saved editor name was not published"); + const auto invalidDestination = savedMap.parent_path() / "blocked.map"; + std::filesystem::create_directory(invalidDestination); + require(!editor.save(invalidDestination.string(), "Must not publish"), "Editor accepted a directory as a save file"); + require(editor.game.mapHeader.getMapName() == "Editor atomic", "Failed save changed the editor name"); + require(editor.load(savedMap.string()), "Atomically saved map did not reload"); + std::cout << "PASS editor atomic save/reload and failed replacement retains live metadata" << std::endl; + require(editor.load("maps/balanced.map"), "Restore the shared editor fixture after save tests"); + // Opening a script file dialog must return to the host without polling + // input or suspending the C++ stack. Escape closes only that child. + for (int action : {ScriptEditorScreen::LOAD, ScriptEditorScreen::SAVE}) { + ScriptEditorScreen script(&editor.game); + script.onAction(nullptr, GAGGUI::BUTTON_RELEASED, action, 0); + script.dispatchTimer(0); + script.dispatchPaint(); + script.drawFileDialog(); + SDL_Event escape{}; + escape.type = SDL_KEYDOWN; + escape.key.keysym.sym = SDLK_ESCAPE; + script.translateAndProcessEvent(&escape); + require(script.endValue < 0, "Cancelling script file dialog must retain its parent"); + SDL_Event click{}; + click.type = SDL_MOUSEBUTTONDOWN; + click.button.button = SDL_BUTTON_LEFT; + click.button.x = script.decX + 170; + click.button.y = script.decY + 380; + script.translateAndProcessEvent(&click); + click.type = SDL_MOUSEBUTTONUP; + script.translateAndProcessEvent(&click); + require(script.endValue == ScriptEditorScreen::CANCEL, "Script editor must remain usable after child cancellation"); + } + const auto originalRng = getSyncRandState(); + for (const char* stage : {"[Loading units]", "[Loading buildings]", "[Resolving team links]"}) { + for (unsigned extraSteps : {1u, 3u}) { + { + MapEdit partial; + auto task = partial.loadTask("maps/balanced.map"); + unsigned steps = 0; + while (std::string(task.stage()) != stage) { + require(++steps < 5000 && !task.advance(), "Team parser did not reach its checkpoint"); + } + for (unsigned step = 0; step < extraSteps; ++step) + require(!task.advance(), "Team parser cancellation fixture completed too early"); + if (std::string(stage) == "[Resolving team links]") { + auto& team = *partial.game.teams[0]; + bool hasUnit = false, hasBuilding = false; + for (int i = 0; i < Unit::MAX_COUNT; ++i) hasUnit |= team.myUnits[i] != nullptr; + for (int i = 0; i < Building::MAX_COUNT; ++i) hasBuilding |= team.myBuildings[i] != nullptr; + require(hasUnit && hasBuilding, "Cancellation fixture must own real units and buildings"); + } + // Destroy the suspended parser before the partially linked + // team's units/buildings and their owning game. + } + setSyncRandState(originalRng); + } + } + setSyncRandState(originalRng); + for (unsigned frames : {1u, 4u, 20u}) { + GAGGUI::ScreenStack screens(*globalContainer->gfx); + screens.push(std::make_unique("maps/balanced.map", fixedSlice())); + for (unsigned frame = 0; frame < frames; ++frame) screens.frame(frame, {}); + SDL_Event escape{}; escape.type = SDL_KEYDOWN; escape.key.keysym.sym = SDLK_ESCAPE; + screens.frame(frames, {escape}); screens.frame(frames + 1, {}); + require(!screens.running() && screens.result() == 0, "Partial map loading must accept cancellation"); + require(getSyncRandState() == originalRng, "Cancelled load must restore RNG state"); + } + { + std::unique_ptr loaded; + GAGGUI::ScreenStack screens(*globalContainer->gfx); + screens.push(std::make_unique("maps/balanced.map", fixedSlice()), + [&](GAGGUI::Screen& screen, int result) { + require(result == 1, "Scheduled map load failed"); + loaded = static_cast(screen).takeEditor(); + }); + unsigned frame = 0; + while (screens.running()) { screens.frame(frame++, {}); require(frame < 2000, "Map load did not terminate"); } + require(frame > 20 && loaded->game.checkSum() == editor.game.checkSum(), "Scheduled and synchronous loads must agree"); + } + auto snapshot = [&]() { + std::vector values; + for (int x = 0; x < editor.game.map.getW(); ++x) + for (int y = 0; y < editor.game.map.getH(); ++y) + values.push_back(editor.game.map.getTile(x, y).fertility); + values.push_back(editor.game.map.fertilityMaximum); + return values; + }; + const auto original = snapshot(); + { + FertilityCalculator::Job cancelled(editor.game.map); + require(!cancelled.advance(0), "Zero work must not finish a job"); + cancelled.advance(4096); + bool rejected = false; + try { cancelled.commit(); } catch (const std::logic_error&) { rejected = true; } + require(rejected, "Incomplete fertility must not be committed"); + } + require(snapshot() == original, "Cancelled fertility changed the map"); + LegacyFertilityReference::compute(editor.game.map, {}); + const auto expected = snapshot(); + require(expected.back() > 0, "Fertility oracle fixture must exercise nonzero weights"); + for (const std::size_t budget : {1u, 7919u, 65536u}) { + for (int x = 0; x < editor.game.map.getW(); ++x) + for (int y = 0; y < editor.game.map.getH(); ++y) + editor.game.map.getTile(x, y).fertility = 42; + editor.game.map.fertilityMaximum = 42; + const auto untouched = snapshot(); + FertilityCalculator::Job job(editor.game.map); + float previous = 0; + while (!job.advance(budget)) { + require(job.progress() >= previous && job.progress() <= 1.f, "Progress must be monotonic"); + previous = job.progress(); + } + require(snapshot() == untouched, "Ready job published before commit"); + job.commit(); job.commit(); + require(snapshot() == expected, "Resumable fertility differs from original algorithm"); + } + { + const auto beforeCancel = snapshot(); + GAGGUI::ScreenStack screens(*globalContainer->gfx); + screens.push(std::make_unique(editor.game.map)); + SDL_Event escape{}; + escape.type = SDL_KEYDOWN; + escape.key.keysym.sym = SDLK_ESCAPE; + screens.frame(1000, {escape}); + screens.frame(1001, {}); + require(!screens.running() && screens.result() == 0, "Fertility screen must accept cancellation"); + require(snapshot() == beforeCancel, "Cancelling the progress screen changed the map"); + } + editor.beginEditing(); + editor.mapHasBeenModified(); + SDL_Event open{}; + open.type = SDL_KEYDOWN; + open.key.keysym.sym = SDLK_ESCAPE; + open.key.keysym.scancode = SDL_SCANCODE_ESCAPE; + SDL_Event down{}; + down.type = SDL_MOUSEBUTTONDOWN; + down.button.button = SDL_BUTTON_LEFT; + down.button.x = 400; down.button.y = 375; + SDL_Event up = down; up.type = SDL_MOUSEBUTTONUP; + require(editor.advanceEditing({open}, 1000), "Editor must accept menu input incrementally"); + require(editor.advanceEditing({down, up}, 1033) && editor.needsQuitDecision(), + "Modified editor must request an explicit quit decision"); + editor.drawEditing(); + editor.resolveQuitDecision(2); + require(editor.advanceEditing({}, 1066) && !editor.needsQuitDecision(), "Cancel must keep editing"); + editor.advanceEditing({open}, 1099); + editor.advanceEditing({down, up}, 1132); + require(editor.needsQuitDecision(), "Quit can be requested again"); + editor.resolveQuitDecision(1); + require(!editor.advanceEditing({}, 1165) && editor.editingReturnCode() == 0, + "Discard must finish without advancing or drawing another editor frame"); + } + delete globalContainer; + SDLNet_Quit(); + std::cout << "PASS: engine sessions, editor decisions, fertility equivalence and cancellation\n"; +} diff --git a/test/GameSpeedTest.cpp b/test/GameSpeedTest.cpp index 3e3cb5665..f1d047164 100644 --- a/test/GameSpeedTest.cpp +++ b/test/GameSpeedTest.cpp @@ -156,11 +156,38 @@ int main(int argc, char** argv) { gui.step(); SDL_Delay(pass==0?40:1); } + // Sample both cadences at the end of the measurement window. + // Otherwise the 40 ms pass stops with a camera sample from before + // its final sleep, while the 1 ms pass samples almost at the end. + SDL_PushEvent(&mouse); + gui.step(); distance[pass]=(before-gui.viewportX)&gui.game.map.getMaskW(); } std::cerr<<"Camera distances: "<=10 && distance[0]<=14); assert(std::abs(distance[0]-distance[1])<=2); + SDL_Event centered{}; + centered.type = SDL_MOUSEMOTION; + centered.motion.x = 200; centered.motion.y = 200; + SDL_Event held{}; + held.type = SDL_KEYDOWN; + held.key.keysym.sym = SDLK_LEFT; + held.key.keysym.scancode = SDL_SCANCODE_LEFT; + const Uint64 inputStart = SDL_GetTicks64() + 40; + gui.step({centered, held}, inputStart); + const int heldX = gui.viewportX; + gui.step({}, inputStart + 40); + assert(gui.viewportX == ((heldX - 1) & gui.game.map.getMaskW())); + SDL_Event focus{}; + focus.type = SDL_WINDOWEVENT; + focus.window.event = SDL_WINDOWEVENT_FOCUS_LOST; + gui.step({focus}, inputStart + 80); + const int releasedX = gui.viewportX; + gui.step({}, inputStart + 120); + focus.window.event = SDL_WINDOWEVENT_FOCUS_GAINED; + gui.step({focus}, inputStart + 160); + assert(gui.viewportX == releasedX); + std::cout << "PASS: supplied input, held-key scrolling and focus cleanup\n"; std::cout<<"PASS: multiplayer controls, replay eligibility, camera cadence " < #include "MultiplayerGameScreen.h" -#include "YOGClientBringup.h" #include "YOGServer.h" #include "FileManager.h" #include "GUITextInput.h" #include "Toolkit.h" +#include #include #include @@ -51,43 +53,49 @@ Uint32 timeoutTimer(Uint32, void*) class JoinScreen : public LANFindScreen { public: - JoinScreen(const std::string& address, const std::string& capture) : capture(capture) + JoinScreen(ScreenStack& screens, const std::string& address, const std::string& capture) : LANFindScreen(screens), capture(capture) { // Use the real form's public widget API; no networking is stubbed. for (Widget* widget : widgets) if (auto* input = dynamic_cast(widget)) if (input->getText() == "localhost") input->setText(address); } - void onTimer(Uint32 tick) override - { - LANFindScreen::onTimer(tick); - const Uint64 start = SDL_GetTicks64(); - // SDL timers only enqueue input. Rendering and network state stay on - // the real screen's main thread, including the nested lobby loop. - SDL_TimerID ready = SDL_AddTimer(5000, readyTimer, nullptr); - SDL_TimerID leave = SDL_AddTimer(25000, leaveTimer, nullptr); - SDL_TimerID timeout = SDL_AddTimer(40000, timeoutTimer, nullptr); - LANFindScreen::onAction(nullptr, BUTTON_RELEASED, CONNECT, 0); - SDL_RemoveTimer(ready); - SDL_RemoveTimer(leave); - SDL_RemoveTimer(timeout); - SDL_SaveBMP(globalContainer->gfx->getSDLSurface(), capture.c_str()); - bool ok = returnCode != QUIT_APPLICATION && SDL_GetTicks64() - start < 39000; - std::puts(ok ? "JOIN PASS: lobby returned through Leave Game" : "JOIN FAIL: lobby did not complete before the timeout"); - endExecute(ok ? 0 : 1); - } + ~JoinScreen() override { + for (auto timer : timers) if (timer) SDL_RemoveTimer(timer); + } + void onTimer(Uint32 tick) override { + LANFindScreen::onTimer(tick); + if (!started) { + started = true; + start = SDL_GetTicks64(); + timers[0] = SDL_AddTimer(5000, readyTimer, nullptr); + timers[1] = SDL_AddTimer(25000, leaveTimer, nullptr); + timers[2] = SDL_AddTimer(40000, timeoutTimer, nullptr); + LANFindScreen::onAction(nullptr, BUTTON_RELEASED, CONNECT, 0); + return; + } + // Parent updates resume only after the scheduled LAN session returns. + SDL_SaveBMP(globalContainer->gfx->getSDLSurface(), capture.c_str()); + const bool ok = SDL_GetTicks64() - start < 39000; + std::puts(ok ? "JOIN PASS: lobby returned through Leave Game" : "JOIN FAIL: lobby timed out"); + endExecute(ok ? 0 : 1); + } private: - std::string capture; + std::string capture; + bool started = false; + Uint64 start = 0; + SDL_TimerID timers[3]{}; }; -class HostScreen : public Glob2TabScreen +class HostObserver { public: - HostScreen(std::shared_ptr game, int cycles, std::string capture) - : Glob2TabScreen(true), game(game), cycles(cycles), capture(capture), start(SDL_GetTicks64()) {} - void onTimer(Uint32 tick) override + HostObserver(std::shared_ptr client, int cycles, std::string capture) + : client(client), cycles(cycles), capture(capture), start(SDL_GetTicks64()) {} + std::optional result; + void onTimer(Uint32 tick) { - Glob2TabScreen::onTimer(tick); + if (!pendingCapture.empty()) { SDL_SaveBMP(globalContainer->gfx->getSDLSurface(), pendingCapture.c_str()); @@ -96,9 +104,11 @@ class HostScreen : public Glob2TabScreen if (SDL_GetTicks64() - start > 180000) { std::puts("HOST FAIL: timed out waiting for ready/join/leave cycles"); - endExecute(1); + result = 1; return; } + auto game = client->getMultiplayerGame(); + if (!game) return; auto& header = game->getGameHeader(); int count = header.getNumberOfPlayers(); if (count != previousCount) @@ -118,13 +128,13 @@ class HostScreen : public Glob2TabScreen if (!ids.insert(p.playerID).second || p.number != i || p.numberMask != (Uint32(1) << i)) { std::puts("HOST FAIL: duplicate identity or invalid slot mask"); - endExecute(1); + result = 1; } } if (count > 2) { std::puts("HOST FAIL: expected exactly one host and one guest"); - endExecute(1); + result = 1; } if (count == 2 && game->isGameReadyToStart() && !readySeen) { @@ -141,18 +151,51 @@ class HostScreen : public Glob2TabScreen { std::puts("HOST PASS: all ready/join/leave cycles completed"); game->leaveGame(); - endExecute(0); + result = 0; } } } private: - std::shared_ptr game; + std::shared_ptr client; int cycles, completed = 0, previousCount = -1; bool readySeen = false; std::string capture, pendingCapture; Uint64 start; }; +bool connectionFailureChecks() +{ + // Accept the TCP handshake at the OS level but never pump YOG, so the + // client really remains connected without receiving a protocol greeting. + YOGServer stalled(YOGAnonymousLogin, YOGSingleGame); + if (!stalled.isListening()) return false; + for (bool cancel : {true, false}) { + auto client = std::make_shared(); + client->connect("127.0.0.1"); + const auto deadline = SDL_GetTicks64() + 2000; + while (!client->isConnected() && SDL_GetTicks64() < deadline) { + client->update(); SDL_Delay(1); + } + if (!client->isConnected()) return false; + ScreenStack screens(*globalContainer->gfx); + screens.push(std::make_unique(screens, client, "timeout probe")); + screens.frame(0, {}); + SDL_Event escape{}; + escape.type = SDL_KEYDOWN; + escape.key.keysym.sym = SDLK_ESCAPE; + if (cancel) { + screens.frame(1, {escape}); screens.frame(2, {}); + } else { + // Advance the host clock, not wall time, through the real deadline. + screens.frame(10000, {}); screens.frame(10001, {}); + screens.frame(10002, {escape}); screens.frame(10003, {}); screens.frame(10004, {}); + } + if (screens.running() || screens.result() != (cancel ? 0 : 1) || client->isConnected()) return false; + } + std::puts("LAN progress PASS: cancellation and greeting timeout release the connection"); + return true; +} + int host(int cycles, const std::string& capture) { auto client = std::make_shared(); @@ -161,23 +204,24 @@ int host(int cycles, const std::string& capture) server->enableLANBroadcasting(); client->attachGameServer(server); client->connect("127.0.0.1"); - if (LANBringup::waitForConnectionState(*client, YOGClient::WaitingForLoginInformation) != LANBringup::Result::Reached) return 1; - client->attemptLogin("LAN host"); - if (LANBringup::waitForConnectionState(*client, YOGClient::ClientOnStandby) != LANBringup::Result::Reached) return 1; - auto game = std::make_shared(client); - client->setMultiplayerGame(game); - game->createNewGame("LAN regression"); // A private map name forces a real transfer without touching user maps. MapHeader map = Engine::loadMapHeader("maps/FourSquares1.map"); map.setMapName("LAN regression transfer"); std::filesystem::copy_file("maps/FourSquares1.map", Toolkit::getFileManager()->getDir(0) + "/" + map.getFileName(), std::filesystem::copy_options::overwrite_existing); - game->setMapHeader(map); - HostScreen screen(game, cycles, capture); - MultiplayerGameScreen lobby(&screen, game, client); - int rc = screen.execute(globalContainer->gfx, 20); - client->setMultiplayerGame({}); + ScreenStack screens(*globalContainer->gfx); + screens.push(std::make_unique(screens, client, "LAN host", map)); + HostObserver observer(client, cycles, capture); + while (screens.running() && !observer.result) { + std::vector events; + SDL_Event event; + while (SDL_PollEvent(&event)) events.push_back(event); + screens.frame(SDL_GetTicks(), events); + observer.onTimer(SDL_GetTicks()); + SDL_Delay(20); + } + int rc = observer.result.value_or(1); return rc; } } @@ -210,15 +254,16 @@ int main(int argc, char** argv) globals.load(); if (SDLNet_Init() < 0) return 1; int rc = 0; - if (std::string(argv[1]) == "host") rc = host(std::stoi(argv[3]), argv[4]); + if (std::string(argv[1]) == "host") rc = connectionFailureChecks() ? host(std::stoi(argv[3]), argv[4]) : 1; else for (int cycle = 0; cycle < std::stoi(argv[3]) && !rc; ++cycle) { const auto downloaded = std::filesystem::path(globals.fileManager->getDir(0)) / "maps/LAN_regression_transfer.map"; // Force a second request too: rejoining must reuse the server's upload // rather than append another copy of its chunks to the cached transfer. std::filesystem::remove(downloaded); - JoinScreen screen(argv[2], std::string(argv[4]) + "-" + std::to_string(cycle + 1) + ".bmp"); - rc = screen.execute(globals.gfx, 20); + ScreenStack screens(*globals.gfx); + screens.push(std::make_unique(screens, argv[2], std::string(argv[4]) + "-" + std::to_string(cycle + 1) + ".bmp")); + rc = screens.execute(20); std::ifstream original("maps/FourSquares1.map", std::ios::binary); std::ifstream received(downloaded, std::ios::binary); std::string expected((std::istreambuf_iterator(original)), {}); diff --git a/test/LegacyFertilityReference.h b/test/LegacyFertilityReference.h new file mode 100644 index 000000000..5fb6c7e07 --- /dev/null +++ b/test/LegacyFertilityReference.h @@ -0,0 +1,147 @@ +// Frozen pre-job algorithm, used only as a migration equivalence oracle. +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2007-2008 Bradley Arsenault + +#include + +#include "Map.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + // 31x31 weighting kernel: water tiles within Chebyshev distance kFertilityRadius + // of a grass tile contribute weight = int(kSqrtScale * sqrt((R-|dx|)*(R-|dy|))). + constexpr int kFertilityRadius = 15; + constexpr int kKernelSide = 2 * kFertilityRadius + 1; + constexpr float kSqrtScale = 4.2f; + + constexpr std::array, 8> kBfsNeighbors{{ + {-1, -1}, { 0, -1}, { 1, -1}, + {-1, 0}, { 1, 0}, + {-1, 1}, { 0, 1}, { 1, 1}, + }}; + + using DistanceMap = std::vector>; + + const std::array& fertilityKernel() + { + static const auto kernel = []() { + std::array k{}; + for (int ny = -kFertilityRadius; ny <= kFertilityRadius; ++ny) + { + for (int nx = -kFertilityRadius; nx <= kFertilityRadius; ++nx) + { + const int value = (kFertilityRadius - std::abs(nx)) + * (kFertilityRadius - std::abs(ny)); + const int idx = (ny + kFertilityRadius) * kKernelSide + + (nx + kFertilityRadius); + k[idx] = static_cast( + int(kSqrtScale * std::sqrt(static_cast(value)))); + } + } + return k; + }(); + return kernel; + } + + /// 8-connected BFS from every takeable corn/wood tile, traversing only grass + /// cells. Cells that are unreachable (or non-grass and not seeded) stay nullopt. + DistanceMap computeResourceDistance(const Map& map) + { + DistanceMap distance(static_cast(map.getW()) * map.getH()); + std::queue> frontier; + + for (int x = 0; x < map.getW(); ++x) + { + for (int y = 0; y < map.getH(); ++y) + { + if (map.isResourceTakeable(x, y, CORN) + || map.isResourceTakeable(x, y, WOOD)) + { + distance[map.coordToIndex(x, y)] = 0; + frontier.emplace(x, y); + } + } + } + + while (!frontier.empty()) + { + const auto [px, py] = frontier.front(); + frontier.pop(); + const Uint16 nextDepth = + static_cast(*distance[map.coordToIndex(px, py)] + 1); + + for (const auto [dx, dy] : kBfsNeighbors) + { + const int nx = map.normalizeX(px + dx); + const int ny = map.normalizeY(py + dy); + auto& cell = distance[map.coordToIndex(nx, ny)]; + if (!cell.has_value() && map.isGrass(nx, ny)) + { + cell = nextDepth; + frontier.emplace(nx, ny); + } + } + } + return distance; + } +} + +namespace LegacyFertilityReference +{ + using ProgressCallback = std::function; + void compute(Map& map, const ProgressCallback& progress) + { + // BFS is fast relative to the kernel pass, so it isn't progress-reported. + const DistanceMap reachable = computeResourceDistance(map); + const auto& kernel = fertilityKernel(); + + std::vector fertility( + static_cast(map.getW()) * map.getH(), 0); + Uint16 fertilityMax = 0; + + for (int x = 0; x < map.getW(); ++x) + { + if (progress) + progress(static_cast(x) / static_cast(map.getW())); + + for (int y = 0; y < map.getH(); ++y) + { + if (!map.isGrass(x, y)) + continue; + if (!reachable[map.coordToIndex(x, y)].has_value()) + continue; + + Uint16 total = 0; + for (int ny = -kFertilityRadius; ny <= kFertilityRadius; ++ny) + { + for (int nx = -kFertilityRadius; nx <= kFertilityRadius; ++nx) + { + // Map::isWater wraps coords via coordToIndex; no normalize needed. + if (map.isWater(x + nx, y + ny)) + { + const int kIdx = (ny + kFertilityRadius) * kKernelSide + + (nx + kFertilityRadius); + total += kernel[kIdx]; + } + } + } + fertilityMax = std::max(fertilityMax, total); + fertility[map.coordToIndex(x, y)] = total; + } + } + + for (int x = 0; x < map.getW(); ++x) + for (int y = 0; y < map.getH(); ++y) + map.getTile(x, y).fertility = fertility[map.coordToIndex(x, y)]; + map.fertilityMaximum = fertilityMax; + } +} diff --git a/test/NativeMultiplayerPeer.cpp b/test/NativeMultiplayerPeer.cpp new file mode 100644 index 000000000..6c21ad04e --- /dev/null +++ b/test/NativeMultiplayerPeer.cpp @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// A real native simulation peer for browser/native cross-play integration tests. +#include "GlobalContainer.h" +#include "YOGClient.h" +#include "YOGClientGameListManager.h" +#include "MultiplayerGame.h" +#include "MultiplayerGameEventListener.h" +#include +#include +#include + +GlobalContainer* globalContainer = nullptr; +class MatchEvents : public MultiplayerGameEventListener { +public: + bool started = false, ended = false; + void handleMultiplayerGameEvent(std::shared_ptr event) override { + if (event->getEventType() == MGEGameStarted) { + started = true; + std::cout << "native match started" << std::endl; + } + if (event->getEventType() == MGEGameEndedNormally || event->getEventType() == MGEGameExit) ended = true; + } +}; +int main(int argc, char** argv) { + if (argc != 2 && argc != 4) return 2; + try { + SDL_setenv("SDL_VIDEODRIVER", "dummy", 1); + SDL_setenv("SDL_AUDIODRIVER", "dummy", 1); + SDL_setenv("GLOB2_CHECKSUM_SIDECAR", "1", 1); + if (argc == 4) SDL_setenv("SSL_CERT_FILE", argv[3], 1); + globalContainer = new GlobalContainer(argv[1]); + globalContainer->settings.screenWidth = 800; + globalContainer->settings.screenHeight = 600; + globalContainer->settings.screenFlags = 0; + globalContainer->settings.mute = true; + globalContainer->runNoX = true; + globalContainer->load(); + globalContainer->automaticEndingGame = true; + // Safety limit: the browser resigns after 250 ticks; normal victory + // must end this session before the fallback limit. + globalContainer->automaticEndingSteps = 1000; + if (SDLNet_Init() != 0) throw std::runtime_error("Network initialization failed"); + { + auto client = std::make_shared(); + client->connect(argc == 4 ? argv[2] : "127.0.0.1"); + std::shared_ptr game; + MatchEvents events; + bool ready = false; + const auto deadline = SDL_GetTicks64() + 60000; + while (!events.ended && SDL_GetTicks64() < deadline) { + client->update(); + if (client->getConnectionState() == YOGClient::WaitingForLoginInformation) + client->attemptLogin("transportguest", "fixture-only"); + if (!game && client->getLoginState() == YOGLoginSuccessful) { + const auto& rooms = client->getGameListManager()->getGameList(); + if (!rooms.empty()) { + game = std::make_shared(client); + client->setMultiplayerGame(game); + game->addEventListener(&events); + game->joinGame(rooms.front().getGameID()); + } + } + if (game && !events.ended) { + game->update(); + if (game->takeStartRequest()) game->startEngine(); + if (game->isFullyInGame() && !ready) { + game->setHumanReady(true); + ready = true; + std::cout << "native peer joined order-rate=" << int(game->getGameHeader().getOrderRate()) << " player-id=" << client->getPlayerID() << std::endl; + } + } + SDL_Delay(1); + } + if (!events.started || !events.ended) throw std::runtime_error("Native match did not complete"); + std::cout << "native match order-rate=" << int(game->getGameHeader().getOrderRate()) << std::endl; + game->removeEventListener(&events); + game->leaveGame(); + client->setMultiplayerGame({}); + client->disconnect(); + } + delete globalContainer; globalContainer = nullptr; + SDLNet_Quit(); + std::cout << "native match complete" << std::endl; + } catch (const std::exception& error) { std::cerr << error.what() << '\n'; return 1; } +} diff --git a/test/NetConnectionHarness.cpp b/test/NetConnectionHarness.cpp new file mode 100644 index 000000000..ac9b75d08 --- /dev/null +++ b/test/NetConnectionHarness.cpp @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "NetConnection.h" +#include "NetListener.h" +#include "message/AuthMessages.h" +#include "message/RegistrationMessages.h" +#include "message/RouterAdminMessages.h" +#include "GlobalContainer.h" +#include "YOGServer.h" +#include "Version.h" +#include +#include +#include + +GlobalContainer* globalContainer = nullptr; +namespace { +void require(bool value, const char* message) { if (!value) throw std::runtime_error(message); } +class FakeTransport : public NetTransport { +public: + State current = State::Closed; + std::deque> input; + std::vector> output; + void open(const std::string&, uint16_t) override { current = State::Connecting; } + void close() override { current = State::Closed; input.clear(); } + State state() const override { return current; } + bool send(std::vector bytes) override { output.push_back(std::move(bytes)); return true; } + bool receive(std::vector& bytes) override { + if (input.empty()) return false; + bytes = std::move(input.front()); input.pop_front(); return true; + } +}; +} +int main(int argc, char** argv) { + try { + if (argc == 3 && std::string(argv[1]) == "--serve") { + require(SDL_Init(0) == 0 && SDLNet_Init() == 0, "SDL network init failed"); + globalContainer = new GlobalContainer(argv[2]); + YOGServer server(YOGRequirePassword, YOGMultipleGames); + require(server.isListening(), "YOG test port is already occupied"); + for (const auto* name : {"transportplayer", "transportguest"}) + require(server.registerInformation(name, "fixture-only", "127.0.0.1", NET_PROTOCOL_VERSION) + == YOGLoginSuccessful, "Could not create isolated test account"); + for (Uint16 version : {Uint16(NET_PROTOCOL_VERSION-1), Uint16(NET_PROTOCOL_VERSION+1)}) { + require(server.verifyLoginInformation("transportplayer", "fixture-only", "127.0.0.1", version) + == YOGClientVersionTooOld, "Incompatible version passed account verification"); + require(server.registerInformation("incompatibleregistration", "fixture-only", "127.0.0.1", version) + == YOGClientVersionTooOld, "Incompatible version created an account"); + } + std::cout << "YOG test server ready" << std::endl; + for (;;) { server.update(); SDL_Delay(10); } + } + const std::string secret = "must-not-appear-in-logs"; + require(NetAttemptLogin("test", secret).format().find(secret) == std::string::npos && + NetRegistrationRequest("test", secret).format().find(secret) == std::string::npos && + NetRouterAdministratorLogin(secret).format().find(secret) == std::string::npos, + "Credential message formatting exposed a password"); + auto selected = std::make_unique(); + auto& wire = *selected; + NetConnection connection(std::move(selected)); + auto original = std::make_shared(); + connection.openConnection("unused", 0); + connection.sendMessage(original); + require(wire.output.empty(), "Connecting must retain, not send, greeting"); + wire.current = NetTransport::State::Connected; + connection.update(); + require(wire.output.size() == 1, "Greeting lost while connecting"); + const auto frame = wire.output.front(); + // Split at every possible byte boundary, including the length prefix. + for (size_t cut = 1; cut < frame.size(); ++cut) { + wire.input.emplace_back(frame.begin(), frame.begin() + cut); + require(!connection.getMessage(), "Partial frame was decoded"); + wire.input.emplace_back(frame.begin() + cut, frame.end()); + const auto decoded = connection.getMessage(); + require(decoded && *decoded == *original, "Fragmented frame changed the message"); + } + const auto serverInfo = std::make_shared(YOGRequirePassword, YOGMultipleGames, 17); + connection.sendMessage(serverInfo); + wire.input.push_back(wire.output.back()); + const auto decodedInfo = connection.getMessage(); + require(decodedInfo && *decodedInfo == *serverInfo, "Server version or player identity lost in greeting"); + wire.input.push_back({0,5,MNetSendServerInformation,YOGRequirePassword,YOGMultipleGames,0,17}); + const auto legacyInfo = std::dynamic_pointer_cast(connection.getMessage()); + require(legacyInfo && legacyInfo->getNetVersion() == 0, "Legacy greeting must advertise incompatible version zero"); + auto joined = frame; + joined.insert(joined.end(), frame.begin(), frame.end()); + wire.input.push_back(joined); + require(connection.getMessage() && connection.getMessage() && !connection.getMessage(), "Coalesced frames lost boundaries"); + for (const auto& invalid : std::vector>{ + {0, 0}, {0, 1, 255}, + {0, 5, MNetAttemptLogin, 255, 255, 255, 255}, {0, 1, original->getMessageType()}, + {0, 4, original->getMessageType(), 0, 1, 0}, + {0, 6, MNetSendServerInformation, YOGRequirePassword, YOGMultipleGames, 0, 17, 0}}) { + connection.openConnection("unused", 0); wire.current = NetTransport::State::Connected; + wire.input.push_back(invalid); connection.update(); + require(!connection.isConnected(), "Malformed message did not close the connection"); + } + connection.openConnection("unused", 0); wire.current = NetTransport::State::Connected; + joined.clear(); + for (unsigned i = 0; i < 257; ++i) joined.insert(joined.end(), frame.begin(), frame.end()); + wire.input.push_back(joined); connection.update(); + require(!connection.isConnected() && !connection.getMessage(), "Inbound queue was not bounded"); + connection.openConnection("unused", 0); + auto large = std::make_shared(std::string(20000, 'x'), ""); + for (unsigned i = 0; i < 100 && connection.isConnecting(); ++i) connection.sendMessage(large); + require(!connection.isConnecting(), "Connecting output queue was not bounded"); + connection.openConnection("unused", 0); wire.current = NetTransport::State::Connected; + connection.sendMessage(std::make_shared(std::string(70000, 'x'), "")); + require(!connection.isConnected(), "Oversized frame length was truncated"); + if (argc == 2) { + require(SDL_Init(0) == 0 && SDLNet_Init() == 0, "SDL network init failed"); + { + NetListener listener(static_cast(std::stoi(argv[1]))); + require(listener.isListening(), "Loopback listener failed"); + NetConnection client("127.0.0.1", static_cast(std::stoi(argv[1]))), server; + client.sendMessage(original); // Queue before connection completion. + bool accepted = false, echoed = false; + const auto deadline = SDL_GetTicks64() + 5000; + while (SDL_GetTicks64() < deadline && !echoed) { + if (!accepted) accepted = listener.attemptConnection(server); + client.update(); + if (auto message = server.getMessage()) server.sendMessage(message); + if (auto message = client.getMessage()) echoed = *message == *original; + SDL_Delay(1); + } + require(accepted && echoed, "Native TCP message round trip failed"); + } + SDLNet_Quit(); SDL_Quit(); + } + std::cout << "PASS: shared framing, malformed input, queue limits, queued greeting and TCP round trip\n"; + } catch (const std::exception& error) { std::cerr << error.what() << '\n'; return 1; } +} diff --git a/test/README.md b/test/README.md index 88a44aeaa..dade63813 100644 --- a/test/README.md +++ b/test/README.md @@ -174,7 +174,7 @@ Saved state and step-by-step before/after reproduction: [PR #166 fixture](fixtur ## Entering unit draw regression From the repository root, run `scons -j8 release=1 server=0 entering-unit-draw-test` -and `xvfb-run -a -s '-screen 0 1024x768x24' ./build/src/EnteringUnitDrawHarness`. +and `xvfb-run -a -s '-screen 0 1024x768x24' ./build/linux/client/release/src/EnteringUnitDrawHarness`. A unit on its final step into a building keeps its map slot on the tile it is leaving (`Unit::handleActionEnteringBuilding`) while `posX`/`posY` already name @@ -200,7 +200,7 @@ fullscreen aspect harness, so a job step is a two-liner: - name: Build and run the entering unit draw regression run: | scons -j$(nproc) release=1 server=0 entering-unit-draw-test - timeout 300s xvfb-run -a -s '-screen 0 1024x768x24' ./build/src/EnteringUnitDrawHarness + timeout 300s xvfb-run -a -s '-screen 0 1024x768x24' ./build/linux/client/release/src/EnteringUnitDrawHarness ``` ## Immobile unit gradient regression diff --git a/test/ReplayStepCounterTest.cpp b/test/ReplayStepCounterTest.cpp index 0c47c549e..6c922cd8e 100644 --- a/test/ReplayStepCounterTest.cpp +++ b/test/ReplayStepCounterTest.cpp @@ -47,12 +47,12 @@ using namespace GAGCore; class GameGUI { public: - GameGUI(); + explicit GameGUI(bool persistPreferences = true); ~GameGUI(); bool load(GAGCore::InputStream *stream, bool ignoreGUIData=false); void save(GAGCore::OutputStream *stream, const std::string name); }; -GameGUI::GameGUI() {} +GameGUI::GameGUI(bool) {} GameGUI::~GameGUI() {} bool GameGUI::load(GAGCore::InputStream*, bool) { return false; } void GameGUI::save(GAGCore::OutputStream*, const std::string) {} diff --git a/test/SConstruct b/test/SConstruct index 1f1abb920..3a9b34f70 100644 --- a/test/SConstruct +++ b/test/SConstruct @@ -1,13 +1,17 @@ import os import sys -sys.path.append(os.path.abspath("../scons")) -import ccache - -env = Environment(ENV={"PATH": os.environ["PATH"]}) +sys.path.insert(0, os.path.abspath('../scons')) +from build_layout import write_if_changed +config_dir = os.path.abspath('build/include') +write_if_changed(os.path.join(config_dir, 'glob2/BuildConfig.h'), + '#pragma once\n#define PACKAGE_DATA_DIR ".."\n#define PACKAGE_SOURCE_DIR ".."\n#define PRIMARY_FONT "sans.ttf"\n') +env = Environment(ENV=os.environ.copy()) if "CXX" in ARGUMENTS: env["CXX"] = ARGUMENTS["CXX"] +import ccache + # Compiler cache, shared with the top-level build. Set before the Clone()s # below so every harness environment inherits it. if ccache.enabled(): @@ -17,7 +21,7 @@ if ccache.enabled(): # echo/Echo.h transitively pulls in Map.h / Player.h / Team.h / etc., # which need libgag and SDL2 headers visible at parse time even when the # test itself never references those types. -common_cpppath = ['..', '../src', '../src/ai', +common_cpppath = [config_dir, '..', '../src', '../src/ai', '../src/building', '../src/game/entities', '../src/gui', '../src/team', '../src/unit', '../src/net', '../src/net/irc', '../src/net/message', @@ -28,12 +32,13 @@ common_cpppath = ['..', '../src', '../src/ai', '../libgag/include', '../libusl/src'] common_defines = ['HAVE_CONFIG_H', '_THREAD_SAFE'] +# Shared game headers expose C++20 cooperative tasks, including in these tests. if sys.platform.startswith("linux"): - env.Append(CCFLAGS = Split('-Wall -g -std=gnu++17')) + env.Append(CCFLAGS = Split('-Wall -g -std=gnu++20')) env.Append(CPPPATH = common_cpppath + ['/usr/include/SDL2']) env.Append(CPPDEFINES = common_defines) elif sys.platform == "darwin": - env.Append(CCFLAGS = Split('-Wall -g -std=gnu++17')) + env.Append(CCFLAGS = Split('-Wall -g -std=gnu++20')) env.Append(CPPPATH = common_cpppath + [ '/opt/homebrew/include', '/opt/homebrew/include/SDL2', @@ -42,7 +47,7 @@ elif sys.platform == "darwin": env.Append(LIBPATH = ['/opt/homebrew/lib', '/usr/local/lib']) env.Append(CPPDEFINES = common_defines) else: - env.Append(CCFLAGS = Split('/EHsc /MD /GR')) + env.Append(CCFLAGS = Split('/EHsc /MD /GR /std:c++20')) # Server-mode libgag, built here rather than picked up from ../build: the main # scons only emits libgagserver.a under `server=1`, so a plain client build @@ -56,9 +61,9 @@ if sys.platform == "darwin" or sys.platform.startswith("linux"): gag_server = gag_env.StaticLibrary('gagserver', [ gag_env.Object('gagserver-' + name + '.o', '../libgag/src/' + name + '.cpp') for name in Split(""" - BinaryStream FileManager FileManagerAtomic FormatableString Stream StreamBackend + ApplicationHost BinaryStream FileManager FileManagerAtomic FormatableString Stream StreamBackend StringTable TextStream Toolkit - """)]) + """)] + [gag_env.Object('gagserver-StreamHash.o', 'StreamHash.cpp')]) sources = Split(""" TestsRunner.cpp @@ -464,4 +469,3 @@ replay_objs = [ netsend_objs[1], ] wcdecode_env.Program( target = 'ReplayStepCounterTest', source = replay_objs ) - diff --git a/test/SavegameSafetyHarness.cpp b/test/SavegameSafetyHarness.cpp index f22eb9fd6..7c2b2c2de 100644 --- a/test/SavegameSafetyHarness.cpp +++ b/test/SavegameSafetyHarness.cpp @@ -11,6 +11,12 @@ #include "Utilities.h" #include "Order.h" #include "Player.h" +#include "Version.h" +#include "FileImport.h" +#include "Campaign.h" +#include "KeyboardManager.h" +#include "GameGUIKeyActions.h" +#include "MapEditKeyActions.h" #include #include #include @@ -288,6 +294,237 @@ static void checkRandomContinuation(bool text, bool ai) std::cout << "PASS " << (text ? "binary + text routing" : "binary") << (ai ? " AI" : " human") << " saved game continues RNG and 300 simulation steps across header replacement" << std::endl; } +static std::string headerBytes(const MapHeader& header) +{ + auto *backend = new MemoryStreamBackend; + BinaryOutputStream output(backend); + header.save(&output); + backend->seekFromStart(0); + std::string bytes; + while (!backend->isEndOfStream()) bytes += char(backend->getChar()); + return bytes; +} + +static void checkMapHeaders() +{ + MapHeader source; + source.setMapName("Import validation"); + source.setNumberOfTeams(1); + const std::string bytes = headerBytes(source); + for (bool file : {false, true}) + { + MapHeader header; + header.setMapName("Previous selection"); + const std::string previous = headerBytes(header); + for (size_t cut = 0; cut < bytes.size(); ++cut) + { + auto stream = input(bytes.substr(0, cut), file); + bool rejected = false; + try { rejected = !header.load(stream.get()); } + catch (const std::ios_base::failure&) { rejected = true; } + assert(rejected && headerBytes(header) == previous); + } + const size_t fields = 4 + source.getMapName().size(); + const auto replace = [&](size_t offset, Uint32 value) { + std::string corrupt = bytes; + for (int i = 0; i < 4; ++i) corrupt[offset + i] = char(value >> (24 - i * 8)); + return corrupt; + }; + for (const auto& corrupt : {replace(fields, VERSION_MAJOR + 1), + replace(fields + 4, MINIMUM_VERSION_MINOR - 1), replace(fields + 4, VERSION_MINOR + 1), + replace(fields + 8, 0xffffffffu), replace(fields + 8, Team::MAX_COUNT + 1)}) + { + auto stream = input(corrupt, file); + assert(!header.load(stream.get()) && headerBytes(header) == previous); + } + auto corrupt = bytes; + corrupt[fields + 16] = 2; + auto invalid = input(corrupt, file); + assert(!header.load(invalid.get()) && headerBytes(header) == previous); + auto valid = input(bytes, file); + assert(header.load(valid.get()) && headerBytes(header) == bytes); + } + std::cout << "PASS every truncated header, invalid versions/team counts/save flags rejected; previous header preserved; valid reload succeeds" << std::endl; +} + +static void checkImports(const std::string& bytes, const fs::path& directory) +{ + using namespace ApplicationHost; + const auto selected = [&](const std::string& name, const std::string& payload) { + return SelectedFile{name, std::vector(payload.begin(), payload.end())}; + }; + const auto validate = [](FileImport& operation) { + for (unsigned i = 0; i < 100000 && operation.state() == FileImport::State::Validating; ++i) operation.advance(); + assert(operation.state() != FileImport::State::Validating); + }; + const auto beforeRng = getSyncRandState(); + const auto preferences = directory / "preferences.txt"; + { std::ofstream out(preferences); out << "preserved preferences"; } + const auto preferencesTime = fs::last_write_time(preferences); + const bool remember = globalContainer->settings.rememberUnit; + globalContainer->settings.rememberUnit = true; + { + FileImport cancelled(selected("cancel.game", bytes), "game", persistStorage, + CooperativeSlice(std::chrono::steady_clock::now, std::chrono::milliseconds(4), 1)); + cancelled.advance(); + assert(cancelled.state() == FileImport::State::Validating); + } + assert(getSyncRandState() == beforeRng && !fs::exists(directory / "games/cancel.game")); + for (const auto& payload : {bytes.substr(0, 3), bytes.substr(0, bytes.size()-1), bytes + "extra"}) { + FileImport invalid(selected("invalid.game", payload), "game"); + validate(invalid); + assert(invalid.state() == FileImport::State::Failed && invalid.path().empty()); + } + { + auto stream = input(bytes, false); + MapHeader header; assert(header.load(stream.get())); + auto badCount = bytes; + // GameHeader begins with latency (4), order rate (1), player count (4). + std::fill_n(badCount.begin() + stream->getPosition() + 5, 4, char(0xff)); + auto badOffset = bytes; + const auto offsetField = 4 + header.getMapName().size() + 12; + const Uint32 offset = header.getMapOffset() + 1; + for (int i = 0; i < 4; ++i) badOffset[offsetField+i] = char(offset >> (24-i*8)); + auto badPlayer = bytes; + const auto player = badPlayer.find("PLYb"); assert(player != std::string::npos); + badPlayer[player] = '!'; + for (const auto& corrupt : {badCount, badPlayer, badOffset}) { + FileImport invalid(selected("invalid.game", corrupt), "game"); + validate(invalid); assert(invalid.state() == FileImport::State::Failed); + } + } + for (const auto& name : {"../escape.game", "con.game", "wrong.map"}) { + FileImport invalid(selected(name, bytes), "game"); + validate(invalid); assert(invalid.state() == FileImport::State::Failed); + } + const auto original = directory / "games/Imported.game"; + { std::ofstream out(original, std::ios::binary); out << "previous save"; } + struct ControlledPersistence : Persistence { + std::shared_ptr result; + explicit ControlledPersistence(std::shared_ptr result) : result(std::move(result)) {} + PersistenceState state() const override { return *result; } + }; + auto result = std::make_shared(PersistenceState::Pending); + const auto persist = [result] { return std::make_unique(result); }; + std::string imported; + { + FileImport operation(selected("Imported.game", bytes), "game", persist); + validate(operation); + assert(operation.state() == FileImport::State::Persisting); + imported = operation.path(); + assert(imported == "games/Imported_(1).game"); + assert(contents(directory / imported) == bytes && contents(original) == "previous save"); + operation.advance(); assert(operation.state() == FileImport::State::Persisting); + *result = PersistenceState::Failed; + operation.advance(); assert(operation.canRetry()); + operation.retryPersistence(); + *result = PersistenceState::Succeeded; + operation.advance(); assert(operation.state() == FileImport::State::Succeeded); + } + assert(contents(directory / imported) == bytes && contents(original) == "previous save"); + { + *result = PersistenceState::Failed; + FileImport operation(selected("abandoned.game", bytes), "game", persist); + validate(operation); operation.advance(); assert(operation.canRetry()); + } + assert(!fs::exists(directory / "games/abandoned.game")); + assert(getSyncRandState() == beforeRng); + assert(contents(preferences) == "preserved preferences" && fs::last_write_time(preferences) == preferencesTime); + globalContainer->settings.rememberUnit = remember; + std::cout << "PASS imported save full validation, cancellation/RNG restoration, name collision, pending/failure/retry persistence and abandoned-file cleanup" << std::endl; +} + +static void checkCampaignProgress(const fs::path& directory) +{ + Campaign base; + base.setName("Progress fixture"); + base.setPlayerName("First player"); + CampaignMapEntry first("First", "campaigns/first.map"), second("Second", "campaigns/second.map"); + first.unlockMap(); second.lockMap(); second.getUnlockedByMaps().push_back("First"); + base.appendMap(first); base.appendMap(second); + Campaign source = base; + source.setCompleted("First"); source.setPlayerName("Restored player"); + const auto backup = source.exportProgress(); + const auto unchanged = base.exportProgress(); + for (size_t cut = 0; cut < backup.size(); ++cut) { + assert(!base.importProgress({backup.begin(), backup.begin()+cut})); + assert(base.exportProgress() == unchanged); + } + auto extra = backup; extra.push_back(0); + assert(!base.importProgress(extra)); + assert(!base.importProgress(std::vector(1024*1024+1))); + auto invalidFlag = backup; invalidFlag.back() = 2; + assert(!base.importProgress(invalidFlag)); + auto future = backup; future[7] = 2; + assert(!base.importProgress(future)); + Campaign changed = base; changed.getMap(0).setMapFileName("../different.map"); + assert(!changed.importProgress(backup)); + changed = base; changed.getMap(1).getUnlockedByMaps().clear(); + assert(!changed.importProgress(backup)); + changed = base; changed.setName("Different campaign"); + assert(!changed.importProgress(backup)); + base.getMap(1).unlockMap(); base.getMap(1).setCompleted(true); + assert(base.importProgress(backup)); + assert(base.getMap(0).isCompleted() && base.getMap(1).isCompleted() && base.getMap(1).isUnlocked()); + assert(base.getPlayerName() == "Restored player"); + assert(base.save(true)); + const auto file = directory / "games/Progress_fixture.txt"; + const auto previous = contents(file); + Campaign restored; assert(restored.load(file.string())); + assert(restored.exportProgress() == base.exportProgress()); +#ifndef WIN32 + const auto child = fork(); assert(child >= 0); + if (child == 0) { + std::signal(SIGXFSZ, SIG_IGN); + struct rlimit budget = {0,0}; + if (setrlimit(RLIMIT_FSIZE, &budget)) _exit(2); + base.setPlayerName("Unwritten"); + _exit(base.save(true) ? 3 : 0); + } + int status = 0; assert(waitpid(child, &status, 0) == child); + assert(WIFEXITED(status) && WEXITSTATUS(status) == 0); + assert(contents(file) == previous); +#endif + std::cout << "PASS campaign progress truncation/version/definition validation, monotonic merge, legacy text round trip and atomic failure preservation" << std::endl; +} + +static void checkPreferences(const fs::path& directory) +{ + Settings settings; + settings.optionFlags = GlobalContainer::OPTION_LOW_SPEED_GFX; + const auto file = directory / "preferences-test.txt"; + assert(settings.save(file.string())); + Settings loaded; + loaded.load(file.string()); + assert(loaded.optionFlags == settings.optionFlags); + KeyboardManager game(GameGUIShortcuts), editor(MapEditShortcuts); + assert(game.saveKeyboardLayout() && editor.saveKeyboardLayout()); + const auto gamePath = directory / GameGUIKeyActions::getConfigurationFile(); + const auto editorPath = directory / MapEditKeyActions::getConfigurationFile(); + const auto previous = contents(file), gameBytes = contents(gamePath), editorBytes = contents(editorPath); + assert(!gameBytes.empty() && !editorBytes.empty()); + const auto blocked = directory / "blocked-preferences.txt"; + fs::create_directory(blocked); + assert(!settings.save(blocked.string()) && fs::is_directory(blocked)); +#ifndef WIN32 + const pid_t child = fork(); assert(child >= 0); + if (child == 0) { + std::signal(SIGXFSZ, SIG_IGN); + struct rlimit budget = {0, 0}; + if (setrlimit(RLIMIT_FSIZE, &budget) != 0) _exit(2); + settings.optionFlags = 0; + const bool preferencesFailed = !settings.save(file.string()); + const bool gameFailed = !game.saveKeyboardLayout(); + const bool editorFailed = !editor.saveKeyboardLayout(); + _exit(preferencesFailed && gameFailed && editorFailed ? 0 : 3); + } + int status = 0; assert(waitpid(child, &status, 0) == child); + assert(WIFEXITED(status) && WEXITSTATUS(status) == 0); + assert(contents(file) == previous && contents(gamePath) == gameBytes && contents(editorPath) == editorBytes); +#endif + std::cout << "PASS preference/keyboard writes round trip and preserve prior files on failure" << std::endl; +} + int main(int argc, char **argv) { SDL_SetMainReady(); @@ -305,6 +542,19 @@ int main(int argc, char **argv) for (bool ai : {false,true}) checkRandomContinuation(text,ai); const fs::path directory = fs::absolute(globals.fileManager->getDir(0)); checkAtomicWrites(*globals.fileManager, directory); + checkPreferences(directory); + checkMapHeaders(); + checkCampaignProgress(directory); + for (bool file : {false, true}) + { + const std::string payload("before\0after", 12); + const std::string bytes = std::string("\0\0\0\14", 4) + payload + + std::string("\0\0\0\12", 4) + "next field"; + auto restored = input(bytes, file); + assert(restored->readText("binary string") == payload); + assert(restored->readText("following string") == "next field"); + } + std::cout << "PASS embedded zero bytes preserved in binary strings" << std::endl; { GameGUI gui; auto map = Engine::loadMapHeader("maps/balanced.map"); @@ -313,6 +563,11 @@ int main(int argc, char **argv) header.setRandomSeed(123456); header.getBasePlayer(0) = BasePlayer(0, "Test", 0, BasePlayer::P_LOCAL); assert(gui.loadFromHeaders(map, header, true, true)); + // Loading now leaves resource/area gradients unallocated. A simulation + // tick must finish even before any unit has requested one of them. + gui.game.map.syncStep(0); + gui.game.map.syncStep(1); + std::cout << "PASS ticks finish before lazy gradients are requested" << std::endl; gui.localPlayer = gui.localTeamNo = 0; gui.adjustLocalTeam(); gui.game.stepCounter = 79; @@ -346,6 +601,7 @@ int main(int argc, char **argv) assert(restored.game.stepCounter == gui.game.stepCounter); } std::cout << "PASS production autosave reloads with unchanged simulation checksum components" << std::endl; + checkImports(bytes, directory); #ifndef WIN32 const pid_t child = fork(); assert(child >= 0); @@ -389,7 +645,27 @@ int main(int argc, char **argv) ++count; } std::cout << "PASS " << count << " truncated map loads rejected, followed by successful reuse" << std::endl; - for (bool file : {false, true}) + const auto replaceSint32 = [](std::string& value, size_t offset, Uint32 replacement) + { + for (int byte = 0; byte < 4; ++byte) + value[offset + byte] = char(replacement >> (24 - 8 * byte)); + }; + for (const auto [widthExponent, heightExponent] : { + std::pair{10, 9}, {9, 10}, {3, 9}, {9, 3}, + {0x7fffffffU, 9}, {9, 0x7fffffffU}}) + { + std::string malformed = mapBytes; + replaceSint32(malformed, 4, widthExponent); + replaceSint32(malformed, 8, heightExponent); + auto invalid = input(malformed, false); + Map loaded; + assert(!loaded.load(invalid.get(), savedHeader, &gui.game)); + assert(loaded.getW() == 0 && loaded.getH() == 0); + } + assert(Map::supportedDimensions(4, 4)); + assert(Map::supportedDimensions(9, 9)); + std::cout << "PASS unsupported and extreme map dimensions rejected before allocation" << std::endl; + for (bool file : {false, true}) { std::string malformed = mapBytes; malformed.replace(cellsEnd, 4, 4, char(0xFF)); diff --git a/test/ScreenExecutionHarness.cpp b/test/ScreenExecutionHarness.cpp new file mode 100644 index 000000000..d39406162 --- /dev/null +++ b/test/ScreenExecutionHarness.cpp @@ -0,0 +1,308 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace GAGGUI; + +void require(bool condition, const char* message) +{ + if (!condition) throw std::runtime_error(message); +} + +struct Probe : Screen +{ + int created = 0, destroyed = 0, paints = 0, inputs = 0, timers = 0; + Uint32 lastTick = 0; + bool closeOnCreate = false, closeOnTimer = false; + void onAction(Widget*, Action action, int, int) override + { + if (action == SCREEN_CREATED) { + ++created; + if (closeOnCreate) endExecute(7); + } + if (action == SCREEN_DESTROYED) ++destroyed; + } + void paint() override { ++paints; } + void onTimer(Uint32 tick) override + { + ++timers; + lastTick = tick; + if (closeOnTimer) endExecute(9); + } + void onSDLEvent(SDL_Event*) override { ++inputs; endExecute(42); } +}; + +struct TaskLifetime { int& live; TaskLifetime(int& live) : live(live) { ++live; } ~TaskLifetime() { --live; } }; +GAGCore::CooperativeTask childTask(int& live, bool fail) +{ + TaskLifetime lifetime(live); + co_await GAGCore::CooperativeTask::checkpoint("child"); + if (fail) throw std::runtime_error("child failure"); + co_return true; +} +GAGCore::CooperativeTask parentTask(int& live, bool fail) +{ + TaskLifetime lifetime(live); + const bool result = co_await childTask(live, fail); + co_await GAGCore::CooperativeTask::checkpoint("parent"); + co_return result; +} + +GAGCore::CooperativeTask timedWork(GAGCore::CooperativeSlice::Time& now, int& steps, + std::chrono::milliseconds cost, int count) +{ + for (int i = 0; i < count; ++i) { + ++steps; now += cost; + co_await GAGCore::CooperativeTask::checkpoint("work"); + } + co_return true; +} +int main() +{ + { + using Slice = GAGCore::CooperativeSlice; + Slice::Time now{}; + int steps = 0; + Slice slice([&] { return now; }); + auto task = timedWork(now, steps, std::chrono::milliseconds(1), 10); + require(!slice.advance(task) && steps == 4, "Slice stops at its elapsed-time budget"); + require(!slice.advance(task) && steps == 8, "Each callback starts a fresh time budget"); + require(slice.advance(task) && task.result() && steps == 10, "Slice reports completion without an extra callback"); + steps = 0; + auto slow = timedWork(now, steps, std::chrono::milliseconds(10), 3); + require(!slice.advance(slow) && steps == 1, "An expensive checkpoint stops the slice immediately afterward"); + steps = 0; + auto cheap = timedWork(now, steps, std::chrono::milliseconds(0), 100); + require(!slice.advance(cheap) && steps == 64, "Checkpoint cap bounds zero-cost or low-resolution clocks"); + require(slice.advance(cheap) && steps == 100, "Capped work resumes without skipping steps"); + bool rejected = false; + try { Slice invalid([&] { return now; }, std::chrono::milliseconds(0)); } + catch (const std::invalid_argument&) { rejected = true; } + require(rejected, "Invalid time budgets are rejected"); + } + + { + GAGCore::InputState held; + SDL_Event key{}; key.type = SDL_KEYDOWN; key.key.keysym.scancode = SDL_SCANCODE_LEFT; + key.key.keysym.mod = KMOD_CTRL; + held.observe(key); held.clearHeld(); + require(!held.keyboard()[SDL_SCANCODE_LEFT] && held.modifiers() == KMOD_NONE && held.hasFocus(), + "Suspending input clears controls without losing window focus"); + } + + int live = 0; + { + auto task = parentTask(live, false); + require(live == 0 && !task.advance() && live == 2, "Nested jobs start lazily and stop at child checkpoints"); + require(std::string(task.stage()) == "child", "Child progress is visible to the root"); + } + require(live == 0, "Cancelling root releases suspended children"); + { + auto task = parentTask(live, false); + task.advance(); + require(!task.advance() && live == 1, "Child completion resumes its parent to the next checkpoint"); + require(task.advance() && task.result() && live == 0, "Root completes with the child result"); + } + { + auto task = parentTask(live, true); + task.advance(); + require(task.advance(), "Child exception completes the root"); + bool rejected = false; + try { task.result(); } catch (const std::runtime_error&) { rejected = true; } + require(rejected && live == 0, "Child exceptions propagate and release resources"); + } + SDL_setenv("SDL_VIDEODRIVER", "dummy", 1); + SDL_setenv("SDL_AUDIODRIVER", "dummy", 1); + GAGCore::GraphicContext context(800, 600, 0, "Screen lifecycle regression"); + GAGCore::DrawableSurface surface(800, 600); + Probe screen; + screen.beginExecution(&surface); + require(screen.created == 1 && screen.paints == 0, "Begin must not draw"); + screen.updateExecution(1234); + require(screen.lastTick == 1234 && screen.timers == 1, "Host owns the timer"); + screen.drawExecution(); + require(screen.paints == 1, "Drawing is an explicit phase"); + bool rejected = false; + try { screen.beginExecution(&surface); } catch (const std::logic_error&) { rejected = true; } + require(rejected, "A screen cannot execute twice concurrently"); + rejected = false; + try { screen.finishExecution(); } catch (const std::logic_error&) { rejected = true; } + require(rejected, "A running screen cannot finish"); + SDL_Event event{}; + event.type = SDL_USEREVENT; + screen.handleExecutionEvent(event); + screen.handleExecutionEvent(event); + screen.updateExecution(5678); + screen.drawExecution(); + require(screen.inputs == 1 && screen.timers == 1 && screen.paints == 1, + "Completed screens must not consume another frame or event"); + require(screen.finishExecution() == 42 && screen.finishExecution() == 42, + "Completion result must be stable"); + require(screen.destroyed == 1, "Destroy callback must run exactly once"); + + screen.beginExecution(&surface); + event.type = SDL_QUIT; + screen.handleExecutionEvent(event); + require(screen.finishExecution() == Screen::QUIT_APPLICATION, "Quit must propagate"); + require(screen.created == 2 && screen.destroyed == 2 && screen.inputs == 1, + "A completed screen can be reused and quit bypasses widget input"); + +#if defined(USE_OSX) || defined(USE_WIN32) + screen.beginExecution(&surface); + event = {}; + event.type = SDL_KEYDOWN; +#ifdef USE_OSX + event.key.keysym.sym = SDLK_q; + event.key.keysym.mod = KMOD_GUI; +#else + event.key.keysym.sym = SDLK_F4; + event.key.keysym.mod = KMOD_ALT; +#endif + screen.handleExecutionEvent(event); + require(screen.finishExecution() == Screen::QUIT_APPLICATION, + "Quit chords must use the supplied event's modifiers"); +#endif + + Probe immediate; + immediate.closeOnCreate = true; + require(immediate.execute(&surface, 40) == 7, "Creation callback may complete the screen"); + require(immediate.paints == 0 && immediate.destroyed == 1, + "Creation-time completion must not be overwritten by the wrapper"); + Probe timer; + timer.closeOnTimer = true; + require(timer.execute(&surface, 40) == 9, "Legacy host must drive the same lifecycle"); + require(timer.timers == 1 && timer.paints == 1 && timer.destroyed == 1, + "Timer completion must stop before another draw"); + // A child request must return before construction/dispatch starts. The + // parent stays alive through child completion and resumes on a later frame. + struct Stacked : Screen { + void onAction(Widget*, Action, int, int) override {} + std::function input; + int& destroyed; + explicit Stacked(int& destroyed) : destroyed(destroyed) {} + ~Stacked() override { ++destroyed; } + void onSDLEvent(SDL_Event*) override { if (input) input(); } + }; + int rootDestroyed = 0, childDestroyed = 0, callbacks = 0, rootInputs = 0; + ScreenStack stack(surface); + auto root = std::make_unique(rootDestroyed); + auto* rootPtr = root.get(); + root->input = [&] { + ++rootInputs; + auto child = std::make_unique(childDestroyed); + auto* childPtr = child.get(); + child->input = [childPtr] { childPtr->endExecute(17); }; + stack.push(std::move(child), [&](Screen&, int result) { + require(result == 17 && rootDestroyed == 0 && childDestroyed == 0, + "Completion can read child results while both screens live"); + ++callbacks; + }); + }; + stack.push(std::move(root)); + event = {}; event.type = SDL_USEREVENT; + stack.frame(0, {event, event}); + require(rootInputs == 1 && callbacks == 0, "Opening input must not leak into a child"); + stack.frame(40, {event}); + require(callbacks == 0 && childDestroyed == 0, "Completion is deferred out of dispatch"); + stack.frame(80, {}); + require(callbacks == 1 && childDestroyed == 1 && rootDestroyed == 0, + "Child is destroyed after completion and parent remains alive"); + rootPtr->endExecute(8); + stack.frame(120, {}); + require(!stack.running() && stack.result() == 8 && rootDestroyed == 1, + "Root completion empties the stack and retains its result"); + + int abandonedParent = 0, abandonedChild = 0; + ScreenStack abandoning(surface); + auto parent = std::make_unique(abandonedParent); + auto* parentPtr = parent.get(); + parent->input = [&] { + bool recursiveRejected = false; + try { abandoning.frame(1, {}); } catch (const std::logic_error&) { recursiveRejected = true; } + require(recursiveRejected, "Callbacks cannot recursively drive the host"); + abandoning.push(std::make_unique(abandonedChild), [](Screen&, int) { + throw std::runtime_error("Cancelled child continuation must not run"); + }); + parentPtr->endExecute(4); + }; + abandoning.push(std::move(parent)); + abandoning.frame(0, {event}); + abandoning.frame(40, {}); + require(!abandoning.running() && abandonedParent == 1 && abandonedChild == 1, + "Completing a parent cancels children queued by its final callback"); + + int cancelled = 0; + ScreenStack quitting(surface); + quitting.push(std::make_unique(cancelled), [&](Screen&, int) { + throw std::runtime_error("Quit must not run admission/continuation callbacks"); + }); + quitting.frame(0, {}); + event.type = SDL_QUIT; + quitting.frame(40, {event}); + require(!quitting.running() && quitting.result() == Screen::QUIT_APPLICATION && cancelled == 1, + "Quit releases owned screens without starting another flow"); + GAGCore::InputState held; + event = {}; event.type = SDL_KEYDOWN; + event.key.keysym.scancode = SDL_SCANCODE_LEFT; + event.key.keysym.mod = KMOD_CTRL; + held.observe(event); + require(held.keyboard()[SDL_SCANCODE_LEFT] && held.modifiers() == KMOD_CTRL, + "Held keys and modifiers come from supplied events"); + event.type = SDL_WINDOWEVENT; + event.window.event = SDL_WINDOWEVENT_FOCUS_LOST; + held.observe(event); + require(!held.hasFocus() && !held.keyboard()[SDL_SCANCODE_LEFT] && held.modifiers() == KMOD_NONE, + "Focus loss clears held input even without key-up delivery"); + event = {}; event.type = SDL_KEYDOWN; event.key.keysym.scancode = SDL_SCANCODE_LEFT; + held.observe(event); + require(!held.keyboard()[SDL_SCANCODE_LEFT], "Unfocused input cannot become held"); + event.type = SDL_WINDOWEVENT; event.window.event = SDL_WINDOWEVENT_FOCUS_GAINED; + held.observe(event); + require(held.hasFocus() && !held.keyboard()[SDL_SCANCODE_LEFT], "Focus return starts with released keys"); + struct BorrowingScreen : Screen { + std::weak_ptr resource; + bool& aliveDuringDestruction; + bool complete; + BorrowingScreen(std::weak_ptr resource, bool& alive, bool complete) + : resource(resource), aliveDuringDestruction(alive), complete(complete) {} + ~BorrowingScreen() override { aliveDuringDestruction = !resource.expired(); } + void onAction(Widget*, Action, int, int) override {} + void onTimer(Uint32) override { if (complete) endExecute(0); } + }; + for (int mode = 0; mode < 3; ++mode) { + bool aliveDuringDestruction = false; + auto resource = std::make_shared(7); + std::weak_ptr released = resource; + ScreenStack lifetime(surface); + lifetime.push(std::make_unique(resource, aliveDuringDestruction, mode == 0), + [resource](Screen&, int) {}); + resource.reset(); + if (mode != 2) lifetime.frame(0, {}); + if (mode != 0) lifetime.stop(); + lifetime.frame(40, {}); + require(aliveDuringDestruction && released.expired(), + "Continuation-owned resources outlive screens on completion and cancellation"); + } + + struct HostProbe : GAGCore::ApplicationHost::Loop { + int& frames; bool& destroyed; + HostProbe(int& frames, bool& destroyed) : frames(frames), destroyed(destroyed) {} + ~HostProbe() override { destroyed = true; } + bool frame(std::uint32_t, const std::vector&) override { return ++frames < 3; } + std::uint32_t delay(std::uint32_t) override { return 0; } + }; + int hostFrames = 0; + bool hostDestroyed = false, hostCompleted = false; + GAGCore::ApplicationHost::run(std::make_unique(hostFrames, hostDestroyed), [&] { + require(hostDestroyed && hostFrames == 3, "Host releases application state before global cleanup"); + hostCompleted = true; + }); + require(hostCompleted, "Native host completes exactly once before returning"); + std::cout << "PASS: screen phases, completion, reuse, quit and compatibility host\n"; +} diff --git a/test/StreamHash.cpp b/test/StreamHash.cpp new file mode 100644 index 000000000..6b08ed364 --- /dev/null +++ b/test/StreamHash.cpp @@ -0,0 +1,5 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SHA-1 is compiled as C++ in the game. Keep the same linkage for streams in +// standalone harnesses that do not link the game's password registry. +// As an archive member this is only selected if a harness lacks its own copy. +#include "../gnupg/sha1.c" diff --git a/test/WssTransportHarness.cpp b/test/WssTransportHarness.cpp new file mode 100644 index 000000000..e2c01adb0 --- /dev/null +++ b/test/WssTransportHarness.cpp @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "NetTransport.h" +#include +#include +#include +#include + +int main(int argc, char** argv) { + if (argc != 3) return 2; + using Clock = std::chrono::steady_clock; + try { + const std::string mode = argv[2]; + auto transport = makeNetTransport(); + const auto start = Clock::now(); + transport->open(argv[1], mode == "router" ? 7491 : 7489); + while (transport->state() == NetTransport::State::Connecting && Clock::now() - start < std::chrono::seconds(12)) { + if (mode == "cancel" && Clock::now() - start > std::chrono::milliseconds(100)) break; + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + if (mode == "cancel") { + const auto closing = Clock::now(); transport->close(); + if (Clock::now() - closing > std::chrono::milliseconds(500)) throw std::runtime_error("Close blocked"); + } else if (mode == "refuse" || mode == "timeout") { + if (transport->state() != NetTransport::State::Closed) throw std::runtime_error("Invalid peer accepted or timeout missing"); + } else { + if (transport->state() != NetTransport::State::Connected) throw std::runtime_error("Secure connection failed"); + if (transport->send(std::vector(NetTransport::queueLimit + 1))) throw std::runtime_error("Outbound limit missing"); + std::vector sent(64000), received, part; + for (size_t i = 0; i < sent.size(); ++i) sent[i] = i % 251; + if (!transport->send(sent)) throw std::runtime_error("Send failed"); + const auto deadline = Clock::now() + std::chrono::seconds(5); + while (Clock::now() < deadline && received.size() < sent.size() && transport->state() == NetTransport::State::Connected) { + while (transport->receive(part)) received.insert(received.end(), part.begin(), part.end()); + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + if (mode == "badframe") { + if (transport->state() != NetTransport::State::Closed) throw std::runtime_error("Invalid frame accepted"); + } else if (sent != received) throw std::runtime_error("Secure echo differed"); + transport->close(); + } + std::cout << "PASS: native WSS " << mode << '\n'; + } catch (const std::exception& error) { std::cerr << error.what() << '\n'; return 1; } +} diff --git a/test/run-engine-session-test.py b/test/run-engine-session-test.py new file mode 100644 index 000000000..685b6819a --- /dev/null +++ b/test/run-engine-session-test.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +"""Run from any directory after `scons release=1 session-test`. + +Uses SDL dummy software display; requires installed game data. +All preferences, saves and replays go into a disposable profile. +""" +import os +import platform +from pathlib import Path +import re +import shutil +import subprocess +import tempfile +import uuid + +root = Path(__file__).resolve().parents[1] +profile = 'glob2-session-test-' + uuid.uuid4().hex +try: + with tempfile.TemporaryDirectory(prefix=profile) as work: + result = subprocess.run( + [str(root / os.environ.get('GLOB2_BUILD_DIR', 'build/' + platform.system().lower() + '/client/release') / 'src/engine-session-test'), profile], cwd=work, + env=dict(os.environ, SDL_AUDIODRIVER='dummy'), timeout=60, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, + ) + print(result.stdout, end='') + if result.returncode == 0: + checksums = re.findall(r'nox::gui\.game\.checkSum\(\) = ([0-9a-f]+)', result.stdout) + assert len(checksums) == 3, 'Missing session checksums' + assert len(set(checksums)) == 1, 'Callback timing changed simulation state' + raise SystemExit(result.returncode) +finally: + if os.name != 'nt': + shutil.rmtree(Path.home() / ('.' + profile), ignore_errors=True) diff --git a/test/run-game-speed-tests.py b/test/run-game-speed-tests.py index 372d13951..e79e73f5f 100644 --- a/test/run-game-speed-tests.py +++ b/test/run-game-speed-tests.py @@ -6,6 +6,7 @@ """ import argparse import os +import platform from pathlib import Path import re import shutil @@ -20,9 +21,10 @@ profile = 'glob2-speed-test-' + uuid.uuid4().hex try: with tempfile.TemporaryDirectory(prefix=profile) as work: + binary = root / os.environ.get('GLOB2_BUILD_DIR', 'build/' + platform.system().lower() + '/client/release') / 'src/game-speed-tests' try: result = subprocess.run( - [str(root / 'build/src/game-speed-tests'), profile] + (['--settings-only'] if args.settings_only else []), cwd=work, + [str(binary), profile] + (['--settings-only'] if args.settings_only else []), cwd=work, env=dict(os.environ, SDL_AUDIODRIVER='dummy'), timeout=60, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, ) diff --git a/test/run-network-transport-tests.py b/test/run-network-transport-tests.py new file mode 100644 index 000000000..d1bc3c11c --- /dev/null +++ b/test/run-network-transport-tests.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python3 +"""Run shared framing and native loopback tests after `scons release=1 transport-test`.""" +from pathlib import Path +import os +import platform +import socket +import subprocess + +root = Path(__file__).resolve().parents[1] +build = os.environ.get('GLOB2_BUILD_DIR', 'build/' + platform.system().lower() + '/client/release') +with socket.socket() as listener: + listener.bind(('127.0.0.1', 0)) + port = listener.getsockname()[1] +raise SystemExit(subprocess.run( + [str(root / build / 'src/net-connection-test'), str(port)], cwd=root, timeout=15, +).returncode) diff --git a/tests/baselines/cross-replay.checksums b/tests/baselines/cross-replay.checksums index e63d7d1ce..9d13fe1bf 100644 Binary files a/tests/baselines/cross-replay.checksums and b/tests/baselines/cross-replay.checksums differ diff --git a/tests/baselines/cross-replay.replay b/tests/baselines/cross-replay.replay index d41fed37c..b0937777b 100644 Binary files a/tests/baselines/cross-replay.replay and b/tests/baselines/cross-replay.replay differ diff --git a/tests/build_system/coexistence.py b/tests/build_system/coexistence.py new file mode 100644 index 000000000..7f0e4d2ae --- /dev/null +++ b/tests/build_system/coexistence.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""Build from one checkout and assert incremental configurations survive each other.""" +import argparse +import concurrent.futures +import hashlib +from pathlib import Path +import platform +import subprocess + + +def run(arguments): + subprocess.run(['scons', 'release=1', '-j2', '--debug=explain', *arguments], check=True) + + +def snapshot(directory): + # Signature DB access and logs can change on no-op builds; compilation outputs cannot. + files = [p for p in directory.rglob('*') if p.is_file() and + (p.suffix in ('.o', '.a', '.wasm', '.js', '.html', '.data') or + p.name in ('BuildConfig.h', 'glob2', 'compile_commands.json'))] + if not files: + raise AssertionError(f'No build outputs in {directory}') + # SCons regenerates its compilation database on every invocation by design. + return {str(p): (None if p.name == 'compile_commands.json' else p.stat().st_mtime_ns, + hashlib.sha256(p.read_bytes()).hexdigest()) for p in files} + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--order', choices=('native-first', 'web-first', 'concurrent'), required=True) + args = parser.parse_args() + tracked = subprocess.check_output(['git', 'diff', 'HEAD', '--binary']) + native, web = [], ['target=web'] + if args.order == 'concurrent': + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: + results = [pool.submit(run, target) for target in (native, web)] + for result in results: + result.result() + else: + for target in ((native, web) if args.order == 'native-first' else (web, native)): + run(target) + directories = [Path('build') / platform.system().lower() / 'client/release', + Path('build/emscripten/client/release')] + before = [snapshot(p) for p in directories] + for target in (native, web, native, web): + run(target) + after = [snapshot(p) for p in directories] + changes = [] + for previous, current in zip(before, after): + for path in sorted(previous.keys() | current.keys()): + if previous.get(path) == current.get(path): + continue + if path not in previous: + reason = 'added' + elif path not in current: + reason = 'removed' + elif previous[path][1] != current[path][1]: + reason = 'content changed' + else: + reason = 'timestamp changed (content unchanged)' + changes.append(f'{path}: {reason}') + assert not changes, ('A no-op build changed compilation outputs after ' + + repr(target or ['target=native']) + ':\n' + '\n'.join(changes)) + assert subprocess.check_output(['git', 'diff', 'HEAD', '--binary']) == tracked, 'Build changed tracked source files' + for filename in ('config.h', 'options_cache.py', 'compile_commands.json'): + assert not Path(filename).exists(), f'Global build state created: {filename}' + + +if __name__ == '__main__': + main() diff --git a/tests/build_system/test_layout.py b/tests/build_system/test_layout.py new file mode 100644 index 000000000..847952b25 --- /dev/null +++ b/tests/build_system/test_layout.py @@ -0,0 +1,74 @@ +import itertools +import json +from pathlib import Path +import sys +import tempfile +import unittest +import subprocess +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / 'scons')) +from build_layout import build_identity, default_directory, prepare_directory, write_if_changed, BuildLock +from sources import CLIENT_SOURCES, SERVER_SOURCES, GAG_SOURCES, USL_SOURCES + + +class BuildLayoutTests(unittest.TestCase): + def test_all_supported_configurations_have_distinct_directories(self): + configurations = [] + for release, host in itertools.product(('0','1'), ('darwin','linux','windows')): + for role in ('client','server','router','gateway'): + configurations.append(build_identity({'release':release,'role':role}, host)) + configurations += [build_identity({'target':'web','release':release}) for release in ('0','1')] + directories = [default_directory(c) for c in configurations] + self.assertEqual(len(directories), len(set(directories))) + + def test_legacy_server_and_mingw_options_select_separate_identities(self): + self.assertEqual(build_identity({'server':'1'})['role'], 'server') + self.assertEqual(build_identity({'mingwcross':'1'})['toolchain'], 'mingwcross') + self.assertEqual(build_identity({})['role'], 'client') + self.assertTrue(build_identity({})['native_wss']) + self.assertFalse(build_identity({'wss':'0'})['native_wss']) + self.assertNotEqual(default_directory(build_identity({})), + default_directory(build_identity({'wss':'0'}))) + + def test_incompatible_explicit_directory_is_rejected(self): + with tempfile.TemporaryDirectory() as path: + native = build_identity({}) + prepare_directory(path,native) + prepare_directory(path,native) + with self.assertRaises(ValueError): + prepare_directory(path,build_identity({'target':'web'})) + self.assertEqual(json.loads((Path(path)/'identity.json').read_text()),native) + + def test_invalid_combinations_fail_before_toolchain_initialization(self): + for args in ({'target':'unknown'}, {'target':'web','server':'1'}, {'target':'web','mingwcross':'1'}, {'role':'unknown'}): + with self.subTest(args=args), self.assertRaises(ValueError): + build_identity(args) + + def test_identical_generated_header_does_not_invalidate_objects(self): + with tempfile.TemporaryDirectory() as directory: + path=Path(directory)/'include/glob2/BuildConfig.h' + write_if_changed(path,'#pragma once\n') + stamp=path.stat().st_mtime_ns + write_if_changed(path,'#pragma once\n') + self.assertEqual(stamp,path.stat().st_mtime_ns) + write_if_changed(path,'#pragma once\n#define CHANGED 1\n') + self.assertIn('CHANGED',path.read_text()) + + def test_same_identity_cannot_have_two_concurrent_writers(self): + with tempfile.TemporaryDirectory() as directory: + with BuildLock(directory): + code = "import sys; sys.path.insert(0,sys.argv[1]); from build_layout import BuildLock; BuildLock(sys.argv[2])" + process = subprocess.run([sys.executable, '-c', code, str(Path(__file__).resolve().parents[2]/'scons'), directory], capture_output=True, text=True) + self.assertNotEqual(process.returncode,0) + self.assertIn('Another build is using',process.stderr) + with BuildLock(directory): + pass + + def test_shared_manifests_are_valid_and_unique(self): + root=Path(__file__).resolve().parents[2] + for prefix,files in [('src',CLIENT_SOURCES),('src',SERVER_SOURCES),('libgag/src',GAG_SOURCES),('libusl/src',USL_SOURCES)]: + self.assertEqual(len(files),len(set(files))) + for filename in files: + self.assertTrue((root/prefix/filename).is_file(),filename) + +if __name__ == '__main__': + unittest.main() diff --git a/tests/build_system/test_platform_boundary.py b/tests/build_system/test_platform_boundary.py new file mode 100644 index 000000000..53ead0fdd --- /dev/null +++ b/tests/build_system/test_platform_boundary.py @@ -0,0 +1,25 @@ +"""Dependency rules for the shared game and platform implementations.""" +from pathlib import Path +import re +import unittest + + +class PlatformBoundaryTests(unittest.TestCase): + def test_shared_code_does_not_embed_browser_api_calls(self): + root = Path(__file__).resolve().parents[2] + forbidden = re.compile(r'\b(?:EM_ASM\w*|EM_JS|emscripten_\w+)\s*\(|#\s*include\s*[<"]emscripten') + for directory in ('src', 'libgag', 'libusl'): + for path in (root / directory).rglob('*'): + if path.suffix in ('.cpp', '.h'): + self.assertIsNone(forbidden.search(path.read_text()), str(path.relative_to(root))) + + def test_no_delay_macro_redefines_sdl_behavior(self): + root = Path(__file__).resolve().parents[2] + for directory in ('src', 'libgag', 'browser'): + for suffix in ('*.cpp', '*.h'): + for path in (root / directory).rglob(suffix): + self.assertNotRegex(path.read_text(), r'#\s*define\s+SDL_Delay\b', str(path)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/deployment/test_compose.py b/tests/deployment/test_compose.py new file mode 100644 index 000000000..840f979b1 --- /dev/null +++ b/tests/deployment/test_compose.py @@ -0,0 +1,144 @@ +"""Real Compose/TLS/YOG regression. Build deploy/Dockerfile and Wasm first. + +Uses an isolated project, ephemeral host ports, and disposable volumes. +""" +import base64 +import http.client +import os +from pathlib import Path +import re +import socket +import ssl +import struct +import subprocess +import sys +import time +import unittest +import uuid + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / 'tests/gateway')) +from test_gateway import receive, send_frame, read_frame + + +def free_port(): + with socket.socket() as sock: + sock.bind(('127.0.0.1', 0)) + return sock.getsockname()[1] + + +def text(value): + data = value.encode() + return struct.pack('!I', len(data)) + data + + +class ComposeTests(unittest.TestCase): + @classmethod + def compose(cls, *args): + return subprocess.check_output(['docker', 'compose', '-p', cls.project, + '-f', str(ROOT / 'deploy/compose.yaml'), *args], env=cls.env, text=True, + stderr=subprocess.STDOUT, timeout=180) + + @classmethod + def setUpClass(cls): + cls.project = 'glob2-test-' + uuid.uuid4().hex[:12] + cls.port = free_port() + cls.env = dict(os.environ, GLOB2_SITE='https://localhost', + GLOB2_ORIGIN=f'https://localhost:{cls.port}', GLOB2_HTTPS_PORT=str(cls.port), + GLOB2_HTTP_PORT=str(free_port())) + cls.protocol = int(re.search(r'#define NET_PROTOCOL_VERSION (\d+)', + (ROOT / 'src/Version.h').read_text()).group(1)) + cls.addClassCleanup(cls.compose, 'down', '--volumes', '--remove-orphans') + try: + cls.compose('up', '-d', '--wait', '--wait-timeout', '120', '--no-build') + ca = cls.compose('exec', '-T', 'web', 'cat', '/data/caddy/pki/authorities/local/root.crt') + cls.tls = ssl.create_default_context(cadata=ca) + except Exception: + print(cls.compose('logs', '--tail', '50')) + raise + + def connect(self, path='/yog'): + sock = self.tls.wrap_socket(socket.create_connection(('127.0.0.1', self.port), timeout=10), + server_hostname='localhost') + self.addCleanup(sock.close) + key = base64.b64encode(os.urandom(16)).decode() + sock.sendall((f'GET {path} HTTP/1.1\r\nHost: localhost:{self.port}\r\n' + f'Origin: https://localhost:{self.port}\r\nUpgrade: websocket\r\n' + f'Connection: Upgrade\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: {key}\r\n\r\n').encode()) + headers = b'' + while not headers.endswith(b'\r\n\r\n'): + headers += receive(sock, 1) + self.assertEqual(int(headers.split(b' ')[1]), 101) + return sock, bytearray() + + def send(self, connection, opcode, payload=b''): + send_frame(connection[0], struct.pack('!H', len(payload) + 1) + bytes([opcode]) + payload) + + def wait_message(self, connection, types): + sock, buffered = connection + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + while len(buffered) >= 2: + size = int.from_bytes(buffered[:2], 'big') + if len(buffered) < size + 2: + break + message = bytes(buffered[2:size + 2]); del buffered[:size + 2] + if message[0] == 5: + self.send(connection, 6) + if message[0] in types: + return message + opcode, data = read_frame(sock) + if opcode == 9: + send_frame(sock, data, opcode=10) + else: + self.assertEqual(opcode, 2) + buffered.extend(data) + self.fail('Timed out waiting for YOG message') + + def login(self, username, register=False): + connection = self.connect() + self.send(connection, 9, struct.pack('!H', self.protocol)) + self.wait_message(connection, {10}) + self.send(connection, 2 if register else 1, text(username) + text('fixture-only')) + response = self.wait_message(connection, {0, 4, 7, 8}) + self.assertEqual(response[0], 0 if register else 4) + return connection + + def test_assets_tls_and_private_routes(self): + for target, status in [('/', 200), ('/index.wasm', 200), ('/metrics', 404), ('/healthz', 404)]: + client = http.client.HTTPSConnection('localhost', self.port, context=self.tls, timeout=10) + try: + client.request('HEAD', target) + response = client.getresponse() + self.assertEqual(response.status, status) + finally: + client.close() + self.connect('/router') + + def test_account_survives_recreation_and_router_loss_is_refused(self): + username = 'compose' + uuid.uuid4().hex + connection = self.login(username, register=True) + connection[0].close() + self.compose('stop', 'router') + connection = self.login(username) + # Wait for the lobby to observe router closure before requesting a room. + deadline = time.monotonic() + 10 + while True: + self.send(connection, 15, text('Deployment test')) + response = self.wait_message(connection, {16, 17}) + if response[0] == 17: + self.assertEqual(response[1], 1) # No router available. + break + self.send(connection, 22) # Leave a room accepted before loss was observed. + self.assertLess(time.monotonic(), deadline) + connection[0].close() + self.compose('up', '-d', '--force-recreate', '--wait', '--wait-timeout', '120', '--no-build', 'lobby', 'router', 'gateway') + connection = self.login(username) + self.send(connection, 15, text('Deployment test')) + self.assertEqual(self.wait_message(connection, {16, 17})[0], 16) + logs = self.compose('logs', 'lobby', 'router', 'gateway') + self.assertNotIn('fixture-only', logs) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/gateway/test_gateway.py b/tests/gateway/test_gateway.py new file mode 100644 index 000000000..843ffa76c --- /dev/null +++ b/tests/gateway/test_gateway.py @@ -0,0 +1,162 @@ +"""Black-box gateway tests using real TCP and RFC 6455 frames; no pip dependencies.""" +import base64 +import hashlib +import http.client +import os +from pathlib import Path +import platform +import socket +import socketserver +import struct +import subprocess +import threading +import unittest + + +class Echo(socketserver.BaseRequestHandler): + def handle(self): + while data := self.request.recv(4096): + self.request.sendall(data) + + +class EchoServer(socketserver.ThreadingTCPServer): + daemon_threads = True + + +def receive(sock, size): + result = b'' + while len(result) < size: + part = sock.recv(size - len(result)) + if not part: + raise EOFError('Connection closed') + result += part + return result + + +def send_frame(sock, payload, opcode=2, final=True): + mask = os.urandom(4) + length = len(payload) + header = bytes([(128 if final else 0) | opcode]) + if length < 126: + header += bytes([128 | length]) + elif length < 65536: + header += b'\xfe' + struct.pack('!H', length) + else: + header += b'\xff' + struct.pack('!Q', length) + sock.sendall(header + mask + bytes(b ^ mask[i % 4] for i, b in enumerate(payload))) + + +def read_frame(sock): + flags, length = receive(sock, 2) + if length & 128: + raise AssertionError('Server must not mask frames') + if length == 126: + length = struct.unpack('!H', receive(sock, 2))[0] + elif length == 127: + length = struct.unpack('!Q', receive(sock, 8))[0] + return flags & 15, receive(sock, length) + + +class GatewayTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.backend = EchoServer(('127.0.0.1', 0), Echo) + cls.thread = threading.Thread(target=cls.backend.serve_forever, daemon=True) + cls.thread.start() + root = Path(__file__).resolve().parents[2] + binary = os.environ.get('GLOB2_GATEWAY', str(root / 'build' / platform.system().lower() / 'gateway/release/glob2-ws-gateway')) + cls.process = subprocess.Popen([binary, '--port', '0', '--lobby-port', str(cls.backend.server_address[1]), + '--router-port', str(cls.backend.server_address[1])], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + line = cls.process.stdout.readline() + if not line.startswith('gateway listening on '): + raise RuntimeError('Gateway startup failed: ' + line + cls.process.stderr.read()) + cls.port = int(line.strip().rsplit(':', 1)[1]) + + @classmethod + def tearDownClass(cls): + cls.process.terminate() + cls.process.communicate(timeout=5) + cls.backend.shutdown() + cls.backend.server_close() + cls.thread.join() + + def connect(self, path='/yog', origin='http://127.0.0.1:8765', expected=101, extra=b''): + sock = socket.create_connection(('127.0.0.1', self.port), timeout=3) + self.addCleanup(sock.close) + key = base64.b64encode(os.urandom(16)).decode() + request = f'GET {path} HTTP/1.1\r\nHost: localhost\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: {key}\r\n' + if origin is not None: + request += f'Origin: {origin}\r\n' + sock.sendall((request + '\r\n').encode() + extra) + response = b'' + while not response.endswith(b'\r\n\r\n'): + response += receive(sock, 1) + self.assertEqual(int(response.split(b' ')[1]), expected) + if expected == 101: + accept = base64.b64encode(hashlib.sha1((key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').encode()).digest()) + self.assertIn(accept, response) + return sock + + def assert_echo(self, sock, payload): + result = b'' + while len(result) < len(payload): + opcode, data = read_frame(sock) + self.assertIn(opcode, (0, 2)) + result += data + self.assertEqual(result, payload) + + def test_binary_stream_both_backends(self): + for path in ('/yog', '/router'): + with self.subTest(path=path): + sock = self.connect(path) + payload = bytes(range(256)) * 240 + send_frame(sock, payload) + self.assert_echo(sock, payload) + + def test_fragmented_message_and_consecutive_messages(self): + sock = self.connect() + send_frame(sock, b'first', final=False) + send_frame(sock, b'second', opcode=0) + send_frame(sock, b'third') + self.assert_echo(sock, b'firstsecondthird') + + def test_native_without_origin(self): + sock = self.connect(origin=None) + send_frame(sock, b'native') + self.assert_echo(sock, b'native') + + def test_reject_origin_and_arbitrary_backend(self): + self.connect(origin='https://untrusted.example', expected=403) + self.connect(path='/localhost:22', expected=404) + + def test_text_is_not_forwarded(self): + sock = self.connect() + send_frame(sock, b'text', opcode=1) + self.assertEqual(sock.recv(1), b'') + + def test_http_input_cannot_be_forwarded_as_websocket_payload(self): + self.connect(extra=b'not a websocket frame', expected=400) + + def test_oversized_message(self): + sock = self.connect() + send_frame(sock, b'x' * 65537) + opcode, data = read_frame(sock) + self.assertEqual(opcode, 8) + self.assertEqual(struct.unpack('!H', data[:2])[0], 1009) + + def test_health_and_metrics(self): + conn = http.client.HTTPConnection('127.0.0.1', self.port, timeout=3) + self.addCleanup(conn.close) + conn.request('GET', '/healthz') + response = conn.getresponse() + self.assertEqual(response.status, 200) + self.assertEqual(response.read(), b'ok\n') + conn.request('GET', '/metrics') + response = conn.getresponse() + self.assertEqual(response.status, 200) + self.assertIn(b'glob2_gateway_connections ', response.read()) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/transport/test_wss.py b/tests/transport/test_wss.py new file mode 100644 index 000000000..1dcd9c389 --- /dev/null +++ b/tests/transport/test_wss.py @@ -0,0 +1,120 @@ +"""Native WSS certificate, hostname, framing, cancellation, and timeout tests.""" +import base64 +import hashlib +import os +from pathlib import Path +import platform +import socketserver +import ssl +import struct +import subprocess +import sys +import tempfile +import threading +import time +import unittest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / 'tests/gateway')) +from test_gateway import receive + + +class Peer(socketserver.BaseRequestHandler): + def handle(self): + try: + sock = self.request + sock.settimeout(15) + if self.server.mode == 'stall': + self.server.stopped.wait(13) + return + with self.server.tls.wrap_socket(sock, server_side=True) as sock: + headers = b'' + while not headers.endswith(b'\r\n\r\n'): + headers += receive(sock, 1) + self.server.path = headers.split(b' ')[1].decode() + values = dict(line.split(b':', 1) for line in headers.split(b'\r\n')[1:] if b':' in line) + key = values[b'Sec-WebSocket-Key'].strip() + accept = base64.b64encode(hashlib.sha1(key + b'258EAFA5-E914-47DA-95CA-C5AB0DC85B11').digest()) + sock.sendall(b'HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ' + accept + b'\r\n\r\n') + while True: + flags, length = receive(sock, 2) + if not length & 128: + raise AssertionError('Client frame was not masked') + length &= 127 + if length == 126: length = struct.unpack('!H', receive(sock, 2))[0] + elif length == 127: length = struct.unpack('!Q', receive(sock, 8))[0] + mask = receive(sock, 4) + body = receive(sock, length) + body = bytes(b ^ mask[i % 4] for i, b in enumerate(body)) + if self.server.mode == 'oversized': body = bytes(65537) + opcode = 1 if self.server.mode == 'text' else 2 + if len(body) < 126: header = bytes([128 | opcode, len(body)]) + elif len(body) < 65536: header = bytes([128 | opcode, 126]) + struct.pack('!H', len(body)) + else: header = bytes([128 | opcode, 127]) + struct.pack('!Q', len(body)) + sock.sendall(header + body) + except (OSError, EOFError): + pass + + +class Server(socketserver.ThreadingTCPServer): + daemon_threads = True + allow_reuse_address = True + + +class WssTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.directory = tempfile.TemporaryDirectory(prefix='glob2-wss-') + cls.addClassCleanup(cls.directory.cleanup) + cls.cert = Path(cls.directory.name) / 'cert.pem' + key = Path(cls.directory.name) / 'key.pem' + subprocess.run(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', + '-keyout', str(key), '-out', str(cls.cert), '-days', '1', '-subj', '/CN=localhost', + '-addext', 'subjectAltName=DNS:localhost'], check=True, capture_output=True) + cls.tls = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + cls.tls.load_cert_chain(cls.cert, key) + cls.binary = ROOT / f'build/{platform.system().lower()}/client/release/src/wss-transport-test' + + def run_peer(self, mode='echo', probe='echo', trusted=True, hostname='localhost'): + with Server(('127.0.0.1', 0), Peer) as server: + server.mode = mode; server.tls = self.tls; server.path = None + server.stopped = threading.Event() + worker = threading.Thread(target=server.serve_forever, daemon=True); worker.start() + try: + env = dict(os.environ) + env['SSL_CERT_FILE'] = str(self.cert) if trusted else str(Path(self.directory.name) / 'absent.pem') + result = subprocess.run([str(self.binary), f'wss://{hostname}:{server.server_address[1]}', probe], + env=env, capture_output=True, text=True, timeout=16) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + if probe in ('echo', 'router'): + self.assertEqual(server.path, '/router' if probe == 'router' else '/yog') + finally: + server.stopped.set(); server.shutdown(); worker.join() + + def test_verified_echo_and_fixed_routes(self): + self.run_peer(); self.run_peer(probe='router') + + def test_untrusted_certificate(self): + self.run_peer(probe='refuse', trusted=False) + + def test_wrong_hostname(self): + self.run_peer(probe='refuse', hostname='127.0.0.1') + + def test_text_and_oversized_messages_close(self): + self.run_peer(mode='text', probe='badframe') + self.run_peer(mode='oversized', probe='badframe') + + def test_stalled_tls_can_be_cancelled(self): + self.run_peer(mode='stall', probe='cancel') + + def test_stalled_tls_times_out(self): + self.run_peer(mode='stall', probe='timeout') + + def test_credential_and_path_urls_are_rejected(self): + for url in ('wss://user:password@localhost', 'wss://localhost/router', 'wss://localhost?token=secret'): + result = subprocess.run([str(self.binary), url, 'refuse'], capture_output=True, text=True, timeout=3) + self.assertEqual(result.returncode, 0, result.stderr) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/transport/tls_forwarder.py b/tests/transport/tls_forwarder.py new file mode 100644 index 000000000..978762128 --- /dev/null +++ b/tests/transport/tls_forwarder.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Isolated TLS terminator for browser/native WSS match tests, not deployment.""" +import asyncio +from pathlib import Path +import ssl +import subprocess +import sys + +async def main(): + directory, backend = Path(sys.argv[1]), int(sys.argv[2]) + cert, key = directory / 'cert.pem', directory / 'key.pem' + subprocess.run(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', + '-keyout', str(key), '-out', str(cert), '-days', '1', '-subj', '/CN=localhost', + '-addext', 'subjectAltName=DNS:localhost'], check=True, capture_output=True) + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(cert, key) + async def accept(reader, writer): + upstream = None + tasks = [] + async def copy(source, destination): + while chunk := await source.read(16384): + destination.write(chunk) + await destination.drain() + try: + remote, upstream = await asyncio.open_connection('127.0.0.1', backend) + tasks = [asyncio.create_task(copy(reader, upstream)), asyncio.create_task(copy(remote, writer))] + await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) + except (OSError, asyncio.IncompleteReadError): + pass + finally: + for task in tasks: task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + writer.close() + if upstream: upstream.close() + server = await asyncio.start_server(accept, '127.0.0.1', 0, ssl=context) + print(f'TLS forwarder listening on {server.sockets[0].getsockname()[1]}', flush=True) + async with server: await server.serve_forever() + +asyncio.run(main()) diff --git a/tools/MenuColonyHarness.cpp b/tools/MenuColonyHarness.cpp index b175fe54d..36f54fb34 100644 --- a/tools/MenuColonyHarness.cpp +++ b/tools/MenuColonyHarness.cpp @@ -22,6 +22,7 @@ #include "GameGUI.h" #include "Engine.h" #include "MapEdit.h" +#include "MapEditorScreen.h" #include "EndGameScreen.h" #include "CampaignMenuScreen.h" #include "CampaignSelectorScreen.h" @@ -41,6 +42,7 @@ #include #include #include +#include #include #include #include @@ -52,8 +54,6 @@ GlobalContainer* globalContainer=nullptr; using namespace GAGCore; std::string replayFilenameToName(const std::string&); -std::string getSyncRandState() { std::ostringstream out; out << randomGenerator; return out.str(); } - void require(bool condition, const char* message) { if (!condition) { std::cerr << "FAIL: " << message << '\n'; std::exit(2); } @@ -255,10 +255,11 @@ template class Preview : public T void capture(const std::string& name,const std::string& path) { FrontendScope scope; + GAGGUI::ScreenStack screens(*globalContainer->gfx); if(name=="colony") { FrontendTheme::current->colony->draw(globalContainer->gfx->getW(),globalContainer->gfx->getH()); } else if(name=="main" || name=="fallback") { Preview s; s.render(); s.checkBounds(); } - else if(name=="options") { Preview parent; parent.selectFirstListItem(); Preview s(parent.getGameHeader(),parent.getMapHeader(),false); s.render(); s.checkBounds(); } - else if(name=="save-replay") { Preview parent; parent.render(); LoadSaveScreen s("replays","replay",false,"Save replay","",replayFilenameToName,glob2NameToFilename); s.dispatchPaint(); globalContainer->gfx->drawSurface(s.decX,s.decY,s.getSurface()); } + else if(name=="options") { Preview parent(screens); parent.selectFirstListItem(); Preview s(parent.getGameHeader(),parent.getMapHeader(),false); s.render(); s.checkBounds(); } + else if(name=="save-replay") { Preview parent(screens); parent.render(); LoadSaveScreen s("replays","replay",false,"Save replay","",replayFilenameToName,glob2NameToFilename); s.dispatchPaint(); globalContainer->gfx->drawSurface(s.decX,s.decY,s.getSurface()); } else if(name=="settings" || name=="settings-buildings" || name=="settings-keys") { Preview s; @@ -266,16 +267,16 @@ void capture(const std::string& name,const std::string& path) if(name=="settings-keys") s.selectCategory(SettingsScreen::Category::Controls); s.render(); s.checkBounds(); } - else if(name=="lan") { Preview s; s.render(); s.checkBounds(); } - else if(name=="campaign") { Preview s; s.render(); s.checkBounds(); } - else if(name=="editor") { Preview s; s.render(); s.checkBounds(); } + else if(name=="lan") { Preview s(screens); s.render(); s.checkBounds(); } + else if(name=="campaign") { Preview s(screens); s.render(); s.checkBounds(); } + else if(name=="editor") { Preview s(screens); s.render(); s.checkBounds(); } else if(name=="credits") { Preview s; s.advance(450); s.render(); s.checkBounds(); } else if(name=="load") { Preview s("games","game",true); s.render(); s.checkBounds(); } - else if(name=="missions") { Preview s("campaigns/Tutorial_Campaign.txt"); s.render(); s.checkBounds(); } + else if(name=="missions") { Preview s("campaigns/Tutorial_Campaign.txt",screens); s.render(); s.checkBounds(); } else if(name=="campaign-select") { Preview s; s.render(); s.checkBounds(); } else if(name=="new-map") { Preview s; s.render(); s.checkBounds(); } - else if(name=="lan-find") { Preview s; s.render(); s.checkBounds(); } - else if(name=="login") { Preview s(std::make_shared()); s.render(); s.checkBounds(); } + else if(name=="lan-find") { Preview s(screens); s.render(); s.checkBounds(); } + else if(name=="login") { Preview s(screens,std::make_shared()); s.render(); s.checkBounds(); } else if(name=="register") { Preview s(std::make_shared()); s.render(); s.checkBounds(); } else if(name=="results") { @@ -287,7 +288,7 @@ void capture(const std::string& name,const std::string& path) } else if(name=="custom" || name=="custom-players" || name=="custom-rules") { - Preview s; s.prepare(); + Preview s(screens); s.prepare(); if(name=="custom-players") s.activateGroup(1); if(name=="custom-rules") s.activateGroup(2); s.render(); s.checkBounds(); @@ -437,6 +438,7 @@ std::cout << "global_assets_ms=" << std::chrono::duration(std } { FrontendScope scope; + GAGGUI::ScreenStack screens(*globalContainer->gfx); LoadSaveScreen dialog("replays","replay",false,"Save replay","",replayFilenameToName,glob2NameToFilename); dialog.dispatchPaint(); SDL_Event text{}; text.type=SDL_TEXTINPUT; SDL_strlcpy(text.text.text,"colony-review",sizeof(text.text.text)); @@ -446,7 +448,7 @@ std::cout << "global_assets_ms=" << std::chrono::duration(std require(std::string(dialog.getName())=="colony-revie","replay dialog editing"); key.key.keysym.sym=SDLK_ESCAPE; dialog.dispatchEvents(&key); require(dialog.endValue==LoadSaveScreen::CANCEL,"replay dialog cancellation"); - Preview custom; custom.selectFirstListItem(); custom.render(); + Preview custom(screens); custom.selectFirstListItem(); custom.render(); require(custom.getMapHeader().getNumberOfTeams()>0,"map selection loads teams"); key.key.keysym.sym=SDLK_ESCAPE; custom.dispatchEvents(&key); require(custom.result()==CustomGameScreen::CANCEL,"custom game cancellation"); @@ -471,11 +473,13 @@ std::cout << "global_assets_ms=" << std::chrono::duration(std } globals.replaying=false; globals.replayFastForward=false; { - MapEdit editor; - require(editor.load("maps/balanced.map"),"load editor fixture"); + auto editor=std::make_unique(); + require(editor->load("maps/balanced.map"),"load editor fixture"); + GAGGUI::ScreenStack screens(*globals.gfx); + screens.push(std::make_unique(screens,std::move(editor))); SessionExit sequence{0,globals.gfx->getW()/2,globals.gfx->getH()/2+75}; const auto timer=SDL_AddTimer(500,exitSession,&sequence); require(timer,"editor input timer"); - const int result=editor.run(); SDL_RemoveTimer(timer); + const int result=screens.execute(40); SDL_RemoveTimer(timer); require(result==0,"return from editor"); require(GAGGUI::Style::style==&theme && FrontendTheme::allowed,"editor restores menu theme"); } @@ -509,18 +513,19 @@ std::cout << "global_assets_ms=" << std::chrono::duration(std } if(mode=="navigation") { + GAGGUI::ScreenStack screens(*globals.gfx); { Preview s; s.executeCancellation(); } - { Preview s; s.executeCancellation(); } + { Preview s(screens); s.executeCancellation(); } { Preview s; s.executeCancellation(); } - { Preview s("campaigns/Tutorial_Campaign.txt"); s.executeCancellation(); } - { Preview s; s.selectFirstListItem(); s.executeKeyboardCancellation(); } + { Preview s("campaigns/Tutorial_Campaign.txt",screens); s.executeCancellation(); } + { Preview s(screens); s.selectFirstListItem(); s.executeKeyboardCancellation(); } { Preview s("games","game",true); s.executeCancellation(); } { Preview s; s.executeEscape(); } - { Preview s; s.executeCancellation(); } + { Preview s(screens); s.executeCancellation(); } { Preview s; s.executeCancellation(); } - { Preview s; s.executeCancellation(); } - { Preview s; s.executeCancellation(); } - { Preview s(std::make_shared()); s.executeCancellation(); } + { Preview s(screens); s.executeCancellation(); } + { Preview s(screens); s.executeCancellation(); } + { Preview s(screens,std::make_shared()); s.executeCancellation(); } { Preview s(std::make_shared()); s.executeCancellation(); } { Preview s; s.executeCancellation(); } std::cout << "PASS: actual screen loops, mouse/keyboard exits, theme restoration\n";