From c8ccb4d91d264042380a5ab49edc670d5c106c19 Mon Sep 17 00:00:00 2001 From: Bradley Arsenault Date: Mon, 7 Sep 2026 20:58:04 -0400 Subject: [PATCH 01/20] Add isolated WebAssembly browser build and host gateway Introduce the browser shell and pinned Emscripten toolchain, isolate native and web build outputs, and add the bounded WebSocket gateway. Keep the native build usable alongside browser artifacts. --- .github/workflows/build.yml | 66 ++- .gitignore | 6 + SConstruct | 81 ++-- browser/BrowserPlatform.h | 6 + browser/README.md | 80 ++++ browser/VoiceRecorder.cpp | 8 + browser/build.py | 8 + browser/setup.py | 14 + browser/shell.html | 64 +++ browser/smoke-test.js | 76 ++++ browser/toolchain.json | 5 + docs/browser/adr-001-build-isolation.md | 36 ++ docs/browser/gateway.md | 63 +++ docs/browser/implementation.md | 51 +++ libgag/include/GAGSys.h | 2 +- libgag/src/FileManager.cpp | 2 +- libgag/src/GUIBase.cpp | 3 + libgag/src/GraphicContext.cpp | 13 +- libgag/src/GraphicContextPrivate.h | 2 +- libgag/src/GraphicContextResize.cpp | 9 +- libgag/src/SConscript | 23 +- libusl/src/SConscript | 7 +- scons/build_layout.py | 91 ++++ scons/gateway_build.py | 34 ++ scons/sources.py | 549 ++++++++++++++++++++++++ scons/web_build.py | 81 ++++ src/EngineRun.cpp | 8 +- src/FertilityCalculatorDialog.cpp | 10 + src/Glob2.cpp | 16 +- src/GlobalContainerArgs.cpp | 2 +- src/MainMenuScreen.cpp | 2 +- src/SConscript | 478 +-------------------- src/SoundMixer.cpp | 10 +- src/VoiceRecorder.cpp | 2 +- src/VoiceRecorder.h | 6 +- src/gui/GameGUI.cpp | 2 +- src/gui/GameGUIOrders.cpp | 2 +- src/gui/GameGUIPersistence.cpp | 2 +- src/gui/GameGUIStep.cpp | 2 +- src/net/gateway/Gateway.cpp | 231 ++++++++++ src/net/irc/IRC.cpp | 2 +- test/SConstruct | 15 +- test/run-game-speed-tests.py | 4 +- tests/build_system/coexistence.py | 53 +++ tests/build_system/test_layout.py | 70 +++ tests/gateway/test_gateway.py | 162 +++++++ 46 files changed, 1897 insertions(+), 562 deletions(-) create mode 100644 browser/BrowserPlatform.h create mode 100644 browser/README.md create mode 100644 browser/VoiceRecorder.cpp create mode 100644 browser/build.py create mode 100644 browser/setup.py create mode 100644 browser/shell.html create mode 100644 browser/smoke-test.js create mode 100644 browser/toolchain.json create mode 100644 docs/browser/adr-001-build-isolation.md create mode 100644 docs/browser/gateway.md create mode 100644 docs/browser/implementation.md create mode 100644 scons/build_layout.py create mode 100644 scons/gateway_build.py create mode 100644 scons/sources.py create mode 100644 scons/web_build.py create mode 100644 src/net/gateway/Gateway.cpp create mode 100644 tests/build_system/coexistence.py create mode 100644 tests/build_system/test_layout.py create mode 100644 tests/gateway/test_gateway.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0f02f7838..d42aec044 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -82,6 +82,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 @@ -119,19 +122,19 @@ jobs: - 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: | @@ -146,7 +149,7 @@ jobs: - 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: | @@ -166,8 +169,8 @@ jobs: - 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: | @@ -182,17 +185,17 @@ jobs: - 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: @@ -241,7 +244,7 @@ jobs: - 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: | @@ -302,6 +305,9 @@ jobs: - 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 @@ -337,7 +343,7 @@ jobs: - 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/windows/client/release/src/SavegameSafetyHarness.exe - name: Test team statistics save compatibility run: | @@ -348,3 +354,37 @@ jobs: - name: Build the YOG server run: scons -j$(nproc) release=1 mingw=1 server=1 --build=build-server + + web-coexistence: + name: browser and native (${{ matrix.order }}) + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + order: [native-first, web-first, concurrent] + steps: + - uses: actions/checkout@v4 + - name: Install native and build dependencies + run: | + 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 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: Verify clean builds and incremental isolation + run: python3 tests/build_system/coexistence.py --order ${{ matrix.order }} + - name: Build and test gateway + run: | + scons role=gateway release=1 -j2 + python3 -m unittest discover -s tests/gateway -v + - name: Build headless router + run: scons role=router release=1 -j2 + - uses: actions/upload-artifact@v4 + with: + name: glob2-web-development-${{ matrix.order }} + 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..f26c90023 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,9 @@ Glob2-*.dmg /build-server/ /artifacts/ +/tools/browser-emsdk/ +/build-browser/ +/build-browser.log +/build-*-support.log +/test/build/ +/build-*.log diff --git a/SConstruct b/SConstruct index f295acf2d..fda98a60a 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") @@ -35,17 +39,19 @@ 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.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): + write_if_changed(self.path, self.f.getvalue()) + def add(self, variable, doc, value=""): self.f.write("// %s\n" % doc) self.f.write("#define %s %s\n" % (variable, value)) @@ -53,8 +59,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: @@ -235,6 +241,7 @@ def configure(env, server_only): print("Missing %s" % t) Exit(1) + configfile.finish() conf.Finish() contents = configfile.f.getvalue() previous = None @@ -254,8 +261,28 @@ def main(): metavar='portaudio', help='should portaudio be used') AddOption('--build', - default='build', + default=None, help='build directory') + 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() # SCons scrubs the shell environment for build commands; without TMPDIR, # tools like ar fall back to /tmp, which sandboxed environments may block. @@ -264,12 +291,21 @@ def main(): # 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'] + 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 +363,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 +417,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 +441,6 @@ def main(): Export('crossroot_abs') Export('isWindowsPlatform') - bdir = GetOption('build') targets = [ "campaigns", "data", diff --git a/browser/BrowserPlatform.h b/browser/BrowserPlatform.h new file mode 100644 index 000000000..78acb7a97 --- /dev/null +++ b/browser/BrowserPlatform.h @@ -0,0 +1,6 @@ +#pragma once +#include +#include +#include +// Every desktop wait must yield so browser input and audio can run. +#define SDL_Delay(ms) emscripten_sleep((ms) > 0 ? (ms) : 1) diff --git a/browser/README.md b/browser/README.md new file mode 100644 index 000000000..1ebab6161 --- /dev/null +++ b/browser/README.md @@ -0,0 +1,80 @@ +# Single-player browser experiment + +This target compiles Globulation 2 to WebAssembly with Emscripten 4.0.15. +It uses the SDL2 software renderer, Asyncify for browser event-loop yielding, +and IndexedDB for local saves. Multiplayer menu entries and voice recording +are disabled. Native builds keep their normal implementations. + +## 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 Tutorial, Campaign, or Custom Game. Clicking the canvas focuses keyboard +input and enables music. Save through the in-game menu; IndexedDB automatically +persists files when they close. Saves belong to this browser profile and +origin (including the port); clearing site data deletes them. Reload after +choosing Quit to restart. The game starts at the viewport resolution; resizing +the page scales the canvas proportionally and may add black bars. + +## Scope + +This is a desktop-browser experiment with mouse and keyboard controls. +Networking code remains compiled to satisfy existing dependencies but the +multiplayer entry points are hidden. Voice chat is a no-op; music uses the +existing Vorbis mixer. Map fertility calculation runs cooperatively on the +browser thread. There is no WebGL renderer rewrite or mobile UI adaptation. + +`Module.glob2Tick`, `Module.glob2Screen`, and `Module.browserLog` expose +simulation and UI diagnostics for smoke checks without adding page controls. + +## Verified experiment results + +Tested in Chromium on 2026-09-07 (America/Toronto): + +- Automatic startup into the native menu, with only a canvas on the page. +- Canvas and viewport both 1200x900; no HTML buttons or visible wrapper text. +- Custom match on A big pond with the default AI players: 24.93 simulation + ticks/second over a 125-tick sample (normal target: 25 ticks/second). +- Keyboard pause stops simulation advancement. +- In-game save writes a 2,593,629-byte `.game` file. Automatic IndexedDB + persistence restores identical SHA-256 bytes after page reload, and the + saved match loads and resumes. +- Tutorial Campaign / Introduction and Basics launches; browser audio context + runs after interaction. Audible output has not been independently checked. +- No uncaught JavaScript errors in the final custom-match/save/load smoke test. + +`browser/smoke-test.js` is a Playwright Page function exercising the final +custom-match, pause, persistence, and reload flow. It creates or overwrites +`Browser_smoke.game` in this local experiment's browser storage. + +The generated payload is about 26 MiB of assets, 12 MiB of WebAssembly, and +615 KiB of JavaScript, before HTTP compression. The build and SDK are local +outputs, not committed assets. Serve the output directory; opening the HTML +as a `file:` URL is unsupported. + +This does not establish large-map/late-game performance, full campaign +completion, Safari/Firefox compatibility, native/browser replay determinism, +or a production-ready port. Resizing scales the initial game resolution. +The SDL audio backend emits a ScriptProcessorNode deprecation warning; +legacy diagnostics also write informational music messages to stderr. 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/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/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..e3d26e18f --- /dev/null +++ b/browser/shell.html @@ -0,0 +1,64 @@ + + + + + + +Globulation 2 + + + + + +{{{ SCRIPT }}} + + diff --git a/browser/smoke-test.js b/browser/smoke-test.js new file mode 100644 index 000000000..5322fa35a --- /dev/null +++ b/browser/smoke-test.js @@ -0,0 +1,76 @@ +// Evaluate this function with a Playwright Page while the local server is running. +async (page) => { + const result = {}; + const errors = []; + page.on('pageerror', e => errors.push(String(e))); + await page.setViewportSize({width:1200, height:900}); + const open = async () => { + await page.goto('http://127.0.0.1:8765/'); + await page.waitForFunction(() => window.Module?.glob2Screen?.includes('MainMenuScreen')); + }; + const click = async (x, y) => { + await page.locator('#canvas').click({position:{x,y}, delay:80}); + }; + // Native menus use a centered 640x480 coordinate system. + const menu = async (x, y) => click(x + 280, y + 210); + const screen = async name => page.waitForFunction(name => Module.glob2Screen?.includes(name), name); + await open(); + result.page = await page.evaluate(() => ({ + buttons:document.querySelectorAll('button').length, + visibleText:document.body.innerText, + canvas:[Module.canvas.width,Module.canvas.height], + viewport:[innerWidth,innerHeight], + })); + if (result.page.buttons || result.page.visibleText.trim()) throw Error('Page contains wrapper UI'); + if (result.page.canvas.join() !== result.page.viewport.join()) throw Error('Game does not fill viewport'); + await menu(480,200); + await screen('CustomGameScreen'); + await menu(100,70); + await menu(530,380); + await page.waitForFunction(() => Module.glob2Tick > 25); + const before = await page.evaluate(() => ({tick:Module.glob2Tick, time:performance.now()})); + await page.waitForFunction(t => Module.glob2Tick >= t+125, before.tick, {timeout:15000}); + const after = await page.evaluate(() => ({tick:Module.glob2Tick, time:performance.now()})); + result.ticksPerSecond = (after.tick-before.tick)*1000/(after.time-before.time); + await page.locator('#canvas').press('p', {delay:80}); + await page.waitForTimeout(150); + const paused = await page.evaluate(() => Module.glob2Tick); + await page.waitForTimeout(250); + if (await page.evaluate(() => Module.glob2Tick) !== paused) throw Error('Pause did not stop simulation'); + result.pauseTick = paused; + await page.locator('#canvas').press('Escape', {delay:80}); + // In-game overlay: 320x260, centered in the viewport. + await click(600,400); + // Save overlay: 300x275, text field 190px from its top. + await click(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 smoke', {delay:20}); + await click(520,555); + const save = '/home/web_user/.glob2/games/Browser_smoke.game'; + await page.waitForFunction(path => FS.analyzePath(path).exists, save); + await page.waitForFunction(() => !Module.saveMount.idbPersistState); + const hash = async () => page.evaluate(async path => { + const data = FS.readFile(path); + const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', data)); + return {size:data.length,sha256:Array.from(digest).map(n=>n.toString(16).padStart(2,'0')).join('')}; + }, save); + result.saved = await hash(); + await open(); + result.restored = await hash(); + if (result.saved.sha256 !== result.restored.sha256) throw Error('Persistent save changed after reload'); + // Derive the smoke save row without removing other experiment saves. + const row = await page.evaluate(() => FS.readdir('/home/web_user/.glob2/games') + .filter(n=>n.endsWith('.game')).sort((a,b)=>a.replaceAll('_',' ').localeCompare(b.replaceAll('_',' '),undefined,{numeric:true,sensitivity:'base'})).indexOf('Browser_smoke.game')); + await menu(160,200); + await screen('ChooseMapScreen'); + // The bundled standard font renders list rows at 16px. + await menu(100,70 + row*16); + await menu(530,380); + await page.waitForFunction(t => Module.glob2Tick >= t, paused); + result.loadedTick = await page.evaluate(() => Module.glob2Tick); + result.audio = await page.evaluate(() => Module.SDL2?.audioContext?.state); + result.errors = errors; + if (errors.length) throw Error(errors.join('\n')); + return result; +} 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/docs/browser/adr-001-build-isolation.md b/docs/browser/adr-001-build-isolation.md new file mode 100644 index 000000000..d7200c1c7 --- /dev/null +++ b/docs/browser/adr-001-build-isolation.md @@ -0,0 +1,36 @@ +# 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. + +Validation consists of build identity unit tests plus real native-first, +web-first, and concurrent CI jobs. Subsequent alternating builds must preserve +object and artifact contents and timestamps, as well as tracked source files. +This does not replace platform-specific runtime and determinism tests. diff --git a/docs/browser/gateway.md b/docs/browser/gateway.md new file mode 100644 index 000000000..f024c6366 --- /dev/null +++ b/docs/browser/gateway.md @@ -0,0 +1,63 @@ +# Development WebSocket gateway + +This is transport infrastructure, not yet a supported multiplayer release. +The browser client still has multiplayer disabled. Protocol negotiation, native +WSS, browser transport integration, account migration, invitation rooms and +coordinated recovery remain separate delivery gates. + +## 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. + +## 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. A supported Compose distribution has not yet been delivered. + +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. diff --git a/docs/browser/implementation.md b/docs/browser/implementation.md new file mode 100644 index 000000000..fd002096e --- /dev/null +++ b/docs/browser/implementation.md @@ -0,0 +1,51 @@ +# Browser platform delivery + +The browser target is under development, not a supported release. The release +requires desktop browser single-player support, matching-release native +cross-play, YOG invitation rooms with guests and accounts, 120-second coordinated +reconnect, and self-hosted distribution. Mobile, voice chat, rankings, cloud saves, +late joining, and backend-restart match recovery are excluded. + +## Architecture contracts + +- SCons is the source of truth for every toolchain. Source manifests are plain + Python tuples; target/configuration identities own all generated outputs. +- Game and AI code must not call browser APIs. Platform implementations own + scheduling, graphics, storage, audio activation, and transport. +- YOG owns identities, rooms and match lifecycle. A fixed-backend WebSocket + gateway owns transport only. Simulation remains deterministic client lockstep. +- Durable persistence acknowledgment must follow successful storage completion. +- Resize is an application event applied between frames, never a reload. +- A reconnect checkpoint must include simulation and network continuation state, + exclude another player's local UI state, and pass checksum verification. + +## Release gates + +- [ ] Build coexistence across native client, lobby, router, gateway, and web +- [ ] Explicit application/screen scheduling without Asyncify +- [ ] WebGL2 rendering with context restoration and software fallback +- [ ] Live resize and focus/visibility lifecycle +- [ ] Transactional browser storage with import/export and failure handling +- [ ] Browser and native secure transports; compatible protocol handshake +- [ ] Invitation rooms, guests, optional accounts and credential migration +- [ ] Pause barriers, checkpoints and refresh recovery +- [ ] Self-hosting, immutable releases, backups, health checks and metrics +- [ ] Browser, native, deployment, determinism and fault-injection test gates + +## Build identity + +Default outputs are `build///`. `--build=PATH` overrides +that path, but PATH must belong to the same identity. Mixing identities is an +error. `identity.json` records ownership; generated configuration is +`include/glob2/BuildConfig.h`. Options are explicit on every invocation; emitted +`options.py`/`options.json` records inputs and is not silently loaded. + +Existing commands such as `scons release=1`, `scons server=1`, and +`scons mingwcross=1` keep selecting the same kinds of builds. Their default +artifact paths are now isolated. For example, the macOS release client is +`build/darwin/client/release/src/glob2`. Browser output is +`build/emscripten/client/release/index.html`. + +The experiment compatibility command `python3 browser/build.py` delegates to +SCons. Emscripten 4.0.15 and its checksum-verified ports (including Boost 1.83) +are selected independently of installed native development libraries. 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/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/GUIBase.cpp b/libgag/src/GUIBase.cpp index 5d17c6f01..637530cf8 100644 --- a/libgag/src/GUIBase.cpp +++ b/libgag/src/GUIBase.cpp @@ -405,6 +405,9 @@ namespace GAGGUI int Screen::execute(DrawableSurface *gfx, int stepLength) { +#ifdef __EMSCRIPTEN__ + EM_ASM({ Module['glob2Screen'] = UTF8ToString($0); }, typeid(*this).name()); +#endif Uint64 frameStartTime; Sint64 frameWaitTime; diff --git a/libgag/src/GraphicContext.cpp b/libgag/src/GraphicContext.cpp index a598eca91..df2f42276 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; @@ -470,7 +474,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); @@ -596,6 +602,9 @@ namespace GAGCore void GraphicContext::nextFrame(void) { +#ifdef __EMSCRIPTEN__ + emscripten_sleep(1); +#endif DrawableSurface::nextFrame(); if (sdlsurface) { diff --git a/libgag/src/GraphicContextPrivate.h b/libgag/src/GraphicContextPrivate.h index a8de232ff..0b4141ff8 100644 --- a/libgag/src/GraphicContextPrivate.h +++ b/libgag/src/GraphicContextPrivate.h @@ -10,7 +10,7 @@ #include #ifdef HAVE_CONFIG_H -#include +#include #endif #ifdef HAVE_OPENGL 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..9c68edbdc 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") 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/scons/build_layout.py b/scons/build_layout.py new file mode 100644 index 000000000..35333d448 --- /dev/null +++ b/scons/build_layout.py @@ -0,0 +1,91 @@ +"""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') + return {'target': target, 'role': role, 'toolchain': toolchain, 'mode': mode} + + +def default_directory(identity): + return Path('build') / identity['toolchain'] / identity['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..e7b13c73c --- /dev/null +++ b/scons/sources.py @@ -0,0 +1,549 @@ +"""Source manifests shared by every Glob2 toolchain. Paths are relative to each library.""" + +CLIENT_SOURCES = ( + '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_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/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', +) + +GAG_SOURCES = ( + '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', +) + +GAG_SERVER_SOURCES = ( + '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..38917a771 --- /dev/null +++ b/scons/web_build.py @@ -0,0 +1,81 @@ +"""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 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', + '-include', str(root / 'browser/BrowserPlatform.h')] + PORTS) + env.Append(LINKFLAGS=['-fexceptions', '-O2' if identity['mode']=='release' else '-O0', + '-sASYNCIFY', '-sASYNCIFY_STACK_SIZE=1048576', '-sALLOW_MEMORY_GROWTH', + '-sINITIAL_MEMORY=134217728', '-sSTACK_SIZE=8388608', '-sASSERTIONS=1', + '-sFORCE_FILESYSTEM', '-lidbfs.js', + "'-sEXPORTED_RUNTIME_METHODS=[\"callMain\",\"FS\"]'", + '--shell-file', 'browser/shell.html'] + 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 != 'VoiceRecorder.cpp'] + files += ['libgag/src/' + s for s in GAG_SOURCES] + files += ['libusl/src/' + s for s in USL_SOURCES] + files += ['browser/VoiceRecorder.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/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/EngineRun.cpp b/src/EngineRun.cpp index 3a735247e..59f96e763 100644 --- a/src/EngineRun.cpp +++ b/src/EngineRun.cpp @@ -112,8 +112,6 @@ void Engine::gatherAndAdvanceOrders(bool wasReadyLastTick) } } - gui.game.setWaitingOnMask(net->getWaitingOnMask()); - if (multiplayer) multiplayer->update(); @@ -127,6 +125,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,6 +199,9 @@ void Engine::executeOrdersAndStep(bool readyNow) } gui.game.syncStep(gui.localTeamNo); +#ifdef __EMSCRIPTEN__ + EM_ASM({ Module['glob2Tick'] = $0; Module['glob2Screen'] = 'match'; }, gui.game.stepCounter); +#endif } } diff --git a/src/FertilityCalculatorDialog.cpp b/src/FertilityCalculatorDialog.cpp index 73511694e..605046093 100644 --- a/src/FertilityCalculatorDialog.cpp +++ b/src/FertilityCalculatorDialog.cpp @@ -46,6 +46,15 @@ void FertilityCalculatorDialog::onTimer(Uint32) void FertilityCalculatorDialog::runModal() { +#ifdef __EMSCRIPTEN__ + FertilityCalculator::compute(map, [this](float p) { + progressFraction.store(p, std::memory_order_relaxed); + refreshProgressDisplay(); + dispatchPaint(); + emscripten_sleep(1); + }); + computeDone.store(true, std::memory_order_release); +#else computeThread = std::thread([this]() { FertilityCalculator::compute(map, [this](float p) { progressFraction.store(p, std::memory_order_relaxed); @@ -57,6 +66,7 @@ void FertilityCalculatorDialog::runModal() if (computeThread.joinable()) computeThread.join(); +#endif } void FertilityCalculatorDialog::refreshProgressDisplay() diff --git a/src/Glob2.cpp b/src/Glob2.cpp index 0e5bded0f..7cc4af76a 100644 --- a/src/Glob2.cpp +++ b/src/Glob2.cpp @@ -4,6 +4,9 @@ #include "Glob2.h" #include "GlobalContainer.h" #include "YOGServer.h" +#ifdef GLOB2_ROUTER_ONLY +#include "YOGServerRouter.h" +#endif #ifndef YOG_SERVER_ONLY @@ -454,6 +457,13 @@ int Glob2::run(int argc, char *argv[]) } atexit(SDLNet_Quit); + +#ifdef GLOB2_ROUTER_ONLY + YOGServerRouter router; + int routerResult = router.run(); + delete globalContainer; + return routerResult; +#endif if (globalContainer->hostServer) { YOGServer server(YOGRequirePassword, YOGMultipleGames); @@ -674,5 +684,9 @@ int main(int argc, char *argv[]) #endif Glob2 glob2; - return glob2.run(argc, argv); + int result = glob2.run(argc, argv); +#ifdef __EMSCRIPTEN__ + EM_ASM({ Module['glob2Screen'] = 'exited'; Module['onGameExit']($0); }, result); +#endif + return result; } 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/MainMenuScreen.cpp b/src/MainMenuScreen.cpp index 048849c2c..596bc28a4 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" diff --git a/src/SConscript b/src/SConscript index 1cfb2b1e5..2e7859cc0 100644 --- a/src/SConscript +++ b/src/SConscript @@ -1,469 +1,7 @@ -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') local = env.Clone() #Add libgag and USL, not as a library, but as an object @@ -568,7 +106,7 @@ if not env['server']: #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 +115,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/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/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/gui/GameGUI.cpp b/src/gui/GameGUI.cpp index 437e678c2..482eb6e72 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" 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..3e39e311d 100644 --- a/src/gui/GameGUIPersistence.cpp +++ b/src/gui/GameGUIPersistence.cpp @@ -22,7 +22,7 @@ #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) diff --git a/src/gui/GameGUIStep.cpp b/src/gui/GameGUIStep.cpp index cdc598ebb..b33c5e93a 100644 --- a/src/gui/GameGUIStep.cpp +++ b/src/gui/GameGUIStep.cpp @@ -30,7 +30,7 @@ #include "Player.h" #include "ReplayReader.h" #include "ReplayWriter.h" -#include "config.h" +#include #include "Order.h" #include 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/test/SConstruct b/test/SConstruct index 1f1abb920..2d581d42c 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', @@ -464,4 +468,3 @@ replay_objs = [ netsend_objs[1], ] wcdecode_env.Program( target = 'ReplayStepCounterTest', source = replay_objs ) - 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/tests/build_system/coexistence.py b/tests/build_system/coexistence.py new file mode 100644 index 000000000..9e8eaa4aa --- /dev/null +++ b/tests/build_system/coexistence.py @@ -0,0 +1,53 @@ +#!/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', *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) + assert [snapshot(p) for p in directories] == before, 'A no-op build changed compilation outputs' + 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..39f2070ed --- /dev/null +++ b/tests/build_system/test_layout.py @@ -0,0 +1,70 @@ +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') + + 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/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() From 213ba0af6aadadaa47568d5a210b666e139eec90 Mon Sep 17 00:00:00 2001 From: Bradley Arsenault Date: Mon, 7 Sep 2026 22:22:58 -0400 Subject: [PATCH 02/20] Run browser gameplay through scheduled screen lifecycles Drive native and browser entry points through the shared application host and owned screen stack. Convert loading, editing, map generation, and related UI flows to cooperative work that can advance across browser frames. --- .github/workflows/build.yml | 30 ++ .gitignore | 1 + browser/ApplicationHost.cpp | 60 +++ browser/BrowserPlatform.h | 6 - browser/README.md | 23 +- browser/package-lock.json | 58 ++ browser/package.json | 6 + browser/playwright.config.js | 25 + browser/shell.html | 27 + browser/smoke-test.js | 76 --- browser/tests/single-player.spec.js | 319 +++++++++++ data/texts.ar.txt | 24 + data/texts.br.txt | 26 +- data/texts.ca.txt | 24 + data/texts.cz.txt | 24 + data/texts.de.txt | 24 + data/texts.dk.txt | 24 + data/texts.en.txt | 24 + data/texts.eo.txt | 24 + data/texts.es.txt | 24 + data/texts.eu.txt | 24 + data/texts.fa.txt | 24 + data/texts.fi.txt | 24 + data/texts.fr.txt | 24 + data/texts.gr.txt | 24 + data/texts.hu.txt | 24 + data/texts.id.txt | 24 + data/texts.it.txt | 24 + data/texts.ja.txt | 24 + data/texts.keys.txt | 12 + data/texts.ko.txt | 24 + data/texts.nl.txt | 24 + data/texts.pl.txt | 24 + data/texts.pt.txt | 24 + data/texts.ro.txt | 24 + data/texts.ru.txt | 24 + data/texts.si.txt | 24 + data/texts.sk.txt | 24 + data/texts.sr.txt | 24 + data/texts.sv.txt | 24 + data/texts.tr.txt | 24 + data/texts.uk.txt | 24 + data/texts.vi.txt | 24 + data/texts.zh-cn.txt | 24 + data/texts.zh-tw.txt | 24 + docs/browser/adr-002-host-migration.md | 31 ++ docs/browser/adr-003-screen-execution.md | 195 +++++++ docs/browser/adr-004-cooperative-loading.md | 154 ++++++ docs/browser/adr-005-generation-randomness.md | 121 +++++ docs/browser/gateway.md | 4 + docs/development-notes.md | 20 +- libgag/include/ApplicationHost.h | 31 ++ libgag/include/CooperativeSlice.h | 35 ++ libgag/include/CooperativeTask.h | 85 +++ libgag/include/GUIBase.h | 12 + libgag/include/InputState.h | 36 ++ libgag/include/ScreenStack.h | 34 ++ libgag/src/ApplicationHost.cpp | 28 + libgag/src/GUIBase.cpp | 198 ++++--- libgag/src/GUIMessageBox.cpp | 3 +- libgag/src/GraphicContext.cpp | 3 - libgag/src/SConscript | 3 + libgag/src/ScreenStack.cpp | 105 ++++ scons/sources.py | 12 + scons/web_build.py | 7 +- src/Application.cpp | 66 +++ src/Application.h | 20 + src/CampaignEditor.cpp | 84 ++- src/CampaignEditor.h | 4 +- src/CampaignMainMenu.cpp | 32 +- src/CampaignMainMenu.h | 4 +- src/CampaignMenuScreen.cpp | 52 +- src/CampaignMenuScreen.h | 4 +- src/CustomGameScreen.cpp | 2 +- src/CustomGameScreen.h | 4 +- src/EditorGenerateScreen.cpp | 16 + src/EditorGenerateScreen.h | 9 + src/EditorLoadScreen.cpp | 49 ++ src/EditorLoadScreen.h | 29 + src/EditorMainMenu.cpp | 146 ++--- src/EditorMainMenu.h | 6 +- src/EndGameScreen.cpp | 3 +- src/Engine.cpp | 55 +- src/Engine.h | 35 +- src/EngineInit.cpp | 185 +++---- src/EngineRun.cpp | 184 ++++--- src/FertilityCalculator.cpp | 172 +++--- src/FertilityCalculator.h | 31 +- src/FertilityCalculatorDialog.cpp | 3 +- src/FertilityScreen.cpp | 26 + src/FertilityScreen.h | 16 + src/Game.h | 6 + src/GameLoadScreen.cpp | 48 ++ src/GameLoadScreen.h | 28 + src/GameSessionScreen.cpp | 60 +++ src/GameSessionScreen.h | 28 + src/Game_editor.cpp | 8 +- src/Game_io.cpp | 56 +- src/Glob2.cpp | 200 +------ src/Glob2.h | 6 +- src/MainMenuScreen.cpp | 5 - src/MainMenuScreen.h | 1 - src/MapEditorScreen.cpp | 69 +++ src/MapEditorScreen.h | 23 + src/MessageScreen.cpp | 19 + src/MessageScreen.h | 13 + src/PerlinNoise.cpp | 18 +- src/PerlinNoise.h | 41 +- src/SConscript | 4 + src/ScriptEditorScreen.cpp | 65 ++- src/ScriptEditorScreen.h | 9 +- src/SinglePlayerFlow.cpp | 55 ++ src/SinglePlayerFlow.h | 21 + src/Utilities.cpp | 23 + src/Utilities.h | 3 + src/gui/GameGUI.h | 8 + src/gui/GameGUIDraw.cpp | 4 +- src/gui/GameGUIInput.cpp | 15 +- src/gui/GameGUIInputKey.cpp | 6 +- src/gui/GameGUIInputMouse.cpp | 4 +- src/gui/GameGUIPersistence.cpp | 37 +- src/gui/GameGUIStep.cpp | 50 +- src/gui/GameGUIToolManager.cpp | 13 +- src/gui/GameGUIToolManager.h | 5 +- src/map/Map.h | 8 + src/map/edit/MapEdit.h | 30 +- src/map/edit/MapEditActionView.cpp | 5 +- src/map/edit/MapEditCtor.cpp | 2 +- src/map/edit/MapEditDelegate.cpp | 18 +- src/map/edit/MapEditEvents.cpp | 27 +- src/map/edit/MapEditIO.cpp | 324 ++++++------ src/map/generator/GameMaps.cpp | 35 +- src/map/generator/Generator.cpp | 163 ++++-- src/map/generator/GeneratorDivide.cpp | 116 ++-- src/map/generator/GeneratorHeightmap.cpp | 45 +- src/map/generator/GeneratorPoints.cpp | 93 +++- src/map/generator/GeneratorSplit.cpp | 55 +- src/map/generator/HeightMapGenerator.cpp | 190 +++++-- src/map/generator/HeightMapGenerator.h | 29 +- src/map/generator/MapGenerator.h | 25 + src/map/generator/MapHomogen.cpp | 17 +- src/map/generator/MapOldIslands.cpp | 16 +- src/map/generator/MapOldRandom.cpp | 22 +- src/map/generator/MapRandom.cpp | 72 ++- src/map/gradient/MapGradientGlobal.cpp | 10 +- src/map/io/MapIO.cpp | 30 +- src/team/Team.cpp | 10 - src/team/Team.h | 4 +- src/team/TeamSerialization.cpp | 19 +- test/CustomGameSetupHarness.cpp | 19 +- test/EngineSessionHarness.cpp | 499 ++++++++++++++++++ test/GameSpeedTest.cpp | 27 + test/LegacyFertilityReference.h | 147 ++++++ test/ScreenExecutionHarness.cpp | 308 +++++++++++ test/run-engine-session-test.py | 33 ++ tests/build_system/test_platform_boundary.py | 25 + 156 files changed, 5647 insertions(+), 1518 deletions(-) create mode 100644 browser/ApplicationHost.cpp delete mode 100644 browser/BrowserPlatform.h create mode 100644 browser/package-lock.json create mode 100644 browser/package.json create mode 100644 browser/playwright.config.js delete mode 100644 browser/smoke-test.js create mode 100644 browser/tests/single-player.spec.js create mode 100644 docs/browser/adr-002-host-migration.md create mode 100644 docs/browser/adr-003-screen-execution.md create mode 100644 docs/browser/adr-004-cooperative-loading.md create mode 100644 docs/browser/adr-005-generation-randomness.md create mode 100644 libgag/include/ApplicationHost.h create mode 100644 libgag/include/CooperativeSlice.h create mode 100644 libgag/include/CooperativeTask.h create mode 100644 libgag/include/InputState.h create mode 100644 libgag/include/ScreenStack.h create mode 100644 libgag/src/ApplicationHost.cpp create mode 100644 libgag/src/ScreenStack.cpp create mode 100644 src/Application.cpp create mode 100644 src/Application.h create mode 100644 src/EditorGenerateScreen.cpp create mode 100644 src/EditorGenerateScreen.h create mode 100644 src/EditorLoadScreen.cpp create mode 100644 src/EditorLoadScreen.h create mode 100644 src/FertilityScreen.cpp create mode 100644 src/FertilityScreen.h create mode 100644 src/GameLoadScreen.cpp create mode 100644 src/GameLoadScreen.h create mode 100644 src/GameSessionScreen.cpp create mode 100644 src/GameSessionScreen.h create mode 100644 src/MapEditorScreen.cpp create mode 100644 src/MapEditorScreen.h create mode 100644 src/MessageScreen.cpp create mode 100644 src/MessageScreen.h create mode 100644 src/SinglePlayerFlow.cpp create mode 100644 src/SinglePlayerFlow.h create mode 100644 test/EngineSessionHarness.cpp create mode 100644 test/LegacyFertilityReference.h create mode 100644 test/ScreenExecutionHarness.cpp create mode 100644 test/run-engine-session-test.py create mode 100644 tests/build_system/test_platform_boundary.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d42aec044..769b96859 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -119,6 +119,16 @@ jobs: 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 @@ -380,6 +390,26 @@ jobs: python3 -m unittest discover -s tests/gateway -v - name: Build headless router run: scons role=router release=1 -j2 + - uses: actions/setup-node@v4 + if: matrix.order == 'concurrent' + with: + node-version: '22' + cache: npm + cache-dependency-path: browser/package-lock.json + - name: Test browser single-player flow + if: matrix.order == 'concurrent' + working-directory: browser + run: | + npm ci --ignore-scripts + npx playwright install --with-deps chromium firefox webkit + npm test + - uses: actions/upload-artifact@v4 + if: failure() && matrix.order == 'concurrent' + with: + name: browser-failure-traces + path: | + build/browser-test-results + build/browser-test-report - uses: actions/upload-artifact@v4 with: name: glob2-web-development-${{ matrix.order }} diff --git a/.gitignore b/.gitignore index f26c90023..81a3f384b 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,4 @@ Glob2-*.dmg /build-*-support.log /test/build/ /build-*.log +/browser/node_modules/ diff --git a/browser/ApplicationHost.cpp b/browser/ApplicationHost.cpp new file mode 100644 index 000000000..df2ad0f4e --- /dev/null +++ b/browser/ApplicationHost.cpp @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include +#include + +namespace GAGCore::ApplicationHost +{ +namespace +{ +struct ScheduledLoop { std::unique_ptr loop; std::function complete; }; +void scheduledFrame(void* opaque) +{ + auto* state = static_cast(opaque); + 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; + } + // Queue only after the frame returns. During the migration an Asyncify + // suspension inside a legacy dialog must not start a second frame. + 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 milliseconds) +{ + emscripten_sleep(milliseconds ? milliseconds : 1); +} +void screenChanged(const char* name) +{ + EM_ASM({ Module['glob2Screen'] = 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 matchFrame(bool paused) +{ + EM_ASM({ + Module['glob2Frames'] = (Module['glob2Frames'] || 0) + 1; + Module['glob2Paused'] = Boolean($0); + }, paused); +} +} diff --git a/browser/BrowserPlatform.h b/browser/BrowserPlatform.h deleted file mode 100644 index 78acb7a97..000000000 --- a/browser/BrowserPlatform.h +++ /dev/null @@ -1,6 +0,0 @@ -#pragma once -#include -#include -#include -// Every desktop wait must yield so browser input and audio can run. -#define SDL_Delay(ms) emscripten_sleep((ms) > 0 ? (ms) : 1) diff --git a/browser/README.md b/browser/README.md index 1ebab6161..0ea553a34 100644 --- a/browser/README.md +++ b/browser/README.md @@ -64,9 +64,26 @@ Tested in Chromium on 2026-09-07 (America/Toronto): runs after interaction. Audible output has not been independently checked. - No uncaught JavaScript errors in the final custom-match/save/load smoke test. -`browser/smoke-test.js` is a Playwright Page function exercising the final -custom-match, pause, persistence, and reload flow. It creates or overwrites -`Browser_smoke.game` in this local experiment's browser storage. +## 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 +``` + +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`. These initial tests do not yet cover the +complete supported-release matrix. WebKit automation does not substitute for +release testing in actual Safari, nor Chromium for Edge. The generated payload is about 26 MiB of assets, 12 MiB of WebAssembly, and 615 KiB of JavaScript, before HTTP compression. The build and SDK are local 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.json b/browser/package.json new file mode 100644 index 000000000..65845e357 --- /dev/null +++ b/browser/package.json @@ -0,0 +1,6 @@ +{ + "name": "glob2-browser-tests", + "private": true, + "scripts": {"test": "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..dd7acb422 --- /dev/null +++ b/browser/playwright.config.js @@ -0,0 +1,25 @@ +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}})), + 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/shell.html b/browser/shell.html index e3d26e18f..a448f96e8 100644 --- a/browser/shell.html +++ b/browser/shell.html @@ -36,6 +36,7 @@ Module.saveMount = FS.mount(IDBFS, {autoPersist:true}, '/home/web_user').mount; addRunDependency('browser-saves'); FS.syncfs(true, err => { + Module.storageRestore = err ? 'failed' : 'ready'; if (err) Module.printErr('Save restore failed: ' + err); removeRunDependency('browser-saves'); }); @@ -52,6 +53,32 @@ }, onAbort(reason) { Module.printErr('Game stopped: ' + reason); } }; +async function localFileDigest(directory, name, extension) { + if (!/^[A-Za-z0-9 _.-]+$/.test(name) || name.includes('..') || !name.endsWith('.' + extension)) + throw Error('Invalid local file name'); + const path = '/home/web_user/.glob2/' + directory + '/' + name; + if (!FS.analyzePath(path).exists) return null; + const data = FS.readFile(path); + const bytes = new Uint8Array(await crypto.subtle.digest('SHA-256', data)); + return Object.freeze({size:data.length, sha256:Array.from(bytes, b => b.toString(16).padStart(2,'0')).join('')}); +} +// Read-only observability shared by developer tools and automated assertions. +Object.defineProperty(window, 'glob2Diagnostics', {value: Object.freeze({ + snapshot() { + return Object.freeze({version:1, screen:Module.glob2Screen || 'loading', + tick:Module.glob2Tick || 0, frames:Module.glob2Frames || 0, + paused:Boolean(Module.glob2Paused), width:canvas.width, height:canvas.height, + restore:Module.storageRestore || 'restoring', + persisting:Boolean(Module.saveMount?.idbPersistState), + audio:Module.SDL2?.audioContext?.state || 'inactive'}); + }, + saves() { + const path = '/home/web_user/.glob2/games'; + return FS.analyzePath(path).exists ? FS.readdir(path).filter(n => n.endsWith('.game')).sort() : []; + }, + saveDigest(name) { return localFileDigest('games', name, 'game'); }, + mapDigest(name) { return localFileDigest('maps', name, 'map'); } +})}); function focusGame() { canvas.focus(); Module.SDL2?.audioContext?.resume(); diff --git a/browser/smoke-test.js b/browser/smoke-test.js deleted file mode 100644 index 5322fa35a..000000000 --- a/browser/smoke-test.js +++ /dev/null @@ -1,76 +0,0 @@ -// Evaluate this function with a Playwright Page while the local server is running. -async (page) => { - const result = {}; - const errors = []; - page.on('pageerror', e => errors.push(String(e))); - await page.setViewportSize({width:1200, height:900}); - const open = async () => { - await page.goto('http://127.0.0.1:8765/'); - await page.waitForFunction(() => window.Module?.glob2Screen?.includes('MainMenuScreen')); - }; - const click = async (x, y) => { - await page.locator('#canvas').click({position:{x,y}, delay:80}); - }; - // Native menus use a centered 640x480 coordinate system. - const menu = async (x, y) => click(x + 280, y + 210); - const screen = async name => page.waitForFunction(name => Module.glob2Screen?.includes(name), name); - await open(); - result.page = await page.evaluate(() => ({ - buttons:document.querySelectorAll('button').length, - visibleText:document.body.innerText, - canvas:[Module.canvas.width,Module.canvas.height], - viewport:[innerWidth,innerHeight], - })); - if (result.page.buttons || result.page.visibleText.trim()) throw Error('Page contains wrapper UI'); - if (result.page.canvas.join() !== result.page.viewport.join()) throw Error('Game does not fill viewport'); - await menu(480,200); - await screen('CustomGameScreen'); - await menu(100,70); - await menu(530,380); - await page.waitForFunction(() => Module.glob2Tick > 25); - const before = await page.evaluate(() => ({tick:Module.glob2Tick, time:performance.now()})); - await page.waitForFunction(t => Module.glob2Tick >= t+125, before.tick, {timeout:15000}); - const after = await page.evaluate(() => ({tick:Module.glob2Tick, time:performance.now()})); - result.ticksPerSecond = (after.tick-before.tick)*1000/(after.time-before.time); - await page.locator('#canvas').press('p', {delay:80}); - await page.waitForTimeout(150); - const paused = await page.evaluate(() => Module.glob2Tick); - await page.waitForTimeout(250); - if (await page.evaluate(() => Module.glob2Tick) !== paused) throw Error('Pause did not stop simulation'); - result.pauseTick = paused; - await page.locator('#canvas').press('Escape', {delay:80}); - // In-game overlay: 320x260, centered in the viewport. - await click(600,400); - // Save overlay: 300x275, text field 190px from its top. - await click(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 smoke', {delay:20}); - await click(520,555); - const save = '/home/web_user/.glob2/games/Browser_smoke.game'; - await page.waitForFunction(path => FS.analyzePath(path).exists, save); - await page.waitForFunction(() => !Module.saveMount.idbPersistState); - const hash = async () => page.evaluate(async path => { - const data = FS.readFile(path); - const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', data)); - return {size:data.length,sha256:Array.from(digest).map(n=>n.toString(16).padStart(2,'0')).join('')}; - }, save); - result.saved = await hash(); - await open(); - result.restored = await hash(); - if (result.saved.sha256 !== result.restored.sha256) throw Error('Persistent save changed after reload'); - // Derive the smoke save row without removing other experiment saves. - const row = await page.evaluate(() => FS.readdir('/home/web_user/.glob2/games') - .filter(n=>n.endsWith('.game')).sort((a,b)=>a.replaceAll('_',' ').localeCompare(b.replaceAll('_',' '),undefined,{numeric:true,sensitivity:'base'})).indexOf('Browser_smoke.game')); - await menu(160,200); - await screen('ChooseMapScreen'); - // The bundled standard font renders list rows at 16px. - await menu(100,70 + row*16); - await menu(530,380); - await page.waitForFunction(t => Module.glob2Tick >= t, paused); - result.loadedTick = await page.evaluate(() => Module.glob2Tick); - result.audio = await page.evaluate(() => Module.SDL2?.audioContext?.state); - result.errors = errors; - if (errors.length) throw Error(errors.join('\n')); - return result; -} diff --git a/browser/tests/single-player.spec.js b/browser/tests/single-player.spec.js new file mode 100644 index 000000000..eaf6a2760 --- /dev/null +++ b/browser/tests/single-player.spec.js @@ -0,0 +1,319 @@ +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); + +test.beforeEach(async ({page}) => { + await page.goto('/'); + 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); + expect(await page.locator('body').innerText()).toBe(''); + 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 menu(page, 160, 360); + await screen(page, 'SettingsScreen'); + await menu(page, 530, 440); + await screen(page, 'MainMenuScreen'); + await menu(page, 160, 440); + await screen(page, 'CreditScreen'); + await page.locator('#canvas').press('Escape'); + await screen(page, 'MainMenuScreen'); + await menu(page, 480, 440); + await screen(page, 'exited'); + expect(errors).toEqual([]); +}); + +test('campaign selector returns to its suspended parent and can reopen', async ({page}) => { + await menu(page, 160, 120); + 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 menu(page, 480, 120); + 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('custom options and AI descriptions return to setup, and a finished game returns there too', async ({page}) => { + await menu(page, 480, 200); + await screen(page, 'CustomGameScreen'); + await menu(page, 100, 70); + await menu(page, 310, 440); + await screen(page, 'CustomGameOtherOptions'); + await page.locator('#canvas').press('Escape'); + await screen(page, 'CustomGameScreen'); + await menu(page, 310, 390); + await screen(page, 'AIDescriptionScreen'); + await page.locator('#canvas').press('Enter'); + await screen(page, 'CustomGameScreen'); + await menu(page, 530, 380); + 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'); +}); + +test('custom match pauses, persists and resumes after reload', async ({page}) => { + const errors = []; + page.on('pageerror', error => errors.push(String(error))); + await menu(page, 480, 200); + await screen(page, 'CustomGameScreen'); + await menu(page, 100, 70); + await menu(page, 530, 380); + 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 menu(page, 160, 200); + 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 menu(page, 480, 360); + 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 menu(page, 480, 360); + 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 menu(page, 480, 360); + 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 menu(page, 480, 360); + await screen(page, 'EditorMainMenu'); + for (const cancel of [true, false]) { + await menu(page, 320, 150); + await screen(page, 'ChooseMapScreen'); + await menu(page, 100, 70); + await menu(page, 530, 380); + if (cancel) { + await screen(page, 'EditorLoadScreen'); + await page.locator('#canvas').press('Escape', {delay:80}); + } 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 menu(page, 480, 200); + await screen(page, 'CustomGameScreen'); + await menu(page, 100, 70); + await menu(page, 530, 380); + await screen(page, 'GameLoadScreen'); + await page.locator('#canvas').press('Escape', {delay:80}); + await screen(page, 'CustomGameScreen'); + await page.locator('#canvas').press('Escape', {delay:80}); + await screen(page, 'MainMenuScreen'); + await menu(page, 480, 120); + await screen(page, 'CampaignMenuScreen'); + await menu(page, 100, 60); + await menu(page, 160, 450); + await screen(page, 'GameLoadScreen'); + await page.locator('#canvas').press('Escape', {delay:80}); + 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('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 menu(page, 480, 360); + 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 click(page, 520, 555); + await screen(page, 'EditorLoadScreen'); + if (cancel) 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); + 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 menu(page, 480, 360); + 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/data/texts.ar.txt b/data/texts.ar.txt index 6b0a50ae3..abc3385c3 100644 --- a/data/texts.ar.txt +++ b/data/texts.ar.txt @@ -1764,3 +1764,27 @@ OpenGL غير متوفر في هذا الإصدار. عرض الطارة تلقائيًا [settings Automatically show the torus overview while moving around the map (OpenGL).] إظهار نظرة عامة للخريطة على سطح طارة تلقائيًا أثناء التنقل في الخريطة (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... diff --git a/data/texts.br.txt b/data/texts.br.txt index ee9f9ff15..4b001059b 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 +Map Download Failed: Connection to YOG lost [Map name: %0] Nome do mapa: %0. [map name] @@ -1762,3 +1762,27 @@ 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] +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... diff --git a/data/texts.ca.txt b/data/texts.ca.txt index 34a9f531f..e922880e6 100644 --- a/data/texts.ca.txt +++ b/data/texts.ca.txt @@ -1774,3 +1774,27 @@ 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] +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... diff --git a/data/texts.cz.txt b/data/texts.cz.txt index 026d20a00..c82884572 100644 --- a/data/texts.cz.txt +++ b/data/texts.cz.txt @@ -1766,3 +1766,27 @@ 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] +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... diff --git a/data/texts.de.txt b/data/texts.de.txt index 8b56bc6b6..bd6202fd1 100644 --- a/data/texts.de.txt +++ b/data/texts.de.txt @@ -1766,3 +1766,27 @@ 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] +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... diff --git a/data/texts.dk.txt b/data/texts.dk.txt index a41a2ee83..33245aa61 100644 --- a/data/texts.dk.txt +++ b/data/texts.dk.txt @@ -1842,3 +1842,27 @@ 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] +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... diff --git a/data/texts.en.txt b/data/texts.en.txt index abc5c8e05..722cff7c0 100644 --- a/data/texts.en.txt +++ b/data/texts.en.txt @@ -1764,3 +1764,27 @@ 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... diff --git a/data/texts.eo.txt b/data/texts.eo.txt index 2d8581aef..9e3bcf381 100644 --- a/data/texts.eo.txt +++ b/data/texts.eo.txt @@ -1764,3 +1764,27 @@ 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] +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... diff --git a/data/texts.es.txt b/data/texts.es.txt index 9b3b17b8f..b506c14d4 100644 --- a/data/texts.es.txt +++ b/data/texts.es.txt @@ -1766,3 +1766,27 @@ 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] +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... diff --git a/data/texts.eu.txt b/data/texts.eu.txt index 0c2ed27ea..cb2eeead4 100644 --- a/data/texts.eu.txt +++ b/data/texts.eu.txt @@ -1776,3 +1776,27 @@ 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] +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... diff --git a/data/texts.fa.txt b/data/texts.fa.txt index 12b85bb48..ccbb1e4f7 100644 --- a/data/texts.fa.txt +++ b/data/texts.fa.txt @@ -1764,3 +1764,27 @@ OpenGL در این بیلد موجود نیست. نمای خودکار چنبره [settings Automatically show the torus overview while moving around the map (OpenGL).] هنگام حرکت در نقشه (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... diff --git a/data/texts.fi.txt b/data/texts.fi.txt index 219a9752d..f6bebd574 100644 --- a/data/texts.fi.txt +++ b/data/texts.fi.txt @@ -1766,3 +1766,27 @@ 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] +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... diff --git a/data/texts.fr.txt b/data/texts.fr.txt index 5d0c5bba1..41b5b541c 100644 --- a/data/texts.fr.txt +++ b/data/texts.fr.txt @@ -1776,3 +1776,27 @@ 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] +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... diff --git a/data/texts.gr.txt b/data/texts.gr.txt index 63643dfc0..075344185 100644 --- a/data/texts.gr.txt +++ b/data/texts.gr.txt @@ -1836,3 +1836,27 @@ Cortex Αυτόματη τοροειδής προβολή [settings Automatically show the torus overview while moving around the map (OpenGL).] Αυτόματη εμφάνιση της επισκόπησης του χάρτη σε σχήμα τόρου ενώ μετακινείστε στον χάρτη (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... diff --git a/data/texts.hu.txt b/data/texts.hu.txt index ba3f8442a..b5afa4832 100644 --- a/data/texts.hu.txt +++ b/data/texts.hu.txt @@ -1766,3 +1766,27 @@ 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] +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... diff --git a/data/texts.id.txt b/data/texts.id.txt index 73bab062d..61464d58b 100644 --- a/data/texts.id.txt +++ b/data/texts.id.txt @@ -1762,3 +1762,27 @@ 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] +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... diff --git a/data/texts.it.txt b/data/texts.it.txt index d1f94de42..2e1b6fe62 100644 --- a/data/texts.it.txt +++ b/data/texts.it.txt @@ -1828,3 +1828,27 @@ 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] +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... diff --git a/data/texts.ja.txt b/data/texts.ja.txt index 59f1b0326..5421d35d2 100644 --- a/data/texts.ja.txt +++ b/data/texts.ja.txt @@ -1762,3 +1762,27 @@ Globulation 2 のカーソルを表示します。 自動トーラス表示 [settings Automatically show the torus overview while moving around the map (OpenGL).] マップ内を移動するときに、トーラス(ドーナツ)形状のマップ全体図を自動的に表示します (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... diff --git a/data/texts.keys.txt b/data/texts.keys.txt index a1fe56a5c..25d6a438e 100644 --- a/data/texts.keys.txt +++ b/data/texts.keys.txt @@ -880,3 +880,15 @@ [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] diff --git a/data/texts.ko.txt b/data/texts.ko.txt index a30df9dbd..b0bc6b821 100644 --- a/data/texts.ko.txt +++ b/data/texts.ko.txt @@ -1762,3 +1762,27 @@ Globulation 2 커서를 표시합니다. 자동 토러스 보기 [settings Automatically show the torus overview while moving around the map (OpenGL).] 지도 안에서 이동할 때 토러스(도넛) 모양의 지도 전체 보기를 자동으로 표시합니다(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... diff --git a/data/texts.nl.txt b/data/texts.nl.txt index 1f54e7dd1..24905bb85 100644 --- a/data/texts.nl.txt +++ b/data/texts.nl.txt @@ -1790,3 +1790,27 @@ 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] +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... diff --git a/data/texts.pl.txt b/data/texts.pl.txt index 63df2cad3..67ade7965 100644 --- a/data/texts.pl.txt +++ b/data/texts.pl.txt @@ -1766,3 +1766,27 @@ 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] +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... diff --git a/data/texts.pt.txt b/data/texts.pt.txt index 5a7e362a0..3eb17fa7d 100644 --- a/data/texts.pt.txt +++ b/data/texts.pt.txt @@ -1772,3 +1772,27 @@ 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] +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... diff --git a/data/texts.ro.txt b/data/texts.ro.txt index 3dc953a18..69b909d57 100644 --- a/data/texts.ro.txt +++ b/data/texts.ro.txt @@ -1766,3 +1766,27 @@ 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] +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... diff --git a/data/texts.ru.txt b/data/texts.ru.txt index 88553314a..2b4222801 100644 --- a/data/texts.ru.txt +++ b/data/texts.ru.txt @@ -1764,3 +1764,27 @@ OpenGL недоступен в этой сборке. Автоматический вид тора [settings Automatically show the torus overview while moving around the map (OpenGL).] Автоматически отображать обзор карты в форме тора при перемещении по карте (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... diff --git a/data/texts.si.txt b/data/texts.si.txt index b1706a81f..4ccaa29c4 100644 --- a/data/texts.si.txt +++ b/data/texts.si.txt @@ -1766,3 +1766,27 @@ 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] +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... diff --git a/data/texts.sk.txt b/data/texts.sk.txt index 8ce179f40..ecac525a1 100644 --- a/data/texts.sk.txt +++ b/data/texts.sk.txt @@ -1774,3 +1774,27 @@ 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] +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... diff --git a/data/texts.sr.txt b/data/texts.sr.txt index 9356daf8e..f38b89513 100644 --- a/data/texts.sr.txt +++ b/data/texts.sr.txt @@ -1766,3 +1766,27 @@ OpenGL није доступан у овој верзији. Аутоматски торусни приказ [settings Automatically show the torus overview while moving around the map (OpenGL).] Аутоматски прикажи преглед мапе у облику торуса током кретања по мапи (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... diff --git a/data/texts.sv.txt b/data/texts.sv.txt index a8152a4f2..b863352ea 100644 --- a/data/texts.sv.txt +++ b/data/texts.sv.txt @@ -1776,3 +1776,27 @@ 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] +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... diff --git a/data/texts.tr.txt b/data/texts.tr.txt index 53860b8ff..99d4db009 100644 --- a/data/texts.tr.txt +++ b/data/texts.tr.txt @@ -1774,3 +1774,27 @@ 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] +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... diff --git a/data/texts.uk.txt b/data/texts.uk.txt index 7599531a6..d5a812485 100644 --- a/data/texts.uk.txt +++ b/data/texts.uk.txt @@ -1762,3 +1762,27 @@ OpenGL недоступний у цій збірці. Автоматичний вигляд тора [settings Automatically show the torus overview while moving around the map (OpenGL).] Автоматично показувати огляд карти у формі тора під час переміщення по карті (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... diff --git a/data/texts.vi.txt b/data/texts.vi.txt index 7051e8f5c..a7a27dd8a 100644 --- a/data/texts.vi.txt +++ b/data/texts.vi.txt @@ -1762,3 +1762,27 @@ 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] +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... diff --git a/data/texts.zh-cn.txt b/data/texts.zh-cn.txt index 728759744..1eb2a7e7f 100644 --- a/data/texts.zh-cn.txt +++ b/data/texts.zh-cn.txt @@ -1762,3 +1762,27 @@ OpenGL 在此版本中不可用。 自动环面视图 [settings Automatically show the torus overview while moving around the map (OpenGL).] 在地图上移动时自动显示圆环形状的地图概览 (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... diff --git a/data/texts.zh-tw.txt b/data/texts.zh-tw.txt index b47c4de3b..9dd89736e 100644 --- a/data/texts.zh-tw.txt +++ b/data/texts.zh-tw.txt @@ -1838,3 +1838,27 @@ OpenGL 在此版本中不可用。 自動環面視圖 [settings Automatically show the torus overview while moving around the map (OpenGL).] 在地圖上移動時自動顯示圓環形狀的地圖概覽 (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... diff --git a/docs/browser/adr-002-host-migration.md b/docs/browser/adr-002-host-migration.md new file mode 100644 index 000000000..bcce186a8 --- /dev/null +++ b/docs/browser/adr-002-host-migration.md @@ -0,0 +1,31 @@ +# ADR 002: explicit host calls during lifecycle migration + +Status: transitional implementation; Asyncify removal is not complete. + +The browser experiment forcibly redefined `SDL_Delay` in every translation unit +and slept inside `GraphicContext::nextFrame`. This changed unrelated networking +waits and hid application scheduling inside drawing. It also placed JavaScript +diagnostics in the engine and UI classes. + +`ApplicationHost` now owns the transitional wait and diagnostic contract. +Desktop implements waits using SDL. Browser implements waits using Asyncify, +including a yield when an application frame is already late. UI loops call it +explicitly. Rendering does not wait. Browser interop is compiled only from the +browser platform directory. Static dependency tests enforce that boundary. + +The browser publishes a versioned, read-only `glob2Diagnostics` interface: +screen, simulation tick, engine frame count, pause state, render dimensions, +storage restore/write activity, audio state, save names and save digests. +Tests drive the real UI and use these observations to wait for outcomes. +This interface cannot issue orders, advance ticks, alter saves, or navigate +menus. The legacy Emscripten `Module` is still exposed by the current shell. + +This is not the final callback-based application lifecycle. Blocking screen +and modal loops still need replacement with explicit screen-stack transitions; +loading/map generation still need resumable, cancellable jobs. The supported +target must remove Asyncify and have both hosts drive the same update API. +See the [Emscripten execution model](https://emscripten.org/docs/porting/emscripten-runtime-environment.html). + +The initial maintained suite uses [Playwright projects](https://playwright.dev/docs/test-configuration) +for Chromium, Firefox and WebKit. It does not establish Safari/Edge release +coverage, checkpoint correctness, or durable-write failure handling. diff --git a/docs/browser/adr-003-screen-execution.md b/docs/browser/adr-003-screen-execution.md new file mode 100644 index 000000000..ec8b8d1f4 --- /dev/null +++ b/docs/browser/adr-003-screen-execution.md @@ -0,0 +1,195 @@ +# ADR 003: explicit screen execution phases + +Status: first runtime migration step; screen-stack conversion remains pending. + +`Screen` now exposes `beginExecution`, `updateExecution`, +`handleExecutionEvent`, `drawExecution`, and `finishExecution`. The host supplies +input and timer values. These methods do not poll events or wait. This makes +screen lifecycle behavior testable without depending on wall-clock timing. + +`execute()` remains a compatibility host: it polls SDL, coalesces motion and +window events, drives those phases, and uses the application host's wait. +Existing menus therefore share the new execution path while their callers are +migrated incrementally. Legacy timer-before-input ordering is retained. + +Completion stops subsequent phase dispatch. Creation callbacks can complete a +screen immediately, and destruction callbacks run once when the host finishes +it. Double execution and finishing a running screen are rejected. A completed +screen can be started again. Application quit remains a distinct result. + +The regression harness uses SDL's dummy software display and explicit timer +values. It covers phase separation, completion from creation/input/timer, +ignored input after completion, quit propagation, reuse, and compatibility. +Run `scons release=1 screen-test` followed by +`build//client/release/libgag/src/ScreenExecutionHarness`. + +An owning screen stack now supplies deferred transitions and completion +callbacks for the campaign selector flow, as described below. Overlay modal +loops, engine scheduling, resumable jobs, and removal of Asyncify are still +required. Calling a legacy child `execute()` from a callback is still blocking; +this API extraction does not claim that all screen callbacks are resumable. + +## Owning stack and first migrated flow + +`ScreenStack` owns screens with `unique_ptr`. Hosts submit an SDL event batch +and a timer sample to `frame`; the stack itself does not poll or sleep. Pushes +are queued and applied at a frame boundary. Requesting a child suspends further +parent input immediately, so the opening input cannot activate the child. +Completion callbacks can inspect the completed screen before it is destroyed; +its parent stays alive. A pending child is cancelled if its parent completes +before admission. Application quit unwinds owned screens without invoking +continuations that could open another flow. Recursive frames are rejected. + +The campaign new/load selector now uses this stack. Selection cancellation +returns to the retained parent; successful selection queues the campaign menu. +`ScreenStack::execute` is a transitional polling host for the current desktop +and Asyncify browser callers. Campaign mission execution now uses `GameSessionScreen`, described below; +other menu families have not yet migrated. This change does +not remove Asyncify or claim callback-safe mission loading. + + +## Incremental engine sessions + +The engine exposes begin, step, draw, delay, and finish session operations. +Hosts supply monotonic millisecond samples and decide when to schedule the next +step. Delay queries never sleep or advance the simulation. Native `run()` drives +these same operations. The regression harness compares simulation checksums +under regular and delayed callback schedules and checks invalid lifecycle calls. + +This is a session boundary, not yet the complete application scheduler: +`GameGUI::step(events, now)` consumes host-supplied input, but can still open +legacy modal dialogs. Its no-argument compatibility wrapper polls SDL. Finishing a +session can still synchronously load a requested save. Presentation preparation and end-game screen creation are shared with the +owned game-session screen. Music loading is still synchronous. These remaining call stacks must migrate +before a callback-only browser host can replace Asyncify. + + +## Ordered gameplay input + +Gameplay owns held-key/modifier state derived from delivered events. Focus loss +clears held keys, mouse dragging, and edge scrolling. Returning focus requires +new input. Building previews and placement use those processed modifiers too. +Mouse buttons update state from their events; motion is dispatched before a +following button or focus event so an old motion cannot arrive after release. + +`Engine::stepSession(now, events)` buffers input until its GUI cadence and never +consumes the host's event queue. The compatibility overload collects SDL events. +The native session harness plants a sentinel in SDL's queue to check this +boundary; the gameplay regression checks held-key scrolling and focus cleanup +with supplied timer samples. Browser visibility pause/resume, full menu input +migration, and nonblocking dialogs remain separate required work. + + +## Campaign session ownership + +Campaign and tutorial menus now queue an owned `GameSessionScreen` after map +initialization. It drives the engine's incremental session API and exposes the +engine's requested delay to the common stack host. It retains the engine while +an end-game screen is on top, so statistics and replay export do not outlive +their game data. Returning from that screen completes the game screen and +refreshes/saves the retained campaign menu. Stack shutdown also saves campaign +progress while suppressing navigation continuations. + +The legacy `Engine::run` shares presentation preparation and end-screen creation +with this path. Screen execution hooks are virtual so game presentation does +not run the menu renderer or translate input coordinates twice. The native +session harness checks the same fixture through this ownership path, and the +browser suite exercises two tutorial start/quit/end-screen/return cycles. + +Map initialization, music loading, requested-save loading, and campaign save +error dialogs are still synchronous. Custom games, replays, editor, and network +menu flows still need to adopt the same ownership path before the browser host +can shed Asyncify. + + +## Custom games and load/replay navigation + +`SinglePlayerFlow` owns navigation alongside the screen stack. It queues custom +setup or save/replay selection, initializes an engine from the selection, and +queues the same `GameSessionScreen` used by campaigns. Finishing a custom game +returns to fresh custom setup, preserving desktop behavior. Command-line +replays share this session ownership path. The old blocking no-argument +`Engine::initCustom` and `initLoadGame` methods are removed; engine initialization +accepts the selected map/player headers or filename directly. + +Custom options and AI descriptions are child screens whose parent remains +alive, including the game-header references edited by the options screen. +Actual map/replay loading and in-session load requests remain synchronous and +must become resumable jobs. Editor/network flows and the outer main-menu loop +remain migration work; browser support still depends on Asyncify. + + +## Shared application host loop + +`Application` owns the screen stack and navigation across menus and single-player +flows. Its `frame(tick, events)` and `delay(now)` are the common native/browser +update interface. Returning from a flow recreates the main menu, including +translated labels after settings changes. The old outer menu switch loop and +unused static main-menu execution entry point are removed. + +Native `ApplicationHost::run` polls SDL and drives that interface until completion. +The browser implementation schedules one callback with `emscripten_async_call` +and queues its successor only when it returns. This also avoids concurrent frames +while a remaining legacy callback suspends through Asyncify. The host releases +all application state before its completion callback destroys global resources; +main does not report a premature browser exit just because scheduling returned. + +The native host harness verifies completion/destruction ordering. Browser tests +cover settings/credits return and application exit, alongside gameplay flows. +Editor/network internals, loaders, and some dialogs remain blocking. The browser +build still uses Asyncify for those paths; scheduled outer execution is not a +claim that the complete runtime migration is finished. + +## Editor navigation and borrowed draft lifetime + +Editor setup, campaign selection, campaign editing, and campaign-map entry +editing now queue child screens. Newly added entries use a draft owned by the +completion callback; accepting the entry appends it to the campaign and displays +its edited name. Existing entries borrow from the retained parent campaign. +The stack destroys a completed/cancelled screen before releasing its completion +callback, so captured resources outlive any screen that borrows them. The native +harness checks normal completion, active cancellation, and cancellation before +admission. + +At this stage, the map editor's own run loop, generation/loading, and save-error +message boxes remained synchronous; the next section records the loop migration. Failed map loading now returns without entering the editor +with invalid map data. The browser regression adds and reopens a campaign entry, +then cancels back through the owning parents. + +## Incremental map editor + +`MapEditorScreen` owns a loaded/generated `MapEdit`. The editor accepts supplied +input, advances editor state/timers, and draws through separate methods; its +old polling/sleeping run methods are removed. The common host applies its 33 ms +cadence. Held keys come from processed events, and focus loss clears scrolling +and active drags. + +Quitting a modified map queues `MessageScreen`, an in-game decision with an +explicit caption-index result. Cancel resumes the retained editor, discard +finishes it, and save opens its existing save interface. No editor call stack is +suspended for this decision. The native fixture exercises cancel/discard; browser +checks generate a uniform map and navigate both decisions through actual input. +Generation, parsing, save I/O, fertility calculation, and remaining nested error +or script dialogs still require resumable/asynchronous migration. + +## Resumable fertility work + +Fertility calculation now exposes a platform-independent `Job`. Seeding, +resource reachability, and the weighting kernel all advance under an explicit +operation budget. Temporary distances and output belong to the job. The map must +remain alive and unchanged during the job; only a ready job may publish results. +Cancellation is destruction of the job, leaving the map unchanged. Final commit +copies the staged values in one pass so rendering never sees partial results. + +The editor owns a `FertilityScreen` child while calculating overlays or preparing +a map save. Its host schedules bounded work and continues accepting cancellation. +Canceling either the save selector or calculation preserves unsaved edits and +cancels any pending quit. A failed file-open is reported through an owned message. +The synchronous adapter remains for old map-format loading; serialization and +browser durability are separate work still to migrate. + +A frozen pre-migration algorithm in the native harness checks exact equality at +three operation budgets, including single-operation calls. Additional assertions +cover monotonic progress, rejected premature commit, cancellation, and publication +only after commit. Browser tests cover save cancellation, completed map writes, +and reload persistence using real controls and read-only file digests. diff --git a/docs/browser/adr-004-cooperative-loading.md b/docs/browser/adr-004-cooperative-loading.md new file mode 100644 index 000000000..c95270e64 --- /dev/null +++ b/docs/browser/adr-004-cooperative-loading.md @@ -0,0 +1,154 @@ +# ADR 004: explicit coroutine jobs for nested loading + +Status: incremental implementation; loading is not yet fully latency-bounded. + +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. + +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, and individual propagation sweeps. Legacy fertility loading uses the bounded +fertility job. Remaining work includes subdividing large team/player parsing, +building-specific gradients, stream decompression, scripts, and allocation +work; a checkpoint count is not evidence of a maximum frame time. In-session game reload callers still drain synchronously and need owned loading +flows; startup and editor replacement are described below. +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. This screen is for startup +only, with no active session; replacing a live session requires a separate +transaction because the legacy 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, native network, and in-session +reload callers. Replay indexing, AI initialization, individual parser stages, +serialization, and initial music loading are not yet fully subdivided. Passing +startup cancellation tests does not certify a maximum loading 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. In-session game replacement is still +separate work because engine replay/session globals require different ownership. + +## Gradients during loading + +Resource, forbidden-area, guard-area, and clear-area seeding now expose tasks, +with checkpoints every 16,384 cells. Global chamfer propagation yields every +16 rows of each forward/backward sweep. Map loading awaits these jobs before +publishing its game. Their algorithm and traversal order are unchanged. + +The task APIs borrow a map and its gradient buffers. Neither simulation nor +another gradient operation may observe/mutate that map while a task is suspended. +Running games continue using synchronous adapters, which drain the identical +implementation before returning. Cancellation discards the private loading map; +there is no partially completed gradient in an active simulation. + +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`, which allocates and builds resource +and area gradients using the loading tasks. Generation callers await this chain; +existing editor/runtime callers still drain it synchronously through `addTeam`. +Team masks, colors, header count, prestige limits, and script initialization retain +their original order. + +The asynchronous API is for a privately owned preparation game. A cancelled team +addition leaves a partial game to discard; it is not a transaction for adding a +team to a live match. The editor generation screen owns that discard and RNG +rollback. Tests destroy jobs after the header/Team exist and at several subsequent +gradient checkpoints, exercising cleanup with both allocated and missing arrays. +Race loading, object construction, and initial area-array filling still contain +synchronous work and remain part of 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..332aecd71 --- /dev/null +++ b/docs/browser/adr-005-generation-randomness.md @@ -0,0 +1,121 @@ +# ADR 005: generation randomness before cooperative scheduling + +Status: explicit seeds and cooperative editor generation implemented; long helper operations remain. + +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. + +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. Other gradient and allocation helpers still contain synchronous work: this does not +yet guarantee a maximum callback duration. Further subdivision and measured +large-map latency gates are required before removing Asyncify. + +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 still +contain synchronous work. +Full generation latency and cancellation bounds remain open until those paths +are subdivided and measured against large-map fixtures. + +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/gateway.md b/docs/browser/gateway.md index f024c6366..e7424fce0 100644 --- a/docs/browser/gateway.md +++ b/docs/browser/gateway.md @@ -34,6 +34,10 @@ 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 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/ApplicationHost.h b/libgag/include/ApplicationHost.h new file mode 100644 index 000000000..45e41df8c --- /dev/null +++ b/libgag/include/ApplicationHost.h @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once +#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); + +// Transitional wait for legacy modal loops. The browser implementation yields +// through Asyncify until these loops become resumable application screens. +void wait(std::uint32_t milliseconds); + +// Read-only diagnostics; hosts decide whether and how to publish them. +void screenChanged(const char* name); +void simulationAdvanced(std::uint32_t tick); +void matchFrame(bool paused); +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/GUIBase.h b/libgag/include/GUIBase.h index fc298fb67..cbdb3beb9 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,17 @@ 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 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 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/ScreenStack.h b/libgag/include/ScreenStack.h new file mode 100644 index 000000000..f4f9877a5 --- /dev/null +++ b/libgag/include/ScreenStack.h @@ -0,0 +1,34 @@ +// 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(); +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..05c01dbc6 --- /dev/null +++ b/libgag/src/ApplicationHost.cpp @@ -0,0 +1,28 @@ +// 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); +} +void screenChanged(const char*) {} +void simulationAdvanced(std::uint32_t) {} +void matchFrame(bool) {} +void exited(int) {} +} diff --git a/libgag/src/GUIBase.cpp b/libgag/src/GUIBase.cpp index 637530cf8..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,113 +407,107 @@ namespace GAGGUI } } - int Screen::execute(DrawableSurface *gfx, int stepLength) + void Screen::beginExecution(DrawableSurface *surface) { -#ifdef __EMSCRIPTEN__ - EM_ASM({ Module['glob2Screen'] = UTF8ToString($0); }, typeid(*this).name()); -#endif - 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; @@ -740,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; @@ -774,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/GraphicContext.cpp b/libgag/src/GraphicContext.cpp index df2f42276..7cea852bb 100644 --- a/libgag/src/GraphicContext.cpp +++ b/libgag/src/GraphicContext.cpp @@ -602,9 +602,6 @@ namespace GAGCore void GraphicContext::nextFrame(void) { -#ifdef __EMSCRIPTEN__ - emscripten_sleep(1); -#endif DrawableSurface::nextFrame(); if (sdlsurface) { diff --git a/libgag/src/SConscript b/libgag/src/SConscript index 9c68edbdc..1e11cdda8 100644 --- a/libgag/src/SConscript +++ b/libgag/src/SConscript @@ -21,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..1889ac515 --- /dev/null +++ b/libgag/src/ScreenStack.cpp @@ -0,0 +1,105 @@ +// 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::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/scons/sources.py b/scons/sources.py index e7b13c73c..461a4c627 100644 --- a/scons/sources.py +++ b/scons/sources.py @@ -1,6 +1,9 @@ """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', @@ -98,12 +101,18 @@ 'CustomGameScreen.cpp', 'DynamicClouds.cpp', 'EditorMainMenu.cpp', + 'EditorLoadScreen.cpp', + 'EditorGenerateScreen.cpp', 'EndGameScreen.cpp', 'Engine.cpp', + 'GameSessionScreen.cpp', + 'GameLoadScreen.cpp', + 'SinglePlayerFlow.cpp', 'EngineInit.cpp', 'EngineLoaders.cpp', 'EngineRun.cpp', 'FertilityCalculator.cpp', + 'FertilityScreen.cpp', 'FertilityCalculatorDialog.cpp', 'Game.cpp', 'Game_orders.cpp', @@ -469,6 +478,7 @@ ) GAG_SOURCES = ( + 'ApplicationHost.cpp', 'BinaryStream.cpp', 'CursorManager.cpp', 'FileManager.cpp', @@ -484,6 +494,7 @@ 'GUIDropdown.cpp', 'GUIAnimation.cpp', 'GUIBase.cpp', + 'ScreenStack.cpp', 'GUIButton.cpp', 'GUIFileList.cpp', 'GUIKeySelector.cpp', @@ -517,6 +528,7 @@ ) GAG_SERVER_SOURCES = ( + 'ApplicationHost.cpp', 'BinaryStream.cpp', 'Stream.cpp', 'FileManager.cpp', diff --git a/scons/web_build.py b/scons/web_build.py index 38917a771..6e71f6271 100644 --- a/scons/web_build.py +++ b/scons/web_build.py @@ -45,8 +45,7 @@ def build_web(directory, identity, arguments): ''') 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', - '-include', str(root / 'browser/BrowserPlatform.h')] + PORTS) + 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', '-sASYNCIFY', '-sASYNCIFY_STACK_SIZE=1048576', '-sALLOW_MEMORY_GROWTH', '-sINITIAL_MEMORY=134217728', '-sSTACK_SIZE=8388608', '-sASSERTIONS=1', @@ -63,9 +62,9 @@ def prepare_ports(target, source, env): 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 != 'VoiceRecorder.cpp'] - files += ['libgag/src/' + s for s in GAG_SOURCES] + 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'] + files += ['browser/VoiceRecorder.cpp', 'browser/ApplicationHost.cpp'] objects = [env.Object(str(output / 'obj' / (f + '.o')), f) for f in files] env.Requires(objects, ports) env.Depends(objects, str(config)) diff --git a/src/Application.cpp b/src/Application.cpp new file mode 100644 index 000000000..95d4dd233 --- /dev/null +++ b/src/Application.cpp @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "Application.h" +#include "GlobalContainer.h" +#include "MainMenuScreen.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" + +Application::Application() : screens(*globalContainer->gfx), singlePlayer(screens) +{ + if (globalContainer->replaying) singlePlayer.replay(globalContainer->replayFileName); + else mainMenu(); +} + +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()); break; + case MainMenuScreen::MULTIPLAYERS_YOG: + screens.push(std::make_unique(std::make_shared())); break; + case MainMenuScreen::QUIT: screens.stop(); break; + } +} + +bool Application::frame(std::uint32_t tick, const std::vector& events) +{ + lastFrame = tick; + screens.frame(tick, events); + if (!screens.running()) { + if (screens.result() == GAGGUI::Screen::QUIT_APPLICATION) return false; + mainMenu(); + } + return true; +} + +std::uint32_t Application::delay(std::uint32_t now) +{ + const auto elapsed = static_cast(now - lastFrame); + return screens.delay(now, elapsed < 40 ? 40 - elapsed : 0); +} diff --git a/src/Application.h b/src/Application.h new file mode 100644 index 000000000..a50c5afbe --- /dev/null +++ b/src/Application.h @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once +#include +#include +#include "SinglePlayerFlow.h" + +// Shared application state. Scheduling/event polling belong to platform hosts. +class Application : public GAGCore::ApplicationHost::Loop +{ +public: + Application(); + bool frame(std::uint32_t tick, const std::vector& events) override; + std::uint32_t delay(std::uint32_t now) override; +private: + GAGGUI::ScreenStack screens; + SinglePlayerFlow singlePlayer; + std::uint32_t lastFrame = 0; + void mainMenu(); + void choose(int choice); +}; diff --git a/src/CampaignEditor.cpp b/src/CampaignEditor.cpp index 90c6baf2d..4475b4fc4 100644 --- a/src/CampaignEditor.cpp +++ b/src/CampaignEditor.cpp @@ -12,7 +12,7 @@ #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); @@ -60,58 +60,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(); diff --git a/src/CampaignEditor.h b/src/CampaignEditor.h index 10ed0332f..10e88d6e8 100644 --- a/src/CampaignEditor.h +++ b/src/CampaignEditor.h @@ -5,6 +5,7 @@ #include "Glob2Screen.h" #include "Campaign.h" +#include #include "GUIText.h" #include "GUIButton.h" #include "GUIList.h" @@ -15,7 +16,7 @@ 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); enum { @@ -27,6 +28,7 @@ class CampaignEditor : public Glob2Screen }; private: Campaign campaign; + GAGGUI::ScreenStack& screens; /// Title of the screen, depends on the directory given in parameter Text *title; /// The ok button 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..e38106741 100644 --- a/src/CampaignMenuScreen.cpp +++ b/src/CampaignMenuScreen.cpp @@ -5,11 +5,14 @@ #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" -CampaignMenuScreen::CampaignMenuScreen(const std::string& name) +CampaignMenuScreen::CampaignMenuScreen(const std::string& name, GAGGUI::ScreenStack& screens) : screens(screens) { if (!campaign.load(name)) campaign.setName(name); @@ -35,6 +38,12 @@ CampaignMenuScreen::CampaignMenuScreen(const std::string& name) 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. + campaign.save(true); + return; + } if ((action==BUTTON_RELEASED) || (action==BUTTON_SHORTCUT)) { if (par1==EXIT) @@ -50,27 +59,26 @@ void CampaignMenuScreen::onAction(Widget *source, Action action, int par1, int p 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]")); + 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(); + if (!campaign.save(true)) { + auto& strings = *Toolkit::getStringTable(); + screens.push(std::make_unique(strings.getString("[ERROR_CANT_SAVE_CAMPAIGN]"), + std::vector{strings.getString("[ok]")})); + } + }); + } else if (result == 2) { + auto& strings = *Toolkit::getStringTable(); + screens.push(std::make_unique(strings.getString("[ERROR_CANT_LOAD_MAP]"), + std::vector{strings.getString("[ok]")})); + } + }); } } } diff --git a/src/CampaignMenuScreen.h b/src/CampaignMenuScreen.h index 7ad14f706..88865a523 100644 --- a/src/CampaignMenuScreen.h +++ b/src/CampaignMenuScreen.h @@ -4,6 +4,7 @@ #pragma once #include "Campaign.h" +#include #include "Glob2Screen.h" #include "GUIButton.h" #include "GUICheckList.h" @@ -17,7 +18,7 @@ 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(); enum @@ -27,6 +28,7 @@ class CampaignMenuScreen : public Glob2Screen }; private: Campaign campaign; + GAGGUI::ScreenStack& screens; /// Title of the screen Text* title; diff --git a/src/CustomGameScreen.cpp b/src/CustomGameScreen.cpp index 99e839b17..86b997cb6 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(); diff --git a/src/CustomGameScreen.h b/src/CustomGameScreen.h index 8158bebdc..00aee467e 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; @@ -51,6 +52,7 @@ class CustomGameScreen : public Glob2TabScreen private: friend struct CustomGameSetupHarness; + GAGGUI::ScreenStack& screens; CustomGameSetup setup; MapHeader mapHeader; GameHeader gameHeader; 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..f5f4b2fcd 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 @@ -552,7 +553,7 @@ void EndGameScreen::saveReplay(const char *dir, const char *ext) 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))); + GAGCore::ApplicationHost::wait(std::max(0, 40ll - static_cast(ntime) + static_cast(time))); } if (loadSaveScreen->endValue==0) diff --git a/src/Engine.cpp b/src/Engine.cpp index 28607ce8b..73fceeb82 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" @@ -33,10 +32,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 +75,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..51cea0ef3 100644 --- a/src/Engine.h +++ b/src/Engine.h @@ -51,14 +51,18 @@ 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); /// 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); + 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(); + - /// 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); @@ -78,6 +82,19 @@ 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); + bool finishSession(); + //! Type of error the engine init function can return enum EngineError @@ -105,6 +122,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 +171,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 +188,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. diff --git a/src/EngineInit.cpp b/src/EngineInit.cpp index 770c010eb..4a13334f6 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,117 +22,59 @@ #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(); - } - - previousCustomSpeed=globalContainer->settings.gameSpeed; - globalContainer->settings.gameSpeed=customGameScreen.selectedSpeed(); - return EE_NO_ERROR; + 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(const std::string &gameName) +int Engine::initCustom(MapHeader& map, GameHeader& players, int localTeam) { - 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); + gui.localPlayer = 0; + gui.localTeamNo = localTeam; + co_return co_await initGameTask(map, players); +} +int Engine::initCustom(const std::string& filename) +{ + const bool loaded = initCustomTask(filename).run(); + if (!loaded) showMapLoadError(); + return loaded ? EE_NO_ERROR : EE_CANT_LOAD_MAP; +} +GAGCore::CooperativeTask Engine::initCustomTask(std::string filename) +{ + 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) { gui.localPlayer = localPlayer; @@ -312,11 +252,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 +271,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 +343,7 @@ int Engine::initGame(MapHeader& mapHeader, GameHeader& gameHeader, bool setGameH } } - return EE_NO_ERROR; + co_return true; } @@ -463,8 +409,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 +426,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 +457,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 +489,9 @@ void Engine::finalAdjustments(void) } gui.game.setAlliances(); } + +void Engine::cancelInitialization() +{ + teardownSession(); + clearReplayState(); +} diff --git a/src/EngineRun.cpp b/src/EngineRun.cpp index 59f96e763..db2807e8c 100644 --- a/src/EngineRun.cpp +++ b/src/EngineRun.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 "AINames.h" @@ -20,11 +21,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 +52,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) @@ -199,14 +201,13 @@ void Engine::executeOrdersAndStep(bool readyNow) } gui.game.syncStep(gui.localTeamNo); -#ifdef __EMSCRIPTEN__ - EM_ASM({ Module['glob2Tick'] = $0; Module['glob2Screen'] = 'match'; }, gui.game.stepCounter); -#endif + 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) { @@ -224,9 +225,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 @@ -235,10 +249,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. @@ -246,6 +257,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 @@ -489,77 +501,89 @@ 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; - - st.wasReadyLastTick = readyNow; - } + 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; +} - if (globalContainer->automaticEndingGame) - printAutomaticEndingSummary(); +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 (multiplayer) - reportMultiplayerResult(); +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; +} - teardownSession(); +bool Engine::finishSession() +{ + 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(); + bool restart = false; + prepareNextGameSession(restart); + return restart; +} - 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..209cdfdaa 100644 --- a/src/FertilityCalculator.cpp +++ b/src/FertilityCalculator.cpp @@ -13,6 +13,7 @@ #include #include #include +#include namespace { @@ -51,95 +52,92 @@ 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; + 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; } + const auto [x, y] = s.coordinate(); + if (!s.map.isGrass(x, y) || !s.distance[s.map.coordToIndex(x, y)]) { ++s.cursor; continue; } + const int nx = s.kernelOffset % kKernelSide - kFertilityRadius; + const int ny = s.kernelOffset / kKernelSide - kFertilityRadius; + if (s.map.isWater(x + nx, y + ny)) s.total += kernel[s.kernelOffset]; + if (++s.kernelOffset == kKernelSide * kKernelSide) { + s.fertility[s.map.coordToIndex(x, y)] = 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 index 605046093..18b50af31 100644 --- a/src/FertilityCalculatorDialog.cpp +++ b/src/FertilityCalculatorDialog.cpp @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Copyright (C) 2007-2008 Bradley Arsenault +#include #include "FertilityCalculatorDialog.h" #include "FertilityCalculator.h" @@ -51,7 +52,7 @@ void FertilityCalculatorDialog::runModal() progressFraction.store(p, std::memory_order_relaxed); refreshProgressDisplay(); dispatchPaint(); - emscripten_sleep(1); + GAGCore::ApplicationHost::wait(1); }); computeDone.store(true, std::memory_order_release); #else 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/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/GameLoadScreen.cpp b/src/GameLoadScreen.cpp new file mode 100644 index 000000000..d2eba9223 --- /dev/null +++ b/src/GameLoadScreen.cpp @@ -0,0 +1,48 @@ +// 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) + : slice(std::move(slice)), previousRng(getSyncRandState()), engine(std::make_unique()) +{ + 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(*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..682b4ad9b --- /dev/null +++ b/src/GameLoadScreen.h @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once +#include "Glob2Screen.h" +#include +#include +#include +#include +class Engine; +namespace GAGGUI { class Text; } +// Startup loading only: there must be no active engine/session. +class GameLoadScreen : public Glob2Screen +{ +public: + using Initializer = std::function; + explicit GameLoadScreen(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..190741e90 --- /dev/null +++ b/src/GameSessionScreen.cpp @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "GameSessionScreen.h" +#include "Engine.h" +#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->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 { + 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 (engine->finishSession()) { + engine->beginSession(clock); + nextTick = clock; + 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)); +} diff --git a/src/GameSessionScreen.h b/src/GameSessionScreen.h new file mode 100644 index 000000000..5f7529f41 --- /dev/null +++ b/src/GameSessionScreen.h @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once +#include +#include +#include +#include +class Engine; + +// Retains the initialized engine through gameplay and the end-game screen. +// Loading remains a separate migration concern. +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 handleExecutionEvent(SDL_Event event) override; + void drawExecution() override; + Uint32 executionDelay(Uint32 now, Uint32 fallback) override; +private: + GAGGUI::ScreenStack& stack; + std::unique_ptr engine; + std::vector input; + bool started = false, finished = false; + Uint32 lastTick = 0; + Uint64 clock = 0, nextTick = 0; +}; diff --git a/src/Game_editor.cpp b/src/Game_editor.cpp index a1646435d..ccb5c024d 100644 --- a/src/Game_editor.cpp +++ b/src/Game_editor.cpp @@ -80,6 +80,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 +190,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 +203,37 @@ 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); + co_await GAGCore::CooperativeTask::checkpoint("[Loading players]"); players[i]=new Player(stream, teams, versionMinor); 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/Glob2.cpp b/src/Glob2.cpp index 7cc4af76a..c23522c32 100644 --- a/src/Glob2.cpp +++ b/src/Glob2.cpp @@ -1,6 +1,8 @@ // 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" @@ -15,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" @@ -78,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); @@ -459,7 +426,8 @@ int Glob2::run(int argc, char *argv[]) #ifdef GLOB2_ROUTER_ONLY - YOGServerRouter router; + const char* lobbyHost = std::getenv("GLOB2_YOG_HOST"); + YOGServerRouter router(lobbyHost ? lobbyHost : "127.0.0.1"); int routerResult = router.run(); delete globalContainer; return routerResult; @@ -506,149 +474,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 @@ -685,8 +517,6 @@ int main(int argc, char *argv[]) Glob2 glob2; int result = glob2.run(argc, argv); -#ifdef __EMSCRIPTEN__ - EM_ASM({ Module['glob2Screen'] = 'exited'; Module['onGameExit']($0); }, result); -#endif - return result; + 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/MainMenuScreen.cpp b/src/MainMenuScreen.cpp index 596bc28a4..3db66a3c1 100644 --- a/src/MainMenuScreen.cpp +++ b/src/MainMenuScreen.cpp @@ -235,8 +235,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..6e2415b20 100644 --- a/src/MainMenuScreen.h +++ b/src/MainMenuScreen.h @@ -30,7 +30,6 @@ 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; diff --git a/src/MapEditorScreen.cpp b/src/MapEditorScreen.cpp new file mode 100644 index 000000000..eed511ff9 --- /dev/null +++ b/src/MapEditorScreen.cpp @@ -0,0 +1,69 @@ +// 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; +} diff --git a/src/MapEditorScreen.h b/src/MapEditorScreen.h new file mode 100644 index 000000000..37199f5f3 --- /dev/null +++ b/src/MapEditorScreen.h @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once +#include +#include +#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 handleExecutionEvent(SDL_Event event) override; + void drawExecution() override; + Uint32 executionDelay(Uint32 now, Uint32) override; +private: + GAGGUI::ScreenStack& screens; + std::unique_ptr editor; + std::vector input; + bool started = false; + Uint32 lastFrame = 0; +}; 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/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/SConscript b/src/SConscript index 2e7859cc0..4f97c13f9 100644 --- a/src/SConscript +++ b/src/SConscript @@ -91,6 +91,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. 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/SinglePlayerFlow.cpp b/src/SinglePlayerFlow.cpp new file mode 100644 index 000000000..2cc6e94cb --- /dev/null +++ b/src/SinglePlayerFlow.cpp @@ -0,0 +1,55 @@ +// 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) +{ + screens.push(std::make_unique(std::move(initialize)), + [this, repeatCustom](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)](Engine& engine) { + return engine.initCustomTask(map, players, team); + }, true); + }); +} + +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..b32cca1be --- /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); +}; 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/gui/GameGUI.h b/src/gui/GameGUI.h index f392da353..0856dadff 100644 --- a/src/gui/GameGUI.h +++ b/src/gui/GameGUI.h @@ -5,6 +5,7 @@ #pragma once #include +#include #include #include #include @@ -78,6 +79,8 @@ 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(); @@ -92,8 +95,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); @@ -530,6 +535,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 diff --git a/src/gui/GameGUIDraw.cpp b/src/gui/GameGUIDraw.cpp index 3ac44b866..fc4f48f52 100644 --- a/src/gui/GameGUIDraw.cpp +++ b/src/gui/GameGUIDraw.cpp @@ -370,12 +370,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) { 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/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/GameGUIPersistence.cpp b/src/gui/GameGUIPersistence.cpp index 3e39e311d..b61227019 100644 --- a/src/gui/GameGUIPersistence.cpp +++ b/src/gui/GameGUIPersistence.cpp @@ -26,31 +26,36 @@ 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) diff --git a/src/gui/GameGUIStep.cpp b/src/gui/GameGUIStep.cpp index b33c5e93a..4701e8dae 100644 --- a/src/gui/GameGUIStep.cpp +++ b/src/gui/GameGUIStep.cpp @@ -111,16 +111,22 @@ 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) +{ + 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)) + // Process host-supplied events in their original order; coalesce only mouse motion. + for (auto event : events) { GAGCore::GraphicContext::translateMouseEvent(&event); if (event.type==SDL_MOUSEMOTION) @@ -167,14 +173,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 +188,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 +210,7 @@ void GameGUI::step(void) } if (wasMouseMotion) processEvent(&mouseMotionEvent); - if (wasWindowEvent) - processEvent(&windowEvent); + flushScrollWheelOrders(); @@ -219,7 +220,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 +239,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/map/Map.h b/src/map/Map.h index 6132d9380..2b421990a 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 @@ -113,6 +114,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 +127,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 +632,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 +820,15 @@ 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/edit/MapEdit.h b/src/map/edit/MapEdit.h index a915f3d1b..cf9d1651c 100644 --- a/src/map/edit/MapEdit.h +++ b/src/map/edit/MapEdit.h @@ -4,6 +4,8 @@ #pragma once #include +#include +#include #include "Brush.h" #include "GAGSys.h" @@ -364,19 +366,35 @@ 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 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/MapEditActionView.cpp b/src/map/edit/MapEditActionView.cpp index 08fb55bd3..cb0d2fb76 100644 --- a/src/map/edit/MapEditActionView.cpp +++ b/src/map/edit/MapEditActionView.cpp @@ -203,10 +203,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/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..2a5d6aabe 100644 --- a/src/map/edit/MapEditDelegate.cpp +++ b/src/map/edit/MapEditDelegate.cpp @@ -63,7 +63,7 @@ void MapEdit::delegateMenu(SDL_Event& event) { case LoadSaveScreen::OK: { - load(loadSaveScreen->getFileName()); + requestLoad(loadSaveScreen->getFileName()); performAction("close load screen"); } break; @@ -81,11 +81,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; + performAction("close save screen"); + } + break; case LoadSaveScreen::CANCEL: { + doQuitAfterLoadSave = false; performAction("close save screen"); } } @@ -134,9 +138,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/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..b03889e6e 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" @@ -20,73 +21,35 @@ 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()) { @@ -103,6 +66,7 @@ bool MapEdit::save(const std::string filename, const std::string name) // 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. + hasMapBeenModified = false; game.mapHeader.setMapName(name); game.mapHeader.setIsSavedGame(false); return true; @@ -111,16 +75,7 @@ bool MapEdit::save(const std::string filename, const std::string name) -int MapEdit::run(int sizeX, int sizeY, TerrainType terrainType) -{ - game.map.setSize(sizeX, sizeY, terrainType); - game.map.setGame(&game); - return run(); -} - - - -int MapEdit::run(void) +void MapEdit::beginEditing() { FrontendScope editor(false); minimap.setGame(game); @@ -128,114 +83,143 @@ 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; + 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) { - 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)); - } + 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"); } - return returnCode; + 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) + { + 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(); + + +} + +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; + bool saved = true; + if (!pendingSaveFilename.empty()) { + if (completed) saved = save(pendingSaveFilename, pendingSaveName); + if (!completed || !saved) doQuitAfterLoadSave = false; + pendingSaveFilename.clear(); pendingSaveName.clear(); + } else if (completed) { + overlay.forceRecompute(); + overlay.compute(game, OverlayArea::Fertility, team); + } else isFertilityOn = false; + return saved; +} 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..f397e3e1e 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 (descriptor.wDec < 4 || descriptor.wDec >= 16 || descriptor.hDec < 4 || descriptor.hDec >= 16 || + 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/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. +/// This random map generator generates a heightfield and then choses levels at which to draw the line between water, sand, gras and sand again (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/MapIO.cpp b/src/map/io/MapIO.cpp index d820b98e7..6d21899fe 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,7 +42,7 @@ 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: @@ -44,7 +50,7 @@ try 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; + 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 +121,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 +139,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 +156,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 +183,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 +257,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 +279,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/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/test/CustomGameSetupHarness.cpp b/test/CustomGameSetupHarness.cpp index f7c8717e6..91bcf4d09 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" @@ -97,7 +98,8 @@ struct CustomGameSetupHarness if (write) files->remove(CustomGamePreferences::filename); if (write) { - CustomGameScreen screen; + GAGGUI::ScreenStack screens; + 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 +123,8 @@ struct CustomGameSetupHarness { std::string premade; { - CustomGameScreen screen; + GAGGUI::ScreenStack screens; + 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 +143,8 @@ struct CustomGameSetupHarness assert(screen.previewPending); } { - CustomGameScreen screen; + GAGGUI::ScreenStack screens; + 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 +154,8 @@ struct CustomGameSetupHarness screen.setup.premadeMap = "/missing/saved-map.map"; } { - CustomGameScreen screen; + GAGGUI::ScreenStack screens; + 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 +165,8 @@ struct CustomGameSetupHarness out.write(truncated.data(), truncated.size(), "broken preferences"); }); { - CustomGameScreen screen; + GAGGUI::ScreenStack screens; + CustomGameScreen screen(screens); assert(screen.validMap && screen.setup.capacity == 4 && screen.setup.speed == 0); } files->remove(CustomGamePreferences::filename); @@ -340,7 +346,8 @@ struct CustomGameSetupHarness assert(profile.returnCode == AINames::selectionIndex(AI::CORTEX)); } - CustomGameScreen screen; + GAGGUI::ScreenStack screens; + 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..5a024794d --- /dev/null +++ b/test/EngineSessionHarness.cpp @@ -0,0 +1,499 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "Engine.h" +#include "Unit.h" +#include "Building.h" +#include "GameSessionScreen.h" +#include "MapEdit.h" +#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 +#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"); + { + 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"); + { + 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() && slices >= 8 && scheduled == expected, + "Scheduled gradient must yield and match the queue oracle"); + auto cancelled = seed; + { + auto partial = map.updateGlobalGradientTask(cancelled.data()); + 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"); + } + } + { + // Adding a team to a private preparation game must be cancellable + // after its header/Team exist but before all map arrays are allocated. + for (unsigned extraSteps : {0u, 5u, 20u}) { + Game partial(nullptr); + MapGenerator generator; + MapGenerationDescriptor descriptor; + require(generator.generateMap(partial, descriptor, 12345), "Team fixture generation failed"); + auto task = partial.addTeamTask(); + while (std::string(task.stage()) != "[Building gradients]") + require(!task.advance(), "Team task must yield during gradient construction"); + require(partial.mapHeader.getNumberOfTeams() == 2, "Team header must precede map preparation"); + for (unsigned step = 0; step < extraSteps; ++step) + require(!task.advance(), "Team cancellation fixture finished too early"); + // task is destroyed before partial, releasing its nested frame. + } + } + { + 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"); + } + 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())); + }); + while (screens.running()) { + screens.frame(1000 + frames * 40, {}); + require(++frames <= 2000, "Stack-driven loading/session failed to finish"); + } + require(loadingFrames > 20 && frames == loadingFrames + 51 && screens.result() == GAGGUI::Screen::QUIT_APPLICATION, + "Loading must yield before transferring the engine to the 50-tick session"); + } + 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"); + // 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); + } + } + { + MapEdit partial; + auto task = partial.loadTask("maps/balanced.map"); + while (std::string(task.stage()) != "[Building gradients]") + require(!task.advance(), "Fixture must reach gradient allocation checkpoints"); + // Destruction at a partially built gradient array used to assert/leak. + } + 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.getCase(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.getCase(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 "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.getCase(x, y).fertility = fertility[map.coordToIndex(x, y)]; + map.fertilityMaximum = fertilityMax; + } +} 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/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/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() From 4f9e00442e13e8f8599d3eda297cac97fbd2ce06 Mon Sep 17 00:00:00 2001 From: Bradley Arsenault Date: Tue, 8 Sep 2026 08:42:32 -0400 Subject: [PATCH 03/20] Add browser YOG cross-play and deployment support Connect the existing framed YOG protocol through browser WebSockets, retain native TCP and WSS peers, schedule multiplayer navigation safely, and add the tested gateway deployment configuration and cross-play coverage. --- .dockerignore | 14 + .github/workflows/build.yml | 22 +- SConstruct | 7 + browser/IRCTextMessageHandler.cpp | 18 ++ browser/NetTransport.cpp | 94 ++++++ browser/README.md | 5 +- browser/tests/multiplayer.spec.js | 272 +++++++++++++++++ deploy/Caddyfile | 12 + deploy/Dockerfile | 29 ++ deploy/README.md | 86 ++++++ deploy/compose.yaml | 92 ++++++ deploy/healthcheck.py | 13 + docs/browser/gateway.md | 93 +++++- scons/sources.py | 7 +- scons/web_build.py | 6 +- src/Glob2.cpp | 4 +- src/MainMenuScreen.cpp | 2 + src/MultiplayerGame.cpp | 2 +- src/Order.h | 2 +- src/SConscript | 14 + src/add_net_thread_message.py | 103 ------- src/net/NetConnection.cpp | 249 ++++++---------- src/net/NetConnection.h | 97 +++--- src/net/NetConnectionThread.cpp | 236 --------------- src/net/NetConnectionThread.h | 31 -- src/net/NetConnectionThreadMessage.cpp | 359 ----------------------- src/net/NetConnectionThreadMessage.h | 261 ---------------- src/net/NetTransport.cpp | 127 ++++++++ src/net/NetTransport.h | 24 ++ src/net/WssTransport.cpp | 168 +++++++++++ src/net/message/AuthMessages.cpp | 2 +- src/net/message/RegistrationMessages.cpp | 2 +- src/net/message/RouterAdminMessages.cpp | 2 +- src/yog/YOGClient.cpp | 3 +- src/yog/YOGConsts.h | 1 + src/yog/YOGServer.cpp | 13 +- src/yog/YOGServer.h | 4 +- src/yog/YOGServerRouterManager.cpp | 13 +- src/yog/YOGServerRouterManager.h | 1 + test/NativeMultiplayerPeer.cpp | 82 ++++++ test/NetConnectionHarness.cpp | 115 ++++++++ test/WssTransportHarness.cpp | 43 +++ test/run-network-transport-tests.py | 16 + tests/deployment/test_compose.py | 144 +++++++++ tests/transport/test_wss.py | 120 ++++++++ tests/transport/tls_forwarder.py | 39 +++ 46 files changed, 1810 insertions(+), 1239 deletions(-) create mode 100644 .dockerignore create mode 100644 browser/IRCTextMessageHandler.cpp create mode 100644 browser/NetTransport.cpp create mode 100644 browser/tests/multiplayer.spec.js create mode 100644 deploy/Caddyfile create mode 100644 deploy/Dockerfile create mode 100644 deploy/README.md create mode 100644 deploy/compose.yaml create mode 100644 deploy/healthcheck.py delete mode 100644 src/add_net_thread_message.py delete mode 100644 src/net/NetConnectionThread.cpp delete mode 100644 src/net/NetConnectionThread.h delete mode 100644 src/net/NetConnectionThreadMessage.cpp delete mode 100644 src/net/NetConnectionThreadMessage.h create mode 100644 src/net/NetTransport.cpp create mode 100644 src/net/NetTransport.h create mode 100644 src/net/WssTransport.cpp create mode 100644 test/NativeMultiplayerPeer.cpp create mode 100644 test/NetConnectionHarness.cpp create mode 100644 test/WssTransportHarness.cpp create mode 100644 test/run-network-transport-tests.py create mode 100644 tests/deployment/test_compose.py create mode 100644 tests/transport/test_wss.py create mode 100644 tests/transport/tls_forwarder.py 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 769b96859..a531a8baa 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,6 +5,8 @@ on: branches: [master] pull_request: workflow_dispatch: + schedule: + - cron: '19 4 * * *' jobs: linux: @@ -54,7 +56,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 @@ -309,6 +311,7 @@ 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 @@ -377,7 +380,7 @@ jobs: - name: Install native and build dependencies run: | 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 zlib1g-dev libfribidi-dev libgl1-mesa-dev libglu1-mesa-dev libepoxy-dev + 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 @@ -396,13 +399,26 @@ jobs: node-version: '22' cache: npm cache-dependency-path: browser/package-lock.json - - name: Test browser single-player flow + - name: Build transport integration fixture + if: matrix.order == 'concurrent' + run: | + scons release=1 transport-test -j2 + python3 test/run-network-transport-tests.py + python3 -m unittest discover -s tests/transport -v + - name: Test browser single-player and multiplayer if: matrix.order == 'concurrent' working-directory: browser + env: + GLOB2_ALL_AIS: ${{ github.event_name == 'schedule' && '1' || '0' }} run: | npm ci --ignore-scripts npx playwright install --with-deps chromium firefox webkit npm test + - name: Test self-hosting with real TLS and persistent volumes + if: matrix.order == 'concurrent' + run: | + docker compose -f deploy/compose.yaml build lobby + python3 -m unittest discover -s tests/deployment -v - uses: actions/upload-artifact@v4 if: failure() && matrix.order == 'concurrent' with: diff --git a/SConstruct b/SConstruct index fda98a60a..503b630e7 100644 --- a/SConstruct +++ b/SConstruct @@ -153,6 +153,13 @@ def configure(env, server_only): if conf.CheckLib("boost_system"): env.Append(LIBS=["boost_system"]) env.Append(LIBS=["pthread"]) + if not server_only: + 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"]) + if env["mingw"] or env["mingwcross"]: + env.Append(LIBS=["ws2_32", "mswsock"]) + if not conf.CheckCXXHeader("boost/logic/tribool.hpp"): 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 index 0ea553a34..51c3408bb 100644 --- a/browser/README.md +++ b/browser/README.md @@ -40,8 +40,9 @@ the page scales the canvas proportionally and may add black bars. ## Scope This is a desktop-browser experiment with mouse and keyboard controls. -Networking code remains compiled to satisfy existing dependencies but the -multiplayer entry points are hidden. Voice chat is a no-op; music uses the +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. There is no WebGL renderer rewrite or mobile UI adaptation. diff --git a/browser/tests/multiplayer.spec.js b/browser/tests/multiplayer.spec.js new file mode 100644 index 000000000..a4f7d8bd6 --- /dev/null +++ b/browser/tests/multiplayer.spec.js @@ -0,0 +1,272 @@ +const {test, expect} = require('@playwright/test'); +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('/'); await screen('MainMenuScreen'); + await click(440, 490); 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('/'); await screen('MainMenuScreen'); + await click(440, 490); 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('Glob2TabScreen'); + 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('/'); await screen('MainMenuScreen'); + await click(440, 490); 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('Glob2TabScreen'); +} +function receivedTypes(page, direction = 'framereceived') { + 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(bytes[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', 'Maxima', '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) => { + 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 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).toBeGreaterThan(100); + 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')}); + } 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 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))); + await expect.poll(() => hostTypes().filter(type => type === 26).length).toBeGreaterThanOrEqual(2); + await click(1090, 475); + await expect.poll(async () => (await page.evaluate(() => glob2Diagnostics.snapshot())).tick).toBeGreaterThan(125); + await page.screenshot({path: testInfo.outputPath('native-cross-play.png')}); + await expect.poll(() => peer.exitCode ?? peer.signalCode, {timeout: 45000}).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); + 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)); + + } 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}); + } +}); 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/gateway.md b/docs/browser/gateway.md index e7424fce0..f713dbce4 100644 --- a/docs/browser/gateway.md +++ b/docs/browser/gateway.md @@ -1,9 +1,11 @@ # Development WebSocket gateway This is transport infrastructure, not yet a supported multiplayer release. -The browser client still has multiplayer disabled. Protocol negotiation, native -WSS, browser transport integration, account migration, invitation rooms and -coordinated recovery remain separate delivery gates. +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. Upgraded protocol negotiation, account migration, invitation rooms and coordinated recovery remain release +gates; complete cross-play matches are not yet certified. ## Build and run @@ -60,8 +62,91 @@ The lobby/router control connection uses the existing internal TCP port 7490. The listener is plain HTTP/WebSocket on loopback by default. Public deployment requires a TLS reverse proxy, private backend ports, and restricted metrics -routing. A supported Compose distribution has not yet been delivered. +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 250 simulation ticks; its checksums are compared with the browser +at the negotiated command cadence. 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 Maxima; +`GLOB2_ALL_AIS=1` covers all seven shipped AIs and is enabled nightly. 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 now require OpenSSL development headers/libraries alongside +Boost. 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/scons/sources.py b/scons/sources.py index 461a4c627..ca081ba0a 100644 --- a/scons/sources.py +++ b/scons/sources.py @@ -238,8 +238,8 @@ 'net/NetBroadcaster.cpp', 'net/NetBroadcastListener.cpp', 'net/NetConnection.cpp', - 'net/NetConnectionThread.cpp', - 'net/NetConnectionThreadMessage.cpp', + 'net/NetTransport.cpp', + 'net/WssTransport.cpp', 'net/NetEngine.cpp', 'net/NetGamePlayerManager.cpp', 'net/NetListener.cpp', @@ -390,8 +390,7 @@ 'map/io/MapHeader.cpp', 'net/NetBroadcaster.cpp', 'net/NetConnection.cpp', - 'net/NetConnectionThread.cpp', - 'net/NetConnectionThreadMessage.cpp', + 'net/NetTransport.cpp', 'net/NetGamePlayerManager.cpp', 'net/NetListener.cpp', 'net/message/NetMessage.cpp', diff --git a/scons/web_build.py b/scons/web_build.py index 6e71f6271..9c89f5e41 100644 --- a/scons/web_build.py +++ b/scons/web_build.py @@ -49,7 +49,7 @@ def build_web(directory, identity, arguments): env.Append(LINKFLAGS=['-fexceptions', '-O2' if identity['mode']=='release' else '-O0', '-sASYNCIFY', '-sASYNCIFY_STACK_SIZE=1048576', '-sALLOW_MEMORY_GROWTH', '-sINITIAL_MEMORY=134217728', '-sSTACK_SIZE=8388608', '-sASSERTIONS=1', - '-sFORCE_FILESYSTEM', '-lidbfs.js', + '-sFORCE_FILESYSTEM', '-lidbfs.js', '-lwebsocket.js', "'-sEXPORTED_RUNTIME_METHODS=[\"callMain\",\"FS\"]'", '--shell-file', 'browser/shell.html'] + PORTS) for asset_directory in ('data', 'maps', 'campaigns', 'scripts'): @@ -61,10 +61,10 @@ def prepare_ports(target, source, env): 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 != 'VoiceRecorder.cpp'] + 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'] + 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)) diff --git a/src/Glob2.cpp b/src/Glob2.cpp index c23522c32..86ece08f2 100644 --- a/src/Glob2.cpp +++ b/src/Glob2.cpp @@ -434,7 +434,9 @@ int Glob2::run(int argc, char *argv[]) #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; diff --git a/src/MainMenuScreen.cpp b/src/MainMenuScreen.cpp index 3db66a3c1..50b29077f 100644 --- a/src/MainMenuScreen.cpp +++ b/src/MainMenuScreen.cpp @@ -169,7 +169,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}; diff --git a/src/MultiplayerGame.cpp b/src/MultiplayerGame.cpp index ebdc45b68..2d6403578 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(); } 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/SConscript b/src/SConscript index 4f97c13f9..8b187b256 100644 --- a/src/SConscript +++ b/src/SConscript @@ -108,6 +108,20 @@ 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) + 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-router" if env.get("role") == "router" else "glob2-server", server_source_files) 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/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..79c73b497 --- /dev/null +++ b/src/net/NetTransport.cpp @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "NetTransport.h" +#include +#include +#include +#include +#include + +#ifndef YOG_SERVER_ONLY +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(); + selected = address.rfind("wss://", 0) == 0 ? makeWssTransport() : 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/message/AuthMessages.cpp b/src/net/message/AuthMessages.cpp index 138fc71b8..95563763b 100644 --- a/src/net/message/AuthMessages.cpp +++ b/src/net/message/AuthMessages.cpp @@ -174,7 +174,7 @@ void NetAttemptLogin::decodeData(GAGCore::InputStream* stream) std::string NetAttemptLogin::format() const { std::ostringstream s; - s<<"NetAttemptLogin("<<"username=\""<("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(); @@ -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/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/NativeMultiplayerPeer.cpp b/test/NativeMultiplayerPeer.cpp new file mode 100644 index 000000000..f8987d742 --- /dev/null +++ b/test/NativeMultiplayerPeer.cpp @@ -0,0 +1,82 @@ +// 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; + globalContainer->automaticEndingSteps = 250; + 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->isFullyInGame() && !ready) { + game->setHumanReady(true); + ready = true; + std::cout << "native peer joined order-rate=" << int(game->getGameHeader().getOrderRate()) << 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..c806502d9 --- /dev/null +++ b/test/NetConnectionHarness.cpp @@ -0,0 +1,115 @@ +// 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"); + 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"); + } + 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}}) { + 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/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-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/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/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()) From ef191bb0b4c03b96288842efeedcc1f80c52cd94 Mon Sep 17 00:00:00 2001 From: Bradley Arsenault Date: Tue, 8 Sep 2026 10:17:06 -0400 Subject: [PATCH 04/20] Add durable storage, imports, resizing, and WebGL2 Integrate browser-specific services with the shared source layout, make persistence failures recoverable, support validated file import and export, resize active screens at frame boundaries, and add opt-in WebGL2 context recovery. --- .github/workflows/build.yml | 16 +- .gitignore | 2 +- SConstruct | 7 +- browser/ApplicationHost.cpp | 139 +++++++++++++ browser/benchmarks/rendering.cjs | 35 ++++ browser/file-selection.js | 55 +++++ browser/package.json | 2 +- browser/shell.html | 43 +++- browser/storage.js | 59 ++++++ browser/tests/campaign-progress.spec.js | 101 ++++++++++ browser/tests/editor-storage.spec.js | 49 +++++ browser/tests/file-selection.spec.js | 34 ++++ browser/tests/game-url.js | 9 + browser/tests/import.spec.js | 123 +++++++++++ browser/tests/multiplayer.spec.js | 17 +- browser/tests/pixels.js | 16 ++ browser/tests/rendering.spec.js | 98 +++++++++ browser/tests/single-player.spec.js | 13 +- browser/tests/storage.spec.js | 100 +++++++++ browser/tests/viewport.spec.js | 74 +++++++ browser/unit/file-selection.test.js | 65 ++++++ browser/unit/storage.test.js | 47 +++++ browser/visibility.config.js | 2 + browser/visibility/lifecycle.spec.js | 64 ++++++ data/texts.ar.txt | 42 ++++ data/texts.br.txt | 42 ++++ data/texts.ca.txt | 42 ++++ data/texts.cz.txt | 42 ++++ data/texts.de.txt | 42 ++++ data/texts.dk.txt | 42 ++++ data/texts.en.txt | 42 ++++ data/texts.eo.txt | 42 ++++ data/texts.es.txt | 42 ++++ data/texts.eu.txt | 42 ++++ data/texts.fa.txt | 42 ++++ data/texts.fi.txt | 42 ++++ data/texts.fr.txt | 42 ++++ data/texts.gr.txt | 42 ++++ data/texts.hu.txt | 42 ++++ data/texts.id.txt | 42 ++++ data/texts.it.txt | 42 ++++ data/texts.ja.txt | 42 ++++ data/texts.keys.txt | 21 ++ data/texts.ko.txt | 42 ++++ data/texts.nl.txt | 42 ++++ data/texts.pl.txt | 42 ++++ data/texts.pt.txt | 42 ++++ data/texts.ro.txt | 42 ++++ data/texts.ru.txt | 42 ++++ data/texts.si.txt | 42 ++++ data/texts.sk.txt | 42 ++++ data/texts.sr.txt | 42 ++++ data/texts.sv.txt | 42 ++++ data/texts.tr.txt | 42 ++++ data/texts.uk.txt | 42 ++++ data/texts.vi.txt | 42 ++++ data/texts.zh-cn.txt | 42 ++++ data/texts.zh-tw.txt | 42 ++++ docs/browser/adr-004-cooperative-loading.md | 36 ++-- docs/browser/adr-006-webgl2-rendering.md | 93 +++++++++ docs/browser/gateway.md | 4 +- docs/browser/storage.md | 119 +++++++++++ docs/browser/viewport.md | 59 ++++++ libgag/include/ApplicationHost.h | 32 +++ libgag/include/GUIBase.h | 3 + libgag/include/SDLGraphicContext.h | 5 + libgag/include/ScreenStack.h | 2 + libgag/src/ApplicationHost.cpp | 14 ++ libgag/src/BinaryStream.cpp | 3 +- libgag/src/DrawableSurface.cpp | 57 +++++- libgag/src/DrawableSurfaceCompound.cpp | 7 +- libgag/src/FileManagerAtomic.cpp | 19 ++ libgag/src/GraphicContext.cpp | 37 +++- libgag/src/GraphicContextCompound.cpp | 10 +- libgag/src/GraphicContextDraw.cpp | 16 +- libgag/src/GraphicContextPrivate.h | 8 +- libgag/src/ScreenStack.cpp | 12 ++ libgag/src/Sprite.cpp | 7 +- scons/sources.py | 1 + scons/web_build.py | 7 +- src/Application.cpp | 42 +++- src/Application.h | 2 + src/Campaign.cpp | 88 ++++++-- src/Campaign.h | 5 + src/CampaignMenuScreen.cpp | 158 ++++++++++++++- src/CampaignMenuScreen.h | 19 +- src/ChooseMapScreen.cpp | 91 ++++++++- src/ChooseMapScreen.h | 11 + src/Engine.h | 2 + src/FileImport.cpp | 127 ++++++++++++ src/FileImport.h | 32 +++ src/GameHeader.cpp | 2 +- src/GameSessionScreen.cpp | 15 ++ src/GameSessionScreen.h | 4 +- src/Game_io.cpp | 3 +- src/MapEditorScreen.cpp | 13 ++ src/MapEditorScreen.h | 2 + src/Player.cpp | 1 + src/gui/GameGUI.cpp | 5 +- src/gui/GameGUI.h | 5 +- src/gui/GameGUIInputMenu.cpp | 24 +-- src/gui/GameGUILoadSave.cpp | 45 ++++- src/gui/GameGUILoadSave.h | 14 +- src/gui/GameGUIPersistence.cpp | 22 ++ src/gui/GameGUIStep.cpp | 6 + src/map/MapStep.cpp | 5 +- src/map/edit/MapEdit.h | 1 + src/map/edit/MapEditDelegate.cpp | 2 +- src/map/edit/MapEditIO.cpp | 69 ++++--- src/map/io/MapHeader.cpp | 22 +- src/map/io/MapHeader.h | 1 + src/render/Minimap.cpp | 6 + src/render/Minimap.h | 3 +- test/EngineSessionHarness.cpp | 110 +++++++--- test/SavegameSafetyHarness.cpp | 213 ++++++++++++++++++++ 115 files changed, 4116 insertions(+), 182 deletions(-) create mode 100644 browser/benchmarks/rendering.cjs create mode 100644 browser/file-selection.js create mode 100644 browser/storage.js create mode 100644 browser/tests/campaign-progress.spec.js create mode 100644 browser/tests/editor-storage.spec.js create mode 100644 browser/tests/file-selection.spec.js create mode 100644 browser/tests/game-url.js create mode 100644 browser/tests/import.spec.js create mode 100644 browser/tests/pixels.js create mode 100644 browser/tests/rendering.spec.js create mode 100644 browser/tests/storage.spec.js create mode 100644 browser/tests/viewport.spec.js create mode 100644 browser/unit/file-selection.test.js create mode 100644 browser/unit/storage.test.js create mode 100644 browser/visibility.config.js create mode 100644 browser/visibility/lifecycle.spec.js create mode 100644 docs/browser/adr-006-webgl2-rendering.md create mode 100644 docs/browser/storage.md create mode 100644 docs/browser/viewport.md create mode 100644 src/FileImport.cpp create mode 100644 src/FileImport.h diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a531a8baa..e06bfdc29 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -187,9 +187,9 @@ jobs: - 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 + 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 the YOG server run: scons CXX=${{ matrix.compiler }} -j$(nproc) release=1 server=1 --build=build-server @@ -361,9 +361,9 @@ jobs: - 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 + python3 test/run-savegame-safety-tests.py --check-preferences build/windows/client/release/src/TeamStatsSaveHarness.exe . + python3 test/run-savegame-safety-tests.py --check-preferences --expect-stdout test/fixtures/team-stats/version88.expected.txt build/windows/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/windows/client/release/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 @@ -405,7 +405,7 @@ jobs: scons release=1 transport-test -j2 python3 test/run-network-transport-tests.py python3 -m unittest discover -s tests/transport -v - - name: Test browser single-player and multiplayer + - name: Test browser lifecycle, renderers and multiplayer if: matrix.order == 'concurrent' working-directory: browser env: @@ -414,6 +414,8 @@ jobs: npm ci --ignore-scripts npx playwright install --with-deps chromium firefox webkit npm test + GLOB2_TEST_RENDERER=webgl2 npx playwright test viewport.spec.js + xvfb-run -a npx playwright test --config visibility.config.js - name: Test self-hosting with real TLS and persistent volumes if: matrix.order == 'concurrent' run: | diff --git a/.gitignore b/.gitignore index 81a3f384b..851a3d63c 100644 --- a/.gitignore +++ b/.gitignore @@ -33,4 +33,4 @@ Glob2-*.dmg /build-*-support.log /test/build/ /build-*.log -/browser/node_modules/ +/browser/node_modules diff --git a/SConstruct b/SConstruct index 503b630e7..cff7f3fc7 100644 --- a/SConstruct +++ b/SConstruct @@ -290,14 +290,19 @@ def main(): from gateway_build import build_gateway build_gateway(bdir, identity, ARGUMENTS) return - env = Environment() + 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) diff --git a/browser/ApplicationHost.cpp b/browser/ApplicationHost.cpp index df2ad0f4e..7ee289a3f 100644 --- a/browser/ApplicationHost.cpp +++ b/browser/ApplicationHost.cpp @@ -1,6 +1,8 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include +#include #include +#include namespace GAGCore::ApplicationHost { @@ -10,6 +12,36 @@ struct ScheduledLoop { std::unique_ptr loop; std::function complet 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); @@ -35,6 +67,113 @@ void wait(std::uint32_t milliseconds) { emscripten_sleep(milliseconds ? milliseconds : 1); } +bool takeVisibilityChange(bool& hidden) +{ + const int state = EM_ASM_INT({ + if (!Module.visibilityPending) return -1; + Module.visibilityPending = false; + return document.hidden || Module.gpuLost ? 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'] = UTF8ToString($0); }, name); 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/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.json b/browser/package.json index 65845e357..5389ea1ff 100644 --- a/browser/package.json +++ b/browser/package.json @@ -1,6 +1,6 @@ { "name": "glob2-browser-tests", "private": true, - "scripts": {"test": "playwright test"}, + "scripts": {"test": "node --test unit/*.test.js && playwright test"}, "devDependencies": {"@playwright/test": "1.63.0"} } diff --git a/browser/shell.html b/browser/shell.html index a448f96e8..06496c8da 100644 --- a/browser/shell.html +++ b/browser/shell.html @@ -27,26 +27,38 @@ var Module = { noInitialRun: true, canvas, + GL_MAX_TEXTURE_IMAGE_UNITS: 1, browserLog: [], print(text) { Module.browserLog.push(String(text)); console.log(text); }, printErr(text) { Module.browserLog.push(String(text)); console.error(text); }, preRun: [function() { ENV.HOME = '/home/web_user'; FS.mkdirTree('/home/web_user'); + Module.storage = new Glob2Storage(callback => FS.syncfs(false, callback)); + IDBFS.queuePersist = () => Module.storage.changed(); Module.saveMount = FS.mount(IDBFS, {autoPersist:true}, '/home/web_user').mount; addRunDependency('browser-saves'); FS.syncfs(true, err => { Module.storageRestore = err ? 'failed' : 'ready'; + Module.storage.restored(err); if (err) Module.printErr('Save restore failed: ' + err); removeRunDependency('browser-saves'); }); }], onRuntimeInitialized() { - const scale = Math.max(1, 800 / innerWidth, 600 / innerHeight); - const width = Math.round(innerWidth * scale); - const height = Math.round(innerHeight * scale); + const scale = Math.max(1, 800 / Math.max(1, innerWidth), 600 / Math.max(1, innerHeight)); + const width = Math.round(Math.max(1, innerWidth) * scale); + const height = Math.round(Math.max(1, innerHeight) * scale); + Module.pendingViewport = {width: Math.floor(innerWidth), height: Math.floor(innerHeight)}; canvas.focus(); - Module.callMain(['-s', width + 'x' + height, '-F', '-G']); + // Keep the established default until the GPU backend passes performance gates. + const software = new URLSearchParams(location.search).get('renderer') !== 'webgl2'; + const context = software ? null : canvas.getContext('webgl2', { + alpha:false, antialias:false, depth:false, stencil:false + }); + const gpu = Boolean(context); + Module.renderer = gpu ? 'webgl2' : 'software'; + Module.callMain(['-s', width + 'x' + height, '-F', gpu ? '-g' : '-G']); }, onGameExit(code) { Module.print('Game exited with code ' + code + '. Reload the page to restart.'); @@ -54,7 +66,7 @@ onAbort(reason) { Module.printErr('Game stopped: ' + reason); } }; async function localFileDigest(directory, name, extension) { - if (!/^[A-Za-z0-9 _.-]+$/.test(name) || name.includes('..') || !name.endsWith('.' + extension)) + if (!/^[A-Za-z0-9 _().-]+$/.test(name) || name.includes('..') || !name.endsWith('.' + extension)) throw Error('Invalid local file name'); const path = '/home/web_user/.glob2/' + directory + '/' + name; if (!FS.analyzePath(path).exists) return null; @@ -65,11 +77,13 @@ // Read-only observability shared by developer tools and automated assertions. Object.defineProperty(window, 'glob2Diagnostics', {value: Object.freeze({ snapshot() { - return Object.freeze({version:1, screen:Module.glob2Screen || 'loading', + return Object.freeze({version:1, renderer:Module.renderer || 'loading', contextLost:Boolean(Module.gpuLost), contextRestores:Module.gpuRestores || 0, screen:Module.glob2Screen || 'loading', tick:Module.glob2Tick || 0, frames:Module.glob2Frames || 0, paused:Boolean(Module.glob2Paused), width:canvas.width, height:canvas.height, restore:Module.storageRestore || 'restoring', - persisting:Boolean(Module.saveMount?.idbPersistState), + import:Module.importState || 'idle', + persisting:Module.storage?.state === 'writing', + persistence:Module.storage?.state || 'restoring', audio:Module.SDL2?.audioContext?.state || 'inactive'}); }, saves() { @@ -77,12 +91,25 @@ return FS.analyzePath(path).exists ? FS.readdir(path).filter(n => n.endsWith('.game')).sort() : []; }, saveDigest(name) { return localFileDigest('games', name, 'game'); }, - mapDigest(name) { return localFileDigest('maps', name, 'map'); } + mapDigest(name) { return localFileDigest('maps', name, 'map'); }, + replayDigest(name) { return localFileDigest('replays', name, 'replay'); }, + campaignDigest(name) { return localFileDigest('games', name, 'txt'); } })}); function focusGame() { canvas.focus(); Module.SDL2?.audioContext?.resume(); } +addEventListener('resize', () => { + Module.pendingViewport = {width: Math.floor(innerWidth), height: Math.floor(innerHeight)}; +}); +Module.visibilityPending = true; +canvas.addEventListener('webglcontextlost', event => { + event.preventDefault(); + Module.gpuLost = true; + Module.visibilityPending = true; +}); +canvas.addEventListener('webglcontextrestored', () => { Module.gpuRestorePending = true; }); +document.addEventListener('visibilitychange', () => { Module.visibilityPending = true; }); canvas.addEventListener('pointerdown', focusGame); canvas.addEventListener('keydown', () => Module.SDL2?.audioContext?.resume()); 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-progress.spec.js b/browser/tests/campaign-progress.spec.js new file mode 100644 index 000000000..325e6e196 --- /dev/null +++ b/browser/tests/campaign-progress.spec.js @@ -0,0 +1,101 @@ +const {test,expect}=require('@playwright/test'); +const {gameURL}=require('./game-url'); +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 menu(page,480,120);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/editor-storage.spec.js b/browser/tests/editor-storage.spec.js new file mode 100644 index 000000000..2302a9d6c --- /dev/null +++ b/browser/tests/editor-storage.spec.js @@ -0,0 +1,49 @@ +const {test,expect}=require('@playwright/test'); +const {gameURL}=require('./game-url'); +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 click(page,760,570);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/game-url.js b/browser/tests/game-url.js new file mode 100644 index 000000000..3224da5ac --- /dev/null +++ b/browser/tests/game-url.js @@ -0,0 +1,9 @@ +// Apply the same renderer selection a player can request in the address bar. +// This lets the complete existing suite qualify either backend. +function gameURL() { + const renderer = process.env.GLOB2_TEST_RENDERER; + if (!renderer) return '/'; + if (!['software', 'webgl2'].includes(renderer)) throw new Error('Unknown test renderer: ' + renderer); + return '/?renderer=' + renderer; +} +module.exports = {gameURL}; diff --git a/browser/tests/import.spec.js b/browser/tests/import.spec.js new file mode 100644 index 000000000..b7d1ec1fb --- /dev/null +++ b/browser/tests/import.spec.js @@ -0,0 +1,123 @@ +const {test, expect} = require('@playwright/test'); +const {gameURL} = require('./game-url'); +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 menu(page,480,200); await screen(page,'CustomGameScreen'); + await menu(page,100,70); await menu(page,530,380); + 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 menu(page,160,200); 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 menu(page,160,200); 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')); + await menu(page,480,200); await screen(page,'CustomGameScreen'); + 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')}); + const download=page.waitForEvent('download'); await menu(page,155,485); + expect(digest(await fs.readFile(await (await download).path()))).toEqual(digest(bytes)); + await menu(page,530,380); + 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 menu(page,160,200); 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/multiplayer.spec.js b/browser/tests/multiplayer.spec.js index a4f7d8bd6..23210c923 100644 --- a/browser/tests/multiplayer.spec.js +++ b/browser/tests/multiplayer.spec.js @@ -1,4 +1,8 @@ +const {gameURL} = require('./game-url'); 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'); @@ -70,7 +74,7 @@ test('browser YOG login exchanges the native protocol through the real gateway', }; 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('/'); await screen('MainMenuScreen'); + await page.goto(gameURL()); await screen('MainMenuScreen'); await click(440, 490); await screen('YOGLoginScreen'); await click(420, 510); await page.locator('#canvas').press('Home'); @@ -93,7 +97,7 @@ test('registered browser player enters and leaves the native YOG lobby', async ( 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('/'); await screen('MainMenuScreen'); + await page.goto(gameURL()); await screen('MainMenuScreen'); await click(440, 490); await screen('YOGLoginScreen'); await click(420, 510); await page.locator('#canvas').press('Home'); @@ -115,7 +119,7 @@ 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('/'); await screen('MainMenuScreen'); + await page.goto(gameURL()); await screen('MainMenuScreen'); await click(440, 490); await screen('YOGLoginScreen'); for (const [y, value] of [[510, name], [580, 'fixture-only']]) { await click(420, y); await page.locator('#canvas').press('Home'); @@ -161,11 +165,14 @@ function sentChecksums(page) { return checksums; } -const multiplayerAIs = ['no AI', 'Numbi', 'Castor', 'Warrush', 'ReachToInfinity', 'Nicowar', 'Maxima', 'Cortex'] +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 { @@ -197,7 +204,7 @@ test(`two browser players create, join and start a YOG match (${ai.name})`, asyn 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).toBeGreaterThan(100); + await expect.poll(async () => (await target.evaluate(() => glob2Diagnostics.snapshot())).tick, {timeout:60000}).toBeGreaterThan(100); await expect.poll(() => Math.min(hostChecksums.length, guestChecksums.length)).toBeGreaterThanOrEqual(25); const count = Math.min(hostChecksums.length, guestChecksums.length); expect(count).toBeGreaterThanOrEqual(25); diff --git a/browser/tests/pixels.js b/browser/tests/pixels.js new file mode 100644 index 000000000..71df374f9 --- /dev/null +++ b/browser/tests/pixels.js @@ -0,0 +1,16 @@ +// 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}; diff --git a/browser/tests/rendering.spec.js b/browser/tests/rendering.spec.js new file mode 100644 index 000000000..6ce60e981 --- /dev/null +++ b/browser/tests/rendering.spec.js @@ -0,0 +1,98 @@ +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}); + +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 click(page, 760, 410); await screen(page, 'CustomGameScreen'); + await click(page, 380, 280); await click(page, 810, 590); + 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 click(page,440,570); 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 click(page,760,410); await screen(page,'CustomGameScreen'); + await click(page,380,280); await click(page,810,590); + 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 click(page,440,570); await recover('SettingsScreen'); + await click(page,810,650); await screen(page,'MainMenuScreen'); + await click(page,760,570); 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/single-player.spec.js b/browser/tests/single-player.spec.js index eaf6a2760..9fab41a6c 100644 --- a/browser/tests/single-player.spec.js +++ b/browser/tests/single-player.spec.js @@ -1,3 +1,4 @@ +const {gameURL} = require('./game-url'); const {test, expect} = require('@playwright/test'); const state = page => page.evaluate(() => glob2Diagnostics.snapshot()); @@ -6,7 +7,7 @@ const click = (page, x, y) => page.locator('#canvas').click({position:{x,y}, del const menu = (page, x, y) => click(page, x + 280, y + 210); test.beforeEach(async ({page}) => { - await page.goto('/'); + await page.goto(gameURL()); await screen(page, 'MainMenuScreen'); }); @@ -116,6 +117,16 @@ test('custom match pauses, persists and resumes after reload', async ({page}) => 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 menu(page, 160, 200); 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 menu(page, 160, 200); await screen(page, 'ChooseMapScreen'); await menu(page, 100, 70); diff --git a/browser/tests/storage.spec.js b/browser/tests/storage.spec.js new file mode 100644 index 000000000..1e4c14674 --- /dev/null +++ b/browser/tests/storage.spec.js @@ -0,0 +1,100 @@ +const {gameURL} = require('./game-url'); +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 click(page,760,410); await screen(page,'CustomGameScreen'); + await click(page,380,280); await click(page,810,590); + 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 click(page,440,570); await screen(page,'SettingsScreen'); + await click(page,810,650); 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/viewport.spec.js b/browser/tests/viewport.spec.js new file mode 100644 index 000000000..d1306e229 --- /dev/null +++ b/browser/tests/viewport.spec.js @@ -0,0 +1,74 @@ +const {gameURL} = require('./game-url'); +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) { + await page.setViewportSize({width,height}); + await expect.poll(async () => { const s=await snapshot(page); return [s.width,s.height]; }).toEqual([width,height]); + 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 menu(page,160,360); await screen(page,'SettingsScreen'); + await resize(page,900,650); + await menu(page,530,440); await screen(page,'MainMenuScreen'); + await resize(page,1440,900); + await menu(page,160,440); 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 menu(page,480,200); await screen(page,'CustomGameScreen'); + await menu(page,100,70); await menu(page,530,380); + 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); await screen(page,'MinimumViewportScreen'); + const suspended = (await snapshot(page)).tick; + await page.locator('#canvas').press('Escape',{delay:80}); + expect((await snapshot(page)).tick).toBe(suspended); + await resize(page,900,650); + await expect.poll(async () => (await snapshot(page)).tick).toBeGreaterThan(suspended); + 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 menu(page,480,360); 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 show an in-game notice and return to the retained menu', async ({page}, info) => { + await menu(page,160,360); await screen(page,'SettingsScreen'); + await resize(page,500,400); await screen(page,'MinimumViewportScreen'); + await page.screenshot({path:info.outputPath('minimum-viewport.png')}); + await page.locator('#canvas').press('Escape'); await screen(page,'MinimumViewportScreen'); + await resize(page,1100,700); await screen(page,'SettingsScreen'); + await menu(page,530,440); await screen(page,'MainMenuScreen'); +}); + +test.describe('high density display', () => { + test.use({deviceScaleFactor:2}); + test('keeps one rendering pixel per CSS pixel', async ({page}) => { + expect(await page.evaluate(() => devicePixelRatio)).toBe(2); + await resize(page,1100,750); + await menu(page,160,360); await screen(page,'SettingsScreen'); + }); +}); 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..7f3bbd45a --- /dev/null +++ b/browser/visibility/lifecycle.spec.js @@ -0,0 +1,64 @@ +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'); + +// 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}) => { + const profile = await fs.mkdtemp(path.join(os.tmpdir(),'glob2-visibility-')); + const child = spawn(chromium.executablePath(), ['--remote-debugging-port=0', + '--user-data-dir='+profile, '--no-first-run', '--no-default-browser-check', 'about:blank'], {stdio:'ignore'}); + const exited = new Promise(resolve => child.once('exit',resolve)); + let browser; + try { + let port; + await expect.poll(async () => { + 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(baseURL); await screen('MainMenuScreen'); + await click(480,200); 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}); + const frames=(await snapshot()).frames; + await expect.poll(async ()=>(await snapshot()).frames).toBeGreaterThan(frames+2); + await click(320,290); await screen('EndGameScreen'); + } 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/data/texts.ar.txt b/data/texts.ar.txt index abc3385c3..fbaf48a23 100644 --- a/data/texts.ar.txt +++ b/data/texts.ar.txt @@ -1788,3 +1788,45 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... +[browser window too small] +Enlarge the window to at least 800 x 600. +[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 diff --git a/data/texts.br.txt b/data/texts.br.txt index 4b001059b..590825f90 100644 --- a/data/texts.br.txt +++ b/data/texts.br.txt @@ -1786,3 +1786,45 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... +[browser window too small] +Enlarge the window to at least 800 x 600. +[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 diff --git a/data/texts.ca.txt b/data/texts.ca.txt index e922880e6..8e7930031 100644 --- a/data/texts.ca.txt +++ b/data/texts.ca.txt @@ -1798,3 +1798,45 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... +[browser window too small] +Enlarge the window to at least 800 x 600. +[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 diff --git a/data/texts.cz.txt b/data/texts.cz.txt index c82884572..c0ce6171a 100644 --- a/data/texts.cz.txt +++ b/data/texts.cz.txt @@ -1790,3 +1790,45 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... +[browser window too small] +Enlarge the window to at least 800 x 600. +[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 diff --git a/data/texts.de.txt b/data/texts.de.txt index bd6202fd1..e0a10066c 100644 --- a/data/texts.de.txt +++ b/data/texts.de.txt @@ -1790,3 +1790,45 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... +[browser window too small] +Enlarge the window to at least 800 x 600. +[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 diff --git a/data/texts.dk.txt b/data/texts.dk.txt index 33245aa61..afd21c912 100644 --- a/data/texts.dk.txt +++ b/data/texts.dk.txt @@ -1866,3 +1866,45 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... +[browser window too small] +Enlarge the window to at least 800 x 600. +[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 diff --git a/data/texts.en.txt b/data/texts.en.txt index 722cff7c0..ca6a22bff 100644 --- a/data/texts.en.txt +++ b/data/texts.en.txt @@ -1788,3 +1788,45 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... +[browser window too small] +Enlarge the window to at least 800 x 600. +[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 diff --git a/data/texts.eo.txt b/data/texts.eo.txt index 9e3bcf381..99d467611 100644 --- a/data/texts.eo.txt +++ b/data/texts.eo.txt @@ -1788,3 +1788,45 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... +[browser window too small] +Enlarge the window to at least 800 x 600. +[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 diff --git a/data/texts.es.txt b/data/texts.es.txt index b506c14d4..c1b740c6a 100644 --- a/data/texts.es.txt +++ b/data/texts.es.txt @@ -1790,3 +1790,45 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... +[browser window too small] +Enlarge the window to at least 800 x 600. +[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 diff --git a/data/texts.eu.txt b/data/texts.eu.txt index cb2eeead4..c30038a4f 100644 --- a/data/texts.eu.txt +++ b/data/texts.eu.txt @@ -1800,3 +1800,45 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... +[browser window too small] +Enlarge the window to at least 800 x 600. +[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 diff --git a/data/texts.fa.txt b/data/texts.fa.txt index ccbb1e4f7..2724b5228 100644 --- a/data/texts.fa.txt +++ b/data/texts.fa.txt @@ -1788,3 +1788,45 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... +[browser window too small] +Enlarge the window to at least 800 x 600. +[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 diff --git a/data/texts.fi.txt b/data/texts.fi.txt index f6bebd574..c880295a4 100644 --- a/data/texts.fi.txt +++ b/data/texts.fi.txt @@ -1790,3 +1790,45 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... +[browser window too small] +Enlarge the window to at least 800 x 600. +[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 diff --git a/data/texts.fr.txt b/data/texts.fr.txt index 41b5b541c..11174f3cd 100644 --- a/data/texts.fr.txt +++ b/data/texts.fr.txt @@ -1800,3 +1800,45 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... +[browser window too small] +Enlarge the window to at least 800 x 600. +[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 diff --git a/data/texts.gr.txt b/data/texts.gr.txt index 075344185..20081db9a 100644 --- a/data/texts.gr.txt +++ b/data/texts.gr.txt @@ -1860,3 +1860,45 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... +[browser window too small] +Enlarge the window to at least 800 x 600. +[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 diff --git a/data/texts.hu.txt b/data/texts.hu.txt index b5afa4832..1cff99aa3 100644 --- a/data/texts.hu.txt +++ b/data/texts.hu.txt @@ -1790,3 +1790,45 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... +[browser window too small] +Enlarge the window to at least 800 x 600. +[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 diff --git a/data/texts.id.txt b/data/texts.id.txt index 61464d58b..5da7047e3 100644 --- a/data/texts.id.txt +++ b/data/texts.id.txt @@ -1786,3 +1786,45 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... +[browser window too small] +Enlarge the window to at least 800 x 600. +[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 diff --git a/data/texts.it.txt b/data/texts.it.txt index 2e1b6fe62..04bd49d90 100644 --- a/data/texts.it.txt +++ b/data/texts.it.txt @@ -1852,3 +1852,45 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... +[browser window too small] +Enlarge the window to at least 800 x 600. +[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 diff --git a/data/texts.ja.txt b/data/texts.ja.txt index 5421d35d2..596a0a535 100644 --- a/data/texts.ja.txt +++ b/data/texts.ja.txt @@ -1786,3 +1786,45 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... +[browser window too small] +Enlarge the window to at least 800 x 600. +[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 diff --git a/data/texts.keys.txt b/data/texts.keys.txt index 25d6a438e..e81a3b9a4 100644 --- a/data/texts.keys.txt +++ b/data/texts.keys.txt @@ -892,3 +892,24 @@ [Loading units] [Loading buildings] [Resolving team links] +[browser window too small] +[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] diff --git a/data/texts.ko.txt b/data/texts.ko.txt index b0bc6b821..e86a07522 100644 --- a/data/texts.ko.txt +++ b/data/texts.ko.txt @@ -1786,3 +1786,45 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... +[browser window too small] +Enlarge the window to at least 800 x 600. +[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 diff --git a/data/texts.nl.txt b/data/texts.nl.txt index 24905bb85..9a6d278af 100644 --- a/data/texts.nl.txt +++ b/data/texts.nl.txt @@ -1814,3 +1814,45 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... +[browser window too small] +Enlarge the window to at least 800 x 600. +[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 diff --git a/data/texts.pl.txt b/data/texts.pl.txt index 67ade7965..6d05e59c6 100644 --- a/data/texts.pl.txt +++ b/data/texts.pl.txt @@ -1790,3 +1790,45 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... +[browser window too small] +Enlarge the window to at least 800 x 600. +[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 diff --git a/data/texts.pt.txt b/data/texts.pt.txt index 3eb17fa7d..6307e565f 100644 --- a/data/texts.pt.txt +++ b/data/texts.pt.txt @@ -1796,3 +1796,45 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... +[browser window too small] +Enlarge the window to at least 800 x 600. +[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 diff --git a/data/texts.ro.txt b/data/texts.ro.txt index 69b909d57..4c63cef18 100644 --- a/data/texts.ro.txt +++ b/data/texts.ro.txt @@ -1790,3 +1790,45 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... +[browser window too small] +Enlarge the window to at least 800 x 600. +[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 diff --git a/data/texts.ru.txt b/data/texts.ru.txt index 2b4222801..65dc4856f 100644 --- a/data/texts.ru.txt +++ b/data/texts.ru.txt @@ -1788,3 +1788,45 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... +[browser window too small] +Enlarge the window to at least 800 x 600. +[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 diff --git a/data/texts.si.txt b/data/texts.si.txt index 4ccaa29c4..2ca624637 100644 --- a/data/texts.si.txt +++ b/data/texts.si.txt @@ -1790,3 +1790,45 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... +[browser window too small] +Enlarge the window to at least 800 x 600. +[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 diff --git a/data/texts.sk.txt b/data/texts.sk.txt index ecac525a1..abe2aea1c 100644 --- a/data/texts.sk.txt +++ b/data/texts.sk.txt @@ -1798,3 +1798,45 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... +[browser window too small] +Enlarge the window to at least 800 x 600. +[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 diff --git a/data/texts.sr.txt b/data/texts.sr.txt index f38b89513..536663c69 100644 --- a/data/texts.sr.txt +++ b/data/texts.sr.txt @@ -1790,3 +1790,45 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... +[browser window too small] +Enlarge the window to at least 800 x 600. +[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 diff --git a/data/texts.sv.txt b/data/texts.sv.txt index b863352ea..b788e7165 100644 --- a/data/texts.sv.txt +++ b/data/texts.sv.txt @@ -1800,3 +1800,45 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... +[browser window too small] +Enlarge the window to at least 800 x 600. +[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 diff --git a/data/texts.tr.txt b/data/texts.tr.txt index 99d4db009..afbd0ac8c 100644 --- a/data/texts.tr.txt +++ b/data/texts.tr.txt @@ -1798,3 +1798,45 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... +[browser window too small] +Enlarge the window to at least 800 x 600. +[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 diff --git a/data/texts.uk.txt b/data/texts.uk.txt index d5a812485..ba9a5ea8d 100644 --- a/data/texts.uk.txt +++ b/data/texts.uk.txt @@ -1786,3 +1786,45 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... +[browser window too small] +Enlarge the window to at least 800 x 600. +[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 diff --git a/data/texts.vi.txt b/data/texts.vi.txt index a7a27dd8a..023c1253e 100644 --- a/data/texts.vi.txt +++ b/data/texts.vi.txt @@ -1786,3 +1786,45 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... +[browser window too small] +Enlarge the window to at least 800 x 600. +[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 diff --git a/data/texts.zh-cn.txt b/data/texts.zh-cn.txt index 1eb2a7e7f..d69086cd6 100644 --- a/data/texts.zh-cn.txt +++ b/data/texts.zh-cn.txt @@ -1786,3 +1786,45 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... +[browser window too small] +Enlarge the window to at least 800 x 600. +[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 diff --git a/data/texts.zh-tw.txt b/data/texts.zh-tw.txt index 9dd89736e..9b63dd3ca 100644 --- a/data/texts.zh-tw.txt +++ b/data/texts.zh-tw.txt @@ -1862,3 +1862,45 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... +[browser window too small] +Enlarge the window to at least 800 x 600. +[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 diff --git a/docs/browser/adr-004-cooperative-loading.md b/docs/browser/adr-004-cooperative-loading.md index c95270e64..b4bc9802c 100644 --- a/docs/browser/adr-004-cooperative-loading.md +++ b/docs/browser/adr-004-cooperative-loading.md @@ -86,16 +86,11 @@ separate work because engine replay/session globals require different ownership. ## Gradients during loading -Resource, forbidden-area, guard-area, and clear-area seeding now expose tasks, -with checkpoints every 16,384 cells. Global chamfer propagation yields every -16 rows of each forward/backward sweep. Map loading awaits these jobs before -publishing its game. Their algorithm and traversal order are unchanged. - -The task APIs borrow a map and its gradient buffers. Neither simulation nor -another gradient operation may observe/mutate that map while a task is suspended. -Running games continue using synchronous adapters, which drain the identical -implementation before returning. Cancellation discards the private loading map; -there is no partially completed gradient in an active simulation. +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 @@ -111,19 +106,14 @@ because simulation callers share the synchronous implementation. ## Team setup during generation -`Game::addTeamTask` awaits `Map::addTeamTask`, which allocates and builds resource -and area gradients using the loading tasks. Generation callers await this chain; -existing editor/runtime callers still drain it synchronously through `addTeam`. -Team masks, colors, header count, prestige limits, and script initialization retain -their original order. - -The asynchronous API is for a privately owned preparation game. A cancelled team -addition leaves a partial game to discard; it is not a transaction for adding a -team to a live match. The editor generation screen owns that discard and RNG -rollback. Tests destroy jobs after the header/Team exist and at several subsequent -gradient checkpoints, exercising cleanup with both allocated and missing arrays. -Race loading, object construction, and initial area-array filling still contain -synchronous work and remain part of loading latency qualification. +`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 diff --git a/docs/browser/adr-006-webgl2-rendering.md b/docs/browser/adr-006-webgl2-rendering.md new file mode 100644 index 000000000..27c853a03 --- /dev/null +++ b/docs/browser/adr-006-webgl2-rendering.md @@ -0,0 +1,93 @@ +# ADR 006: Reuse the 2D GPU renderer for the first WebGL2 backend + +Status: in progress; browser support remains experimental. + +Glob2 already has GPU implementations of its 2D drawing operations. The browser +build now compiles those implementations with the pinned Emscripten SDK's legacy +OpenGL compatibility layer, targeting WebGL2 exclusively. Sprites, fonts, +terrain, overlays and primitives are drawn by the GPU. This does not upload a +software-rendered screen once per frame. + +This is a deliberate delivery compromise: preserve the shared drawing interface +and desktop behavior while establishing working GPU rendering and regression +coverage. It is not a bespoke modern shader renderer. The compatibility layer +adds overhead and uses SDK internals during context restoration. SDK upgrades +must run the rendering and recovery suite. A later direct GLES3 implementation +can replace that layer behind the same interface if benchmarks or browser +compatibility require it. + +## Ownership and lifecycle + +The browser host selects WebGL2 when requested with `?renderer=webgl2` and +available. Software remains the default, and `?renderer=software` explicitly +selects it. Lack of WebGL2 falls back to software. +The page retains only the game canvas. Browser builds use GLES-compatible headers +and flags; native builds retain their existing OpenGL dependencies. + +The shared renderer retains CPU surfaces, including sprite atlases, as the +source for texture restoration. Texture names start at zero and cannot be used +before allocation. Atlas coordinates use the atlas's actual normalization, +including the power-of-two texture path used by the browser. + +Viewport changes use the existing frame-boundary event. Updating the drawable, +projection, clipping and screen layouts leaves the simulation and camera intact. +One CSS pixel remains one drawing-buffer pixel. + +On context loss the browser host suspends application execution through the same +lifecycle transition used for hidden tabs. On restoration it recreates the +compatibility shaders and streaming buffers, then asks the renderer to rebuild +textures and projection from retained CPU state. The screen stack resets its +timing baseline before execution resumes. Game state is not serialized or +reloaded. Context loss does not provide coordinated multiplayer suspension yet. + +## Validation and remaining release gates + +`browser/tests/rendering.spec.js` exercises real custom-game controls, checks the +actual WebGL2 context and drawing-buffer dimensions, checks software selection, +and loses/restores the real context repeatedly during a match. Multiplayer +correctness fixtures allow extra time for two clients sharing a headless software +GPU, and use explicit screenshots instead of continuous trace readback. These +timeouts do not establish the controlled performance gate. Viewport tests +inspect presented screenshots, because WebGL may clear its drawing buffer after +presentation. + +Complete cross-browser single-player and viewport coverage, visual review, +native regression checks and controlled performance baselines are required. +Context loss during settings, the editor and its confirmation dialog is also +covered across all three browser engines, with retained controls verified after +restoration. Context loss during other legacy blocking dialogs/loading, unrecoverable GPU failure +UI, and fallback after an unexpected context-creation failure still need release +qualification. This milestone does not make the full platform stable. + + +## Performance gate remains open + +A six-second local comparison on Apple M3 with Chromium's Metal backend reached +approximately 25 simulation ticks/second for both WebGL2 and software when rerun +without this task's other browser tests. An earlier comparison during concurrent +test activity measured about 18 for WebGL2 and 25 for software. These are sanity +checks, not the controlled release benchmark matrix; they demonstrate why the +reference environment must be controlled. The latest readings are recorded in +`browser/benchmarks/apple-m3-sanity.json`. + +An earlier WebGL single-player run exceeded deadlines in editor-load and +startup-cancellation scenarios under headless Chromium. A fresh run on the +current build passes all 22 Chromium single-player, viewport and rendering +scenarios, including those two cases, without changing their deadlines. The +complete cross-browser GPU suite and controlled performance fixtures remain +release gates; software remains the default. + +Run the complete existing suite against WebGL using +`GLOB2_TEST_RENDERER=webgl2 npx playwright test` from `browser/`. The dedicated +rendering scenarios always exercise WebGL2, even in the default suite. Renderer +optimization and a passing complete WebGL suite are required before changing the +default. The maintained interfaces and resource recovery added here remain useful +if the SDK compatibility layer needs replacement. + +The comparison can be repeated against a locally served build with +`GLOB2_TEST_URL=http://127.0.0.1:8770 GLOB2_ANGLE=metal node benchmarks/rendering.cjs webgl2` +and then `software`, from `browser/`. Omit `GLOB2_ANGLE` to use Chromium's default +hardware backend. This opens a dedicated browser, selects the first custom map +through the menu and samples six seconds after warmup. Record the reported GPU +and eliminate competing workloads for meaningful comparisons; it does not yet +supply fixed-seed small/typical/late-game release fixtures. diff --git a/docs/browser/gateway.md b/docs/browser/gateway.md index f713dbce4..4951ed32b 100644 --- a/docs/browser/gateway.md +++ b/docs/browser/gateway.md @@ -107,8 +107,8 @@ at the negotiated command cadence. 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 Maxima; -`GLOB2_ALL_AIS=1` covers all seven shipped AIs and is enabled nightly. Native +multiplayer suite. The default browser/browser cases use no AI and Cortex; +`GLOB2_ALL_AIS=1` covers all six shipped AIs and is enabled nightly. 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. diff --git a/docs/browser/storage.md b/docs/browser/storage.md new file mode 100644 index 000000000..f0a7a3edd --- /dev/null +++ b/docs/browser/storage.md @@ -0,0 +1,119 @@ +# 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. diff --git a/docs/browser/viewport.md b/docs/browser/viewport.md new file mode 100644 index 000000000..3c358797b --- /dev/null +++ b/docs/browser/viewport.md @@ -0,0 +1,59 @@ +# 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 so time spent +behind the minimum-size notice does not become simulation catch-up work. + +Rendering uses one pixel per CSS pixel, including displays with a device scale +factor of two. Below 800 by 600, an in-game notice covers the retained screen; +restoring a usable size resumes that screen. 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, the minimum-size +notice, editor discard dialogs, and high-density displays. Screenshots capture the +resized game menu and the notice. 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. + +## Remaining release work + +This implementation covers scheduled application screens with software and +WebGL2 rendering. Legacy blocking multiplayer flows defer application resize handling; +those flows must migrate to the screen stack. WebGL2 context restoration is +covered by the rendering suite; its ownership and remaining qualification are +recorded in [ADR 006](adr-006-webgl2-rendering.md). Camera coordinates retain the existing +whole-tile precision. Broader selection/dragging and nested-dialog coverage is +still needed, along with browser video-preference policy, hidden-tab lifecycle, +and coordinated multiplayer suspension. Very narrow notice layouts and allocation +failure messages need further work. These limitations keep the platform +experimental; this document does not qualify the full release requirements. + +## 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. +Firefox/WebKit real-window qualification and coordinated multiplayer suspension +remain required. diff --git a/libgag/include/ApplicationHost.h b/libgag/include/ApplicationHost.h index 45e41df8c..f0633eebb 100644 --- a/libgag/include/ApplicationHost.h +++ b/libgag/include/ApplicationHost.h @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later #pragma once #include +#include #include #include #include @@ -23,8 +24,39 @@ void run(std::unique_ptr loop, std::function complete); // through Asyncify until these loops become resumable application screens. 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); void exited(int result); diff --git a/libgag/include/GUIBase.h b/libgag/include/GUIBase.h index cbdb3beb9..e63347449 100644 --- a/libgag/include/GUIBase.h +++ b/libgag/include/GUIBase.h @@ -329,6 +329,8 @@ namespace GAGGUI //! 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; } @@ -379,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/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 index f4f9877a5..762b5caf8 100644 --- a/libgag/include/ScreenStack.h +++ b/libgag/include/ScreenStack.h @@ -23,6 +23,8 @@ class ScreenStack // 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; diff --git a/libgag/src/ApplicationHost.cpp b/libgag/src/ApplicationHost.cpp index 05c01dbc6..9ce4c92f5 100644 --- a/libgag/src/ApplicationHost.cpp +++ b/libgag/src/ApplicationHost.cpp @@ -21,6 +21,20 @@ 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) {} 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..82cb388c8 100644 --- a/libgag/src/DrawableSurface.cpp +++ b/libgag/src/DrawableSurface.cpp @@ -9,9 +9,15 @@ #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; @@ -94,6 +100,9 @@ namespace GAGCore { glGenTextures(1, reinterpret_cast(&texture)); glState.allocatedTextureCount++; +#ifdef GLOB2_WEBGL2 + gpuSurfaces.insert(this); +#endif initTextureSize(); } #endif @@ -101,6 +110,7 @@ namespace GAGCore void DrawableSurface::initTextureSize(void) { + if (!texture || textureInfo) return; #ifdef HAVE_OPENGL if (_gc->optionFlags & GraphicContext::USEGPU) { @@ -116,7 +126,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 +153,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 +358,36 @@ namespace GAGCore dirty = true; } } + +#ifdef GLOB2_WEBGL2 +namespace GAGCore { +void GraphicContext::restoreBrowserContext() +{ + if (!_gc || !(_gc->optionFlags & USEGPU)) return; + 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..5889c1b73 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 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/GraphicContext.cpp b/libgag/src/GraphicContext.cpp index 7cea852bb..4538a6ba9 100644 --- a/libgag/src/GraphicContext.cpp +++ b/libgag/src/GraphicContext.cpp @@ -414,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 @@ -443,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 @@ -582,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 +#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/ScreenStack.cpp b/libgag/src/ScreenStack.cpp index 1889ac515..c9b26d316 100644 --- a/libgag/src/ScreenStack.cpp +++ b/libgag/src/ScreenStack.cpp @@ -21,6 +21,18 @@ void ScreenStack::push(std::unique_ptr screen, Completion completed) 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; 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/scons/sources.py b/scons/sources.py index ca081ba0a..2298e17ba 100644 --- a/scons/sources.py +++ b/scons/sources.py @@ -107,6 +107,7 @@ 'Engine.cpp', 'GameSessionScreen.cpp', 'GameLoadScreen.cpp', + 'FileImport.cpp', 'SinglePlayerFlow.cpp', 'EngineInit.cpp', 'EngineLoaders.cpp', diff --git a/scons/web_build.py b/scons/web_build.py index 9c89f5e41..efcf150d3 100644 --- a/scons/web_build.py +++ b/scons/web_build.py @@ -36,6 +36,8 @@ def build_web(directory, identity, arguments): 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" @@ -47,11 +49,12 @@ def build_web(directory, identity, arguments): 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', '-sASYNCIFY', '-sASYNCIFY_STACK_SIZE=1048576', '-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'] + PORTS) + '--shell-file', 'browser/shell.html', '--pre-js', 'browser/storage.js', '--pre-js', 'browser/file-selection.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")}' @@ -69,7 +72,7 @@ def prepare_ports(target, source, env): env.Requires(objects, ports) env.Depends(objects, str(config)) program = env.Program(str(output / 'index.html'), objects) - env.Depends(program, ['browser/shell.html', 'browser/toolchain.json']) + env.Depends(program, ['browser/shell.html', 'browser/storage.js', 'browser/file-selection.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) diff --git a/src/Application.cpp b/src/Application.cpp index 95d4dd233..1c86a37c0 100644 --- a/src/Application.cpp +++ b/src/Application.cpp @@ -1,7 +1,10 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include "Application.h" +#include +#include #include "GlobalContainer.h" #include "MainMenuScreen.h" +#include "MessageScreen.h" #include "CampaignMainMenu.h" #include "CampaignMenuScreen.h" #include "SettingsScreen.h" @@ -11,9 +14,29 @@ #include "YOGLoginScreen.h" #include "YOGClient.h" +namespace { +class MinimumViewportScreen : public GAGGUI::Screen +{ +public: + void onAction(GAGGUI::Widget*, GAGGUI::Action, int, int) override {} + void paint() override { + GAGGUI::Screen::paint(); + auto* font = GAGCore::Toolkit::getFont("standard"); + const auto text = GAGCore::Toolkit::getStringTable()->getString("[browser window too small]"); + getSurface()->drawString(std::max(8, (getW() - font->getStringWidth(text)) / 2), + std::max(8, getH()/2 - 10), font, text); + } +}; +} + Application::Application() : screens(*globalContainer->gfx), singlePlayer(screens) { - if (globalContainer->replaying) singlePlayer.replay(globalContainer->replayFileName); + 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(); } @@ -51,6 +74,22 @@ void Application::choose(int choice) 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); + if ((width < 800 || height < 600) && !minimumNotice) { + auto notice = std::make_unique(); + minimumNotice = notice.get(); + screens.push(std::move(notice), [this](GAGGUI::Screen&, int) { minimumNotice = nullptr; }); + } else if (width >= 800 && height >= 600 && minimumNotice) { + minimumNotice->endExecute(0); + } + } + } screens.frame(tick, events); if (!screens.running()) { if (screens.result() == GAGGUI::Screen::QUIT_APPLICATION) return false; @@ -61,6 +100,7 @@ bool Application::frame(std::uint32_t tick, const std::vector& events std::uint32_t Application::delay(std::uint32_t now) { + if (hidden) return 100; const auto elapsed = static_cast(now - lastFrame); return screens.delay(now, elapsed < 40 ? 40 - elapsed : 0); } diff --git a/src/Application.h b/src/Application.h index a50c5afbe..66bbe2e7a 100644 --- a/src/Application.h +++ b/src/Application.h @@ -15,6 +15,8 @@ class Application : public GAGCore::ApplicationHost::Loop GAGGUI::ScreenStack screens; SinglePlayerFlow singlePlayer; std::uint32_t lastFrame = 0; + GAGGUI::Screen* minimumNotice = nullptr; // Owned by screens. + bool hidden = 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/CampaignMenuScreen.cpp b/src/CampaignMenuScreen.cpp index e38106741..7c23a92f6 100644 --- a/src/CampaignMenuScreen.cpp +++ b/src/CampaignMenuScreen.cpp @@ -11,6 +11,8 @@ #include "GlobalContainer.h" #include "GUIMapPreview.h" #include "GUIMessageBox.h" +#include +#include CampaignMenuScreen::CampaignMenuScreen(const std::string& name, GAGGUI::ScreenStack& screens) : screens(screens) { @@ -34,6 +36,17 @@ CampaignMenuScreen::CampaignMenuScreen(const std::string& name, GAGGUI::ScreenSt 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) @@ -41,24 +54,49 @@ void CampaignMenuScreen::onAction(Widget *source, Action action, int par1, int p if (action == SCREEN_DESTROYED) { // Also persist progress when application quit unwinds the stack and // suppresses normal continuation callbacks. - campaign.save(true); + 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) { + // 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); @@ -67,11 +105,8 @@ void CampaignMenuScreen::onAction(Widget *source, Action action, int par1, int p screens.push(std::make_unique(screens, static_cast(loading).takeEngine()), [this](Screen&, int) { repopulateAvailableMissions(); - if (!campaign.save(true)) { - auto& strings = *Toolkit::getStringTable(); - screens.push(std::make_unique(strings.getString("[ERROR_CANT_SAVE_CAMPAIGN]"), - std::vector{strings.getString("[ok]")})); - } + dirty = true; + saveProgress(); }); } else if (result == 2) { auto& strings = *Toolkit::getStringTable(); @@ -87,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) @@ -128,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 88865a523..3578c858b 100644 --- a/src/CampaignMenuScreen.h +++ b/src/CampaignMenuScreen.h @@ -4,6 +4,7 @@ #pragma once #include "Campaign.h" +#include #include #include "Glob2Screen.h" #include "GUIButton.h" @@ -21,6 +22,7 @@ class CampaignMenuScreen : public Glob2Screen 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, @@ -28,12 +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; + /// The exit to menuscreen button + TextButton* exitButton; /// The "start mission" button Button* startMission; diff --git a/src/ChooseMapScreen.cpp b/src/ChooseMapScreen.cpp index b66469843..c05b030da 100644 --- a/src/ChooseMapScreen.cpp +++ b/src/ChooseMapScreen.cpp @@ -2,6 +2,8 @@ // 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" @@ -49,6 +51,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 +88,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,8 +106,35 @@ 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(); @@ -150,7 +194,12 @@ void ChooseMapScreen::onAction(Widget *source, Action action, int par1, int par2 } 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 +233,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/Engine.h b/src/Engine.h index 51cea0ef3..bbb083d59 100644 --- a/src/Engine.h +++ b/src/Engine.h @@ -61,6 +61,8 @@ class Engine 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); } diff --git a/src/FileImport.cpp b/src/FileImport.cpp new file mode 100644 index 000000000..27259d0c7 --- /dev/null +++ b/src/FileImport.cpp @@ -0,0 +1,127 @@ +// 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(); + } + } + // 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/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/GameSessionScreen.cpp b/src/GameSessionScreen.cpp index 190741e90..68d9d7137 100644 --- a/src/GameSessionScreen.cpp +++ b/src/GameSessionScreen.cpp @@ -20,6 +20,7 @@ void GameSessionScreen::updateExecution(Uint32 tick) nextTick = clock; started = true; } else { + if (resetClock) { lastTick = tick; nextTick = clock; resetClock = false; } clock += static_cast(tick - lastTick); lastTick = tick; } @@ -58,3 +59,17 @@ 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) +{ + engine->viewportResized(oldWidth, oldHeight, width, height); + input.clear(); + resetClock = true; +} + +void GameSessionScreen::suspendExecution() +{ + engine->suspendInput(); + input.clear(); + resetClock = true; +} diff --git a/src/GameSessionScreen.h b/src/GameSessionScreen.h index 5f7529f41..575f50e3c 100644 --- a/src/GameSessionScreen.h +++ b/src/GameSessionScreen.h @@ -15,6 +15,8 @@ class GameSessionScreen : public GAGGUI::Screen ~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; @@ -22,7 +24,7 @@ class GameSessionScreen : public GAGGUI::Screen GAGGUI::ScreenStack& stack; std::unique_ptr engine; std::vector input; - bool started = false, finished = false; + bool started = false, finished = false, resetClock = false; Uint32 lastTick = 0; Uint64 clock = 0, nextTick = 0; }; diff --git a/src/Game_io.cpp b/src/Game_io.cpp index dceef9f60..4a459be0b 100644 --- a/src/Game_io.cpp +++ b/src/Game_io.cpp @@ -227,7 +227,8 @@ GAGCore::CooperativeTask Game::loadTask(GAGCore::InputStream *stream) { stream->readEnterSection(i); co_await GAGCore::CooperativeTask::checkpoint("[Loading players]"); - players[i]=new Player(stream, teams, versionMinor); + players[i]=new Player(); + if (!players[i]->load(stream, teams, versionMinor)) co_return false; stream->readLeaveSection(); } stream->readLeaveSection(); diff --git a/src/MapEditorScreen.cpp b/src/MapEditorScreen.cpp index eed511ff9..56aba1c61 100644 --- a/src/MapEditorScreen.cpp +++ b/src/MapEditorScreen.cpp @@ -67,3 +67,16 @@ 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 index 37199f5f3..187e20960 100644 --- a/src/MapEditorScreen.h +++ b/src/MapEditorScreen.h @@ -11,6 +11,8 @@ class MapEditorScreen : public GAGGUI::Screen ~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; 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/gui/GameGUI.cpp b/src/gui/GameGUI.cpp index 482eb6e72..1f797c610 100644 --- a/src/gui/GameGUI.cpp +++ b/src/gui/GameGUI.cpp @@ -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(); } diff --git a/src/gui/GameGUI.h b/src/gui/GameGUI.h index 0856dadff..02359896b 100644 --- a/src/gui/GameGUI.h +++ b/src/gui/GameGUI.h @@ -67,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(); @@ -86,6 +86,8 @@ class GameGUI 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; } @@ -242,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); 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/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/GameGUIPersistence.cpp b/src/gui/GameGUIPersistence.cpp index b61227019..d5fec4975 100644 --- a/src/gui/GameGUIPersistence.cpp +++ b/src/gui/GameGUIPersistence.cpp @@ -168,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; + moveParticles(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 4701e8dae..b34c40bf0 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 @@ -121,6 +122,11 @@ void GameGUI::step(void) 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; 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; tgetFileName(); pendingSaveName = loadSaveScreen->getName(); fertilityRequested = true; - performAction("close save screen"); + loadSaveScreen->endValue = -1; } break; case LoadSaveScreen::CANCEL: diff --git a/src/map/edit/MapEditIO.cpp b/src/map/edit/MapEditIO.cpp index b03889e6e..4312c1cee 100644 --- a/src/map/edit/MapEditIO.cpp +++ b/src/map/edit/MapEditIO.cpp @@ -50,27 +50,15 @@ bool MapEdit::save(const std::string filename, const std::string name) assert(filename.size()); assert(name.size()); - 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. - hasMapBeenModified = false; - game.mapHeader.setMapName(name); - game.mapHeader.setIsSavedGame(false); - return true; - } + if (!Toolkit::getFileManager()->writeAtomically(filename, [&](OutputStream& stream) { + game.save(&stream, true, name); + })) return false; + + // Only publish the new editor name after the complete file was replaced. + hasMapBeenModified = false; + game.mapHeader.setMapName(name); + game.mapHeader.setIsSavedGame(false); + return true; } @@ -95,6 +83,10 @@ void MapEdit::beginEditing() 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); @@ -212,14 +204,41 @@ void MapEdit::resolveQuitDecision(int choice) bool MapEdit::finishFertility(bool completed) { fertilityRequested = false; - bool saved = true; if (!pendingSaveFilename.empty()) { - if (completed) saved = save(pendingSaveFilename, pendingSaveName); - if (!completed || !saved) doQuitAfterLoadSave = false; + 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 saved; + 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/io/MapHeader.cpp b/src/map/io/MapHeader.cpp index fe2dfd50b..d942b9b5b 100644 --- a/src/map/io/MapHeader.cpp +++ b/src/map/io/MapHeader.cpp @@ -6,6 +6,7 @@ #include "Game.h" #include #include "FileManager.h" +#include MapHeader::MapHeader() { @@ -28,10 +29,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 +55,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/render/Minimap.cpp b/src/render/Minimap.cpp index f940fedca..622dd9954 100644 --- a/src/render/Minimap.cpp +++ b/src/render/Minimap.cpp @@ -51,6 +51,12 @@ void Minimap::setGame(Game& ngame) +void Minimap::resizeViewport(int width) +{ + gameWidth = width; + if (!noX && game) computeMinimapPositioning(); +} + void Minimap::draw(int localteam, int viewportX, int viewportY, int viewportW, int viewportH) { if (noX) return; 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/test/EngineSessionHarness.cpp b/test/EngineSessionHarness.cpp index 5a024794d..f23db0b0d 100644 --- a/test/EngineSessionHarness.cpp +++ b/test/EngineSessionHarness.cpp @@ -3,6 +3,8 @@ #include "Unit.h" #include "Building.h" #include "GameSessionScreen.h" +#include "GameGUILoadSave.h" +#include "GameUtilities.h" #include "MapEdit.h" #include "FertilityCalculator.h" #include "FertilityScreen.h" @@ -20,6 +22,7 @@ #include "GlobalContainer.h" #include #include +#include #include GlobalContainer* globalContainer = nullptr; @@ -96,6 +99,63 @@ int main(int argc, char** argv) globalContainer->settings.gameSpeed = 0; globalContainer->load(); require(SDLNet_Init() == 0, "SDL networking init failed"); + { + 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(); + 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"); + } + + { + 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); @@ -139,35 +199,18 @@ int main(int argc, char** argv) auto task = map.updateGlobalGradientTask(scheduled.data()); unsigned slices = 0; while (!task.advance()) require(++slices < 1000, "Gradient did not converge"); - require(task.result() && slices >= 8 && scheduled == expected, + 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()); - require(!partial.advance() && !partial.advance(), "Gradient cancellation must precede completion"); + 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"); } } - { - // Adding a team to a private preparation game must be cancellable - // after its header/Team exist but before all map arrays are allocated. - for (unsigned extraSteps : {0u, 5u, 20u}) { - Game partial(nullptr); - MapGenerator generator; - MapGenerationDescriptor descriptor; - require(generator.generateMap(partial, descriptor, 12345), "Team fixture generation failed"); - auto task = partial.addTeamTask(); - while (std::string(task.stage()) != "[Building gradients]") - require(!task.advance(), "Team task must yield during gradient construction"); - require(partial.mapHeader.getNumberOfTeams() == 2, "Team header must precede map preparation"); - for (unsigned step = 0; step < extraSteps; ++step) - require(!task.advance(), "Team cancellation fixture finished too early"); - // task is destroyed before partial, releasing its nested frame. - } - } { MapGenerator generator; for (auto method : {MapGenerationDescriptor::eUNIFORM, MapGenerationDescriptor::eSWAMP, @@ -309,12 +352,18 @@ int main(int argc, char** argv) loadingFrames = frames; screens.push(std::make_unique(screens, static_cast(screen).takeEngine())); }); + bool suspended = false; while (screens.running()) { - screens.frame(1000 + frames * 40, {}); + 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"); } - require(loadingFrames > 20 && frames == loadingFrames + 51 && screens.result() == GAGGUI::Screen::QUIT_APPLICATION, - "Loading must yield before transferring the engine to the 50-tick session"); + // 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(); @@ -343,6 +392,16 @@ int main(int argc, char** argv) { 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}) { @@ -391,13 +450,6 @@ int main(int argc, char** argv) setSyncRandState(originalRng); } } - { - MapEdit partial; - auto task = partial.loadTask("maps/balanced.map"); - while (std::string(task.stage()) != "[Building gradients]") - require(!task.advance(), "Fixture must reach gradient allocation checkpoints"); - // Destruction at a partially built gradient array used to assert/leak. - } setSyncRandState(originalRng); for (unsigned frames : {1u, 4u, 20u}) { GAGGUI::ScreenStack screens(*globalContainer->gfx); diff --git a/test/SavegameSafetyHarness.cpp b/test/SavegameSafetyHarness.cpp index f22eb9fd6..a40d2911a 100644 --- a/test/SavegameSafetyHarness.cpp +++ b/test/SavegameSafetyHarness.cpp @@ -11,6 +11,9 @@ #include "Utilities.h" #include "Order.h" #include "Player.h" +#include "Version.h" +#include "FileImport.h" +#include "Campaign.h" #include #include #include @@ -286,6 +289,198 @@ 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; } int main(int argc, char **argv) @@ -305,6 +500,18 @@ 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); + 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 +520,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 +558,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); From b5da8cec87fff110d7047e2e5ccc6635f48f9e42 Mon Sep 17 00:00:00 2001 From: Bradley Arsenault Date: Tue, 8 Sep 2026 15:39:26 -0400 Subject: [PATCH 05/20] Stabilize and qualify the browser release Finish settings, audio, navigation, replay-save, and multiplayer edge cases; remove Asyncify; expand native and cross-browser regression coverage; refresh reviewer documentation; and omit machine-specific results, screenshots, and handoff logs from the PR. --- .github/workflows/build.yml | 118 ++++--- SConstruct | 16 +- browser/ApplicationHost.cpp | 19 +- browser/README.md | 111 +++--- browser/audio.js | 25 ++ browser/package-static.py | 24 ++ browser/playwright.config.js | 5 +- browser/shell.html | 91 ++++- browser/tests/campaign-editor-storage.spec.js | 60 ++++ browser/tests/campaign-progress.spec.js | 4 +- browser/tests/chooser-errors.spec.js | 27 ++ browser/tests/editor-storage.spec.js | 4 +- browser/tests/game-url.js | 9 - browser/tests/import.spec.js | 12 +- browser/tests/input.spec.js | 50 +++ browser/tests/main-menu.js | 51 +++ browser/tests/multiplayer.spec.js | 223 +++++++++++- browser/tests/pixels.js | 18 + browser/tests/rendering.spec.js | 11 +- browser/tests/replay-save.spec.js | 66 ++++ browser/tests/runtime-build.spec.js | 30 ++ browser/tests/session-reload.spec.js | 81 +++++ browser/tests/settings-storage.spec.js | 89 +++++ browser/tests/shutdown-storage.spec.js | 63 ++++ browser/tests/single-player.spec.js | 89 +++-- browser/tests/storage.spec.js | 6 +- browser/tests/team-colors.spec.js | 47 +++ browser/tests/viewport.spec.js | 48 +-- browser/unit/audio.test.js | 47 +++ browser/visibility/lifecycle.spec.js | 48 ++- darwin/README.md | 7 + data/texts.ar.txt | 16 +- data/texts.br.txt | 18 +- data/texts.ca.txt | 16 +- data/texts.cz.txt | 16 +- data/texts.de.txt | 16 +- data/texts.dk.txt | 16 +- data/texts.en.txt | 16 +- data/texts.eo.txt | 16 +- data/texts.es.txt | 16 +- data/texts.eu.txt | 16 +- data/texts.fa.txt | 16 +- data/texts.fi.txt | 16 +- data/texts.fr.txt | 16 +- data/texts.gr.txt | 16 +- data/texts.hu.txt | 16 +- data/texts.id.txt | 16 +- data/texts.it.txt | 16 +- data/texts.ja.txt | 16 +- data/texts.keys.txt | 8 +- data/texts.ko.txt | 16 +- data/texts.nl.txt | 16 +- data/texts.pl.txt | 16 +- data/texts.pt.txt | 16 +- data/texts.ro.txt | 16 +- data/texts.ru.txt | 16 +- data/texts.si.txt | 16 +- data/texts.sk.txt | 16 +- data/texts.sr.txt | 16 +- data/texts.sv.txt | 16 +- data/texts.tr.txt | 16 +- data/texts.uk.txt | 16 +- data/texts.vi.txt | 16 +- data/texts.zh-cn.txt | 16 +- data/texts.zh-tw.txt | 16 +- debian/control | 2 +- docs/browser/adr-001-build-isolation.md | 11 +- docs/browser/adr-002-host-migration.md | 68 ++-- docs/browser/adr-003-screen-execution.md | 316 +++++++----------- docs/browser/adr-004-cooperative-loading.md | 55 ++- docs/browser/adr-005-generation-randomness.md | 22 +- docs/browser/adr-006-webgl2-rendering.md | 150 ++++----- docs/browser/adr-007-script-lifetimes.md | 42 +++ docs/browser/gateway.md | 22 +- docs/browser/implementation.md | 112 ++++--- docs/browser/protocol.md | 69 ++++ docs/browser/storage.md | 62 ++++ docs/browser/viewport.md | 65 ++-- libgag/include/AlphaMapRender.h | 9 + libgag/include/ApplicationHost.h | 6 +- libgag/src/ApplicationHost.cpp | 1 + libgag/src/DrawableSurface.cpp | 14 +- libgag/src/GUITabScreen.cpp | 3 + libusl/src/code.cpp | 6 +- libusl/src/code.h | 4 +- libusl/src/interpreter.cpp | 4 +- libusl/src/interpreter.h | 5 +- libusl/src/native.h | 21 +- libusl/src/types.cpp | 22 +- libusl/src/types.h | 38 +-- libusl/src/usl.cpp | 10 +- scons/build_layout.py | 9 +- scons/sources.py | 3 +- scons/web_build.py | 6 +- src/Application.cpp | 99 ++++-- src/Application.h | 7 +- src/CampaignEditor.cpp | 45 ++- src/CampaignEditor.h | 9 +- src/CampaignMenuScreen.h | 2 +- src/ChooseMapScreen.cpp | 31 +- src/EndGameScreen.cpp | 78 ++--- src/EndGameScreen.h | 12 +- src/Engine.cpp | 1 + src/Engine.h | 13 +- src/EngineInit.cpp | 15 +- src/EngineRun.cpp | 52 +-- src/FertilityCalculator.cpp | 16 +- src/FertilityCalculatorDialog.cpp | 80 ----- src/FertilityCalculatorDialog.h | 46 --- src/FileImport.cpp | 11 + src/GUIMapPreview.cpp | 7 +- src/Game.cpp | 1 - src/GameLoadScreen.cpp | 7 +- src/GameLoadScreen.h | 4 +- src/GameSessionScreen.cpp | 37 +- src/GameSessionScreen.h | 4 +- src/Game_editor.cpp | 1 - src/Game_io.cpp | 1 - src/Game_orders.cpp | 1 - src/Game_sync.cpp | 1 - src/GlobalContainer.cpp | 5 + src/KeyboardManager.cpp | 15 +- src/KeyboardManager.h | 3 +- src/LANFindScreen.cpp | 66 +--- src/LANFindScreen.h | 4 +- src/LANMenuScreen.cpp | 93 ++---- src/LANMenuScreen.h | 9 +- src/LANSessionScreen.cpp | 87 +++++ src/LANSessionScreen.h | 29 ++ src/MainMenuScreen.cpp | 49 ++- src/MainMenuScreen.h | 3 + src/MapEditorScreen.h | 2 + src/MapScriptUSL.cpp | 2 + src/MapScriptUSL.h | 3 +- src/MultiplayerGame.cpp | 55 +-- src/MultiplayerGame.h | 13 +- src/MultiplayerGameScreen.cpp | 38 ++- src/MultiplayerGameScreen.h | 6 +- src/ReplayWriter.cpp | 49 +-- src/SConscript | 11 +- src/Settings.cpp | 12 +- src/Settings.h | 2 +- src/SettingsScreen.cpp | 24 ++ src/SettingsScreen.h | 5 + src/TorusView.cpp | 6 +- src/TorusViewRender.cpp | 16 +- src/Version.h | 5 +- src/gui/GameGUI.cpp | 2 + src/gui/GameGUI.h | 3 - src/gui/GameGUIPersistence.cpp | 2 +- src/gui/GameGUIStep.cpp | 2 +- src/gui/GameGUITorus.cpp | 4 +- src/map/Map.h | 11 +- src/map/edit/MapEditActionTerrain.cpp | 1 - src/map/edit/MapEditActionView.cpp | 1 - src/map/edit/MapEditClicks.cpp | 1 - src/map/edit/MapEditDelegate.cpp | 1 - src/map/edit/MapEditDraw.cpp | 1 - src/map/edit/MapEditIO.cpp | 1 - src/map/generator/Generator.cpp | 3 +- src/map/generator/MapGenerationDescriptor.cpp | 6 +- src/map/generator/MapRandom.cpp | 2 +- src/map/io/MapHeader.cpp | 3 +- src/map/io/MapIO.cpp | 3 +- src/map/io/MapThumbnail.cpp | 21 +- src/net/NetTransport.cpp | 15 +- src/net/message/AuthMessages.cpp | 14 +- src/net/message/AuthMessages.h | 2 + src/render/GameRender.cpp | 1 - src/render/GameRenderBuildings.cpp | 1 - src/render/GameRenderOverlay.cpp | 1 - src/render/GameRenderTerrain.cpp | 1 - src/render/GameRenderUnits.cpp | 1 - src/render/Minimap.cpp | 9 +- src/yog/YOGClient.cpp | 48 ++- src/yog/YOGClientBringup.cpp | 70 ---- src/yog/YOGClientBringup.h | 41 --- src/yog/YOGClientDownloadingMapScreen.cpp | 16 +- src/yog/YOGClientDownloadingMapScreen.h | 7 +- src/yog/YOGClientGameConnectionDialog.cpp | 24 +- src/yog/YOGClientGameConnectionDialog.h | 13 +- src/yog/YOGClientLobbyScreen.cpp | 71 ++-- src/yog/YOGClientLobbyScreen.h | 7 +- src/yog/YOGClientMapDownloadScreen.cpp | 38 +-- src/yog/YOGClientMapDownloadScreen.h | 5 +- src/yog/YOGClientMapUploadScreen.cpp | 30 +- src/yog/YOGClientMapUploadScreen.h | 7 +- src/yog/YOGLoginScreen.cpp | 65 ++-- src/yog/YOGLoginScreen.h | 6 +- src/yog/YOGRegisterScreen.cpp | 2 +- src/yog/YOGServer.cpp | 4 +- src/yog/YOGServerPlayer.cpp | 19 +- src/yog/YOGServerPlayer.h | 1 + test/EngineSessionHarness.cpp | 235 ++++++++++++- test/LANSessionHarness.cpp | 135 +++++--- test/LegacyFertilityReference.h | 2 +- test/NativeMultiplayerPeer.cpp | 7 +- test/NetConnectionHarness.cpp | 17 +- test/README.md | 4 +- test/ReplayStepCounterTest.cpp | 4 +- test/SConstruct | 11 +- test/SavegameSafetyHarness.cpp | 65 +++- test/StreamHash.cpp | 5 + tests/baselines/cross-replay.checksums | Bin 154820 -> 154820 bytes tests/baselines/cross-replay.replay | Bin 256668 -> 714568 bytes tests/build_system/coexistence.py | 20 +- tests/build_system/test_layout.py | 4 + tools/MenuColonyHarness.cpp | 49 +-- 208 files changed, 3870 insertions(+), 1662 deletions(-) create mode 100644 browser/audio.js create mode 100644 browser/package-static.py create mode 100644 browser/tests/campaign-editor-storage.spec.js create mode 100644 browser/tests/chooser-errors.spec.js delete mode 100644 browser/tests/game-url.js create mode 100644 browser/tests/input.spec.js create mode 100644 browser/tests/main-menu.js create mode 100644 browser/tests/replay-save.spec.js create mode 100644 browser/tests/runtime-build.spec.js create mode 100644 browser/tests/session-reload.spec.js create mode 100644 browser/tests/settings-storage.spec.js create mode 100644 browser/tests/shutdown-storage.spec.js create mode 100644 browser/tests/team-colors.spec.js create mode 100644 browser/unit/audio.test.js create mode 100644 darwin/README.md create mode 100644 docs/browser/adr-007-script-lifetimes.md create mode 100644 docs/browser/protocol.md delete mode 100644 src/FertilityCalculatorDialog.cpp delete mode 100644 src/FertilityCalculatorDialog.h create mode 100644 src/LANSessionScreen.cpp create mode 100644 src/LANSessionScreen.h delete mode 100644 src/yog/YOGClientBringup.cpp delete mode 100644 src/yog/YOGClientBringup.h create mode 100644 test/StreamHash.cpp diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e06bfdc29..3d146df7a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,12 +5,22 @@ on: branches: [master] pull_request: workflow_dispatch: - schedule: - - cron: '19 4 * * *' + 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 @@ -106,9 +116,12 @@ 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: | @@ -156,7 +169,7 @@ jobs: - 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: | @@ -171,12 +184,12 @@ jobs: - 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: | @@ -191,9 +204,6 @@ jobs: 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 the YOG server - run: scons CXX=${{ matrix.compiler }} -j$(nproc) release=1 server=1 --build=build-server - - name: Build and run the terrain resource regression run: | scons -j$(nproc) release=1 server=0 terrain-test @@ -214,16 +224,16 @@ jobs: 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: @@ -231,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 @@ -250,8 +260,8 @@ 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: | @@ -286,6 +296,7 @@ jobs: windows: name: windows (mingw-w64) + if: ${{ github.event_name != 'workflow_dispatch' || !inputs.browser_only }} runs-on: windows-latest defaults: run: @@ -343,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: @@ -351,86 +362,99 @@ 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/windows/client/release/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/windows/client/release/src/TeamStatsSaveHarness.exe . - python3 test/run-savegame-safety-tests.py --check-preferences --expect-stdout test/fixtures/team-stats/version88.expected.txt build/windows/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/windows/client/release/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-coexistence: - name: browser and native (${{ matrix.order }}) + web: + name: browser runs-on: ubuntu-24.04 - strategy: - fail-fast: false - matrix: - order: [native-first, web-first, concurrent] + 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: Verify clean builds and incremental isolation - run: python3 tests/build_system/coexistence.py --order ${{ matrix.order }} + - name: Build WebAssembly client + run: scons target=web release=1 -j$(nproc) - name: Build and test gateway run: | - scons role=gateway release=1 -j2 + 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 -j2 + run: scons role=router release=1 -j$(nproc) - uses: actions/setup-node@v4 - if: matrix.order == 'concurrent' with: node-version: '22' cache: npm cache-dependency-path: browser/package-lock.json - name: Build transport integration fixture - if: matrix.order == 'concurrent' run: | - scons release=1 transport-test -j2 + 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 - if: matrix.order == 'concurrent' working-directory: browser env: - GLOB2_ALL_AIS: ${{ github.event_name == 'schedule' && '1' || '0' }} + GLOB2_FIREFOX_HEADED: '1' run: | npm ci --ignore-scripts npx playwright install --with-deps chromium firefox webkit - npm test - GLOB2_TEST_RENDERER=webgl2 npx playwright test viewport.spec.js + # 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 - if: matrix.order == 'concurrent' run: | docker compose -f deploy/compose.yaml build lobby python3 -m unittest discover -s tests/deployment -v - uses: actions/upload-artifact@v4 - if: failure() && matrix.order == 'concurrent' + 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-${{ matrix.order }} + name: glob2-web-development + retention-days: 7 path: | build/emscripten/client/release/index.html build/emscripten/client/release/index.js diff --git a/SConstruct b/SConstruct index cff7f3fc7..06b3544a7 100644 --- a/SConstruct +++ b/SConstruct @@ -31,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)) @@ -45,12 +46,21 @@ def establish_options(env): class Configuration: """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): - write_if_changed(self.path, self.f.getvalue()) + 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) @@ -153,10 +163,12 @@ def configure(env, server_only): if conf.CheckLib("boost_system"): env.Append(LIBS=["boost_system"]) env.Append(LIBS=["pthread"]) - if not server_only: + 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"]) diff --git a/browser/ApplicationHost.cpp b/browser/ApplicationHost.cpp index 7ee289a3f..dde1dbee7 100644 --- a/browser/ApplicationHost.cpp +++ b/browser/ApplicationHost.cpp @@ -52,8 +52,8 @@ void scheduledFrame(void* opaque) complete(); return; } - // Queue only after the frame returns. During the migration an Asyncify - // suspension inside a legacy dialog must not start a second frame. + // 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())); } } @@ -63,16 +63,20 @@ void run(std::unique_ptr loop, std::function complete) emscripten_async_call(scheduledFrame, state, 0); } -void wait(std::uint32_t milliseconds) +void wait(std::uint32_t) { - emscripten_sleep(milliseconds ? milliseconds : 1); + 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({ - if (!Module.visibilityPending) return -1; + 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; - return document.hidden || Module.gpuLost ? 1 : 0; + Module.hostHidden = current; + return current ? 1 : 0; }); if (state < 0) return false; hidden = state != 0; @@ -176,7 +180,7 @@ std::unique_ptr persistStorage() { return std::make_unique', 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/playwright.config.js b/browser/playwright.config.js index dd7acb422..f9ba3ee98 100644 --- a/browser/playwright.config.js +++ b/browser/playwright.config.js @@ -15,7 +15,10 @@ module.exports = defineConfig({ trace: 'retain-on-failure', screenshot: 'only-on-failure', }, - projects: ['chromium','firefox','webkit'].map(browserName => ({name:browserName, use:{browserName}})), + 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, '..'), diff --git a/browser/shell.html b/browser/shell.html index 06496c8da..e20179d6c 100644 --- a/browser/shell.html +++ b/browser/shell.html @@ -6,15 +6,39 @@ Globulation 2 +
+ Globulation 2 + + Loading game… +
{{{ SCRIPT }}} 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 index 325e6e196..9974aefb3 100644 --- a/browser/tests/campaign-progress.spec.js +++ b/browser/tests/campaign-progress.spec.js @@ -1,11 +1,11 @@ const {test,expect}=require('@playwright/test'); -const {gameURL}=require('./game-url'); +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 menu(page,480,120);await screen(page,'CampaignMenuScreen');} +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'); 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 index 2302a9d6c..b2479e0b8 100644 --- a/browser/tests/editor-storage.spec.js +++ b/browser/tests/editor-storage.spec.js @@ -1,5 +1,5 @@ const {test,expect}=require('@playwright/test'); -const {gameURL}=require('./game-url'); +const {clickMainMenu,gameURL}=require('./main-menu'); const fs=require('node:fs/promises'); const {createHash}=require('node:crypto'); const state=page=>page.evaluate(()=>glob2Diagnostics.snapshot()); @@ -8,7 +8,7 @@ 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 click(page,760,570);await screen(page,'EditorMainMenu'); + 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}); diff --git a/browser/tests/game-url.js b/browser/tests/game-url.js deleted file mode 100644 index 3224da5ac..000000000 --- a/browser/tests/game-url.js +++ /dev/null @@ -1,9 +0,0 @@ -// Apply the same renderer selection a player can request in the address bar. -// This lets the complete existing suite qualify either backend. -function gameURL() { - const renderer = process.env.GLOB2_TEST_RENDERER; - if (!renderer) return '/'; - if (!['software', 'webgl2'].includes(renderer)) throw new Error('Unknown test renderer: ' + renderer); - return '/?renderer=' + renderer; -} -module.exports = {gameURL}; diff --git a/browser/tests/import.spec.js b/browser/tests/import.spec.js index b7d1ec1fb..f15937b1f 100644 --- a/browser/tests/import.spec.js +++ b/browser/tests/import.spec.js @@ -1,5 +1,5 @@ const {test, expect} = require('@playwright/test'); -const {gameURL} = require('./game-url'); +const {clickMainMenu,gameURL}=require('./main-menu'); const fs = require('node:fs/promises'); const path = require('node:path'); const {createHash} = require('node:crypto'); @@ -21,7 +21,7 @@ async function chooseSave(page,name) { await menu(page,100,70+16*index); } async function exportedSave(page) { - await menu(page,480,200); await screen(page,'CustomGameScreen'); + await clickMainMenu(page,'custom'); await screen(page,'CustomGameScreen'); await menu(page,100,70); await menu(page,530,380); await expect.poll(async () => (await state(page)).tick).toBeGreaterThan(25); await page.locator('#canvas').press('p',{delay:80}); @@ -35,7 +35,7 @@ async function exportedSave(page) { 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 menu(page,160,200); await screen(page,'ChooseMapScreen'); await chooseSave(page,'Original.game'); + 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'); @@ -59,7 +59,7 @@ test('imports an exported save, preserves duplicate names, rejects corruption an } await page.reload(); await screen(page,'MainMenuScreen'); expect(await page.evaluate(() => glob2Diagnostics.saveDigest('Original_(1).game'))).toEqual(expected); - await menu(page,160,200); await screen(page,'ChooseMapScreen'); + 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; @@ -69,7 +69,7 @@ test('imports an exported save, preserves duplicate names, rejects corruption an 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')); - await menu(page,480,200); await screen(page,'CustomGameScreen'); + await clickMainMenu(page,'custom'); await screen(page,'CustomGameScreen'); 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')}); @@ -81,7 +81,7 @@ test('imports a custom map and starts it through the normal setup screen', async 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 menu(page,160,200); await screen(page,'ChooseMapScreen'); await menu(page,340,440); + 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); diff --git a/browser/tests/input.spec.js b/browser/tests/input.spec.js new file mode 100644 index 000000000..f6b50acb9 --- /dev/null +++ b/browser/tests/input.spec.js @@ -0,0 +1,50 @@ +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('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 click(page,380,280);await click(page,810,590); + 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..22c28d91b --- /dev/null +++ b/browser/tests/main-menu.js @@ -0,0 +1,51 @@ +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}); +}; diff --git a/browser/tests/multiplayer.spec.js b/browser/tests/multiplayer.spec.js index 23210c923..7595aacac 100644 --- a/browser/tests/multiplayer.spec.js +++ b/browser/tests/multiplayer.spec.js @@ -1,4 +1,4 @@ -const {gameURL} = require('./game-url'); +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. @@ -75,7 +75,7 @@ test('browser YOG login exchanges the native protocol through the real gateway', 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 click(440, 490); await screen('YOGLoginScreen'); + 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'); @@ -98,7 +98,7 @@ test('registered browser player enters and leaves the native YOG lobby', async ( 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 click(440, 490); await screen('YOGLoginScreen'); + 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'); @@ -108,7 +108,7 @@ test('registered browser player enters and leaves the native YOG lobby', async ( 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('Glob2TabScreen'); + await screen('YOGSessionScreen'); await page.screenshot({path: testInfo.outputPath('yog-lobby.png')}); await page.locator('#canvas').press('Escape'); await screen('MainMenuScreen'); @@ -120,15 +120,79 @@ async function loginPlayer(page, name) { 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 click(440, 490); await screen('YOGLoginScreen'); + 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('Glob2TabScreen'); + await click(810, 590); await screen('YOGSessionScreen'); } -function receivedTypes(page, direction = 'framereceived') { +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); @@ -137,7 +201,7 @@ function receivedTypes(page, direction = 'framereceived') { while (bytes.length >= 2) { const size = bytes.readUInt16BE(0); if (!size || bytes.length < size + 2) break; - result.push(bytes[2]); bytes = bytes.subarray(size + 2); + result.push(decode(bytes.subarray(2, size + 2))); bytes = bytes.subarray(size + 2); } }); }); @@ -193,6 +257,7 @@ test(`two browser players create, join and start a YOG match (${ai.name})`, asyn 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)); @@ -205,6 +270,8 @@ test(`two browser players create, join and start a YOG match (${ai.name})`, asyn 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); @@ -212,6 +279,17 @@ test(`two browser players create, join and start a YOG match (${ai.name})`, asyn 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(); } }); @@ -223,6 +301,7 @@ test(`browser and native players complete matching simulation checkpoints (${tra 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); @@ -235,14 +314,29 @@ test(`browser and native players complete matching simulation checkpoints (${tra peer = started.child; peer.stdout.on('data', chunk => peerLog.push(String(chunk))); peer.stderr.on('data', chunk => peerLog.push(String(chunk))); - await expect.poll(() => hostTypes().filter(type => type === 26).length).toBeGreaterThanOrEqual(2); + 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(() => peer.exitCode ?? peer.signalCode, {timeout: 45000}).toBe(0); + 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; }; @@ -270,6 +364,8 @@ test(`browser and native players complete matching simulation checkpoints (${tra // 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); @@ -277,3 +373,110 @@ test(`browser and native players complete matching simulation checkpoints (${tra 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 index 71df374f9..16d8ac78f 100644 --- a/browser/tests/pixels.js +++ b/browser/tests/pixels.js @@ -14,3 +14,21 @@ async function hasRenderedPixels(page) { }, 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; diff --git a/browser/tests/rendering.spec.js b/browser/tests/rendering.spec.js index 6ce60e981..b6bd5a5b5 100644 --- a/browser/tests/rendering.spec.js +++ b/browser/tests/rendering.spec.js @@ -1,4 +1,5 @@ const {test, expect} = require('@playwright/test'); +const {clickMainMenu}=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}); @@ -11,7 +12,7 @@ test('WebGL2 draws a playable match and resizes its drawing buffer', async ({pag 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 click(page, 760, 410); await screen(page, 'CustomGameScreen'); + await clickMainMenu(page,'custom'); await screen(page, 'CustomGameScreen'); await click(page, 380, 280); await click(page, 810, 590); await expect.poll(async () => (await state(page)).tick).toBeGreaterThan(25); await page.setViewportSize({width:1280,height:720}); @@ -29,7 +30,7 @@ test('software renderer remains available', async ({page}) => { await screen(page,'MainMenuScreen'); expect((await state(page)).renderer).toBe('software'); expect(await page.evaluate(() => Boolean(document.querySelector('#canvas').getContext('2d')))).toBe(true); - await click(page,440,570); await screen(page,'SettingsScreen'); + await clickMainMenu(page,'settings'); await screen(page,'SettingsScreen'); }); @@ -37,7 +38,7 @@ test('WebGL context restoration keeps the match and can recover repeatedly', asy const errors = []; page.on('pageerror', error => errors.push(String(error))); await page.goto('/?renderer=webgl2'); await screen(page,'MainMenuScreen'); - await click(page,760,410); await screen(page,'CustomGameScreen'); + await clickMainMenu(page,'custom'); await screen(page,'CustomGameScreen'); await click(page,380,280); await click(page,810,590); await expect.poll(async () => (await state(page)).tick).toBeGreaterThan(25); for (let count=1; count<=2; ++count) { @@ -81,9 +82,9 @@ test('WebGL context restoration retains settings, editor and confirmation contro await expect.poll(() => require('./pixels').hasRenderedPixels(page)).toBe(true); expect(await page.evaluate(() => document.querySelector('#canvas').getContext('webgl2').getError())).toBe(0); } - await click(page,440,570); await recover('SettingsScreen'); + await clickMainMenu(page,'settings'); await recover('SettingsScreen'); await click(page,810,650); await screen(page,'MainMenuScreen'); - await click(page,760,570); await screen(page,'EditorMainMenu'); + 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}); diff --git a/browser/tests/replay-save.spec.js b/browser/tests/replay-save.spec.js new file mode 100644 index 000000000..368a2dee0 --- /dev/null +++ b/browser/tests/replay-save.spec.js @@ -0,0 +1,66 @@ +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.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 click(page,380,280);await click(page,810,590); + 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..fded36e99 --- /dev/null +++ b/browser/tests/session-reload.spec.js @@ -0,0 +1,81 @@ +const {test,expect}=require('@playwright/test'); +const {clickMainMenu,gameURL}=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 click(page,380,280);await click(page,810,590); + 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 click(page,380,280);await click(page,810,590); + 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..c50a21267 --- /dev/null +++ b/browser/tests/settings-storage.spec.js @@ -0,0 +1,89 @@ +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 preferences=page=>page.evaluate(()=>glob2Diagnostics.preferences()); + +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 click(page,600,650);await screen(page,'MainMenuScreen'); + expect(await preferences(page)).toEqual({optionFlags:1,mute:1}); + await clickMainMenu(page,'settings');await screen(page,'SettingsScreen'); + await click(page,520,585); // Actual Mute checkbox, relative to the centered settings panel. + await click(page,600,650);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 click(page,600,650);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 click(page,600,650); await screen(page,'MainMenuScreen'); + expect(await preferences(page)).toEqual({optionFlags:1,mute:1}); + await clickMainMenu(page,'settings'); await screen(page,'SettingsScreen'); + await click(page,520,370); // Turn high-quality graphics on using the actual toggle. + 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 click(page,600,650); + await expect.poll(async()=>(await state(page)).persistence).toBe('failed'); + await screen(page,'SettingsScreen'); + await expect.poll(()=>require('./pixels').hasLightText(page,{x:300,y:604,width:600,height:22})).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 click(page,600,650); 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 click(page,600,650); + 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 click(page,600,650);await screen(page,'MainMenuScreen'); + await clickMainMenu(page,'settings');await screen(page,'SettingsScreen'); + await click(page,520,370); + await page.evaluate(()=>{IDBObjectStore.prototype.put=function(){throw new DOMException('Injected quota exhaustion','QuotaExceededError');};}); + await click(page,600,650); + await expect.poll(async()=>(await state(page)).persistence).toBe('failed'); + await screen(page,'SettingsScreen'); + await click(page,810,650);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..46656ded1 --- /dev/null +++ b/browser/tests/shutdown-storage.spec.js @@ -0,0 +1,63 @@ +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}); + +async function start(page){ + await page.goto(gameURL()); await screen(page,'MainMenuScreen'); + await clickMainMenu(page,'settings'); await screen(page,'SettingsScreen'); + await click(page,600,650); 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 index 9fab41a6c..bea7841b1 100644 --- a/browser/tests/single-player.spec.js +++ b/browser/tests/single-player.spec.js @@ -1,4 +1,4 @@ -const {gameURL} = require('./game-url'); +const {gameURL,clickMainMenu}=require('./main-menu'); const {test, expect} = require('@playwright/test'); const state = page => page.evaluate(() => glob2Diagnostics.snapshot()); @@ -6,7 +6,36 @@ const screen = (page, name) => expect.poll(async () => (await state(page)).scree 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); -test.beforeEach(async ({page}) => { +// 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'); }); @@ -14,28 +43,28 @@ test.beforeEach(async ({page}) => { 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); - expect(await page.locator('body').innerText()).toBe(''); + 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 menu(page, 160, 360); + await clickMainMenu(page, 'settings'); await screen(page, 'SettingsScreen'); await menu(page, 530, 440); await screen(page, 'MainMenuScreen'); - await menu(page, 160, 440); + await clickMainMenu(page, 'credits'); await screen(page, 'CreditScreen'); await page.locator('#canvas').press('Escape'); await screen(page, 'MainMenuScreen'); - await menu(page, 480, 440); + 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 menu(page, 160, 120); + await clickMainMenu(page, 'campaign'); await screen(page, 'CampaignMainMenu'); for (let attempt = 0; attempt < 2; ++attempt) { await menu(page, 320, 90); @@ -48,7 +77,7 @@ test('campaign selector returns to its suspended parent and can reopen', async ( }); test('tutorial sessions quit through the end screen and can restart', async ({page}) => { - await menu(page, 480, 120); + await clickMainMenu(page, 'tutorial'); await screen(page, 'CampaignMenuScreen'); for (let attempt = 0; attempt < 2; ++attempt) { await menu(page, 100, 60); @@ -64,7 +93,7 @@ test('tutorial sessions quit through the end screen and can restart', async ({pa }); test('custom options and AI descriptions return to setup, and a finished game returns there too', async ({page}) => { - await menu(page, 480, 200); + await clickMainMenu(page, 'custom'); await screen(page, 'CustomGameScreen'); await menu(page, 100, 70); await menu(page, 310, 440); @@ -89,7 +118,7 @@ test('custom options and AI descriptions return to setup, and a finished game re test('custom match pauses, persists and resumes after reload', async ({page}) => { const errors = []; page.on('pageerror', error => errors.push(String(error))); - await menu(page, 480, 200); + await clickMainMenu(page, 'custom'); await screen(page, 'CustomGameScreen'); await menu(page, 100, 70); await menu(page, 530, 380); @@ -117,7 +146,7 @@ test('custom match pauses, persists and resumes after reload', async ({page}) => 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 menu(page, 160, 200); await screen(page, 'ChooseMapScreen'); + await clickMainMenu(page, 'load'); await screen(page, 'ChooseMapScreen'); await menu(page, 100, 70); const downloadEvent = page.waitForEvent('download'); await menu(page, 340, 320); @@ -127,7 +156,7 @@ test('custom match pauses, persists and resumes after reload', async ({page}) => 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 menu(page, 160, 200); + await clickMainMenu(page, 'load'); await screen(page, 'ChooseMapScreen'); await menu(page, 100, 70); await menu(page, 530, 380); @@ -139,7 +168,7 @@ test('custom match pauses, persists and resumes after reload', async ({page}) => 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 menu(page, 480, 360); + await clickMainMenu(page, 'editor'); await screen(page, 'EditorMainMenu'); await menu(page, 320, 90); await screen(page, 'NewMapScreen'); @@ -169,7 +198,7 @@ test('editor setup and campaign entry dialogs return to their retained parents', 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 menu(page, 480, 360); + await clickMainMenu(page, 'editor'); await screen(page, 'EditorMainMenu'); await menu(page, 320, 90); await screen(page, 'NewMapScreen'); @@ -190,7 +219,7 @@ test('map editor frames resume after cancelling quit and can discard a new map', test('editor save cancellation keeps edits open and completed fertility saves the map', async ({page}) => { - await menu(page, 480, 360); + await clickMainMenu(page, 'editor'); await screen(page, 'EditorMainMenu'); await menu(page, 320, 90); await screen(page, 'NewMapScreen'); @@ -227,16 +256,16 @@ test('editor save cancellation keeps edits open and completed fertility saves th test('editor map loading can be cancelled and restarted', async ({page}) => { const errors = []; page.on('pageerror', error => errors.push(String(error))); - await menu(page, 480, 360); + 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 screen(page, 'EditorLoadScreen'); - await page.locator('#canvas').press('Escape', {delay:80}); + await cancelHeldLoader(page, 'EditorLoadScreen'); } else { await screen(page, 'MapEditorScreen'); await page.locator('#canvas').press('Escape', {delay:80}); @@ -250,21 +279,21 @@ test('editor map loading can be cancelled and restarted', async ({page}) => { test('custom and tutorial startup can be cancelled and retried', async ({page}) => { const errors = []; page.on('pageerror', error => errors.push(String(error))); - await menu(page, 480, 200); + await clickMainMenu(page, 'custom'); await screen(page, 'CustomGameScreen'); await menu(page, 100, 70); + await holdLoader(page, 'GameLoadScreen'); await menu(page, 530, 380); - await screen(page, 'GameLoadScreen'); - await page.locator('#canvas').press('Escape', {delay:80}); + await cancelHeldLoader(page, 'GameLoadScreen'); await screen(page, 'CustomGameScreen'); await page.locator('#canvas').press('Escape', {delay:80}); await screen(page, 'MainMenuScreen'); - await menu(page, 480, 120); + await clickMainMenu(page, 'tutorial'); await screen(page, 'CampaignMenuScreen'); await menu(page, 100, 60); + await holdLoader(page, 'GameLoadScreen'); await menu(page, 160, 450); - await screen(page, 'GameLoadScreen'); - await page.locator('#canvas').press('Escape', {delay:80}); + await cancelHeldLoader(page, 'GameLoadScreen'); await screen(page, 'CampaignMenuScreen'); await menu(page, 160, 450); // The same selected mission remains available. await screen(page, 'match'); @@ -275,7 +304,7 @@ test('custom and tutorial startup can be cancelled and retried', async ({page}) 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 menu(page, 480, 360); + await clickMainMenu(page, 'editor'); await screen(page, 'EditorMainMenu'); await menu(page, 320, 90); await screen(page, 'NewMapScreen'); @@ -285,9 +314,13 @@ test('cancelling an editor replacement preserves edits and a completed load repl 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); - await screen(page, 'EditorLoadScreen'); - if (cancel) await page.locator('#canvas').press('Escape', {delay:80}); + 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); @@ -305,7 +338,7 @@ 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 menu(page, 480, 360); + await clickMainMenu(page, 'editor'); await screen(page, 'EditorMainMenu'); await menu(page, 320, 90); await screen(page, 'NewMapScreen'); diff --git a/browser/tests/storage.spec.js b/browser/tests/storage.spec.js index 1e4c14674..7547ad6bb 100644 --- a/browser/tests/storage.spec.js +++ b/browser/tests/storage.spec.js @@ -1,4 +1,4 @@ -const {gameURL} = require('./game-url'); +const {gameURL,clickMainMenu}=require('./main-menu'); const {test,expect}=require('@playwright/test'); const fs=require('node:fs/promises'); const {createHash}=require('node:crypto'); @@ -33,7 +33,7 @@ for (const fault of ['abort','quota']) test(`${fault} failure retains the previo }; }, fault); await page.goto(gameURL()); await screen(page,'MainMenuScreen'); - await click(page,760,410); await screen(page,'CustomGameScreen'); + await clickMainMenu(page,'custom'); await screen(page,'CustomGameScreen'); await click(page,380,280); await click(page,810,590); await expect.poll(async ()=>(await state(page)).tick).toBeGreaterThan(25); await page.locator('#canvas').press('p',{delay:80}); @@ -89,7 +89,7 @@ test('restore failure is explained before entering the game', async ({page},info 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 click(page,440,570); await screen(page,'SettingsScreen'); + await clickMainMenu(page,'settings'); await screen(page,'SettingsScreen'); await click(page,810,650); await screen(page,'MainMenuScreen'); // Startup and settings writes must not retry the database after failed restore. expect(await page.evaluate(()=>restoreFault.attempts)).toBe(1); 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/viewport.spec.js b/browser/tests/viewport.spec.js index d1306e229..97f3532d7 100644 --- a/browser/tests/viewport.spec.js +++ b/browser/tests/viewport.spec.js @@ -1,12 +1,12 @@ -const {gameURL} = require('./game-url'); +const {gameURL,clickMainMenu}=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) { +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,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); @@ -14,28 +14,27 @@ async function resize(page,width,height) { 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 menu(page,160,360); await screen(page,'SettingsScreen'); + await clickMainMenu(page,'settings'); await screen(page,'SettingsScreen'); await resize(page,900,650); await menu(page,530,440); await screen(page,'MainMenuScreen'); await resize(page,1440,900); - await menu(page,160,440); await screen(page,'CreditScreen'); + 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 menu(page,480,200); await screen(page,'CustomGameScreen'); + await clickMainMenu(page,'custom'); await screen(page,'CustomGameScreen'); await menu(page,100,70); await menu(page,530,380); 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); await screen(page,'MinimumViewportScreen'); - const suspended = (await snapshot(page)).tick; - await page.locator('#canvas').press('Escape',{delay:80}); - expect((await snapshot(page)).tick).toBe(suspended); + 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); - await expect.poll(async () => (await snapshot(page)).tick).toBeGreaterThan(suspended); 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); @@ -45,7 +44,7 @@ test('a running match survives resize and its open menu follows the new center', await page.locator('#canvas').press('Enter'); await screen(page,'CustomGameScreen'); }); test('editor dialogs and discard controls follow viewport changes', async ({page}) => { - await menu(page,480,360); await screen(page,'EditorMainMenu'); + 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}); @@ -55,20 +54,29 @@ test('editor dialogs and discard controls follow viewport changes', async ({page await menu(page,320,360); await screen(page,'EditorMainMenu'); }); -test('small viewports show an in-game notice and return to the retained menu', async ({page}, info) => { - await menu(page,160,360); await screen(page,'SettingsScreen'); - await resize(page,500,400); await screen(page,'MinimumViewportScreen'); - await page.screenshot({path:info.outputPath('minimum-viewport.png')}); - await page.locator('#canvas').press('Escape'); await screen(page,'MinimumViewportScreen'); +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 menu(page,530,440); 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('high density display', () => { test.use({deviceScaleFactor:2}); - test('keeps one rendering pixel per CSS pixel', async ({page}) => { + test('uses the renderer-appropriate backing resolution', async ({page}) => { expect(await page.evaluate(() => devicePixelRatio)).toBe(2); - await resize(page,1100,750); - await menu(page,160,360); await screen(page,'SettingsScreen'); + 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/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/visibility/lifecycle.spec.js b/browser/visibility/lifecycle.spec.js index 7f3bbd45a..fdf361371 100644 --- a/browser/visibility/lifecycle.spec.js +++ b/browser/visibility/lifecycle.spec.js @@ -3,24 +3,39 @@ 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}) => { +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', 'about:blank'], {stdio:'ignore'}); - const exited = new Promise(resolve => child.once('exit',resolve)); - let browser; + '--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]; + 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); @@ -30,8 +45,16 @@ test('background single-player suspends and returns without catching up', async y:(y+(s.height-480)/2)*box.height/s.height},delay:80}); }; await page.bringToFront(); - await page.goto(baseURL); await screen('MainMenuScreen'); - await click(480,200); await screen('CustomGameScreen'); + 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(); @@ -52,9 +75,20 @@ test('background single-player suspends and returns without catching up', async // 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(); 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 fbaf48a23..51270cf05 100644 --- a/data/texts.ar.txt +++ b/data/texts.ar.txt @@ -1788,8 +1788,6 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... -[browser window too small] -Enlarge the window to at least 800 x 600. [saving to storage] Saving... [save failed retry] @@ -1830,3 +1828,17 @@ Campaign progress not saved. Retry or export a backup. 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.br.txt b/data/texts.br.txt index 590825f90..ea0acee9e 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] -Map Download Failed: Connection to YOG lost +Falha no download do mapa: conexão perdida [Map name: %0] Nome do mapa: %0. [map name] @@ -1786,8 +1786,6 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... -[browser window too small] -Enlarge the window to at least 800 x 600. [saving to storage] Saving... [save failed retry] @@ -1828,3 +1826,17 @@ Campaign progress not saved. Retry or export a backup. 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.ca.txt b/data/texts.ca.txt index 8e7930031..a2e325fcb 100644 --- a/data/texts.ca.txt +++ b/data/texts.ca.txt @@ -1798,8 +1798,6 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... -[browser window too small] -Enlarge the window to at least 800 x 600. [saving to storage] Saving... [save failed retry] @@ -1840,3 +1838,17 @@ Campaign progress not saved. Retry or export a backup. 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.cz.txt b/data/texts.cz.txt index c0ce6171a..fef5ba70a 100644 --- a/data/texts.cz.txt +++ b/data/texts.cz.txt @@ -1790,8 +1790,6 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... -[browser window too small] -Enlarge the window to at least 800 x 600. [saving to storage] Saving... [save failed retry] @@ -1832,3 +1830,17 @@ Campaign progress not saved. Retry or export a backup. 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.de.txt b/data/texts.de.txt index e0a10066c..217343bb3 100644 --- a/data/texts.de.txt +++ b/data/texts.de.txt @@ -1790,8 +1790,6 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... -[browser window too small] -Enlarge the window to at least 800 x 600. [saving to storage] Saving... [save failed retry] @@ -1832,3 +1830,17 @@ Campaign progress not saved. Retry or export a backup. 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.dk.txt b/data/texts.dk.txt index afd21c912..dc49068de 100644 --- a/data/texts.dk.txt +++ b/data/texts.dk.txt @@ -1866,8 +1866,6 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... -[browser window too small] -Enlarge the window to at least 800 x 600. [saving to storage] Saving... [save failed retry] @@ -1908,3 +1906,17 @@ Campaign progress not saved. Retry or export a backup. 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.en.txt b/data/texts.en.txt index ca6a22bff..67dbd6cf6 100644 --- a/data/texts.en.txt +++ b/data/texts.en.txt @@ -1788,8 +1788,6 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... -[browser window too small] -Enlarge the window to at least 800 x 600. [saving to storage] Saving... [save failed retry] @@ -1830,3 +1828,17 @@ Campaign progress not saved. Retry or export a backup. 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 99d467611..e30d918b8 100644 --- a/data/texts.eo.txt +++ b/data/texts.eo.txt @@ -1788,8 +1788,6 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... -[browser window too small] -Enlarge the window to at least 800 x 600. [saving to storage] Saving... [save failed retry] @@ -1830,3 +1828,17 @@ Campaign progress not saved. Retry or export a backup. 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.es.txt b/data/texts.es.txt index c1b740c6a..8f967c57c 100644 --- a/data/texts.es.txt +++ b/data/texts.es.txt @@ -1790,8 +1790,6 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... -[browser window too small] -Enlarge the window to at least 800 x 600. [saving to storage] Saving... [save failed retry] @@ -1832,3 +1830,17 @@ Campaign progress not saved. Retry or export a backup. 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.eu.txt b/data/texts.eu.txt index c30038a4f..b7bc98482 100644 --- a/data/texts.eu.txt +++ b/data/texts.eu.txt @@ -1800,8 +1800,6 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... -[browser window too small] -Enlarge the window to at least 800 x 600. [saving to storage] Saving... [save failed retry] @@ -1842,3 +1840,17 @@ Campaign progress not saved. Retry or export a backup. 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.fa.txt b/data/texts.fa.txt index 2724b5228..c374abdb7 100644 --- a/data/texts.fa.txt +++ b/data/texts.fa.txt @@ -1788,8 +1788,6 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... -[browser window too small] -Enlarge the window to at least 800 x 600. [saving to storage] Saving... [save failed retry] @@ -1830,3 +1828,17 @@ Campaign progress not saved. Retry or export a backup. 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.fi.txt b/data/texts.fi.txt index c880295a4..2d25c45d6 100644 --- a/data/texts.fi.txt +++ b/data/texts.fi.txt @@ -1790,8 +1790,6 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... -[browser window too small] -Enlarge the window to at least 800 x 600. [saving to storage] Saving... [save failed retry] @@ -1832,3 +1830,17 @@ Campaign progress not saved. Retry or export a backup. 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.fr.txt b/data/texts.fr.txt index 11174f3cd..fc10a595e 100644 --- a/data/texts.fr.txt +++ b/data/texts.fr.txt @@ -1800,8 +1800,6 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... -[browser window too small] -Enlarge the window to at least 800 x 600. [saving to storage] Saving... [save failed retry] @@ -1842,3 +1840,17 @@ Campaign progress not saved. Retry or export a backup. 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.gr.txt b/data/texts.gr.txt index 20081db9a..65004f627 100644 --- a/data/texts.gr.txt +++ b/data/texts.gr.txt @@ -1860,8 +1860,6 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... -[browser window too small] -Enlarge the window to at least 800 x 600. [saving to storage] Saving... [save failed retry] @@ -1902,3 +1900,17 @@ Campaign progress not saved. Retry or export a backup. 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.hu.txt b/data/texts.hu.txt index 1cff99aa3..0420fa7e9 100644 --- a/data/texts.hu.txt +++ b/data/texts.hu.txt @@ -1790,8 +1790,6 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... -[browser window too small] -Enlarge the window to at least 800 x 600. [saving to storage] Saving... [save failed retry] @@ -1832,3 +1830,17 @@ Campaign progress not saved. Retry or export a backup. 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.id.txt b/data/texts.id.txt index 5da7047e3..68b0f478b 100644 --- a/data/texts.id.txt +++ b/data/texts.id.txt @@ -1786,8 +1786,6 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... -[browser window too small] -Enlarge the window to at least 800 x 600. [saving to storage] Saving... [save failed retry] @@ -1828,3 +1826,17 @@ Campaign progress not saved. Retry or export a backup. 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.it.txt b/data/texts.it.txt index 04bd49d90..00fabfc8a 100644 --- a/data/texts.it.txt +++ b/data/texts.it.txt @@ -1852,8 +1852,6 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... -[browser window too small] -Enlarge the window to at least 800 x 600. [saving to storage] Saving... [save failed retry] @@ -1894,3 +1892,17 @@ Campaign progress not saved. Retry or export a backup. 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.ja.txt b/data/texts.ja.txt index 596a0a535..16ed9ba33 100644 --- a/data/texts.ja.txt +++ b/data/texts.ja.txt @@ -1786,8 +1786,6 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... -[browser window too small] -Enlarge the window to at least 800 x 600. [saving to storage] Saving... [save failed retry] @@ -1828,3 +1826,17 @@ Campaign progress not saved. Retry or export a backup. 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.keys.txt b/data/texts.keys.txt index e81a3b9a4..b5aead429 100644 --- a/data/texts.keys.txt +++ b/data/texts.keys.txt @@ -892,7 +892,6 @@ [Loading units] [Loading buildings] [Resolving team links] -[browser window too small] [saving to storage] [save failed retry] [export save] @@ -913,3 +912,10 @@ [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 e86a07522..9f8a84cfb 100644 --- a/data/texts.ko.txt +++ b/data/texts.ko.txt @@ -1786,8 +1786,6 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... -[browser window too small] -Enlarge the window to at least 800 x 600. [saving to storage] Saving... [save failed retry] @@ -1828,3 +1826,17 @@ Campaign progress not saved. Retry or export a backup. 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.nl.txt b/data/texts.nl.txt index 9a6d278af..c383b0c19 100644 --- a/data/texts.nl.txt +++ b/data/texts.nl.txt @@ -1814,8 +1814,6 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... -[browser window too small] -Enlarge the window to at least 800 x 600. [saving to storage] Saving... [save failed retry] @@ -1856,3 +1854,17 @@ Campaign progress not saved. Retry or export a backup. 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.pl.txt b/data/texts.pl.txt index 6d05e59c6..627a7ae53 100644 --- a/data/texts.pl.txt +++ b/data/texts.pl.txt @@ -1790,8 +1790,6 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... -[browser window too small] -Enlarge the window to at least 800 x 600. [saving to storage] Saving... [save failed retry] @@ -1832,3 +1830,17 @@ Campaign progress not saved. Retry or export a backup. 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.pt.txt b/data/texts.pt.txt index 6307e565f..459002acb 100644 --- a/data/texts.pt.txt +++ b/data/texts.pt.txt @@ -1796,8 +1796,6 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... -[browser window too small] -Enlarge the window to at least 800 x 600. [saving to storage] Saving... [save failed retry] @@ -1838,3 +1836,17 @@ Campaign progress not saved. Retry or export a backup. 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.ro.txt b/data/texts.ro.txt index 4c63cef18..cd886ec53 100644 --- a/data/texts.ro.txt +++ b/data/texts.ro.txt @@ -1790,8 +1790,6 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... -[browser window too small] -Enlarge the window to at least 800 x 600. [saving to storage] Saving... [save failed retry] @@ -1832,3 +1830,17 @@ Campaign progress not saved. Retry or export a backup. 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.ru.txt b/data/texts.ru.txt index 65dc4856f..9688f6a42 100644 --- a/data/texts.ru.txt +++ b/data/texts.ru.txt @@ -1788,8 +1788,6 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... -[browser window too small] -Enlarge the window to at least 800 x 600. [saving to storage] Saving... [save failed retry] @@ -1830,3 +1828,17 @@ Campaign progress not saved. Retry or export a backup. 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.si.txt b/data/texts.si.txt index 2ca624637..67f7e8db8 100644 --- a/data/texts.si.txt +++ b/data/texts.si.txt @@ -1790,8 +1790,6 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... -[browser window too small] -Enlarge the window to at least 800 x 600. [saving to storage] Saving... [save failed retry] @@ -1832,3 +1830,17 @@ Campaign progress not saved. Retry or export a backup. 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.sk.txt b/data/texts.sk.txt index abe2aea1c..5fd5e809c 100644 --- a/data/texts.sk.txt +++ b/data/texts.sk.txt @@ -1798,8 +1798,6 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... -[browser window too small] -Enlarge the window to at least 800 x 600. [saving to storage] Saving... [save failed retry] @@ -1840,3 +1838,17 @@ Campaign progress not saved. Retry or export a backup. 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.sr.txt b/data/texts.sr.txt index 536663c69..66c583fa6 100644 --- a/data/texts.sr.txt +++ b/data/texts.sr.txt @@ -1790,8 +1790,6 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... -[browser window too small] -Enlarge the window to at least 800 x 600. [saving to storage] Saving... [save failed retry] @@ -1832,3 +1830,17 @@ Campaign progress not saved. Retry or export a backup. 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.sv.txt b/data/texts.sv.txt index b788e7165..289ad88ab 100644 --- a/data/texts.sv.txt +++ b/data/texts.sv.txt @@ -1800,8 +1800,6 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... -[browser window too small] -Enlarge the window to at least 800 x 600. [saving to storage] Saving... [save failed retry] @@ -1842,3 +1840,17 @@ Campaign progress not saved. Retry or export a backup. 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.tr.txt b/data/texts.tr.txt index afbd0ac8c..4e264bba6 100644 --- a/data/texts.tr.txt +++ b/data/texts.tr.txt @@ -1798,8 +1798,6 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... -[browser window too small] -Enlarge the window to at least 800 x 600. [saving to storage] Saving... [save failed retry] @@ -1840,3 +1838,17 @@ Campaign progress not saved. Retry or export a backup. 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.uk.txt b/data/texts.uk.txt index ba9a5ea8d..92e6bf675 100644 --- a/data/texts.uk.txt +++ b/data/texts.uk.txt @@ -1786,8 +1786,6 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... -[browser window too small] -Enlarge the window to at least 800 x 600. [saving to storage] Saving... [save failed retry] @@ -1828,3 +1826,17 @@ Campaign progress not saved. Retry or export a backup. 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.vi.txt b/data/texts.vi.txt index 023c1253e..2978e4f60 100644 --- a/data/texts.vi.txt +++ b/data/texts.vi.txt @@ -1786,8 +1786,6 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... -[browser window too small] -Enlarge the window to at least 800 x 600. [saving to storage] Saving... [save failed retry] @@ -1828,3 +1826,17 @@ Campaign progress not saved. Retry or export a backup. 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.zh-cn.txt b/data/texts.zh-cn.txt index d69086cd6..24308f799 100644 --- a/data/texts.zh-cn.txt +++ b/data/texts.zh-cn.txt @@ -1786,8 +1786,6 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... -[browser window too small] -Enlarge the window to at least 800 x 600. [saving to storage] Saving... [save failed retry] @@ -1828,3 +1826,17 @@ Campaign progress not saved. Retry or export a backup. 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.zh-tw.txt b/data/texts.zh-tw.txt index 9b63dd3ca..800bda3a3 100644 --- a/data/texts.zh-tw.txt +++ b/data/texts.zh-tw.txt @@ -1862,8 +1862,6 @@ Loading units... Loading buildings... [Resolving team links] Resolving team links... -[browser window too small] -Enlarge the window to at least 800 x 600. [saving to storage] Saving... [save failed retry] @@ -1904,3 +1902,17 @@ Campaign progress not saved. Retry or export a backup. 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/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/docs/browser/adr-001-build-isolation.md b/docs/browser/adr-001-build-isolation.md index d7200c1c7..4e128ef00 100644 --- a/docs/browser/adr-001-build-isolation.md +++ b/docs/browser/adr-001-build-isolation.md @@ -30,7 +30,10 @@ The browser SDK revision/version and Boost port version are recorded in release dependency lock covering SDK archive digests and native gateway dependencies is still required before reproducible release status. -Validation consists of build identity unit tests plus real native-first, -web-first, and concurrent CI jobs. Subsequent alternating builds must preserve -object and artifact contents and timestamps, as well as tracked source files. -This does not replace platform-specific runtime and determinism tests. +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 index bcce186a8..efbdd473a 100644 --- a/docs/browser/adr-002-host-migration.md +++ b/docs/browser/adr-002-host-migration.md @@ -1,31 +1,37 @@ -# ADR 002: explicit host calls during lifecycle migration - -Status: transitional implementation; Asyncify removal is not complete. - -The browser experiment forcibly redefined `SDL_Delay` in every translation unit -and slept inside `GraphicContext::nextFrame`. This changed unrelated networking -waits and hid application scheduling inside drawing. It also placed JavaScript -diagnostics in the engine and UI classes. - -`ApplicationHost` now owns the transitional wait and diagnostic contract. -Desktop implements waits using SDL. Browser implements waits using Asyncify, -including a yield when an application frame is already late. UI loops call it -explicitly. Rendering does not wait. Browser interop is compiled only from the -browser platform directory. Static dependency tests enforce that boundary. - -The browser publishes a versioned, read-only `glob2Diagnostics` interface: -screen, simulation tick, engine frame count, pause state, render dimensions, -storage restore/write activity, audio state, save names and save digests. -Tests drive the real UI and use these observations to wait for outcomes. -This interface cannot issue orders, advance ticks, alter saves, or navigate -menus. The legacy Emscripten `Module` is still exposed by the current shell. - -This is not the final callback-based application lifecycle. Blocking screen -and modal loops still need replacement with explicit screen-stack transitions; -loading/map generation still need resumable, cancellable jobs. The supported -target must remove Asyncify and have both hosts drive the same update API. -See the [Emscripten execution model](https://emscripten.org/docs/porting/emscripten-runtime-environment.html). - -The initial maintained suite uses [Playwright projects](https://playwright.dev/docs/test-configuration) -for Chromium, Firefox and WebKit. It does not establish Safari/Edge release -coverage, checkpoint correctness, or durable-write failure handling. +# 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 index ec8b8d1f4..f3b717663 100644 --- a/docs/browser/adr-003-screen-execution.md +++ b/docs/browser/adr-003-screen-execution.md @@ -1,195 +1,121 @@ -# ADR 003: explicit screen execution phases - -Status: first runtime migration step; screen-stack conversion remains pending. - -`Screen` now exposes `beginExecution`, `updateExecution`, -`handleExecutionEvent`, `drawExecution`, and `finishExecution`. The host supplies -input and timer values. These methods do not poll events or wait. This makes -screen lifecycle behavior testable without depending on wall-clock timing. - -`execute()` remains a compatibility host: it polls SDL, coalesces motion and -window events, drives those phases, and uses the application host's wait. -Existing menus therefore share the new execution path while their callers are -migrated incrementally. Legacy timer-before-input ordering is retained. - -Completion stops subsequent phase dispatch. Creation callbacks can complete a -screen immediately, and destruction callbacks run once when the host finishes -it. Double execution and finishing a running screen are rejected. A completed -screen can be started again. Application quit remains a distinct result. - -The regression harness uses SDL's dummy software display and explicit timer -values. It covers phase separation, completion from creation/input/timer, -ignored input after completion, quit propagation, reuse, and compatibility. -Run `scons release=1 screen-test` followed by -`build//client/release/libgag/src/ScreenExecutionHarness`. - -An owning screen stack now supplies deferred transitions and completion -callbacks for the campaign selector flow, as described below. Overlay modal -loops, engine scheduling, resumable jobs, and removal of Asyncify are still -required. Calling a legacy child `execute()` from a callback is still blocking; -this API extraction does not claim that all screen callbacks are resumable. - -## Owning stack and first migrated flow - -`ScreenStack` owns screens with `unique_ptr`. Hosts submit an SDL event batch -and a timer sample to `frame`; the stack itself does not poll or sleep. Pushes -are queued and applied at a frame boundary. Requesting a child suspends further -parent input immediately, so the opening input cannot activate the child. -Completion callbacks can inspect the completed screen before it is destroyed; -its parent stays alive. A pending child is cancelled if its parent completes -before admission. Application quit unwinds owned screens without invoking -continuations that could open another flow. Recursive frames are rejected. - -The campaign new/load selector now uses this stack. Selection cancellation -returns to the retained parent; successful selection queues the campaign menu. -`ScreenStack::execute` is a transitional polling host for the current desktop -and Asyncify browser callers. Campaign mission execution now uses `GameSessionScreen`, described below; -other menu families have not yet migrated. This change does -not remove Asyncify or claim callback-safe mission loading. - - -## Incremental engine sessions - -The engine exposes begin, step, draw, delay, and finish session operations. -Hosts supply monotonic millisecond samples and decide when to schedule the next -step. Delay queries never sleep or advance the simulation. Native `run()` drives -these same operations. The regression harness compares simulation checksums -under regular and delayed callback schedules and checks invalid lifecycle calls. - -This is a session boundary, not yet the complete application scheduler: -`GameGUI::step(events, now)` consumes host-supplied input, but can still open -legacy modal dialogs. Its no-argument compatibility wrapper polls SDL. Finishing a -session can still synchronously load a requested save. Presentation preparation and end-game screen creation are shared with the -owned game-session screen. Music loading is still synchronous. These remaining call stacks must migrate -before a callback-only browser host can replace Asyncify. - - -## Ordered gameplay input - -Gameplay owns held-key/modifier state derived from delivered events. Focus loss -clears held keys, mouse dragging, and edge scrolling. Returning focus requires -new input. Building previews and placement use those processed modifiers too. -Mouse buttons update state from their events; motion is dispatched before a -following button or focus event so an old motion cannot arrive after release. - -`Engine::stepSession(now, events)` buffers input until its GUI cadence and never -consumes the host's event queue. The compatibility overload collects SDL events. -The native session harness plants a sentinel in SDL's queue to check this -boundary; the gameplay regression checks held-key scrolling and focus cleanup -with supplied timer samples. Browser visibility pause/resume, full menu input -migration, and nonblocking dialogs remain separate required work. - - -## Campaign session ownership - -Campaign and tutorial menus now queue an owned `GameSessionScreen` after map -initialization. It drives the engine's incremental session API and exposes the -engine's requested delay to the common stack host. It retains the engine while -an end-game screen is on top, so statistics and replay export do not outlive -their game data. Returning from that screen completes the game screen and -refreshes/saves the retained campaign menu. Stack shutdown also saves campaign -progress while suppressing navigation continuations. - -The legacy `Engine::run` shares presentation preparation and end-screen creation -with this path. Screen execution hooks are virtual so game presentation does -not run the menu renderer or translate input coordinates twice. The native -session harness checks the same fixture through this ownership path, and the -browser suite exercises two tutorial start/quit/end-screen/return cycles. - -Map initialization, music loading, requested-save loading, and campaign save -error dialogs are still synchronous. Custom games, replays, editor, and network -menu flows still need to adopt the same ownership path before the browser host -can shed Asyncify. - - -## Custom games and load/replay navigation - -`SinglePlayerFlow` owns navigation alongside the screen stack. It queues custom -setup or save/replay selection, initializes an engine from the selection, and -queues the same `GameSessionScreen` used by campaigns. Finishing a custom game -returns to fresh custom setup, preserving desktop behavior. Command-line -replays share this session ownership path. The old blocking no-argument -`Engine::initCustom` and `initLoadGame` methods are removed; engine initialization -accepts the selected map/player headers or filename directly. - -Custom options and AI descriptions are child screens whose parent remains -alive, including the game-header references edited by the options screen. -Actual map/replay loading and in-session load requests remain synchronous and -must become resumable jobs. Editor/network flows and the outer main-menu loop -remain migration work; browser support still depends on Asyncify. - - -## Shared application host loop - -`Application` owns the screen stack and navigation across menus and single-player -flows. Its `frame(tick, events)` and `delay(now)` are the common native/browser -update interface. Returning from a flow recreates the main menu, including -translated labels after settings changes. The old outer menu switch loop and -unused static main-menu execution entry point are removed. - -Native `ApplicationHost::run` polls SDL and drives that interface until completion. -The browser implementation schedules one callback with `emscripten_async_call` -and queues its successor only when it returns. This also avoids concurrent frames -while a remaining legacy callback suspends through Asyncify. The host releases -all application state before its completion callback destroys global resources; -main does not report a premature browser exit just because scheduling returned. - -The native host harness verifies completion/destruction ordering. Browser tests -cover settings/credits return and application exit, alongside gameplay flows. -Editor/network internals, loaders, and some dialogs remain blocking. The browser -build still uses Asyncify for those paths; scheduled outer execution is not a -claim that the complete runtime migration is finished. - -## Editor navigation and borrowed draft lifetime - -Editor setup, campaign selection, campaign editing, and campaign-map entry -editing now queue child screens. Newly added entries use a draft owned by the -completion callback; accepting the entry appends it to the campaign and displays -its edited name. Existing entries borrow from the retained parent campaign. -The stack destroys a completed/cancelled screen before releasing its completion -callback, so captured resources outlive any screen that borrows them. The native -harness checks normal completion, active cancellation, and cancellation before -admission. - -At this stage, the map editor's own run loop, generation/loading, and save-error -message boxes remained synchronous; the next section records the loop migration. Failed map loading now returns without entering the editor -with invalid map data. The browser regression adds and reopens a campaign entry, -then cancels back through the owning parents. - -## Incremental map editor - -`MapEditorScreen` owns a loaded/generated `MapEdit`. The editor accepts supplied -input, advances editor state/timers, and draws through separate methods; its -old polling/sleeping run methods are removed. The common host applies its 33 ms -cadence. Held keys come from processed events, and focus loss clears scrolling -and active drags. - -Quitting a modified map queues `MessageScreen`, an in-game decision with an -explicit caption-index result. Cancel resumes the retained editor, discard -finishes it, and save opens its existing save interface. No editor call stack is -suspended for this decision. The native fixture exercises cancel/discard; browser -checks generate a uniform map and navigate both decisions through actual input. -Generation, parsing, save I/O, fertility calculation, and remaining nested error -or script dialogs still require resumable/asynchronous migration. - -## Resumable fertility work - -Fertility calculation now exposes a platform-independent `Job`. Seeding, -resource reachability, and the weighting kernel all advance under an explicit -operation budget. Temporary distances and output belong to the job. The map must -remain alive and unchanged during the job; only a ready job may publish results. -Cancellation is destruction of the job, leaving the map unchanged. Final commit -copies the staged values in one pass so rendering never sees partial results. - -The editor owns a `FertilityScreen` child while calculating overlays or preparing -a map save. Its host schedules bounded work and continues accepting cancellation. -Canceling either the save selector or calculation preserves unsaved edits and -cancels any pending quit. A failed file-open is reported through an owned message. -The synchronous adapter remains for old map-format loading; serialization and -browser durability are separate work still to migrate. - -A frozen pre-migration algorithm in the native harness checks exact equality at -three operation budgets, including single-operation calls. Additional assertions -cover monotonic progress, rejected premature commit, cancellation, and publication -only after commit. Browser tests cover save cancellation, completed map writes, -and reload persistence using real controls and read-only file digests. +# 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 index b4bc9802c..430ff66ef 100644 --- a/docs/browser/adr-004-cooperative-loading.md +++ b/docs/browser/adr-004-cooperative-loading.md @@ -1,6 +1,6 @@ # ADR 004: explicit coroutine jobs for nested loading -Status: incremental implementation; loading is not yet fully latency-bounded. +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 @@ -15,6 +15,29 @@ 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 @@ -30,13 +53,13 @@ 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, and individual propagation sweeps. Legacy fertility loading uses the bounded -fertility job. Remaining work includes subdividing large team/player parsing, -building-specific gradients, stream decompression, scripts, and allocation -work; a checkpoint count is not evidence of a maximum frame time. In-session game reload callers still drain synchronously and need owned loading -flows; startup and editor replacement are described below. -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. +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 @@ -54,16 +77,15 @@ 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. This screen is for startup -only, with no active session; replacing a live session requires a separate -transaction because the legacy replay globals are shared. Engine file/replay +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, native network, and in-session -reload callers. Replay indexing, AI initialization, individual parser stages, -serialization, and initial music loading are not yet fully subdivided. Passing -startup cancellation tests does not certify a maximum loading frame duration. +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 @@ -81,8 +103,7 @@ 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. In-session game replacement is still -separate work because engine replay/session globals require different ownership. +replace it and verify that it is unmodified. Live game replacement uses the engine-owned transaction described in ADR 003. ## Gradients during loading diff --git a/docs/browser/adr-005-generation-randomness.md b/docs/browser/adr-005-generation-randomness.md index 332aecd71..4f98c343b 100644 --- a/docs/browser/adr-005-generation-randomness.md +++ b/docs/browser/adr-005-generation-randomness.md @@ -1,6 +1,6 @@ -# ADR 005: generation randomness before cooperative scheduling +# ADR 005: deterministic randomness for cooperative generation -Status: explicit seeds and cooperative editor generation implemented; long helper operations remain. +Status: accepted. Generation previously mixed the synchronized generator with libc `rand`, time-based reseeding, and shared static Perlin lookup tables. Consequently, @@ -25,6 +25,12 @@ 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 @@ -43,9 +49,8 @@ job before the partial editor and restores the prior RNG state. Generation error 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. Other gradient and allocation helpers still contain synchronous work: this does not -yet guarantee a maximum callback duration. Further subdivision and measured -large-map latency gates are required before removing Asyncify. +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 @@ -94,10 +99,9 @@ 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 still -contain synchronous work. -Full generation latency and cancellation bounds remain open until those paths -are subdivided and measured against large-map fixtures. +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 diff --git a/docs/browser/adr-006-webgl2-rendering.md b/docs/browser/adr-006-webgl2-rendering.md index 27c853a03..aa9d3063b 100644 --- a/docs/browser/adr-006-webgl2-rendering.md +++ b/docs/browser/adr-006-webgl2-rendering.md @@ -1,93 +1,69 @@ -# ADR 006: Reuse the 2D GPU renderer for the first WebGL2 backend +# ADR 006: reuse the 2D GPU renderer for WebGL2 -Status: in progress; browser support remains experimental. +Status: accepted. Glob2 already has GPU implementations of its 2D drawing operations. The browser -build now compiles those implementations with the pinned Emscripten SDK's legacy -OpenGL compatibility layer, targeting WebGL2 exclusively. Sprites, fonts, -terrain, overlays and primitives are drawn by the GPU. This does not upload a -software-rendered screen once per frame. - -This is a deliberate delivery compromise: preserve the shared drawing interface -and desktop behavior while establishing working GPU rendering and regression -coverage. It is not a bespoke modern shader renderer. The compatibility layer -adds overhead and uses SDK internals during context restoration. SDK upgrades -must run the rendering and recovery suite. A later direct GLES3 implementation -can replace that layer behind the same interface if benchmarks or browser -compatibility require it. +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 selects WebGL2 when requested with `?renderer=webgl2` and -available. Software remains the default, and `?renderer=software` explicitly -selects it. Lack of WebGL2 falls back to software. -The page retains only the game canvas. Browser builds use GLES-compatible headers -and flags; native builds retain their existing OpenGL dependencies. - -The shared renderer retains CPU surfaces, including sprite atlases, as the -source for texture restoration. Texture names start at zero and cannot be used -before allocation. Atlas coordinates use the atlas's actual normalization, -including the power-of-two texture path used by the browser. - -Viewport changes use the existing frame-boundary event. Updating the drawable, -projection, clipping and screen layouts leaves the simulation and camera intact. -One CSS pixel remains one drawing-buffer pixel. - -On context loss the browser host suspends application execution through the same -lifecycle transition used for hidden tabs. On restoration it recreates the -compatibility shaders and streaming buffers, then asks the renderer to rebuild -textures and projection from retained CPU state. The screen stack resets its -timing baseline before execution resumes. Game state is not serialized or -reloaded. Context loss does not provide coordinated multiplayer suspension yet. - -## Validation and remaining release gates - -`browser/tests/rendering.spec.js` exercises real custom-game controls, checks the -actual WebGL2 context and drawing-buffer dimensions, checks software selection, -and loses/restores the real context repeatedly during a match. Multiplayer -correctness fixtures allow extra time for two clients sharing a headless software -GPU, and use explicit screenshots instead of continuous trace readback. These -timeouts do not establish the controlled performance gate. Viewport tests -inspect presented screenshots, because WebGL may clear its drawing buffer after -presentation. - -Complete cross-browser single-player and viewport coverage, visual review, -native regression checks and controlled performance baselines are required. -Context loss during settings, the editor and its confirmation dialog is also -covered across all three browser engines, with retained controls verified after -restoration. Context loss during other legacy blocking dialogs/loading, unrecoverable GPU failure -UI, and fallback after an unexpected context-creation failure still need release -qualification. This milestone does not make the full platform stable. - - -## Performance gate remains open - -A six-second local comparison on Apple M3 with Chromium's Metal backend reached -approximately 25 simulation ticks/second for both WebGL2 and software when rerun -without this task's other browser tests. An earlier comparison during concurrent -test activity measured about 18 for WebGL2 and 25 for software. These are sanity -checks, not the controlled release benchmark matrix; they demonstrate why the -reference environment must be controlled. The latest readings are recorded in -`browser/benchmarks/apple-m3-sanity.json`. - -An earlier WebGL single-player run exceeded deadlines in editor-load and -startup-cancellation scenarios under headless Chromium. A fresh run on the -current build passes all 22 Chromium single-player, viewport and rendering -scenarios, including those two cases, without changing their deadlines. The -complete cross-browser GPU suite and controlled performance fixtures remain -release gates; software remains the default. - -Run the complete existing suite against WebGL using -`GLOB2_TEST_RENDERER=webgl2 npx playwright test` from `browser/`. The dedicated -rendering scenarios always exercise WebGL2, even in the default suite. Renderer -optimization and a passing complete WebGL suite are required before changing the -default. The maintained interfaces and resource recovery added here remain useful -if the SDK compatibility layer needs replacement. - -The comparison can be repeated against a locally served build with -`GLOB2_TEST_URL=http://127.0.0.1:8770 GLOB2_ANGLE=metal node benchmarks/rendering.cjs webgl2` -and then `software`, from `browser/`. Omit `GLOB2_ANGLE` to use Chromium's default -hardware backend. This opens a dedicated browser, selects the first custom map -through the menu and samples six seconds after warmup. Record the reported GPU -and eliminate competing workloads for meaningful comparisons; it does not yet -supply fixed-seed small/typical/late-game release fixtures. +The browser host selects WebGL2 with `?renderer=webgl2` when it is available. +Software remains the default, and `?renderer=software` selects it explicitly. +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 index 4951ed32b..1349c9c82 100644 --- a/docs/browser/gateway.md +++ b/docs/browser/gateway.md @@ -4,8 +4,11 @@ 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. Upgraded protocol negotiation, account migration, invitation rooms and coordinated recovery remain release -gates; complete cross-play matches are not yet certified. +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 @@ -102,13 +105,16 @@ 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 250 simulation ticks; its checksums are compared with the browser -at the negotiated command cadence. This uses the native game implementation, +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 and is enabled nightly. Native +`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. @@ -119,8 +125,10 @@ callback driven and does not create a worker thread or use Asyncify itself. ## Native secure gateway connections -Desktop client builds now require OpenSSL development headers/libraries alongside -Boost. Headless lobby/router builds and Emscripten do not use this dependency. +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. diff --git a/docs/browser/implementation.md b/docs/browser/implementation.md index fd002096e..955c5ef59 100644 --- a/docs/browser/implementation.md +++ b/docs/browser/implementation.md @@ -1,51 +1,69 @@ -# Browser platform delivery - -The browser target is under development, not a supported release. The release -requires desktop browser single-player support, matching-release native -cross-play, YOG invitation rooms with guests and accounts, 120-second coordinated -reconnect, and self-hosted distribution. Mobile, voice chat, rankings, cloud saves, -late joining, and backend-restart match recovery are excluded. - -## Architecture contracts - -- SCons is the source of truth for every toolchain. Source manifests are plain - Python tuples; target/configuration identities own all generated outputs. -- Game and AI code must not call browser APIs. Platform implementations own - scheduling, graphics, storage, audio activation, and transport. -- YOG owns identities, rooms and match lifecycle. A fixed-backend WebSocket - gateway owns transport only. Simulation remains deterministic client lockstep. -- Durable persistence acknowledgment must follow successful storage completion. -- Resize is an application event applied between frames, never a reload. -- A reconnect checkpoint must include simulation and network continuation state, - exclude another player's local UI state, and pass checksum verification. - -## Release gates - -- [ ] Build coexistence across native client, lobby, router, gateway, and web -- [ ] Explicit application/screen scheduling without Asyncify -- [ ] WebGL2 rendering with context restoration and software fallback -- [ ] Live resize and focus/visibility lifecycle -- [ ] Transactional browser storage with import/export and failure handling -- [ ] Browser and native secure transports; compatible protocol handshake -- [ ] Invitation rooms, guests, optional accounts and credential migration -- [ ] Pause barriers, checkpoints and refresh recovery -- [ ] Self-hosting, immutable releases, backups, health checks and metrics -- [ ] Browser, native, deployment, determinism and fault-injection test gates +# 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, but PATH must belong to the same identity. Mixing identities is an -error. `identity.json` records ownership; generated configuration is -`include/glob2/BuildConfig.h`. Options are explicit on every invocation; emitted -`options.py`/`options.json` records inputs and is not silently loaded. - -Existing commands such as `scons release=1`, `scons server=1`, and -`scons mingwcross=1` keep selecting the same kinds of builds. Their default -artifact paths are now isolated. For example, the macOS release client is -`build/darwin/client/release/src/glob2`. Browser output is -`build/emscripten/client/release/index.html`. - -The experiment compatibility command `python3 browser/build.py` delegates to -SCons. Emscripten 4.0.15 and its checksum-verified ports (including Boost 1.83) -are selected independently of installed native development libraries. +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 index f0a7a3edd..99d7de6fb 100644 --- a/docs/browser/storage.md +++ b/docs/browser/storage.md @@ -117,3 +117,65 @@ 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 index 3c358797b..2b83b2b5f 100644 --- a/docs/browser/viewport.md +++ b/docs/browser/viewport.md @@ -13,38 +13,26 @@ 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 so time spent -behind the minimum-size notice does not become simulation catch-up work. +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. Below 800 by 600, an in-game notice covers the retained screen; -restoring a usable size resumes that screen. The page has no permanent wrapper -controls. +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, the minimum-size -notice, editor discard dialogs, and high-density displays. Screenshots capture the -resized game menu and the notice. The native engine-session harness checks actual +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. -## Remaining release work - -This implementation covers scheduled application screens with software and -WebGL2 rendering. Legacy blocking multiplayer flows defer application resize handling; -those flows must migrate to the screen stack. WebGL2 context restoration is -covered by the rendering suite; its ownership and remaining qualification are -recorded in [ADR 006](adr-006-webgl2-rendering.md). Camera coordinates retain the existing -whole-tile precision. Broader selection/dragging and nested-dialog coverage is -still needed, along with browser video-preference policy, hidden-tab lifecycle, -and coordinated multiplayer suspension. Very narrow notice layouts and allocation -failure messages need further work. These limitations keep the platform -experimental; this document does not qualify the full release requirements. - ## Visibility lifecycle The browser retains visibility edges until the application consumes them, even @@ -55,5 +43,36 @@ 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. -Firefox/WebKit real-window qualification and coordinated multiplayer suspension -remain required. +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/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 index f0633eebb..683cf4f22 100644 --- a/libgag/include/ApplicationHost.h +++ b/libgag/include/ApplicationHost.h @@ -20,8 +20,8 @@ class Loop // Native hosts return after completion; browser hosts return after scheduling. void run(std::unique_ptr loop, std::function complete); -// Transitional wait for legacy modal loops. The browser implementation yields -// through Asyncify until these loops become resumable application screens. +// 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. @@ -59,5 +59,7 @@ void screenChanged(const char* name); void importChanged(const char* state); void simulationAdvanced(std::uint32_t tick); void matchFrame(bool paused); +// Read-only presentation diagnostic for the active multiplayer room. +void roomReady(bool canStart); void exited(int result); } diff --git a/libgag/src/ApplicationHost.cpp b/libgag/src/ApplicationHost.cpp index 9ce4c92f5..05e2dd701 100644 --- a/libgag/src/ApplicationHost.cpp +++ b/libgag/src/ApplicationHost.cpp @@ -38,5 +38,6 @@ void importChanged(const char*) {} void screenChanged(const char*) {} void simulationAdvanced(std::uint32_t) {} void matchFrame(bool) {} +void roomReady(bool) {} void exited(int) {} } diff --git a/libgag/src/DrawableSurface.cpp b/libgag/src/DrawableSurface.cpp index 82cb388c8..20919ec9d 100644 --- a/libgag/src/DrawableSurface.cpp +++ b/libgag/src/DrawableSurface.cpp @@ -20,15 +20,11 @@ namespace GAGCore #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; } 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/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 index 35333d448..b9cec6d5c 100644 --- a/scons/build_layout.py +++ b/scons/build_layout.py @@ -23,11 +23,16 @@ def build_identity(arguments, host=None): if target == 'web': toolchain = 'emscripten' mode = 'profile' if enabled(arguments.get('profile', 0)) else ('release' if enabled(arguments.get('release', 0)) else 'debug') - return {'target': target, 'role': role, 'toolchain': toolchain, 'mode': mode} + 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): - return Path('build') / identity['toolchain'] / identity['role'] / identity['mode'] + 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): diff --git a/scons/sources.py b/scons/sources.py index 2298e17ba..84d10c1ab 100644 --- a/scons/sources.py +++ b/scons/sources.py @@ -114,7 +114,6 @@ 'EngineRun.cpp', 'FertilityCalculator.cpp', 'FertilityScreen.cpp', - 'FertilityCalculatorDialog.cpp', 'Game.cpp', 'Game_orders.cpp', 'Game_io.cpp', @@ -180,6 +179,7 @@ 'LANFindScreen.cpp', 'LANGameInformation.cpp', 'LANMenuScreen.cpp', + 'LANSessionScreen.cpp', 'MainMenuScreen.cpp', 'map/Map.cpp', 'map/gradient/MapGradientArea.cpp', @@ -322,7 +322,6 @@ 'WinningConditions.cpp', 'yog/YOGAfterJoinGameInformation.cpp', 'yog/YOGClientBlockedList.cpp', - 'yog/YOGClientBringup.cpp', 'yog/YOGClientChatChannel.cpp', 'yog/YOGClientChatListener.cpp', 'yog/YOGClientCommandManager.cpp', diff --git a/scons/web_build.py b/scons/web_build.py index efcf150d3..bf5ca896b 100644 --- a/scons/web_build.py +++ b/scons/web_build.py @@ -50,11 +50,11 @@ def build_web(directory, identity, arguments): 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', - '-sASYNCIFY', '-sASYNCIFY_STACK_SIZE=1048576', '-sALLOW_MEMORY_GROWTH', + '-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'] + PORTS) + '--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")}' @@ -72,7 +72,7 @@ def prepare_ports(target, source, env): 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/toolchain.json']) + 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) diff --git a/src/Application.cpp b/src/Application.cpp index 1c86a37c0..6ad6b92d5 100644 --- a/src/Application.cpp +++ b/src/Application.cpp @@ -1,5 +1,6 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include "Application.h" +#include "FrontendTheme.h" #include #include #include "GlobalContainer.h" @@ -13,23 +14,73 @@ #include "LANMenuScreen.h" #include "YOGLoginScreen.h" #include "YOGClient.h" +#include +#include namespace { -class MinimumViewportScreen : public GAGGUI::Screen +// 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: - void onAction(GAGGUI::Widget*, GAGGUI::Action, int, int) override {} - void paint() override { - GAGGUI::Screen::paint(); - auto* font = GAGCore::Toolkit::getFont("standard"); - const auto text = GAGCore::Toolkit::getStringTable()->getString("[browser window too small]"); - getSurface()->drawString(std::max(8, (getW() - font->getStringWidth(text)) / 2), - std::max(8, getH()/2 - 10), font, text); + 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() : screens(*globalContainer->gfx), singlePlayer(screens) +Application::Application() + : frontend(std::make_unique()), screens(*globalContainer->gfx), + shutdownScreens(*globalContainer->gfx), singlePlayer(screens) { if (GAGCore::ApplicationHost::storageRestoreFailed()) { auto& strings = *GAGCore::Toolkit::getStringTable(); @@ -40,6 +91,8 @@ Application::Application() : screens(*globalContainer->gfx), singlePlayer(screen else mainMenu(); } +Application::~Application() = default; + void Application::mainMenu() { // Rebuild translated labels after returning from settings. @@ -64,9 +117,9 @@ void Application::choose(int choice) 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()); break; + case MainMenuScreen::MULTIPLAYERS_LAN: screens.push(std::make_unique(screens)); break; case MainMenuScreen::MULTIPLAYERS_YOG: - screens.push(std::make_unique(std::make_shared())); break; + screens.push(std::make_unique(screens, std::make_shared())); break; case MainMenuScreen::QUIT: screens.stop(); break; } } @@ -81,18 +134,24 @@ bool Application::frame(std::uint32_t tick, const std::vector& events const int oldWidth = globalContainer->gfx->getW(), oldHeight = globalContainer->gfx->getH(); if (globalContainer->gfx->resizeViewport(width, height)) { screens.viewportResized(oldWidth, oldHeight, width, height); - if ((width < 800 || height < 600) && !minimumNotice) { - auto notice = std::make_unique(); - minimumNotice = notice.get(); - screens.push(std::move(notice), [this](GAGGUI::Screen&, int) { minimumNotice = nullptr; }); - } else if (width >= 800 && height >= 600 && minimumNotice) { - minimumNotice->endExecute(0); - } + 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) return false; + if (screens.result() == GAGGUI::Screen::QUIT_APPLICATION) { + quitting = true; + shutdownScreens.push(std::make_unique()); + return true; + } mainMenu(); } return true; @@ -102,5 +161,5 @@ std::uint32_t Application::delay(std::uint32_t now) { if (hidden) return 100; const auto elapsed = static_cast(now - lastFrame); - return screens.delay(now, elapsed < 40 ? 40 - elapsed : 0); + return (quitting ? shutdownScreens : screens).delay(now, elapsed < 40 ? 40 - elapsed : 0); } diff --git a/src/Application.h b/src/Application.h index 66bbe2e7a..da87d1508 100644 --- a/src/Application.h +++ b/src/Application.h @@ -4,19 +4,24 @@ #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; - GAGGUI::Screen* minimumNotice = nullptr; // Owned by screens. bool hidden = false; + bool quitting = false; void mainMenu(); void choose(int choice); }; diff --git a/src/CampaignEditor.cpp b/src/CampaignEditor.cpp index 4475b4fc4..788615099 100644 --- a/src/CampaignEditor.cpp +++ b/src/CampaignEditor.cpp @@ -6,7 +6,6 @@ #include "StringTable.h" #include "ChooseMapScreen.h" #include "GlobalContainer.h" -#include "GUIMessageBox.h" #include #include #include "GUICheckList.h" @@ -35,6 +34,9 @@ CampaignEditor::CampaignEditor(const std::string& name, GAGGUI::ScreenStack& scr 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, GAGGUI::ScreenStack& scr 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) { @@ -123,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 10e88d6e8..bd0a64801 100644 --- a/src/CampaignEditor.h +++ b/src/CampaignEditor.h @@ -5,7 +5,9 @@ #include "Glob2Screen.h" #include "Campaign.h" +#include "FrontendTheme.h" #include +#include #include "GUIText.h" #include "GUIButton.h" #include "GUIList.h" @@ -18,6 +20,7 @@ class CampaignEditor : public Glob2Screen public: 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, @@ -27,6 +30,7 @@ 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 @@ -47,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(); @@ -90,4 +98,3 @@ class CampaignMapEntryEditor : public Glob2Screen /// The label for isUnlocked Text *isUnlockedLabel; }; - diff --git a/src/CampaignMenuScreen.h b/src/CampaignMenuScreen.h index 3578c858b..dece0fcee 100644 --- a/src/CampaignMenuScreen.h +++ b/src/CampaignMenuScreen.h @@ -47,7 +47,7 @@ class CampaignMenuScreen : public Glob2Screen /// Title of the screen Text* title; - /// The exit to menuscreen button + /// The exit to menu screen button TextButton* exitButton; /// The "start mission" button Button* startMission; diff --git a/src/ChooseMapScreen.cpp b/src/ChooseMapScreen.cpp index c05b030da..15dd492a8 100644 --- a/src/ChooseMapScreen.cpp +++ b/src/ChooseMapScreen.cpp @@ -9,7 +9,6 @@ #include "GlobalContainer.h" #include #include -#include #include #include #include @@ -138,6 +137,16 @@ void ChooseMapScreen::onAction(Widget *source, Action action, int par1, int par2 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()); @@ -175,21 +184,15 @@ 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)) diff --git a/src/EndGameScreen.cpp b/src/EndGameScreen.cpp index f5f4b2fcd..989ee4cd4 100644 --- a/src/EndGameScreen.cpp +++ b/src/EndGameScreen.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include "GlobalContainer.h" #include "Team.h" #include "TeamDisplay.h" @@ -525,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(); - GAGCore::ApplicationHost::wait(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 73fceeb82..568002991 100644 --- a/src/Engine.cpp +++ b/src/Engine.cpp @@ -24,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 diff --git a/src/Engine.h b/src/Engine.h index bbb083d59..47cbced80 100644 --- a/src/Engine.h +++ b/src/Engine.h @@ -68,6 +68,7 @@ class Engine /// 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(); @@ -95,6 +96,11 @@ class Engine 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(); @@ -215,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 4a13334f6..a3e840df1 100644 --- a/src/EngineInit.cpp +++ b/src/EngineInit.cpp @@ -77,15 +77,22 @@ GAGCore::CooperativeTask Engine::initCustomTask(std::string 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()); @@ -100,7 +107,7 @@ int Engine::initMultiplayer(std::shared_ptr multiplayerGame, st net->setNetworkInfo(multiplayerGame->getGameHeader().getOrderRate(), client->getGameConnection()); - return Engine::EE_NO_ERROR; + co_return true; } diff --git a/src/EngineRun.cpp b/src/EngineRun.cpp index db2807e8c..8e435a61d 100644 --- a/src/EngineRun.cpp +++ b/src/EngineRun.cpp @@ -8,6 +8,7 @@ #include "ChecksumSidecar.h" #include "DatasetWriter.h" #include "Engine.h" +#include #include "EngineTiming.h" #include "Game.h" #include "GlobalContainer.h" @@ -452,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 @@ -562,7 +532,7 @@ bool Engine::stepSession(Uint64 now, const std::vector& events) return gui.isRunning; } -bool Engine::finishSession() +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"); @@ -571,9 +541,19 @@ bool Engine::finishSession() teardownSession(); session.reset(); sessionInput.clear(); - bool restart = false; - prepareNextGameSession(restart); - return restart; + 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}; +} + +bool Engine::finishSession() +{ + const auto request = finishSessionForHost(); + if (!request) return false; + return (request->replay ? loadReplay(request->filename) : initCustom(request->filename)) == EE_NO_ERROR; } void Engine::runOneGameSession(bool& doRunOnceAgain) diff --git a/src/FertilityCalculator.cpp b/src/FertilityCalculator.cpp index 209cdfdaa..acd1f830e 100644 --- a/src/FertilityCalculator.cpp +++ b/src/FertilityCalculator.cpp @@ -66,6 +66,8 @@ namespace FertilityCalculator 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) {} @@ -99,13 +101,19 @@ namespace FertilityCalculator } } else { if (s.cursor == s.size) { s.phase = State::Ready; continue; } - const auto [x, y] = s.coordinate(); - if (!s.map.isGrass(x, y) || !s.distance[s.map.coordToIndex(x, y)]) { ++s.cursor; 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(x + nx, y + ny)) s.total += kernel[s.kernelOffset]; + if (s.map.isWater(s.kernelX + nx, s.kernelY + ny)) s.total += kernel[s.kernelOffset]; if (++s.kernelOffset == kKernelSide * kKernelSide) { - s.fertility[s.map.coordToIndex(x, y)] = s.total; + s.fertility[s.kernelIndex] = s.total; s.maximum = std::max(s.maximum, s.total); s.total = 0; s.kernelOffset = 0; ++s.cursor; } diff --git a/src/FertilityCalculatorDialog.cpp b/src/FertilityCalculatorDialog.cpp deleted file mode 100644 index 18b50af31..000000000 --- a/src/FertilityCalculatorDialog.cpp +++ /dev/null @@ -1,80 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// Copyright (C) 2007-2008 Bradley Arsenault - -#include -#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() -{ -#ifdef __EMSCRIPTEN__ - FertilityCalculator::compute(map, [this](float p) { - progressFraction.store(p, std::memory_order_relaxed); - refreshProgressDisplay(); - dispatchPaint(); - GAGCore::ApplicationHost::wait(1); - }); - computeDone.store(true, std::memory_order_release); -#else - 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(); -#endif -} - -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/FileImport.cpp b/src/FileImport.cpp index 27259d0c7..f22d4664b 100644 --- a/src/FileImport.cpp +++ b/src/FileImport.cpp @@ -80,6 +80,17 @@ CooperativeTask FileImport::validate() { 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. 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/GameLoadScreen.cpp b/src/GameLoadScreen.cpp index d2eba9223..1e13e02ab 100644 --- a/src/GameLoadScreen.cpp +++ b/src/GameLoadScreen.cpp @@ -8,14 +8,17 @@ #include #include GameLoadScreen::GameLoadScreen(Initializer initialize, GAGCore::CooperativeSlice slice) - : slice(std::move(slice)), previousRng(getSyncRandState()), engine(std::make_unique()) + : 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(*engine)); + task.emplace(initialize(*this->engine)); } GameLoadScreen::~GameLoadScreen() { diff --git a/src/GameLoadScreen.h b/src/GameLoadScreen.h index 682b4ad9b..9c9b5a7fb 100644 --- a/src/GameLoadScreen.h +++ b/src/GameLoadScreen.h @@ -7,12 +7,14 @@ #include class Engine; namespace GAGGUI { class Text; } -// Startup loading only: there must be no active engine/session. +// 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; diff --git a/src/GameSessionScreen.cpp b/src/GameSessionScreen.cpp index 68d9d7137..16836210e 100644 --- a/src/GameSessionScreen.cpp +++ b/src/GameSessionScreen.cpp @@ -1,6 +1,12 @@ // 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) @@ -8,7 +14,7 @@ GameSessionScreen::GameSessionScreen(GAGGUI::ScreenStack& stack, std::unique_ptr { if (!this->engine) throw std::invalid_argument("A game screen requires an initialized engine"); } -GameSessionScreen::~GameSessionScreen() { if (started) engine->restoreCursor(); } +GameSessionScreen::~GameSessionScreen() { if (started && engine) engine->restoreCursor(); } void GameSessionScreen::updateExecution(Uint32 tick) { @@ -29,9 +35,28 @@ void GameSessionScreen::updateExecution(Uint32 tick) input.clear(); nextTick = clock + engine->sessionDelay(clock); if (!running) { - if (engine->finishSession()) { - engine->beginSession(clock); - nextTick = clock; + 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; @@ -62,14 +87,14 @@ Uint32 GameSessionScreen::executionDelay(Uint32 now, Uint32 fallback) void GameSessionScreen::viewportResized(int oldWidth, int oldHeight, int width, int height) { - engine->viewportResized(oldWidth, oldHeight, width, height); + if (engine) engine->viewportResized(oldWidth, oldHeight, width, height); input.clear(); resetClock = true; } void GameSessionScreen::suspendExecution() { - engine->suspendInput(); + if (engine) engine->suspendInput(); input.clear(); resetClock = true; } diff --git a/src/GameSessionScreen.h b/src/GameSessionScreen.h index 575f50e3c..a8f533501 100644 --- a/src/GameSessionScreen.h +++ b/src/GameSessionScreen.h @@ -2,12 +2,13 @@ #pragma once #include #include +#include "FrontendTheme.h" #include #include class Engine; // Retains the initialized engine through gameplay and the end-game screen. -// Loading remains a separate migration concern. +// In-game load/replay requests transfer the finalized engine to a loader child. class GameSessionScreen : public GAGGUI::Screen { public: @@ -21,6 +22,7 @@ class GameSessionScreen : public GAGGUI::Screen void drawExecution() override; Uint32 executionDelay(Uint32 now, Uint32 fallback) override; private: + FrontendScope theme{false}; GAGGUI::ScreenStack& stack; std::unique_ptr engine; std::vector input; diff --git a/src/Game_editor.cpp b/src/Game_editor.cpp index ccb5c024d..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 diff --git a/src/Game_io.cpp b/src/Game_io.cpp index 4a459be0b..d1e015f88 100644 --- a/src/Game_io.cpp +++ b/src/Game_io.cpp @@ -33,7 +33,6 @@ #include "Brush.h" #include "Bullet.h" #include "FertilityCalculator.h" -#include "FertilityCalculatorDialog.h" #include "ReplayWriter.h" 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/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/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 50b29077f..0310f1198 100644 --- a/src/MainMenuScreen.cpp +++ b/src/MainMenuScreen.cpp @@ -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; @@ -186,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"}) diff --git a/src/MainMenuScreen.h b/src/MainMenuScreen.h index 6e2415b20..666e087b4 100644 --- a/src/MainMenuScreen.h +++ b/src/MainMenuScreen.h @@ -32,10 +32,13 @@ class MainMenuScreen:public Glob2Screen void onAction(Widget *source, Action action, int par1, int par2) override; 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.h b/src/MapEditorScreen.h index 187e20960..9a3884e2a 100644 --- a/src/MapEditorScreen.h +++ b/src/MapEditorScreen.h @@ -2,6 +2,7 @@ #pragma once #include #include +#include "FrontendTheme.h" #include class MapEdit; class MapEditorScreen : public GAGGUI::Screen @@ -17,6 +18,7 @@ class MapEditorScreen : public GAGGUI::Screen void drawExecution() override; Uint32 executionDelay(Uint32 now, Uint32) override; private: + FrontendScope theme{false}; GAGGUI::ScreenStack& screens; std::unique_ptr editor; std::vector input; 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/MultiplayerGame.cpp b/src/MultiplayerGame.cpp index 2d6403578..b7c2730cb 100644 --- a/src/MultiplayerGame.cpp +++ b/src/MultiplayerGame.cpp @@ -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/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 8b187b256..11645ae55 100644 --- a/src/SConscript +++ b/src/SConscript @@ -3,6 +3,8 @@ 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") @@ -117,10 +119,11 @@ if not env['server']: peer_sources += local.Object('NativeMultiplayerPeer.o', '#test/NativeMultiplayerPeer.cpp') peer_test = local.Program('native-multiplayer-peer', peer_sources) local.Alias('transport-test', peer_test) - 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) + 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") 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..c0205402f 100644 --- a/src/SettingsScreen.cpp +++ b/src/SettingsScreen.cpp @@ -97,6 +97,16 @@ bool SettingsScreen::persist() if(keyboardDirty[0] && gameKeys.saveKeyboardLayout()) keyboardDirty[0]=false; if(keyboardDirty[1] && 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; + } + } return !failed; } void SettingsScreen::finishInteraction() { dragging.clear(); if(settingsDirty || keyboardDirty[0] || keyboardDirty[1]) persist(); } @@ -127,6 +137,20 @@ 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; + // 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(); + } + } } bool SettingsScreen::displayConfirmationPending() const { return modal==Modal::Display; } bool SettingsScreen::restartRequired() const diff --git a/src/SettingsScreen.h b/src/SettingsScreen.h index 3ddf8f3a0..26f363206 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, @@ -76,6 +78,9 @@ 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; bool displayError=false; Settings previousDisplay; KeyboardManager gameKeys, editorKeys; diff --git a/src/TorusView.cpp b/src/TorusView.cpp index 9b5a34536..85a62c4c6 100644 --- a/src/TorusView.cpp +++ b/src/TorusView.cpp @@ -7,6 +7,10 @@ #include #include +#if defined(HAVE_OPENGL) && !defined(__EMSCRIPTEN__) +#define GLOB2_TORUS_OPENGL +#endif + namespace { float clamp(float x, float a, float b) { return std::max(a, std::min(b, x)); } @@ -35,7 +39,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..4e05a1753 100644 --- a/src/TorusViewRender.cpp +++ b/src/TorusViewRender.cpp @@ -12,7 +12,11 @@ #include #include #include -#ifdef HAVE_OPENGL +#if defined(HAVE_OPENGL) && !defined(__EMSCRIPTEN__) +#define GLOB2_TORUS_OPENGL +#endif + +#ifdef GLOB2_TORUS_OPENGL #ifdef __APPLE__ #include #include @@ -38,7 +42,7 @@ 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 struct SkyPoint { float x, y, z, brightness; @@ -165,7 +169,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 +202,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() || @@ -257,7 +261,7 @@ 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); glPushAttrib(GL_TEXTURE_BIT); @@ -291,7 +295,7 @@ void TorusView::updateClouds(int time) 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(); 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/gui/GameGUI.cpp b/src/gui/GameGUI.cpp index 1f797c610..d6ae71285 100644 --- a/src/gui/GameGUI.cpp +++ b/src/gui/GameGUI.cpp @@ -293,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 02359896b..f725775a0 100644 --- a/src/gui/GameGUI.h +++ b/src/gui/GameGUI.h @@ -512,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; @@ -700,4 +698,3 @@ class GameGUI void viewportChanged(int oldViewportX, int viewportX, int oldViewportY, int viewportY); }; - diff --git a/src/gui/GameGUIPersistence.cpp b/src/gui/GameGUIPersistence.cpp index d5fec4975..53376c136 100644 --- a/src/gui/GameGUIPersistence.cpp +++ b/src/gui/GameGUIPersistence.cpp @@ -178,7 +178,7 @@ void GameGUI::viewportResized(int oldWidth, int oldHeight, int width, int height 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; - moveParticles(oldX, viewportX, oldY, viewportY); + viewportChanged(oldX, viewportX, oldY, viewportY); if (gameMenuScreen) gameMenuScreen->viewportResized(oldWidth, oldHeight, width, height); } diff --git a/src/gui/GameGUIStep.cpp b/src/gui/GameGUIStep.cpp index b34c40bf0..f8510962a 100644 --- a/src/gui/GameGUIStep.cpp +++ b/src/gui/GameGUIStep.cpp @@ -131,7 +131,7 @@ void GameGUI::step(const std::vector& events, Uint64 now) bool wasMouseMotion=false; int oldMouseMapX = -1, oldMouseMapY = -1; // hopefully the values here will never matter - // Process host-supplied events in their original order; coalesce only mouse motion. + // we get all pending events but for mouse motion we only keep the last one for (auto event : events) { GAGCore::GraphicContext::translateMouseEvent(&event); 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 2b421990a..b553a1ca4 100644 --- a/src/map/Map.h +++ b/src/map/Map.h @@ -101,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 @@ -831,4 +841,3 @@ class Map GAGCore::CooperativeTask oldMakeIslandsMapTask(MapGenerationDescriptor &descriptor); }; - 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 cb0d2fb76..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) 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/MapEditDelegate.cpp b/src/map/edit/MapEditDelegate.cpp index 2bddc40aa..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) 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/MapEditIO.cpp b/src/map/edit/MapEditIO.cpp index 4312c1cee..f7d9910aa 100644 --- a/src/map/edit/MapEditIO.cpp +++ b/src/map/edit/MapEditIO.cpp @@ -15,7 +15,6 @@ #include "Unit.h" #include "UnitType.h" #include "Utilities.h" -#include "FertilityCalculatorDialog.h" #include "GUIMessageBox.h" #include "SDLCompat.h" diff --git a/src/map/generator/Generator.cpp b/src/map/generator/Generator.cpp index f397e3e1e..c42ab7605 100644 --- a/src/map/generator/Generator.cpp +++ b/src/map/generator/Generator.cpp @@ -26,7 +26,7 @@ bool MapGenerator::generateMap(Game& game, MapGenerationDescriptor& descriptor, GAGCore::CooperativeTask MapGenerator::generateMapTask(Game& game, MapGenerationDescriptor& descriptor, Uint32 seed) { co_await GAGCore::CooperativeTask::checkpoint("[Generating map]"); - if (descriptor.wDec < 4 || descriptor.wDec >= 16 || descriptor.hDec < 4 || descriptor.hDec >= 16 || + 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; @@ -475,4 +475,3 @@ GAGCore::CooperativeTask MapGenerator::computeIslesTask(Game& game, MapGeneratio } - 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/MapRandom.cpp b/src/map/generator/MapRandom.cpp index e6bae3324..bbc9b175e 100644 --- a/src/map/generator/MapRandom.cpp +++ b/src/map/generator/MapRandom.cpp @@ -12,7 +12,7 @@ #include "Map.h" #include "Utilities.h" -/// This random map generator generates a heightfield and then choses levels at which to draw the line between water, sand, gras and sand again (desert) +/// 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(); diff --git a/src/map/io/MapHeader.cpp b/src/map/io/MapHeader.cpp index d942b9b5b..4318c417a 100644 --- a/src/map/io/MapHeader.cpp +++ b/src/map/io/MapHeader.cpp @@ -3,8 +3,9 @@ #include "Version.h" #include "MapHeader.h" -#include "Game.h" #include +#include +#include #include "FileManager.h" #include diff --git a/src/map/io/MapIO.cpp b/src/map/io/MapIO.cpp index 6d21899fe..6c98eefed 100644 --- a/src/map/io/MapIO.cpp +++ b/src/map/io/MapIO.cpp @@ -48,8 +48,7 @@ try // 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) + if (!supportedDimensions(wDec, hDec)) co_return false; w = 1< 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/NetTransport.cpp b/src/net/NetTransport.cpp index 79c73b497..621d69296 100644 --- a/src/net/NetTransport.cpp +++ b/src/net/NetTransport.cpp @@ -1,12 +1,15 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include "NetTransport.h" +#ifdef HAVE_CONFIG_H +#include +#endif #include #include #include #include #include -#ifndef YOG_SERVER_ONLY +#if !defined(YOG_SERVER_ONLY) && defined(GLOB2_NATIVE_WSS) std::unique_ptr makeWssTransport(); #endif @@ -105,7 +108,15 @@ class NativeTransport final : public NetTransport { public: void open(const std::string& address, uint16_t port) override { close(); - selected = address.rfind("wss://", 0) == 0 ? makeWssTransport() : std::make_unique(); + 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(); } diff --git a/src/net/message/AuthMessages.cpp b/src/net/message/AuthMessages.cpp index 95563763b..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) { diff --git a/src/net/message/AuthMessages.h b/src/net/message/AuthMessages.h index 35898df6c..87579f339 100644 --- a/src/net/message/AuthMessages.h +++ b/src/net/message/AuthMessages.h @@ -44,10 +44,12 @@ class NetSendServerInformation : public NetMessage YOGLoginPolicy getLoginPolicy() const; YOGGamePolicy getGamePolicy() const; YOGPlayerID getPlayerID() const; + Uint16 getNetVersion() const; private: YOGLoginPolicy loginPolicy; YOGGamePolicy gamePolicy; YOGPlayerID playerID; + Uint16 netVersion; }; /// Client -> server login attempt with username and password. diff --git a/src/render/GameRender.cpp b/src/render/GameRender.cpp index 09067a7eb..916b74f53 100644 --- a/src/render/GameRender.cpp +++ b/src/render/GameRender.cpp @@ -25,7 +25,6 @@ #include "Brush.h" #include "DynamicClouds.h" -#include "FertilityCalculatorDialog.h" #include "GameRenderInternal.h" diff --git a/src/render/GameRenderBuildings.cpp b/src/render/GameRenderBuildings.cpp index 7c35804aa..8564560a2 100644 --- a/src/render/GameRenderBuildings.cpp +++ b/src/render/GameRenderBuildings.cpp @@ -26,7 +26,6 @@ #include "Brush.h" -#include "FertilityCalculatorDialog.h" // Building rendering. Split from Game_render.cpp. diff --git a/src/render/GameRenderOverlay.cpp b/src/render/GameRenderOverlay.cpp index 58b7c8e13..1a6d83bd6 100644 --- a/src/render/GameRenderOverlay.cpp +++ b/src/render/GameRenderOverlay.cpp @@ -25,7 +25,6 @@ #include "Brush.h" #include "Bullet.h" -#include "FertilityCalculatorDialog.h" #include "ReplayWriter.h" diff --git a/src/render/GameRenderTerrain.cpp b/src/render/GameRenderTerrain.cpp index 8faba2503..d6e473289 100644 --- a/src/render/GameRenderTerrain.cpp +++ b/src/render/GameRenderTerrain.cpp @@ -23,7 +23,6 @@ #include "Brush.h" -#include "FertilityCalculatorDialog.h" #include "GameRenderInternal.h" diff --git a/src/render/GameRenderUnits.cpp b/src/render/GameRenderUnits.cpp index 0dd8ee3d3..f779c82d8 100644 --- a/src/render/GameRenderUnits.cpp +++ b/src/render/GameRenderUnits.cpp @@ -30,7 +30,6 @@ #include "Brush.h" #include "UnitSkin.h" -#include "FertilityCalculatorDialog.h" // Unit rendering. Split from Game_render.cpp. diff --git a/src/render/Minimap.cpp b/src/render/Minimap.cpp index 622dd9954..5af12b16c 100644 --- a/src/render/Minimap.cpp +++ b/src/render/Minimap.cpp @@ -49,14 +49,14 @@ void Minimap::setGame(Game& ngame) game = &ngame; } - - void Minimap::resizeViewport(int width) { - gameWidth = width; - if (!noX && game) computeMinimapPositioning(); + gameWidth = width; + if (!noX && game) computeMinimapPositioning(); } + + void Minimap::draw(int localteam, int viewportX, int viewportY, int viewportW, int viewportH) { if (noX) return; @@ -426,4 +426,3 @@ void Minimap::computeColors(int row, int localTeam) surface->drawPixel(dx+decX, dy+decY, r, g, b, Color::ALPHA_OPAQUE); } } - diff --git a/src/yog/YOGClient.cpp b/src/yog/YOGClient.cpp index 62bd3d6e2..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" @@ -132,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; @@ -157,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; @@ -177,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 @@ -367,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) @@ -439,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); @@ -448,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/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 1243d3cba..b56084b07 100644 --- a/src/yog/YOGServer.cpp +++ b/src/yog/YOGServer.cpp @@ -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; 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/test/EngineSessionHarness.cpp b/test/EngineSessionHarness.cpp index f23db0b0d..1ef58a4f5 100644 --- a/test/EngineSessionHarness.cpp +++ b/test/EngineSessionHarness.cpp @@ -6,6 +6,20 @@ #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" @@ -20,9 +34,13 @@ #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; @@ -35,6 +53,46 @@ GAGCore::CooperativeSlice fixedSlice() 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); @@ -99,6 +157,122 @@ int main(int argc, char** argv) 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.onAction(nullptr, GAGGUI::BUTTON_RELEASED, SettingsScreen::OK, 0); + 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; @@ -127,6 +301,7 @@ int main(int argc, char** argv) 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"); @@ -135,6 +310,7 @@ int main(int argc, char** argv) 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"); } { @@ -281,6 +457,61 @@ int main(int argc, char** argv) } 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; @@ -476,7 +707,7 @@ int main(int argc, char** argv) 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.getCase(x, y).fertility); + values.push_back(editor.game.map.getTile(x, y).fertility); values.push_back(editor.game.map.fertilityMaximum); return values; }; @@ -496,7 +727,7 @@ int main(int argc, char** argv) 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.getCase(x, y).fertility = 42; + editor.game.map.getTile(x, y).fertility = 42; editor.game.map.fertilityMaximum = 42; const auto untouched = snapshot(); FertilityCalculator::Job job(editor.game.map); diff --git a/test/LANSessionHarness.cpp b/test/LANSessionHarness.cpp index 00ccc056a..bec2a5321 100644 --- a/test/LANSessionHarness.cpp +++ b/test/LANSessionHarness.cpp @@ -3,12 +3,14 @@ #include "GlobalContainer.h" #include "Engine.h" #include "LANFindScreen.h" +#include "LANSessionScreen.h" +#include #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 index 6f6eff0c0..5fb6c7e07 100644 --- a/test/LegacyFertilityReference.h +++ b/test/LegacyFertilityReference.h @@ -141,7 +141,7 @@ namespace LegacyFertilityReference for (int x = 0; x < map.getW(); ++x) for (int y = 0; y < map.getH(); ++y) - map.getCase(x, y).fertility = fertility[map.coordToIndex(x, y)]; + map.getTile(x, y).fertility = fertility[map.coordToIndex(x, y)]; map.fertilityMaximum = fertilityMax; } } diff --git a/test/NativeMultiplayerPeer.cpp b/test/NativeMultiplayerPeer.cpp index f8987d742..6c21ad04e 100644 --- a/test/NativeMultiplayerPeer.cpp +++ b/test/NativeMultiplayerPeer.cpp @@ -36,7 +36,9 @@ int main(int argc, char** argv) { globalContainer->runNoX = true; globalContainer->load(); globalContainer->automaticEndingGame = true; - globalContainer->automaticEndingSteps = 250; + // 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(); @@ -60,10 +62,11 @@ int main(int argc, char** argv) { } 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()) << std::endl; + std::cout << "native peer joined order-rate=" << int(game->getGameHeader().getOrderRate()) << " player-id=" << client->getPlayerID() << std::endl; } } SDL_Delay(1); diff --git a/test/NetConnectionHarness.cpp b/test/NetConnectionHarness.cpp index c806502d9..ac9b75d08 100644 --- a/test/NetConnectionHarness.cpp +++ b/test/NetConnectionHarness.cpp @@ -39,6 +39,12 @@ int main(int argc, char** argv) { 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); } } @@ -66,6 +72,14 @@ int main(int argc, char** argv) { 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); @@ -73,7 +87,8 @@ int main(int argc, char** argv) { 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, 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"); 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 2d581d42c..3a9b34f70 100644 --- a/test/SConstruct +++ b/test/SConstruct @@ -32,12 +32,13 @@ common_cpppath = [config_dir, '..', '../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', @@ -46,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 @@ -60,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 diff --git a/test/SavegameSafetyHarness.cpp b/test/SavegameSafetyHarness.cpp index a40d2911a..7c2b2c2de 100644 --- a/test/SavegameSafetyHarness.cpp +++ b/test/SavegameSafetyHarness.cpp @@ -14,6 +14,9 @@ #include "Version.h" #include "FileImport.h" #include "Campaign.h" +#include "KeyboardManager.h" +#include "GameGUIKeyActions.h" +#include "MapEditKeyActions.h" #include #include #include @@ -289,6 +292,8 @@ 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; @@ -483,6 +488,43 @@ static void checkCampaignProgress(const fs::path& directory) 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(); @@ -500,6 +542,7 @@ 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}) @@ -602,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/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/tests/baselines/cross-replay.checksums b/tests/baselines/cross-replay.checksums index e63d7d1cee7494c8c3b0ad0462cf7229850df33b..9d13fe1bf2c2ca7b3bdafe25dcbffadae53fa459 100644 GIT binary patch literal 154820 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 literal 154820 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 blX`#;3)ZMvP$aP6EhsX|Lb0GU^(^@Rs_Z$D diff --git a/tests/baselines/cross-replay.replay b/tests/baselines/cross-replay.replay index d41fed37cd807d583e52f03ad87d8ed3f631924e..37fe910310f2a9b38359b3af3c80cddc0698f0fa 100644 GIT binary patch literal 714568 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 delta 34436 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 ZxNU!Ua+)?Ym;$`v=df2ncl!CC>yaOQDPdA024Z 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 #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"; From ecc35f775190aeda696eb6feef4b8ddb58fe2c26 Mon Sep 17 00:00:00 2001 From: Bradley Arsenault Date: Thu, 10 Sep 2026 14:54:38 -0400 Subject: [PATCH 06/20] Fix stale trapped-unit-test harness path in CI The linux and windows jobs still pointed run-savegame-safety-tests.py at build/src/TrappedUnitLifecycleTest[.exe], the pre-build_layout.py path. Every sibling harness step in the same jobs already moved to the per-toolchain/role/mode layout; these two were missed, so both jobs failed immediately with FileNotFoundError. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XyKsqjkUFwsmf7dxp9SYdV --- .github/workflows/build.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3d146df7a..f18c1c8de 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -164,7 +164,7 @@ jobs: - 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: | @@ -338,7 +338,7 @@ jobs: - 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: | From 750ea6d374dd233bddccc5eb462decd3b0710392 Mon Sep 17 00:00:00 2001 From: Bradley Arsenault Date: Thu, 10 Sep 2026 16:46:29 -0400 Subject: [PATCH 07/20] Regenerate cross-replay.replay for the version-94 floor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit master's tip (#223, fetch-job apportionment) bumped VERSION_MINOR and REPLAY_MINIMUM_VERSION_MINOR to 94 after this branch's last recording at 93. ReplayReader::open() rejects anything below the floor, and browser/tests/import.spec.js and session-reload.spec.js both import this exact file and expect it to load. Re-recorded from the unchanged games/cross-replay.game (seed 42, SmallForTwo, Econo vs Nicowar) run to its natural end via `--nox games/cross-replay.game 0 1`, with GLOB2_CHECKSUM_SIDECAR_MAX_TICKS=100 to match the existing checksum sidecar's cap. cross-replay.checksums came out byte-identical to the prior recording — expected, since the sidecar only covers the first 100 ticks and Giszmo's review already established the simulation is bit-identical between master and this branch on this exact save. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XyKsqjkUFwsmf7dxp9SYdV --- tests/baselines/cross-replay.replay | Bin 714568 -> 722927 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/tests/baselines/cross-replay.replay b/tests/baselines/cross-replay.replay index 37fe910310f2a9b38359b3af3c80cddc0698f0fa..b0937777b4d29a33c14ce682c9c54f8348e9a4d5 100644 GIT binary patch delta 32066 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 TP<5tLd=P-7mr^Jl_Ne|3wEIr; delta 28988 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�#rsiv@*Oimdw&Fq#RAFr60=QpVh(zJOu0Ot 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 Date: Thu, 10 Sep 2026 17:03:35 -0400 Subject: [PATCH 08/20] Translate the new browser-durability/import UI strings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 39 keys added for import/export and durable-save-failure UI (Loading headers, saving to storage, storage restore failed, etc.) had only their English source text copied into every non-English catalog. master's newer test_catalogs_do_not_reintroduce_english_fallbacks (pulled in by the rebase) flags any non-English catalog whose value for a key equals the English source and isn't reviewed shared vocabulary — this predates the rebase but was never checked before, since that test didn't exist on this branch until now. Translated all 39 keys into all 32 non-English catalogs. Two catalog codes don't follow ISO 639-1: texts.si.txt holds Slovenian, not Sinhala (the shipped fonts have no Sinhala glyphs at all, confirmed by test_font_coverage.py, and none of its existing content is Sinhala either); texts.sr.txt is Serbian in Cyrillic, matching its existing content, not Latin. Verified: check_translations.py --strict (0 structural errors), test_translations.py, test_font_coverage.py, and test_text_area_layout.py all pass. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XyKsqjkUFwsmf7dxp9SYdV --- data/texts.ar.txt | 78 ++++++++++++++++++++++---------------------- data/texts.br.txt | 78 ++++++++++++++++++++++---------------------- data/texts.ca.txt | 78 ++++++++++++++++++++++---------------------- data/texts.cz.txt | 78 ++++++++++++++++++++++---------------------- data/texts.de.txt | 78 ++++++++++++++++++++++---------------------- data/texts.dk.txt | 78 ++++++++++++++++++++++---------------------- data/texts.eo.txt | 78 ++++++++++++++++++++++---------------------- data/texts.es.txt | 78 ++++++++++++++++++++++---------------------- data/texts.eu.txt | 78 ++++++++++++++++++++++---------------------- data/texts.fa.txt | 78 ++++++++++++++++++++++---------------------- data/texts.fi.txt | 78 ++++++++++++++++++++++---------------------- data/texts.fr.txt | 78 ++++++++++++++++++++++---------------------- data/texts.gr.txt | 78 ++++++++++++++++++++++---------------------- data/texts.hu.txt | 78 ++++++++++++++++++++++---------------------- data/texts.id.txt | 78 ++++++++++++++++++++++---------------------- data/texts.it.txt | 78 ++++++++++++++++++++++---------------------- data/texts.ja.txt | 78 ++++++++++++++++++++++---------------------- data/texts.ko.txt | 78 ++++++++++++++++++++++---------------------- data/texts.nl.txt | 78 ++++++++++++++++++++++---------------------- data/texts.pl.txt | 78 ++++++++++++++++++++++---------------------- data/texts.pt.txt | 78 ++++++++++++++++++++++---------------------- data/texts.ro.txt | 78 ++++++++++++++++++++++---------------------- data/texts.ru.txt | 78 ++++++++++++++++++++++---------------------- data/texts.si.txt | 78 ++++++++++++++++++++++---------------------- data/texts.sk.txt | 78 ++++++++++++++++++++++---------------------- data/texts.sr.txt | 78 ++++++++++++++++++++++---------------------- data/texts.sv.txt | 78 ++++++++++++++++++++++---------------------- data/texts.tr.txt | 78 ++++++++++++++++++++++---------------------- data/texts.uk.txt | 78 ++++++++++++++++++++++---------------------- data/texts.vi.txt | 78 ++++++++++++++++++++++---------------------- data/texts.zh-cn.txt | 78 ++++++++++++++++++++++---------------------- data/texts.zh-tw.txt | 78 ++++++++++++++++++++++---------------------- 32 files changed, 1248 insertions(+), 1248 deletions(-) diff --git a/data/texts.ar.txt b/data/texts.ar.txt index 51270cf05..876f49693 100644 --- a/data/texts.ar.txt +++ b/data/texts.ar.txt @@ -1765,80 +1765,80 @@ OpenGL غير متوفر في هذا الإصدار. [settings Automatically show the torus overview while moving around the map (OpenGL).] إظهار نظرة عامة للخريطة على سطح طارة تلقائيًا أثناء التنقل في الخريطة (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. +بروتوكولا العميل والخادم مختلفان. ثبّت نفس إصدار Glob2 المثبت على الخادم. [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.br.txt b/data/texts.br.txt index ea0acee9e..3949516f1 100644 --- a/data/texts.br.txt +++ b/data/texts.br.txt @@ -1763,80 +1763,80 @@ 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] -The map could not be saved. Check the destination and available space. Your edits are still open. +Não foi possível salvar o mapa. Verifique o destino e o espaço disponível. Suas edições continuam abertas. [Loading headers] -Loading headers... +Carregando cabeçalhos... [Loading teams] -Loading teams... +Carregando equipes... [Loading terrain] -Loading terrain... +Carregando o terreno... [Building gradients] -Building gradients... +Gerando gradientes... [Loading players] -Loading players... +Carregando jogadores... [Loading scripts] -Loading scripts... +Carregando scripts... [Generating map] -Generating map... +Gerando o mapa... [ERROR_CANT_GENERATE_MAP] -The map could not be generated. Try different generation settings. +Não foi possível gerar o mapa. Tente outras configurações de geração. [Loading units] -Loading units... +Carregando unidades... [Loading buildings] -Loading buildings... +Carregando construções... [Resolving team links] -Resolving team links... +Resolvendo vínculos de equipe... [saving to storage] -Saving... +Salvando... [save failed retry] -Save failed. Retry. +Falha ao salvar. Tente de novo. [export save] -Export save +Exportar jogo salvo [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. +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] -Continue +Continuar [export file] -Export file +Exportar arquivo [export failed] -File export failed +Falha ao exportar o arquivo [import file] -Import +Importar [select import file] -Choose a file to import +Escolha um arquivo para importar [validating import] -Validating file... +Validando o arquivo... [import succeeded] -File imported +Arquivo importado [import cancelled] -Import cancelled +Importação cancelada [import failed] -Import failed: invalid or unsupported file +Falha na importação: arquivo inválido ou não suportado [import persistence failed] -Not saved: select Import to retry or Export file +Não salvo: escolha Importar para tentar de novo ou Exportar arquivo [import progress] -Import progress +Progresso da importação [export progress] -Export progress +Progresso da exportação [retry save] -Retry save +Tentar salvar de novo [campaign save failed] -Campaign progress not saved. Retry or export a backup. +Progresso da campanha não salvo. Tente novamente ou exporte um backup. [leave without saving] -Leave without saving +Sair sem salvar [campaign import failed] -Invalid progress file or different campaign version +Arquivo de progresso inválido ou versão de campanha diferente [network release mismatch] -Client and server protocols differ. Install the same Glob2 release as the server. +Os protocolos do cliente e do servidor são diferentes. Instale a mesma versão do Glob2 do servidor. [settings continue] -Continue +Continuar [settings save failed] -Settings save not confirmed. Retry, or continue. +Salvamento das configurações não confirmado. Tente de novo, ou continue. [shutdown save failed] -Final save not confirmed. Retry, or quit without saving. +Salvamento final não confirmado. Tente de novo, ou saia sem salvar. [quit without saving] -Quit without saving +Sair sem salvar [game closed] -Game closed. You can close this window or reload to play again. +Jogo encerrado. Você pode fechar esta janela ou recarregar para jogar de novo. [campaign editor save failed] -Save not confirmed. OK retries; Cancel returns to the editor menu. +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 a2e325fcb..1f0783505 100644 --- a/data/texts.ca.txt +++ b/data/texts.ca.txt @@ -1775,80 +1775,80 @@ 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] -The map could not be saved. Check the destination and available space. Your edits are still open. +No s'ha pogut desar el mapa. Comproveu la destinació i l'espai disponible. Les vostres edicions encara estan obertes. [Loading headers] -Loading headers... +Carregant capçaleres... [Loading teams] -Loading teams... +Carregant equips... [Loading terrain] -Loading terrain... +Carregant el terreny... [Building gradients] -Building gradients... +Generant gradients... [Loading players] -Loading players... +Carregant jugadors... [Loading scripts] -Loading scripts... +Carregant scripts... [Generating map] -Generating map... +Generant el mapa... [ERROR_CANT_GENERATE_MAP] -The map could not be generated. Try different generation settings. +No s'ha pogut generar el mapa. Proveu altres paràmetres de generació. [Loading units] -Loading units... +Carregant unitats... [Loading buildings] -Loading buildings... +Carregant construccions... [Resolving team links] -Resolving team links... +Resolent enllaços d'equip... [saving to storage] -Saving... +Desant... [save failed retry] -Save failed. Retry. +Ha fallat el desat. Torneu-ho a provar. [export save] -Export save +Exporta la partida desada [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. +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] -Continue +Continua [export file] -Export file +Exporta el fitxer [export failed] -File export failed +Ha fallat l'exportació del fitxer [import file] -Import +Importa [select import file] -Choose a file to import +Trieu un fitxer per importar [validating import] -Validating file... +Validant el fitxer... [import succeeded] -File imported +Fitxer importat [import cancelled] -Import cancelled +Importació cancel·lada [import failed] -Import failed: invalid or unsupported file +Ha fallat la importació: fitxer no vàlid o no compatible [import persistence failed] -Not saved: select Import to retry or Export file +No desat: trieu Importa per reintentar-ho, o Exporta el fitxer [import progress] -Import progress +Progrés de la importació [export progress] -Export progress +Progrés de l'exportació [retry save] -Retry save +Reintenta el desat [campaign save failed] -Campaign progress not saved. Retry or export a backup. +El progrés de la campanya no s'ha desat. Torneu-ho a provar o exporteu una còpia de seguretat. [leave without saving] -Leave without saving +Surt sense desar [campaign import failed] -Invalid progress file or different campaign version +Fitxer de progrés no vàlid o versió de campanya diferent [network release mismatch] -Client and server protocols differ. Install the same Glob2 release as the server. +Els protocols del client i del servidor difereixen. Instal·leu la mateixa versió de Glob2 que el servidor. [settings continue] -Continue +Continua [settings save failed] -Settings save not confirmed. Retry, or continue. +Desat dels ajustos no confirmat. Torneu-ho a provar, o continueu. [shutdown save failed] -Final save not confirmed. Retry, or quit without saving. +Desat final no confirmat. Torneu-ho a provar, o sortiu sense desar. [quit without saving] -Quit without saving +Surt sense desar [game closed] -Game closed. You can close this window or reload to play again. +Partida tancada. Podeu tancar aquesta finestra o tornar a carregar per jugar de nou. [campaign editor save failed] -Save not confirmed. OK retries; Cancel returns to the editor menu. +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 fef5ba70a..531057fe8 100644 --- a/data/texts.cz.txt +++ b/data/texts.cz.txt @@ -1767,80 +1767,80 @@ 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] -The map could not be saved. Check the destination and available space. Your edits are still open. +Mapu se nepodařilo uložit. Zkontrolujte cíl a dostupné místo. Vaše úpravy jsou stále otevřené. [Loading headers] -Loading headers... +Načítání hlaviček... [Loading teams] -Loading teams... +Načítání týmů... [Loading terrain] -Loading terrain... +Načítání terénu... [Building gradients] -Building gradients... +Vytvářejí se gradienty... [Loading players] -Loading players... +Načítání hráčů... [Loading scripts] -Loading scripts... +Načítání skriptů... [Generating map] -Generating map... +Generuje se mapa... [ERROR_CANT_GENERATE_MAP] -The map could not be generated. Try different generation settings. +Mapu se nepodařilo vytvořit. Zkuste jiná nastavení generování. [Loading units] -Loading units... +Načítání jednotek... [Loading buildings] -Loading buildings... +Načítání budov... [Resolving team links] -Resolving team links... +Řeší se propojení týmů... [saving to storage] -Saving... +Ukládá se... [save failed retry] -Save failed. Retry. +Uložení selhalo. Zkuste to znovu. [export save] -Export save +Exportovat uloženou hru [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. +Ú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] -Continue +Pokračovat [export file] -Export file +Exportovat soubor [export failed] -File export failed +Export souboru selhal [import file] -Import +Importovat [select import file] -Choose a file to import +Vyberte soubor k importu [validating import] -Validating file... +Ověřuje se soubor... [import succeeded] -File imported +Soubor byl importován [import cancelled] -Import cancelled +Import zrušen [import failed] -Import failed: invalid or unsupported file +Import selhal: neplatný nebo nepodporovaný soubor [import persistence failed] -Not saved: select Import to retry or Export file +Neuloženo: zvolte Importovat pro nový pokus, nebo Exportovat soubor [import progress] -Import progress +Průběh importu [export progress] -Export progress +Průběh exportu [retry save] -Retry save +Zkusit uložení znovu [campaign save failed] -Campaign progress not saved. Retry or export a backup. +Postup tažení nebyl uložen. Zkuste to znovu nebo exportujte zálohu. [leave without saving] -Leave without saving +Ukončit bez uložení [campaign import failed] -Invalid progress file or different campaign version +Neplatný soubor postupu nebo jiná verze tažení [network release mismatch] -Client and server protocols differ. Install the same Glob2 release as the server. +Protokoly klienta a serveru se liší. Nainstalujte stejnou verzi Glob2 jako server. [settings continue] -Continue +Pokračovat [settings save failed] -Settings save not confirmed. Retry, or continue. +Uložení nastavení nepotvrzeno. Zkuste to znovu, nebo pokračujte. [shutdown save failed] -Final save not confirmed. Retry, or quit without saving. +Závěrečné uložení nepotvrzeno. Zkuste to znovu, nebo ukončete bez uložení. [quit without saving] -Quit without saving +Ukončit bez uložení [game closed] -Game closed. You can close this window or reload to play again. +Hra byla ukončena. Toto okno můžete zavřít nebo znovu načíst stránku a hrát znovu. [campaign editor save failed] -Save not confirmed. OK retries; Cancel returns to the editor menu. +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 217343bb3..39e3dbc04 100644 --- a/data/texts.de.txt +++ b/data/texts.de.txt @@ -1767,80 +1767,80 @@ 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] -The map could not be saved. Check the destination and available space. Your edits are still open. +Die Karte konnte nicht gespeichert werden. Prüfen Sie Ziel und verfügbaren Speicherplatz. Ihre Änderungen sind weiterhin offen. [Loading headers] -Loading headers... +Kopfdaten werden geladen... [Loading teams] -Loading teams... +Teams werden geladen... [Loading terrain] -Loading terrain... +Gelände wird geladen... [Building gradients] -Building gradients... +Verlaufsfelder werden erstellt... [Loading players] -Loading players... +Spieler werden geladen... [Loading scripts] -Loading scripts... +Skripte werden geladen... [Generating map] -Generating map... +Karte wird generiert... [ERROR_CANT_GENERATE_MAP] -The map could not be generated. Try different generation settings. +Die Karte konnte nicht generiert werden. Versuchen Sie andere Generierungseinstellungen. [Loading units] -Loading units... +Einheiten werden geladen... [Loading buildings] -Loading buildings... +Gebäude werden geladen... [Resolving team links] -Resolving team links... +Team-Verknüpfungen werden aufgelöst... [saving to storage] -Saving... +Wird gespeichert... [save failed retry] -Save failed. Retry. +Speichern fehlgeschlagen. Erneut versuchen. [export save] -Export save +Speicherstand exportieren [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. +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] -Continue +Weiter [export file] -Export file +Datei exportieren [export failed] -File export failed +Dateiexport fehlgeschlagen [import file] -Import +Importieren [select import file] -Choose a file to import +Datei zum Importieren auswählen [validating import] -Validating file... +Datei wird überprüft... [import succeeded] -File imported +Datei importiert [import cancelled] -Import cancelled +Import abgebrochen [import failed] -Import failed: invalid or unsupported file +Import fehlgeschlagen: ungültige oder nicht unterstützte Datei [import persistence failed] -Not saved: select Import to retry or Export file +Nicht gespeichert: Importieren zum erneuten Versuch oder Datei exportieren wählen [import progress] -Import progress +Importfortschritt [export progress] -Export progress +Exportfortschritt [retry save] -Retry save +Speichern erneut versuchen [campaign save failed] -Campaign progress not saved. Retry or export a backup. +Kampagnenfortschritt nicht gespeichert. Erneut versuchen oder eine Sicherung exportieren. [leave without saving] -Leave without saving +Beenden ohne zu speichern [campaign import failed] -Invalid progress file or different campaign version +Ungültige Fortschrittsdatei oder abweichende Kampagnenversion [network release mismatch] -Client and server protocols differ. Install the same Glob2 release as the server. +Client- und Serverprotokoll stimmen nicht überein. Installieren Sie dieselbe Glob2-Version wie der Server. [settings continue] -Continue +Weiter [settings save failed] -Settings save not confirmed. Retry, or continue. +Einstellungen speichern nicht bestätigt. Erneut versuchen oder fortfahren. [shutdown save failed] -Final save not confirmed. Retry, or quit without saving. +Letztes Speichern nicht bestätigt. Erneut versuchen oder ohne Speichern beenden. [quit without saving] -Quit without saving +Beenden ohne zu speichern [game closed] -Game closed. You can close this window or reload to play again. +Spiel beendet. Sie können dieses Fenster schließen oder neu laden, um erneut zu spielen. [campaign editor save failed] -Save not confirmed. OK retries; Cancel returns to the editor menu. +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 dc49068de..77cd8eabf 100644 --- a/data/texts.dk.txt +++ b/data/texts.dk.txt @@ -1843,80 +1843,80 @@ 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] -The map could not be saved. Check the destination and available space. Your edits are still open. +Kortet kunne ikke gemmes. Tjek destinationen og den tilgængelige plads. Dine ændringer er stadig åbne. [Loading headers] -Loading headers... +Indlæser headere... [Loading teams] -Loading teams... +Indlæser hold... [Loading terrain] -Loading terrain... +Indlæser terræn... [Building gradients] -Building gradients... +Bygger gradienter... [Loading players] -Loading players... +Indlæser spillere... [Loading scripts] -Loading scripts... +Indlæser scripts... [Generating map] -Generating map... +Genererer kort... [ERROR_CANT_GENERATE_MAP] -The map could not be generated. Try different generation settings. +Kortet kunne ikke genereres. Prøv andre genereringsindstillinger. [Loading units] -Loading units... +Indlæser enheder... [Loading buildings] -Loading buildings... +Indlæser bygninger... [Resolving team links] -Resolving team links... +Løser holdforbindelser... [saving to storage] -Saving... +Gemmer... [save failed retry] -Save failed. Retry. +Lagring mislykkedes. Prøv igen. [export save] -Export save +Eksportér gemt spil [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. +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] -Continue +Fortsæt [export file] -Export file +Eksportér fil [export failed] -File export failed +Eksport af fil mislykkedes [import file] -Import +Importér [select import file] -Choose a file to import +Vælg en fil at importere [validating import] -Validating file... +Validerer fil... [import succeeded] -File imported +Fil importeret [import cancelled] -Import cancelled +Import annulleret [import failed] -Import failed: invalid or unsupported file +Import mislykkedes: ugyldig eller ikke-understøttet fil [import persistence failed] -Not saved: select Import to retry or Export file +Ikke gemt: vælg Importér for at prøve igen, eller Eksportér fil [import progress] -Import progress +Importfremskridt [export progress] -Export progress +Eksportfremskridt [retry save] -Retry save +Prøv at gemme igen [campaign save failed] -Campaign progress not saved. Retry or export a backup. +Kampagnens fremskridt blev ikke gemt. Prøv igen, eller eksportér en sikkerhedskopi. [leave without saving] -Leave without saving +Afslut uden at gemme [campaign import failed] -Invalid progress file or different campaign version +Ugyldig fremskridtsfil eller anden kampagneversion [network release mismatch] -Client and server protocols differ. Install the same Glob2 release as the server. +Klient- og serverprotokoller er forskellige. Installér samme Glob2-version som serveren. [settings continue] -Continue +Fortsæt [settings save failed] -Settings save not confirmed. Retry, or continue. +Lagring af indstillinger ikke bekræftet. Prøv igen, eller fortsæt. [shutdown save failed] -Final save not confirmed. Retry, or quit without saving. +Endelig lagring ikke bekræftet. Prøv igen, eller afslut uden at gemme. [quit without saving] -Quit without saving +Afslut uden at gemme [game closed] -Game closed. You can close this window or reload to play again. +Spillet er lukket. Du kan lukke dette vindue eller genindlæse for at spille igen. [campaign editor save failed] -Save not confirmed. OK retries; Cancel returns to the editor menu. +Lagring ikke bekræftet. OK prøver igen; Annuller går tilbage til editormenuen. diff --git a/data/texts.eo.txt b/data/texts.eo.txt index e30d918b8..468299331 100644 --- a/data/texts.eo.txt +++ b/data/texts.eo.txt @@ -1765,80 +1765,80 @@ 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] -The map could not be saved. Check the destination and available space. Your edits are still open. +La mapo ne povis esti konservita. Kontrolu la celon kaj la disponeblan spacon. Viaj redaktoj ankoraŭ estas malfermitaj. [Loading headers] -Loading headers... +Ŝargante kapojn... [Loading teams] -Loading teams... +Ŝargante teamojn... [Loading terrain] -Loading terrain... +Ŝargante terenon... [Building gradients] -Building gradients... +Konstruante gradientojn... [Loading players] -Loading players... +Ŝargante ludantojn... [Loading scripts] -Loading scripts... +Ŝargante skriptojn... [Generating map] -Generating map... +Generante mapon... [ERROR_CANT_GENERATE_MAP] -The map could not be generated. Try different generation settings. +La mapo ne povis esti generita. Provu aliajn generajn agordojn. [Loading units] -Loading units... +Ŝargante unuojn... [Loading buildings] -Loading buildings... +Ŝargante konstruaĵojn... [Resolving team links] -Resolving team links... +Solvante teamajn ligilojn... [saving to storage] -Saving... +Konservante... [save failed retry] -Save failed. Retry. +Konservado malsukcesis. Reprovu. [export save] -Export save +Eksporti konservaĵon [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. +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] -Continue +Daŭrigi [export file] -Export file +Eksporti dosieron [export failed] -File export failed +Eksportado de dosiero malsukcesis [import file] -Import +Importi [select import file] -Choose a file to import +Elektu dosieron por importi [validating import] -Validating file... +Kontrolante dosieron... [import succeeded] -File imported +Dosiero importita [import cancelled] -Import cancelled +Importado nuligita [import failed] -Import failed: invalid or unsupported file +Importado malsukcesis: nevalida aŭ nesubtenata dosiero [import persistence failed] -Not saved: select Import to retry or Export file +Ne konservita: elektu Importi por reprovi, aŭ Eksporti dosieron [import progress] -Import progress +Importa progreso [export progress] -Export progress +Eksporta progreso [retry save] -Retry save +Reprovi konservadon [campaign save failed] -Campaign progress not saved. Retry or export a backup. +Kampanja progreso ne konservita. Reprovu aŭ eksportu sekurkopion. [leave without saving] -Leave without saving +Eliri sen konservi [campaign import failed] -Invalid progress file or different campaign version +Nevalida progresa dosiero aŭ malsama kampanja versio [network release mismatch] -Client and server protocols differ. Install the same Glob2 release as the server. +La protokoloj de kliento kaj servilo malsamas. Instalu la saman version de Glob2 kiel la servilo. [settings continue] -Continue +Daŭrigi [settings save failed] -Settings save not confirmed. Retry, or continue. +Konservado de agordoj ne konfirmita. Reprovu, aŭ daŭrigu. [shutdown save failed] -Final save not confirmed. Retry, or quit without saving. +Fina konservado ne konfirmita. Reprovu, aŭ eliru sen konservi. [quit without saving] -Quit without saving +Eliri sen konservi [game closed] -Game closed. You can close this window or reload to play again. +La ludo fermiĝis. Vi povas fermi ĉi tiun fenestron aŭ reŝargi por denove ludi. [campaign editor save failed] -Save not confirmed. OK retries; Cancel returns to the editor menu. +Konservado ne konfirmita. Bone reprovas; Nuligi revenas al la redaktila menuo. diff --git a/data/texts.es.txt b/data/texts.es.txt index 8f967c57c..3659e7b2f 100644 --- a/data/texts.es.txt +++ b/data/texts.es.txt @@ -1767,80 +1767,80 @@ 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] -The map could not be saved. Check the destination and available space. Your edits are still open. +No se pudo guardar el mapa. Compruebe el destino y el espacio disponible. Sus cambios siguen abiertos. [Loading headers] -Loading headers... +Cargando cabeceras... [Loading teams] -Loading teams... +Cargando equipos... [Loading terrain] -Loading terrain... +Cargando terreno... [Building gradients] -Building gradients... +Generando gradientes... [Loading players] -Loading players... +Cargando jugadores... [Loading scripts] -Loading scripts... +Cargando scripts... [Generating map] -Generating map... +Generando mapa... [ERROR_CANT_GENERATE_MAP] -The map could not be generated. Try different generation settings. +No se pudo generar el mapa. Pruebe otras opciones de generación. [Loading units] -Loading units... +Cargando unidades... [Loading buildings] -Loading buildings... +Cargando construcciones... [Resolving team links] -Resolving team links... +Resolviendo enlaces de equipo... [saving to storage] -Saving... +Guardando... [save failed retry] -Save failed. Retry. +Error al guardar. Reintente. [export save] -Export save +Exportar partida guardada [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. +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] -Continue +Continuar [export file] -Export file +Exportar archivo [export failed] -File export failed +Error al exportar el archivo [import file] -Import +Importar [select import file] -Choose a file to import +Elija un archivo para importar [validating import] -Validating file... +Validando archivo... [import succeeded] -File imported +Archivo importado [import cancelled] -Import cancelled +Importación cancelada [import failed] -Import failed: invalid or unsupported file +Error al importar: archivo inválido o no admitido [import persistence failed] -Not saved: select Import to retry or Export file +No guardado: elija Importar para reintentar o Exportar archivo [import progress] -Import progress +Progreso de importación [export progress] -Export progress +Progreso de exportación [retry save] -Retry save +Reintentar guardado [campaign save failed] -Campaign progress not saved. Retry or export a backup. +Progreso de la campaña no guardado. Reintente o exporte una copia de seguridad. [leave without saving] -Leave without saving +Salir sin guardar [campaign import failed] -Invalid progress file or different campaign version +Archivo de progreso inválido o versión de campaña diferente [network release mismatch] -Client and server protocols differ. Install the same Glob2 release as the server. +Los protocolos del cliente y del servidor difieren. Instale la misma versión de Glob2 que el servidor. [settings continue] -Continue +Continuar [settings save failed] -Settings save not confirmed. Retry, or continue. +Guardado de ajustes no confirmado. Reintente, o continúe. [shutdown save failed] -Final save not confirmed. Retry, or quit without saving. +Guardado final no confirmado. Reintente, o salga sin guardar. [quit without saving] -Quit without saving +Salir sin guardar [game closed] -Game closed. You can close this window or reload to play again. +Partida cerrada. Puede cerrar esta ventana o recargar para volver a jugar. [campaign editor save failed] -Save not confirmed. OK retries; Cancel returns to the editor menu. +Guardado no confirmado. Aceptar reintenta; Cancelar vuelve al menú del editor. diff --git a/data/texts.eu.txt b/data/texts.eu.txt index b7bc98482..00d998797 100644 --- a/data/texts.eu.txt +++ b/data/texts.eu.txt @@ -1777,80 +1777,80 @@ 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] -The map could not be saved. Check the destination and available space. Your edits are still open. +Mapa ezin izan da gorde. Egiaztatu helmuga eta erabilgarri dagoen lekua. Zure edizioak oraindik irekita daude. [Loading headers] -Loading headers... +Goiburuak kargatzen... [Loading teams] -Loading teams... +Taldeak kargatzen... [Loading terrain] -Loading terrain... +Lurraldea kargatzen... [Building gradients] -Building gradients... +Gradienteak eraikitzen... [Loading players] -Loading players... +Jokalariak kargatzen... [Loading scripts] -Loading scripts... +Script-ak kargatzen... [Generating map] -Generating map... +Mapa sortzen... [ERROR_CANT_GENERATE_MAP] -The map could not be generated. Try different generation settings. +Mapa ezin izan da sortu. Probatu sortze-ezarpen desberdinak. [Loading units] -Loading units... +Unitateak kargatzen... [Loading buildings] -Loading buildings... +Eraikinak kargatzen... [Resolving team links] -Resolving team links... +Taldeen loturak ebazten... [saving to storage] -Saving... +Gordetzen... [save failed retry] -Save failed. Retry. +Gordetzeak huts egin du. Saiatu berriro. [export save] -Export save +Esportatu gordetako jokoa [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. +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] -Continue +Jarraitu [export file] -Export file +Esportatu fitxategia [export failed] -File export failed +Fitxategia esportatzeak huts egin du [import file] -Import +Inportatu [select import file] -Choose a file to import +Aukeratu inportatzeko fitxategi bat [validating import] -Validating file... +Fitxategia balioztatzen... [import succeeded] -File imported +Fitxategia inportatu da [import cancelled] -Import cancelled +Inportazioa bertan behera utzi da [import failed] -Import failed: invalid or unsupported file +Inportazioak huts egin du: fitxategi baliogabea edo onartu gabea [import persistence failed] -Not saved: select Import to retry or Export file +Gorde gabe: aukeratu Inportatu berriro saiatzeko, edo Esportatu fitxategia [import progress] -Import progress +Inportazioaren aurrerapena [export progress] -Export progress +Esportazioaren aurrerapena [retry save] -Retry save +Saiatu berriro gordetzen [campaign save failed] -Campaign progress not saved. Retry or export a backup. +Kanpainaren aurrerapena ez da gorde. Saiatu berriro edo esportatu babeskopia bat. [leave without saving] -Leave without saving +Irten gorde gabe [campaign import failed] -Invalid progress file or different campaign version +Aurrerapen fitxategi baliogabea edo kanpaina bertsio desberdina [network release mismatch] -Client and server protocols differ. Install the same Glob2 release as the server. +Bezeroaren eta zerbitzariaren protokoloak desberdinak dira. Instalatu zerbitzariak duen Glob2 bertsio bera. [settings continue] -Continue +Jarraitu [settings save failed] -Settings save not confirmed. Retry, or continue. +Ezarpenak gordetzea ez da berretsi. Saiatu berriro, edo jarraitu. [shutdown save failed] -Final save not confirmed. Retry, or quit without saving. +Azken gordetzea ez da berretsi. Saiatu berriro, edo irten gorde gabe. [quit without saving] -Quit without saving +Irten gorde gabe [game closed] -Game closed. You can close this window or reload to play again. +Jokoa itxi da. Leiho hau itxi dezakezu edo orria berritu berriro jolasteko. [campaign editor save failed] -Save not confirmed. OK retries; Cancel returns to the editor menu. +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 c374abdb7..ef44f1a80 100644 --- a/data/texts.fa.txt +++ b/data/texts.fa.txt @@ -1765,80 +1765,80 @@ OpenGL در این بیلد موجود نیست. [settings Automatically show the torus overview while moving around the map (OpenGL).] هنگام حرکت در نقشه (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. +پروتکل‌های کلاینت و سرور متفاوت است. همان نسخه Glob2 سرور را نصب کنید. [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.fi.txt b/data/texts.fi.txt index 2d25c45d6..ab78b2e67 100644 --- a/data/texts.fi.txt +++ b/data/texts.fi.txt @@ -1767,80 +1767,80 @@ 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] -The map could not be saved. Check the destination and available space. Your edits are still open. +Karttaa ei voitu tallentaa. Tarkista kohde ja käytettävissä oleva tila. Muutoksesi ovat yhä avoinna. [Loading headers] -Loading headers... +Ladataan otsikkotietoja... [Loading teams] -Loading teams... +Ladataan joukkueita... [Loading terrain] -Loading terrain... +Ladataan maastoa... [Building gradients] -Building gradients... +Rakennetaan liukuvärejä... [Loading players] -Loading players... +Ladataan pelaajia... [Loading scripts] -Loading scripts... +Ladataan skriptejä... [Generating map] -Generating map... +Luodaan karttaa... [ERROR_CANT_GENERATE_MAP] -The map could not be generated. Try different generation settings. +Karttaa ei voitu luoda. Kokeile eri luontiasetuksia. [Loading units] -Loading units... +Ladataan yksiköitä... [Loading buildings] -Loading buildings... +Ladataan rakennuksia... [Resolving team links] -Resolving team links... +Ratkaistaan joukkueiden linkkejä... [saving to storage] -Saving... +Tallennetaan... [save failed retry] -Save failed. Retry. +Tallennus epäonnistui. Yritä uudelleen. [export save] -Export save +Vie tallennus [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. +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] -Continue +Jatka [export file] -Export file +Vie tiedosto [export failed] -File export failed +Tiedoston vienti epäonnistui [import file] -Import +Tuo [select import file] -Choose a file to import +Valitse tuotava tiedosto [validating import] -Validating file... +Tarkistetaan tiedostoa... [import succeeded] -File imported +Tiedosto tuotu [import cancelled] -Import cancelled +Tuonti peruutettu [import failed] -Import failed: invalid or unsupported file +Tuonti epäonnistui: virheellinen tai tukematon tiedosto [import persistence failed] -Not saved: select Import to retry or Export file +Ei tallennettu: valitse Tuo yrittääksesi uudelleen, tai Vie tiedosto [import progress] -Import progress +Tuonnin edistyminen [export progress] -Export progress +Viennin edistyminen [retry save] -Retry save +Yritä tallennusta uudelleen [campaign save failed] -Campaign progress not saved. Retry or export a backup. +Kampanjan edistymistä ei tallennettu. Yritä uudelleen tai vie varmuuskopio. [leave without saving] -Leave without saving +Poistu tallentamatta [campaign import failed] -Invalid progress file or different campaign version +Virheellinen edistymistiedosto tai eri kampanjaversio [network release mismatch] -Client and server protocols differ. Install the same Glob2 release as the server. +Asiakkaan ja palvelimen protokollat eroavat. Asenna sama Glob2-versio kuin palvelimella. [settings continue] -Continue +Jatka [settings save failed] -Settings save not confirmed. Retry, or continue. +Asetusten tallennusta ei vahvistettu. Yritä uudelleen tai jatka. [shutdown save failed] -Final save not confirmed. Retry, or quit without saving. +Lopullista tallennusta ei vahvistettu. Yritä uudelleen tai poistu tallentamatta. [quit without saving] -Quit without saving +Poistu tallentamatta [game closed] -Game closed. You can close this window or reload to play again. +Peli suljettu. Voit sulkea tämän ikkunan tai ladata sivun uudelleen pelataksesi taas. [campaign editor save failed] -Save not confirmed. OK retries; Cancel returns to the editor menu. +Tallennusta ei vahvistettu. OK yrittää uudelleen; Peruuta palaa editorin valikkoon. diff --git a/data/texts.fr.txt b/data/texts.fr.txt index fc10a595e..366b3d35c 100644 --- a/data/texts.fr.txt +++ b/data/texts.fr.txt @@ -1777,80 +1777,80 @@ 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] -The map could not be saved. Check the destination and available space. Your edits are still open. +La carte n'a pas pu être sauvegardée. Vérifiez la destination et l'espace disponible. Vos modifications sont toujours ouvertes. [Loading headers] -Loading headers... +Chargement des en-têtes... [Loading teams] -Loading teams... +Chargement des équipes... [Loading terrain] -Loading terrain... +Chargement du terrain... [Building gradients] -Building gradients... +Génération des dégradés... [Loading players] -Loading players... +Chargement des joueurs... [Loading scripts] -Loading scripts... +Chargement des scripts... [Generating map] -Generating map... +Génération de la carte... [ERROR_CANT_GENERATE_MAP] -The map could not be generated. Try different generation settings. +La carte n'a pas pu être générée. Essayez d'autres paramètres de génération. [Loading units] -Loading units... +Chargement des unités... [Loading buildings] -Loading buildings... +Chargement des constructions... [Resolving team links] -Resolving team links... +Résolution des liens d'équipe... [saving to storage] -Saving... +Sauvegarde... [save failed retry] -Save failed. Retry. +Échec de la sauvegarde. Réessayez. [export save] -Export save +Exporter la sauvegarde [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. +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] -Continue +Continuer [export file] -Export file +Exporter le fichier [export failed] -File export failed +Échec de l'exportation du fichier [import file] -Import +Importer [select import file] -Choose a file to import +Choisissez un fichier à importer [validating import] -Validating file... +Validation du fichier... [import succeeded] -File imported +Fichier importé [import cancelled] -Import cancelled +Importation annulée [import failed] -Import failed: invalid or unsupported file +Échec de l'importation : fichier invalide ou non pris en charge [import persistence failed] -Not saved: select Import to retry or Export file +Non sauvegardé : choisissez Importer pour réessayer ou Exporter le fichier [import progress] -Import progress +Progression de l'importation [export progress] -Export progress +Progression de l'exportation [retry save] -Retry save +Réessayer la sauvegarde [campaign save failed] -Campaign progress not saved. Retry or export a backup. +Progression de la campagne non sauvegardée. Réessayez ou exportez une sauvegarde. [leave without saving] -Leave without saving +Quitter sans sauvegarder [campaign import failed] -Invalid progress file or different campaign version +Fichier de progression invalide ou version de campagne différente [network release mismatch] -Client and server protocols differ. Install the same Glob2 release as the server. +Les protocoles du client et du serveur diffèrent. Installez la même version de Glob2 que le serveur. [settings continue] -Continue +Continuer [settings save failed] -Settings save not confirmed. Retry, or continue. +Sauvegarde des paramètres non confirmée. Réessayez, ou continuez. [shutdown save failed] -Final save not confirmed. Retry, or quit without saving. +Sauvegarde finale non confirmée. Réessayez, ou quittez sans sauvegarder. [quit without saving] -Quit without saving +Quitter sans sauvegarder [game closed] -Game closed. You can close this window or reload to play again. +Partie terminée. Vous pouvez fermer cette fenêtre ou recharger pour rejouer. [campaign editor save failed] -Save not confirmed. OK retries; Cancel returns to the editor menu. +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 65004f627..e6050a0c3 100644 --- a/data/texts.gr.txt +++ b/data/texts.gr.txt @@ -1837,80 +1837,80 @@ Cortex [settings Automatically show the torus overview while moving around the map (OpenGL).] Αυτόματη εμφάνιση της επισκόπησης του χάρτη σε σχήμα τόρου ενώ μετακινείστε στον χάρτη (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. +Τα πρωτόκολλα πελάτη και διακομιστή διαφέρουν. Εγκαταστήστε την ίδια έκδοση Glob2 με τον διακομιστή. [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. +Η αποθήκευση δεν επιβεβαιώθηκε. Το OK ξαναδοκιμάζει· η Ακύρωση επιστρέφει στο μενού του επεξεργαστή. diff --git a/data/texts.hu.txt b/data/texts.hu.txt index 0420fa7e9..c2074a53b 100644 --- a/data/texts.hu.txt +++ b/data/texts.hu.txt @@ -1767,80 +1767,80 @@ 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] -The map could not be saved. Check the destination and available space. Your edits are still open. +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] -Loading headers... +Fejlécek betöltése... [Loading teams] -Loading teams... +Csapatok betöltése... [Loading terrain] -Loading terrain... +Terep betöltése... [Building gradients] -Building gradients... +Színátmenetek építése... [Loading players] -Loading players... +Játékosok betöltése... [Loading scripts] -Loading scripts... +Szkriptek betöltése... [Generating map] -Generating map... +Térkép generálása... [ERROR_CANT_GENERATE_MAP] -The map could not be generated. Try different generation settings. +A térképet nem sikerült legenerálni. Próbáljon más generálási beállításokat. [Loading units] -Loading units... +Egységek betöltése... [Loading buildings] -Loading buildings... +Épületek betöltése... [Resolving team links] -Resolving team links... +Csapatkapcsolatok feloldása... [saving to storage] -Saving... +Mentés... [save failed retry] -Save failed. Retry. +A mentés sikertelen. Próbálja újra. [export save] -Export save +Mentés exportálása [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. +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] -Continue +Folytatás [export file] -Export file +Fájl exportálása [export failed] -File export failed +A fájl exportálása sikertelen [import file] -Import +Importálás [select import file] -Choose a file to import +Válasszon importálandó fájlt [validating import] -Validating file... +Fájl ellenőrzése... [import succeeded] -File imported +Fájl importálva [import cancelled] -Import cancelled +Az importálás megszakítva [import failed] -Import failed: invalid or unsupported file +Az importálás sikertelen: érvénytelen vagy nem támogatott fájl [import persistence failed] -Not saved: select Import to retry or Export file +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 progress +Importálás folyamata [export progress] -Export progress +Exportálás folyamata [retry save] -Retry save +Mentés újrapróbálása [campaign save failed] -Campaign progress not saved. Retry or export a backup. +A hadjárat haladása nem mentődött. Próbálja újra, vagy exportáljon biztonsági mentést. [leave without saving] -Leave without saving +Kilépés mentés nélkül [campaign import failed] -Invalid progress file or different campaign version +Érvénytelen haladásfájl vagy eltérő hadjáratverzió [network release mismatch] -Client and server protocols differ. Install the same Glob2 release as the server. +A kliens és a szerver protokollja eltér. Telepítse a szerverrel megegyező Glob2-verziót. [settings continue] -Continue +Folytatás [settings save failed] -Settings save not confirmed. Retry, or continue. +A beállítások mentése nincs megerősítve. Próbálja újra, vagy folytassa. [shutdown save failed] -Final save not confirmed. Retry, or quit without saving. +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] -Quit without saving +Kilépés mentés nélkül [game closed] -Game closed. You can close this window or reload to play again. +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] -Save not confirmed. OK retries; Cancel returns to the editor menu. +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 68b0f478b..b5b724945 100644 --- a/data/texts.id.txt +++ b/data/texts.id.txt @@ -1763,80 +1763,80 @@ 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] -The map could not be saved. Check the destination and available space. Your edits are still open. +Peta tidak dapat disimpan. Periksa tujuan dan ruang yang tersedia. Perubahan Anda masih terbuka. [Loading headers] -Loading headers... +Memuat header... [Loading teams] -Loading teams... +Memuat tim... [Loading terrain] -Loading terrain... +Memuat medan... [Building gradients] -Building gradients... +Membangun gradien... [Loading players] -Loading players... +Memuat pemain... [Loading scripts] -Loading scripts... +Memuat skrip... [Generating map] -Generating map... +Membuat peta... [ERROR_CANT_GENERATE_MAP] -The map could not be generated. Try different generation settings. +Peta tidak dapat dibuat. Coba pengaturan pembuatan yang lain. [Loading units] -Loading units... +Memuat unit... [Loading buildings] -Loading buildings... +Memuat bangunan... [Resolving team links] -Resolving team links... +Menyelesaikan tautan tim... [saving to storage] -Saving... +Menyimpan... [save failed retry] -Save failed. Retry. +Penyimpanan gagal. Coba lagi. [export save] -Export save +Ekspor simpanan [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. +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] -Continue +Lanjutkan [export file] -Export file +Ekspor berkas [export failed] -File export failed +Ekspor berkas gagal [import file] -Import +Impor [select import file] -Choose a file to import +Pilih berkas untuk diimpor [validating import] -Validating file... +Memvalidasi berkas... [import succeeded] -File imported +Berkas diimpor [import cancelled] -Import cancelled +Impor dibatalkan [import failed] -Import failed: invalid or unsupported file +Impor gagal: berkas tidak valid atau tidak didukung [import persistence failed] -Not saved: select Import to retry or Export file +Belum tersimpan: pilih Impor untuk mencoba lagi, atau Ekspor berkas [import progress] -Import progress +Progres impor [export progress] -Export progress +Progres ekspor [retry save] -Retry save +Coba simpan lagi [campaign save failed] -Campaign progress not saved. Retry or export a backup. +Progres kampanye tidak tersimpan. Coba lagi atau ekspor cadangan. [leave without saving] -Leave without saving +Keluar tanpa menyimpan [campaign import failed] -Invalid progress file or different campaign version +Berkas progres tidak valid atau versi kampanye berbeda [network release mismatch] -Client and server protocols differ. Install the same Glob2 release as the server. +Protokol klien dan server berbeda. Pasang versi Glob2 yang sama dengan server. [settings continue] -Continue +Lanjutkan [settings save failed] -Settings save not confirmed. Retry, or continue. +Penyimpanan pengaturan belum dikonfirmasi. Coba lagi, atau lanjutkan. [shutdown save failed] -Final save not confirmed. Retry, or quit without saving. +Penyimpanan akhir belum dikonfirmasi. Coba lagi, atau keluar tanpa menyimpan. [quit without saving] -Quit without saving +Keluar tanpa menyimpan [game closed] -Game closed. You can close this window or reload to play again. +Permainan ditutup. Anda dapat menutup jendela ini atau memuat ulang untuk bermain lagi. [campaign editor save failed] -Save not confirmed. OK retries; Cancel returns to the editor menu. +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 00fabfc8a..4b94c7c4a 100644 --- a/data/texts.it.txt +++ b/data/texts.it.txt @@ -1829,80 +1829,80 @@ 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] -The map could not be saved. Check the destination and available space. Your edits are still open. +Impossibile salvare la mappa. Controlla la destinazione e lo spazio disponibile. Le modifiche sono ancora aperte. [Loading headers] -Loading headers... +Caricamento intestazioni... [Loading teams] -Loading teams... +Caricamento squadre... [Loading terrain] -Loading terrain... +Caricamento terreno... [Building gradients] -Building gradients... +Generazione dei gradienti... [Loading players] -Loading players... +Caricamento giocatori... [Loading scripts] -Loading scripts... +Caricamento script... [Generating map] -Generating map... +Generazione della mappa... [ERROR_CANT_GENERATE_MAP] -The map could not be generated. Try different generation settings. +Impossibile generare la mappa. Prova altre impostazioni di generazione. [Loading units] -Loading units... +Caricamento unità... [Loading buildings] -Loading buildings... +Caricamento edifici... [Resolving team links] -Resolving team links... +Risoluzione dei collegamenti tra squadre... [saving to storage] -Saving... +Salvataggio... [save failed retry] -Save failed. Retry. +Salvataggio non riuscito. Riprova. [export save] -Export save +Esporta salvataggio [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. +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] -Continue +Continua [export file] -Export file +Esporta file [export failed] -File export failed +Esportazione del file non riuscita [import file] -Import +Importa [select import file] -Choose a file to import +Scegli un file da importare [validating import] -Validating file... +Verifica del file... [import succeeded] -File imported +File importato [import cancelled] -Import cancelled +Importazione annullata [import failed] -Import failed: invalid or unsupported file +Importazione non riuscita: file non valido o non supportato [import persistence failed] -Not saved: select Import to retry or Export file +Non salvato: scegli Importa per riprovare o Esporta file [import progress] -Import progress +Avanzamento importazione [export progress] -Export progress +Avanzamento esportazione [retry save] -Retry save +Riprova il salvataggio [campaign save failed] -Campaign progress not saved. Retry or export a backup. +Progresso della campagna non salvato. Riprova o esporta un backup. [leave without saving] -Leave without saving +Esci senza salvare [campaign import failed] -Invalid progress file or different campaign version +File di progresso non valido o versione della campagna diversa [network release mismatch] -Client and server protocols differ. Install the same Glob2 release as the server. +I protocolli client e server differiscono. Installa la stessa versione di Glob2 del server. [settings continue] -Continue +Continua [settings save failed] -Settings save not confirmed. Retry, or continue. +Salvataggio delle impostazioni non confermato. Riprova, oppure continua. [shutdown save failed] -Final save not confirmed. Retry, or quit without saving. +Salvataggio finale non confermato. Riprova, oppure esci senza salvare. [quit without saving] -Quit without saving +Esci senza salvare [game closed] -Game closed. You can close this window or reload to play again. +Partita chiusa. Puoi chiudere questa finestra o ricaricare per giocare di nuovo. [campaign editor save failed] -Save not confirmed. OK retries; Cancel returns to the editor menu. +Salvataggio non confermato. Ok riprova; Annulla torna al menu dell'editor. diff --git a/data/texts.ja.txt b/data/texts.ja.txt index 16ed9ba33..5b37450e0 100644 --- a/data/texts.ja.txt +++ b/data/texts.ja.txt @@ -1763,80 +1763,80 @@ Globulation 2 のカーソルを表示します。 [settings Automatically show the torus overview while moving around the map (OpenGL).] マップ内を移動するときに、トーラス(ドーナツ)形状のマップ全体図を自動的に表示します (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. +クライアントとサーバーのプロトコルが異なります。サーバーと同じGlob2のバージョンをインストールしてください。 [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. +保存が確認されていません。OKで再試行、キャンセルでエディタメニューに戻ります。 diff --git a/data/texts.ko.txt b/data/texts.ko.txt index 9f8a84cfb..a3efdcaea 100644 --- a/data/texts.ko.txt +++ b/data/texts.ko.txt @@ -1763,80 +1763,80 @@ Globulation 2 커서를 표시합니다. [settings Automatically show the torus overview while moving around the map (OpenGL).] 지도 안에서 이동할 때 토러스(도넛) 모양의 지도 전체 보기를 자동으로 표시합니다(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. +클라이언트와 서버의 프로토콜이 다릅니다. 서버와 동일한 Glob2 버전을 설치하세요. [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.nl.txt b/data/texts.nl.txt index c383b0c19..19648d67f 100644 --- a/data/texts.nl.txt +++ b/data/texts.nl.txt @@ -1791,80 +1791,80 @@ 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] -The map could not be saved. Check the destination and available space. Your edits are still open. +De kaart kon niet worden opgeslagen. Controleer de bestemming en beschikbare ruimte. Uw wijzigingen staan nog open. [Loading headers] -Loading headers... +Kopgegevens worden geladen... [Loading teams] -Loading teams... +Teams worden geladen... [Loading terrain] -Loading terrain... +Terrein wordt geladen... [Building gradients] -Building gradients... +Gradiënten worden opgebouwd... [Loading players] -Loading players... +Spelers worden geladen... [Loading scripts] -Loading scripts... +Scripts worden geladen... [Generating map] -Generating map... +Kaart wordt gegenereerd... [ERROR_CANT_GENERATE_MAP] -The map could not be generated. Try different generation settings. +De kaart kon niet worden gegenereerd. Probeer andere generatie-instellingen. [Loading units] -Loading units... +Eenheden worden geladen... [Loading buildings] -Loading buildings... +Gebouwen worden geladen... [Resolving team links] -Resolving team links... +Teamkoppelingen worden herleid... [saving to storage] -Saving... +Bezig met opslaan... [save failed retry] -Save failed. Retry. +Opslaan mislukt. Probeer opnieuw. [export save] -Export save +Opgeslagen spel exporteren [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. +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] -Continue +Doorgaan [export file] -Export file +Bestand exporteren [export failed] -File export failed +Bestand exporteren mislukt [import file] -Import +Importeren [select import file] -Choose a file to import +Kies een bestand om te importeren [validating import] -Validating file... +Bestand wordt gecontroleerd... [import succeeded] -File imported +Bestand geïmporteerd [import cancelled] -Import cancelled +Importeren geannuleerd [import failed] -Import failed: invalid or unsupported file +Importeren mislukt: ongeldig of niet-ondersteund bestand [import persistence failed] -Not saved: select Import to retry or Export file +Niet opgeslagen: kies Importeren om opnieuw te proberen of Bestand exporteren [import progress] -Import progress +Voortgang van import [export progress] -Export progress +Voortgang van export [retry save] -Retry save +Opslaan opnieuw proberen [campaign save failed] -Campaign progress not saved. Retry or export a backup. +Campagnevoortgang niet opgeslagen. Probeer opnieuw of exporteer een back-up. [leave without saving] -Leave without saving +Afsluiten zonder op te slaan [campaign import failed] -Invalid progress file or different campaign version +Ongeldig voortgangsbestand of andere campagneversie [network release mismatch] -Client and server protocols differ. Install the same Glob2 release as the server. +Client- en serverprotocol verschillen. Installeer dezelfde Glob2-versie als de server. [settings continue] -Continue +Doorgaan [settings save failed] -Settings save not confirmed. Retry, or continue. +Opslaan van instellingen niet bevestigd. Probeer opnieuw, of ga door. [shutdown save failed] -Final save not confirmed. Retry, or quit without saving. +Laatste keer opslaan niet bevestigd. Probeer opnieuw, of sluit af zonder op te slaan. [quit without saving] -Quit without saving +Afsluiten zonder op te slaan [game closed] -Game closed. You can close this window or reload to play again. +Spel gesloten. U kunt dit venster sluiten of herladen om opnieuw te spelen. [campaign editor save failed] -Save not confirmed. OK retries; Cancel returns to the editor menu. +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 627a7ae53..6bafef3d1 100644 --- a/data/texts.pl.txt +++ b/data/texts.pl.txt @@ -1767,80 +1767,80 @@ 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] -The map could not be saved. Check the destination and available space. Your edits are still open. +Nie udało się zapisać mapy. Sprawdź miejsce docelowe i dostępną przestrzeń. Twoje zmiany wciąż są otwarte. [Loading headers] -Loading headers... +Wczytywanie nagłówków... [Loading teams] -Loading teams... +Wczytywanie drużyn... [Loading terrain] -Loading terrain... +Wczytywanie terenu... [Building gradients] -Building gradients... +Tworzenie gradientów... [Loading players] -Loading players... +Wczytywanie graczy... [Loading scripts] -Loading scripts... +Wczytywanie skryptów... [Generating map] -Generating map... +Generowanie mapy... [ERROR_CANT_GENERATE_MAP] -The map could not be generated. Try different generation settings. +Nie udało się wygenerować mapy. Spróbuj innych ustawień generowania. [Loading units] -Loading units... +Wczytywanie jednostek... [Loading buildings] -Loading buildings... +Wczytywanie budynków... [Resolving team links] -Resolving team links... +Rozwiązywanie powiązań drużyn... [saving to storage] -Saving... +Zapisywanie... [save failed retry] -Save failed. Retry. +Zapis nieudany. Spróbuj ponownie. [export save] -Export save +Eksportuj zapis [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. +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] -Continue +Kontynuuj [export file] -Export file +Eksportuj plik [export failed] -File export failed +Eksport pliku nie powiódł się [import file] -Import +Importuj [select import file] -Choose a file to import +Wybierz plik do zaimportowania [validating import] -Validating file... +Sprawdzanie pliku... [import succeeded] -File imported +Plik zaimportowany [import cancelled] -Import cancelled +Import anulowany [import failed] -Import failed: invalid or unsupported file +Import nie powiódł się: nieprawidłowy lub nieobsługiwany plik [import persistence failed] -Not saved: select Import to retry or Export file +Niezapisane: wybierz Importuj, aby spróbować ponownie, lub Eksportuj plik [import progress] -Import progress +Postęp importu [export progress] -Export progress +Postęp eksportu [retry save] -Retry save +Ponów zapis [campaign save failed] -Campaign progress not saved. Retry or export a backup. +Postęp kampanii nie został zapisany. Spróbuj ponownie lub wyeksportuj kopię zapasową. [leave without saving] -Leave without saving +Wyjdź bez zapisywania [campaign import failed] -Invalid progress file or different campaign version +Nieprawidłowy plik postępu lub inna wersja kampanii [network release mismatch] -Client and server protocols differ. Install the same Glob2 release as the server. +Protokoły klienta i serwera się różnią. Zainstaluj taką samą wersję Glob2 jak na serwerze. [settings continue] -Continue +Kontynuuj [settings save failed] -Settings save not confirmed. Retry, or continue. +Zapis ustawień niepotwierdzony. Spróbuj ponownie lub kontynuuj. [shutdown save failed] -Final save not confirmed. Retry, or quit without saving. +Zapis końcowy niepotwierdzony. Spróbuj ponownie lub wyjdź bez zapisywania. [quit without saving] -Quit without saving +Wyjdź bez zapisywania [game closed] -Game closed. You can close this window or reload to play again. +Gra zamknięta. Możesz zamknąć to okno lub przeładować stronę, aby zagrać ponownie. [campaign editor save failed] -Save not confirmed. OK retries; Cancel returns to the editor menu. +Zapis niepotwierdzony. OK ponawia próbę; Anuluj wraca do menu edytora. diff --git a/data/texts.pt.txt b/data/texts.pt.txt index 459002acb..ff066df38 100644 --- a/data/texts.pt.txt +++ b/data/texts.pt.txt @@ -1773,80 +1773,80 @@ 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] -The map could not be saved. Check the destination and available space. Your edits are still open. +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] -Loading headers... +A carregar cabeçalhos... [Loading teams] -Loading teams... +A carregar equipas... [Loading terrain] -Loading terrain... +A carregar o terreno... [Building gradients] -Building gradients... +A gerar gradientes... [Loading players] -Loading players... +A carregar jogadores... [Loading scripts] -Loading scripts... +A carregar scripts... [Generating map] -Generating map... +A gerar o mapa... [ERROR_CANT_GENERATE_MAP] -The map could not be generated. Try different generation settings. +Não foi possível gerar o mapa. Experimente outras definições de geração. [Loading units] -Loading units... +A carregar unidades... [Loading buildings] -Loading buildings... +A carregar construções... [Resolving team links] -Resolving team links... +A resolver ligações de equipa... [saving to storage] -Saving... +A gravar... [save failed retry] -Save failed. Retry. +Falha ao gravar. Tente novamente. [export save] -Export save +Exportar jogo gravado [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. +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] -Continue +Continuar [export file] -Export file +Exportar ficheiro [export failed] -File export failed +Falha ao exportar o ficheiro [import file] -Import +Importar [select import file] -Choose a file to import +Escolha um ficheiro para importar [validating import] -Validating file... +A validar o ficheiro... [import succeeded] -File imported +Ficheiro importado [import cancelled] -Import cancelled +Importação cancelada [import failed] -Import failed: invalid or unsupported file +Falha na importação: ficheiro inválido ou não suportado [import persistence failed] -Not saved: select Import to retry or Export file +Não gravado: escolha Importar para tentar novamente ou Exportar ficheiro [import progress] -Import progress +Progresso da importação [export progress] -Export progress +Progresso da exportação [retry save] -Retry save +Tentar gravar novamente [campaign save failed] -Campaign progress not saved. Retry or export a backup. +Progresso da campanha não gravado. Tente novamente ou exporte uma cópia de segurança. [leave without saving] -Leave without saving +Sair sem gravar [campaign import failed] -Invalid progress file or different campaign version +Ficheiro de progresso inválido ou versão de campanha diferente [network release mismatch] -Client and server protocols differ. Install the same Glob2 release as the server. +Os protocolos do cliente e do servidor são diferentes. Instale a mesma versão do Glob2 que o servidor. [settings continue] -Continue +Continuar [settings save failed] -Settings save not confirmed. Retry, or continue. +Gravação das definições não confirmada. Tente novamente, ou continue. [shutdown save failed] -Final save not confirmed. Retry, or quit without saving. +Gravação final não confirmada. Tente novamente, ou saia sem gravar. [quit without saving] -Quit without saving +Sair sem gravar [game closed] -Game closed. You can close this window or reload to play again. +Jogo fechado. Pode fechar esta janela ou recarregar para jogar novamente. [campaign editor save failed] -Save not confirmed. OK retries; Cancel returns to the editor menu. +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 cd886ec53..22503b1f0 100644 --- a/data/texts.ro.txt +++ b/data/texts.ro.txt @@ -1767,80 +1767,80 @@ 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] -The map could not be saved. Check the destination and available space. Your edits are still open. +Harta nu a putut fi salvată. Verificați destinația și spațiul disponibil. Modificările dvs. sunt încă deschise. [Loading headers] -Loading headers... +Se încarcă anteturile... [Loading teams] -Loading teams... +Se încarcă echipele... [Loading terrain] -Loading terrain... +Se încarcă terenul... [Building gradients] -Building gradients... +Se generează gradienții... [Loading players] -Loading players... +Se încarcă jucătorii... [Loading scripts] -Loading scripts... +Se încarcă scripturile... [Generating map] -Generating map... +Se generează harta... [ERROR_CANT_GENERATE_MAP] -The map could not be generated. Try different generation settings. +Harta nu a putut fi generată. Încercați alte setări de generare. [Loading units] -Loading units... +Se încarcă unitățile... [Loading buildings] -Loading buildings... +Se încarcă construcțiile... [Resolving team links] -Resolving team links... +Se rezolvă legăturile dintre echipe... [saving to storage] -Saving... +Se salvează... [save failed retry] -Save failed. Retry. +Salvare eșuată. Reîncercați. [export save] -Export save +Exportă jocul salvat [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. +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] -Continue +Continuă [export file] -Export file +Exportă fișierul [export failed] -File export failed +Exportul fișierului a eșuat [import file] -Import +Importă [select import file] -Choose a file to import +Alegeți un fișier de importat [validating import] -Validating file... +Se validează fișierul... [import succeeded] -File imported +Fișier importat [import cancelled] -Import cancelled +Import anulat [import failed] -Import failed: invalid or unsupported file +Import eșuat: fișier nevalid sau neacceptat [import persistence failed] -Not saved: select Import to retry or Export file +Nesalvat: alegeți Importă pentru a reîncerca sau Exportă fișierul [import progress] -Import progress +Progres import [export progress] -Export progress +Progres export [retry save] -Retry save +Reîncearcă salvarea [campaign save failed] -Campaign progress not saved. Retry or export a backup. +Progresul campaniei nu a fost salvat. Reîncercați sau exportați o copie de rezervă. [leave without saving] -Leave without saving +Ieși fără a salva [campaign import failed] -Invalid progress file or different campaign version +Fișier de progres nevalid sau versiune de campanie diferită [network release mismatch] -Client and server protocols differ. Install the same Glob2 release as the server. +Protocoalele clientului și serverului diferă. Instalați aceeași versiune Glob2 ca serverul. [settings continue] -Continue +Continuă [settings save failed] -Settings save not confirmed. Retry, or continue. +Salvarea setărilor neconfirmată. Reîncercați sau continuați. [shutdown save failed] -Final save not confirmed. Retry, or quit without saving. +Salvarea finală neconfirmată. Reîncercați sau ieșiți fără a salva. [quit without saving] -Quit without saving +Ieși fără a salva [game closed] -Game closed. You can close this window or reload to play again. +Joc închis. Puteți închide această fereastră sau reîncărca pentru a juca din nou. [campaign editor save failed] -Save not confirmed. OK retries; Cancel returns to the editor menu. +Salvare neconfirmată. OK reîncearcă; Anulare revine la meniul editorului. diff --git a/data/texts.ru.txt b/data/texts.ru.txt index 9688f6a42..74ec70d22 100644 --- a/data/texts.ru.txt +++ b/data/texts.ru.txt @@ -1765,80 +1765,80 @@ OpenGL недоступен в этой сборке. [settings Automatically show the torus overview while moving around the map (OpenGL).] Автоматически отображать обзор карты в форме тора при перемещении по карте (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. +Протоколы клиента и сервера различаются. Установите ту же версию Glob2, что и на сервере. [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.si.txt b/data/texts.si.txt index 67f7e8db8..1ebe113d4 100644 --- a/data/texts.si.txt +++ b/data/texts.si.txt @@ -1767,80 +1767,80 @@ 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] -The map could not be saved. Check the destination and available space. Your edits are still open. +Zemljevida ni bilo mogoče shraniti. Preverite cilj in razpoložljiv prostor. Vaše spremembe so še vedno odprte. [Loading headers] -Loading headers... +Nalaganje glav... [Loading teams] -Loading teams... +Nalaganje ekip... [Loading terrain] -Loading terrain... +Nalaganje terena... [Building gradients] -Building gradients... +Ustvarjanje gradientov... [Loading players] -Loading players... +Nalaganje igralcev... [Loading scripts] -Loading scripts... +Nalaganje skriptov... [Generating map] -Generating map... +Ustvarjanje zemljevida... [ERROR_CANT_GENERATE_MAP] -The map could not be generated. Try different generation settings. +Zemljevida ni bilo mogoče ustvariti. Poskusite druge nastavitve ustvarjanja. [Loading units] -Loading units... +Nalaganje enot... [Loading buildings] -Loading buildings... +Nalaganje stavb... [Resolving team links] -Resolving team links... +Razreševanje povezav ekip... [saving to storage] -Saving... +Shranjevanje... [save failed retry] -Save failed. Retry. +Shranjevanje ni uspelo. Poskusite znova. [export save] -Export save +Izvozi shranjeno igro [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. +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] -Continue +Nadaljuj [export file] -Export file +Izvozi datoteko [export failed] -File export failed +Izvoz datoteke ni uspel [import file] -Import +Uvozi [select import file] -Choose a file to import +Izberite datoteko za uvoz [validating import] -Validating file... +Preverjanje datoteke... [import succeeded] -File imported +Datoteka uvožena [import cancelled] -Import cancelled +Uvoz preklican [import failed] -Import failed: invalid or unsupported file +Uvoz ni uspel: neveljavna ali nepodprta datoteka [import persistence failed] -Not saved: select Import to retry or Export file +Ni shranjeno: izberite Uvozi za nov poskus, ali Izvozi datoteko [import progress] -Import progress +Napredek uvoza [export progress] -Export progress +Napredek izvoza [retry save] -Retry save +Poskusi znova shraniti [campaign save failed] -Campaign progress not saved. Retry or export a backup. +Napredek pohoda ni bil shranjen. Poskusite znova ali izvozite varnostno kopijo. [leave without saving] -Leave without saving +Izhod brez shranjevanja [campaign import failed] -Invalid progress file or different campaign version +Neveljavna datoteka napredka ali druga različica pohoda [network release mismatch] -Client and server protocols differ. Install the same Glob2 release as the server. +Protokola odjemalca in strežnika se razlikujeta. Namestite enako različico Glob2 kot strežnik. [settings continue] -Continue +Nadaljuj [settings save failed] -Settings save not confirmed. Retry, or continue. +Shranjevanje nastavitev ni potrjeno. Poskusite znova ali nadaljujte. [shutdown save failed] -Final save not confirmed. Retry, or quit without saving. +Končno shranjevanje ni potrjeno. Poskusite znova ali izstopite brez shranjevanja. [quit without saving] -Quit without saving +Izhod brez shranjevanja [game closed] -Game closed. You can close this window or reload to play again. +Igra je zaprta. To okno lahko zaprete ali stran znova naložite, da igrate še enkrat. [campaign editor save failed] -Save not confirmed. OK retries; Cancel returns to the editor menu. +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 5fd5e809c..f096c6b2e 100644 --- a/data/texts.sk.txt +++ b/data/texts.sk.txt @@ -1775,80 +1775,80 @@ 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] -The map could not be saved. Check the destination and available space. Your edits are still open. +Mapu sa nepodarilo uložiť. Skontrolujte cieľ a dostupné miesto. Vaše úpravy sú stále otvorené. [Loading headers] -Loading headers... +Načítavanie hlavičiek... [Loading teams] -Loading teams... +Načítavanie tímov... [Loading terrain] -Loading terrain... +Načítavanie terénu... [Building gradients] -Building gradients... +Vytvárajú sa prechody... [Loading players] -Loading players... +Načítavanie hráčov... [Loading scripts] -Loading scripts... +Načítavanie skriptov... [Generating map] -Generating map... +Generuje sa mapa... [ERROR_CANT_GENERATE_MAP] -The map could not be generated. Try different generation settings. +Mapu sa nepodarilo vygenerovať. Skúste iné nastavenia generovania. [Loading units] -Loading units... +Načítavanie jednotiek... [Loading buildings] -Loading buildings... +Načítavanie budov... [Resolving team links] -Resolving team links... +Riešia sa prepojenia tímov... [saving to storage] -Saving... +Ukladá sa... [save failed retry] -Save failed. Retry. +Uloženie zlyhalo. Skúste to znova. [export save] -Export save +Exportovať uloženú hru [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. +Ú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] -Continue +Pokračovať [export file] -Export file +Exportovať súbor [export failed] -File export failed +Export súboru zlyhal [import file] -Import +Importovať [select import file] -Choose a file to import +Vyberte súbor na import [validating import] -Validating file... +Overuje sa súbor... [import succeeded] -File imported +Súbor bol importovaný [import cancelled] -Import cancelled +Import zrušený [import failed] -Import failed: invalid or unsupported file +Import zlyhal: neplatný alebo nepodporovaný súbor [import persistence failed] -Not saved: select Import to retry or Export file +Neuložené: vyberte Importovať na opätovný pokus, alebo Exportovať súbor [import progress] -Import progress +Priebeh importu [export progress] -Export progress +Priebeh exportu [retry save] -Retry save +Skúsiť uloženie znova [campaign save failed] -Campaign progress not saved. Retry or export a backup. +Postup kampane nebol uložený. Skúste to znova alebo exportujte zálohu. [leave without saving] -Leave without saving +Ukončiť bez uloženia [campaign import failed] -Invalid progress file or different campaign version +Neplatný súbor postupu alebo iná verzia kampane [network release mismatch] -Client and server protocols differ. Install the same Glob2 release as the server. +Protokoly klienta a servera sa líšia. Nainštalujte rovnakú verziu Glob2 ako server. [settings continue] -Continue +Pokračovať [settings save failed] -Settings save not confirmed. Retry, or continue. +Uloženie nastavení nepotvrdené. Skúste to znova, alebo pokračujte. [shutdown save failed] -Final save not confirmed. Retry, or quit without saving. +Záverečné uloženie nepotvrdené. Skúste to znova, alebo ukončite bez uloženia. [quit without saving] -Quit without saving +Ukončiť bez uloženia [game closed] -Game closed. You can close this window or reload to play again. +Hra bola ukončená. Toto okno môžete zavrieť alebo znova načítať stránku a hrať znova. [campaign editor save failed] -Save not confirmed. OK retries; Cancel returns to the editor menu. +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 66c583fa6..84872ce83 100644 --- a/data/texts.sr.txt +++ b/data/texts.sr.txt @@ -1767,80 +1767,80 @@ OpenGL није доступан у овој верзији. [settings Automatically show the torus overview while moving around the map (OpenGL).] Аутоматски прикажи преглед мапе у облику торуса током кретања по мапи (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. +Протоколи клијента и сервера се разликују. Инсталирајте исту верзију Glob2 као сервер. [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.sv.txt b/data/texts.sv.txt index 289ad88ab..f586c8594 100644 --- a/data/texts.sv.txt +++ b/data/texts.sv.txt @@ -1777,80 +1777,80 @@ 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] -The map could not be saved. Check the destination and available space. Your edits are still open. +Kartan kunde inte sparas. Kontrollera destinationen och tillgängligt utrymme. Dina ändringar är fortfarande öppna. [Loading headers] -Loading headers... +Läser in rubriker... [Loading teams] -Loading teams... +Läser in lag... [Loading terrain] -Loading terrain... +Läser in terräng... [Building gradients] -Building gradients... +Bygger gradienter... [Loading players] -Loading players... +Läser in spelare... [Loading scripts] -Loading scripts... +Läser in skript... [Generating map] -Generating map... +Skapar karta... [ERROR_CANT_GENERATE_MAP] -The map could not be generated. Try different generation settings. +Kartan kunde inte skapas. Prova andra genereringsinställningar. [Loading units] -Loading units... +Läser in enheter... [Loading buildings] -Loading buildings... +Läser in byggnader... [Resolving team links] -Resolving team links... +Löser lagkopplingar... [saving to storage] -Saving... +Sparar... [save failed retry] -Save failed. Retry. +Sparning misslyckades. Försök igen. [export save] -Export save +Exportera sparfil [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. +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] -Continue +Fortsätt [export file] -Export file +Exportera fil [export failed] -File export failed +Filexport misslyckades [import file] -Import +Importera [select import file] -Choose a file to import +Välj en fil att importera [validating import] -Validating file... +Verifierar fil... [import succeeded] -File imported +Fil importerad [import cancelled] -Import cancelled +Import avbruten [import failed] -Import failed: invalid or unsupported file +Import misslyckades: ogiltig eller ej stödd fil [import persistence failed] -Not saved: select Import to retry or Export file +Ej sparat: välj Importera för att försöka igen, eller Exportera fil [import progress] -Import progress +Importförlopp [export progress] -Export progress +Exportförlopp [retry save] -Retry save +Försök spara igen [campaign save failed] -Campaign progress not saved. Retry or export a backup. +Kampanjförloppet sparades inte. Försök igen eller exportera en säkerhetskopia. [leave without saving] -Leave without saving +Avsluta utan att spara [campaign import failed] -Invalid progress file or different campaign version +Ogiltig förloppsfil eller annan kampanjversion [network release mismatch] -Client and server protocols differ. Install the same Glob2 release as the server. +Klient- och serverprotokoll skiljer sig åt. Installera samma Glob2-version som servern. [settings continue] -Continue +Fortsätt [settings save failed] -Settings save not confirmed. Retry, or continue. +Sparning av inställningar ej bekräftad. Försök igen, eller fortsätt. [shutdown save failed] -Final save not confirmed. Retry, or quit without saving. +Slutlig sparning ej bekräftad. Försök igen, eller avsluta utan att spara. [quit without saving] -Quit without saving +Avsluta utan att spara [game closed] -Game closed. You can close this window or reload to play again. +Spelet stängt. Du kan stänga det här fönstret eller ladda om för att spela igen. [campaign editor save failed] -Save not confirmed. OK retries; Cancel returns to the editor menu. +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 4e264bba6..3eaa9c8d3 100644 --- a/data/texts.tr.txt +++ b/data/texts.tr.txt @@ -1775,80 +1775,80 @@ 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] -The map could not be saved. Check the destination and available space. Your edits are still open. +Harita kaydedilemedi. Hedefi ve kullanılabilir alanı kontrol edin. Düzenlemeleriniz hâlâ açık. [Loading headers] -Loading headers... +Başlıklar yükleniyor... [Loading teams] -Loading teams... +Takımlar yükleniyor... [Loading terrain] -Loading terrain... +Arazi yükleniyor... [Building gradients] -Building gradients... +Gradyanlar oluşturuluyor... [Loading players] -Loading players... +Oyuncular yükleniyor... [Loading scripts] -Loading scripts... +Betikler yükleniyor... [Generating map] -Generating map... +Harita oluşturuluyor... [ERROR_CANT_GENERATE_MAP] -The map could not be generated. Try different generation settings. +Harita oluşturulamadı. Farklı oluşturma ayarları deneyin. [Loading units] -Loading units... +Birimler yükleniyor... [Loading buildings] -Loading buildings... +Binalar yükleniyor... [Resolving team links] -Resolving team links... +Takım bağlantıları çözümleniyor... [saving to storage] -Saving... +Kaydediliyor... [save failed retry] -Save failed. Retry. +Kayıt başarısız oldu. Yeniden deneyin. [export save] -Export save +Kaydı dışa aktar [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. +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] -Continue +Devam et [export file] -Export file +Dosyayı dışa aktar [export failed] -File export failed +Dosya dışa aktarma başarısız oldu [import file] -Import +İçe aktar [select import file] -Choose a file to import +İçe aktarılacak bir dosya seçin [validating import] -Validating file... +Dosya doğrulanıyor... [import succeeded] -File imported +Dosya içe aktarıldı [import cancelled] -Import cancelled +İçe aktarma iptal edildi [import failed] -Import failed: invalid or unsupported file +İçe aktarma başarısız: geçersiz veya desteklenmeyen dosya [import persistence failed] -Not saved: select Import to retry or Export file +Kaydedilmedi: yeniden denemek için İçe Aktar veya Dosyayı Dışa Aktar'ı seçin [import progress] -Import progress +İçe aktarma ilerlemesi [export progress] -Export progress +Dışa aktarma ilerlemesi [retry save] -Retry save +Kaydetmeyi yeniden dene [campaign save failed] -Campaign progress not saved. Retry or export a backup. +Kampanya ilerlemesi kaydedilmedi. Yeniden deneyin veya bir yedek dışa aktarın. [leave without saving] -Leave without saving +Kaydetmeden çık [campaign import failed] -Invalid progress file or different campaign version +Geçersiz ilerleme dosyası veya farklı kampanya sürümü [network release mismatch] -Client and server protocols differ. Install the same Glob2 release as the server. +İstemci ve sunucu protokolleri farklı. Sunucuyla aynı Glob2 sürümünü yükleyin. [settings continue] -Continue +Devam et [settings save failed] -Settings save not confirmed. Retry, or continue. +Ayar kaydı onaylanmadı. Yeniden deneyin veya devam edin. [shutdown save failed] -Final save not confirmed. Retry, or quit without saving. +Son kayıt onaylanmadı. Yeniden deneyin veya kaydetmeden çıkın. [quit without saving] -Quit without saving +Kaydetmeden çık [game closed] -Game closed. You can close this window or reload to play again. +Oyun kapatıldı. Bu pencereyi kapatabilir veya yeniden oynamak için sayfayı yenileyebilirsiniz. [campaign editor save failed] -Save not confirmed. OK retries; Cancel returns to the editor menu. +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 92e6bf675..1fec6c088 100644 --- a/data/texts.uk.txt +++ b/data/texts.uk.txt @@ -1763,80 +1763,80 @@ OpenGL недоступний у цій збірці. [settings Automatically show the torus overview while moving around the map (OpenGL).] Автоматично показувати огляд карти у формі тора під час переміщення по карті (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. +Протоколи клієнта й сервера відрізняються. Установіть таку саму версію Glob2, як на сервері. [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.vi.txt b/data/texts.vi.txt index 2978e4f60..99ac665cc 100644 --- a/data/texts.vi.txt +++ b/data/texts.vi.txt @@ -1763,80 +1763,80 @@ 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] -The map could not be saved. Check the destination and available space. Your edits are still open. +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] -Loading headers... +Đang tải phần đầu... [Loading teams] -Loading teams... +Đang tải đội... [Loading terrain] -Loading terrain... +Đang tải địa hình... [Building gradients] -Building gradients... +Đang dựng gradient... [Loading players] -Loading players... +Đang tải người chơi... [Loading scripts] -Loading scripts... +Đang tải kịch bản... [Generating map] -Generating map... +Đang tạo bản đồ... [ERROR_CANT_GENERATE_MAP] -The map could not be generated. Try different generation settings. +Không thể tạo bản đồ. Hãy thử các thiết lập tạo khác. [Loading units] -Loading units... +Đang tải đơn vị... [Loading buildings] -Loading buildings... +Đang tải công trình... [Resolving team links] -Resolving team links... +Đang phân giải liên kết đội... [saving to storage] -Saving... +Đang lưu... [save failed retry] -Save failed. Retry. +Lưu thất bại. Hãy thử lại. [export save] -Export save +Xuất bản lưu [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. +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] -Continue +Tiếp tục [export file] -Export file +Xuất tệp [export failed] -File export failed +Xuất tệp thất bại [import file] -Import +Nhập [select import file] -Choose a file to import +Chọn tệp để nhập [validating import] -Validating file... +Đang kiểm tra tệp... [import succeeded] -File imported +Đã nhập tệp [import cancelled] -Import cancelled +Đã hủy nhập [import failed] -Import failed: invalid or unsupported file +Nhập thất bại: tệp không hợp lệ hoặc không được hỗ trợ [import persistence failed] -Not saved: select Import to retry or Export file +Chưa lưu: chọn Nhập để thử lại, hoặc Xuất tệp [import progress] -Import progress +Tiến trình nhập [export progress] -Export progress +Tiến trình xuất [retry save] -Retry save +Thử lưu lại [campaign save failed] -Campaign progress not saved. Retry or export a backup. +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] -Leave without saving +Thoát mà không lưu [campaign import failed] -Invalid progress file or different campaign version +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] -Client and server protocols differ. Install the same Glob2 release as the server. +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] -Continue +Tiếp tục [settings save failed] -Settings save not confirmed. Retry, or continue. +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] -Final save not confirmed. Retry, or quit without saving. +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] -Quit without saving +Thoát mà không lưu [game closed] -Game closed. You can close this window or reload to play again. +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] -Save not confirmed. OK retries; Cancel returns to the editor menu. +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 24308f799..6e1848d15 100644 --- a/data/texts.zh-cn.txt +++ b/data/texts.zh-cn.txt @@ -1763,80 +1763,80 @@ OpenGL 在此版本中不可用。 [settings Automatically show the torus overview while moving around the map (OpenGL).] 在地图上移动时自动显示圆环形状的地图概览 (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. +客户端与服务器协议不一致。请安装与服务器相同的 Glob2 版本。 [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.zh-tw.txt b/data/texts.zh-tw.txt index 800bda3a3..cddc7fbf1 100644 --- a/data/texts.zh-tw.txt +++ b/data/texts.zh-tw.txt @@ -1839,80 +1839,80 @@ OpenGL 在此版本中不可用。 [settings Automatically show the torus overview while moving around the map (OpenGL).] 在地圖上移動時自動顯示圓環形狀的地圖概覽 (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. +用戶端與伺服器通訊協定不一致。請安裝與伺服器相同的 Glob2 版本。 [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. +尚未確認儲存。「確定」重試;「取消」返回編輯器選單。 From f01e0207e7fb787da3dd51e3bddc766db9984a28 Mon Sep 17 00:00:00 2001 From: Bradley Arsenault Date: Thu, 10 Sep 2026 17:25:37 -0400 Subject: [PATCH 09/20] Fix compile/runtime gaps from the CustomGameScreen/SettingsScreen merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI caught what local scons/server builds didn't: none of the explicit harness targets (custom-setup-test, session-test, etc.) build under a bare `scons`, so the earlier rebase verification never compiled them. - test/CustomGameSetupHarness.cpp: fix six ScreenStack constructions left argument-less by the earlier mechanical patch (ScreenStack has no default constructor); rewrite the driver-based UI test to push CustomGameScreen onto a real ScreenStack and drive it via ScreenStack::execute(), matching SinglePlayerFlow::custom(), since Engine::initCustom(void) no longer exists. - Match speed selection was lost in the CustomGameScreen merge: master's redesigned lobby has CustomGameScreen::selectedSpeed(), but neither SinglePlayerFlow::custom() nor the old Engine::initCustom(void) it replaced actually applied it. Added an optional speed parameter to Engine::initCustomTask() (sets/restores previousCustomSpeed, mirroring the removed method) and wired it through SinglePlayerFlow and the test. - test/EngineSessionHarness.cpp used SettingsScreen::OK, an enum from the pre-#236 widget-based screen this branch never knew about; master's redesign has no such enum. Replaced with a direct done() call. - SettingsScreen::done() closed synchronously once local writes succeeded, never actually waiting on the browser persistence flush added during the rebase reconciliation — contradicted this exact session-test assertion ("Settings must poll persistence before closing"). done() now always confirms durability via persist() and only calls endExecute() once any pending flush resolves; onTimer() drives that completion (or reopens editing on a flush failure). Verified: all 24 harness targets CI builds compile clean locally, and engine-session-test (including "settings close only after persistence completion") and custom-setup-test both pass. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XyKsqjkUFwsmf7dxp9SYdV --- src/Engine.h | 2 +- src/EngineInit.cpp | 9 ++++++++- src/SettingsScreen.cpp | 15 +++++++++++++-- src/SettingsScreen.h | 2 ++ src/SinglePlayerFlow.cpp | 5 +++-- test/CustomGameSetupHarness.cpp | 26 ++++++++++++++++++-------- test/EngineSessionHarness.cpp | 2 +- 7 files changed, 46 insertions(+), 15 deletions(-) diff --git a/src/Engine.h b/src/Engine.h index 47cbced80..40b7da79f 100644 --- a/src/Engine.h +++ b/src/Engine.h @@ -56,7 +56,7 @@ class Engine /// 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); + GAGCore::CooperativeTask initCustomTask(MapHeader map, GameHeader players, int localTeam, int speed = -1); GAGCore::CooperativeTask initCustomTask(std::string filename); GAGCore::CooperativeTask initCampaignTask(std::string filename, Campaign* campaign = nullptr, std::string mission = {}); GAGCore::CooperativeTask loadReplayTask(std::string filename); diff --git a/src/EngineInit.cpp b/src/EngineInit.cpp index a3e840df1..3f2ef3583 100644 --- a/src/EngineInit.cpp +++ b/src/EngineInit.cpp @@ -52,10 +52,17 @@ int Engine::initCustom(MapHeader& map, GameHeader& players, int localTeam) if (!loaded) showMapLoadError(); return loaded ? EE_NO_ERROR : EE_CANT_LOAD_MAP; } -GAGCore::CooperativeTask Engine::initCustomTask(MapHeader map, GameHeader players, int localTeam) +GAGCore::CooperativeTask Engine::initCustomTask(MapHeader map, GameHeader players, int localTeam, int speed) { 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; + } co_return co_await initGameTask(map, players); } int Engine::initCustom(const std::string& filename) diff --git a/src/SettingsScreen.cpp b/src/SettingsScreen.cpp index c0205402f..4811eb34b 100644 --- a/src/SettingsScreen.cpp +++ b/src/SettingsScreen.cpp @@ -122,9 +122,18 @@ 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::onAction(Widget*,Action action,int,int) { @@ -142,6 +151,7 @@ void SettingsScreen::onTimer(Uint32 tick) 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. @@ -149,6 +159,7 @@ void SettingsScreen::onTimer(Uint32 tick) saveAt=tick+300; } persistence.reset(); + if(closing && !failed) { closing=false; endExecute(1); } } } } diff --git a/src/SettingsScreen.h b/src/SettingsScreen.h index 26f363206..58a5fabae 100644 --- a/src/SettingsScreen.h +++ b/src/SettingsScreen.h @@ -81,6 +81,8 @@ class SettingsScreen : public Glob2Screen // 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/SinglePlayerFlow.cpp b/src/SinglePlayerFlow.cpp index 2cc6e94cb..becf87624 100644 --- a/src/SinglePlayerFlow.cpp +++ b/src/SinglePlayerFlow.cpp @@ -29,8 +29,9 @@ 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)](Engine& engine) { - return engine.initCustomTask(map, players, team); + launch([map = selected.getMapHeader(), players = selected.getGameHeader(), team = selected.getSelectedColor(0), + speed = selected.selectedSpeed()](Engine& engine) { + return engine.initCustomTask(map, players, team, speed); }, true); }); } diff --git a/test/CustomGameSetupHarness.cpp b/test/CustomGameSetupHarness.cpp index 91bcf4d09..eb4a05686 100644 --- a/test/CustomGameSetupHarness.cpp +++ b/test/CustomGameSetupHarness.cpp @@ -98,7 +98,7 @@ struct CustomGameSetupHarness if (write) files->remove(CustomGamePreferences::filename); if (write) { - GAGGUI::ScreenStack screens; + GAGGUI::ScreenStack screens(*globalContainer->gfx); CustomGameScreen screen(screens); assert(screen.validMap && screen.setup.capacity == 4); assert(screen.setup.setController(2, CustomGameSetup::Shared)); @@ -123,7 +123,7 @@ struct CustomGameSetupHarness { std::string premade; { - GAGGUI::ScreenStack screens; + 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); @@ -143,7 +143,7 @@ struct CustomGameSetupHarness assert(screen.previewPending); } { - GAGGUI::ScreenStack screens; + GAGGUI::ScreenStack screens(*globalContainer->gfx); CustomGameScreen screen(screens); assert(screen.setup.random && screen.previewPending && !screen.validMap); assert(screen.snapshot.empty() && screen.source.empty()); @@ -154,7 +154,7 @@ struct CustomGameSetupHarness screen.setup.premadeMap = "/missing/saved-map.map"; } { - GAGGUI::ScreenStack screens; + 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); @@ -165,7 +165,7 @@ struct CustomGameSetupHarness out.write(truncated.data(), truncated.size(), "broken preferences"); }); { - GAGGUI::ScreenStack screens; + GAGGUI::ScreenStack screens(*globalContainer->gfx); CustomGameScreen screen(screens); assert(screen.validMap && screen.setup.capacity == 4 && screen.setup.speed == 0); } @@ -270,6 +270,16 @@ struct CustomGameSetupHarness globalContainer->settings.gameSpeed = 7; { Engine engine; + GAGGUI::ScreenStack screens(*globalContainer->gfx); + bool loaded = false; + screens.push(std::make_unique(screens), + [&](GAGGUI::Screen &screen, int result) + { + if (result != CustomGameScreen::OK) return; + auto &selected = static_cast(screen); + loaded = engine.initCustomTask(selected.getMapHeader(), selected.getGameHeader(), + selected.getSelectedColor(0), selected.selectedSpeed()).run(); + }); auto timer = SDL_AddTimer(500, Driver::tick, &driver); assert(timer); auto watchdog = SDL_AddTimer( @@ -282,10 +292,10 @@ struct CustomGameSetupHarness return 0; }, nullptr); - int result = engine.initCustom(); + screens.execute(); SDL_RemoveTimer(timer); SDL_RemoveTimer(watchdog); - assert(result == Engine::EE_NO_ERROR); + assert(loaded); assert(driver.next == driver.steps.size()); assert(globalContainer->liveSpectating == (control == CustomGameSetup::Computer)); assert(globalContainer->settings.gameSpeed == 3); @@ -346,7 +356,7 @@ struct CustomGameSetupHarness assert(profile.returnCode == AINames::selectionIndex(AI::CORTEX)); } - GAGGUI::ScreenStack screens; + GAGGUI::ScreenStack screens(*globalContainer->gfx); CustomGameScreen screen(screens); screen.gfx = globalContainer->gfx; screen.dispatchInit(); diff --git a/test/EngineSessionHarness.cpp b/test/EngineSessionHarness.cpp index 1ef58a4f5..55ee6d957 100644 --- a/test/EngineSessionHarness.cpp +++ b/test/EngineSessionHarness.cpp @@ -198,7 +198,7 @@ int main(int argc, char** argv) { SettingsScreen settings; settings.beginExecution(globalContainer->gfx); - settings.onAction(nullptr, GAGGUI::BUTTON_RELEASED, SettingsScreen::OK, 0); + settings.done(); require(settings.isExecutionRunning(), "Settings must poll persistence before closing"); settings.onTimer(SDL_GetTicks()); require(!settings.isExecutionRunning(), "Durable native settings should complete"); From c5df9cb3474fbe74d5166a28ae33041f4dcd185c Mon Sep 17 00:00:00 2001 From: Bradley Arsenault Date: Thu, 10 Sep 2026 17:43:56 -0400 Subject: [PATCH 10/20] Fix remaining stale build/src/ paths in CI CustomGameSetupHarness (4 invocations) and BuildingExpelHarness were still on the pre-build_layout.py path, missed by the earlier trapped-unit-test fix and the merge that introduced these two steps. Audited every remaining harness path in the workflow against the build_layout.py // scheme; no others were stale. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XyKsqjkUFwsmf7dxp9SYdV --- .github/workflows/build.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f18c1c8de..f3b9d5c79 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -126,11 +126,11 @@ jobs: - 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 @@ -179,7 +179,7 @@ jobs: - 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: | From 7a3c399e4d97ff956c981be08892049c7d73b96c Mon Sep 17 00:00:00 2001 From: Bradley Arsenault Date: Thu, 10 Sep 2026 18:28:53 -0400 Subject: [PATCH 11/20] Make SettingsScreen::persist() write unconditionally, not only when dirty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's browser job failed 28 Playwright specs, almost all with the same symptom: the very first "open Settings, close it untouched" in a fresh profile never returns to MainMenuScreen (30s timeout). The one directly relevant spec that passed ("settings wait for durable storage before closing") deliberately stalls the storage write before ever attempting a real one — every spec that lets a real, unstalled first close happen hangs, including specs about unrelated screens/flows that just happen to touch Settings once during setup. Master's persist() only calls settings.save()/saveKeyboardLayout() when the corresponding dirty flag is set. The original (pre-redesign) savePreferences() this replaced always called them unconditionally. Every other caller of persist() (commit(), finishInteraction(), the onTimer retry path) already has at least one dirty flag set by the time it calls persist(), so gating never mattered there — done() calling persist() on a completely untouched screen was the one path this skipped real writes on, and it's exactly the failing scenario. Also restored the try/catch the original had around this whole sequence, which persist() had dropped. I can't run the actual browser/Playwright suite from here to confirm this is the full fix rather than a partial one; pushing it to let CI verify. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XyKsqjkUFwsmf7dxp9SYdV --- src/SettingsScreen.cpp | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/src/SettingsScreen.cpp b/src/SettingsScreen.cpp index 4811eb34b..dbc121b4d 100644 --- a/src/SettingsScreen.cpp +++ b/src/SettingsScreen.cpp @@ -93,20 +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]; - // 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; + 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(); } From 1273e92a4725800ed0a2047e6c400b776f946444 Mon Sep 17 00:00:00 2001 From: Bradley Arsenault Date: Thu, 10 Sep 2026 19:16:00 -0400 Subject: [PATCH 12/20] Fix browser Settings test failures: stale coordinates, not a save bug The 28 failing browser Playwright tests all shared one symptom (Settings never returned to MainMenuScreen), which I first misdiagnosed as a persist() bug. Running the actual Emscripten+Playwright toolchain locally (rather than guessing from CI logs) showed persistStorage() was never even being invoked: the tests click stale pixel coordinates left over from before master's Settings redesign (#236). A screenshot of the real 1200x900 layout put "Done" at (1015,777), nowhere near the tests' (600,650). Root-caused via direct instrumentation (Module.persistenceResults, Module.storage.state) and confirmed by clicking the real coordinate. While fixing the coordinates I found a genuine behavioral gap the redesign introduced: the old screen had a Cancel/Continue button that always closed in one click, independent of the save outcome; the new footer only had "Done" (gated on success) and no equivalent. Added it back as SettingsScreen::abandon(), wired to an always-visible footer button that relabels itself "Continue" once a save has actually failed, reusing the existing "[settings continue]"/"[settings Cancel]" translated strings. Verified empirically: Done still blocks while failed and retries on every click; the new button never retries and always closes in one click, matching storage.spec.js's single-click "restore failure" test and settings-storage.spec.js's "continue after failure" test. Added a formula-based clickSettingsDone/clickSettingsCancel helper to main-menu.js mirroring SettingsScreen::layout()'s panel/footer math, so the click position tracks the panel across any viewport instead of a hardcoded pixel. Verified against real screenshots at 1200x900, 900x650 and 1100x700. Also fixed a test-design bug this surfaced: the redesigned screen auto- saves on every change, so settings-storage.spec.js's fault-injection tests were injecting the storage fault *after* the dirty click, letting the write land before the fault ever applied. Moved fault injection before the change in both affected tests. Added pixels.js:hasDarkText for the redesigned screen's dark-text-on-paper footer status line (the existing hasLightText assumed the old dark-panel/ light-text styling). Verified locally with the full Emscripten/Playwright toolchain: - settings-storage.spec.js: 5/5 pass - shutdown-storage.spec.js, storage.spec.js's restore-failure test, viewport.spec.js's Settings-touching tests: all pass - Native release build (scons release=1) and web build (scons target=web release=1) both clean. CustomGameScreen (master's other redesign, #237) has the same class of stale-coordinate problem in single-player.spec.js, storage.spec.js and rendering.spec.js; that's unstarted and tracked separately. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XyKsqjkUFwsmf7dxp9SYdV --- browser/tests/main-menu.js | 29 +++++++++++++++++ browser/tests/pixels.js | 19 +++++++++++ browser/tests/rendering.spec.js | 4 +-- browser/tests/settings-storage.spec.js | 45 ++++++++++++++++---------- browser/tests/shutdown-storage.spec.js | 4 +-- browser/tests/single-player.spec.js | 4 +-- browser/tests/storage.spec.js | 4 +-- browser/tests/viewport.spec.js | 6 ++-- src/SettingsScreen.cpp | 12 +++++++ src/SettingsScreen.h | 1 + src/SettingsScreenInput.cpp | 6 ++-- src/SettingsScreenLayout.cpp | 6 +++- 12 files changed, 108 insertions(+), 32 deletions(-) diff --git a/browser/tests/main-menu.js b/browser/tests/main-menu.js index 22c28d91b..0cb365f8e 100644 --- a/browser/tests/main-menu.js +++ b/browser/tests/main-menu.js @@ -49,3 +49,32 @@ 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}); +}; diff --git a/browser/tests/pixels.js b/browser/tests/pixels.js index 16d8ac78f..e96b013a6 100644 --- a/browser/tests/pixels.js +++ b/browser/tests/pixels.js @@ -32,3 +32,22 @@ async function hasLightText(page, clip) { }, 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; diff --git a/browser/tests/rendering.spec.js b/browser/tests/rendering.spec.js index b6bd5a5b5..166b5fc19 100644 --- a/browser/tests/rendering.spec.js +++ b/browser/tests/rendering.spec.js @@ -1,5 +1,5 @@ const {test, expect} = require('@playwright/test'); -const {clickMainMenu}=require('./main-menu'); +const {clickMainMenu,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}); @@ -83,7 +83,7 @@ test('WebGL context restoration retains settings, editor and confirmation contro expect(await page.evaluate(() => document.querySelector('#canvas').getContext('webgl2').getError())).toBe(0); } await clickMainMenu(page,'settings'); await recover('SettingsScreen'); - await click(page,810,650); await screen(page,'MainMenuScreen'); + 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'); diff --git a/browser/tests/settings-storage.spec.js b/browser/tests/settings-storage.spec.js index c50a21267..b071e1780 100644 --- a/browser/tests/settings-storage.spec.js +++ b/browser/tests/settings-storage.spec.js @@ -1,32 +1,43 @@ const {test,expect}=require('@playwright/test'); -const {clickMainMenu,gameURL}=require('./main-menu'); +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 click(page,600,650);await screen(page,'MainMenuScreen'); + 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 click(page,520,585); // Actual Mute checkbox, relative to the centered settings panel. - await click(page,600,650);await screen(page,'MainMenuScreen'); + 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 click(page,600,650);await screen(page,'MainMenuScreen'); + 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 click(page,600,650); await screen(page,'MainMenuScreen'); + 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 click(page,520,370); // Turn high-quality graphics on using the actual toggle. + // 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; @@ -38,15 +49,16 @@ for (const fault of ['quota','aborted transaction']) test(`settings survive ${fa return put.apply(this,args); }; },fault); - await click(page,600,650); - await expect.poll(async()=>(await state(page)).persistence).toBe('failed'); + 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(()=>require('./pixels').hasLightText(page,{x:300,y:604,width:600,height:22})).toBe(true); + 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 click(page,600,650); await screen(page,'MainMenuScreen'); + 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}); @@ -63,7 +75,7 @@ test('settings wait for durable storage before closing',async({page})=>{ return sync.call(FS,populate,callback); }; }); - await click(page,600,650); + 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}); @@ -75,14 +87,13 @@ test('settings wait for durable storage before closing',async({page})=>{ 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 click(page,600,650);await screen(page,'MainMenuScreen'); + await clickSettingsDone(page);await screen(page,'MainMenuScreen'); await clickMainMenu(page,'settings');await screen(page,'SettingsScreen'); - await click(page,520,370); await page.evaluate(()=>{IDBObjectStore.prototype.put=function(){throw new DOMException('Injected quota exhaustion','QuotaExceededError');};}); - await click(page,600,650); - await expect.poll(async()=>(await state(page)).persistence).toBe('failed'); + await openGraphicsDetail(page); await selectFull(page); + await expect.poll(async()=>(await state(page)).persistence,{timeout:10000}).toBe('failed'); await screen(page,'SettingsScreen'); - await click(page,810,650);await screen(page,'MainMenuScreen'); + 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 index 46656ded1..8167bed03 100644 --- a/browser/tests/shutdown-storage.spec.js +++ b/browser/tests/shutdown-storage.spec.js @@ -1,5 +1,5 @@ const {test,expect}=require('@playwright/test'); -const {clickMainMenu,gameURL}=require('./main-menu'); +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}); @@ -7,7 +7,7 @@ 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 click(page,600,650); await screen(page,'MainMenuScreen'); + await clickSettingsDone(page); await screen(page,'MainMenuScreen'); } async function failWrites(page){ await page.evaluate(()=>{ diff --git a/browser/tests/single-player.spec.js b/browser/tests/single-player.spec.js index bea7841b1..779340b94 100644 --- a/browser/tests/single-player.spec.js +++ b/browser/tests/single-player.spec.js @@ -1,4 +1,4 @@ -const {gameURL,clickMainMenu}=require('./main-menu'); +const {gameURL,clickMainMenu,clickSettingsDone}=require('./main-menu'); const {test, expect} = require('@playwright/test'); const state = page => page.evaluate(() => glob2Diagnostics.snapshot()); @@ -52,7 +52,7 @@ test('application host returns from settings and credits and shuts down cleanly' page.on('pageerror', error => errors.push(String(error))); await clickMainMenu(page, 'settings'); await screen(page, 'SettingsScreen'); - await menu(page, 530, 440); + await clickSettingsDone(page); await screen(page, 'MainMenuScreen'); await clickMainMenu(page, 'credits'); await screen(page, 'CreditScreen'); diff --git a/browser/tests/storage.spec.js b/browser/tests/storage.spec.js index 7547ad6bb..f5980701e 100644 --- a/browser/tests/storage.spec.js +++ b/browser/tests/storage.spec.js @@ -1,4 +1,4 @@ -const {gameURL,clickMainMenu}=require('./main-menu'); +const {gameURL,clickMainMenu,clickSettingsCancel}=require('./main-menu'); const {test,expect}=require('@playwright/test'); const fs=require('node:fs/promises'); const {createHash}=require('node:crypto'); @@ -90,7 +90,7 @@ test('restore failure is explained before entering the game', async ({page},info 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 click(page,810,650); await screen(page,'MainMenuScreen'); + 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'); diff --git a/browser/tests/viewport.spec.js b/browser/tests/viewport.spec.js index 97f3532d7..29637e1f9 100644 --- a/browser/tests/viewport.spec.js +++ b/browser/tests/viewport.spec.js @@ -1,4 +1,4 @@ -const {gameURL,clickMainMenu}=require('./main-menu'); +const {gameURL,clickMainMenu,clickSettingsDone}=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); @@ -16,7 +16,7 @@ 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 menu(page,530,440); await screen(page,'MainMenuScreen'); + 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'); @@ -59,7 +59,7 @@ test('small viewports retain the active screen and continue rendering', async ({ 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 menu(page,530,440); await screen(page,'MainMenuScreen'); + await clickSettingsDone(page); await screen(page,'MainMenuScreen'); }); test.describe('initial small viewport', () => { diff --git a/src/SettingsScreen.cpp b/src/SettingsScreen.cpp index dbc121b4d..1ee245e25 100644 --- a/src/SettingsScreen.cpp +++ b/src/SettingsScreen.cpp @@ -142,6 +142,18 @@ void SettingsScreen::done() 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) { if(action==SCREEN_DESTROYED) { diff --git a/src/SettingsScreen.h b/src/SettingsScreen.h index 58a5fabae..6e94848ab 100644 --- a/src/SettingsScreen.h +++ b/src/SettingsScreen.h @@ -56,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); 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"));} } From 06c48ae5a1e6218d750e11b241d7a7d68a280ee2 Mon Sep 17 00:00:00 2001 From: Bradley Arsenault Date: Thu, 10 Sep 2026 19:42:07 -0400 Subject: [PATCH 13/20] Fix CustomGameScreen browser crash and stale test coordinates Continuing the same investigation as the Settings fix: the CustomGameScreen tests failed for the same reason (stale coordinates from before master's lobby redesign, #237), but digging into the one test I couldn't fix with a coordinate swap turned up a real crash bug and a real launch bug, both now fixed and covered by tests. Bug 1 - AI profile picker crashes the browser tab: CustomGameScreen::showAIProfile() (wired to each colony's "Info" button) opened its picker via the old choose()/Screen::execute() blocking-loop pattern. The browser host has no Asyncify, so ApplicationHost::wait() inside that loop is a hard error there (docs/browser/adr-003-screen- execution.md) - clicking Info threw an uncaught exception that unwound past the scheduled-stack driver, freezing the page on that screen with no further input reaching it. #237 added this button without a browser build to test it against; #203 is the first PR that makes CustomGameScreen reachable in one. Fixed by pushing CustomGameChoiceScreen through the ScreenStack CustomGameScreen already holds, same as every other dialog this screen opens, instead of blocking-executing it. choose() is now unused and removed. Verified with page-error listeners: no crash, correct CustomGameScreen <-> CustomGameChoiceScreen transitions, and the picked AI now visibly sticks (confirmed via screenshot: colony row updates from "Numbi - Easy" to "Warrush - Medium" after Use). Bug 2 - launching a randomly generated map fails to load: Engine::initCustomTask(MapHeader, GameHeader, ...) never forwarded a source file path to initGameTask, so GameGUI::loadFromHeaders fell back to deriving one from the map's display name. For a premade map that coincidentally matches a real file (maps/FourSquares1.map), so it worked by accident; for a generated map ("Random map" -> maps/Random_map.map, which never exists) it always failed. Fixed by threading CustomGameScreen::sourceFile() (already tracked, just never passed through) through initCustom/initCustomTask into initGameTask's sourceFileName, in both SinglePlayerFlow::custom() and the harness driver. This also makes premade-map loading correctly use the actual selected file instead of a name-based library guess, rather than working by luck. Verified locally: - CustomGameSetupHarness's ui-mode driver (SDL-event-driven, exercises the Info button and a random-map launch end to end) now passes for all three controller modes; previously failed identically with or without the showAIProfile fix, confirming it's Bug 2, not a regression from Bug 1's fix. All of the harness's other CI-invoked modes still pass. - Native release build, server build, and the web build all clean. - Full local Playwright run across every previously-failing CustomGameScreen test (single-player, storage, rendering, viewport, import, input, replay-save, session-reload): 35/35 pass. Added CustomGameScreen::start()'s footer button as clickCustomGameStart() in main-menu.js (mirrors renderLobby()'s button rect, verified against a screenshot) and used it everywhere a test just needs to launch the preselected default map, replacing the old two-click stale-coordinate sequences. Rewrote single-player.spec.js's AI/rules test around what the redesigned lobby actually exposes: "other options" are now inline Game Rules rows (no separate screen to navigate to and back from), and the AI picker is exercised for real now that it doesn't crash - this doubles as the regression test for Bug 1. import.spec.js's "imports a custom map ... through the normal setup screen" test still needs attention separately: the redesigned CustomGameScreen has no import affordance at all anymore (that lives only in ChooseMapScreen's "Load" flow), so the test's premise - importing directly into the lobby - has no current equivalent to test. Left unstarted pending a decision on whether that's a feature gap to close or a flow the test should follow into ChooseMapScreen instead. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XyKsqjkUFwsmf7dxp9SYdV --- browser/tests/import.spec.js | 4 +-- browser/tests/input.spec.js | 4 +-- browser/tests/main-menu.js | 10 ++++++++ browser/tests/rendering.spec.js | 6 ++--- browser/tests/replay-save.spec.js | 4 +-- browser/tests/session-reload.spec.js | 6 ++--- browser/tests/single-player.spec.js | 37 +++++++++++++++++----------- browser/tests/storage.spec.js | 4 +-- browser/tests/viewport.spec.js | 4 +-- src/CustomGameScreen.cpp | 24 +++++++++--------- src/CustomGameScreen.h | 2 -- src/Engine.h | 4 +-- src/EngineInit.cpp | 12 ++++++--- src/SinglePlayerFlow.cpp | 4 +-- test/CustomGameSetupHarness.cpp | 2 +- 15 files changed, 72 insertions(+), 55 deletions(-) diff --git a/browser/tests/import.spec.js b/browser/tests/import.spec.js index f15937b1f..6aba65545 100644 --- a/browser/tests/import.spec.js +++ b/browser/tests/import.spec.js @@ -1,5 +1,5 @@ const {test, expect} = require('@playwright/test'); -const {clickMainMenu,gameURL}=require('./main-menu'); +const {clickMainMenu,gameURL,clickCustomGameStart}=require('./main-menu'); const fs = require('node:fs/promises'); const path = require('node:path'); const {createHash} = require('node:crypto'); @@ -22,7 +22,7 @@ async function chooseSave(page,name) { } async function exportedSave(page) { await clickMainMenu(page,'custom'); await screen(page,'CustomGameScreen'); - await menu(page,100,70); await menu(page,530,380); + 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); diff --git a/browser/tests/input.spec.js b/browser/tests/input.spec.js index f6b50acb9..83fb27a89 100644 --- a/browser/tests/input.spec.js +++ b/browser/tests/input.spec.js @@ -1,5 +1,5 @@ const {test,expect}=require('@playwright/test'); -const {clickMainMenu,gameURL}=require('./main-menu'); +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}); @@ -40,7 +40,7 @@ test('click coordinates stay correct when browser motion delivery is missing',as await page.evaluate(()=>document.addEventListener('mousemove',event=>{ if(event.isTrusted)event.stopImmediatePropagation(); },true)); - await click(page,380,280);await click(page,810,590); + 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); diff --git a/browser/tests/main-menu.js b/browser/tests/main-menu.js index 0cb365f8e..62e39f19e 100644 --- a/browser/tests/main-menu.js +++ b/browser/tests/main-menu.js @@ -78,3 +78,13 @@ 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/rendering.spec.js b/browser/tests/rendering.spec.js index 166b5fc19..24b21722b 100644 --- a/browser/tests/rendering.spec.js +++ b/browser/tests/rendering.spec.js @@ -1,5 +1,5 @@ const {test, expect} = require('@playwright/test'); -const {clickMainMenu,clickSettingsDone}=require('./main-menu'); +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}); @@ -13,7 +13,7 @@ test('WebGL2 draws a playable match and resizes its drawing buffer', async ({pag 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 click(page, 380, 280); await click(page, 810, 590); + 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]); @@ -39,7 +39,7 @@ test('WebGL context restoration keeps the match and can recover repeatedly', asy 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 click(page,380,280); await click(page,810,590); + 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(() => { diff --git a/browser/tests/replay-save.spec.js b/browser/tests/replay-save.spec.js index 368a2dee0..1785f18b5 100644 --- a/browser/tests/replay-save.spec.js +++ b/browser/tests/replay-save.spec.js @@ -1,5 +1,5 @@ const {test,expect}=require('@playwright/test'); -const {clickMainMenu,gameURL}=require('./main-menu'); +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}); @@ -8,7 +8,7 @@ const digest=page=>page.evaluate(()=>glob2Diagnostics.replayDigest('AAA_review_r async function endMatch(page) { await page.goto(gameURL());await screen(page,'MainMenuScreen'); await clickMainMenu(page,'custom');await screen(page,'CustomGameScreen'); - await click(page,380,280);await click(page,810,590); + 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'); diff --git a/browser/tests/session-reload.spec.js b/browser/tests/session-reload.spec.js index fded36e99..66061d93d 100644 --- a/browser/tests/session-reload.spec.js +++ b/browser/tests/session-reload.spec.js @@ -1,5 +1,5 @@ const {test,expect}=require('@playwright/test'); -const {clickMainMenu,gameURL}=require('./main-menu'); +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); @@ -9,7 +9,7 @@ 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 click(page,380,280);await click(page,810,590); + 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); @@ -56,7 +56,7 @@ test('a damaged in-game load returns through a scheduled error notice and permit 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 click(page,380,280);await click(page,810,590); + 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); diff --git a/browser/tests/single-player.spec.js b/browser/tests/single-player.spec.js index 779340b94..33eabee4d 100644 --- a/browser/tests/single-player.spec.js +++ b/browser/tests/single-player.spec.js @@ -1,4 +1,4 @@ -const {gameURL,clickMainMenu,clickSettingsDone}=require('./main-menu'); +const {gameURL,clickMainMenu,clickSettingsDone,clickCustomGameStart}=require('./main-menu'); const {test, expect} = require('@playwright/test'); const state = page => page.evaluate(() => glob2Diagnostics.snapshot()); @@ -92,19 +92,27 @@ test('tutorial sessions quit through the end screen and can restart', async ({pa } }); -test('custom options and AI descriptions return to setup, and a finished game returns there too', async ({page}) => { +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'); - await menu(page, 100, 70); - await menu(page, 310, 440); - await screen(page, 'CustomGameOtherOptions'); - await page.locator('#canvas').press('Escape'); - await screen(page, 'CustomGameScreen'); - await menu(page, 310, 390); - await screen(page, 'AIDescriptionScreen'); - await page.locator('#canvas').press('Enter'); + // 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 menu(page, 530, 380); + 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); @@ -113,6 +121,7 @@ test('custom options and AI descriptions return to setup, and a finished game re 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}) => { @@ -120,8 +129,7 @@ test('custom match pauses, persists and resumes after reload', async ({page}) => page.on('pageerror', error => errors.push(String(error))); await clickMainMenu(page, 'custom'); await screen(page, 'CustomGameScreen'); - await menu(page, 100, 70); - await menu(page, 530, 380); + 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); @@ -281,9 +289,8 @@ test('custom and tutorial startup can be cancelled and retried', async ({page}) page.on('pageerror', error => errors.push(String(error))); await clickMainMenu(page, 'custom'); await screen(page, 'CustomGameScreen'); - await menu(page, 100, 70); await holdLoader(page, 'GameLoadScreen'); - await menu(page, 530, 380); + await clickCustomGameStart(page); await cancelHeldLoader(page, 'GameLoadScreen'); await screen(page, 'CustomGameScreen'); await page.locator('#canvas').press('Escape', {delay:80}); diff --git a/browser/tests/storage.spec.js b/browser/tests/storage.spec.js index f5980701e..56672b272 100644 --- a/browser/tests/storage.spec.js +++ b/browser/tests/storage.spec.js @@ -1,4 +1,4 @@ -const {gameURL,clickMainMenu,clickSettingsCancel}=require('./main-menu'); +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'); @@ -34,7 +34,7 @@ for (const fault of ['abort','quota']) test(`${fault} failure retains the previo }, fault); await page.goto(gameURL()); await screen(page,'MainMenuScreen'); await clickMainMenu(page,'custom'); await screen(page,'CustomGameScreen'); - await click(page,380,280); await click(page,810,590); + 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); diff --git a/browser/tests/viewport.spec.js b/browser/tests/viewport.spec.js index 29637e1f9..6090e0bf2 100644 --- a/browser/tests/viewport.spec.js +++ b/browser/tests/viewport.spec.js @@ -1,4 +1,4 @@ -const {gameURL,clickMainMenu,clickSettingsDone}=require('./main-menu'); +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); @@ -25,7 +25,7 @@ test('a running match survives resize and its open menu follows the new center', // Cold texture creation on a headless software GPU can dominate startup. test.setTimeout(120000); await clickMainMenu(page,'custom'); await screen(page,'CustomGameScreen'); - await menu(page,100,70); await menu(page,530,380); + 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); diff --git a/src/CustomGameScreen.cpp b/src/CustomGameScreen.cpp index 86b997cb6..c9b88e81b 100644 --- a/src/CustomGameScreen.cpp +++ b/src/CustomGameScreen.cpp @@ -301,15 +301,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 +543,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 00aee467e..f24fdc364 100644 --- a/src/CustomGameScreen.h +++ b/src/CustomGameScreen.h @@ -81,8 +81,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/Engine.h b/src/Engine.h index 40b7da79f..8dbd7d006 100644 --- a/src/Engine.h +++ b/src/Engine.h @@ -52,11 +52,11 @@ class Engine int initCampaign(const std::string &mapName); /// Initialize a custom game from the selected map, players and local team. - int initCustom(MapHeader& map, GameHeader& players, int localTeam); + 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); + 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); diff --git a/src/EngineInit.cpp b/src/EngineInit.cpp index 3f2ef3583..ad25a1977 100644 --- a/src/EngineInit.cpp +++ b/src/EngineInit.cpp @@ -46,13 +46,13 @@ GAGCore::CooperativeTask Engine::initCampaignTask(std::string filename, Campaign if (loaded && campaign) gui.setCampaignGame(*campaign, mission); co_return loaded; } -int Engine::initCustom(MapHeader& map, GameHeader& players, int localTeam) +int Engine::initCustom(MapHeader& map, GameHeader& players, int localTeam, const std::string& sourceFileName) { - const bool loaded = initCustomTask(map, players, localTeam).run(); + 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) +GAGCore::CooperativeTask Engine::initCustomTask(MapHeader map, GameHeader players, int localTeam, int speed, std::string sourceFileName) { gui.localPlayer = 0; gui.localTeamNo = localTeam; @@ -63,7 +63,11 @@ GAGCore::CooperativeTask Engine::initCustomTask(MapHeader map, GameHeader player previousCustomSpeed = globalContainer->settings.gameSpeed; globalContainer->settings.gameSpeed = speed; } - co_return co_await initGameTask(map, players); + // 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& filename) { diff --git a/src/SinglePlayerFlow.cpp b/src/SinglePlayerFlow.cpp index becf87624..b04cafce4 100644 --- a/src/SinglePlayerFlow.cpp +++ b/src/SinglePlayerFlow.cpp @@ -30,8 +30,8 @@ void SinglePlayerFlow::custom() if (result != CustomGameScreen::OK) return; auto& selected = static_cast(screen); launch([map = selected.getMapHeader(), players = selected.getGameHeader(), team = selected.getSelectedColor(0), - speed = selected.selectedSpeed()](Engine& engine) { - return engine.initCustomTask(map, players, team, speed); + speed = selected.selectedSpeed(), source = selected.sourceFile()](Engine& engine) { + return engine.initCustomTask(map, players, team, speed, source); }, true); }); } diff --git a/test/CustomGameSetupHarness.cpp b/test/CustomGameSetupHarness.cpp index eb4a05686..1c5757eaf 100644 --- a/test/CustomGameSetupHarness.cpp +++ b/test/CustomGameSetupHarness.cpp @@ -278,7 +278,7 @@ struct CustomGameSetupHarness if (result != CustomGameScreen::OK) return; auto &selected = static_cast(screen); loaded = engine.initCustomTask(selected.getMapHeader(), selected.getGameHeader(), - selected.getSelectedColor(0), selected.selectedSpeed()).run(); + selected.getSelectedColor(0), selected.selectedSpeed(), selected.sourceFile()).run(); }); auto timer = SDL_AddTimer(500, Driver::tick, &driver); assert(timer); From 306ccbf530d3a21d2f974a093eecb142932722ff Mon Sep 17 00:00:00 2001 From: Bradley Arsenault Date: Thu, 10 Sep 2026 19:54:06 -0400 Subject: [PATCH 14/20] Route the map-import browser test through the editor, not the gone lobby button CustomGameScreen has no import affordance anymore after #237's redesign (map browsing there is now "Premade maps"/"Your maps" library tabs, not a file picker) - only ChooseMapScreen still has one, and that screen only offers map-type import when the editor's "Load Map" opens it. Rather than add a new lobby affordance or drop coverage, route the test through the path that actually exists: import via the editor's map chooser, back out without loading it into the editor, then pick it up from the custom-game lobby's own "Your maps" library and start a match with it. Verified the full round trip locally, including that the newly-imported file shows up correctly in the lobby's map preview before starting. Full local Playwright run across import/input/replay-save/session-reload (11 tests, the remaining files touching CustomGameScreen): 11/11 pass. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XyKsqjkUFwsmf7dxp9SYdV --- browser/tests/import.spec.js | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/browser/tests/import.spec.js b/browser/tests/import.spec.js index 6aba65545..8f13bfedc 100644 --- a/browser/tests/import.spec.js +++ b/browser/tests/import.spec.js @@ -69,13 +69,23 @@ test('imports an exported save, preserves duplicate names, rejects corruption an 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')); - await clickMainMenu(page,'custom'); await screen(page,'CustomGameScreen'); + // 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')}); - const download=page.waitForEvent('download'); await menu(page,155,485); - expect(digest(await fs.readFile(await (await download).path()))).toEqual(digest(bytes)); - await menu(page,530,380); + // 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); }); From ad1da5586ac9784d7b5dd4817f051197ae1398b6 Mon Sep 17 00:00:00 2001 From: Bradley Arsenault Date: Thu, 10 Sep 2026 22:57:06 -0400 Subject: [PATCH 15/20] Theme ScreenStack-driven menus and make FrontendScope order-independent Glob2Screen and Glob2TabScreen only enabled the front-end theme inside execute(), the old blocking loop. Screens pushed onto a ScreenStack never call it, so menus without their own paint(), such as CampaignMainMenu and CampaignSelectorScreen, fell back to the pre-theme grass background, and generic Text widgets drew legacy white text on the theme's light panels. MainMenuScreen and CustomGameScreen were unaffected only because they paint the theme themselves. Both classes now hold a FrontendScope for the screen's lifetime. That exposed a second problem: FrontendScope restored a snapshot of the previous style, which assumes scopes end in reverse order. ScreenStack::boundary() runs a finished screen's completion callback, which builds the next screen, before destroying the finished one. After any screen handoff the last scope left Style::style pointing at the FrontendTheme, which ~GlobalContainer then deleted after Application had already freed it, so quitting never reached the exited state. Live scopes are now tracked and the most recently created one decides the style and font colours; with none left, the style the theme was created over is restored. This also covers the existing member scopes on GameSessionScreen, MapEditorScreen, CampaignEditor and EndGameScreen. Verified in the WebAssembly build: Campaign and "Choose a campaign" render with the theme panel and dark text, and Credits -> Escape -> Quit reaches exited. Chromium: shutdown-storage (3/3 failing with the member scope alone), settings-storage, campaign-editor-storage and input specs pass. Native builds were not run locally. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Kem7kazWRvTVQXKfmvX5Tn --- src/FrontendTheme.cpp | 43 ++++++++++++++++++++++++++----------------- src/FrontendTheme.h | 8 +++++--- src/Glob2Screen.h | 14 ++++++++++++-- 3 files changed, 43 insertions(+), 22 deletions(-) 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/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. }; From 62365ce825302cc7f5025bfa1399dbe40bb79284 Mon Sep 17 00:00:00 2001 From: Bradley Arsenault Date: Thu, 10 Sep 2026 22:57:07 -0400 Subject: [PATCH 16/20] Size the browser canvas to the viewport before creating the SDL window Every first load was letterboxed until the window was resized. fitCanvas() had already given the 800x600 placeholder canvas a 4:3 CSS size, and Emscripten's SDL2 adopts a canvas's CSS size when it creates a resizable window, so a 1280x554 viewport got a 738x554 canvas while the game surface stayed 1280x554. The first frame's resizeViewport() found the surface already at the requested size and did nothing; only a later resize called SDL_SetWindowSize. Set the canvas to the viewport size before callMain so SDL adopts the right size. The software renderer now starts at 1280x554; WebGL2 at devicePixelRatio 2 starts with a 2560x1108 backing store shown at 1280x554. The existing initial-viewport test missed this because -s raises sizes below 640x480, which forces a real resize on the first frame. The new test loads at 1280x554 with no resize: it fails on the previous build (width 738) and passes now. viewport.spec.js passes 7/7 in Chromium with both the software and WebGL2 renderers. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Kem7kazWRvTVQXKfmvX5Tn --- browser/shell.html | 6 ++++++ browser/tests/viewport.spec.js | 10 ++++++++++ 2 files changed, 16 insertions(+) diff --git a/browser/shell.html b/browser/shell.html index e20179d6c..80fe0f311 100644 --- a/browser/shell.html +++ b/browser/shell.html @@ -77,6 +77,12 @@ const width = Math.max(1, Math.floor(innerWidth)); const height = Math.max(1, Math.floor(innerHeight)); Module.pendingViewport = {width: Math.floor(innerWidth), height: Math.floor(innerHeight)}; + // SDL adopts the canvas's CSS size when it creates a resizable window. Size the canvas to + // the viewport first; otherwise SDL keeps the 4:3 fit of the 800x600 placeholder and the + // full-window game surface stays letterboxed until the first resize. + canvas.width = width; + canvas.height = height; + fitCanvas(); canvas.focus(); // Keep the established default until the GPU backend passes performance gates. const software = new URLSearchParams(location.search).get('renderer') !== 'webgl2'; diff --git a/browser/tests/viewport.spec.js b/browser/tests/viewport.spec.js index 6090e0bf2..287bd8b41 100644 --- a/browser/tests/viewport.spec.js +++ b/browser/tests/viewport.spec.js @@ -71,6 +71,16 @@ test.describe('initial small viewport', () => { }); }); +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}) => { From 3d4a0152a58b03ca26bfd47e81a89649ba9d1209 Mon Sep 17 00:00:00 2001 From: Bradley Arsenault Date: Thu, 10 Sep 2026 23:53:34 -0400 Subject: [PATCH 17/20] Enable the torus overview in WebGL2 browser builds The torus overview was compiled out of every Emscripten build. With ?renderer=webgl2 it now works in the browser; the software renderer keeps the flat map, as native software builds do. WebGL and Emscripten's legacy-GL bridge differ from desktop GL in three ways the renderer depended on: - There are no attribute stacks. WebGL builds save and restore exactly the state each torus block changes, and disable client arrays afterwards as AlphaMapRender does, so libgag's GL state cache still matches when the HUD draws. - The bridge draws emulated client-array elements as GL_UNSIGNED_SHORT whatever type is requested. 32-bit indices rendered half the mesh as slivers fanning to vertex 0; WebGL builds use 16-bit indices, which the 161x161 vertex mesh fits. - GLSL ES has no #version 120 line; the bridge rewrites the remaining fixed-function shader inputs. The shell now requests a depth buffer for the WebGL2 context, which the ring needs; the 2D renderer never depth-tests. restoreBrowserContext() bumps the GL context generation, so the overview recreates its textures, framebuffer, buffers and shader after WebGL context loss instead of reusing dead names. glob2Diagnostics gains a torus field for tests. Native builds keep the attribute-stack path; the changed torus sources pass a native macOS syntax check but were not built or run natively. Verified in headless Chromium: torus.spec.js 3/3 (toggle in and out, context loss, software stays flat); rendering and viewport specs 11/11 with WebGL2; input, shutdown-storage, settings-storage and viewport specs 17/17 with software rendering. With an Apple M3 GPU through Metal the overview runs at 24.4 fps against 25 fps flat; SwiftShader drops to 3.4 fps, and the simulation slows with it since it advances per frame. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Kem7kazWRvTVQXKfmvX5Tn --- browser/ApplicationHost.cpp | 8 ++ browser/README.md | 2 + browser/shell.html | 5 +- browser/tests/torus.spec.js | 95 +++++++++++++++++++++ libgag/include/ApplicationHost.h | 2 + libgag/src/ApplicationHost.cpp | 1 + libgag/src/DrawableSurface.cpp | 3 + src/TorusView.cpp | 5 +- src/TorusViewRender.cpp | 141 ++++++++++++++++++++++++++++--- src/gui/GameGUIDraw.cpp | 2 + 10 files changed, 251 insertions(+), 13 deletions(-) create mode 100644 browser/tests/torus.spec.js diff --git a/browser/ApplicationHost.cpp b/browser/ApplicationHost.cpp index dde1dbee7..edae23bf2 100644 --- a/browser/ApplicationHost.cpp +++ b/browser/ApplicationHost.cpp @@ -201,4 +201,12 @@ void matchFrame(bool paused) 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/README.md b/browser/README.md index 9c7d6732c..2c7da3f10 100644 --- a/browser/README.md +++ b/browser/README.md @@ -43,6 +43,8 @@ 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. Try `http://127.0.0.1:8765/?renderer=webgl2` to select GPU rendering. +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. diff --git a/browser/shell.html b/browser/shell.html index 80fe0f311..13361842b 100644 --- a/browser/shell.html +++ b/browser/shell.html @@ -86,8 +86,9 @@ canvas.focus(); // Keep the established default until the GPU backend passes performance gates. const software = new URLSearchParams(location.search).get('renderer') !== 'webgl2'; + // The torus overview depth-tests its ring, so the GPU context needs a depth buffer. const context = software ? null : canvas.getContext('webgl2', { - alpha:false, antialias:false, depth:false, stencil:false + alpha:false, antialias:false, depth:true, stencil:false }); const gpu = Boolean(context); Module.renderer = gpu ? 'webgl2' : 'software'; @@ -114,7 +115,7 @@ snapshot() { return Object.freeze({version:1, renderer:Module.renderer || 'loading', contextLost:Boolean(Module.gpuLost), contextRestores:Module.gpuRestores || 0, screen:Module.glob2Screen || 'loading', screenClass:Module.glob2ScreenClass || '', roomCanStart:Boolean(Module.glob2RoomCanStart), tick:Module.glob2Tick || 0, frames:Module.glob2Frames || 0, - paused:Boolean(Module.glob2Paused), width:canvas.width, height:canvas.height, + paused:Boolean(Module.glob2Paused), torus:Module.glob2Screen === 'match' && Boolean(Module.glob2Torus), width:canvas.width, height:canvas.height, restore:Module.storageRestore || 'restoring', import:Module.importState || 'idle', persisting:Module.storage?.state === 'writing', 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/libgag/include/ApplicationHost.h b/libgag/include/ApplicationHost.h index 683cf4f22..f687705da 100644 --- a/libgag/include/ApplicationHost.h +++ b/libgag/include/ApplicationHost.h @@ -59,6 +59,8 @@ 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/src/ApplicationHost.cpp b/libgag/src/ApplicationHost.cpp index 05e2dd701..9333a1778 100644 --- a/libgag/src/ApplicationHost.cpp +++ b/libgag/src/ApplicationHost.cpp @@ -38,6 +38,7 @@ 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/DrawableSurface.cpp b/libgag/src/DrawableSurface.cpp index 20919ec9d..ad586e821 100644 --- a/libgag/src/DrawableSurface.cpp +++ b/libgag/src/DrawableSurface.cpp @@ -360,6 +360,9 @@ 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); diff --git a/src/TorusView.cpp b/src/TorusView.cpp index 85a62c4c6..53abd3c09 100644 --- a/src/TorusView.cpp +++ b/src/TorusView.cpp @@ -7,7 +7,10 @@ #include #include -#if defined(HAVE_OPENGL) && !defined(__EMSCRIPTEN__) +#ifdef HAVE_CONFIG_H +#include +#endif +#if defined(HAVE_OPENGL) #define GLOB2_TORUS_OPENGL #endif diff --git a/src/TorusViewRender.cpp b/src/TorusViewRender.cpp index 4e05a1753..5d2b00837 100644 --- a/src/TorusViewRender.cpp +++ b/src/TorusViewRender.cpp @@ -12,12 +12,19 @@ #include #include #include -#if defined(HAVE_OPENGL) && !defined(__EMSCRIPTEN__) +#ifdef HAVE_CONFIG_H +#include +#endif +#if defined(HAVE_OPENGL) #define GLOB2_TORUS_OPENGL #endif #ifdef GLOB2_TORUS_OPENGL -#ifdef __APPLE__ +#if defined(GLOB2_WEBGL2) +#define GL_GLEXT_PROTOTYPES +#include +#include +#elif defined(__APPLE__) #include #include #define glGenFramebuffers glGenFramebuffersEXT @@ -43,6 +50,82 @@ float smooth(float x) } float mix(float a, float b, float t) { return a + (b - a) * t; } #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; @@ -129,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" @@ -226,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); @@ -245,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) @@ -264,8 +355,12 @@ void TorusView::updateClouds(int time) #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) @@ -288,9 +383,13 @@ 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) @@ -420,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 @@ -502,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; @@ -511,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) @@ -565,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); @@ -592,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. @@ -613,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/gui/GameGUIDraw.cpp b/src/gui/GameGUIDraw.cpp index fc4f48f52..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 @@ -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))); From 0733a7182fa2489933b9043cf88b4bc154719c7c Mon Sep 17 00:00:00 2001 From: Bradley Arsenault Date: Fri, 11 Sep 2026 08:00:26 -0400 Subject: [PATCH 18/20] Keep a generated custom map until its match has loaded On this branch the ScreenStack destroys CustomGameScreen before the GameLoadScreen it queued reads the map. The screen's destructor removed its generated preview, so every random-map launch failed with "The map couldn't be loaded because the file is either damaged or an incompatible version." Premade maps were unaffected. Master's blocking flow keeps the screen alive through the load, so it never hit this. CustomGameScreen::releaseSnapshot() hands the preview's private directory to the launch, and SinglePlayerFlow keeps it until the loader's stack entry is released, whether the load succeeded, failed or was cancelled. The directory is still removed before the match runs, as on master. CustomGameSetupHarness launched its random map inside the completion callback, while the screen was still alive, so it missed this. It now loads after the stack destroys the screen and checks that the directory is removed. The changed sources pass a native macOS syntax check; the harness was not built or run locally. Verified in headless Chromium with software rendering: a new single-player test that launches a random map fails on the previous build with that message and passes with this change; single-player.spec.js 15/15. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Kem7kazWRvTVQXKfmvX5Tn --- browser/tests/single-player.spec.js | 17 +++++++++++++++++ src/CustomGameScreen.cpp | 13 +++++++++++++ src/CustomGameScreen.h | 3 +++ src/SinglePlayerFlow.cpp | 8 +++++--- src/SinglePlayerFlow.h | 2 +- test/CustomGameSetupHarness.cpp | 16 +++++++++++++--- 6 files changed, 52 insertions(+), 7 deletions(-) diff --git a/browser/tests/single-player.spec.js b/browser/tests/single-player.spec.js index 33eabee4d..6fc43350e 100644 --- a/browser/tests/single-player.spec.js +++ b/browser/tests/single-player.spec.js @@ -308,6 +308,23 @@ test('custom and tutorial startup can be cancelled and retried', async ({page}) 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('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))); diff --git a/src/CustomGameScreen.cpp b/src/CustomGameScreen.cpp index c9b88e81b..084690417 100644 --- a/src/CustomGameScreen.cpp +++ b/src/CustomGameScreen.cpp @@ -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; diff --git a/src/CustomGameScreen.h b/src/CustomGameScreen.h index f24fdc364..b4a720b24 100644 --- a/src/CustomGameScreen.h +++ b/src/CustomGameScreen.h @@ -44,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."; diff --git a/src/SinglePlayerFlow.cpp b/src/SinglePlayerFlow.cpp index b04cafce4..570fa3f6c 100644 --- a/src/SinglePlayerFlow.cpp +++ b/src/SinglePlayerFlow.cpp @@ -8,10 +8,12 @@ #include #include -void SinglePlayerFlow::launch(GameLoadScreen::Initializer initialize, bool repeatCustom) +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](GAGGUI::Screen& screen, int result) { + [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(); }); @@ -32,7 +34,7 @@ void SinglePlayerFlow::custom() 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); + }, true, selected.releaseSnapshot()); }); } diff --git a/src/SinglePlayerFlow.h b/src/SinglePlayerFlow.h index b32cca1be..ed43f4c2f 100644 --- a/src/SinglePlayerFlow.h +++ b/src/SinglePlayerFlow.h @@ -17,5 +17,5 @@ class SinglePlayerFlow void replay(const std::string& filename); private: GAGGUI::ScreenStack& screens; - void launch(GameLoadScreen::Initializer initialize, bool repeatCustom); + void launch(GameLoadScreen::Initializer initialize, bool repeatCustom, std::shared_ptr mapFile = nullptr); }; diff --git a/test/CustomGameSetupHarness.cpp b/test/CustomGameSetupHarness.cpp index 1c5757eaf..842781474 100644 --- a/test/CustomGameSetupHarness.cpp +++ b/test/CustomGameSetupHarness.cpp @@ -23,6 +23,7 @@ #include #include #include +#include #include GlobalContainer *globalContainer = nullptr; @@ -271,14 +272,20 @@ struct CustomGameSetupHarness { Engine engine; GAGGUI::ScreenStack screens(*globalContainer->gfx); - bool loaded = false; + 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); - loaded = engine.initCustomTask(selected.getMapHeader(), selected.getGameHeader(), - selected.getSelectedColor(0), selected.selectedSpeed(), selected.sourceFile()).run(); + // 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); @@ -295,7 +302,10 @@ struct CustomGameSetupHarness screens.execute(); SDL_RemoveTimer(timer); SDL_RemoveTimer(watchdog); + 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); From 4d71872d9692a8fb9dc2d3fe535729b3ce78e3a7 Mon Sep 17 00:00:00 2001 From: Bradley Arsenault Date: Fri, 11 Sep 2026 09:09:22 -0400 Subject: [PATCH 19/20] Draw scaled surfaces in the software renderer DrawableSurface's scaled drawSurface overload was an empty TODO, so software rendering dropped every stretched draw. The custom game lobby stretches its 128x128 map thumbnail to fill the preview square, so browser players on the software renderer saw a flat panel with colony markers and no map. WebGL2 and desktop OpenGL draw through GraphicContext's GPU path and were unaffected. The overload now blits with SDL_BlitScaled: nearest-neighbour like the GPU path, clipped to the surface's clip rect, with per-pixel alpha modulated by the requested alpha. Same-size draws keep using the existing unscaled blit. The other software caller is GUIBase's blocking execute(), which now draws its captured background each frame; the stretched magic effect remains GPU-only through canDrawStretchedSprite(). Verified in headless Chromium: a new single-player test that requires the preview square to be mostly map pixels passes with software and WebGL2 rendering and fails on the previous build (0.2% map pixels); single-player.spec.js 16/16 with software rendering. The changed file passes a native macOS syntax check; no native software-rendering run was done. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Kem7kazWRvTVQXKfmvX5Tn --- browser/tests/pixels.js | 18 ++++++++++++++++++ browser/tests/single-player.spec.js | 14 ++++++++++++++ libgag/src/DrawableSurfaceCompound.cpp | 18 +++++++++++++++++- 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/browser/tests/pixels.js b/browser/tests/pixels.js index e96b013a6..559ded645 100644 --- a/browser/tests/pixels.js +++ b/browser/tests/pixels.js @@ -51,3 +51,21 @@ async function hasDarkText(page, clip) { }, 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/single-player.spec.js b/browser/tests/single-player.spec.js index 6fc43350e..e4aea6b30 100644 --- a/browser/tests/single-player.spec.js +++ b/browser/tests/single-player.spec.js @@ -1,4 +1,5 @@ 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()); @@ -325,6 +326,19 @@ test('a generated custom map loads after its setup screen closes', async ({page} 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))); diff --git a/libgag/src/DrawableSurfaceCompound.cpp b/libgag/src/DrawableSurfaceCompound.cpp index 5889c1b73..afce1f565 100644 --- a/libgag/src/DrawableSurfaceCompound.cpp +++ b/libgag/src/DrawableSurfaceCompound.cpp @@ -285,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) From 4a626e15056a6c947c2fb371a4c0f8df6a16ce44 Mon Sep 17 00:00:00 2001 From: Bradley Arsenault Date: Fri, 11 Sep 2026 09:09:22 -0400 Subject: [PATCH 20/20] Default the browser to WebGL2 when it is hardware accelerated The browser host now selects WebGL2 unless the URL asks for software or the browser can't accelerate it. It probes a scratch canvas, because a canvas that already holds a WebGL context can't fall back to 2D. A context the browser would create with a major performance caveat counts as unavailable, and so does one drawn by a CPU rasterizer: Chrome doesn't report SwiftShader as a caveat, so the renderer name is checked for SwiftShader, llvmpipe, softpipe, Software Rasterizer and Basic Render Driver. ?renderer=webgl2 still forces WebGL2 and ?renderer=software forces software. Measured on an Apple M3 in headless Chrome 152 with a FourSquares1 custom match (10 s, 3 trials; frames are paced to 25 fps): both renderers held 25 fps from 1280x720 to 3840x2160. WebGL2 spent 3.6-12.0 ms of main-thread time per frame against 5.5-23.8 ms for software, and its menus stayed near 25% CPU where software reached 84% at 3840x2160. Total CPU in matches was 3-11 points higher with WebGL2 (37% against 30% at 1920x1080), as the legacy GL emulation works in Chrome's GPU process. With SwiftShader, WebGL2 took three to four cores and fell to 16 fps at 1920x1080 while software held 25 fps under half a core, which is why emulated WebGL2 falls back. Players with an accelerated browser now get WebGL2's presentation by default, including the map zoom controls and the torus overview. Verified: rendering, viewport and torus specs 17/17 in Chromium, including new tests for the accelerated default and the unavailable and emulated fallbacks; runtime-build and viewport specs 18/18 in Firefox and WebKit, which both chose WebGL2 on macOS. In real browsers without a renderer parameter, Chrome with the M3 GPU chose WebGL2 and Chrome with SwiftShader chose software; both URL overrides were honoured. Headless test browsers that emulate WebGL2 keep using software unless GLOB2_TEST_RENDERER forces a renderer. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Kem7kazWRvTVQXKfmvX5Tn --- browser/README.md | 11 +++--- browser/shell.html | 21 ++++++++--- browser/tests/rendering.spec.js | 44 ++++++++++++++++++++++++ docs/browser/adr-006-webgl2-rendering.md | 12 ++++--- 4 files changed, 76 insertions(+), 12 deletions(-) diff --git a/browser/README.md b/browser/README.md index 2c7da3f10..db6425fe9 100644 --- a/browser/README.md +++ b/browser/README.md @@ -2,8 +2,9 @@ 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 available with `?renderer=webgl2`; software remains the default and -fallback pending complete GPU qualification. The pinned Emscripten 4.0.15 build +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. @@ -42,7 +43,7 @@ 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. -Try `http://127.0.0.1:8765/?renderer=webgl2` to select GPU rendering. +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. @@ -124,7 +125,9 @@ 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` to select GPU rendering throughout. +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. diff --git a/browser/shell.html b/browser/shell.html index 13361842b..94fdbbc83 100644 --- a/browser/shell.html +++ b/browser/shell.html @@ -84,12 +84,25 @@ canvas.height = height; fitCanvas(); canvas.focus(); - // Keep the established default until the GPU backend passes performance gates. - const software = new URLSearchParams(location.search).get('renderer') !== 'webgl2'; + // WebGL2 is the default when the browser accelerates it. Emulated WebGL2, such as + // SwiftShader, costs several CPU cores where software rendering needs a fraction of + // one, so it counts as unavailable unless ?renderer=webgl2 asks for it. + const requested = new URLSearchParams(location.search).get('renderer'); + const accelerated = () => { + // Probe a scratch canvas: once the game canvas has a WebGL context it can't use 2D. + const probe = document.createElement('canvas').getContext('webgl2', {failIfMajorPerformanceCaveat:true}); + if (!probe) return false; + const info = probe.getExtension('WEBGL_debug_renderer_info'); + const name = String(probe.getParameter(info ? info.UNMASKED_RENDERER_WEBGL : probe.RENDERER)); + probe.getExtension('WEBGL_lose_context')?.loseContext(); + // Chrome doesn't flag SwiftShader as a performance caveat, so check the rasterizer too. + return !/SwiftShader|llvmpipe|softpipe|Software Rasterizer|Basic Render Driver/i.test(name); + }; + const webgl2 = requested === 'webgl2' || (requested !== 'software' && accelerated()); // The torus overview depth-tests its ring, so the GPU context needs a depth buffer. - const context = software ? null : canvas.getContext('webgl2', { + const context = webgl2 ? canvas.getContext('webgl2', { alpha:false, antialias:false, depth:true, stencil:false - }); + }) : null; const gpu = Boolean(context); Module.renderer = gpu ? 'webgl2' : 'software'; Module.callMain(['-s', width + 'x' + height, '-F', gpu ? '-g' : '-G']); diff --git a/browser/tests/rendering.spec.js b/browser/tests/rendering.spec.js index 24b21722b..0b919e881 100644 --- a/browser/tests/rendering.spec.js +++ b/browser/tests/rendering.spec.js @@ -33,6 +33,50 @@ test('software renderer remains available', async ({page}) => { 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 = []; diff --git a/docs/browser/adr-006-webgl2-rendering.md b/docs/browser/adr-006-webgl2-rendering.md index aa9d3063b..cbfc12fd1 100644 --- a/docs/browser/adr-006-webgl2-rendering.md +++ b/docs/browser/adr-006-webgl2-rendering.md @@ -15,10 +15,14 @@ performance requires that later. ## Ownership and lifecycle -The browser host selects WebGL2 with `?renderer=webgl2` when it is available. -Software remains the default, and `?renderer=software` selects it explicitly. -Failure to create WebGL2 falls back to software. Native builds retain their -existing OpenGL dependencies. +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