Update source files

This commit is contained in:
Ryan Walters
2025-10-31 01:10:53 -05:00
commit 1e8c2a24eb
214 changed files with 33143 additions and 0 deletions

18
.cargo/config.toml Normal file
View File

@@ -0,0 +1,18 @@
[build]
rustc-wrapper = "sccache"
[target.wasm32-unknown-unknown]
rustflags = [
'--cfg',
'getrandom_backend="wasm_js"',
'--cfg=web_sys_unstable_apis',
]
# for Linux
[target.x86_64-unknown-linux-gnu]
linker = "clang"
rustflags = ["-C", "link-arg=-fuse-ld=lld"]
# for Windows
[target.x86_64-pc-windows-msvc]
linker = "rust-lld.exe"

17
.cargo/mutants.toml Normal file
View File

@@ -0,0 +1,17 @@
# Copy VCS directories (.git, etc.) to build directories
copy_vcs = true
# Examine only files matching these glob patterns
# examine_globs = ["src/**/*.rs", "examples/**/*.rs"]
# Exclude files matching these glob patterns
# exclude_globs = ["src/test_*.rs", "src/**/test.rs", "tests/**/*.rs"]
# Use built-in defaults for skip_calls (includes "with_capacity")
skip_calls_defaults = true
# Run tests from these specific packages for all mutants
test_package = ["borders-core"]
# Cargo profile to use for builds
profile = "mutant"

2
.cargo/nextest.toml Normal file
View File

@@ -0,0 +1,2 @@
[profile.default]
fail-fast = false

177
.github/workflows/builds.yml vendored Normal file
View File

@@ -0,0 +1,177 @@
name: Builds
on:
- push
env:
CARGO_TERM_COLOR: always
RUST_VERSION: "stable"
jobs:
desktop:
name: Desktop (${{ matrix.target.os }} / ${{ matrix.target.arch }})
runs-on: ${{ matrix.target.runner }}
strategy:
fail-fast: false
matrix:
target:
- os: linux
arch: x86_64
runner: ubuntu-22.04
rust_target: x86_64-unknown-linux-gnu
- os: macos
arch: x86_64
runner: macos-15-intel
rust_target: x86_64-apple-darwin
- os: macos
arch: aarch64
runner: macos-latest
rust_target: aarch64-apple-darwin
- os: windows
arch: x86_64
runner: windows-latest
rust_target: x86_64-pc-windows-msvc
- os: windows
arch: aarch64
runner: windows-latest
rust_target: aarch64-pc-windows-msvc
steps:
- uses: actions/checkout@v5
- name: Setup Rust
uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ env.RUST_VERSION }}
components: rustfmt, clippy
targets: ${{ matrix.target.rust_target }}
- name: Rust cache
uses: Swatinem/rust-cache@v2
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node
uses: actions/setup-node@v5
with:
node-version: 20
cache: pnpm
cache-dependency-path: frontend/pnpm-lock.yaml
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile --prefer-offline
working-directory: frontend
- name: Cache Tauri CLI (macOS only)
if: matrix.target.os == 'macos'
id: cache-tauri-cli
uses: actions/cache@v4
with:
path: ~/.cargo/bin/cargo-tauri
key: ${{ runner.os }}-${{ runner.arch }}-tauri-cli-2
- name: Install cargo-binstall
if: matrix.target.os != 'macos'
uses: taiki-e/install-action@cargo-binstall
- name: Install Tauri CLI (via binstall)
if: matrix.target.os != 'macos'
run: cargo binstall tauri-cli --version '^2' --no-confirm
- name: Install Tauri CLI (from source on macOS)
if: matrix.target.os == 'macos' && steps.cache-tauri-cli.outputs.cache-hit != 'true'
run: cargo install tauri-cli --version '^2' --locked
env:
CARGO_PROFILE_RELEASE_LTO: false
- name: Cache apt packages
if: matrix.target.os == 'linux'
uses: actions/cache@v4
with:
path: |
/var/cache/apt/archives/*.deb
key: ${{ matrix.target.runner }}-apt-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ matrix.target.runner }}-apt-
- name: Install Linux dependencies
if: matrix.target.os == 'linux'
run: |
sudo apt-get update -qq && sudo apt-get install -y --no-install-recommends \
build-essential \
libglib2.0-dev \
libwebkit2gtk-4.1-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
patchelf
- name: Build desktop app
run: cargo tauri build --target ${{ matrix.target.rust_target }}
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: iron-borders-${{ matrix.target.os }}-${{ matrix.target.arch }}
path: |
target/${{ matrix.target.rust_target }}/release/*.exe
target/${{ matrix.target.rust_target }}/release/bundle/appimage/*.AppImage
target/${{ matrix.target.rust_target }}/release/bundle/deb/*.deb
target/${{ matrix.target.rust_target }}/release/bundle/rpm/*.rpm
target/${{ matrix.target.rust_target }}/release/bundle/dmg/*.dmg
if-no-files-found: ignore
browser:
name: Browser (WASM)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Install Rust
uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ env.RUST_VERSION }}
targets: x86_64-unknown-linux-gnu, wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node
uses: actions/setup-node@v5
with:
node-version: 20
cache: pnpm
cache-dependency-path: frontend/pnpm-lock.yaml
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile --prefer-offline
working-directory: frontend
- name: Install wasm-bindgen-cli
uses: taiki-e/install-action@v2
with:
tool: wasm-bindgen-cli@0.2.104
- name: Install wasm-opt
uses: taiki-e/install-action@v2
with:
tool: wasm-opt@0.116.1
- name: Build WASM release
run: |
cargo build -p borders-wasm --profile wasm-release --target wasm32-unknown-unknown
wasm-bindgen --out-dir pkg --out-name borders --target web target/wasm32-unknown-unknown/wasm-release/borders_wasm.wasm
wasm-opt -Oz --enable-bulk-memory --enable-threads --all-features pkg/borders_bg.wasm -o pkg/borders_bg.wasm
mkdir -p frontend/pkg
cp -r pkg/* frontend/pkg/
- name: Build frontend (root-based)
run: pnpm run build:browser
working-directory: frontend
- name: Upload browser artifacts
uses: actions/upload-artifact@v4
with:
name: iron-borders-browser
path: frontend/dist/browser/client/**/*
if-no-files-found: error
deploy-cloudflare:
name: Deploy to Cloudflare Pages
needs: browser
runs-on: ubuntu-latest
if: github.event_name == 'push'
steps:
- name: Download browser artifact
uses: actions/download-artifact@v4
with:
name: iron-borders-browser
path: dist
- name: Deploy to Cloudflare Pages
id: deploy
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy dist --project-name=borders
- name: Print deployment URL
env:
DEPLOYMENT_URL: ${{ steps.deploy.outputs.deployment-url }}
run: echo "Deployed to $DEPLOYMENT_URL"

66
.github/workflows/coverage.yml vendored Normal file
View File

@@ -0,0 +1,66 @@
name: Coverage
on:
- push
env:
CARGO_TERM_COLOR: always
RUST_VERSION: "nightly"
jobs:
coverage:
name: Code Coverage
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Install Rust
uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ env.RUST_VERSION }}
targets: x86_64-unknown-linux-gnu
components: llvm-tools-preview
- uses: Swatinem/rust-cache@v2
- name: Cache apt packages
uses: actions/cache@v4
with:
path: |
/var/cache/apt/archives/*.deb
key: ubuntu-latest-apt-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
ubuntu-latest-apt-
- name: Install Linux dependencies
run: |
sudo apt-get update -qq && sudo apt-get install -y --no-install-recommends \
build-essential \
libglib2.0-dev \
libwebkit2gtk-4.1-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
patchelf
- name: Install cargo-llvm-cov
uses: taiki-e/install-action@cargo-llvm-cov
- name: Install nextest
uses: taiki-e/install-action@nextest
- name: Run tests with coverage
run: cargo llvm-cov nextest --workspace --no-fail-fast
- name: Generate coverage reports
run: |
mkdir -p coverage
cargo llvm-cov report --html --output-dir coverage/html
cargo llvm-cov report --json --output-path coverage/coverage.json
cargo llvm-cov report --lcov --output-path coverage/lcov.info
- name: Upload HTML coverage report
uses: actions/upload-artifact@v4
with:
name: coverage-html
path: coverage/html
retention-days: 7
- name: Upload JSON coverage report
uses: actions/upload-artifact@v4
with:
name: coverage-json
path: coverage/coverage.json
retention-days: 7
- name: Upload LCOV coverage report
uses: actions/upload-artifact@v4
with:
name: coverage-lcov
path: coverage/lcov.info
retention-days: 7

84
.github/workflows/quality.yml vendored Normal file
View File

@@ -0,0 +1,84 @@
name: Quality
on:
- push
env:
CARGO_TERM_COLOR: always
RUST_VERSION: "stable"
jobs:
rust-quality:
name: Rust Quality
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Install Rust
uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ env.RUST_VERSION }}
targets: x86_64-unknown-linux-gnu, wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
- name: Cache apt packages
uses: actions/cache@v4
with:
path: |
/var/cache/apt/archives/*.deb
key: ubuntu-latest-apt-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
ubuntu-latest-apt-
- name: Install Linux dependencies
run: |
sudo apt-get update -qq && sudo apt-get install -y --no-install-recommends \
build-essential \
libglib2.0-dev \
libwebkit2gtk-4.1-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
patchelf
- name: Install just
uses: taiki-e/install-action@just
- name: Install cargo-machete
uses: taiki-e/install-action@cargo-machete
- name: Run Rust checks
run: just --shell bash --shell-arg -c check
- name: Install cargo-audit
uses: taiki-e/install-action@cargo-audit
- name: Run security audit
run: cargo audit
frontend-quality:
name: Frontend Quality
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node
uses: actions/setup-node@v5
with:
node-version: 20
cache: pnpm
cache-dependency-path: frontend/pnpm-lock.yaml
- name: Install Rust
uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ env.RUST_VERSION }}
targets: x86_64-unknown-linux-gnu, wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
- name: Install wasm-bindgen-cli
uses: taiki-e/install-action@v2
with:
tool: wasm-bindgen-cli@0.2.104
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
working-directory: frontend
- name: Install just
uses: taiki-e/install-action@just
- name: Build WASM for frontend checks
run: |
cargo build -p borders-wasm --profile wasm-dev --target wasm32-unknown-unknown
wasm-bindgen --out-dir pkg --out-name borders --target web target/wasm32-unknown-unknown/wasm-dev/borders_wasm.wasm
mkdir -p frontend/pkg
cp -r pkg/* frontend/pkg/
- name: Run frontend TypeScript checks
run: pnpm run build:browser
working-directory: frontend

39
.github/workflows/tests.yml vendored Normal file
View File

@@ -0,0 +1,39 @@
name: Tests
on:
- push
env:
CARGO_TERM_COLOR: always
RUST_VERSION: "stable"
jobs:
test:
name: Test Suite
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Install Rust
uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ env.RUST_VERSION }}
targets: x86_64-unknown-linux-gnu
- uses: Swatinem/rust-cache@v2
- name: Cache apt packages
uses: actions/cache@v4
with:
path: |
/var/cache/apt/archives/*.deb
key: ubuntu-latest-apt-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
ubuntu-latest-apt-
- name: Install Linux dependencies
run: |
sudo apt-get update -qq && sudo apt-get install -y --no-install-recommends \
build-essential \
libglib2.0-dev \
libwebkit2gtk-4.1-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
patchelf
- name: Install nextest
uses: taiki-e/install-action@nextest
- name: Run tests
run: cargo nextest run --workspace --no-fail-fast --hide-progress-bar --failure-output final

32
.gitignore vendored Normal file
View File

@@ -0,0 +1,32 @@
target/
pkg/
*.pem
/*.io/
releases/
mutants.out*/
coverage/
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

1
.source-commit Normal file
View File

@@ -0,0 +1 @@
4f842e4ad8b999d408857d532230bf326dd1a434

8022
Cargo.lock generated Normal file
View File

File diff suppressed because it is too large Load Diff

97
Cargo.toml Normal file
View File

@@ -0,0 +1,97 @@
[workspace]
members = [
"crates/borders-core",
"crates/borders-desktop",
"crates/borders-wasm",
"crates/borders-server",
]
resolver = "2"
[workspace.package]
authors = ["Xevion"]
edition = "2024"
version = "0.6.1"
# Enable a small amount of optimization in the dev profile.
[profile.dev]
opt-level = 1
codegen-units = 256
# Enable a large amount of optimization in the dev profile for dependencies.
[profile.dev.package."*"]
opt-level = 3
[profile.dev.package.borders-desktop]
opt-level = 1
[profile.dev.package.borders-wasm]
opt-level = 1
[profile.dev.package.borders-server]
opt-level = 1
[profile.dev.package.borders-core]
opt-level = 1
# Optimized profile for mutant testing - prioritizes compile speed over runtime performance
[profile.mutant]
inherits = "dev"
opt-level = 1
debug = 0 # No debug info for faster builds
codegen-units = 256 # Maximum parallelism
incremental = false # Faster for many clean builds (mutant testing)
# Keep dependency optimizations from dev profile
[profile.mutant.package."*"]
opt-level = 3
[profile.mutant.package.borders-desktop]
opt-level = 1
[profile.mutant.package.borders-wasm]
opt-level = 1
[profile.mutant.package.borders-server]
opt-level = 1
[profile.mutant.package.borders-core]
opt-level = 1
# Enable more optimization in the release profile at the cost of compile time.
[profile.release]
# Compile the entire crate as one unit.
# Slows compile times, marginal improvements.
codegen-units = 1
# Do a second optimization pass over the entire program, including dependencies.
# Slows compile times, marginal improvements.
lto = "thin"
# Development profile for WASM builds (faster compile times)
[profile.wasm-dev]
inherits = "dev"
opt-level = 1
panic = "abort"
# Size optimization profile for WASM builds
[profile.wasm-release]
inherits = "release"
incremental = false
debug = false
opt-level = "s" # Optimize for size
lto = true # Link-time optimization
codegen-units = 1 # Single codegen unit for better optimization
panic = "abort" # Smaller panic implementation
strip = true # Remove debug symbols
# Performance profiling profile for WASM builds (fast with debug symbols)
[profile.wasm-debug]
inherits = "release"
incremental = false
debug = true # Preserve debug symbols for profiling
opt-level = 3 # Full optimization for performance
lto = "thin" # Link-time optimization without aggressive stripping
codegen-units = 1 # Single codegen unit for better optimization
panic = "abort" # Smaller panic implementation
strip = "none" # Keep all debug symbols

175
Justfile Normal file
View File

@@ -0,0 +1,175 @@
set shell := ["powershell"]
default:
just --list
format:
@cargo fmt --all
# Run benchmarks with optional arguments
# Examples:
# just bench # Run all benchmarks
# just bench border_updates # Run specific benchmark
# just bench -- --save-baseline my_baseline # Save baseline
bench *args:
@cargo bench -p borders-core {{ args }}
# Run tests with custom nextest arguments
# Examples:
# just test # Run all tests
# just test -p borders-core # Run tests in specific package
# just test --test spawn_tests # Run specific test binary
# just test -- test_name_pattern # Filter by test name
# just test -- --skip pattern # Skip tests matching pattern
test *args:
@cargo nextest run --no-fail-fast --workspace --hide-progress-bar --failure-output final {{ args }}
# Run tests matching a pattern (filters by test name)
# Add -p <package> to run in specific package, or --workspace for all packages
test-filter pattern *args:
@cargo nextest run --no-fail-fast --hide-progress-bar --failure-output final {{ args }} -- {{ pattern }}
# Skip tests matching a pattern
# Add -p <package> to run in specific package, or --workspace for all packages
test-skip pattern *args:
@cargo nextest run --no-fail-fast --hide-progress-bar --failure-output final {{ args }} -- --skip {{ pattern }}
# Generate coverage reports (HTML, JSON, LCOV)
# Examples:
# just coverage # Generate all formats in coverage/ directory
# just coverage --open # Generate and open HTML report in browser
coverage *args:
@cargo llvm-cov nextest -p borders-core --no-fail-fast {{ args }}
@if (-not (Test-Path coverage)) { New-Item -ItemType Directory -Path coverage | Out-Null }
@cargo llvm-cov report --html --output-dir coverage/html
@cargo llvm-cov report --json --output-path coverage/coverage.json
@cargo llvm-cov report --lcov --output-path coverage/lcov.info
@echo "Coverage reports generated in coverage/ directory:"
@echo " - HTML: coverage/html/index.html"
@echo " - JSON: coverage/coverage.json"
@echo " - LCOV: coverage/lcov.info"
# Open coverage HTML report in browser
coverage-open:
@if (Test-Path coverage/html/index.html) { \
Invoke-Item coverage/html/index.html; \
} else { \
echo "Coverage report not found. Run 'just coverage' first."; \
exit 1; \
}
mutants *args:
cargo mutants -p borders-core --gitignore true --caught --unviable {{ args }}
mutants-next *args:
cargo mutants -p borders-core --gitignore true --iterate --caught --unviable {{ args }}
check:
@echo "Running clippy (native)..."
@cargo clippy --all-targets --all-features --workspace -- -D warnings
@echo "Running cargo check (native)..."
@cargo check --all-targets --all-features --workspace
@echo "Running clippy (wasm32-unknown-unknown)..."
@cargo clippy --target wasm32-unknown-unknown --all-features -p borders-wasm -- -D warnings
@echo "Running cargo check (wasm32-unknown-unknown)..."
@cargo check --target wasm32-unknown-unknown --all-features -p borders-wasm
@echo "Running cargo machete..."
@cargo machete --with-metadata
@echo "All checks passed"
check-ts:
@just _wasm-build wasm-dev
@echo "Running frontend checks..."
@pnpm run -C frontend check
fix:
@echo "Running cargo fix..."
cargo fix --all-targets --all-features --workspace --allow-dirty
wasm-dev: wasm-dev-build
pnpm -C frontend dev:browser --port 1421
# Build WASM with the specified profile (wasm-dev or wasm-release)
_wasm-build profile:
@$profile = "{{ profile }}"; \
$wasmFile = "target/wasm32-unknown-unknown/$profile/borders_wasm.wasm"; \
$pkgJs = "pkg/borders.js"; \
$pkgWasm = "pkg/borders_bg.wasm"; \
$frontendPkgJs = "frontend/pkg/borders.js"; \
$frontendPkgWasm = "frontend/pkg/borders_bg.wasm"; \
$beforeTime = if (Test-Path $wasmFile) { (Get-Item $wasmFile).LastWriteTime } else { $null }; \
cargo build -p borders-wasm --profile $profile --target wasm32-unknown-unknown; \
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; \
$afterTime = if (Test-Path $wasmFile) { (Get-Item $wasmFile).LastWriteTime } else { $null }; \
$wasRebuilt = ($beforeTime -eq $null) -or ($afterTime -ne $beforeTime); \
$pkgExists = (Test-Path $pkgJs) -and (Test-Path $pkgWasm); \
$frontendPkgExists = (Test-Path $frontendPkgJs) -and (Test-Path $frontendPkgWasm); \
$isRelease = $profile -eq "wasm-release"; \
$isDebug = $profile -eq "wasm-debug"; \
$needsFrontendBuild = $isRelease -or $isDebug; \
if ($wasRebuilt -or -not $pkgExists -or ($needsFrontendBuild -and -not $frontendPkgExists)) { \
Write-Host "Running wasm-bindgen..."; \
wasm-bindgen --out-dir pkg --out-name borders --target web $wasmFile; \
if ($isRelease) { \
Write-Host "Running wasm-opt -Oz..."; \
wasm-opt -Oz --enable-bulk-memory --enable-threads --all-features pkg/borders_bg.wasm -o pkg/borders_bg.wasm; \
} elseif ($isDebug) { \
Write-Host "Running wasm-opt -O3 with debug info..."; \
wasm-opt -O3 --debuginfo --enable-bulk-memory --enable-threads --all-features pkg/borders_bg.wasm -o pkg/borders_bg.wasm; \
}; \
} else { \
Write-Host "WASM not rebuilt, skipping wasm-bindgen"; \
} \
New-Item -ItemType Directory -Force -Path 'frontend/pkg' | Out-Null; \
Copy-Item -Recurse -Force 'pkg/*' 'frontend/pkg/'; \
if ($needsFrontendBuild) { \
Write-Host "Running frontend build..."; \
if ($isDebug) { \
$env:VITE_DEBUG_BUILD = "true"; \
}; \
pnpm -C frontend build:browser; \
if ($isDebug) { \
Remove-Item env:VITE_DEBUG_BUILD; \
}; \
}; \
# Development WASM build, unoptimized
wasm-dev-build:
@just _wasm-build wasm-dev
# Release WASM build, optimized
wasm-release-build:
@just _wasm-build wasm-release
wasm-release: wasm-release-build
@echo "Visit http://localhost:8080 to play"
caddy file-server --listen :8080 --root frontend/dist/browser/client --browse
# Debug WASM build, optimized with debug symbols
wasm-debug-build:
@just _wasm-build wasm-debug
wasm-debug: wasm-debug-build
@echo "Visit http://localhost:8080 to play"
caddy file-server --listen :8080 --root frontend/dist/browser/client --browse
desktop-release:
cargo tauri build
target/release/borders-desktop.exe
desktop-dev:
cargo tauri build --debug
target/debug/borders-desktop.exe
dev *args:
cargo tauri dev {{ args }}
# Run release manager CLI (handles setup specially)
release *args:
@$firstArg = "{{ args }}".Split()[0]; \
if ($firstArg -eq "setup") { \
Write-Host "Installing release manager dependencies..."; \
pnpm install -C scripts/release-manager; \
} else { \
pnpm --silent -C scripts/release-manager exec tsx cli.ts {{ args }}; \
}

13
LICENSE Normal file
View File

@@ -0,0 +1,13 @@
Copyright © 2025 Ryan Walters. All Rights Reserved.
This software and associated documentation files (the "Software") are proprietary
and confidential. Unauthorized copying, modification, distribution, or use of this
Software, via any medium, is strictly prohibited without the express written
permission of the copyright holder.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -0,0 +1,96 @@
[package]
name = "borders-core"
version.workspace = true
edition.workspace = true
authors.workspace = true
[package.metadata.cargo-machete]
ignored = ["serde_bytes", "chrono"]
[features]
default = ["bevy_debug"]
bevy_debug = ["bevy_ecs/detailed_trace"]
[dependencies]
bevy_ecs = { version = "0.17", default-features = false, features = ["std"] }
flume = "0.11"
futures = "0.3"
futures-lite = "2.6.1"
glam = { version = "0.30", features = ["serde", "rkyv"] }
rkyv = { version = "0.8", features = ["hashbrown-0_15"] }
hex = "0.4"
hmac = "0.12"
image = "0.25"
once_cell = "1.20"
quanta = "0.12"
rand = "0.9"
serde = { version = "1.0", features = ["derive", "rc"] }
slotmap = "1.0"
serde_bytes = "0.11"
serde_json = "1.0"
sha2 = "0.10"
tracing = "0.1"
web-transport = "0.9"
extension-traits = "2.0"
# Target-specific dependencies to keep WASM builds compatible
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
tokio = { version = "1", features = [
"rt-multi-thread",
"macros",
"time",
"io-util",
"sync",
] }
reqwest = { version = "0.12", default-features = false, features = [
"json",
"rustls-tls",
"brotli",
"gzip",
"deflate",
"zstd",
] }
hickory-resolver = { version = "0.25", features = [
"tls-ring",
"https-ring",
"quic-ring",
"h3-ring",
"webpki-roots",
] }
uuid = { version = "1.11", features = ["v4", "serde"] }
machineid-rs = "1.2"
directories = "6.0"
ring = "0.17.14"
pem = "3.0.6"
sysinfo = "0.37"
[target.'cfg(windows)'.dependencies]
winreg = "0.55"
[target.'cfg(target_arch = "wasm32")'.dependencies]
tokio = { version = "1", features = ["rt", "macros", "time", "io-util"] }
reqwest = { version = "0.12", default-features = false, features = ["json"] }
uuid = { version = "1.11", features = ["v4", "serde", "js"] }
js-sys = "0.3"
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
gloo-timers = { version = "0.3", features = ["futures"] }
web-sys = { version = "0.3", features = [
"BroadcastChannel",
"MessageEvent",
"Navigator",
"Window",
] }
[dev-dependencies]
assert2 = "0.3"
criterion = { version = "0.7", features = ["html_reports"] }
rstest = "0.23"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
[[bench]]
name = "game_benchmarks"
harness = false
[build-dependencies]
chrono = "0.4"

View File

@@ -0,0 +1,76 @@
{
"tiles": [
{
"color": "#000000",
"name": "Water",
"colorBase": "water",
"colorVariant": 4,
"conquerable": false,
"navigable": true
},
{
"color": "#222222",
"name": "Water",
"colorBase": "water",
"colorVariant": 6,
"conquerable": false,
"navigable": true
},
{
"color": "#555555",
"name": "Water",
"colorBase": "water",
"colorVariant": 12,
"conquerable": false,
"navigable": true
},
{
"color": "#777777",
"name": "Water",
"colorBase": "water",
"colorVariant": 14,
"conquerable": false,
"navigable": true
},
{
"color": "#999999",
"name": "Land",
"colorBase": "mountain",
"colorVariant": 5,
"conquerable": true,
"navigable": false,
"expansionCost": 80,
"expansionTime": 80
},
{
"color": "#BBBBBB",
"name": "Land",
"colorBase": "mountain",
"colorVariant": 9,
"conquerable": true,
"navigable": false,
"expansionCost": 70,
"expansionTime": 70
},
{
"color": "#DDDDDD",
"name": "Land",
"colorBase": "grass",
"colorVariant": 9,
"conquerable": true,
"navigable": false,
"expansionCost": 60,
"expansionTime": 60
},
{
"color": "#FFFFFF",
"name": "Land",
"colorBase": "grass",
"colorVariant": 6,
"conquerable": true,
"navigable": false,
"expansionCost": 50,
"expansionTime": 50
}
]
}

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

View File

@@ -0,0 +1,244 @@
use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main};
use std::hint::black_box;
use borders_core::prelude::*;
use std::collections::{HashMap, HashSet};
/// Setup a game world with specified parameters
fn setup_game_world(map_size: u16, player_count: u16, tiles_per_player: usize) -> (World, Vec<NationId>) {
let mut world = World::new();
// Initialize Time resources (required by many systems)
world.insert_resource(Time::default());
world.insert_resource(FixedTime::from_seconds(0.1));
// Generate terrain - all conquerable for simplicity
let size = (map_size as usize) * (map_size as usize);
let terrain_data = vec![0x80u8; size]; // bit 7 = land/conquerable
let tiles = vec![1u8; size]; // all land tiles
let tile_types = vec![TileType { name: "water".to_string(), color_base: "blue".to_string(), color_variant: 0, conquerable: false, navigable: true, expansion_time: 255, expansion_cost: 255 }, TileType { name: "land".to_string(), color_base: "green".to_string(), color_variant: 0, conquerable: true, navigable: false, expansion_time: 50, expansion_cost: 50 }];
let map_size_vec = U16Vec2::new(map_size, map_size);
let terrain_tile_map = TileMap::from_vec(map_size_vec, terrain_data);
let terrain = TerrainData { _manifest: MapManifest { map: MapMetadata { size: map_size_vec, num_land_tiles: size }, name: "Benchmark Map".to_string(), nations: Vec::new() }, terrain_data: terrain_tile_map, tiles, tile_types };
// Initialize TerritoryManager
let mut territory_manager = TerritoryManager::new(map_size_vec);
let conquerable_tiles = vec![true; size];
territory_manager.reset(map_size_vec, &conquerable_tiles);
// Create player entities and assign territories
let mut entity_map = NationEntityMap::default();
let mut player_ids = Vec::new();
for i in 0..player_count {
let nation_id = if i == 0 { NationId::ZERO } else { NationId::new(i).unwrap() };
player_ids.push(nation_id);
let color = HSLColor::new((i as f32 * 137.5) % 360.0, 0.6, 0.5);
let entity = world.spawn((nation_id, NationName(format!("Player {}", i)), NationColor(color), BorderTiles::default(), Troops(100.0), TerritorySize(0), ShipCount::default())).id();
entity_map.0.insert(nation_id, entity);
// Assign tiles to this player
let start_y = (i as usize * tiles_per_player) / (map_size as usize);
let end_y = ((i as usize + 1) * tiles_per_player) / (map_size as usize);
for y in start_y..end_y.min(map_size as usize) {
for x in 0..(map_size as usize).min(tiles_per_player) {
if y * (map_size as usize) + x < size {
territory_manager.conquer(U16Vec2::new(x as u16, y as u16), nation_id);
}
}
}
}
// Insert core resources
world.insert_resource(entity_map);
world.insert_resource(territory_manager);
world.insert_resource(ActiveAttacks::new());
world.insert_resource(terrain);
world.insert_resource(DeterministicRng::new(0xDEADBEEF));
world.insert_resource(BorderCache::default());
world.insert_resource(AttackControls::default());
world.insert_resource(LocalPlayerContext::new(NationId::ZERO));
world.insert_resource(SpawnPhase { active: false });
// Compute coastal tiles
let map_size_vec = U16Vec2::new(map_size, map_size);
let coastal_tiles = CoastalTiles::compute(world.resource::<TerrainData>(), map_size_vec);
world.insert_resource(coastal_tiles);
(world, player_ids)
}
/// Get 4-directional neighbors (from game utils, inlined to avoid module issues)
fn neighbors(pos: U16Vec2, map_size: U16Vec2) -> impl Iterator<Item = U16Vec2> {
let offsets = [
glam::I16Vec2::new(0, 1), // North
glam::I16Vec2::new(1, 0), // East
glam::I16Vec2::new(0, -1), // South
glam::I16Vec2::new(-1, 0), // West
];
offsets.into_iter().filter_map(move |offset| pos.checked_add_signed(offset).filter(|&n| n.x < map_size.x && n.y < map_size.y))
}
/// Update player borders (simplified version of the border system)
fn update_borders(world: &mut World) {
let (changed_tiles, map_size, tiles_by_owner) = {
let territory_manager = world.resource::<TerritoryManager>();
let changed_tiles: HashSet<U16Vec2> = territory_manager.iter_changes().collect();
if changed_tiles.is_empty() {
return;
}
let map_size = U16Vec2::new(territory_manager.width(), territory_manager.height());
let mut affected_tiles = HashSet::with_capacity(changed_tiles.len() * 5);
for &tile in &changed_tiles {
affected_tiles.insert(tile);
affected_tiles.extend(neighbors(tile, map_size));
}
let mut tiles_by_owner: HashMap<NationId, HashSet<U16Vec2>> = HashMap::new();
for &tile in &affected_tiles {
if let Some(nation_id) = territory_manager.get_nation_id(tile) {
tiles_by_owner.entry(nation_id).or_default().insert(tile);
}
}
(changed_tiles, map_size, tiles_by_owner)
};
let ownership_snapshot: HashMap<U16Vec2, Option<NationId>> = {
let territory_manager = world.resource::<TerritoryManager>();
let mut snapshot = HashMap::new();
for &tile in changed_tiles.iter() {
for neighbor in neighbors(tile, map_size) {
snapshot.entry(neighbor).or_insert_with(|| territory_manager.get_nation_id(neighbor));
}
snapshot.insert(tile, territory_manager.get_nation_id(tile));
}
snapshot
};
let mut nations_query = world.query::<(&NationId, &mut BorderTiles)>();
for (nation_id, mut component_borders) in nations_query.iter_mut(world) {
let empty_set = HashSet::new();
let player_tiles = tiles_by_owner.get(nation_id).unwrap_or(&empty_set);
for &tile in player_tiles {
let is_border = neighbors(tile, map_size).any(|neighbor| ownership_snapshot.get(&neighbor).and_then(|&owner| owner) != Some(*nation_id));
if is_border {
component_borders.0.insert(tile);
} else {
component_borders.0.remove(&tile);
}
}
for &tile in changed_tiles.iter() {
if ownership_snapshot.get(&tile).and_then(|&owner| owner) != Some(*nation_id) {
component_borders.0.remove(&tile);
}
}
}
}
fn bench_border_updates(c: &mut Criterion) {
let mut group = c.benchmark_group("border_updates");
// Parameter: territory sizes (small, medium, large)
let configs = [
("small_10_tiles", 20, 2, 10), // 2 players, 10 tiles each
("medium_100_tiles", 50, 5, 100), // 5 players, 100 tiles each
("large_500_tiles", 100, 10, 500), // 10 players, 500 tiles each
];
for (name, map_size, player_count, tiles_per_player) in configs {
group.bench_with_input(BenchmarkId::from_parameter(name), &name, |b, _| {
let (mut world, player_ids) = setup_game_world(map_size, player_count, tiles_per_player);
// Simulate some territory changes
b.iter(|| {
// Change ownership of a few tiles to trigger border updates
let tiles_to_change = 5;
for i in 0..tiles_to_change {
let tile = U16Vec2::new(i as u16, i as u16);
let new_owner = player_ids[i % player_ids.len()];
world.resource_mut::<TerritoryManager>().conquer(tile, new_owner);
}
update_borders(black_box(&mut world));
// Clear changes for next iteration
world.resource_mut::<TerritoryManager>().clear_changes();
});
});
}
group.finish();
}
fn bench_turn_execution(c: &mut Criterion) {
let mut group = c.benchmark_group("turn_execution");
// Parameter: player counts
let player_counts = [10, 50, 101];
for player_count in player_counts {
group.bench_with_input(BenchmarkId::from_parameter(player_count), &player_count, |b, &count| {
b.iter(|| {
// Setup game state for each iteration
let (mut world, _player_ids) = setup_game_world(100, count, 50);
// Simulate a turn with territory changes
let changes = 10;
for i in 0..changes {
let tile = U16Vec2::new((i * 5) as u16, (i * 5) as u16);
let owner_idx = i % (count as usize);
let owner = if owner_idx == 0 { NationId::ZERO } else { NationId::new(owner_idx as u16).unwrap() };
world.resource_mut::<TerritoryManager>().conquer(tile, owner);
}
update_borders(black_box(&mut world));
// TODO: Add actual turn execution logic here when ready
// This currently only benchmarks territory changes + border updates
});
});
}
group.finish();
}
// TODO: Add benchmark for territory expansion
// fn bench_territory_expansion(c: &mut Criterion) {
// // Benchmark territory growth/conquest operations
// // Parameters: different expansion patterns (linear, radial, etc.)
// }
// TODO: Add benchmark for bot AI decision-making
// fn bench_bot_decisions(c: &mut Criterion) {
// // Benchmark AI decision performance with different territory sizes
// // Parameters: number of border tiles, number of viable targets
// }
// TODO: Add benchmark for attack processing
// fn bench_attack_processing(c: &mut Criterion) {
// // Benchmark combat calculation and resolution
// // Parameters: number of simultaneous attacks
// }
// TODO: Add benchmark for ship pathfinding
// fn bench_ship_pathfinding(c: &mut Criterion) {
// // Benchmark naval pathfinding algorithms
// // Parameters: map complexity, path length
// }
criterion_group!(benches, bench_border_updates, bench_turn_execution);
criterion_main!(benches);

View File

@@ -0,0 +1,74 @@
use std::env;
use std::fs;
use std::path::PathBuf;
fn main() {
// Get the workspace root (two levels up from borders-core)
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap());
let workspace_root = manifest_dir.parent().unwrap().parent().unwrap();
// Determine if we're in production mode (CI or release profile)
let is_production = env::var("CI").is_ok() || env::var("PROFILE").map(|p| p == "release").unwrap_or(false);
// Read git commit from .source-commit file
let source_commit_path = workspace_root.join(".source-commit");
let git_commit = if source_commit_path.exists() {
match fs::read_to_string(&source_commit_path) {
Ok(content) => content.trim().to_string(),
Err(e) if is_production => {
panic!("Failed to read .source-commit file in production: {}", e);
}
Err(_) => "unknown".to_string(),
}
} else {
// Fallback to git command if file doesn't exist (local development)
let git_result = std::process::Command::new("git").args(["rev-parse", "HEAD"]).current_dir(workspace_root).output().ok().and_then(|output| if output.status.success() { String::from_utf8(output.stdout).ok() } else { None }).map(|s| s.trim().to_string());
match git_result {
Some(commit) => commit,
None if is_production => {
panic!("Failed to acquire git commit in production and .source-commit file does not exist");
}
None => "unknown".to_string(),
}
};
// Determine build time based on environment
let build_time = if let Ok(epoch) = env::var("SOURCE_DATE_EPOCH") {
// Use provided timestamp for reproducible builds
match epoch.parse::<i64>().ok().and_then(|ts| chrono::DateTime::from_timestamp(ts, 0)).map(|dt| dt.to_rfc3339()) {
Some(time) => time,
None if is_production => {
panic!("Failed to parse SOURCE_DATE_EPOCH in production: {}", epoch);
}
None => "unknown".to_string(),
}
} else if env::var("CI").is_ok() {
// Generate fresh timestamp in CI
chrono::Utc::now().to_rfc3339()
} else {
// Static value for local development
"dev".to_string()
};
// Set environment variables for compile-time access
println!("cargo:rustc-env=BUILD_GIT_COMMIT={}", git_commit);
println!("cargo:rustc-env=BUILD_TIME={}", build_time);
// Only re-run the build script when specific files change
println!("cargo:rerun-if-changed=build.rs");
// In CI, watch the .source-commit file if it exists
if source_commit_path.exists() {
println!("cargo:rerun-if-changed={}", source_commit_path.display());
}
// In local development, watch .git/HEAD to detect branch switches
// We intentionally don't watch the branch ref file to avoid spurious rebuilds
if env::var("CI").is_err() {
let git_head = workspace_root.join(".git").join("HEAD");
if git_head.exists() {
println!("cargo:rerun-if-changed={}", git_head.display());
}
}
}

View File

@@ -0,0 +1,21 @@
//! Build metadata injected at compile time
/// The version of the application from Cargo.toml
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
/// The git commit hash from .source-commit file or git command
pub const GIT_COMMIT: &str = env!("BUILD_GIT_COMMIT");
/// The build timestamp in RFC3339 format (UTC)
pub const BUILD_TIME: &str = env!("BUILD_TIME");
/// Get the git commit hash (short form, first 7 characters)
pub fn git_commit_short() -> &'static str {
let full = GIT_COMMIT;
if full.len() >= 7 { &full[..7] } else { full }
}
/// Full build information formatted as a string
pub fn info() -> String {
format!("Iron Borders v{} ({})\nBuilt: {}", VERSION, git_commit_short(), BUILD_TIME)
}

View File

@@ -0,0 +1,98 @@
//! Custom DNS resolver using Hickory DNS with DoH/DoT support.
//!
//! This module provides DNS over HTTPS (DoH) functionality for enhanced privacy,
//! with automatic fallback to system DNS if DoH is unavailable.
use hickory_resolver::{
TokioResolver,
config::{NameServerConfigGroup, ResolverConfig},
name_server::TokioConnectionProvider,
};
use once_cell::sync::OnceCell;
use std::net::SocketAddr;
use std::sync::Arc;
use tracing::{debug, warn};
/// Custom DNS resolver for reqwest that uses Hickory DNS with DoH/DoT support.
///
/// This resolver is configured to use Cloudflare's DNS over HTTPS (1.1.1.1).
/// DNS over HTTPS encrypts DNS queries, preventing eavesdropping and tampering.
///
/// The resolver is lazily initialized within the async context to ensure
/// it's created within the Tokio runtime.
#[derive(Clone, Default)]
pub struct HickoryDnsResolver {
/// Lazily initialized resolver to ensure it's created within Tokio runtime context
state: Arc<OnceCell<TokioResolver>>,
}
impl HickoryDnsResolver {
pub fn new() -> Self {
Self { state: Arc::new(OnceCell::new()) }
}
/// Initialize the Hickory DNS resolver with Cloudflare DoH configuration
fn init_resolver() -> Result<TokioResolver, Box<dyn std::error::Error + Send + Sync>> {
let mut group: NameServerConfigGroup = NameServerConfigGroup::google();
group.merge(NameServerConfigGroup::cloudflare());
group.merge(NameServerConfigGroup::quad9());
group.merge(NameServerConfigGroup::google());
let mut config = ResolverConfig::new();
for server in group.iter() {
config.add_name_server(server.clone());
}
// Use tokio() constructor which properly integrates with current Tokio runtime
let resolver = TokioResolver::builder_with_config(config, TokioConnectionProvider::default()).build();
debug!("DNS resolver initialized with Cloudflare DoH");
Ok(resolver)
}
}
/// Fallback to system DNS when DoH is unavailable
async fn fallback_to_system_dns(name: &str) -> Result<Box<dyn Iterator<Item = SocketAddr> + Send>, Box<dyn std::error::Error + Send + Sync>> {
use tokio::net::lookup_host;
let addrs: Vec<SocketAddr> = lookup_host(format!("{}:443", name))
.await?
.map(|mut addr| {
addr.set_port(0);
addr
})
.collect();
debug!("Resolved '{}' via system DNS ({} addresses)", name, addrs.len());
Ok(Box::new(addrs.into_iter()))
}
impl reqwest::dns::Resolve for HickoryDnsResolver {
fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving {
let resolver_state = self.state.clone();
let name_str = name.as_str().to_string();
Box::pin(async move {
// Get or initialize the resolver within the async context (Tokio runtime)
let resolver = match resolver_state.get_or_try_init(Self::init_resolver) {
Ok(r) => r,
Err(e) => {
warn!("Failed to initialize DoH resolver: {}, using system DNS", e);
return fallback_to_system_dns(&name_str).await;
}
};
// Try Hickory DNS first (DoH)
match resolver.lookup_ip(format!("{}.", name_str)).await {
Ok(lookup) => {
let addrs: reqwest::dns::Addrs = Box::new(lookup.into_iter().map(|ip| SocketAddr::new(ip, 0)));
Ok(addrs)
}
Err(e) => {
warn!("DoH lookup failed for '{}': {}, falling back to system DNS", name_str, e);
fallback_to_system_dns(&name_str).await
}
}
})
}
}

View File

@@ -0,0 +1,454 @@
use std::collections::{HashMap, HashSet};
use bevy_ecs::prelude::*;
use glam::{IVec2, U16Vec2};
use rand::rngs::StdRng;
use rand::{Rng, SeedableRng};
use crate::game::SpawnPoint;
use crate::game::core::action::GameAction;
use crate::game::core::constants::bot::*;
use crate::game::core::utils::neighbors;
use crate::game::terrain::data::TerrainData;
use crate::game::world::{NationId, TerritoryManager};
/// Bot AI component - stores per-bot state for decision making
#[derive(Component)]
pub struct Bot {
pub last_action_tick: u64,
pub action_cooldown: u64,
}
impl Default for Bot {
fn default() -> Self {
Self::new()
}
}
impl Bot {
pub fn new() -> Self {
Self::with_seed(0)
}
/// Create a bot with deterministic initial cooldown based on seed
pub fn with_seed(seed: u64) -> Self {
let mut rng = StdRng::seed_from_u64(seed);
// Ensure initial cooldown is at least ACTION_COOLDOWN_MIN to allow borders to be calculated
let cooldown = rng.random_range(ACTION_COOLDOWN_MIN..INITIAL_COOLDOWN_MAX);
Self { last_action_tick: 0, action_cooldown: cooldown }
}
/// Sample a random subset of border tiles to reduce O(n) iteration cost
fn sample_border_tiles(border_tiles: &HashSet<U16Vec2>, border_count: usize, rng: &mut StdRng) -> Vec<U16Vec2> {
if border_count <= MAX_BORDER_SAMPLES {
border_tiles.iter().copied().collect()
} else {
// Random sampling without replacement using Fisher-Yates
let mut border_vec: Vec<U16Vec2> = border_tiles.iter().copied().collect();
// Partial Fisher-Yates shuffle for first MAX_BORDER_SAMPLES elements
for i in 0..MAX_BORDER_SAMPLES {
let j = rng.random_range(i..border_count);
border_vec.swap(i, j);
}
border_vec.truncate(MAX_BORDER_SAMPLES);
border_vec
}
}
/// Tick the bot AI - now deterministic based on turn number and RNG seed
#[allow(clippy::too_many_arguments)]
pub fn tick(&mut self, turn_number: u64, nation_id: NationId, troops: &crate::game::Troops, territory_manager: &TerritoryManager, terrain: &TerrainData, nation_borders: &HashMap<NationId, &HashSet<U16Vec2>>, rng_seed: u64) -> Option<GameAction> {
// Only act every few ticks
if turn_number < self.last_action_tick + self.action_cooldown {
return None;
}
self.last_action_tick = turn_number;
// Deterministic RNG based on turn number, nation ID, and global seed
let seed = rng_seed.wrapping_add(turn_number).wrapping_add(nation_id.get() as u64);
let mut rng = StdRng::seed_from_u64(seed);
self.action_cooldown = rng.random_range(ACTION_COOLDOWN_MIN..ACTION_COOLDOWN_MAX);
// Decide action: expand into wilderness or attack a neighbor
let action_type: f32 = rng.random();
if action_type < EXPAND_PROBABILITY {
// Expand into wilderness (60% chance)
self.expand_wilderness(nation_id, troops, territory_manager, terrain, nation_borders, &mut rng)
} else {
// Attack a neighbor (40% chance)
self.attack_neighbor(nation_id, troops, territory_manager, nation_borders, &mut rng)
}
}
/// Expand into unclaimed territory
fn expand_wilderness(&self, nation_id: NationId, troops: &crate::game::Troops, territory_manager: &TerritoryManager, terrain: &TerrainData, nation_borders: &HashMap<NationId, &HashSet<U16Vec2>>, rng: &mut StdRng) -> Option<GameAction> {
let border_tiles = nation_borders.get(&nation_id)?;
let border_count = border_tiles.len();
let size = territory_manager.size();
let tiles_to_check = Self::sample_border_tiles(border_tiles, border_count, rng);
// Find a valid, unclaimed neighbor tile to attack
for &tile in &tiles_to_check {
if let Some(_neighbor) = neighbors(tile, size).find(|&neighbor| !territory_manager.has_owner(neighbor) && terrain.is_conquerable(neighbor)) {
let troop_percentage: f32 = rng.random_range(EXPAND_TROOPS_MIN..EXPAND_TROOPS_MAX);
let troop_count = (troops.0 * troop_percentage).floor() as u32;
return Some(GameAction::Attack { target: None, troops: troop_count });
}
}
None
}
/// Attack a neighboring nation
fn attack_neighbor(&self, nation_id: NationId, troops: &crate::game::Troops, territory_manager: &TerritoryManager, nation_borders: &HashMap<NationId, &HashSet<U16Vec2>>, rng: &mut StdRng) -> Option<GameAction> {
let border_tiles = nation_borders.get(&nation_id)?;
let border_count = border_tiles.len();
// Find neighboring nations
let mut neighboring_nations = HashSet::new();
let size = territory_manager.size();
let tiles_to_check = Self::sample_border_tiles(border_tiles, border_count, rng);
for &tile in &tiles_to_check {
neighboring_nations.extend(neighbors(tile, size).filter_map(|neighbor| {
let ownership = territory_manager.get_ownership(neighbor);
ownership.nation_id().filter(|&other_nation_id| other_nation_id != nation_id)
}));
}
if neighboring_nations.is_empty() {
return None;
}
// Pick a random neighbor to attack
let neighbor_count = neighboring_nations.len();
let target_id = neighboring_nations.into_iter().nth(rng.random_range(0..neighbor_count)).unwrap();
let troop_percentage: f32 = rng.random_range(ATTACK_TROOPS_MIN..ATTACK_TROOPS_MAX);
let troop_count = (troops.0 * troop_percentage).floor() as u32;
Some(GameAction::Attack { target: Some(target_id), troops: troop_count })
}
}
/// Spatial grid for fast spawn collision detection
/// Divides map into cells for O(1) neighbor queries instead of O(n)
struct SpawnGrid {
grid: HashMap<IVec2, Vec<U16Vec2>>,
cell_size: f32,
}
impl SpawnGrid {
fn new(cell_size: f32) -> Self {
Self { grid: HashMap::new(), cell_size }
}
fn insert(&mut self, pos: U16Vec2) {
let cell = self.pos_to_cell(pos);
self.grid.entry(cell).or_default().push(pos);
}
#[inline]
fn pos_to_cell(&self, pos: U16Vec2) -> IVec2 {
let x = pos.x as f32 / self.cell_size;
let y = pos.y as f32 / self.cell_size;
IVec2::new(x as i32, y as i32)
}
fn has_nearby(&self, pos: U16Vec2, radius: f32) -> bool {
let cell = self.pos_to_cell(pos);
let cell_radius = (radius / self.cell_size).ceil() as i32;
for dx in -cell_radius..=cell_radius {
for dy in -cell_radius..=cell_radius {
let check_cell = cell + IVec2::new(dx, dy);
if let Some(positions) = self.grid.get(&check_cell) {
for &existing_pos in positions {
if calculate_position_distance(pos, existing_pos) < radius {
return true;
}
}
}
}
}
false
}
}
/// Calculate Euclidean distance between two positions
#[inline]
fn calculate_position_distance(pos1: U16Vec2, pos2: U16Vec2) -> f32 {
pos1.as_vec2().distance(pos2.as_vec2())
}
/// Calculate initial bot spawn positions (first pass)
///
/// Places bots at random valid locations with adaptive spacing.
/// Uses spatial grid for O(1) neighbor checks and adaptively reduces
/// minimum distance when map becomes crowded.
///
/// Guarantees all bots spawn (no silent drops). This is deterministic based on rng_seed.
///
/// Returns Vec<SpawnPoint> for each bot
pub fn calculate_initial_spawns(bot_nation_ids: &[NationId], territory_manager: &TerritoryManager, terrain: &TerrainData, rng_seed: u64) -> Vec<SpawnPoint> {
let _guard = tracing::trace_span!("calculate_initial_spawns", bot_count = bot_nation_ids.len()).entered();
let size = territory_manager.size();
let mut spawn_positions = Vec::with_capacity(bot_nation_ids.len());
let mut grid = SpawnGrid::new(MIN_SPAWN_DISTANCE);
let mut current_min_distance = MIN_SPAWN_DISTANCE;
for (bot_index, &nation_id) in bot_nation_ids.iter().enumerate() {
// Deterministic RNG for spawn location
let seed = rng_seed.wrapping_add(nation_id.get() as u64).wrapping_add(bot_index as u64);
let mut rng = StdRng::seed_from_u64(seed);
let mut placed = false;
// Try with current minimum distance
while !placed && current_min_distance >= ABSOLUTE_MIN_DISTANCE {
// Phase 1: Random sampling
for _ in 0..SPAWN_RANDOM_ATTEMPTS {
let tile_pos = U16Vec2::new(rng.random_range(0..size.x), rng.random_range(0..size.y));
// Check if tile is valid land
if territory_manager.has_owner(tile_pos) || !terrain.is_conquerable(tile_pos) {
continue;
}
// Check distance using spatial grid (O(1) instead of O(n))
if !grid.has_nearby(tile_pos, current_min_distance) {
spawn_positions.push(SpawnPoint::new(nation_id, tile_pos));
grid.insert(tile_pos);
placed = true;
break;
}
}
// Phase 2: Grid-guided fallback (if random sampling failed)
if !placed {
// Try a systematic grid search with stride
let stride = (current_min_distance * SPAWN_GRID_STRIDE_FACTOR) as u16;
let mut attempts = 0;
for y in (0..size.y).step_by(stride.max(1) as usize) {
for x in (0..size.x).step_by(stride.max(1) as usize) {
let tile_pos = U16Vec2::new(x, y);
if territory_manager.has_owner(tile_pos) || !terrain.is_conquerable(tile_pos) {
continue;
}
if !grid.has_nearby(tile_pos, current_min_distance) {
spawn_positions.push(SpawnPoint::new(nation_id, tile_pos));
grid.insert(tile_pos);
placed = true;
break;
}
attempts += 1;
if attempts > SPAWN_GRID_MAX_ATTEMPTS {
break;
}
}
if placed {
break;
}
}
}
// Phase 3: Reduce minimum distance and retry
if !placed {
current_min_distance *= DISTANCE_REDUCTION_FACTOR;
if bot_index % 100 == 0 && current_min_distance < MIN_SPAWN_DISTANCE {
tracing::debug!("Adaptive spawn: reduced min_distance to {:.1} for bot {}", current_min_distance, bot_index);
}
}
}
// Final fallback: Place at any valid land tile (guaranteed)
if !placed {
for _ in 0..SPAWN_FALLBACK_ATTEMPTS {
let tile_pos = U16Vec2::new(rng.random_range(0..size.x), rng.random_range(0..size.y));
if !territory_manager.has_owner(tile_pos) && terrain.is_conquerable(tile_pos) {
spawn_positions.push(SpawnPoint::new(nation_id, tile_pos));
grid.insert(tile_pos);
placed = true;
tracing::warn!("Bot {} placed with fallback (no distance constraint)", nation_id);
break;
}
}
}
if !placed {
tracing::error!("Failed to place bot {} after all attempts", nation_id);
}
}
spawn_positions
}
/// Recalculate bot spawns considering human nation positions (second pass)
///
/// For any bot that is too close to a human nation spawn, find a new position.
/// Uses adaptive algorithm with grid acceleration to guarantee all displaced
/// bots find new positions. This maintains determinism while ensuring proper spawn spacing.
///
/// Arguments:
/// - `initial_bot_spawns`: Bot positions from first pass
/// - `human_spawns`: Human nation spawn positions
/// - `territory_manager`: For checking valid tiles
/// - `terrain`: For checking conquerable tiles
/// - `rng_seed`: For deterministic relocation
///
/// Returns updated Vec<SpawnPoint> with relocated bots
pub fn recalculate_spawns_with_players(initial_bot_spawns: Vec<SpawnPoint>, player_spawns: &[SpawnPoint], territory_manager: &TerritoryManager, terrain: &TerrainData, rng_seed: u64) -> Vec<SpawnPoint> {
let _guard = tracing::trace_span!("recalculate_spawns_with_players", bot_count = initial_bot_spawns.len(), human_count = player_spawns.len()).entered();
let size = territory_manager.size();
// Build spatial grid to track occupied spawn locations
// Contains all human nation spawns plus bots that don't need relocation
// Enables O(1) distance checks instead of O(n) iteration
let mut grid = SpawnGrid::new(MIN_SPAWN_DISTANCE);
for spawn in player_spawns {
grid.insert(spawn.tile);
}
// Partition bots into two groups:
// 1. Bots that are far enough from all human nation spawns (keep as-is)
// 2. Bots that violate MIN_SPAWN_DISTANCE from any human nation (need relocation)
let mut bots_to_relocate = Vec::new();
let mut final_spawns = Vec::new();
for spawn in initial_bot_spawns {
let mut needs_relocation = false;
for human_spawn in player_spawns {
if calculate_position_distance(spawn.tile, human_spawn.tile) < MIN_SPAWN_DISTANCE {
needs_relocation = true;
break;
}
}
if needs_relocation {
bots_to_relocate.push(spawn.nation);
} else {
// Bot is valid - add to final list and mark space as occupied
final_spawns.push(spawn);
grid.insert(spawn.tile);
}
}
// Relocate displaced bots using a three-phase adaptive algorithm:
// Phase 1: Random sampling (fast, works well when space is available)
// Phase 2: Grid-based systematic search (fallback when random fails)
// Phase 3: Adaptive distance reduction (progressively relax spacing constraints)
//
// This adaptively reduces spacing as the map fills up, ensuring all bots
// eventually find placement even on crowded maps
let mut current_min_distance = MIN_SPAWN_DISTANCE;
for (reloc_index, &nation_id) in bots_to_relocate.iter().enumerate() {
// Deterministic RNG with a different seed offset to avoid reusing original positions
let seed = rng_seed.wrapping_add(nation_id.get() as u64).wrapping_add(0xDEADBEEF);
let mut rng = StdRng::seed_from_u64(seed);
let mut placed = false;
// Keep trying with progressively relaxed distance constraints
while !placed && current_min_distance >= ABSOLUTE_MIN_DISTANCE {
// Phase 1: Random sampling - try random tiles until we find a valid spot
// Fast and evenly distributed when sufficient space exists
for _ in 0..SPAWN_RANDOM_ATTEMPTS {
let tile_pos = U16Vec2::new(rng.random_range(0..size.x), rng.random_range(0..size.y));
// Skip tiles that are already owned or unconquerable (water/mountains)
if territory_manager.has_owner(tile_pos) || !terrain.is_conquerable(tile_pos) {
continue;
}
// Check if this tile is far enough from all existing spawns
// Grid lookup is O(1) - only checks cells within radius, not all spawns
if !grid.has_nearby(tile_pos, current_min_distance) {
final_spawns.push(SpawnPoint::new(nation_id, tile_pos));
grid.insert(tile_pos);
placed = true;
break;
}
}
// Phase 2: Grid-based systematic search
// When random sampling fails (map is crowded), use a strided grid search
// to systematically check evenly-spaced candidate positions
if !placed {
// Stride determines spacing between checked positions (larger = faster but might miss spots)
let stride = (current_min_distance * SPAWN_GRID_STRIDE_FACTOR) as u16;
let mut attempts = 0;
for y in (0..size.y).step_by(stride.max(1) as usize) {
for x in (0..size.x).step_by(stride.max(1) as usize) {
let tile_pos = U16Vec2::new(x, y);
if territory_manager.has_owner(tile_pos) || !terrain.is_conquerable(tile_pos) {
continue;
}
if !grid.has_nearby(tile_pos, current_min_distance) {
final_spawns.push(SpawnPoint::new(nation_id, tile_pos));
grid.insert(tile_pos);
placed = true;
break;
}
// Prevent infinite loops on maps with very little valid space
attempts += 1;
if attempts > SPAWN_GRID_MAX_ATTEMPTS {
break;
}
}
if placed {
break;
}
}
}
// Phase 3: Adaptive distance reduction
// If both random and grid search failed, the map is too crowded
// Reduce minimum spacing requirement and retry both phases
if !placed {
current_min_distance *= DISTANCE_REDUCTION_FACTOR;
if reloc_index % 50 == 0 && current_min_distance < MIN_SPAWN_DISTANCE {
tracing::debug!("Adaptive relocation: reduced min_distance to {:.1} for bot {}", current_min_distance, reloc_index);
}
}
}
// Final fallback: ignore all distance constraints
// Guarantees placement even on extremely crowded maps
// Simply finds any valid conquerable tile
if !placed {
for _ in 0..SPAWN_FALLBACK_ATTEMPTS {
let tile_pos = U16Vec2::new(rng.random_range(0..size.x), rng.random_range(0..size.y));
if !territory_manager.has_owner(tile_pos) && terrain.is_conquerable(tile_pos) {
final_spawns.push(SpawnPoint::new(nation_id, tile_pos));
grid.insert(tile_pos);
placed = true;
tracing::warn!("Bot {} relocated with fallback (no distance constraint)", nation_id);
break;
}
}
}
if !placed {
tracing::error!("Failed to relocate bot {} after all attempts", nation_id);
}
}
final_spawns
}

View File

@@ -0,0 +1,2 @@
pub mod bot;
pub use bot::*;

View File

@@ -0,0 +1,361 @@
use bevy_ecs::prelude::*;
use bevy_ecs::schedule::IntoScheduleConfigs;
use glam::U16Vec2;
use rand::rngs::StdRng;
use rand::{Rng, SeedableRng};
use std::sync::{Arc, atomic::AtomicBool};
use tracing::{debug, info};
use crate::game::{Game, NationId, ai::bot, core::constants, input};
use crate::{game, networking, time, ui};
/// Builder for Game initialization with unified configuration
///
/// Provides a fluent API for constructing fully-initialized Game instances with:
/// - Map/terrain configuration (required)
/// - Nations configuration (bots, local player)
/// - Network mode (required)
/// - Optional frontend integration
/// - Time system configuration
/// - Spawn phase settings
/// - Deterministic RNG seeding
/// - System toggle for unit tests
///
/// # Examples
///
/// Production desktop game:
/// ```ignore
/// let game = GameBuilder::new()
/// .with_map(terrain_data)
/// .with_bots(500)
/// .with_local_player(NationId::ZERO)
/// .with_network(NetworkMode::Local)
/// .with_frontend(TauriTransport::new())
/// .build();
/// ```
///
/// Integration test:
/// ```ignore
/// let terrain = MapBuilder::new(100, 100).all_conquerable().build();
/// let game = GameBuilder::new()
/// .with_map(terrain)
/// .with_bots(20)
/// .with_network(NetworkMode::Local)
/// .with_spawn_phase(None)
/// .with_rng_seed(42)
/// .build();
/// ```
pub struct GameBuilder {
// Required
terrain_data: Option<Arc<game::TerrainData>>,
network_mode: Option<networking::NetworkMode>,
// Optional with defaults
bot_count: u32,
local_player_id: Option<NationId>,
frontend_transport: Option<Arc<dyn ui::FrontendTransport>>,
tick_rate: u32,
clock: Option<time::Clock>,
spawn_timeout_secs: Option<u32>,
rng_seed: Option<u64>,
enable_systems: bool,
// Input system (Arc allows sharing across game instances on desktop)
input_queue: Arc<game::InputQueue>,
}
impl GameBuilder {
/// Create a new GameBuilder with default configuration
///
/// Defaults:
/// - bot_count: 0
/// - local_player_id: None (headless)
/// - tick_rate: 10 TPS
/// - clock: real-time clock
/// - spawn_timeout_secs: Some(60)
/// - rng_seed: random
/// - enable_systems: true
pub fn new() -> Self {
Self { terrain_data: None, network_mode: None, bot_count: 0, local_player_id: None, frontend_transport: None, tick_rate: 10, clock: None, spawn_timeout_secs: Some(60), rng_seed: None, enable_systems: true, input_queue: Arc::new(game::InputQueue::new()) }
}
/// Add frontend transport for UI integration
pub fn with_frontend(mut self, transport: impl ui::FrontendTransport + 'static) -> Self {
self.frontend_transport = Some(Arc::new(transport));
self
}
/// Set terrain/map data (required)
pub fn with_map(mut self, terrain: Arc<game::TerrainData>) -> Self {
self.terrain_data = Some(terrain);
self
}
/// Set number of bot nations (default: 0)
pub fn with_bots(mut self, count: u32) -> Self {
self.bot_count = count;
self
}
/// Set local player ID (default: None for headless)
pub fn with_local_player(mut self, id: NationId) -> Self {
self.local_player_id = Some(id);
self
}
/// Set network mode (required)
pub fn with_network(mut self, mode: networking::NetworkMode) -> Self {
self.network_mode = Some(mode);
self
}
/// Set tick rate in ticks per second (default: 10 TPS)
pub fn with_tick_rate(mut self, tps: u32) -> Self {
self.tick_rate = tps;
self
}
/// Set custom clock for time system (default: real-time clock)
pub fn with_clock(mut self, clock: time::Clock) -> Self {
self.clock = Some(clock);
self
}
/// Set spawn phase timeout in seconds (default: Some(60), None to skip spawn phase)
pub fn with_spawn_phase(mut self, timeout_secs: Option<u32>) -> Self {
self.spawn_timeout_secs = timeout_secs;
self
}
/// Set RNG seed for deterministic gameplay (default: random)
pub fn with_rng_seed(mut self, seed: u64) -> Self {
self.rng_seed = Some(seed);
self
}
/// Enable or disable systems (default: true, set to false for unit tests)
pub fn with_systems(mut self, enable: bool) -> Self {
self.enable_systems = enable;
self
}
/// Get a sender for platforms to send input events
///
/// Platforms should call this method to get a flume Sender that can be used
/// to send InputEvents into the game's input queue. The queue is automatically
/// processed at the start of each frame during Game::update().
pub fn input_sender(&self) -> flume::Sender<input::InputEvent> {
self.input_queue.sender()
}
/// Use an existing input queue instead of creating a new one
///
/// This allows platforms to create the input queue once and reuse it across
/// multiple game instances (e.g., after QuitGame/StartGame cycles on desktop).
pub fn with_input_queue(mut self, queue: Arc<game::InputQueue>) -> Self {
self.input_queue = queue;
self
}
/// Build and initialize a Game instance
pub fn build(self) -> Game {
let terrain_data = self.terrain_data.expect("Map/terrain data is required - call .with_map()");
let network_mode = self.network_mode.expect("Network mode is required - call .with_network()");
info!("Creating Game with GameBuilder...");
let mut game = Game::new_internal();
let time = if let Some(clock) = self.clock { time::Time::with_clock(clock, 0) } else { time::Time::new() };
game.insert_resource(time);
game.insert_resource(time::FixedTime::from_seconds(1.0 / self.tick_rate as f64));
let map_size = terrain_data.size();
let _guard = tracing::trace_span!(
"game_initialization",
map_size = ?map_size,
)
.entered();
let conquerable_tiles: Vec<bool> = (0..map_size.y)
.flat_map(|y| {
let terrain = terrain_data.clone();
(0..map_size.x).map(move |x| terrain.is_conquerable(U16Vec2::new(x, y)))
})
.collect();
let client_player_id = self.local_player_id.unwrap_or(NationId::ZERO);
let player_count = if self.local_player_id.is_some() { 1 } else { 0 };
let bot_count_usize = self.bot_count as usize;
let nation_count = player_count + bot_count_usize;
let rng_seed = self.rng_seed.unwrap_or_else(rand::random::<u64>);
let mut rng = StdRng::seed_from_u64(rng_seed);
let mut nation_metadata = Vec::new();
let hue_offset = rng.random_range(0.0..360.0);
for i in 0..nation_count {
let is_human = i < player_count;
let nation_id = NationId::new(i as u16).expect("valid player ID");
let hue = (nation_id.get() as f32 * constants::colors::GOLDEN_ANGLE + hue_offset) % 360.0;
let saturation = rng.random_range(constants::colors::SATURATION_MIN..=constants::colors::SATURATION_MAX);
let lightness = rng.random_range(constants::colors::LIGHTNESS_MIN..=constants::colors::LIGHTNESS_MAX);
let color = game::HSLColor::new(hue, saturation, lightness);
let name = if is_human { if player_count == 1 { "Player".to_string() } else { format!("Player {}", i + 1) } } else { format!("Bot {}", i - player_count + 1) };
nation_metadata.push((nation_id, name, color));
}
let mut territory_manager = game::TerritoryManager::new(map_size);
territory_manager.reset(map_size, &conquerable_tiles);
debug!("Territory manager initialized with {} tiles", conquerable_tiles.len());
let mut active_attacks = game::ActiveAttacks::new();
active_attacks.init();
let bot_ids: Vec<NationId> = nation_metadata.iter().skip(player_count).map(|(id, _, _)| *id).collect();
let initial_bot_spawns = bot::calculate_initial_spawns(&bot_ids, &territory_manager, &terrain_data, rng_seed);
if initial_bot_spawns.len() < bot_count_usize {
tracing::warn!("Only {} of {} bots were able to spawn - map may be too small or bot count too high", initial_bot_spawns.len(), bot_count_usize);
}
let mut nation_entity_map = game::NationEntityMap::default();
let initial_territory_size = 0;
for (nation_id, name, color) in &nation_metadata {
let is_bot = bot_ids.contains(nation_id);
let entity = if is_bot {
let bot_seed = rng_seed.wrapping_add(nation_id.get() as u64);
game.world_mut().spawn((bot::Bot::with_seed(bot_seed), *nation_id, game::NationName(name.clone()), game::NationColor(*color), game::BorderTiles::default(), game::Troops(constants::nation::INITIAL_TROOPS), game::TerritorySize(initial_territory_size), game::ships::ShipCount::default())).id()
} else {
game.world_mut().spawn((*nation_id, game::NationName(name.clone()), game::NationColor(*color), game::BorderTiles::default(), game::Troops(constants::nation::INITIAL_TROOPS), game::TerritorySize(initial_territory_size), game::ships::ShipCount::default())).id()
};
nation_entity_map.0.insert(*nation_id, entity);
}
let coastal_tiles = game::CoastalTiles::compute(&terrain_data, map_size);
let world = game.world_mut();
world.insert_resource(nation_entity_map);
world.insert_resource(territory_manager);
world.insert_resource(active_attacks);
world.insert_resource(terrain_data.as_ref().clone());
world.insert_resource(coastal_tiles);
world.insert_resource(game::SpawnManager::new(initial_bot_spawns.clone(), rng_seed));
world.insert_resource(game::ShipIdCounter::new());
world.insert_resource(game::DeterministicRng::new(rng_seed));
world.insert_resource(game::LocalPlayerContext::new(client_player_id));
// Initialize CurrentTurn with turn 0 - this resource must always exist
world.insert_resource(game::CurrentTurn::new(networking::Turn { turn_number: 0, intents: Vec::new() }));
let skip_spawn_phase = self.spawn_timeout_secs.is_none();
let spawn_timeout_secs_f32 = self.spawn_timeout_secs.unwrap_or(60) as f32;
world.insert_resource(game::SpawnTimeout::new(spawn_timeout_secs_f32));
debug!("SpawnTimeout initialized ({} seconds)", spawn_timeout_secs_f32);
world.insert_resource(game::SpawnPhase { active: !skip_spawn_phase });
let (turn_tx, turn_rx) = flume::unbounded();
world.insert_resource(networking::server::LocalTurnServerHandle { paused: Arc::new(AtomicBool::new(!skip_spawn_phase)), running: Arc::new(AtomicBool::new(true)) });
world.insert_resource(networking::server::TurnReceiver { turn_rx });
world.insert_resource(networking::server::TurnGenerator::new(turn_tx));
if self.enable_systems {
let _guard = tracing::debug_span!("game_plugin_build").entered();
game.add_message::<networking::IntentEvent>().add_message::<networking::ProcessTurnEvent>().add_message::<networking::SpawnConfigEvent>().add_message::<game::ships::LaunchShipMessage>().add_message::<game::ships::ShipArrivalMessage>();
game.add_message::<input::MouseButtonMessage>().add_message::<game::input::MouseMotionMessage>().add_message::<input::KeyEventMessage>().add_message::<input::TileClickedAction>().add_message::<input::CameraAction>().add_message::<input::UiAction>().add_message::<ui::protocol::BackendMessage>().add_message::<ui::protocol::FrontendMessage>();
game.init_resource::<ui::LastLeaderboardDigest>().init_resource::<ui::LastAttacksDigest>().init_resource::<ui::LeaderboardThrottle>().init_resource::<ui::DisplayOrderUpdateCounter>().init_resource::<ui::LastDisplayOrder>().init_resource::<ui::NationHighlightState>().init_resource::<ui::ShipStateTracker>();
game.init_resource::<input::SpawnPhase>().init_resource::<input::AttackControls>().init_resource::<game::systems::BorderCache>().init_resource::<game::builder::PreviousSpawnState>();
match &network_mode {
networking::NetworkMode::Local => {
let _guard = tracing::trace_span!("network_setup", mode = "local").entered();
info!("Initializing game in Local mode");
let (tracked_intent_tx, tracked_intent_rx) = flume::unbounded();
let (_placeholder_tx, placeholder_rx) = flume::unbounded();
let backend = networking::client::LocalBackend::new(tracked_intent_tx, placeholder_rx, NationId::ZERO);
let connection = networking::client::Connection::new_local(backend);
game.insert_resource(connection).insert_resource(networking::client::IntentReceiver { rx: tracked_intent_rx });
}
#[cfg(not(target_arch = "wasm32"))]
networking::NetworkMode::Remote { server_address: _ } => {
unimplemented!("Remote networking temporarily disabled");
}
}
game.add_systems(game::Update, input::input_processor_system);
game.add_systems(game::Update, game::systems::manage_spawn_phase_system);
game.add_systems(game::Update, (game::systems::update_current_turn_system, game::systems::process_nation_income_system).chain());
game.add_systems(game::Update, (game::systems::process_and_apply_actions_system, game::systems::tick_attacks_system, game::systems::handle_spawns_system, game::launch_ship_system).chain().after(game::systems::update_current_turn_system));
game.add_systems(game::Update, (game::update_ships_system, game::handle_ship_arrivals_system, game::check_local_player_outcome, game::update_nation_borders_system).chain().after(game::launch_ship_system));
game.add_systems(game::Update, (ui::emit_leaderboard_snapshot_system, ui::emit_attacks_update_system, ui::emit_ships_update_system, ui::emit_nation_highlight_system));
game.add_systems(game::Update, ui::protocol::handle_frontend_messages_system);
game.add_systems(game::Update, (input::handle_tile_clicked_system, input::handle_camera_action_system, input::handle_ui_action_system).after(input::input_processor_system));
game.add_systems(game::Update, networking::server::generate_turns_system.before(networking::server::poll_turns_system));
match &network_mode {
networking::NetworkMode::Local => {
game.add_systems(game::Update, (networking::server::poll_turns_system.before(game::systems::update_current_turn_system), networking::client::send_intent_system));
}
#[cfg(not(target_arch = "wasm32"))]
networking::NetworkMode::Remote { .. } => {}
}
game.add_systems(game::Last, (game::clear_territory_changes_system, game::systems::turn_cleanup_system).chain());
if let Some(transport) = self.frontend_transport {
let _guard = tracing::trace_span!("frontend_plugin_build").entered();
game.insert_resource(ui::RenderBridge::new(transport));
game.add_systems(game::Update, (ui::send_initial_render_data, ui::stream_territory_deltas, ui::stream_spawn_preview_deltas).chain().after(game::systems::update_nation_borders_system));
game.add_systems(game::Update, (ui::emit_backend_messages_system, ui::ingest_frontend_messages_system));
}
}
game.input_queue = Some(self.input_queue);
if self.enable_systems {
game.run_startup();
game.finish();
}
info!("Game created and initialized successfully");
game
}
}
impl Default for GameBuilder {
fn default() -> Self {
Self::new()
}
}
/// Resource to track previous spawn state for incremental updates
#[derive(Resource, Default)]
pub struct PreviousSpawnState {
pub spawns: Vec<game::SpawnPoint>,
}

View File

@@ -0,0 +1,375 @@
/// Active attacks management
///
/// This module manages all ongoing attacks in the game. It provides efficient
/// lookup and coordination of attacks, ensuring proper merging of attacks on
/// the same target and handling counter-attacks.
use std::collections::{HashMap, HashSet};
use bevy_ecs::prelude::*;
use glam::U16Vec2;
use slotmap::{SlotMap, new_key_type};
new_key_type! {
/// Unique key for identifying attacks in the SlotMap
pub struct AttackKey;
}
use super::executor::{AttackConfig, AttackExecutor};
use crate::game::NationId;
use crate::game::ai::bot::Bot;
use crate::game::core::rng::DeterministicRng;
use crate::game::entities::{NationEntityMap, TerritorySize, Troops};
use crate::game::terrain::TerrainData;
use crate::game::world::TerritoryManager;
/// Index structure for efficient attack lookups
///
/// Maintains multiple indices for O(1) lookups by different criteria.
/// All methods maintain index consistency automatically - you cannot
/// accidentally update one index without updating the others.
struct AttackIndex {
/// Maps each pair of nations to the on-going attacks between them
/// Multiple attacks can exist between same pair (islands, ship landings, etc.)
nation_index: HashMap<(NationId, NationId), HashSet<AttackKey>>,
/// Maps each nation to the unclaimed attack it has scheduled, if any
/// Each nation can only have one on-going unclaimed attack at a time
unclaimed_index: HashMap<NationId, AttackKey>,
/// Maps each nation to the on-going attacks it is involved in
/// A nation can have multiple on-going attacks with the same target.
nation_attack_list: HashMap<NationId, HashSet<AttackKey>>,
/// Maps each nation to the on-going attacks it is targeted by
/// A nation can have multiple on-going attacks from the same attacker.
target_attack_list: HashMap<NationId, HashSet<AttackKey>>,
}
impl AttackIndex {
fn new() -> Self {
Self { nation_index: HashMap::new(), unclaimed_index: HashMap::new(), nation_attack_list: HashMap::new(), target_attack_list: HashMap::new() }
}
fn clear(&mut self) {
self.nation_index.clear();
self.unclaimed_index.clear();
self.nation_attack_list.clear();
self.target_attack_list.clear();
}
/// Get existing unclaimed attack for a nation
fn get_unclaimed_attack(&self, nation_id: NationId) -> Option<AttackKey> {
self.unclaimed_index.get(&nation_id).copied()
}
/// Get first existing attack on a target (for merging troops)
fn get_existing_attack(&self, nation_id: NationId, target_id: NationId) -> Option<AttackKey> {
self.nation_index.get(&(nation_id, target_id))?.iter().next().copied()
}
/// Check if counter-attacks exist (opposite direction)
fn has_counter_attacks(&self, nation_id: NationId, target_id: NationId) -> bool {
self.nation_index.get(&(target_id, nation_id)).is_some_and(|set| !set.is_empty())
}
/// Get first counter-attack key (for resolution)
fn get_counter_attack(&self, nation_id: NationId, target_id: NationId) -> Option<AttackKey> {
self.nation_index.get(&(target_id, nation_id))?.iter().next().copied()
}
/// Get all attacks where nation is attacker
fn get_attacks_by_nation(&self, nation: NationId) -> Option<&HashSet<AttackKey>> {
self.nation_attack_list.get(&nation)
}
/// Get all attacks where nation is target
fn get_attacks_on_nation(&self, nation: NationId) -> Option<&HashSet<AttackKey>> {
self.target_attack_list.get(&nation)
}
/// Add a Nation-vs-Nation attack to all indices atomically
fn add_nation_attack(&mut self, source: NationId, target: NationId, key: AttackKey) {
// Invariant: Cannot attack yourself
if source == target {
tracing::error!(
nation_id = %source,
attack_key = ?key,
"Attempted to add self-attack to index (invariant violation)"
);
return;
}
self.nation_index.entry((source, target)).or_default().insert(key);
self.nation_attack_list.entry(source).or_default().insert(key);
self.target_attack_list.entry(target).or_default().insert(key);
}
/// Add an unclaimed territory attack to all indices atomically
fn add_unclaimed_attack(&mut self, nation: NationId, key: AttackKey) {
self.unclaimed_index.insert(nation, key);
self.nation_attack_list.entry(nation).or_default().insert(key);
}
/// Remove a Nation-vs-Nation attack from all indices atomically
fn remove_nation_attack(&mut self, source: NationId, target: NationId, key: AttackKey) {
if let Some(attack_set) = self.nation_attack_list.get_mut(&source) {
attack_set.remove(&key);
}
if let Some(attack_set) = self.target_attack_list.get_mut(&target) {
attack_set.remove(&key);
}
if let Some(attack_set) = self.nation_index.get_mut(&(source, target)) {
attack_set.remove(&key);
}
}
/// Remove an unclaimed territory attack from all indices atomically
fn remove_unclaimed_attack(&mut self, nation: NationId, key: AttackKey) {
self.unclaimed_index.remove(&nation);
if let Some(attack_set) = self.nation_attack_list.get_mut(&nation) {
attack_set.remove(&key);
}
}
}
/// Manages all active attacks in the game
///
/// This resource tracks ongoing attacks and provides efficient lookup
/// by attacker/target relationships. Attacks progress over multiple turns
/// until they run out of troops or conquerable tiles.
///
/// Uses SlotMap for stable keys - no index shifting needed on removal.
/// Uses AttackIndex for consistent multi-index management.
#[derive(Resource)]
pub struct ActiveAttacks {
attacks: SlotMap<AttackKey, AttackExecutor>,
index: AttackIndex,
next_attack_id: u64,
}
impl Default for ActiveAttacks {
fn default() -> Self {
Self::new()
}
}
impl ActiveAttacks {
pub fn new() -> Self {
Self { attacks: SlotMap::with_key(), index: AttackIndex::new(), next_attack_id: 0 }
}
/// Initialize the attack handler
pub fn init(&mut self) {
self.attacks.clear();
self.index.clear();
self.next_attack_id = 0;
}
/// Schedule an attack on unclaimed territory
///
/// If an attack on unclaimed territory already exists for this nation,
/// the troops are added to it and borders are expanded.
#[allow(clippy::too_many_arguments)]
pub fn schedule_unclaimed(&mut self, nation_id: NationId, troops: f32, border_tiles: Option<&HashSet<U16Vec2>>, territory_manager: &TerritoryManager, terrain: &TerrainData, nation_borders: &HashMap<NationId, &HashSet<U16Vec2>>, turn_number: u64, rng: &DeterministicRng) {
// Check if there's already an attack on unclaimed territory
if let Some(attack_key) = self.index.get_unclaimed_attack(nation_id) {
// Add troops to existing attack
self.attacks[attack_key].modify_troops(troops);
// Add new borders to allow multi-region expansion
if let Some(borders) = border_tiles.or_else(|| nation_borders.get(&nation_id).copied()) {
self.attacks[attack_key].add_borders(borders, territory_manager, terrain, rng);
}
return;
}
// Create new attack
self.add_unclaimed(nation_id, troops, border_tiles, territory_manager, terrain, nation_borders, turn_number, rng);
}
/// Schedule an attack on another nation
///
/// Handles attack merging (if attacking same target) and counter-attacks
/// (opposite direction attacks are resolved first).
#[allow(clippy::too_many_arguments)]
pub fn schedule_attack(&mut self, nation_id: NationId, target_id: NationId, mut troops: f32, border_tiles: Option<&HashSet<U16Vec2>>, territory_manager: &TerritoryManager, terrain: &TerrainData, nation_borders: &HashMap<NationId, &HashSet<U16Vec2>>, turn_number: u64, rng: &DeterministicRng) {
// Prevent self-attacks early (before any processing)
if nation_id == target_id {
tracing::warn!(
nation_id = %nation_id,
"Attempted self-attack prevented"
);
return;
}
// Check if there's already an attack on this target
if let Some(attack_key) = self.index.get_existing_attack(nation_id, target_id) {
// Add troops to existing attack
self.attacks[attack_key].modify_troops(troops);
// Add new borders to allow multi-region expansion
if let Some(borders) = border_tiles.or_else(|| nation_borders.get(&nation_id).copied()) {
self.attacks[attack_key].add_borders(borders, territory_manager, terrain, rng);
}
return;
}
// Check for counter-attacks (opposite direction) - prevent mutual attacks
while self.index.has_counter_attacks(nation_id, target_id) {
let opposite_key = self.index.get_counter_attack(nation_id, target_id).unwrap();
if self.attacks[opposite_key].oppose(troops) {
// Counter-attack absorbed the new attack
return;
}
// Counter-attack was defeated, deduct its troops from the new attack
troops -= self.attacks[opposite_key].get_troops();
// Remove the defeated counter-attack
self.remove_attack(opposite_key);
}
// Create new attack
self.add_attack(nation_id, target_id, troops, border_tiles, territory_manager, terrain, nation_borders, turn_number, rng);
}
/// Tick all active attacks
///
/// Progresses each attack by one turn. Attacks that run out of troops
/// or conquerable tiles are removed and their remaining troops are
/// returned to the attacking nation.
#[allow(clippy::too_many_arguments)]
pub fn tick(&mut self, entity_map: &NationEntityMap, nations: &mut Query<(&mut Troops, &mut TerritorySize)>, territory_manager: &mut TerritoryManager, terrain: &TerrainData, nation_borders: &HashMap<NationId, &HashSet<U16Vec2>>, rng: &DeterministicRng, is_bot_query: &Query<Has<Bot>>) {
let attack_count = self.attacks.len();
let _guard = tracing::trace_span!("attacks_tick", attack_count).entered();
let mut attacks_to_remove = Vec::new();
for (attack_key, attack) in &mut self.attacks {
let should_continue = attack.tick(entity_map, nations, territory_manager, terrain, nation_borders, rng);
if !should_continue {
// Return remaining troops to nation (ECS component)
let remaining_troops = attack.get_troops();
if let Some(&entity) = entity_map.0.get(&attack.source)
&& let Ok((mut troops, territory_size)) = nations.get_mut(entity)
{
let is_bot = is_bot_query.get(entity).unwrap_or(false);
troops.0 = crate::game::entities::add_troops_capped(troops.0, remaining_troops, territory_size.0, is_bot);
}
// Mark attack for removal
attacks_to_remove.push(attack_key);
}
}
// Remove completed attacks
for attack_key in attacks_to_remove {
self.remove_attack(attack_key);
}
}
/// Handle a tile being added to a nation's territory
///
/// Notifies all relevant attacks that territory has changed so they can
/// update their borders and targets.
pub fn handle_territory_add(&mut self, tile: U16Vec2, nation_id: NationId, territory_manager: &TerritoryManager, terrain: &TerrainData, rng: &DeterministicRng) {
// Notify all attacks where this nation is the attacker
if let Some(attack_set) = self.index.get_attacks_by_nation(nation_id) {
for &attack_key in attack_set {
self.attacks[attack_key].handle_nation_tile_add(tile, territory_manager, terrain, rng);
}
}
// Notify all attacks where this nation is the target
if let Some(attack_set) = self.index.get_attacks_on_nation(nation_id) {
for &attack_key in attack_set {
self.attacks[attack_key].handle_target_tile_add(tile, territory_manager, rng);
}
}
}
/// Add an attack on unclaimed territory
#[allow(clippy::too_many_arguments)]
fn add_unclaimed(&mut self, nation: NationId, troops: f32, border_tiles: Option<&HashSet<U16Vec2>>, territory_manager: &TerritoryManager, terrain: &TerrainData, nation_borders: &HashMap<NationId, &HashSet<U16Vec2>>, turn_number: u64, rng: &DeterministicRng) {
let attack_id = self.next_attack_id;
self.next_attack_id += 1;
let attack = AttackExecutor::new(AttackConfig { id: attack_id, source: nation, target: None, troops, border_tiles, territory_manager, nation_borders, turn_number, terrain }, rng);
let attack_key = self.attacks.insert(attack);
self.index.add_unclaimed_attack(nation, attack_key);
}
/// Add an attack on a nation
#[allow(clippy::too_many_arguments)]
fn add_attack(&mut self, nation_id: NationId, target_id: NationId, troops: f32, border_tiles: Option<&HashSet<U16Vec2>>, territory_manager: &TerritoryManager, terrain: &TerrainData, nation_borders: &HashMap<NationId, &HashSet<U16Vec2>>, turn_number: u64, rng: &DeterministicRng) {
let attack_id = self.next_attack_id;
self.next_attack_id += 1;
let attack = AttackExecutor::new(AttackConfig { id: attack_id, source: nation_id, target: Some(target_id), troops, border_tiles, territory_manager, nation_borders, turn_number, terrain }, rng);
let attack_key = self.attacks.insert(attack);
self.index.add_nation_attack(nation_id, target_id, attack_key);
}
/// Get all attacks involving a specific nation (as attacker or target)
///
/// Returns a list of (attacker_id, target_id, troops, start_turn, is_outgoing)
/// sorted by start_turn descending (most recent first)
pub fn get_attacks_for_nation(&self, nation_id: NationId) -> Vec<(NationId, Option<NationId>, f32, u64, bool)> {
let mut attacks = Vec::new();
// Add outgoing attacks (nation is attacker)
if let Some(attack_set) = self.index.get_attacks_by_nation(nation_id) {
for &attack_key in attack_set {
let attack = &self.attacks[attack_key];
attacks.push((
attack.source,
attack.target,
attack.get_troops(),
attack.id(),
true, // outgoing
));
}
}
// Add incoming attacks (nation is target)
if let Some(attack_set) = self.index.get_attacks_on_nation(nation_id) {
for &attack_key in attack_set {
let attack = &self.attacks[attack_key];
attacks.push((
attack.source,
attack.target,
attack.get_troops(),
attack.id(),
false, // incoming
));
}
}
// Sort by attack ID descending (most recent first)
attacks.sort_by(|a, b| b.3.cmp(&a.3));
attacks
}
/// Remove an attack and update all indices
///
/// With SlotMap, keys remain stable so no index shifting is needed.
/// HashSet provides O(1) removal without element shifting.
fn remove_attack(&mut self, attack_key: AttackKey) {
let attack = &self.attacks[attack_key];
// Remove from all indices atomically
if let Some(target) = attack.target {
self.index.remove_nation_attack(attack.source, target, attack_key);
} else {
self.index.remove_unclaimed_attack(attack.source, attack_key);
}
// Remove attack from slot map - no index shifting needed!
self.attacks.remove(attack_key);
}
}

View File

@@ -0,0 +1,118 @@
/// Pure combat calculation functions
///
/// This module contains all combat mathematics extracted from the attack system.
/// All functions are pure (no side effects) and deterministic, making them
/// easy to test, reason about, and modify.
use glam::U16Vec2;
use crate::game::core::constants::combat::*;
use crate::game::world::TerritoryManager;
/// Parameters for combat result calculation
pub struct CombatParams<'a> {
pub attacker_troops: f32,
pub attacker_territory_size: usize,
pub defender_troops: Option<f32>,
pub defender_territory_size: Option<usize>,
pub tile: U16Vec2,
pub territory_manager: &'a TerritoryManager,
pub width: u16,
}
/// Result of combat calculations for conquering one tile
#[derive(Debug, Clone, Copy)]
pub struct CombatResult {
/// Troops lost by the attacker
pub attacker_loss: f32,
/// Troops lost by the defender
pub defender_loss: f32,
/// How much of the "tiles per tick" budget this conquest consumes
pub tiles_per_tick_used: f32,
}
/// Sigmoid function for smooth scaling curves
///
/// Used for empire size balancing to create smooth transitions
/// rather than hard thresholds.
#[inline]
pub fn sigmoid(x: f32, decay_rate: f32, midpoint: f32) -> f32 {
1.0 / (1.0 + (-(x - midpoint) * decay_rate).exp())
}
/// Calculate combat result for conquering one tile
///
/// This function determines troop losses and conquest cost based on:
/// - Attacker and defender troop counts and empire sizes
/// - Terrain properties (currently plains baseline)
/// - Empire size balancing (prevents snowballing)
/// - Defense structures (placeholder for future implementation)
pub fn calculate_combat_result(params: CombatParams) -> CombatResult {
if let (Some(defender_troops), Some(defender_territory_size)) = (params.defender_troops, params.defender_territory_size) {
// Attacking claimed territory
// Base terrain values (plains baseline)
let mut mag = BASE_MAG_PLAINS;
let mut speed = BASE_SPEED_PLAINS;
// Defense post check (placeholder - always false for now)
let has_defense_post = check_defense_post_nearby(params.tile, params.territory_manager);
if has_defense_post {
mag *= DEFENSE_POST_MAG_MULTIPLIER;
speed *= DEFENSE_POST_SPEED_MULTIPLIER;
}
// Empire size balancing - prevents snowballing
// Large defenders get debuffed, large attackers get penalized
let defense_sig = 1.0 - sigmoid(defender_territory_size as f32, DEFENSE_DEBUFF_DECAY_RATE, DEFENSE_DEBUFF_MIDPOINT);
let large_defender_speed_debuff = LARGE_DEFENDER_BASE_DEBUFF + LARGE_DEFENDER_SCALING * defense_sig;
let large_defender_attack_debuff = LARGE_DEFENDER_BASE_DEBUFF + LARGE_DEFENDER_SCALING * defense_sig;
let large_attacker_bonus = if params.attacker_territory_size > LARGE_EMPIRE_THRESHOLD as usize { (LARGE_EMPIRE_THRESHOLD as f32 / params.attacker_territory_size as f32).sqrt().powf(LARGE_ATTACKER_POWER_EXPONENT) } else { 1.0 };
let large_attacker_speed_bonus = if params.attacker_territory_size > LARGE_EMPIRE_THRESHOLD as usize { (LARGE_EMPIRE_THRESHOLD as f32 / params.attacker_territory_size as f32).powf(LARGE_ATTACKER_SPEED_EXPONENT) } else { 1.0 };
// Calculate troop ratio
let troop_ratio = (defender_troops / params.attacker_troops.max(1.0)).clamp(TROOP_RATIO_MIN, TROOP_RATIO_MAX);
// Final attacker loss
let attacker_loss = troop_ratio * mag * ATTACKER_LOSS_MULTIPLIER * large_defender_attack_debuff * large_attacker_bonus;
// Defender loss (simple: troops per tile)
let defender_loss = defender_troops / defender_territory_size.max(1) as f32;
// Tiles per tick cost for this tile
let tiles_per_tick_used = (defender_troops / (TILES_PER_TICK_DIVISOR * params.attacker_troops.max(1.0))).clamp(TILES_PER_TICK_MIN, TILES_PER_TICK_MAX) * speed * large_defender_speed_debuff * large_attacker_speed_bonus;
CombatResult { attacker_loss, defender_loss, tiles_per_tick_used }
} else {
// Attacking unclaimed territory
CombatResult { attacker_loss: BASE_MAG_PLAINS / UNCLAIMED_ATTACK_LOSS_DIVISOR, defender_loss: 0.0, tiles_per_tick_used: ((UNCLAIMED_BASE_MULTIPLIER * BASE_SPEED_PLAINS.max(MIN_SPEED_PLAINS)) / params.attacker_troops.max(1.0)).clamp(UNCLAIMED_TILES_MIN, UNCLAIMED_TILES_MAX) }
}
}
/// Calculate tiles conquered per tick based on troop ratio and border size
///
/// This determines how fast an attack progresses. It's based on:
/// - The attacker's troop advantage (or disadvantage)
/// - The size of the attack border
/// - Random variation for organic-looking expansion
pub fn calculate_tiles_per_tick(attacker_troops: f32, defender_troops: Option<f32>, border_size: f32) -> f32 {
if let Some(defender_troops) = defender_troops {
// Dynamic based on troop ratio
let ratio = ((ATTACK_RATIO_MULTIPLIER * attacker_troops) / defender_troops.max(1.0)) * ATTACK_RATIO_SCALE;
let clamped_ratio = ratio.clamp(ATTACK_RATIO_MIN, ATTACK_RATIO_MAX);
clamped_ratio * border_size * CLAIMED_TILES_PER_TICK_MULTIPLIER
} else {
// Fixed rate for unclaimed territory
border_size * UNCLAIMED_TILES_PER_TICK_MULTIPLIER
}
}
/// Check if defender has a defense post nearby (placeholder)
///
/// This will be implemented when defense structures are added to the game.
/// For now, always returns false.
fn check_defense_post_nearby(_tile: U16Vec2, _territory_manager: &TerritoryManager) -> bool {
// Placeholder for future defense post implementation
false
}

View File

@@ -0,0 +1,396 @@
/// Attack execution logic
///
/// This module contains the `AttackExecutor` which manages the progression
/// of a single attack over multiple turns. It handles tile prioritization,
/// border expansion, and conquest mechanics.
use std::collections::{BinaryHeap, HashMap, HashSet};
use glam::U16Vec2;
use rand::Rng;
use super::calculator::{CombatParams, calculate_combat_result, calculate_tiles_per_tick};
use crate::game::core::constants::combat::*;
use crate::game::core::rng::DeterministicRng;
use crate::game::core::utils::neighbors;
use crate::game::entities::{NationEntityMap, TerritorySize, Troops};
use crate::game::terrain::TerrainData;
use crate::game::world::{NationId, TerritoryManager};
use bevy_ecs::prelude::*;
/// Priority queue entry for tile conquest
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct TilePriority {
tile: U16Vec2,
priority: i64, // Lower value = higher priority (conquered sooner)
}
impl PartialOrd for TilePriority {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for TilePriority {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
other.priority.cmp(&self.priority).then_with(|| self.tile.x.cmp(&other.tile.x).then_with(|| self.tile.y.cmp(&other.tile.y)))
}
}
/// Configuration for creating an AttackExecutor
pub struct AttackConfig<'a> {
pub id: u64,
pub source: NationId,
pub target: Option<NationId>,
pub troops: f32,
pub border_tiles: Option<&'a HashSet<U16Vec2>>,
pub territory_manager: &'a TerritoryManager,
pub nation_borders: &'a HashMap<NationId, &'a HashSet<U16Vec2>>,
pub turn_number: u64,
pub terrain: &'a TerrainData,
}
/// Executes a single ongoing attack (conquering tiles over time)
///
/// An attack progresses over multiple turns, conquering tiles based on:
/// - Available troops
/// - Troop ratio vs defender
/// - Border size and connectivity
/// - Combat formulas from the calculator module
///
/// The executor maintains a priority queue of tiles to conquer and updates
/// borders as it progresses.
pub struct AttackExecutor {
id: u64,
pub source: NationId,
pub target: Option<NationId>,
troops: f32,
/// Active conquest frontier - tiles being evaluated/conquered by this attack.
/// Distinct from nation BorderTiles: dynamically shrinks as tiles are conquered
/// and expands as new neighbors become targets.
conquest_frontier: HashSet<U16Vec2>,
priority_queue: BinaryHeap<TilePriority>,
start_turn: u64,
current_turn: u64,
tiles_conquered: usize, // Counter for each tile conquered (for priority calculation)
pending_removal: bool, // Mark attack for removal on next tick (allows final troops=0 update)
}
impl AttackExecutor {
/// Create a new attack executor
pub fn new(config: AttackConfig, rng: &DeterministicRng) -> Self {
let mut executor = Self { id: config.id, source: config.source, target: config.target, troops: config.troops, conquest_frontier: HashSet::new(), priority_queue: BinaryHeap::new(), start_turn: config.turn_number, current_turn: config.turn_number, tiles_conquered: 0, pending_removal: false };
executor.initialize_border(config.border_tiles, config.territory_manager, config.terrain, config.nation_borders, rng);
executor
}
/// Modify the amount of troops in the attack
pub fn modify_troops(&mut self, amount: f32) {
self.troops += amount;
}
/// Add new border tiles to the attack, allowing expansion from multiple fronts
///
/// This enables multi-region expansion when attacking the same target from different areas
pub fn add_borders(&mut self, new_border_tiles: &HashSet<U16Vec2>, territory_manager: &TerritoryManager, terrain: &TerrainData, rng: &DeterministicRng) {
// Add neighbors from each new border tile
for &tile in new_border_tiles {
for neighbor in neighbors(tile, territory_manager.size()) {
if self.is_valid_target(neighbor, territory_manager, terrain) && !self.conquest_frontier.contains(&neighbor) {
self.add_tile_to_border(neighbor, territory_manager, rng);
}
}
}
}
/// Oppose an attack (counter-attack)
///
/// Returns true if the attack continues, false if it was defeated
pub fn oppose(&mut self, troop_count: f32) -> bool {
if self.troops > troop_count {
self.troops -= troop_count;
true
} else {
false
}
}
/// Get the unique attack identifier
pub fn id(&self) -> u64 {
self.id
}
/// Get the amount of troops in the attack
pub fn get_troops(&self) -> f32 {
self.troops.max(0.0).floor()
}
/// Get the turn this attack started
pub fn get_start_turn(&self) -> u64 {
self.start_turn
}
/// Tick the attack executor
///
/// Returns true if the attack continues, false if it's finished
pub fn tick(&mut self, entity_map: &NationEntityMap, nations: &mut Query<(&mut Troops, &mut TerritorySize)>, territory_manager: &mut TerritoryManager, terrain: &TerrainData, nation_borders: &HashMap<NationId, &HashSet<U16Vec2>>, rng: &DeterministicRng) -> bool {
let _guard = tracing::trace_span!("attack_tick", nation_id = %self.source).entered();
// If marked for removal, remove now (allows one final update with troops=0)
if self.pending_removal {
return false;
}
self.current_turn += 1;
// Calculate how many tiles to conquer this tick
let mut tiles_per_tick = self.calculate_tiles_per_tick(entity_map, nations, rng);
// Track if we've already refreshed this tick to prevent infinite refresh loops
let mut has_refreshed = false;
// Process tiles from priority queue
while tiles_per_tick > 0.0 {
if self.troops < 1.0 {
self.troops = 0.0;
self.pending_removal = true;
return true; // Keep alive for one more tick to send troops=0
}
if self.priority_queue.is_empty() {
// If we already refreshed this tick, stop to prevent infinite loop
if has_refreshed {
self.troops = 0.0;
self.pending_removal = true;
return true; // Keep alive for one more tick to send troops=0
}
// Remember border size before refresh
let border_size_before = self.conquest_frontier.len();
// Refresh border tiles one last time before giving up
self.refresh_border(nation_borders, territory_manager, terrain, rng);
has_refreshed = true;
// If refresh found no new tiles, attack is finished
if self.conquest_frontier.len() == border_size_before {
self.troops = 0.0;
self.pending_removal = true;
return true; // Keep alive for one more tick to send troops=0
}
// If still empty after refresh (all tiles invalid), attack is finished
if self.priority_queue.is_empty() {
self.troops = 0.0;
self.pending_removal = true;
return true; // Keep alive for one more tick to send troops=0
}
}
let tile_priority = self.priority_queue.pop().unwrap();
let tile = tile_priority.tile;
self.conquest_frontier.remove(&tile);
// Check connectivity and validity
let on_border = Self::check_borders_tile(tile, self.source, territory_manager);
let tile_valid = self.is_valid_target(tile, territory_manager, terrain);
// Prevent attacking own tiles (race condition during conquest)
let tile_owner = territory_manager.get_ownership(tile);
let attacking_self = tile_owner.nation_id() == Some(self.source);
// Skip if any check fails
if !tile_valid || !on_border || attacking_self {
continue;
}
// Add neighbors BEFORE conquering (critical for correct expansion)
self.add_neighbors_to_border(tile, territory_manager, terrain, rng);
// Query attacker territory size from ECS
let attacker_troops = self.troops;
let attacker_territory_size = if let Some(&attacker_entity) = entity_map.0.get(&self.source)
&& let Ok((_, territory)) = nations.get(attacker_entity)
{
territory.0
} else {
// Attacker no longer exists - immediate removal (error state)
return false;
};
// Query defender stats from ECS if attacking a player
let (defender_troops, defender_territory_size) = if let Some(target_id) = self.target { if let Some(&defender_entity) = entity_map.0.get(&target_id) { if let Ok((troops, territory)) = nations.get(defender_entity) { (Some(troops.0), Some(territory.0)) } else { (None, None) } } else { (None, None) } } else { (None, None) };
// Calculate losses for this tile
let combat_result = { calculate_combat_result(CombatParams { attacker_troops, attacker_territory_size: attacker_territory_size as usize, defender_troops, defender_territory_size: defender_territory_size.map(|s| s as usize), tile, territory_manager, width: territory_manager.width() }) };
// Check if we still have enough troops to conquer this tile
if self.troops < combat_result.attacker_loss {
self.troops = 0.0;
self.pending_removal = true;
return true; // Keep alive for one more tick to send troops=0
}
// Apply troop losses
self.troops -= combat_result.attacker_loss;
if let Some(target_id) = self.target
&& let Some(&defender_entity) = entity_map.0.get(&target_id)
&& let Ok((mut troops, _)) = nations.get_mut(defender_entity)
{
troops.0 = (troops.0 - combat_result.defender_loss).max(0.0);
}
// Conquer the tile
let previous_owner = territory_manager.conquer(tile, self.source);
// Update nation territory sizes
if let Some(nation_id) = previous_owner
&& let Some(&nation_entity) = entity_map.0.get(&nation_id)
&& let Ok((_, mut territory_size)) = nations.get_mut(nation_entity)
{
territory_size.0 = territory_size.0.saturating_sub(1);
}
if let Some(&nation_entity) = entity_map.0.get(&self.source)
&& let Ok((_, mut territory_size)) = nations.get_mut(nation_entity)
{
territory_size.0 += 1;
}
// Increment tiles conquered counter (used for priority calculation)
self.tiles_conquered += 1;
// Decrement tiles per tick counter
tiles_per_tick -= combat_result.tiles_per_tick_used;
}
// Check if attack should continue
!self.priority_queue.is_empty() && self.troops >= 1.0
}
/// Calculate tiles conquered per tick based on troop ratio and border size
fn calculate_tiles_per_tick(&mut self, entity_map: &NationEntityMap, nations: &Query<(&mut Troops, &mut TerritorySize)>, rng: &DeterministicRng) -> f32 {
// Add random 0-4 to border size
// This introduces natural variation in expansion speed
let mut context_rng = rng.for_context(self.source.get() as u64);
let random_border_adjustment = context_rng.random_range(0..BORDER_RANDOM_ADJUSTMENT_MAX) as f32;
let border_size = self.priority_queue.len() as f32 + random_border_adjustment;
// Query defender troops if attacking a player
let defender_troops = if let Some(target_id) = self.target { entity_map.0.get(&target_id).and_then(|&entity| nations.get(entity).ok()).map(|(troops, _)| troops.0) } else { None };
calculate_tiles_per_tick(self.troops, defender_troops, border_size)
}
/// Check if a tile is a valid target for this attack
fn is_valid_target(&self, tile: U16Vec2, territory_manager: &TerritoryManager, terrain: &TerrainData) -> bool {
if let Some(target_id) = self.target {
territory_manager.is_owner(tile, target_id)
} else {
// For unclaimed attacks, check if tile is unowned and conquerable (not water)
!territory_manager.has_owner(tile) && terrain.is_conquerable(tile)
}
}
/// Add a tile to the border with proper priority calculation
fn add_tile_to_border(&mut self, tile: U16Vec2, territory_manager: &TerritoryManager, rng: &DeterministicRng) {
self.conquest_frontier.insert(tile);
let priority = self.calculate_tile_priority(tile, territory_manager, rng);
self.priority_queue.push(TilePriority { tile, priority });
}
/// Initialize border tiles from player's existing borders
fn initialize_border(&mut self, border_tiles: Option<&HashSet<U16Vec2>>, territory_manager: &TerritoryManager, terrain: &TerrainData, nation_borders: &HashMap<NationId, &HashSet<U16Vec2>>, rng: &DeterministicRng) {
self.initialize_border_internal(border_tiles, territory_manager, terrain, nation_borders, rng, false);
}
/// Refresh the attack border by re-scanning all nation border tiles
///
/// This gives the attack one last chance to find conquerable tiles before ending
fn refresh_border(&mut self, nation_borders: &HashMap<NationId, &HashSet<U16Vec2>>, territory_manager: &TerritoryManager, terrain: &TerrainData, rng: &DeterministicRng) {
self.initialize_border_internal(None, territory_manager, terrain, nation_borders, rng, true);
}
/// Internal method to initialize or refresh border tiles
fn initialize_border_internal(&mut self, border_tiles: Option<&HashSet<U16Vec2>>, territory_manager: &TerritoryManager, terrain: &TerrainData, nation_borders: &HashMap<NationId, &HashSet<U16Vec2>>, rng: &DeterministicRng, clear_first: bool) {
if clear_first {
self.priority_queue.clear();
self.conquest_frontier.clear();
}
// Get borders or use empty set as fallback (needs lifetime handling)
let empty_borders = HashSet::new();
let borders = border_tiles.or_else(|| nation_borders.get(&self.source).copied()).unwrap_or(&empty_borders);
let border_count = borders.len();
let _refresh_guard;
let _init_guard;
if clear_first {
_refresh_guard = tracing::trace_span!("refresh_attack_border", border_count).entered();
} else {
_init_guard = tracing::trace_span!("initialize_attack_border", border_count).entered();
}
// Find all target tiles adjacent to our borders
for &tile in borders {
for neighbor in neighbors(tile, territory_manager.size()) {
if self.is_valid_target(neighbor, territory_manager, terrain) && !self.conquest_frontier.contains(&neighbor) {
self.add_tile_to_border(neighbor, territory_manager, rng);
}
}
}
}
/// Add neighbors of a newly conquered tile to the border
fn add_neighbors_to_border(&mut self, tile: U16Vec2, territory_manager: &TerritoryManager, terrain: &TerrainData, rng: &DeterministicRng) {
for neighbor in neighbors(tile, territory_manager.size()) {
if self.is_valid_target(neighbor, territory_manager, terrain) && !self.conquest_frontier.contains(&neighbor) {
self.add_tile_to_border(neighbor, territory_manager, rng);
}
}
}
/// Calculate priority for a tile (lower = conquered sooner)
///
/// Uses tiles_conquered counter to ensure wave-like expansion
fn calculate_tile_priority(&self, tile: U16Vec2, territory_manager: &TerritoryManager, rng: &DeterministicRng) -> i64 {
// Count how many neighbors are owned by attacker
let num_owned_by_attacker = neighbors(tile, territory_manager.size()).filter(|&neighbor| territory_manager.is_owner(neighbor, self.source)).count();
let terrain_mag = 1.0;
// Random factor (0-7)
let mut tile_rng = rng.for_tile(tile);
let random_factor = tile_rng.random_range(0..TILE_PRIORITY_RANDOM_MAX);
// Priority calculation (lower = higher priority, conquered sooner)
// Base calculation: tiles surrounded by more attacker neighbors get LOWER modifier values
// Adding tiles_conquered ensures tiles discovered earlier get lower priority values
// This creates wave-like expansion: older tiles (lower priority) conquered before newer tiles (higher priority)
let base = (random_factor + 10) as f32;
let modifier = TILE_PRIORITY_BASE - (num_owned_by_attacker as f32 * TILE_PRIORITY_NEIGHBOR_PENALTY) + (terrain_mag / 2.0);
(base * modifier) as i64 + self.tiles_conquered as i64
}
/// Handle the addition of a tile to the nation's territory
pub fn handle_nation_tile_add(&mut self, tile: U16Vec2, territory_manager: &TerritoryManager, terrain: &TerrainData, rng: &DeterministicRng) {
// When nation gains a tile, check its neighbors for new targets
self.add_neighbors_to_border(tile, territory_manager, terrain, rng);
}
/// Handle the addition of a tile to the target's territory
pub fn handle_target_tile_add(&mut self, tile: U16Vec2, territory_manager: &TerritoryManager, rng: &DeterministicRng) {
// If target gains a tile that borders our territory, add it to attack
if Self::check_borders_tile(tile, self.source, territory_manager) && !self.conquest_frontier.contains(&tile) {
self.conquest_frontier.insert(tile);
let priority = self.calculate_tile_priority(tile, territory_manager, rng);
self.priority_queue.push(TilePriority { tile, priority });
}
}
/// Check if a tile borders the nation's territory
fn check_borders_tile(tile: U16Vec2, nation_id: NationId, territory_manager: &TerritoryManager) -> bool {
neighbors(tile, territory_manager.size()).any(|neighbor| territory_manager.is_owner(neighbor, nation_id))
}
}

View File

@@ -0,0 +1,7 @@
pub mod active;
pub mod calculator;
pub mod executor;
pub use active::*;
pub use calculator::*;
pub use executor::*;

View File

@@ -0,0 +1,57 @@
//! Game action system
//!
//! This module defines the core action types that can be performed in the game.
//! Actions represent discrete game events that can be initiated by both human players
//! and AI bots. They are processed deterministically during turn execution.
use rkyv::{Archive, Deserialize as RkyvDeserialize, Serialize as RkyvSerialize};
use serde::{Deserialize, Serialize};
use crate::game::core::utils::u16vec2_serde;
use crate::game::world::NationId;
/// Core game action type
///
/// This enum represents all possible actions that can be performed in the game.
/// Unlike `Intent`, which is a network-layer wrapper, `GameAction` is the actual
/// game-level operation.
///
/// Actions can originate from:
/// - Players (via input systems → intents → network → SourcedIntent wrapper)
/// - Bots (calculated deterministically during turn execution)
///
/// Nation identity is provided separately:
/// - For players: wrapped in SourcedIntent by server (prevents spoofing)
/// - For bots: generated with id in turn execution context
///
/// Note: Spawning is handled separately via Turn(0) and direct spawn manager updates,
/// not through the action system.
#[derive(Debug, Clone, Serialize, Deserialize, Archive, RkyvSerialize, RkyvDeserialize)]
#[rkyv(derive(Debug))]
pub enum GameAction {
/// Attack a target nation with a specified number of troops
///
/// The attack will proceed across all borders shared with the target:
/// - `target: Some(nation_id)` - Attack specific nation across all shared borders
/// - `target: None` - Expand into unclaimed territory from all borders
Attack { target: Option<NationId>, troops: u32 },
/// Launch a transport ship to attack across water
LaunchShip {
#[serde(with = "u16vec2_serde")]
target_tile: glam::U16Vec2,
troops: u32,
},
// Future action types:
// BuildStructure { target: U16Vec2, structure_type: StructureType },
// LaunchNuke { target: U16Vec2 },
// RequestAlliance { target: NationId },
// DeclareWar { target: NationId },
}
/// Troop count specification for attacks
pub enum TroopCount {
/// Use a ratio of the nation's current troops (0.0-1.0)
Ratio(f32),
/// Use an absolute troop count
Absolute(u32),
}

View File

@@ -0,0 +1,255 @@
/// Game constants organized by domain
///
/// This module centralizes all game balance constants that were previously
/// scattered across multiple files. Constants are grouped by gameplay domain
/// for easy discovery and tuning.
pub mod game {
/// Game tick interval in milliseconds (10 TPS = 100ms per turn)
pub const TICK_INTERVAL: u64 = 100;
/// Number of bot nations
pub const BOT_COUNT: usize = 500;
}
pub mod combat {
/// Empire size balancing - prevents snowballing by large empires
/// Defense effectiveness decreases as empire grows beyond this threshold
pub const DEFENSE_DEBUFF_MIDPOINT: f32 = 150_000.0;
/// Rate of defense effectiveness decay for large empires
/// Uses natural log decay for smooth scaling
pub const DEFENSE_DEBUFF_DECAY_RATE: f32 = std::f32::consts::LN_2 / 50_000.0;
/// Base terrain magnitude cost for plains (baseline terrain)
/// Determines troop losses when conquering a tile
pub const BASE_MAG_PLAINS: f32 = 80.0;
/// Base terrain speed for plains (baseline terrain)
/// Affects how many tiles can be conquered per tick
pub const BASE_SPEED_PLAINS: f32 = 16.5;
/// Maximum random adjustment to border size when calculating expansion speed
/// Introduces natural variation in attack progression (0-4 range)
pub const BORDER_RANDOM_ADJUSTMENT_MAX: u32 = 5;
/// Multiplier for tiles conquered per tick when attacking unclaimed territory
pub const UNCLAIMED_TILES_PER_TICK_MULTIPLIER: f32 = 2.0;
/// Multiplier for tiles conquered per tick when attacking claimed territory
pub const CLAIMED_TILES_PER_TICK_MULTIPLIER: f32 = 3.0;
/// Large empire threshold for attack penalties (>100k tiles)
pub const LARGE_EMPIRE_THRESHOLD: u32 = 100_000;
/// Random factor range for tile priority calculation (0-7)
pub const TILE_PRIORITY_RANDOM_MAX: u32 = 8;
/// Defense post magnitude multiplier (when implemented)
pub const DEFENSE_POST_MAG_MULTIPLIER: f32 = 5.0;
/// Defense post speed multiplier (when implemented)
pub const DEFENSE_POST_SPEED_MULTIPLIER: f32 = 3.0;
/// Base defense debuff for large defenders (70%)
pub const LARGE_DEFENDER_BASE_DEBUFF: f32 = 0.7;
/// Scaling factor for large defender sigmoid (30%)
pub const LARGE_DEFENDER_SCALING: f32 = 0.3;
/// Power exponent for large attacker bonus calculation
pub const LARGE_ATTACKER_POWER_EXPONENT: f32 = 0.7;
/// Speed exponent for large attacker penalty calculation
pub const LARGE_ATTACKER_SPEED_EXPONENT: f32 = 0.6;
/// Minimum troop ratio for combat calculations
pub const TROOP_RATIO_MIN: f32 = 0.6;
/// Maximum troop ratio for combat calculations
pub const TROOP_RATIO_MAX: f32 = 2.0;
/// Multiplier for attacker loss calculations
pub const ATTACKER_LOSS_MULTIPLIER: f32 = 0.8;
/// Divisor for tiles per tick calculation
pub const TILES_PER_TICK_DIVISOR: f32 = 5.0;
/// Minimum tiles per tick cost
pub const TILES_PER_TICK_MIN: f32 = 0.2;
/// Maximum tiles per tick cost
pub const TILES_PER_TICK_MAX: f32 = 1.5;
/// Divisor for unclaimed territory attack losses
pub const UNCLAIMED_ATTACK_LOSS_DIVISOR: f32 = 5.0;
/// Base multiplier for unclaimed territory conquest speed
pub const UNCLAIMED_BASE_MULTIPLIER: f32 = 2_000.0;
/// Minimum speed value for plains terrain
pub const MIN_SPEED_PLAINS: f32 = 10.0;
/// Minimum tiles per tick for unclaimed territory
pub const UNCLAIMED_TILES_MIN: f32 = 5.0;
/// Maximum tiles per tick for unclaimed territory
pub const UNCLAIMED_TILES_MAX: f32 = 100.0;
/// Multiplier for attack ratio calculation
pub const ATTACK_RATIO_MULTIPLIER: f32 = 5.0;
/// Scale factor for attack ratio
pub const ATTACK_RATIO_SCALE: f32 = 2.0;
/// Minimum attack ratio for dynamic calculation
pub const ATTACK_RATIO_MIN: f32 = 0.01;
/// Maximum attack ratio for dynamic calculation
pub const ATTACK_RATIO_MAX: f32 = 0.5;
/// Base priority value for tile conquest
pub const TILE_PRIORITY_BASE: f32 = 1.0;
/// Priority penalty per owned neighbor tile
pub const TILE_PRIORITY_NEIGHBOR_PENALTY: f32 = 0.5;
}
pub mod nation {
/// Multiplier for max troops calculation
pub const MAX_TROOPS_MULTIPLIER: f32 = 2.0;
/// Power exponent for max troops based on territory size
pub const MAX_TROOPS_POWER: f32 = 0.6;
/// Scale factor for max troops calculation
pub const MAX_TROOPS_SCALE: f32 = 1000.0;
/// Base max troops value
pub const MAX_TROOPS_BASE: f32 = 50_000.0;
/// Bots get 33% of human max troops
pub const BOT_MAX_TROOPS_MULTIPLIER: f32 = 0.33;
/// Base income per tick
pub const BASE_INCOME: f32 = 10.0;
/// Power exponent for income calculation
pub const INCOME_POWER: f32 = 0.73;
/// Divisor for income calculation
pub const INCOME_DIVISOR: f32 = 4.0;
/// Bots get 60% of human income
pub const BOT_INCOME_MULTIPLIER: f32 = 0.6;
/// Initial troops for all nations at spawn
pub const INITIAL_TROOPS: f32 = 2500.0;
}
pub mod bot {
/// Maximum initial cooldown for bot actions (0-9 ticks)
pub const INITIAL_COOLDOWN_MAX: u64 = 10;
/// Minimum cooldown between bot actions (ticks)
pub const ACTION_COOLDOWN_MIN: u64 = 3;
/// Maximum cooldown between bot actions (ticks)
pub const ACTION_COOLDOWN_MAX: u64 = 15;
/// Probability that bot chooses expansion over attack (60%)
pub const EXPAND_PROBABILITY: f32 = 0.6;
/// Minimum troop percentage for wilderness expansion (10%)
pub const EXPAND_TROOPS_MIN: f32 = 0.1;
/// Maximum troop percentage for wilderness expansion (30%)
pub const EXPAND_TROOPS_MAX: f32 = 0.3;
/// Minimum troop percentage for nation attacks (20%)
pub const ATTACK_TROOPS_MIN: f32 = 0.2;
/// Maximum troop percentage for nation attacks (50%)
pub const ATTACK_TROOPS_MAX: f32 = 0.5;
/// Minimum distance between spawn points (in tiles)
pub const MIN_SPAWN_DISTANCE: f32 = 70.0;
/// Absolute minimum spawn distance for fallback
pub const ABSOLUTE_MIN_DISTANCE: f32 = 5.0;
/// Distance reduction factor per adaptive wave (15% reduction)
pub const DISTANCE_REDUCTION_FACTOR: f32 = 0.85;
/// Number of random spawn placement attempts
pub const SPAWN_RANDOM_ATTEMPTS: usize = 1000;
/// Maximum attempts for grid-guided spawn placement
pub const SPAWN_GRID_MAX_ATTEMPTS: usize = 200;
/// Maximum attempts for fallback spawn placement
pub const SPAWN_FALLBACK_ATTEMPTS: usize = 10_000;
/// Stride factor for grid-guided spawn placement (80% of current distance)
pub const SPAWN_GRID_STRIDE_FACTOR: f32 = 0.8;
/// Maximum border tiles sampled for bot decision making
pub const MAX_BORDER_SAMPLES: usize = 20;
}
pub mod colors {
/// Golden angle for visually distinct color distribution (degrees)
pub const GOLDEN_ANGLE: f32 = 137.5;
/// Minimum saturation for nation colors
pub const SATURATION_MIN: f32 = 0.75;
/// Maximum saturation for nation colors
pub const SATURATION_MAX: f32 = 0.95;
/// Minimum lightness for nation colors
pub const LIGHTNESS_MIN: f32 = 0.35;
/// Maximum lightness for nation colors
pub const LIGHTNESS_MAX: f32 = 0.65;
}
pub mod input {
/// Default attack ratio when game starts (50%)
pub const DEFAULT_ATTACK_RATIO: f32 = 0.5;
/// Step size for attack ratio adjustment (10%)
pub const ATTACK_RATIO_STEP: f32 = 0.1;
/// Minimum attack ratio (10%)
pub const ATTACK_RATIO_MIN: f32 = 0.1;
/// Maximum attack ratio (100%)
pub const ATTACK_RATIO_MAX: f32 = 1.0;
}
pub mod ships {
/// Maximum ships per nation
pub const MAX_SHIPS_PER_NATION: usize = 5;
/// Ticks required to move one tile (1 = fast speed)
pub const TICKS_PER_TILE: u32 = 1;
/// Maximum path length for ship pathfinding
pub const MAX_PATH_LENGTH: usize = 1_000_000;
/// Percentage of troops carried by ship (20%)
pub const TROOP_PERCENT: f32 = 0.20;
}
pub mod outcome {
/// Win threshold - percentage of map needed to win (80%)
pub const WIN_THRESHOLD: f32 = 0.80;
}
pub mod spawning {
/// Radius of tiles claimed around spawn point (creates 5x5 square)
pub const SPAWN_RADIUS: i16 = 2;
/// Spawn timeout duration in seconds
pub const SPAWN_TIMEOUT_SECS: f32 = 2.0;
}

View File

@@ -0,0 +1,18 @@
//! Core game logic and data structures
//!
//! This module contains the fundamental game types and logic.
pub mod action;
pub mod constants;
pub mod outcome;
pub mod rng;
pub mod turn_execution;
pub mod utils;
// Re-export commonly used types
pub use action::*;
pub use constants::*;
pub use outcome::*;
pub use rng::*;
pub use turn_execution::*;
pub use utils::*;

View File

@@ -0,0 +1,78 @@
use crate::game::core::constants::outcome::WIN_THRESHOLD;
use crate::game::entities::{Dead, NationName, TerritorySize};
use crate::game::input::context::LocalPlayerContext;
use crate::game::{NationEntityMap, NationId, TerritoryManager};
use crate::ui::protocol::{BackendMessage, GameOutcome};
use bevy_ecs::prelude::*;
use tracing::info;
/// System that checks if the local player has won or lost
pub fn check_local_player_outcome(mut local_context: If<ResMut<LocalPlayerContext>>, territory_manager: Res<TerritoryManager>, active_nations: Query<(&NationId, &TerritorySize, &NationName), Without<Dead>>, nation_entity_map: Res<NationEntityMap>, mut backend_messages: MessageWriter<BackendMessage>) {
// Don't check if outcome already determined
if local_context.my_outcome.is_some() {
return;
}
let my_player_id = local_context.id;
// Get local player entity and stats
let Some(&my_entity) = nation_entity_map.0.get(&my_player_id) else {
return;
};
let Ok((_, my_territory_size, _)) = active_nations.get(my_entity) else {
// Query failed but entity exists in entity_map, so player must have Dead marker
info!("Local player defeated - eliminated (Dead marker)");
local_context.mark_defeated();
backend_messages.write(BackendMessage::GameEnded { outcome: GameOutcome::Defeat });
return;
};
let my_tiles = my_territory_size.0;
// Don't check outcome until player has spawned (has tiles)
if my_tiles == 0 {
return;
}
// Calculate total claimable tiles for victory condition checks
let total_claimable_tiles = crate::game::queries::count_land_tiles(&territory_manager);
if total_claimable_tiles > 0 {
let my_occupation = my_tiles as f32 / total_claimable_tiles as f32;
// Check if I've won by occupation
if my_occupation >= WIN_THRESHOLD {
info!("Local player victorious - reached {:.1}% occupation ({}/{} claimable tiles, threshold: {:.0}%)", my_occupation * 100.0, my_tiles, total_claimable_tiles, WIN_THRESHOLD * 100.0);
local_context.mark_victorious();
backend_messages.write(BackendMessage::GameEnded { outcome: GameOutcome::Victory });
return;
}
// Check if any opponent has won by occupation (which means I lost)
for (id, territory, name) in active_nations.iter() {
if *id == my_player_id {
continue;
}
let occupation = territory.0 as f32 / total_claimable_tiles as f32;
if occupation >= WIN_THRESHOLD {
tracing::event!(target:module_path!(),tracing::Level::INFO,"Local player defeated - {} reached {:.1}% occupation ({}/{} claimable tiles, threshold: {:.0}%)",name.0,occupation*100.0,territory.0,total_claimable_tiles,WIN_THRESHOLD*100.0);
local_context.mark_defeated();
backend_messages.write(BackendMessage::GameEnded { outcome: GameOutcome::Defeat });
return;
}
}
}
// Check victory by eliminating all opponents
// If no living opponents remain (query filters Without<Dead>), then I've won
let any_opponents_alive = active_nations.iter().any(|(opponent_id, _, _)| *opponent_id != my_player_id);
if !any_opponents_alive {
info!("Local player victorious - all opponents eliminated");
local_context.mark_victorious();
backend_messages.write(BackendMessage::GameEnded { outcome: GameOutcome::Victory });
}
}

View File

@@ -0,0 +1,85 @@
use bevy_ecs::prelude::*;
use rand::SeedableRng;
use rand::rngs::StdRng;
use crate::game::NationId;
/// Centralized deterministic RNG resource
///
/// This resource provides deterministic random number generation for all game systems.
/// It is updated at the start of each turn with the current turn number, ensuring that
/// the same sequence of turns always produces the same random values.
///
/// # Determinism Guarantees
///
/// - Same turn number + base seed + context → same RNG state
/// - No stored RNG state in individual systems (prevents desync)
/// - All randomness flows through this single source of truth
///
/// # Usage
///
/// Systems should never store RNG state. Instead, request context-specific RNG:
///
/// ```rust,ignore
/// fn my_system(rng: Res<DeterministicRng>) {
/// let mut player_rng = rng.for_player(player_id);
/// let random_value = player_rng.gen_range(0..10);
/// }
/// ```
#[derive(Resource)]
pub struct DeterministicRng {
/// Base seed for the entire game (set at game start)
base_seed: u64,
/// Current turn number (updated each turn)
turn_number: u64,
}
impl DeterministicRng {
/// Create a new DeterministicRng with a base seed
pub fn new(base_seed: u64) -> Self {
Self { base_seed, turn_number: 0 }
}
/// Update the turn number (should be called at start of each turn)
pub fn update_turn(&mut self, turn_number: u64) {
self.turn_number = turn_number;
}
/// Get the current turn number
#[inline]
pub fn turn_number(&self) -> u64 {
self.turn_number
}
/// Create an RNG for a specific context within the current turn
///
/// The context_id allows different systems/entities to have independent
/// random sequences while maintaining determinism.
#[inline]
pub fn for_context(&self, context_id: u64) -> StdRng {
let seed = self
.turn_number
.wrapping_mul(997) // Prime multiplier for turn
.wrapping_add(self.base_seed)
.wrapping_add(context_id.wrapping_mul(1009)); // Prime multiplier for context
StdRng::seed_from_u64(seed)
}
/// Get an RNG for a specific nation's actions this turn
///
/// This is a convenience wrapper around `for_context` for nation-specific randomness.
#[inline]
pub fn for_nation(&self, id: NationId) -> StdRng {
self.for_context(id.get() as u64)
}
/// Get an RNG for a specific tile's calculations this turn
///
/// Useful for tile-based randomness that should be consistent within a turn.
pub fn for_tile(&self, tile: glam::U16Vec2) -> StdRng {
// Use large offset to avoid collision with nation IDs
// Convert tile position to unique ID
let tile_id = (tile.y as u64) * u16::MAX as u64 + (tile.x as u64);
self.for_context(1_000_000 + tile_id)
}
}

View File

@@ -0,0 +1,190 @@
use std::collections::{HashMap, HashSet};
use bevy_ecs::prelude::*;
use glam::U16Vec2;
use crate::game::ai::bot::Bot;
use crate::game::combat::ActiveAttacks;
use crate::game::core::action::{GameAction, TroopCount};
use crate::game::core::rng::DeterministicRng;
use crate::game::entities::{Dead, NationEntityMap, TerritorySize, Troops, remove_troops};
use crate::game::ships::LaunchShipMessage;
use crate::game::terrain::data::TerrainData;
use crate::game::world::{NationId, TerritoryManager};
use crate::networking::{Intent, Turn};
/// Execute bot AI to generate actions
/// This must be called before execute_turn to avoid query conflicts
/// Returns (nation_id, action) pairs
pub fn process_bot_actions(turn_number: u64, territory_manager: &TerritoryManager, terrain: &TerrainData, nation_borders: &HashMap<NationId, &HashSet<U16Vec2>>, rng_seed: u64, bots: &mut Query<(&NationId, &Troops, &mut Bot), Without<Dead>>) -> Vec<(NationId, GameAction)> {
let mut bot_actions = Vec::new();
for (nation_id, troops, mut bot) in &mut *bots {
if let Some(action) = bot.tick(turn_number, *nation_id, troops, territory_manager, terrain, nation_borders, rng_seed) {
bot_actions.push((*nation_id, action));
}
}
bot_actions
}
/// Execute a full game turn
#[allow(clippy::too_many_arguments)]
pub fn execute_turn(turn: &Turn, turn_number: u64, bot_actions: Vec<(NationId, GameAction)>, territory_manager: &mut TerritoryManager, terrain: &TerrainData, active_attacks: &mut ActiveAttacks, rng: &mut DeterministicRng, nation_borders: &HashMap<NationId, &HashSet<U16Vec2>>, entity_map: &NationEntityMap, nations: &mut Query<(&mut Troops, &mut TerritorySize)>, is_bot_query: &Query<Has<Bot>>, launch_ship_writer: &mut MessageWriter<LaunchShipMessage>) {
let _guard = tracing::trace_span!("execute_turn", turn_number, intent_count = turn.intents.len(), bot_action_count = bot_actions.len()).entered();
// Update RNG for this turn
rng.update_turn(turn_number);
// PHASE 1: Process bot actions (deterministic, based on turn N-1 state)
{
let _guard = tracing::trace_span!("apply_bot_actions", count = bot_actions.len()).entered();
for (nation_id, action) in bot_actions {
apply_action(nation_id, action, turn_number, territory_manager, terrain, active_attacks, rng, nation_borders, entity_map, nations, launch_ship_writer);
}
}
// PHASE 2: Process player intents (from network)
for sourced_intent in &turn.intents {
match &sourced_intent.intent {
Intent::Action(action) => {
apply_action(sourced_intent.source, action.clone(), turn_number, territory_manager, terrain, active_attacks, rng, nation_borders, entity_map, nations, launch_ship_writer);
}
Intent::SetSpawn { .. } => {}
}
}
// PHASE 3: Tick game systems (attacks, etc.)
active_attacks.tick(entity_map, nations, territory_manager, terrain, nation_borders, rng, is_bot_query);
}
/// Apply a game action (attack or ship launch)
#[allow(clippy::too_many_arguments)]
pub fn apply_action(nation_id: NationId, action: GameAction, turn_number: u64, territory_manager: &TerritoryManager, terrain: &TerrainData, active_attacks: &mut ActiveAttacks, rng: &DeterministicRng, nation_borders: &HashMap<NationId, &HashSet<U16Vec2>>, entity_map: &NationEntityMap, nations: &mut Query<(&mut Troops, &mut TerritorySize)>, launch_ship_writer: &mut MessageWriter<LaunchShipMessage>) {
match action {
GameAction::Attack { target, troops } => {
handle_attack(nation_id, target, troops, turn_number, territory_manager, terrain, active_attacks, rng, nation_borders, entity_map, nations);
}
GameAction::LaunchShip { target_tile, troops } => {
launch_ship_writer.write(LaunchShipMessage { nation_id, target_tile, troops });
}
}
}
/// Handle nation spawn at a given tile
#[allow(clippy::too_many_arguments)]
pub fn handle_spawn(nation_id: NationId, tile: U16Vec2, territory_manager: &mut TerritoryManager, terrain: &TerrainData, active_attacks: &mut ActiveAttacks, rng: &DeterministicRng, entity_map: &NationEntityMap, nations: &mut Query<(&mut Troops, &mut TerritorySize)>) {
if territory_manager.has_owner(tile) || !terrain.is_conquerable(tile) {
tracing::debug!(
nation_id = %nation_id,
?tile,
"Spawn on occupied/water tile ignored"
);
return;
}
// Claim 5x5 territory around spawn point
let size = territory_manager.size();
// We need to work with territory data directly to use the helper function
// Convert TerritoryManager slice to Vec for modification
let mut territories: Vec<_> = territory_manager.as_slice().to_vec();
let changed = crate::game::systems::spawn_territory::claim_spawn_territory(tile, nation_id, &mut territories, terrain, size);
// Apply changes back to TerritoryManager
if !changed.is_empty() {
for &tile_pos in &changed {
territory_manager.conquer(tile_pos, nation_id);
}
// Update nation stats
if let Some(&entity) = entity_map.0.get(&nation_id)
&& let Ok((_, mut territory_size)) = nations.get_mut(entity)
{
territory_size.0 += changed.len() as u32;
}
// Notify active attacks that territory changed
for &t in &changed {
active_attacks.handle_territory_add(t, nation_id, territory_manager, terrain, rng);
}
}
}
/// Handle an attack action
#[allow(clippy::too_many_arguments)]
pub fn handle_attack(nation_id: NationId, target: Option<NationId>, troops: u32, turn_number: u64, territory_manager: &TerritoryManager, terrain: &TerrainData, active_attacks: &mut ActiveAttacks, rng: &DeterministicRng, nation_borders: &HashMap<NationId, &HashSet<U16Vec2>>, entity_map: &NationEntityMap, nations: &mut Query<(&mut Troops, &mut TerritorySize)>) {
handle_attack_internal(nation_id, target, TroopCount::Absolute(troops), true, None, turn_number, territory_manager, terrain, active_attacks, rng, nation_borders, entity_map, nations);
}
/// Handle attack with specific border tiles and troop allocation
#[allow(clippy::too_many_arguments)]
pub fn handle_attack_internal(nation_id: NationId, target: Option<NationId>, troop_count: TroopCount, deduct_from_nation: bool, border_tiles: Option<&HashSet<U16Vec2>>, turn_number: u64, territory_manager: &TerritoryManager, terrain: &TerrainData, active_attacks: &mut ActiveAttacks, rng: &DeterministicRng, nation_borders: &HashMap<NationId, &HashSet<U16Vec2>>, entity_map: &NationEntityMap, nations: &mut Query<(&mut Troops, &mut TerritorySize)>) {
// Validate not attacking self
if target == Some(nation_id) {
tracing::debug!(
nation_id = ?nation_id,
"Attack on own nation ignored"
);
return;
}
let troops = match troop_count {
TroopCount::Ratio(ratio) => {
let Some(&entity) = entity_map.0.get(&nation_id) else {
return;
};
if let Ok((troops, _)) = nations.get(entity) {
troops.0 * ratio
} else {
return;
}
}
TroopCount::Absolute(count) => count as f32,
};
// Clamp troops to available and deduct from nation's pool when creating the attack (if requested)
if deduct_from_nation {
let Some(&entity) = entity_map.0.get(&nation_id) else {
return;
};
if let Ok((mut troops_comp, _)) = nations.get_mut(entity) {
let available = troops_comp.0;
let clamped_troops = troops.min(available);
if troops > available {
tracing::warn!(
nation_id = ?nation_id,
requested = troops,
available = available,
"Attack requested more troops than available, clamping to available"
);
}
troops_comp.0 = remove_troops(troops_comp.0, clamped_troops);
} else {
return;
}
}
let border_tiles_to_use = border_tiles.or_else(|| nation_borders.get(&nation_id).copied());
match target {
None => {
// Attack unclaimed territory
if entity_map.0.contains_key(&nation_id) {
active_attacks.schedule_unclaimed(nation_id, troops, border_tiles_to_use, territory_manager, terrain, nation_borders, turn_number, rng);
}
}
Some(target_id) => {
// Attack specific nation
let attacker_exists = entity_map.0.contains_key(&nation_id);
let target_exists = entity_map.0.contains_key(&target_id);
if attacker_exists && target_exists {
active_attacks.schedule_attack(nation_id, target_id, troops, border_tiles_to_use, territory_manager, terrain, nation_borders, turn_number, rng);
}
}
}
}

View File

@@ -0,0 +1,45 @@
use glam::{I16Vec2, U16Vec2};
/// Serde helper for U16Vec2 serialization
pub mod u16vec2_serde {
use serde::{Deserialize, Deserializer, Serialize, Serializer};
pub fn serialize<S>(vec: &glam::U16Vec2, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
(vec.x, vec.y).serialize(serializer)
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<glam::U16Vec2, D::Error>
where
D: Deserializer<'de>,
{
let (x, y) = <(u16, u16)>::deserialize(deserializer)?;
Ok(glam::U16Vec2::new(x, y))
}
}
/// Returns an iterator over all valid cardinal neighbors of a tile position.
///
/// Yields positions for left, right, up, and down neighbors that are within bounds.
/// Handles boundary checks for the 4-connected grid.
///
/// # Examples
/// ```
/// use glam::U16Vec2;
/// use borders_core::game::utils::neighbors;
///
/// let size = U16Vec2::new(10, 10);
/// let tile = U16Vec2::new(5, 5);
/// let neighbor_count = neighbors(tile, size).count();
/// assert_eq!(neighbor_count, 4);
/// ```
pub fn neighbors(tile: U16Vec2, size: U16Vec2) -> impl Iterator<Item = U16Vec2> {
const CARDINAL_DIRECTIONS: [I16Vec2; 4] = [I16Vec2::new(-1, 0), I16Vec2::new(1, 0), I16Vec2::new(0, -1), I16Vec2::new(0, 1)];
CARDINAL_DIRECTIONS.into_iter().filter_map(move |offset| {
let neighbor = tile.checked_add_signed(offset)?;
if neighbor.x < size.x && neighbor.y < size.y { Some(neighbor) } else { None }
})
}

View File

@@ -0,0 +1,146 @@
use bevy_ecs::prelude::*;
use std::collections::{HashMap, HashSet};
use std::ops::{Deref, DerefMut};
use crate::game::core::constants::nation::*;
use crate::game::world::NationId;
/// Marker component to identify eliminated nations
/// Alive nations are identified by the ABSENCE of this component
/// Use Without<Dead> in queries to filter for alive nations
#[derive(Component, Debug, Clone, Copy, Default)]
pub struct Dead;
/// Nation name component
#[derive(Component, Debug, Clone)]
pub struct NationName(pub String);
/// Nation color component
#[derive(Component, Debug, Clone, Copy)]
pub struct NationColor(pub HSLColor);
/// Border tiles component - tiles at the edge of a nation's territory
#[derive(Component, Debug, Clone, Default)]
pub struct BorderTiles(pub HashSet<glam::U16Vec2>);
impl Deref for BorderTiles {
type Target = HashSet<glam::U16Vec2>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl DerefMut for BorderTiles {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
/// Troops component - current troop count
#[derive(Component, Debug, Clone, Copy)]
pub struct Troops(pub f32);
/// Territory size component - number of tiles owned
#[derive(Component, Debug, Clone, Copy)]
pub struct TerritorySize(pub u32);
/// Maps nation IDs to their ECS entities for O(1) lookup
///
/// This resource enables systems to quickly find a nation's entity
/// by their nation_id without iterating through all entities.
#[derive(Resource, Default)]
pub struct NationEntityMap(pub HashMap<NationId, Entity>);
impl NationEntityMap {
/// Get the entity for a nation, panicking if not found
///
/// # Panics
/// Panics if the nation ID is not in the map
#[inline]
pub fn get_entity(&self, nation_id: NationId) -> Entity {
*self.0.get(&nation_id).unwrap_or_else(|| panic!("Nation entity not found for nation {}", nation_id.get()))
}
/// Try to get the entity for a nation
///
/// Returns None if the nation ID is not in the map
#[inline]
pub fn try_get_entity(&self, nation_id: NationId) -> Option<Entity> {
self.0.get(&nation_id).copied()
}
}
/// HSL Color representation
#[derive(Debug, Clone, Copy)]
pub struct HSLColor {
pub h: f32, // Hue: 0-360
pub s: f32, // Saturation: 0-1
pub l: f32, // Lightness: 0-1
}
impl HSLColor {
pub fn new(h: f32, s: f32, l: f32) -> Self {
Self { h, s, l }
}
pub fn to_rgba(&self) -> [f32; 4] {
let c = (1.0 - (2.0 * self.l - 1.0).abs()) * self.s;
let h_prime = self.h / 60.0;
let x = c * (1.0 - ((h_prime % 2.0) - 1.0).abs());
let (r1, g1, b1) = if h_prime < 1.0 {
(c, x, 0.0)
} else if h_prime < 2.0 {
(x, c, 0.0)
} else if h_prime < 3.0 {
(0.0, c, x)
} else if h_prime < 4.0 {
(0.0, x, c)
} else if h_prime < 5.0 {
(x, 0.0, c)
} else {
(c, 0.0, x)
};
let m = self.l - c / 2.0;
[r1 + m, g1 + m, b1 + m, 1.0]
}
}
/// Calculate maximum troop capacity based on territory size
#[inline]
pub fn calculate_max_troops(territory_size: u32, is_bot: bool) -> f32 {
let base_max = MAX_TROOPS_MULTIPLIER * ((territory_size as f32).powf(MAX_TROOPS_POWER) * MAX_TROOPS_SCALE + MAX_TROOPS_BASE);
if is_bot { base_max * BOT_MAX_TROOPS_MULTIPLIER } else { base_max }
}
/// Calculate income for this tick based on current troops and territory
#[inline]
pub fn calculate_income(troops: f32, territory_size: u32, is_bot: bool) -> f32 {
let max_troops = calculate_max_troops(territory_size, is_bot);
// Base income calculation
let mut income = BASE_INCOME + (troops.powf(INCOME_POWER) / INCOME_DIVISOR);
// Soft cap as approaching max troops
let ratio = 1.0 - (troops / max_troops);
income *= ratio;
// Apply bot modifier
if is_bot { income * BOT_INCOME_MULTIPLIER } else { income }
}
/// Add troops with max cap enforcement
#[inline]
pub fn add_troops_capped(current: f32, amount: f32, territory_size: u32, is_bot: bool) -> f32 {
let max_troops = calculate_max_troops(territory_size, is_bot);
(current + amount).min(max_troops)
}
/// Remove troops, ensuring non-negative result
#[inline]
pub fn remove_troops(current: f32, amount: f32) -> f32 {
(current - amount).max(0.0)
}

View File

@@ -0,0 +1,66 @@
use bevy_ecs::prelude::*;
use serde::{Deserialize, Serialize};
use crate::game::NationId;
/// Represents the outcome for a specific player (local, not shared)
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PlayerOutcome {
/// Player has won the game
Victory,
/// Player has been eliminated/defeated
Defeat,
}
/// Local player context - CLIENT-SPECIFIC state, NOT part of deterministic game state
///
/// **Important: This is LOCAL context, not shared/deterministic state!**
///
/// This resource contains information specific to THIS client's perspective:
/// - Which player ID this client controls
/// - Whether this player won/lost (irrelevant to other clients)
/// - Whether this client can send commands or is spectating
///
/// In multiplayer:
/// - Each client has their own LocalPlayerContext with different player IDs
/// - One client may have `my_outcome = Victory` while others have `Defeat`
/// - A spectator would have `can_send_intents = false`
/// - The shared game state continues running regardless
#[derive(Resource)]
pub struct LocalPlayerContext {
/// The player ID for this client
pub id: NationId,
/// The outcome for this specific player (if determined)
/// None = still playing, Some(Victory/Defeat) = game ended for this player
pub my_outcome: Option<PlayerOutcome>,
/// Whether this client can send intents (false when defeated or spectating)
pub can_send_intents: bool,
}
impl LocalPlayerContext {
/// Create a new local player context for the given player ID
pub fn new(id: NationId) -> Self {
Self { id, my_outcome: None, can_send_intents: true }
}
/// Mark the local player as defeated
pub fn mark_defeated(&mut self) {
self.my_outcome = Some(PlayerOutcome::Defeat);
self.can_send_intents = false;
}
/// Mark the local player as victorious
pub fn mark_victorious(&mut self) {
self.my_outcome = Some(PlayerOutcome::Victory);
// Player can still send intents after victory (to continue playing if desired)
// Or set to false if you want to prevent further actions
}
/// Check if the local player is still actively playing
#[inline]
pub fn is_playing(&self) -> bool {
self.my_outcome.is_none() && self.can_send_intents
}
}

View File

@@ -0,0 +1,68 @@
//! Input event and message types
use bevy_ecs::message::Message;
use glam::{U16Vec2, Vec2};
use serde::{Deserialize, Serialize};
use super::types::{ButtonState, KeyCode, MouseButton};
/// Raw input events sent by platforms via the input queue
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum InputEvent {
/// Mouse button pressed or released
MouseButton { button: MouseButton, state: ButtonState, tile: Option<U16Vec2>, world_pos: Vec2 },
/// Mouse moved over the map
MouseMotion { tile: Option<U16Vec2>, world_pos: Vec2 },
/// Keyboard key pressed or released
KeyEvent { key: KeyCode, state: ButtonState },
}
/// Mouse button message
#[derive(Message, Debug, Clone)]
pub struct MouseButtonMessage {
pub button: MouseButton,
pub state: ButtonState,
pub tile: Option<U16Vec2>,
pub world_pos: Vec2,
}
/// Mouse motion message
#[derive(Message, Debug, Clone)]
pub struct MouseMotionMessage {
pub tile: Option<U16Vec2>,
pub world_pos: Vec2,
}
/// Keyboard key message
#[derive(Message, Debug, Clone)]
pub struct KeyEventMessage {
pub key: KeyCode,
pub state: ButtonState,
}
/// A tile was clicked
#[derive(Message, Debug, Clone)]
pub struct TileClickedAction {
pub tile: U16Vec2,
pub button: MouseButton,
}
/// Camera-related actions
#[derive(Message, Debug, Clone)]
pub enum CameraAction {
/// Center camera on player territory
Center,
/// Camera drag/interaction started
InteractionStarted,
/// Camera drag/interaction ended
InteractionEnded,
}
/// UI-related actions
#[derive(Message, Debug, Clone)]
pub enum UiAction {
/// Adjust attack ratio by amount
UpdateAttackRatio { amount: f32 },
/// Toggle pause
TogglePause,
}

View File

@@ -0,0 +1,213 @@
//! Action handler systems - process semantic input actions
use bevy_ecs::prelude::*;
use tracing::{debug, info, trace};
use crate::game::core::constants::input::*;
use crate::game::systems::borders::BorderCache;
use crate::game::terrain::TerrainData;
use crate::game::{BorderTiles, CoastalTiles, GameAction, LocalPlayerContext, NationEntityMap, SpawnManager, SpawnTimeout, TerritoryManager, Troops};
use crate::networking::{Intent, IntentEvent};
use super::events::{CameraAction, TileClickedAction, UiAction};
/// Resource tracking whether spawn phase is active
#[derive(Resource, Default)]
pub struct SpawnPhase {
pub active: bool,
}
/// Resource for attack control settings
#[derive(Resource)]
pub struct AttackControls {
pub attack_ratio: f32,
}
impl Default for AttackControls {
fn default() -> Self {
Self { attack_ratio: DEFAULT_ATTACK_RATIO }
}
}
/// Handle tile clicks during spawn and gameplay phases
#[allow(clippy::too_many_arguments)]
pub fn handle_tile_clicked_system(mut tile_clicked: MessageReader<TileClickedAction>, spawn_phase: Res<SpawnPhase>, local_context: If<Res<LocalPlayerContext>>, mut spawn_manager: Option<ResMut<SpawnManager>>, mut spawn_timeout: Option<ResMut<SpawnTimeout>>, mut intent_writer: MessageWriter<IntentEvent>, territory_manager: Res<TerritoryManager>, terrain: Res<TerrainData>, coastal_tiles: Res<CoastalTiles>, attack_controls: Res<AttackControls>, entity_map: Res<NationEntityMap>, border_query: Query<&BorderTiles>, troops_query: Query<&Troops>) {
// Can't interact if not allowed to send intents
if !local_context.can_send_intents {
return;
};
for action in tile_clicked.read() {
if spawn_phase.active {
// Spawn phase logic
handle_spawn_click(action.tile, local_context.as_ref(), &mut spawn_manager, &mut spawn_timeout, &mut intent_writer, &territory_manager, &terrain);
} else {
// Gameplay phase logic
handle_attack_click(action.tile, local_context.as_ref(), &territory_manager, &terrain, &coastal_tiles, &attack_controls, &mut intent_writer, &entity_map, &border_query, &troops_query);
}
}
}
/// Handle spawn click logic
#[allow(clippy::too_many_arguments)]
fn handle_spawn_click(tile_coord: glam::U16Vec2, local_context: &LocalPlayerContext, spawn_manager: &mut Option<ResMut<SpawnManager>>, spawn_timeout: &mut Option<ResMut<SpawnTimeout>>, intent_writer: &mut MessageWriter<IntentEvent>, territory_manager: &TerritoryManager, terrain: &TerrainData) {
let _guard = tracing::trace_span!("spawn_click").entered();
let tile_ownership = territory_manager.get_ownership(tile_coord);
if tile_ownership.is_owned() {
debug!("Spawn click on tile {:?} ignored - occupied", tile_coord);
return;
}
// Check if tile is water/unconquerable
if !terrain.is_conquerable(tile_coord) {
debug!("Spawn click on tile {:?} ignored - water or unconquerable", tile_coord);
return;
}
// Player has chosen a spawn location - send to server
info!("Player {} setting spawn at tile {:?}", local_context.id.get(), tile_coord);
// Check if this is the first spawn (timer not started yet)
let is_first_spawn = if let Some(spawn_mgr) = spawn_manager { spawn_mgr.get_player_spawns().is_empty() } else { true };
// Send SetSpawn intent to server (not Action - this won't be in game history)
// Server will validate, track, and eventually send Turn(0) when timeout expires
intent_writer.write(IntentEvent(Intent::SetSpawn { tile_index: tile_coord }));
// Start spawn timeout on first spawn (spawn_phase plugin will emit countdown updates)
if is_first_spawn && let Some(timeout) = spawn_timeout {
timeout.start();
info!("Spawn timeout started ({:.1}s)", timeout.duration_secs);
}
// Update local spawn manager for preview/bot recalculation
// Note: This only updates the spawn manager, not the game instance
// The actual game state is updated when Turn(0) is processed
if let Some(spawn_mgr) = spawn_manager {
// Update spawn manager (triggers bot spawn recalculation)
spawn_mgr.update_player_spawn(local_context.id, tile_coord, territory_manager, terrain);
info!("Spawn manager updated with player {} spawn at tile {:?}", local_context.id.get(), tile_coord);
info!("Total spawns in manager: {}", spawn_mgr.get_all_spawns().len());
}
}
/// Handle attack click logic
#[allow(clippy::too_many_arguments)]
fn handle_attack_click(tile_coord: glam::U16Vec2, local_context: &LocalPlayerContext, territory_manager: &TerritoryManager, terrain: &TerrainData, coastal_tiles: &CoastalTiles, attack_controls: &AttackControls, intent_writer: &mut MessageWriter<IntentEvent>, entity_map: &NationEntityMap, border_query: &Query<&BorderTiles>, troops_query: &Query<&Troops>) {
let _guard = tracing::trace_span!("attack_click").entered();
let tile_ownership = territory_manager.get_ownership(tile_coord);
let nation_id = local_context.id;
// Can't attack own tiles
if tile_ownership.is_owned_by(nation_id) {
return;
}
// Check if target is water - ignore water clicks
if terrain.is_navigable(tile_coord) {
return;
}
// Check if target is connected to player's territory
let size = territory_manager.size();
let is_connected = crate::game::connectivity::is_connected_to_player(territory_manager.as_slice(), terrain, tile_coord, nation_id, size);
if is_connected {
// Target is connected to player's territory - use normal attack
// Calculate absolute troop count from ratio
let troops = if let Some(&entity) = entity_map.0.get(&nation_id)
&& let Ok(troops_comp) = troops_query.get(entity)
{
(troops_comp.0 * attack_controls.attack_ratio).floor() as u32
} else {
0
};
intent_writer.write(IntentEvent(Intent::Action(GameAction::Attack { target: tile_ownership.nation_id(), troops })));
return;
}
// Target is NOT connected - need to use ship
debug!("Target {:?} not connected to player territory, attempting ship launch", tile_coord);
// Find target's nearest coastal tile
let target_coastal_tile = crate::game::connectivity::find_coastal_tile_in_region(territory_manager.as_slice(), terrain, tile_coord, size);
let Some(target_coastal_tile) = target_coastal_tile else {
debug!("No coastal tile found in target's region for tile {:?}", tile_coord);
return;
};
// Find player's nearest coastal tile using O(1) entity lookup
let player_border_tiles = entity_map.0.get(&nation_id).and_then(|&entity| border_query.get(entity).ok());
let launch_tile = player_border_tiles.and_then(|tiles| crate::game::ships::pathfinding::find_nearest_player_coastal_tile(coastal_tiles.tiles(), tiles, target_coastal_tile));
let Some(launch_tile) = launch_tile else {
debug!("Player has no coastal tiles to launch ship from");
return;
};
debug!("Found launch tile {:?} and target coastal tile {:?} for target {:?}", launch_tile, target_coastal_tile, tile_coord);
// Try to find a water path from launch tile to target coastal tile
let path = crate::game::ships::pathfinding::find_water_path(terrain, launch_tile, target_coastal_tile, crate::game::ships::MAX_PATH_LENGTH);
if let Some(_path) = path {
// We can reach the target by ship!
// Calculate absolute troop count from ratio
let troops = if let Some(&entity) = entity_map.0.get(&nation_id)
&& let Ok(troops_comp) = troops_query.get(entity)
{
(troops_comp.0 * attack_controls.attack_ratio).floor() as u32
} else {
0
};
debug!("Launching ship to target {:?} with {} troops", tile_coord, troops);
intent_writer.write(IntentEvent(Intent::Action(GameAction::LaunchShip { target_tile: tile_coord, troops })));
} else {
debug!("No water path found from {:?} to {:?}", launch_tile, target_coastal_tile);
}
}
/// Handle camera actions
pub fn handle_camera_action_system(mut camera_actions: MessageReader<CameraAction>, border_cache: Res<BorderCache>, local_context: If<Res<LocalPlayerContext>>) {
for action in camera_actions.read() {
match action {
CameraAction::Center => {
// Find any owned tile to center on
if let Some(_tile) = border_cache.get(local_context.id).and_then(|tiles| tiles.iter().next().copied()) {
// TODO: Implement camera centering when camera commands are added
tracing::debug!("Camera center requested (not implemented)");
}
}
CameraAction::InteractionStarted => {
trace!("Camera interaction started");
}
CameraAction::InteractionEnded => {
trace!("Camera interaction ended");
}
}
}
}
/// Handle UI actions
pub fn handle_ui_action_system(mut ui_actions: MessageReader<UiAction>, mut attack_controls: ResMut<AttackControls>) {
for action in ui_actions.read() {
match action {
UiAction::UpdateAttackRatio { amount } => {
attack_controls.attack_ratio = (attack_controls.attack_ratio + amount).clamp(ATTACK_RATIO_MIN, ATTACK_RATIO_MAX);
debug!("Attack ratio changed to {:.1}", attack_controls.attack_ratio);
}
UiAction::TogglePause => {
// TODO: Implement pause functionality
debug!("Pause toggle requested (not implemented)");
}
}
}
}

View File

@@ -0,0 +1,17 @@
//! Player input handling
//!
//! This module handles player input events and local player context.
pub mod context;
pub mod events;
pub mod handlers;
pub mod processor;
pub mod queue;
pub mod types;
pub use context::*;
pub use events::*;
pub use handlers::*;
pub use processor::*;
pub use queue::*;
pub use types::*;

View File

@@ -0,0 +1,70 @@
//! Input processor system - converts raw input into action messages
use bevy_ecs::prelude::*;
use crate::game::core::constants::input::*;
use super::events::*;
use super::types::{ButtonState, KeyCode, MouseButton};
/// Processes raw input messages and emits action messages
///
/// This system runs in PreUpdate and:
/// 1. Reads raw MouseButtonMessage, KeyEventMessage from the queue
/// 2. Filters out camera drags (clicks during middle mouse drag)
/// 3. Emits semantic action messages (TileClickedAction, CameraAction, UiAction)
///
/// Action handler systems in Update consume these action messages.
pub fn input_processor_system(mut mouse_events: MessageReader<MouseButtonMessage>, mut key_events: MessageReader<KeyEventMessage>, mut tile_clicked: MessageWriter<TileClickedAction>, mut camera_action: MessageWriter<CameraAction>, mut ui_action: MessageWriter<UiAction>, mut camera_dragging: Local<bool>) {
// Process mouse button events
for event in mouse_events.read() {
match event.button {
MouseButton::Left => match event.state {
ButtonState::Pressed => {
// Reset camera drag flag on new press
*camera_dragging = false;
}
ButtonState::Released => {
// Only emit TileClicked if no camera drag occurred
if !*camera_dragging && let Some(tile) = event.tile {
tile_clicked.write(TileClickedAction { tile, button: event.button });
}
*camera_dragging = false;
}
},
MouseButton::Middle => {
if event.state == ButtonState::Pressed {
*camera_dragging = true;
camera_action.write(CameraAction::InteractionStarted);
} else {
camera_action.write(CameraAction::InteractionEnded);
}
}
_ => {}
}
}
// Process keyboard events
for event in key_events.read() {
// Only process key presses (not releases)
if event.state != ButtonState::Pressed {
continue;
}
match event.key {
KeyCode::KeyC => {
camera_action.write(CameraAction::Center);
}
KeyCode::Digit1 => {
ui_action.write(UiAction::UpdateAttackRatio { amount: -ATTACK_RATIO_STEP });
}
KeyCode::Digit2 => {
ui_action.write(UiAction::UpdateAttackRatio { amount: ATTACK_RATIO_STEP });
}
KeyCode::Space => {
ui_action.write(UiAction::TogglePause);
}
_ => {}
}
}
}

View File

@@ -0,0 +1,40 @@
//! Async input queue for platform-to-game communication
use flume::{Receiver, Sender};
use super::events::InputEvent;
/// Input queue using flume channel for lock-free, async-friendly input handling
///
/// Platforms send InputEvent via the sender, and Game drains them each frame
/// before running systems. This decouples platform input from ECS execution.
pub struct InputQueue {
sender: Sender<InputEvent>,
receiver: Receiver<InputEvent>,
}
impl InputQueue {
/// Create a new unbounded input queue
pub fn new() -> Self {
let (sender, receiver) = flume::unbounded();
Self { sender, receiver }
}
/// Get a sender for platforms to send input events
pub fn sender(&self) -> Sender<InputEvent> {
self.sender.clone()
}
/// Drain all pending input events from the queue
///
/// Called internally by Game::update() to convert queued events into Bevy messages
pub(crate) fn drain(&self) -> impl Iterator<Item = InputEvent> + '_ {
self.receiver.try_iter()
}
}
impl Default for InputQueue {
fn default() -> Self {
Self::new()
}
}

View File

@@ -0,0 +1,34 @@
//! Input type definitions
use serde::{Deserialize, Serialize};
/// Mouse button types
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum MouseButton {
Left,
Middle,
Right,
Back,
Forward,
}
/// Button state (pressed or released)
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ButtonState {
Pressed,
Released,
}
/// Keyboard key codes
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum KeyCode {
KeyW,
KeyA,
KeyS,
KeyD,
KeyC,
Digit1,
Digit2,
Space,
Escape,
}

View File

@@ -0,0 +1,206 @@
//! Game logic and state management
//!
//! This module contains all game-related functionality organized by domain.
// Core modules
pub mod ai;
pub mod builder;
pub mod combat;
pub mod core;
pub mod entities;
pub mod input;
pub mod queries;
pub mod ships;
pub mod systems;
pub mod terrain;
pub mod world;
// Re-exports from submodules
pub use builder::*;
pub use combat::*;
pub use core::*;
pub use entities::*;
pub use input::*;
pub use queries::*;
pub use ships::*;
pub use systems::*;
pub use terrain::*;
pub use world::*;
use bevy_ecs::message::{Message, Messages};
use bevy_ecs::prelude::*;
use bevy_ecs::schedule::{IntoScheduleConfigs, ScheduleLabel, Schedules};
use bevy_ecs::system::ScheduleSystem;
use std::fmt::Debug;
use std::sync::Arc;
#[derive(Debug, Hash, PartialEq, Eq, Clone, ScheduleLabel)]
pub struct Startup;
#[derive(Debug, Hash, PartialEq, Eq, Clone, ScheduleLabel)]
pub struct Update;
#[derive(Debug, Hash, PartialEq, Eq, Clone, ScheduleLabel)]
pub struct Last;
pub struct Game {
world: World,
input_queue: Option<Arc<input::InputQueue>>,
}
impl std::ops::Deref for Game {
type Target = World;
fn deref(&self) -> &Self::Target {
&self.world
}
}
impl std::ops::DerefMut for Game {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.world
}
}
impl Game {
/// Internal method for creating an uninitialized Game
///
/// This is used internally by GameBuilder and for test setup.
/// Most users should use `GameBuilder` instead.
#[doc(hidden)]
pub fn new_internal() -> Self {
let mut world = World::new();
// Initialize schedules with proper ordering
let mut schedules = Schedules::new();
schedules.insert(Schedule::new(Startup));
schedules.insert(Schedule::new(Update));
schedules.insert(Schedule::new(Last));
world.insert_resource(schedules);
Self { world, input_queue: None }
}
pub fn world(&self) -> &World {
&self.world
}
pub fn world_mut(&mut self) -> &mut World {
&mut self.world
}
pub fn insert_resource<R: Resource>(&mut self, resource: R) -> &mut Self {
self.world.insert_resource(resource);
self
}
pub fn init_resource<R: Resource + FromWorld>(&mut self) -> &mut Self {
self.world.init_resource::<R>();
self
}
pub fn insert_non_send_resource<R: 'static>(&mut self, resource: R) -> &mut Self {
self.world.insert_non_send_resource(resource);
self
}
pub fn add_message<M: Message>(&mut self) -> &mut Self {
if !self.world.contains_resource::<Messages<M>>() {
self.world.init_resource::<Messages<M>>();
// Add system to update this message type each frame
self.add_systems(Last, |mut messages: ResMut<Messages<M>>| {
messages.update();
});
}
self
}
pub fn add_systems<M>(&mut self, schedule: impl ScheduleLabel, systems: impl IntoScheduleConfigs<ScheduleSystem, M>) -> &mut Self {
let mut schedules = self.world.resource_mut::<Schedules>();
if let Some(schedule_inst) = schedules.get_mut(schedule) {
schedule_inst.add_systems(systems);
}
self
}
/// Process queued input events and send them as Bevy messages
///
/// Called internally by update() at the start of each frame to drain the
/// input queue and convert InputEvents into Bevy messages.
fn process_input_queue(&mut self) {
let Some(queue) = &self.input_queue else {
return;
};
for event in queue.drain() {
match event {
input::InputEvent::MouseButton { button, state, tile, world_pos } => {
self.world.write_message(input::MouseButtonMessage { button, state, tile, world_pos });
}
input::InputEvent::MouseMotion { tile, world_pos } => {
self.world.write_message(input::MouseMotionMessage { tile, world_pos });
}
input::InputEvent::KeyEvent { key, state } => {
self.world.write_message(input::KeyEventMessage { key, state });
}
}
}
}
pub fn update(&mut self) {
let _guard = tracing::trace_span!("game_update").entered();
// Process queued input at the start of each frame
self.process_input_queue();
// Remove schedules temporarily to avoid resource_scope conflicts
let mut schedules = self.world.remove_resource::<Schedules>().unwrap();
// Run Update schedule
if let Some(schedule) = schedules.get_mut(Update) {
let _guard = tracing::trace_span!("update_schedule").entered();
schedule.run(&mut self.world);
}
// Run Last schedule (includes event updates)
if let Some(schedule) = schedules.get_mut(Last) {
let _guard = tracing::trace_span!("last_schedule").entered();
schedule.run(&mut self.world);
}
// Re-insert schedules
self.world.insert_resource(schedules);
}
pub fn run_startup(&mut self) {
let _guard = tracing::trace_span!("run_startup_schedule").entered();
// Remove schedules temporarily to avoid resource_scope conflicts
let mut schedules = self.world.remove_resource::<Schedules>().unwrap();
// Run Startup schedule
if let Some(schedule) = schedules.get_mut(Startup) {
schedule.run(&mut self.world);
}
// Re-insert schedules
self.world.insert_resource(schedules);
}
pub fn finish(&mut self) {
// Finalize schedules
let mut schedules = self.world.remove_resource::<Schedules>().unwrap();
let system_count: usize = schedules.iter().map(|(_, schedule)| schedule.systems().map(|iter| iter.count()).unwrap_or(0)).sum();
let _guard = tracing::trace_span!("finish_schedules", system_count = system_count).entered();
for (_, schedule) in schedules.iter_mut() {
schedule.graph_mut().initialize(&mut self.world);
}
self.world.insert_resource(schedules);
}
}

View File

@@ -0,0 +1,19 @@
//! ECS query helper utilities for common game state queries
use glam::U16Vec2;
use crate::game::systems::borders::BorderCache;
use crate::game::{NationId, TerritoryManager};
/// Find any tile owned by a specific nation (useful for camera centering)
/// Returns the tile position if found
#[inline]
pub fn find_nation_tile(border_cache: &BorderCache, nation_id: NationId) -> Option<U16Vec2> {
border_cache.get(nation_id)?.iter().next().copied()
}
/// Count the total number of conquerable (non-water, owned) tiles on the map
#[inline]
pub fn count_land_tiles(territory_manager: &TerritoryManager) -> u32 {
territory_manager.as_slice().iter().filter(|ownership| ownership.is_owned()).count() as u32
}

View File

@@ -0,0 +1,100 @@
use bevy_ecs::prelude::*;
use glam::U16Vec2;
/// Ship component containing all ship state
#[derive(Component, Debug, Clone)]
pub struct Ship {
pub id: u32,
pub troops: u32,
pub path: Vec<U16Vec2>,
pub current_path_index: usize,
pub ticks_per_tile: u32,
pub ticks_since_move: u32,
pub launch_tick: u64,
pub target_tile: U16Vec2,
}
impl Ship {
/// Create a new ship
pub fn new(id: u32, troops: u32, path: Vec<U16Vec2>, ticks_per_tile: u32, launch_tick: u64) -> Self {
let target_tile = *path.last().unwrap_or(&path[0]);
Self { id, troops, path, current_path_index: 0, ticks_per_tile, ticks_since_move: 0, launch_tick, target_tile }
}
/// Update the ship's position based on the current tick
/// Returns true if the ship has reached its destination
pub fn update(&mut self) -> bool {
if self.has_arrived() {
return true;
}
self.ticks_since_move += 1;
if self.ticks_since_move >= self.ticks_per_tile {
self.ticks_since_move = 0;
self.current_path_index += 1;
if self.has_arrived() {
return true;
}
}
false
}
/// Get the current tile the ship is on
#[inline]
pub fn get_current_tile(&self) -> U16Vec2 {
if self.current_path_index < self.path.len() { self.path[self.current_path_index] } else { self.target_tile }
}
/// Check if the ship has reached its destination
#[inline]
pub fn has_arrived(&self) -> bool {
self.current_path_index >= self.path.len() - 1
}
/// Get interpolation factor for smooth rendering (0.0 to 1.0)
#[inline]
pub fn get_visual_interpolation(&self) -> f32 {
if self.ticks_per_tile == 0 {
return 1.0;
}
self.ticks_since_move as f32 / self.ticks_per_tile as f32
}
/// Get the next tile in the path (for interpolation)
#[inline]
pub fn get_next_tile(&self) -> Option<U16Vec2> {
if self.current_path_index + 1 < self.path.len() { Some(self.path[self.current_path_index + 1]) } else { None }
}
}
/// Component tracking number of ships owned by a player
#[derive(Component, Debug, Clone, Copy, Default)]
pub struct ShipCount(pub usize);
/// Resource for generating unique ship IDs
#[derive(Resource)]
pub struct ShipIdCounter {
next_id: u32,
}
impl ShipIdCounter {
pub fn new() -> Self {
Self { next_id: 1 }
}
pub fn generate_id(&mut self) -> u32 {
let id = self.next_id;
self.next_id += 1;
id
}
}
impl Default for ShipIdCounter {
fn default() -> Self {
Self::new()
}
}

View File

@@ -0,0 +1,15 @@
//! Ship system using ECS architecture.
//!
//! Ships are entities with parent-child relationships to players.
//! See systems.rs for launch/update/arrival systems.
mod components;
pub mod pathfinding;
pub mod systems;
pub use components::*;
pub use pathfinding::*;
pub use systems::*;
// Re-export ship constants from central location
pub use crate::game::core::constants::ships::*;

View File

@@ -0,0 +1,248 @@
use std::cmp::Ordering;
use std::collections::{BinaryHeap, HashMap, HashSet};
use glam::U16Vec2;
use tracing::debug;
use crate::game::terrain::data::TerrainData;
use crate::game::utils::neighbors;
/// A node in the pathfinding search
#[derive(Clone, Eq, PartialEq)]
struct PathNode {
pos: U16Vec2,
g_cost: u32, // Cost from start
h_cost: u32, // Heuristic cost to goal
f_cost: u32, // Total cost (g + h)
}
impl Ord for PathNode {
fn cmp(&self, other: &Self) -> Ordering {
// Reverse ordering for min-heap
other.f_cost.cmp(&self.f_cost)
}
}
impl PartialOrd for PathNode {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
/// Find a water path from start_tile to target_tile using A* algorithm
/// Returns None if no path exists
pub fn find_water_path(terrain: &TerrainData, start_tile: U16Vec2, target_tile: U16Vec2, max_path_length: usize) -> Option<Vec<U16Vec2>> {
let size = terrain.size();
// Check if target is reachable (must be coastal or water)
if !is_valid_ship_destination(terrain, target_tile, size) {
debug!("Pathfinding failed: target {:?} is not a valid ship destination", target_tile);
return None;
}
// Find actual water start position (adjacent to coast)
debug!("Pathfinding: looking for water launch tile adjacent to coastal tile {:?}", start_tile);
let water_start = find_water_launch_tile(terrain, start_tile, size)?;
debug!("Pathfinding: found water launch tile {:?}", water_start);
// Find water tiles adjacent to target if target is land
let water_targets = if terrain.is_navigable(target_tile) { vec![target_tile] } else { find_adjacent_water_tiles(terrain, target_tile, size) };
if water_targets.is_empty() {
return None;
}
// Run A* pathfinding
let mut open_set = BinaryHeap::new();
let mut closed_set = HashSet::new();
let mut came_from: HashMap<U16Vec2, U16Vec2> = HashMap::new();
let mut g_scores: HashMap<U16Vec2, u32> = HashMap::new();
// Initialize with start node
let start_h = water_start.manhattan_distance(water_targets[0]) as u32;
open_set.push(PathNode { pos: water_start, g_cost: 0, h_cost: start_h, f_cost: start_h });
g_scores.insert(water_start, 0);
while let Some(current_node) = open_set.pop() {
let current_pos = current_node.pos;
// Check if we've reached any of the target tiles
if water_targets.contains(&current_pos) {
// Reconstruct path
let mut path = vec![current_pos];
let mut current_tile = current_pos;
while let Some(&parent) = came_from.get(&current_tile) {
path.push(parent);
current_tile = parent;
// Prevent infinite loops
if path.len() > max_path_length {
return None;
}
}
path.reverse();
// If original target was land, add it to the end
if !terrain.is_navigable(target_tile) {
path.push(target_tile);
}
return Some(path);
}
// Skip if already processed
if closed_set.contains(&current_pos) {
continue;
}
closed_set.insert(current_pos);
// Check if we've exceeded max path length
if current_node.g_cost as usize > max_path_length {
continue;
}
// Explore neighbors
for neighbor in neighbors(current_pos, size) {
if closed_set.contains(&neighbor) {
continue;
}
if !terrain.is_navigable(neighbor) {
continue;
}
let tentative_g = current_node.g_cost + 1;
if tentative_g < *g_scores.get(&neighbor).unwrap_or(&u32::MAX) {
came_from.insert(neighbor, current_pos);
g_scores.insert(neighbor, tentative_g);
// Find best heuristic to any target
let h_cost = water_targets.iter().map(|&t| neighbor.manhattan_distance(t) as u32).min().unwrap_or(0);
let f_cost = tentative_g + h_cost;
open_set.push(PathNode { pos: neighbor, g_cost: tentative_g, h_cost, f_cost });
}
}
}
debug!("Pathfinding failed: no path found from {:?} to {:?}", start_tile, target_tile);
None
}
/// Find a water tile adjacent to a coastal land tile for ship launch
fn find_water_launch_tile(terrain: &TerrainData, coast_tile: U16Vec2, size: U16Vec2) -> Option<U16Vec2> {
debug!("find_water_launch_tile: checking coastal tile {:?}", coast_tile);
let water_tile = neighbors(coast_tile, size)
.inspect(|&neighbor| {
if terrain.is_navigable(neighbor) {
debug!(" Checking neighbor {:?}: is_water=true", neighbor);
}
})
.find(|&neighbor| terrain.is_navigable(neighbor));
if let Some(tile) = water_tile {
debug!(" Found water launch tile {:?}", tile);
} else {
debug!(" No water launch tile found for coastal tile {:?}", coast_tile);
}
water_tile
}
/// Find all water tiles adjacent to a land tile
fn find_adjacent_water_tiles(terrain: &TerrainData, tile: U16Vec2, size: U16Vec2) -> Vec<U16Vec2> {
neighbors(tile, size).filter(|&neighbor| terrain.is_navigable(neighbor)).collect()
}
/// Check if a tile is a valid ship destination (water or coastal land)
fn is_valid_ship_destination(terrain: &TerrainData, tile: U16Vec2, size: U16Vec2) -> bool {
// If it's water, it's valid
if terrain.is_navigable(tile) {
return true;
}
// If it's land, check if it's coastal
neighbors(tile, size).any(|neighbor| terrain.is_navigable(neighbor))
}
/// Simplify a path by removing unnecessary waypoints (path smoothing)
/// This maintains determinism as it's purely geometric
pub fn smooth_path(path: Vec<U16Vec2>, terrain: &TerrainData) -> Vec<U16Vec2> {
if path.len() <= 2 {
return path;
}
let mut smoothed = vec![path[0]];
let mut current_idx = 0;
while current_idx < path.len() - 1 {
let mut farthest = current_idx + 1;
// Find the farthest point we can see directly
for i in (current_idx + 2)..path.len() {
if has_clear_water_line(terrain, path[current_idx], path[i]) {
farthest = i;
} else {
break;
}
}
smoothed.push(path[farthest]);
current_idx = farthest;
}
smoothed
}
/// Check if there's a clear water line between two tiles
/// Uses Bresenham-like algorithm for deterministic line checking
fn has_clear_water_line(terrain: &TerrainData, from: U16Vec2, to: U16Vec2) -> bool {
let x0 = from.x as i32;
let y0 = from.y as i32;
let x1 = to.x as i32;
let y1 = to.y as i32;
let dx = (x1 - x0).abs();
let dy = (y1 - y0).abs();
let sx = if x0 < x1 { 1 } else { -1 };
let sy = if y0 < y1 { 1 } else { -1 };
let mut err = dx - dy;
let mut x = x0;
let mut y = y0;
loop {
if !terrain.is_navigable(U16Vec2::new(x as u16, y as u16)) {
return false; // Hit land
}
if x == x1 && y == y1 {
return true; // Reached target
}
let e2 = 2 * err;
if e2 > -dy {
err -= dy;
x += sx;
}
if e2 < dx {
err += dx;
y += sy;
}
}
}
/// Find the nearest coastal tile owned by a player to a target tile
/// Returns None if no valid coastal tile found
pub fn find_nearest_player_coastal_tile(coastal_tiles: &HashSet<U16Vec2>, player_border_tiles: &HashSet<U16Vec2>, target_tile: U16Vec2) -> Option<U16Vec2> {
let best_tile = player_border_tiles.iter().filter(|&tile| coastal_tiles.contains(tile)).min_by_key(|&tile| tile.manhattan_distance(target_tile));
debug!("Finding coastal tile: coastal_tiles.len={}, player_border_tiles.len={}, target_tile={:?}, best_tile={:?}", coastal_tiles.len(), player_border_tiles.len(), target_tile, best_tile);
best_tile.copied()
}

View File

@@ -0,0 +1,286 @@
use std::collections::HashSet;
use bevy_ecs::hierarchy::ChildOf;
use bevy_ecs::prelude::*;
use glam::U16Vec2;
use tracing::debug;
use crate::game::terrain::TerrainData;
use crate::game::{
ActiveAttacks, CoastalTiles, DeterministicRng, NationEntityMap, TerritoryManager, TerritorySize, Troops,
entities::remove_troops,
ships::{MAX_SHIPS_PER_NATION, Ship, ShipCount, ShipIdCounter, TICKS_PER_TILE, TROOP_PERCENT},
world::NationId,
};
use crate::game::{ActiveTurn, BorderCache, CurrentTurn};
/// Event for requesting a ship launch
#[derive(Debug, Clone, Message)]
pub struct LaunchShipMessage {
pub nation_id: NationId,
pub target_tile: U16Vec2,
pub troops: u32,
}
/// Event for ship arrivals at their destination
#[derive(Debug, Clone, Message)]
pub struct ShipArrivalMessage {
pub owner_id: NationId,
pub target_tile: U16Vec2,
pub troops: u32,
}
/// System to handle ship launch requests
/// Validates launch conditions and spawns ship entities as children of nations
/// Uses If<Res<ActiveTurn>> to skip when no active turn
#[allow(clippy::too_many_arguments)]
pub fn launch_ship_system(active_turn: If<Res<ActiveTurn>>, mut launch_events: MessageReader<LaunchShipMessage>, mut commands: Commands, mut ship_id_counter: ResMut<ShipIdCounter>, mut players: Query<(&NationId, &mut Troops, &mut ShipCount)>, terrain: Res<TerrainData>, coastal_tiles: Res<CoastalTiles>, territory_manager: Res<TerritoryManager>, border_cache: Res<BorderCache>, entity_map: Res<NationEntityMap>) {
let turn_number = active_turn.turn_number;
let size = territory_manager.size();
let territory_slice = territory_manager.as_slice();
for event in launch_events.read() {
let _guard = tracing::trace_span!(
"launch_ship",
nation_id = ?event.nation_id,
?event.target_tile
)
.entered();
// Get nation entity
let Some(&player_entity) = entity_map.0.get(&event.nation_id) else {
debug!(?event.nation_id, "Nation not found");
continue;
};
// Get nation components
let Ok((_, mut troops, mut ship_count)) = players.get_mut(player_entity) else {
debug!(?event.nation_id, "Dead nation cannot launch ships");
continue;
};
// Check ship limit
if ship_count.0 >= MAX_SHIPS_PER_NATION {
debug!(
?event.nation_id,
"Nation cannot launch ship: already has {}/{} ships",
ship_count.0,
MAX_SHIPS_PER_NATION
);
continue;
}
// Check troops
if troops.0 <= 0.0 {
debug!(?event.nation_id, "Nation has no troops to launch ship");
continue;
}
// Clamp troops to available, use default 20% if 0 requested
let troops_to_send = if event.troops > 0 {
let available = troops.0 as u32;
let clamped = event.troops.min(available);
if event.troops > available {
debug!(
?event.nation_id,
requested = event.troops,
available = available,
"Ship launch requested more troops than available, clamping"
);
}
clamped
} else {
// Default to 20% of troops if 0 requested
(troops.0 * TROOP_PERCENT).floor() as u32
};
if troops_to_send == 0 {
debug!(?event.nation_id, "Not enough troops to launch ship");
continue;
}
// Find target's nearest coastal tile
let target_coastal_tile = crate::game::connectivity::find_coastal_tile_in_region(territory_slice, &terrain, event.target_tile, size);
let target_coastal_tile = match target_coastal_tile {
Some(tile) => tile,
None => {
debug!(
?event.nation_id,
?event.target_tile,
"No coastal tile found in target region"
);
continue;
}
};
// Find nation's nearest coastal tile
let nation_border_tiles = border_cache.get(event.nation_id);
let launch_tile = nation_border_tiles.and_then(|tiles| crate::game::ships::pathfinding::find_nearest_player_coastal_tile(coastal_tiles.tiles(), tiles, target_coastal_tile));
let launch_tile = match launch_tile {
Some(tile) => tile,
None => {
debug!(
?event.nation_id,
?event.target_tile,
"Nation has no coastal tiles to launch from"
);
continue;
}
};
// Calculate water path from launch tile to target coastal tile
let path = {
let _guard = tracing::trace_span!("ship_pathfinding", ?launch_tile, ?target_coastal_tile).entered();
crate::game::ships::pathfinding::find_water_path(&terrain, launch_tile, target_coastal_tile, crate::game::ships::MAX_PATH_LENGTH)
};
let path = match path {
Some(p) => p,
None => {
debug!(
?event.nation_id,
?event.target_tile,
?launch_tile,
"No water path found"
);
continue;
}
};
// Generate ship ID
let ship_id = ship_id_counter.generate_id();
// Deduct troops from nation
troops.0 = remove_troops(troops.0, troops_to_send as f32);
// Create ship as child of nation entity
let ship = Ship::new(ship_id, troops_to_send, path, TICKS_PER_TILE, turn_number);
let ship_entity = commands.spawn(ship).id();
commands.entity(player_entity).add_child(ship_entity);
// Increment ship count
ship_count.0 += 1;
debug!(
?event.nation_id,
?event.target_tile,
troops_to_send,
?launch_tile,
ship_id,
"Ship launched successfully"
);
}
}
/// System to update all ships and emit arrival events
pub fn update_ships_system(mut ships: Query<(Entity, &mut Ship, &ChildOf)>, mut arrival_events: MessageWriter<ShipArrivalMessage>, mut commands: Commands, mut players: Query<(&NationId, &mut ShipCount)>) {
let _guard = tracing::trace_span!("update_ships", ship_count = ships.iter().len()).entered();
for (ship_entity, mut ship, parent) in ships.iter_mut() {
if ship.update() {
// Ship has arrived at destination
arrival_events.write(ShipArrivalMessage {
owner_id: {
if let Ok((nation_id, _)) = players.get(parent.0) {
*nation_id
} else {
debug!(ship_id = ship.id, "Ship parent entity missing NationId");
commands.entity(ship_entity).despawn();
continue;
}
},
target_tile: ship.target_tile,
troops: ship.troops,
});
if let Ok((nation_id, mut ship_count)) = players.get_mut(parent.0) {
ship_count.0 = ship_count.0.saturating_sub(1);
debug!(ship_id = ship.id, nation_id = nation_id.get(), troops = ship.troops, "Ship arrived at destination");
}
// Despawn ship
commands.entity(ship_entity).despawn();
}
}
}
/// System to handle ship arrivals and create beachheads
/// Uses If<Res<ActiveTurn>> to skip when no active turn
#[allow(clippy::too_many_arguments)]
pub fn handle_ship_arrivals_system(_active_turn: If<Res<ActiveTurn>>, mut arrival_events: MessageReader<ShipArrivalMessage>, current_turn: Res<CurrentTurn>, terrain: Res<TerrainData>, mut territory_manager: ResMut<TerritoryManager>, mut active_attacks: ResMut<ActiveAttacks>, rng: Res<DeterministicRng>, entity_map: Res<NationEntityMap>, border_cache: Res<BorderCache>, mut players: Query<(&mut Troops, &mut TerritorySize)>, mut commands: Commands) {
let arrivals: Vec<_> = arrival_events.read().cloned().collect();
if arrivals.is_empty() {
return;
}
let _guard = tracing::trace_span!("ship_arrivals", arrival_count = arrivals.len()).entered();
for arrival in arrivals {
tracing::debug!(
?arrival.owner_id,
?arrival.target_tile,
arrival.troops,
"Ship arrived at destination, establishing beachhead"
);
// Step 1: Force-claim the landing tile as beachhead
let arrival_nation_id = arrival.owner_id;
let previous_owner = territory_manager.conquer(arrival.target_tile, arrival_nation_id);
// Step 2: Update nation stats
if let Some(nation_id) = previous_owner
&& let Some(&prev_entity) = entity_map.0.get(&nation_id)
&& let Ok((mut troops, mut territory_size)) = players.get_mut(prev_entity)
{
territory_size.0 = territory_size.0.saturating_sub(1);
if territory_size.0 == 0 {
troops.0 = 0.0;
commands.entity(prev_entity).insert(crate::game::Dead);
}
}
if let Some(&entity) = entity_map.0.get(&arrival_nation_id)
&& let Ok((_, mut territory_size)) = players.get_mut(entity)
{
territory_size.0 += 1;
}
let turn_number = current_turn.turn.turn_number;
let size = territory_manager.size();
let target_tile = arrival.target_tile;
let troops = arrival.troops;
// Step 3: Notify active attacks of territory change
active_attacks.handle_territory_add(target_tile, arrival_nation_id, &territory_manager, &terrain, &rng);
// Step 4: Create attack from beachhead to expand
// Find valid attack targets (not water, not our own tiles)
let valid_targets: Vec<U16Vec2> = crate::game::utils::neighbors(target_tile, size).filter(|&neighbor| terrain.is_conquerable(neighbor) && territory_manager.get_nation_id(neighbor) != Some(arrival_nation_id)).collect();
// Pick a deterministic random target from valid targets
if !valid_targets.is_empty() {
// Deterministic random selection using turn number and beachhead position
let seed = turn_number.wrapping_mul(31).wrapping_add(target_tile.x as u64).wrapping_add(target_tile.y as u64);
let index = (seed % valid_targets.len() as u64) as usize;
let attack_target_tile = valid_targets[index];
// Determine the target nation (None if unclaimed)
let attack_target = territory_manager.get_nation_id(attack_target_tile);
// Build nation borders map for compatibility
let nation_borders = border_cache.as_map();
let beachhead_borders = Some(&HashSet::from([target_tile]));
crate::game::handle_attack_internal(arrival_nation_id, attack_target, crate::game::TroopCount::Absolute(troops), false, beachhead_borders, turn_number, &territory_manager, &terrain, &mut active_attacks, &rng, &nation_borders, &entity_map, &mut players);
} else {
tracing::debug!(?arrival_nation_id, ?target_tile, "Ship landed but no valid attack targets found (all adjacent tiles are water or owned)");
}
}
}

View File

@@ -0,0 +1,166 @@
/// Border tile management
///
/// This module manages border tiles for all nations. A border tile is a tile
/// adjacent to a tile with a different owner. Borders are used for:
/// - Attack targeting (attacks expand from border tiles)
/// - UI rendering (show nation borders on the map)
/// - Ship launching (find coastal borders for naval operations)
use std::collections::{HashMap, HashSet};
use bevy_ecs::prelude::*;
use glam::U16Vec2;
use crate::game::{
CurrentTurn,
entities::BorderTiles,
utils::neighbors,
world::{NationId, TerritoryManager},
};
/// Cached border data for efficient non-ECS lookups
///
/// This resource caches border tiles per nation to avoid reconstructing
/// HashMaps every turn. It is updated by `update_player_borders_system`
/// only when borders actually change.
#[derive(Resource, Default)]
pub struct BorderCache {
borders: HashMap<NationId, HashSet<U16Vec2>>,
}
impl BorderCache {
/// Get border tiles for a specific nation
#[inline]
pub fn get(&self, nation_id: NationId) -> Option<&HashSet<U16Vec2>> {
self.borders.get(&nation_id)
}
/// Update the border cache with current border data
fn update(&mut self, nation_id: NationId, borders: &HashSet<U16Vec2>) {
self.borders.insert(nation_id, borders.clone());
}
/// Get all nation borders as a HashMap (for compatibility)
pub fn as_map(&self) -> HashMap<NationId, &HashSet<U16Vec2>> {
self.borders.iter().map(|(id, borders)| (*id, borders)).collect()
}
}
/// Result of a border transition
#[derive(Debug)]
pub struct BorderTransitionResult {
/// Tiles that became interior (not borders anymore)
pub territory: Vec<U16Vec2>,
/// Tiles that are now attacker borders
pub attacker: Vec<U16Vec2>,
/// Tiles that are now defender borders
pub defender: Vec<U16Vec2>,
}
/// Group affected tiles by their owner for efficient per-nation processing
///
/// Instead of checking every tile for every nation (O(nations * tiles)),
/// we group tiles by owner once (O(tiles)) and then process each group.
fn group_tiles_by_owner(affected_tiles: &HashSet<U16Vec2>, territory: &TerritoryManager) -> HashMap<NationId, HashSet<U16Vec2>> {
let _guard = tracing::trace_span!("group_tiles_by_owner", tile_count = affected_tiles.len()).entered();
let mut grouped: HashMap<NationId, HashSet<U16Vec2>> = HashMap::new();
for &tile in affected_tiles {
if let Some(nation_id) = territory.get_ownership(tile).nation_id() {
grouped.entry(nation_id).or_default().insert(tile);
}
}
grouped
}
/// System to clear territory changes
pub fn clear_territory_changes_system(current_turn: Res<CurrentTurn>, mut territory_manager: ResMut<TerritoryManager>) {
if current_turn.active && territory_manager.has_changes() {
tracing::trace!(count = territory_manager.iter_changes().count(), "Clearing territory changes");
territory_manager.clear_changes();
}
}
/// Update all nation borders based on territory changes (batched system)
///
/// This system runs once per turn AFTER all territory changes (conquests, spawns, ships).
/// It drains the TerritoryManager's change buffer and updates borders for all affected nations.
/// It also updates the BorderCache for efficient non-ECS lookups.
pub fn update_nation_borders_system(mut nations: Query<(&NationId, &mut BorderTiles)>, territory_manager: Res<TerritoryManager>, mut border_cache: ResMut<BorderCache>) {
if !territory_manager.has_changes() {
return; // Early exit - no work needed
}
let _guard = tracing::trace_span!("update_player_borders").entered();
let (changed_tiles, raw_change_count): (HashSet<U16Vec2>, usize) = {
let _guard = tracing::trace_span!("collect_changed_tiles").entered();
let changes_vec: Vec<U16Vec2> = territory_manager.iter_changes().collect();
let raw_count = changes_vec.len();
let unique_set: HashSet<U16Vec2> = changes_vec.into_iter().collect();
(unique_set, raw_count)
};
if raw_change_count != changed_tiles.len() {
tracing::warn!(raw_changes = raw_change_count, unique_changes = changed_tiles.len(), duplicates = raw_change_count - changed_tiles.len(), "Duplicate tile changes detected in ChangeBuffer - this causes performance degradation");
}
// Build affected tiles (changed + all neighbors)
let affected_tiles = {
let _guard = tracing::trace_span!("build_affected_tiles", changed_count = changed_tiles.len()).entered();
let mut affected_tiles = HashSet::with_capacity(changed_tiles.len() * 5);
let size = territory_manager.size();
for &tile in &changed_tiles {
affected_tiles.insert(tile);
affected_tiles.extend(crate::game::core::utils::neighbors(tile, size));
}
affected_tiles
};
// Group tiles by owner for efficient per-nation processing
let tiles_by_owner = group_tiles_by_owner(&affected_tiles, &territory_manager);
tracing::trace!(nation_count = nations.iter().len(), changed_tile_count = changed_tiles.len(), affected_tile_count = affected_tiles.len(), unique_owners = tiles_by_owner.len(), "Border update statistics");
// Update each nation's borders (pure ECS) and BorderCache
{
let _guard = tracing::trace_span!("update_all_nation_borders", nation_count = nations.iter().len()).entered();
for (nation_id, mut component_borders) in &mut nations {
// Only process tiles owned by this nation (or empty set if none)
let empty_set = HashSet::new();
let nation_tiles = tiles_by_owner.get(nation_id).unwrap_or(&empty_set);
update_borders_for_player(&mut component_borders, *nation_id, nation_tiles, &territory_manager);
// Update the cache with the new border data
border_cache.update(*nation_id, &component_borders);
}
}
}
/// Update borders for a single nation based on their owned tiles
///
/// Only processes tiles owned by this nation, significantly reducing
/// redundant work when multiple nations exist.
fn update_borders_for_player(borders: &mut HashSet<U16Vec2>, nation_id: NationId, nation_tiles: &HashSet<U16Vec2>, territory: &TerritoryManager) {
let _guard = tracing::trace_span!(
"update_borders_for_nation",
nation_id = %nation_id,
nation_tile_count = nation_tiles.len(),
current_border_count = borders.len()
)
.entered();
for &tile in nation_tiles {
// Check if it's a border (has at least one neighbor with different owner)
let is_border = neighbors(tile, territory.size()).any(|neighbor| !territory.is_owner(neighbor, nation_id));
if is_border {
borders.insert(tile);
} else {
borders.remove(&tile);
}
}
}

View File

@@ -0,0 +1,30 @@
use bevy_ecs::prelude::*;
use tracing::trace;
use crate::game::ai::bot::Bot;
use crate::game::entities;
use crate::game::{ActiveTurn, CurrentTurn, Dead, TerritorySize, Troops};
/// Process player income at 10 TPS (once per turn)
/// Uses If<Res<ActiveTurn>> to skip when no active turn
///
/// Uses Has<Bot> to distinguish bot vs human players:
/// - true = bot player (60% income, 33% max troops)
/// - false = human player (100% income, 100% max troops)
pub fn process_nation_income_system(_active_turn: If<Res<ActiveTurn>>, current_turn: Res<CurrentTurn>, mut players: Query<(&mut Troops, &TerritorySize, Has<Bot>), Without<Dead>>) {
// Skip income processing on Turn 0 - players haven't spawned yet
// Spawning happens during execute_turn_gameplay_system on Turn 0
if current_turn.turn.turn_number == 0 {
trace!("Skipping income on Turn 0 (pre-spawn)");
return;
}
// Process income for all alive players (Without<Dead> filter)
for (mut troops, territory_size, is_bot) in &mut players {
// Calculate and apply income
let income = entities::calculate_income(troops.0, territory_size.0, is_bot);
troops.0 = entities::add_troops_capped(troops.0, income, territory_size.0, is_bot);
}
trace!("Income processed for turn {}", current_turn.turn.turn_number);
}

View File

@@ -0,0 +1,39 @@
//! Game systems that run each tick/turn
//!
//! This module contains systems that execute game logic.
use bevy_ecs::prelude::*;
use bevy_ecs::system::SystemParam;
use crate::game::entities::NationEntityMap;
use crate::game::terrain::data::TerrainData;
pub mod borders;
pub mod income;
pub mod spawn;
pub mod spawn_territory;
pub mod spawn_timeout;
pub mod turn;
pub mod turn_actions;
pub mod turn_attacks;
pub mod turn_spawns;
// Re-export system functions and types
pub use borders::*;
pub use income::*;
pub use spawn::*;
pub use spawn_territory::*;
pub use spawn_timeout::*;
pub use turn::*;
pub use turn_actions::process_and_apply_actions_system;
pub use turn_attacks::tick_attacks_system;
pub use turn_spawns::handle_spawns_system;
/// SystemParam to group read-only game resources
/// Used across multiple turn execution systems
#[derive(SystemParam)]
pub struct GameResources<'w> {
pub border_cache: Res<'w, BorderCache>,
pub nation_entity_map: Res<'w, NationEntityMap>,
pub terrain: Res<'w, TerrainData>,
}

View File

@@ -0,0 +1,80 @@
use bevy_ecs::prelude::*;
use crate::game::NationId;
/// Represents a spawn point for a nation (player or bot)
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SpawnPoint {
pub nation: NationId,
pub tile: glam::U16Vec2,
}
impl SpawnPoint {
pub fn new(nation: NationId, tile: glam::U16Vec2) -> Self {
Self { nation, tile }
}
}
/// Manages spawn positions during the pre-game spawn phase
///
/// This resource tracks bot and nation spawn positions before the game starts ticking.
/// It allows for dynamic recalculation of bot positions when nations change their spawn
/// location, implementing the two-pass spawn system described in the README.
#[derive(Resource)]
pub struct SpawnManager {
/// Initial bot spawn positions from first pass
pub initial_bot_spawns: Vec<SpawnPoint>,
/// Current bot spawn positions after recalculation
/// These are updated whenever a player chooses/changes their spawn
pub current_bot_spawns: Vec<SpawnPoint>,
/// Nation spawn positions
/// Tracks human nation spawn selections
pub player_spawns: Vec<SpawnPoint>,
/// RNG seed for deterministic spawn calculations
pub rng_seed: u64,
}
impl SpawnManager {
/// Create a new SpawnManager with initial bot spawns
pub fn new(initial_bot_spawns: Vec<SpawnPoint>, rng_seed: u64) -> Self {
Self { current_bot_spawns: initial_bot_spawns.clone(), initial_bot_spawns, player_spawns: Vec::new(), rng_seed }
}
/// Update a nation's spawn position and recalculate bot spawns if necessary
///
/// This triggers the second pass of the two-pass spawn system, relocating
/// any bots that are too close to the new nation position.
pub fn update_player_spawn(&mut self, nation_id: NationId, tile_index: glam::U16Vec2, territory_manager: &crate::game::TerritoryManager, terrain: &crate::game::terrain::TerrainData) {
let spawn_point = SpawnPoint::new(nation_id, tile_index);
// Update or add nation spawn
if let Some(entry) = self.player_spawns.iter_mut().find(|spawn| spawn.nation == nation_id) {
*entry = spawn_point;
} else {
self.player_spawns.push(spawn_point);
}
// Recalculate bot spawns with updated nation positions
self.current_bot_spawns = crate::game::ai::bot::recalculate_spawns_with_players(self.initial_bot_spawns.clone(), &self.player_spawns, territory_manager, terrain, self.rng_seed);
}
/// Get all current spawn positions (nations + bots)
pub fn get_all_spawns(&self) -> Vec<SpawnPoint> {
let mut all_spawns = self.player_spawns.clone();
all_spawns.extend(self.current_bot_spawns.iter().copied());
all_spawns
}
/// Get only bot spawn positions
pub fn get_bot_spawns(&self) -> &[SpawnPoint] {
&self.current_bot_spawns
}
/// Get only nation spawn positions
pub fn get_player_spawns(&self) -> &[SpawnPoint] {
&self.player_spawns
}
}

View File

@@ -0,0 +1,61 @@
//! Spawn territory claiming logic
//!
//! Provides utilities for claiming 5x5 territories around spawn points.
use glam::U16Vec2;
use std::collections::HashSet;
use crate::game::terrain::data::TerrainData;
use crate::game::world::{NationId, TileOwnership};
/// Claims a 5x5 territory around a spawn point
///
/// Claims all unclaimed, conquerable tiles within 2 tiles of the spawn center
/// and returns the set of tiles that were successfully claimed.
#[inline]
pub fn claim_spawn_territory(spawn_center: U16Vec2, nation: NationId, territories: &mut [TileOwnership], terrain: &TerrainData, map_size: U16Vec2) -> HashSet<U16Vec2> {
let width = map_size.x as usize;
(-2..=2)
.flat_map(|dy| (-2..=2).map(move |dx| (dx, dy)))
.filter_map(|(dx, dy)| {
let x = (spawn_center.x as i32 + dx).clamp(0, map_size.x as i32 - 1) as usize;
let y = (spawn_center.y as i32 + dy).clamp(0, map_size.y as i32 - 1) as usize;
let tile_pos = U16Vec2::new(x as u16, y as u16);
let idx = y * width + x;
if territories[idx].is_unclaimed() && terrain.is_conquerable(tile_pos) {
territories[idx] = TileOwnership::Owned(nation);
Some(tile_pos)
} else {
None
}
})
.collect()
}
/// Clears spawn territory for a specific nation within a 5x5 area
///
/// Reverts all tiles owned by the given nation within the 5x5 area back to unclaimed
/// and returns the set of tiles that were cleared.
#[inline]
pub fn clear_spawn_territory(spawn_center: U16Vec2, nation: NationId, territories: &mut [TileOwnership], map_size: U16Vec2) -> HashSet<U16Vec2> {
let width = map_size.x as usize;
(-2..=2)
.flat_map(|dy| (-2..=2).map(move |dx| (dx, dy)))
.filter_map(|(dx, dy)| {
let x = (spawn_center.x as i32 + dx).clamp(0, map_size.x as i32 - 1) as usize;
let y = (spawn_center.y as i32 + dy).clamp(0, map_size.y as i32 - 1) as usize;
let tile_pos = U16Vec2::new(x as u16, y as u16);
let idx = y * width + x;
if territories[idx].is_owned_by(nation) {
territories[idx] = TileOwnership::Unclaimed;
Some(tile_pos)
} else {
None
}
})
.collect()
}

View File

@@ -0,0 +1,107 @@
use bevy_ecs::prelude::*;
use tracing::trace;
use crate::{game::SpawnPhase, time, ui};
/// Tracks spawn phase timeout state on the client side
///
/// This resource is used to:
/// - Show countdown timer in UI
/// - Know when spawn phase is active
/// - Calculate remaining time for display
#[derive(Resource)]
pub struct SpawnTimeout {
/// Whether spawn phase is currently active
pub active: bool,
/// Accumulated time since start (seconds)
pub elapsed_secs: f32,
/// Total timeout duration in seconds
pub duration_secs: f32,
/// Remaining time in seconds (updated each frame)
pub remaining_secs: f32,
/// Whether the initial spawn phase message has been sent
pub initial_message_sent: bool,
}
impl Default for SpawnTimeout {
fn default() -> Self {
Self {
active: false,
elapsed_secs: 0.0,
duration_secs: 5.0, // Local mode: 5 seconds
remaining_secs: 5.0,
initial_message_sent: false,
}
}
}
impl SpawnTimeout {
/// Create a new spawn timeout with specified duration
pub fn new(duration_secs: f32) -> Self {
Self { active: false, elapsed_secs: 0.0, duration_secs, remaining_secs: duration_secs, initial_message_sent: false }
}
/// Start the timeout countdown
pub fn start(&mut self) {
if self.elapsed_secs == 0.0 {
self.active = true;
self.elapsed_secs = 0.0;
self.remaining_secs = self.duration_secs;
}
}
/// Update remaining time (call each frame with delta time)
pub fn update(&mut self, delta_secs: f32) {
if !self.active {
return;
}
self.elapsed_secs += delta_secs;
self.remaining_secs = (self.duration_secs - self.elapsed_secs).max(0.0);
if self.remaining_secs <= 0.0 {
self.active = false;
}
}
/// Stop the timeout
pub fn stop(&mut self) {
self.active = false;
self.elapsed_secs = 0.0;
}
/// Check if timeout has expired
#[inline]
pub fn has_expired(&self) -> bool {
!self.active && self.remaining_secs <= 0.0
}
}
/// System to manage spawn timeout and emit countdown updates
pub fn manage_spawn_phase_system(mut spawn_timeout: If<ResMut<SpawnTimeout>>, spawn_phase: Res<SpawnPhase>, time: Res<time::Time>, mut backend_messages: MessageWriter<ui::BackendMessage>) {
if !spawn_phase.active {
return;
}
// Emit initial message if not sent yet
if !spawn_timeout.initial_message_sent {
backend_messages.write(ui::BackendMessage::SpawnPhaseUpdate { countdown: None });
spawn_timeout.initial_message_sent = true;
trace!("Emitted initial SpawnPhaseUpdate (no countdown)");
}
// Emit countdown updates once the timer is active
if spawn_timeout.active {
spawn_timeout.update(time.delta_secs());
let started_at_ms = time.epoch_millis() - (spawn_timeout.elapsed_secs * 1000.0) as u64;
backend_messages.write(ui::BackendMessage::SpawnPhaseUpdate { countdown: Some(ui::SpawnCountdown { started_at_ms, duration_secs: spawn_timeout.duration_secs }) });
trace!("SpawnPhaseUpdate: remaining {:.1}s", spawn_timeout.remaining_secs);
}
}

View File

@@ -0,0 +1,68 @@
use bevy_ecs::prelude::*;
use bevy_ecs::system::SystemState;
use crate::networking::{ProcessTurnEvent, Turn};
/// Marker resource indicating a turn is actively being processed
/// Added when a new turn arrives, removed after all turn-based systems complete
#[derive(Resource)]
pub struct ActiveTurn {
pub turn_number: u64,
}
impl ActiveTurn {
pub fn new(turn: &Turn) -> Self {
Self { turn_number: turn.turn_number }
}
}
/// Resource containing the current turn data
/// Updated once per turn (10 TPS), provides turn context to all gameplay systems
#[derive(Resource)]
pub struct CurrentTurn {
pub active: bool,
pub turn: Turn,
}
impl CurrentTurn {
pub fn new(turn: Turn) -> Self {
Self { active: true, turn }
}
}
/// System to receive turn events and update CurrentTurn resource
/// Exclusive system to ensure ActiveTurn is inserted immediately (not deferred)
pub fn update_current_turn_system(world: &mut World) {
// Create a SystemState to read messages in exclusive system
let mut system_state: SystemState<MessageReader<ProcessTurnEvent>> = SystemState::new(world);
let mut turn_events = system_state.get_mut(world);
// Read all turn events (should only be one per frame at 10 TPS)
let turns: Vec<Turn> = turn_events.read().map(|e| e.0.clone()).collect();
// Apply the state back to world
system_state.apply(world);
if turns.is_empty() {
return;
}
// Take the latest turn (in case multiple arrived, though this shouldn't happen)
let turn = turns.into_iter().last().unwrap();
// Insert ActiveTurn immediately to signal turn is being processed
world.insert_resource(ActiveTurn::new(&turn));
// Update CurrentTurn (must always exist)
let mut current_turn = world.get_resource_mut::<CurrentTurn>().expect("CurrentTurn must be initialized in GameBuilder::build()");
current_turn.active = true;
current_turn.turn = turn;
}
/// System to cleanup ActiveTurn and CurrentTurn resources after all turn-based systems complete
pub fn turn_cleanup_system(mut current_turn: ResMut<CurrentTurn>, mut commands: Commands) {
if current_turn.active {
commands.remove_resource::<ActiveTurn>();
current_turn.active = false;
}
}

View File

@@ -0,0 +1,54 @@
use bevy_ecs::prelude::*;
use tracing::trace;
use crate::game::TerritoryManager;
use crate::game::ai::bot::Bot;
use crate::game::combat::ActiveAttacks;
use crate::game::core::rng::DeterministicRng;
use crate::game::core::turn_execution::{apply_action, process_bot_actions};
use crate::game::entities::{Dead, TerritorySize, Troops};
use crate::game::ships::LaunchShipMessage;
use crate::game::systems::GameResources;
use crate::game::systems::turn::{ActiveTurn, CurrentTurn};
use crate::game::world::NationId;
use crate::networking::Intent;
/// Process bot AI and apply all actions (bot + player)
/// Runs first in turn execution chain
/// Uses If<Res<ActiveTurn>> to skip when no active turn
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
pub fn process_and_apply_actions_system(_active_turn: If<Res<ActiveTurn>>, current_turn: Res<CurrentTurn>, territory_manager: Res<TerritoryManager>, mut active_attacks: ResMut<ActiveAttacks>, mut rng: ResMut<DeterministicRng>, resources: GameResources, mut player_queries: ParamSet<(Query<(&mut Troops, &mut TerritorySize)>, Query<(&NationId, &Troops, &mut Bot), Without<Dead>>)>, mut launch_ship_writer: MessageWriter<LaunchShipMessage>) {
let turn = &current_turn.turn;
let _guard = tracing::trace_span!("process_and_apply_actions", turn_number = turn.turn_number, intent_count = turn.intents.len()).entered();
trace!("Processing actions for turn {} with {} intents", turn.turn_number, turn.intents.len());
// Use BorderCache for border data
let nation_borders = resources.border_cache.as_map();
// Update RNG for this turn
rng.update_turn(turn.turn_number);
// Process bot AI to generate actions
let bot_actions = process_bot_actions(turn.turn_number, &territory_manager, &resources.terrain, &nation_borders, rng.turn_number(), &mut player_queries.p1());
// PHASE 1: Apply bot actions
{
let _guard = tracing::trace_span!("apply_bot_actions", count = bot_actions.len()).entered();
for (nation_id, action) in bot_actions {
apply_action(nation_id, action, turn.turn_number, &territory_manager, &resources.terrain, &mut active_attacks, &rng, &nation_borders, &resources.nation_entity_map, &mut player_queries.p0(), &mut launch_ship_writer);
}
}
// PHASE 2: Apply player intents
for sourced_intent in &turn.intents {
match &sourced_intent.intent {
Intent::Action(action) => {
apply_action(sourced_intent.source, action.clone(), turn.turn_number, &territory_manager, &resources.terrain, &mut active_attacks, &rng, &nation_borders, &resources.nation_entity_map, &mut player_queries.p0(), &mut launch_ship_writer);
}
Intent::SetSpawn { .. } => {}
}
}
}

View File

@@ -0,0 +1,21 @@
use bevy_ecs::prelude::*;
use crate::game::TerritoryManager;
use crate::game::ai::bot::Bot;
use crate::game::combat::ActiveAttacks;
use crate::game::core::rng::DeterministicRng;
use crate::game::entities::{TerritorySize, Troops};
use crate::game::systems::GameResources;
/// Tick active attacks
/// Runs after actions are applied, processes ongoing attacks
#[allow(clippy::too_many_arguments)]
pub fn tick_attacks_system(mut active_attacks: ResMut<ActiveAttacks>, mut territory_manager: ResMut<TerritoryManager>, rng: Res<DeterministicRng>, resources: GameResources, mut nations: Query<(&mut Troops, &mut TerritorySize)>, is_bot_query: Query<Has<Bot>>) {
let _guard = tracing::trace_span!("tick_attacks").entered();
// Use BorderCache for border data
let nation_borders = resources.border_cache.as_map();
// Tick all active attacks
active_attacks.tick(&resources.nation_entity_map, &mut nations, &mut territory_manager, &resources.terrain, &nation_borders, &rng, &is_bot_query);
}

View File

@@ -0,0 +1,51 @@
use bevy_ecs::prelude::*;
use tracing::info;
use crate::game::TerritoryManager;
use crate::game::combat::ActiveAttacks;
use crate::game::core::rng::DeterministicRng;
use crate::game::core::turn_execution::handle_spawn;
use crate::game::entities::{TerritorySize, Troops};
use crate::game::input::handlers::SpawnPhase;
use crate::game::systems::GameResources;
use crate::game::systems::spawn::SpawnManager;
use crate::game::systems::turn::ActiveTurn;
use crate::networking::server::LocalTurnServerHandle;
use crate::ui::protocol::BackendMessage;
/// Handle spawns and end spawn phase
/// Only processes on Turn 0
/// Uses If<Res<ActiveTurn>> to skip when no active turn
#[allow(clippy::too_many_arguments)]
pub fn handle_spawns_system(active_turn: If<Res<ActiveTurn>>, spawn_manager: Option<Res<SpawnManager>>, mut spawn_phase: ResMut<SpawnPhase>, mut backend_messages: MessageWriter<BackendMessage>, server_handle: Option<Res<LocalTurnServerHandle>>, mut territory_manager: ResMut<TerritoryManager>, mut active_attacks: ResMut<ActiveAttacks>, rng: Res<DeterministicRng>, resources: GameResources, mut players: Query<(&mut Troops, &mut TerritorySize)>) {
// Only process on Turn 0
if active_turn.turn_number != 0 {
return;
}
let _guard = tracing::trace_span!("handle_spawns").entered();
// Apply ALL spawns (both human player and bots) to game state on Turn(0)
if let Some(ref spawn_mgr) = spawn_manager {
let all_spawns = spawn_mgr.get_all_spawns();
tracing::debug!("Applying {} spawns to game state on Turn(0)", all_spawns.len());
for spawn in all_spawns {
handle_spawn(spawn.nation, spawn.tile, &mut territory_manager, &resources.terrain, &mut active_attacks, &rng, &resources.nation_entity_map, &mut players);
}
}
// End spawn phase
if spawn_phase.active {
spawn_phase.active = false;
backend_messages.write(BackendMessage::SpawnPhaseEnded);
info!("Spawn phase ended after Turn(0) execution");
if let Some(ref handle) = server_handle {
handle.resume();
info!("Local turn server resumed - game started");
}
}
}

View File

@@ -0,0 +1,124 @@
use crate::game::NationId;
use crate::game::TileOwnership;
use crate::game::terrain::data::TerrainData;
use crate::game::utils::neighbors;
use glam::U16Vec2;
use std::collections::VecDeque;
/// Check if a target tile's region connects to any of the nation's tiles
/// Uses flood-fill through tiles matching the target's ownership
/// Returns true if connected (normal attack), false if disconnected (ship needed)
pub fn is_connected_to_player(territory: &[TileOwnership], terrain: &TerrainData, target_tile: U16Vec2, nation_id: NationId, size: U16Vec2) -> bool {
let target_idx = (target_tile.y as usize) * (size.x as usize) + (target_tile.x as usize);
let target_ownership = territory[target_idx];
// Can't connect to water
if terrain.is_navigable(target_tile) {
return false;
}
// If target is owned by nation, it's already connected
if target_ownership.is_owned_by(nation_id) {
return true;
}
// Flood-fill from target through tiles with same ownership
let mut queue = VecDeque::new();
let mut visited = vec![false; (size.x as usize) * (size.y as usize)];
queue.push_back(target_tile);
visited[target_idx] = true;
while let Some(current_pos) = queue.pop_front() {
for neighbor_pos in neighbors(current_pos, size) {
let neighbor_idx = (neighbor_pos.y as usize) * (size.x as usize) + (neighbor_pos.x as usize);
if visited[neighbor_idx] {
continue;
}
// Don't cross water - only flood-fill through land on the same landmass
if terrain.is_navigable(neighbor_pos) {
continue;
}
let neighbor_ownership = territory[neighbor_idx];
// Check if we found a nation tile - SUCCESS!
if neighbor_ownership.is_owned_by(nation_id) {
return true;
}
// Only continue through tiles matching target's ownership
if neighbor_ownership == target_ownership {
visited[neighbor_idx] = true;
queue.push_back(neighbor_pos);
}
}
}
// Exhausted search without finding nation tile
false
}
/// Find the nearest coastal tile in a region by flood-filling from target
/// Only expands through tiles matching the target's ownership
/// Returns coastal tile position if found
pub fn find_coastal_tile_in_region(territory: &[TileOwnership], terrain: &TerrainData, target_tile: U16Vec2, size: U16Vec2) -> Option<U16Vec2> {
let target_idx = (target_tile.y as usize) * (size.x as usize) + (target_tile.x as usize);
let target_ownership = territory[target_idx];
// Can't find coastal tile in water
if terrain.is_navigable(target_tile) {
return None;
}
// Check if target itself is coastal
if is_coastal_tile(terrain, target_tile, size) {
return Some(target_tile);
}
// BFS from target through same-ownership tiles
let mut queue = VecDeque::new();
let mut visited = vec![false; (size.x as usize) * (size.y as usize)];
queue.push_back(target_tile);
visited[target_idx] = true;
while let Some(current_pos) = queue.pop_front() {
for neighbor_pos in neighbors(current_pos, size) {
let neighbor_idx = (neighbor_pos.y as usize) * (size.x as usize) + (neighbor_pos.x as usize);
if visited[neighbor_idx] {
continue;
}
let neighbor_ownership = territory[neighbor_idx];
// Only expand through matching ownership
if neighbor_ownership == target_ownership {
visited[neighbor_idx] = true;
// Check if this tile is coastal
if is_coastal_tile(terrain, neighbor_pos, size) {
return Some(neighbor_pos);
}
queue.push_back(neighbor_pos);
}
}
}
None
}
/// Check if a tile is coastal (land tile adjacent to water)
pub fn is_coastal_tile(terrain: &TerrainData, tile: U16Vec2, size: U16Vec2) -> bool {
// Must be land tile
if terrain.is_navigable(tile) {
return false;
}
// Check if any neighbor is water (4-directional)
neighbors(tile, size).any(|neighbor| terrain.is_navigable(neighbor))
}

View File

@@ -0,0 +1,250 @@
use bevy_ecs::prelude::Resource;
use glam::U16Vec2;
use image::GenericImageView;
use serde::{Deserialize, Serialize};
use tracing::info;
use crate::game::world::tilemap::TileMap;
/// Calculate terrain color using pastel theme formulas
fn calculate_theme_color(color_base: &str, color_variant: u8) -> [u8; 3] {
let i = color_variant as i32;
match color_base {
"grass" => {
// rgb(238 - 2 * i, 238 - 2 * i, 190 - i)
[(238 - 2 * i).clamp(0, 255) as u8, (238 - 2 * i).clamp(0, 255) as u8, (190 - i).clamp(0, 255) as u8]
}
"mountain" => {
// rgb(250 - 2 * i, 250 - 2 * i, 220 - i)
[(250 - 2 * i).clamp(0, 255) as u8, (250 - 2 * i).clamp(0, 255) as u8, (220 - i).clamp(0, 255) as u8]
}
"water" => {
// rgb(172 - 2 * i, 225 - 2 * i, 249 - 3 * i)
[(172 - 2 * i).clamp(0, 255) as u8, (225 - 2 * i).clamp(0, 255) as u8, (249 - 3 * i).clamp(0, 255) as u8]
}
_ => {
// Default fallback color (gray)
[128, 128, 128]
}
}
}
/// Helper structs for loading World.json format
#[derive(Deserialize)]
struct WorldMapJson {
tiles: Vec<WorldTileDef>,
}
#[derive(Deserialize)]
struct WorldTileDef {
color: String,
name: String,
#[serde(default, rename = "colorBase")]
color_base: Option<String>,
#[serde(default, rename = "colorVariant")]
color_variant: Option<u32>,
conquerable: bool,
navigable: bool,
#[serde(default, rename = "expansionCost")]
expansion_cost: Option<u32>,
#[serde(default, rename = "expansionTime")]
expansion_time: Option<u32>,
}
/// Parse hex color string (#RRGGBB) to RGB bytes
fn parse_hex_rgb(s: &str) -> Option<[u8; 3]> {
let s = s.trim_start_matches('#');
if s.len() != 6 {
return None;
}
let r = u8::from_str_radix(&s[0..2], 16).ok()?;
let g = u8::from_str_radix(&s[2..4], 16).ok()?;
let b = u8::from_str_radix(&s[4..6], 16).ok()?;
Some([r, g, b])
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TileType {
pub name: String,
pub color_base: String,
pub color_variant: u8,
pub conquerable: bool,
pub navigable: bool,
pub expansion_time: u8,
pub expansion_cost: u8,
}
/// Map manifest structure
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct MapManifest {
pub map: MapMetadata,
pub name: String,
pub nations: Vec<NationSpawn>,
}
/// Map size metadata
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct MapMetadata {
pub size: glam::U16Vec2,
pub num_land_tiles: usize,
}
impl MapMetadata {
/// Get the width of the map
#[inline]
pub fn width(&self) -> u16 {
self.size.x
}
/// Get the height of the map
#[inline]
pub fn height(&self) -> u16 {
self.size.y
}
}
/// Nation spawn point
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct NationSpawn {
pub coordinates: [usize; 2],
pub flag: String,
pub name: String,
pub strength: u32,
}
/// Loaded map data
#[derive(Debug, Clone, Resource)]
pub struct TerrainData {
pub _manifest: MapManifest,
/// Legacy terrain data (for backward compatibility)
pub terrain_data: TileMap<u8>,
/// Tile type indices (new format)
pub tiles: Vec<u8>,
/// Tile type definitions
pub tile_types: Vec<TileType>,
}
impl TerrainData {
/// Load the World map from embedded assets
pub fn load_world_map() -> Result<Self, Box<dyn std::error::Error>> {
let _guard = tracing::debug_span!("load_world_map").entered();
const MAP_JSON: &[u8] = include_bytes!("../../../assets/maps/World.json");
const MAP_PNG: &[u8] = include_bytes!("../../../assets/maps/World.png");
// Parse JSON tile definitions
let map_json: WorldMapJson = {
let _guard = tracing::trace_span!("parse_json").entered();
serde_json::from_slice(MAP_JSON)?
};
// Load PNG image
let (png, width, height) = {
let _guard = tracing::trace_span!("load_png").entered();
let png = image::load_from_memory(MAP_PNG)?;
let (width, height) = png.dimensions();
(png, width, height)
};
info!("Loading World map: {}x{}", width, height);
// Build color-to-index lookup table
let color_to_index: Vec<([u8; 3], usize)> = map_json.tiles.iter().enumerate().filter_map(|(idx, t)| parse_hex_rgb(&t.color).map(|rgb| (rgb, idx))).collect();
let pixel_count = (width as usize) * (height as usize);
let mut tiles = vec![0u8; pixel_count];
let mut terrain_data_raw = vec![0u8; pixel_count];
// Match each pixel to nearest tile type by color
{
let _guard = tracing::trace_span!("pixel_processing", pixel_count = pixel_count).entered();
for y in 0..height {
for x in 0..width {
let pixel = png.get_pixel(x, y).0;
let rgb = [pixel[0], pixel[1], pixel[2]];
// Find nearest tile by RGB distance
let (tile_idx, _) = color_to_index
.iter()
.map(|(c, idx)| {
let dr = rgb[0] as i32 - c[0] as i32;
let dg = rgb[1] as i32 - c[1] as i32;
let db = rgb[2] as i32 - c[2] as i32;
let dist = (dr * dr + dg * dg + db * db) as u32;
(idx, dist)
})
.min_by_key(|(_, d)| *d)
.unwrap();
let i = (y * width + x) as usize;
tiles[i] = *tile_idx as u8;
// Set bit 7 if conquerable (land)
if map_json.tiles[*tile_idx].conquerable {
terrain_data_raw[i] |= 0x80;
}
// Lower 5 bits for terrain magnitude (unused for World map)
}
}
}
// Convert to TileType format
let tile_types = {
let _guard = tracing::trace_span!("tile_type_conversion").entered();
map_json.tiles.into_iter().map(|t| TileType { name: t.name, color_base: t.color_base.unwrap_or_default(), color_variant: t.color_variant.unwrap_or(0) as u8, conquerable: t.conquerable, navigable: t.navigable, expansion_cost: t.expansion_cost.unwrap_or(50) as u8, expansion_time: t.expansion_time.unwrap_or(50) as u8 }).collect()
};
let num_land_tiles = terrain_data_raw.iter().filter(|&&b| b & 0x80 != 0).count();
info!("World map loaded: {} land tiles", num_land_tiles);
Ok(Self { _manifest: MapManifest { name: "World".to_string(), map: MapMetadata { size: glam::U16Vec2::new(width as u16, height as u16), num_land_tiles }, nations: vec![] }, terrain_data: TileMap::from_vec(glam::U16Vec2::new(width as u16, height as u16), terrain_data_raw), tiles, tile_types })
}
/// Get the size of the map
#[inline]
pub fn size(&self) -> U16Vec2 {
self.terrain_data.size()
}
#[inline]
pub fn get_value(&self, pos: U16Vec2) -> u8 {
self.terrain_data[pos]
}
/// Check if a tile is land (bit 7 set)
#[inline]
pub fn is_land(&self, pos: U16Vec2) -> bool {
self.get_value(pos) & 0x80 != 0
}
/// Get tile type at position
pub fn get_tile_type(&self, pos: U16Vec2) -> &TileType {
let idx = self.terrain_data.pos_to_index(pos) as usize;
&self.tile_types[self.tiles[idx] as usize]
}
/// Check if a tile is conquerable
pub fn is_conquerable(&self, pos: U16Vec2) -> bool {
self.get_tile_type(pos).conquerable
}
/// Check if a tile is navigable (water)
pub fn is_navigable(&self, pos: U16Vec2) -> bool {
self.get_tile_type(pos).navigable
}
/// Get tile type IDs for rendering (each position maps to a tile type)
pub fn get_tile_ids(&self) -> &[u8] {
&self.tiles
}
/// Get terrain palette colors from tile types (for rendering)
/// Returns a vec where index = tile type ID, value = RGB color
/// Colors are calculated using theme formulas based on colorBase and colorVariant
pub fn get_terrain_palette_colors(&self) -> Vec<[u8; 3]> {
self.tile_types.iter().map(|tile_type| calculate_theme_color(&tile_type.color_base, tile_type.color_variant)).collect()
}
}

View File

@@ -0,0 +1,9 @@
//! Terrain and map connectivity
//!
//! This module handles terrain data and pathfinding/connectivity analysis.
pub mod connectivity;
pub mod data;
pub use connectivity::*;
pub use data::*;

View File

@@ -0,0 +1,254 @@
use std::collections::HashSet;
use glam::U16Vec2;
/// Lightweight change tracking buffer for tile mutations.
///
/// Stores only the indices of changed tiles, using a HashSet to automatically
/// deduplicate when the same tile changes multiple times per turn. This enables
/// efficient delta updates for GPU rendering and network synchronization.
///
/// # Design
/// - Records tile index changes as they occur
/// - Automatically deduplicates tile indices
/// - O(1) average insert, O(changes) iteration
/// - Optional: can be cleared/ignored when tracking not needed
#[derive(Debug, Clone)]
pub struct ChangeBuffer {
changed_indices: HashSet<U16Vec2>,
}
impl ChangeBuffer {
/// Creates a new empty ChangeBuffer.
pub fn new() -> Self {
Self { changed_indices: HashSet::new() }
}
/// Creates a new ChangeBuffer with pre-allocated capacity.
///
/// Use this when you know the approximate number of changes to avoid reallocations.
pub fn with_capacity(capacity: usize) -> Self {
Self { changed_indices: HashSet::with_capacity(capacity) }
}
/// Records a tile index as changed.
///
/// Automatically deduplicates - pushing the same index multiple times
/// only records it once. This is O(1) average case.
#[inline]
pub fn push(&mut self, position: U16Vec2) {
self.changed_indices.insert(position);
}
/// Returns an iterator over changed indices without consuming them.
///
/// Use this when you need to read changes without clearing the buffer.
/// The buffer will still contain all changes after iteration.
pub fn iter(&self) -> impl Iterator<Item = U16Vec2> + '_ {
self.changed_indices.iter().copied()
}
/// Drains all changed indices, returning an iterator and clearing the buffer.
///
/// The buffer retains its capacity for reuse.
pub fn drain(&mut self) -> impl Iterator<Item = U16Vec2> + '_ {
self.changed_indices.drain()
}
/// Clears all tracked changes without returning them.
///
/// The buffer retains its capacity for reuse.
pub fn clear(&mut self) {
self.changed_indices.clear();
}
/// Returns true if any changes have been recorded.
#[inline]
pub fn has_changes(&self) -> bool {
!self.changed_indices.is_empty()
}
/// Returns the number of changes recorded.
///
/// Note: This may include duplicate indices if the same tile was changed multiple times.
#[inline]
pub fn len(&self) -> usize {
self.changed_indices.len()
}
/// Returns true if no changes have been recorded.
#[inline]
pub fn is_empty(&self) -> bool {
self.changed_indices.is_empty()
}
/// Returns the current capacity of the internal buffer.
#[inline]
pub fn capacity(&self) -> usize {
self.changed_indices.capacity()
}
}
impl Default for ChangeBuffer {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use assert2::assert;
use glam::U16Vec2;
use crate::game::world::changes::ChangeBuffer;
#[test]
fn test_len_empty() {
let buffer = ChangeBuffer::new();
assert!(buffer.len() == 0);
}
#[test]
fn test_len_single_item() {
let mut buffer = ChangeBuffer::new();
buffer.push(U16Vec2::new(10, 20));
assert!(buffer.len() == 1);
}
#[test]
fn test_len_multiple_items() {
let mut buffer = ChangeBuffer::new();
buffer.push(U16Vec2::new(10, 20));
buffer.push(U16Vec2::new(30, 40));
buffer.push(U16Vec2::new(50, 60));
assert!(buffer.len() == 3);
}
#[test]
fn test_len_with_duplicates() {
let mut buffer = ChangeBuffer::new();
let pos = U16Vec2::new(10, 20);
buffer.push(pos);
buffer.push(pos);
buffer.push(pos);
// HashSet deduplicates, so len should be 1
assert!(buffer.len() == 1);
}
#[test]
fn test_len_after_drain() {
let mut buffer = ChangeBuffer::new();
buffer.push(U16Vec2::new(10, 20));
buffer.push(U16Vec2::new(30, 40));
// Drain all items
let _drained: Vec<_> = buffer.drain().collect();
assert!(buffer.len() == 0);
}
#[test]
fn test_is_empty_new_buffer() {
let buffer = ChangeBuffer::new();
assert!(buffer.is_empty());
}
#[test]
fn test_is_empty_after_push() {
let mut buffer = ChangeBuffer::new();
buffer.push(U16Vec2::new(10, 20));
assert!(!buffer.is_empty());
}
#[test]
fn test_is_empty_after_clear() {
let mut buffer = ChangeBuffer::new();
buffer.push(U16Vec2::new(10, 20));
buffer.push(U16Vec2::new(30, 40));
buffer.clear();
assert!(buffer.is_empty());
}
#[test]
fn test_is_empty_after_drain() {
let mut buffer = ChangeBuffer::new();
buffer.push(U16Vec2::new(10, 20));
let _drained: Vec<_> = buffer.drain().collect();
assert!(buffer.is_empty());
}
#[test]
fn test_with_capacity_allocates_space() {
let buffer = ChangeBuffer::with_capacity(100);
// Capacity should be at least what we requested
assert!(buffer.capacity() >= 100, "Expected capacity >= 100, but got {}", buffer.capacity());
}
#[test]
fn test_with_capacity_differs_from_new() {
let buffer_new = ChangeBuffer::new();
let buffer_with_cap = ChangeBuffer::with_capacity(100);
// with_capacity should allocate more space than new
assert!(buffer_with_cap.capacity() > buffer_new.capacity(), "with_capacity(100) should have larger capacity than new(), but got {} vs {}", buffer_with_cap.capacity(), buffer_new.capacity());
}
#[test]
fn test_with_capacity_differs_from_default() {
let buffer_default = ChangeBuffer::default();
let buffer_with_cap = ChangeBuffer::with_capacity(100);
// with_capacity should allocate more space than default
assert!(buffer_with_cap.capacity() > buffer_default.capacity(), "with_capacity(100) should have larger capacity than default(), but got {} vs {}", buffer_with_cap.capacity(), buffer_default.capacity());
}
#[test]
fn test_capacity_persists_after_clear() {
let mut buffer = ChangeBuffer::with_capacity(100);
let initial_capacity = buffer.capacity();
buffer.push(U16Vec2::new(10, 20));
buffer.clear();
// Capacity should remain after clear
assert!(buffer.capacity() == initial_capacity, "Capacity should persist after clear, but changed from {} to {}", initial_capacity, buffer.capacity());
}
#[test]
fn test_capacity_persists_after_drain() {
let mut buffer = ChangeBuffer::with_capacity(100);
let initial_capacity = buffer.capacity();
buffer.push(U16Vec2::new(10, 20));
let _drained: Vec<_> = buffer.drain().collect();
// Capacity should remain after drain
assert!(buffer.capacity() == initial_capacity, "Capacity should persist after drain, but changed from {} to {}", initial_capacity, buffer.capacity());
}
#[test]
fn test_len_is_empty_consistency() {
let mut buffer = ChangeBuffer::new();
// Empty: len == 0 and is_empty() == true
assert!(buffer.len() == 0);
assert!(buffer.is_empty());
// Non-empty: len > 0 and is_empty() == false
buffer.push(U16Vec2::new(10, 20));
assert!(buffer.len() > 0);
assert!(!buffer.is_empty());
// Add more items
buffer.push(U16Vec2::new(30, 40));
assert!(buffer.len() == 2);
assert!(!buffer.is_empty());
}
}

View File

@@ -0,0 +1,73 @@
use std::collections::HashSet;
use bevy_ecs::prelude::*;
use glam::U16Vec2;
use crate::game::core::utils::neighbors;
use crate::game::terrain::TerrainData;
/// Resource containing precomputed coastal tile positions
///
/// A coastal tile is defined as a land tile (not water) that is adjacent
/// to at least one water tile in 4-directional connectivity.
///
/// This is computed once during game initialization and never changes,
/// providing O(1) lookups for systems that need to check if a tile is coastal.
#[derive(Resource)]
pub struct CoastalTiles {
tiles: HashSet<U16Vec2>,
}
impl CoastalTiles {
/// Compute all coastal tile positions from terrain data
///
/// This scans the entire map once to find all land tiles adjacent to water.
/// The result is cached in a HashSet for fast lookups.
pub fn compute(terrain: &TerrainData, size: U16Vec2) -> Self {
let mut coastal_tiles = HashSet::new();
let width = size.x as usize;
let height = size.y as usize;
for y in 0..height {
for x in 0..width {
let tile_pos = U16Vec2::new(x as u16, y as u16);
// Skip water tiles
if terrain.is_navigable(tile_pos) {
continue;
}
// Check if any neighbor is water using the neighbors utility
if neighbors(tile_pos, size).any(|neighbor| terrain.is_navigable(neighbor)) {
coastal_tiles.insert(tile_pos);
}
}
}
Self { tiles: coastal_tiles }
}
/// Check if a tile is coastal
#[inline]
pub fn contains(&self, tile: U16Vec2) -> bool {
self.tiles.contains(&tile)
}
/// Get a reference to the set of all coastal tiles
#[inline]
pub fn tiles(&self) -> &HashSet<U16Vec2> {
&self.tiles
}
/// Get the number of coastal tiles
#[inline]
pub fn len(&self) -> usize {
self.tiles.len()
}
/// Check if there are no coastal tiles
#[inline]
pub fn is_empty(&self) -> bool {
self.tiles.is_empty()
}
}

View File

@@ -0,0 +1,200 @@
use bevy_ecs::prelude::*;
use glam::U16Vec2;
use super::changes::ChangeBuffer;
use super::tilemap::TileMap;
use super::{NationId, TileOwnership};
use crate::game::utils::neighbors;
/// Manages territory ownership for all tiles
#[derive(Resource)]
pub struct TerritoryManager {
tile_owners: TileMap<TileOwnership>,
changes: ChangeBuffer,
/// Cached u16 representation for efficient serialization to frontend
u16_cache: Vec<u16>,
cache_dirty: bool,
}
impl TerritoryManager {
/// Creates a new territory manager
pub fn new(map_size: U16Vec2) -> Self {
let size = (map_size.x as usize) * (map_size.y as usize);
Self { tile_owners: TileMap::with_default(map_size, TileOwnership::Unclaimed), changes: ChangeBuffer::with_capacity(size / 100), u16_cache: vec![0; size], cache_dirty: true }
}
/// Resets the territory manager
pub fn reset(&mut self, map_size: U16Vec2, _conquerable_tiles: &[bool]) {
self.tile_owners = TileMap::with_default(map_size, TileOwnership::Unclaimed);
self.changes.clear();
let size = (map_size.x as usize) * (map_size.y as usize);
self.u16_cache.resize(size, 0);
self.cache_dirty = true;
}
/// Checks if a tile is a border tile of the territory of its owner
/// A tile is a border tile if it is adjacent to a tile that is not owned by the same player
pub fn is_border(&self, tile: U16Vec2) -> bool {
let owner = self.tile_owners[tile];
// Border if on map edge
if tile.x == 0 || tile.x == self.tile_owners.width() - 1 || tile.y == 0 || tile.y == self.tile_owners.height() - 1 {
return true;
}
// Border if any neighbor has different owner
for neighbor_pos in self.tile_owners.neighbors(tile) {
if self.tile_owners[neighbor_pos] != owner {
return true;
}
}
false
}
/// Checks if a tile has an owner
pub fn has_owner(&self, tile: U16Vec2) -> bool {
self.tile_owners[tile].is_owned()
}
/// Checks if a tile is owned by a specific nation
pub fn is_owner(&self, tile: U16Vec2, owner: NationId) -> bool {
self.tile_owners[tile].is_owned_by(owner)
}
/// Gets the nation ID of the tile owner, if any
pub fn get_nation_id(&self, tile: U16Vec2) -> Option<NationId> {
self.tile_owners[tile].nation_id()
}
/// Gets the ownership enum for a tile
pub fn get_ownership(&self, tile: U16Vec2) -> TileOwnership {
self.tile_owners[tile]
}
/// Conquers a tile for a nation
/// Returns the previous owner, if any
pub fn conquer(&mut self, tile: U16Vec2, owner: NationId) -> Option<NationId> {
let previous_owner = self.tile_owners[tile];
let new_ownership = TileOwnership::Owned(owner);
if previous_owner != new_ownership {
self.tile_owners[tile] = new_ownership;
self.changes.push(tile);
self.cache_dirty = true;
}
previous_owner.nation_id()
}
/// Clears a tile (removes ownership)
pub fn clear(&mut self, tile: U16Vec2) -> Option<NationId> {
let ownership = self.tile_owners[tile];
if ownership.is_owned() {
self.tile_owners[tile] = TileOwnership::Unclaimed;
self.changes.push(tile);
self.cache_dirty = true;
ownership.nation_id()
} else {
None
}
}
/// Get the size of the map as U16Vec2
#[inline]
pub fn size(&self) -> U16Vec2 {
self.tile_owners.size()
}
/// Get width of the map
#[inline]
pub fn width(&self) -> u16 {
self.tile_owners.width()
}
/// Get height of the map
#[inline]
pub fn height(&self) -> u16 {
self.tile_owners.height()
}
/// Returns a reference to the underlying tile ownership data as a slice of enums
#[inline]
pub fn as_slice(&self) -> &[TileOwnership] {
self.tile_owners.as_slice()
}
/// Returns the tile ownership data as u16 values for frontend serialization
/// This is cached and only recomputed when ownership changes
pub fn as_u16_slice(&mut self) -> &[u16] {
if self.cache_dirty {
let tile_count = self.tile_owners.len();
let _guard = tracing::trace_span!("rebuild_u16_cache", tile_count).entered();
for (i, ownership) in self.tile_owners.as_slice().iter().enumerate() {
self.u16_cache[i] = (*ownership).into();
}
self.cache_dirty = false;
}
&self.u16_cache
}
/// Returns the number of tiles in the map
#[inline]
pub fn len(&self) -> usize {
self.tile_owners.len()
}
/// Returns true if the map has no tiles
#[inline]
pub fn is_empty(&self) -> bool {
self.tile_owners.len() == 0
}
/// Returns an iterator over changed tile positions without consuming them
/// Use this to read changes without clearing the buffer
#[inline]
pub fn iter_changes(&self) -> impl Iterator<Item = U16Vec2> + '_ {
self.changes.iter()
}
/// Drains all changed tile positions, returning an iterator and clearing the change buffer
#[inline]
pub fn drain_changes(&mut self) -> impl Iterator<Item = U16Vec2> + '_ {
self.changes.drain()
}
/// Returns true if any territory changes have been recorded since last drain
#[inline]
pub fn has_changes(&self) -> bool {
self.changes.has_changes()
}
/// Clears all tracked changes without returning them
#[inline]
pub fn clear_changes(&mut self) {
self.changes.clear()
}
/// Calls a closure for each neighbor using tile indices (legacy compatibility)
#[inline]
pub fn on_neighbor_indices<F>(&self, index: u32, closure: F)
where
F: FnMut(u32),
{
self.tile_owners.on_neighbor_indices(index, closure)
}
/// Checks if any neighbor has a different owner than the specified owner
pub fn any_neighbor_has_different_owner(&self, tile: U16Vec2, owner: NationId) -> bool {
let owner_enum = TileOwnership::Owned(owner);
neighbors(tile, self.size()).any(|neighbor| self.tile_owners[neighbor] != owner_enum)
}
/// Converts position to flat u32 index for JavaScript/IPC boundary
#[inline]
pub fn pos_to_index<P: Into<U16Vec2>>(&self, pos: P) -> u32 {
self.tile_owners.pos_to_index(pos)
}
}

View File

@@ -0,0 +1,17 @@
//! World and territory management module
//!
//! This module contains all spatial data structures and territory management.
pub mod changes;
pub mod coastal;
pub mod manager;
pub mod nation_id;
pub mod ownership;
pub mod tilemap;
pub use changes::*;
pub use coastal::*;
pub use manager::*;
pub use nation_id::*;
pub use ownership::*;
pub use tilemap::*;

View File

@@ -0,0 +1,100 @@
use bevy_ecs::prelude::Component;
use rkyv::{Archive, Deserialize as RkyvDeserialize, Serialize as RkyvSerialize};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
/// Unique identifier for a nation/player in the game.
///
/// This is a validated newtype wrapper around u16 that prevents invalid nation IDs
/// from being constructed. The maximum valid nation ID is 65534, as 65535 is reserved
/// for encoding unclaimed tiles in the ownership serialization format.
#[derive(Component, Debug, Copy, Clone, Eq, PartialEq, Hash, Ord, PartialOrd, Archive, RkyvSerialize, RkyvDeserialize)]
#[rkyv(derive(Debug, Hash, PartialEq, Eq))]
pub struct NationId(u16);
impl NationId {
/// Maximum valid nation ID (65534). Value 65535 is reserved for unclaimed tiles.
pub const MAX: u16 = u16::MAX - 1;
/// Constant for nation ID 0 (commonly used for default/first player)
pub const ZERO: Self = Self(0);
/// Creates a new NationId if the value is valid (<= MAX).
///
/// Returns None if id > MAX (i.e., id == 65535).
#[inline]
pub fn new(id: u16) -> Option<Self> {
(id <= Self::MAX).then_some(Self(id))
}
/// Creates a NationId without validation.
///
/// # Safety
/// Caller must ensure id <= MAX. This is primarily for const contexts.
#[inline]
pub const fn new_unchecked(id: u16) -> Self {
Self(id)
}
/// Extracts the inner u16 value.
#[inline]
pub fn get(self) -> u16 {
self.0
}
/// Converts to little-endian bytes.
#[inline]
pub fn to_le_bytes(self) -> [u8; 2] {
self.0.to_le_bytes()
}
}
impl TryFrom<u16> for NationId {
type Error = InvalidNationId;
fn try_from(value: u16) -> Result<Self, Self::Error> {
Self::new(value).ok_or(InvalidNationId(value))
}
}
impl From<NationId> for u16 {
fn from(id: NationId) -> Self {
id.0
}
}
impl std::fmt::Display for NationId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
/// Error type for invalid nation ID values
#[derive(Debug, Clone, Copy)]
pub struct InvalidNationId(pub u16);
impl std::fmt::Display for InvalidNationId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Invalid nation ID: {} (must be <= {})", self.0, NationId::MAX)
}
}
impl std::error::Error for InvalidNationId {}
impl Serialize for NationId {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
Serialize::serialize(&self.0, serializer)
}
}
impl<'de> Deserialize<'de> for NationId {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = u16::deserialize(deserializer)?;
NationId::new(value).ok_or_else(|| serde::de::Error::custom(format!("Invalid nation ID: {} (must be <= {})", value, NationId::MAX)))
}
}

View File

@@ -0,0 +1,64 @@
//! Tile ownership representation
use rkyv::{Archive, Deserialize as RkyvDeserialize, Serialize as RkyvSerialize};
use serde::{Deserialize, Serialize};
use super::NationId;
/// Represents the ownership state of a single tile.
///
/// Terrain type (water, land, mountain, etc.) is stored separately in TerrainData.
/// This enum only tracks whether a tile is owned by a nation or unclaimed.
#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize, Deserialize, Default, Archive, RkyvSerialize, RkyvDeserialize)]
#[rkyv(derive(Debug))]
pub enum TileOwnership {
/// Owned by a specific nation
Owned(NationId),
/// Unclaimed but potentially conquerable land
#[default]
Unclaimed,
}
impl TileOwnership {
/// Check if this tile is owned by any nation
#[inline]
pub fn is_owned(self) -> bool {
matches!(self, TileOwnership::Owned(_))
}
/// Check if this tile is unclaimed land
#[inline]
pub fn is_unclaimed(self) -> bool {
matches!(self, TileOwnership::Unclaimed)
}
/// Get the nation ID if this tile is owned, otherwise None
#[inline]
pub fn nation_id(self) -> Option<NationId> {
match self {
TileOwnership::Owned(id) => Some(id),
TileOwnership::Unclaimed => None,
}
}
/// Check if this tile is owned by a specific nation
#[inline]
pub fn is_owned_by(self, nation_id: NationId) -> bool {
matches!(self, TileOwnership::Owned(id) if id == nation_id)
}
}
impl From<u16> for TileOwnership {
fn from(value: u16) -> Self {
if value == 65535 { TileOwnership::Unclaimed } else { NationId::new(value).map(TileOwnership::Owned).unwrap_or(TileOwnership::Unclaimed) }
}
}
impl From<TileOwnership> for u16 {
fn from(ownership: TileOwnership) -> Self {
match ownership {
TileOwnership::Owned(id) => id.get(),
TileOwnership::Unclaimed => 65535,
}
}
}

View File

@@ -0,0 +1,268 @@
use glam::{U16Vec2, UVec2};
use std::ops::{Index, IndexMut};
/// A 2D grid-based map structure optimized for tile-based games.
///
/// Provides efficient access to tiles using 2D coordinates (U16Vec2) while maintaining
/// cache-friendly contiguous memory layout. Supports generic tile types that implement Copy.
///
/// Uses `u16` for dimensions, supporting maps up to 65,535x65,535 tiles.
///
/// # Type Parameters
/// * `T` - The tile value type. Must implement `Copy` for efficient access.
///
/// # Examples
/// ```
/// use glam::U16Vec2;
/// use borders_core::game::TileMap;
///
/// let mut map = TileMap::<u8>::new(U16Vec2::new(10, 10));
/// map[U16Vec2::new(5, 5)] = 42;
/// assert_eq!(map[U16Vec2::new(5, 5)], 42);
/// ```
#[derive(Clone, Debug)]
pub struct TileMap<T: Copy> {
tiles: Box<[T]>,
size: U16Vec2,
}
impl<T: Copy> TileMap<T> {
/// Creates a new TileMap with the specified dimensions and default value.
///
/// # Arguments
/// * `size` - The size of the map (width, height) in tiles
/// * `default` - The default value to initialize all tiles with
pub fn with_default(size: U16Vec2, default: T) -> Self {
let capacity = (size.x as usize) * (size.y as usize);
let tiles = vec![default; capacity].into_boxed_slice();
Self { tiles, size }
}
/// Creates a TileMap from an existing vector of tile data.
///
/// # Arguments
/// * `size` - The size of the map (width, height) in tiles
/// * `data` - Vector containing tile data in row-major order
///
/// # Panics
/// Panics if `data.len() != size.x * size.y`
pub fn from_vec(size: U16Vec2, data: Vec<T>) -> Self {
assert_eq!(data.len(), (size.x as usize) * (size.y as usize), "Data length must match size.x * size.y");
Self { tiles: data.into_boxed_slice(), size }
}
/// Converts the position to a flat array index.
///
/// Accepts both U16Vec2 and UVec2 for backward compatibility.
///
/// # Safety
/// Debug builds will assert that the position is in bounds.
/// Release builds skip the check for performance.
#[inline]
pub fn pos_to_index<P: Into<U16Vec2>>(&self, pos: P) -> u32 {
let pos = pos.into();
debug_assert!(pos.x < self.size.x && pos.y < self.size.y);
(pos.y as u32) * (self.size.x as u32) + (pos.x as u32)
}
/// Converts a flat array index to a 2D position.
#[inline]
pub fn index_to_pos(&self, index: u32) -> U16Vec2 {
debug_assert!(index < self.tiles.len() as u32);
let width = self.size.x as u32;
U16Vec2::new((index % width) as u16, (index / width) as u16)
}
/// Checks if a position is within the map bounds.
///
/// Accepts both U16Vec2 and UVec2 for backward compatibility.
#[inline]
pub fn in_bounds<P: Into<U16Vec2>>(&self, pos: P) -> bool {
let pos = pos.into();
pos.x < self.size.x && pos.y < self.size.y
}
/// Gets the tile value at the specified position.
///
/// Returns `None` if the position is out of bounds.
pub fn get<P: Into<U16Vec2>>(&self, pos: P) -> Option<T> {
let pos = pos.into();
if self.in_bounds(pos) { Some(self.tiles[self.pos_to_index(pos) as usize]) } else { None }
}
/// Sets the tile value at the specified position.
///
/// Returns `true` if the position was in bounds and the value was set,
/// `false` otherwise.
pub fn set<P: Into<U16Vec2>>(&mut self, pos: P, tile: T) -> bool {
let pos = pos.into();
if self.in_bounds(pos) {
let idx = self.pos_to_index(pos) as usize;
self.tiles[idx] = tile;
true
} else {
false
}
}
/// Returns the size of the map as U16Vec2.
#[inline]
pub fn size(&self) -> U16Vec2 {
self.size
}
/// Returns the width of the map.
#[inline]
pub fn width(&self) -> u16 {
self.size.x
}
/// Returns the height of the map.
#[inline]
pub fn height(&self) -> u16 {
self.size.y
}
/// Returns the total number of tiles in the map.
#[inline]
pub fn len(&self) -> usize {
self.tiles.len()
}
/// Returns `true` if the map contains no tiles.
#[inline]
pub fn is_empty(&self) -> bool {
self.tiles.is_empty()
}
/// Returns an iterator over all valid cardinal neighbors of a position.
///
/// Yields positions for up, down, left, and right neighbors that are within bounds.
pub fn neighbors<P: Into<U16Vec2>>(&self, pos: P) -> impl Iterator<Item = U16Vec2> {
crate::game::utils::neighbors(pos.into(), self.size)
}
/// Calls a closure for each neighbor using tile indices instead of positions.
///
/// This is useful when working with systems that still use raw indices.
pub fn on_neighbor_indices<F>(&self, index: u32, mut closure: F)
where
F: FnMut(u32),
{
let width = self.size.x as u32;
let height = self.size.y as u32;
let x = index % width;
let y = index / width;
if x > 0 {
closure(index - 1);
}
if x < width - 1 {
closure(index + 1);
}
if y > 0 {
closure(index - width);
}
if y < height - 1 {
closure(index + width);
}
}
/// Returns an iterator over all positions and their tile values.
pub fn iter(&self) -> impl Iterator<Item = (U16Vec2, T)> + '_ {
(0..self.size.y).flat_map(move |y| {
(0..self.size.x).map(move |x| {
let pos = U16Vec2::new(x, y);
(pos, self[pos])
})
})
}
/// Returns an iterator over just the tile values.
pub fn iter_values(&self) -> impl Iterator<Item = T> + '_ {
self.tiles.iter().copied()
}
/// Returns an iterator over all positions in the map.
pub fn positions(&self) -> impl Iterator<Item = U16Vec2> + '_ {
(0..self.size.y).flat_map(move |y| (0..self.size.x).map(move |x| U16Vec2::new(x, y)))
}
/// Returns an iterator over tile indices, positions, and values.
pub fn enumerate(&self) -> impl Iterator<Item = (usize, U16Vec2, T)> + '_ {
self.tiles.iter().enumerate().map(move |(idx, &value)| {
let pos = self.index_to_pos(idx as u32);
(idx, pos, value)
})
}
/// Returns a reference to the underlying tile data as a slice.
pub fn as_slice(&self) -> &[T] {
&self.tiles
}
/// Returns a mutable reference to the underlying tile data as a slice.
pub fn as_mut_slice(&mut self) -> &mut [T] {
&mut self.tiles
}
}
impl<T: Copy + Default> TileMap<T> {
/// Creates a new TileMap with the specified dimensions, using T::default() for initialization.
pub fn new(size: U16Vec2) -> Self {
Self::with_default(size, T::default())
}
}
impl<T: Copy> Index<U16Vec2> for TileMap<T> {
type Output = T;
#[inline]
fn index(&self, pos: U16Vec2) -> &Self::Output {
&self.tiles[self.pos_to_index(pos) as usize]
}
}
impl<T: Copy> IndexMut<U16Vec2> for TileMap<T> {
#[inline]
fn index_mut(&mut self, pos: U16Vec2) -> &mut Self::Output {
let idx = self.pos_to_index(pos) as usize;
&mut self.tiles[idx]
}
}
// Backward compatibility: allow indexing with UVec2
impl<T: Copy> Index<UVec2> for TileMap<T> {
type Output = T;
#[inline]
fn index(&self, pos: UVec2) -> &Self::Output {
let pos16 = U16Vec2::new(pos.x as u16, pos.y as u16);
&self.tiles[self.pos_to_index(pos16) as usize]
}
}
impl<T: Copy> IndexMut<UVec2> for TileMap<T> {
#[inline]
fn index_mut(&mut self, pos: UVec2) -> &mut Self::Output {
let pos16 = U16Vec2::new(pos.x as u16, pos.y as u16);
let idx = self.pos_to_index(pos16) as usize;
&mut self.tiles[idx]
}
}
impl<T: Copy> Index<usize> for TileMap<T> {
type Output = T;
#[inline]
fn index(&self, index: usize) -> &Self::Output {
&self.tiles[index]
}
}
impl<T: Copy> IndexMut<usize> for TileMap<T> {
#[inline]
fn index_mut(&mut self, index: usize) -> &mut Self::Output {
&mut self.tiles[index]
}
}

View File

@@ -0,0 +1,43 @@
pub mod build_info;
pub mod game;
pub mod networking;
pub mod telemetry;
pub mod time;
pub mod ui;
use std::future::Future;
/// Spawn an async task on the appropriate runtime for the platform.
///
/// On native targets, uses tokio::spawn for multi-threaded execution.
/// On WASM targets, uses wasm_bindgen_futures::spawn_local for browser integration.
#[cfg(not(target_arch = "wasm32"))]
pub fn spawn_task<F>(future: F)
where
F: Future<Output = ()> + Send + 'static,
{
tokio::spawn(future);
}
#[cfg(target_arch = "wasm32")]
pub fn spawn_task<F>(future: F)
where
F: Future<Output = ()> + 'static,
{
wasm_bindgen_futures::spawn_local(future);
}
#[cfg(not(target_arch = "wasm32"))]
pub mod dns;
pub mod prelude {
//! Prelude module for convenient imports in tests and examples
pub use crate::game::*;
pub use crate::networking::*;
pub use crate::spawn_task;
pub use crate::time::*;
// Re-export common external dependencies
pub use bevy_ecs::prelude::*;
pub use glam::*;
}

View File

@@ -0,0 +1,270 @@
//! Unified connection abstraction for local and remote networking
//!
//! This module provides a generic `Connection` interface that abstracts away
//! the transport mechanism (local channels vs network). Game systems interact
//! with a single `Connection` resource regardless of whether the game is
//! single-player or multiplayer.
use std::collections::{HashMap, VecDeque};
use bevy_ecs::prelude::*;
use flume::{Receiver, Sender};
use tracing::{debug, warn};
use crate::game::NationId;
use crate::networking::{Intent, Turn, protocol::NetMessage};
/// Intent with tracking ID for confirmation
#[derive(Debug, Clone)]
pub struct TrackedIntent {
pub id: u64,
pub intent: Intent,
}
/// Resource for receiving tracked intents (local mode)
#[derive(Resource)]
pub struct TrackedIntentReceiver {
pub rx: Receiver<TrackedIntent>,
}
/// Backend trait for connection implementations
///
/// This trait abstracts the transport mechanism, allowing both local
/// and remote connections to be used interchangeably.
pub trait ConnectionBackend: Send + Sync {
/// Send an intent with tracking ID
fn send_intent(&self, id: u64, intent: Intent);
/// Get the player ID for this connection
fn player_id(&self) -> Option<NationId>;
/// Try to receive a turn (non-blocking)
/// Returns None if no turn is available
fn try_recv_turn(&self) -> Option<Turn>;
}
/// Local backend implementation (single-player)
pub struct LocalBackend {
intent_tx: Sender<TrackedIntent>,
turn_rx: Receiver<Turn>,
player_id: NationId,
}
impl LocalBackend {
pub fn new(intent_tx: Sender<TrackedIntent>, turn_rx: Receiver<Turn>, player_id: NationId) -> Self {
Self { intent_tx, turn_rx, player_id }
}
}
impl ConnectionBackend for LocalBackend {
fn send_intent(&self, id: u64, intent: Intent) {
let tracked = TrackedIntent { id, intent };
if let Err(e) = self.intent_tx.try_send(tracked) {
warn!("Failed to send tracked intent: {:?}", e);
}
}
fn player_id(&self) -> Option<NationId> {
Some(self.player_id)
}
fn try_recv_turn(&self) -> Option<Turn> {
self.turn_rx.try_recv().ok()
}
}
/// Remote backend implementation (multiplayer)
pub struct RemoteBackend {
intent_tx: Sender<NetMessage>,
net_message_rx: Receiver<NetMessage>,
player_id: std::sync::Arc<std::sync::RwLock<Option<NationId>>>,
}
impl RemoteBackend {
pub fn new(intent_tx: Sender<NetMessage>, net_message_rx: Receiver<NetMessage>) -> Self {
Self { intent_tx, net_message_rx, player_id: std::sync::Arc::new(std::sync::RwLock::new(None)) }
}
/// Try to receive a NetMessage (for processing in receive system)
pub fn try_recv_message(&self) -> Option<NetMessage> {
self.net_message_rx.try_recv().ok()
}
/// Set player ID when ServerConfig is received
pub fn set_player_id(&self, player_id: NationId) {
if let Ok(mut guard) = self.player_id.write() {
*guard = Some(player_id);
}
}
}
impl ConnectionBackend for RemoteBackend {
fn send_intent(&self, id: u64, intent: Intent) {
let msg = NetMessage::Intent { id, intent };
if let Err(e) = self.intent_tx.try_send(msg) {
warn!("Failed to send net intent: {:?}", e);
}
}
fn player_id(&self) -> Option<NationId> {
self.player_id.read().ok().and_then(|guard| *guard)
}
fn try_recv_turn(&self) -> Option<Turn> {
// For remote, turn reception is handled by receive_messages_system
// which processes NetMessage protocol
None
}
}
/// Pending intent tracking info
pub struct PendingIntent {
pub intent: Intent,
pub sent_turn: u64,
}
/// Intent tracker for drop detection
pub struct IntentTracker {
next_id: u64,
pending: HashMap<u64, PendingIntent>,
pending_ordered: VecDeque<u64>,
turn_buffer_size: usize,
}
impl IntentTracker {
pub fn new() -> Self {
Self {
next_id: 1,
pending: HashMap::new(),
pending_ordered: VecDeque::new(),
turn_buffer_size: 5, // 500ms buffer at 100ms/turn
}
}
pub fn next_id(&mut self) -> u64 {
let id = self.next_id;
self.next_id += 1;
id
}
pub fn track_sent(&mut self, intent_id: u64, intent: Intent, current_turn: u64) {
self.pending.insert(intent_id, PendingIntent { intent, sent_turn: current_turn });
self.pending_ordered.push_back(intent_id);
}
pub fn confirm_intent(&mut self, intent_id: u64) -> bool {
self.pending.remove(&intent_id).is_some()
}
pub fn expire_old(&mut self, current_turn: u64) -> Vec<PendingIntent> {
let mut expired = Vec::new();
while let Some(&id) = self.pending_ordered.front() {
if let Some(pending) = self.pending.get(&id) {
// Check if still within buffer window
if current_turn.saturating_sub(pending.sent_turn) <= self.turn_buffer_size as u64 {
break;
}
// Expired - remove and warn
expired.push(self.pending.remove(&id).unwrap());
}
// Either expired or already confirmed - remove from queue
self.pending_ordered.pop_front();
}
expired
}
pub fn turn_buffer_size(&self) -> usize {
self.turn_buffer_size
}
}
impl Default for IntentTracker {
fn default() -> Self {
Self::new()
}
}
/// Unified connection resource
///
/// This resource abstracts away local vs remote transport mechanisms.
/// Game systems interact with this single resource regardless of network mode.
#[derive(Resource)]
pub struct Connection {
backend: Box<dyn ConnectionBackend>,
tracker: IntentTracker,
}
impl Connection {
pub fn new(backend: Box<dyn ConnectionBackend>) -> Self {
Self { backend, tracker: IntentTracker::new() }
}
pub fn new_local(backend: LocalBackend) -> Self {
Self::new(Box::new(backend))
}
pub fn new_remote(backend: RemoteBackend) -> Self {
Self::new(Box::new(backend))
}
/// Send an intent with automatic tracking
pub fn send_intent(&mut self, intent: Intent, current_turn: u64) {
let intent_id = self.tracker.next_id();
self.tracker.track_sent(intent_id, intent.clone(), current_turn);
self.backend.send_intent(intent_id, intent);
}
/// Confirm receipt of an intent by ID
pub fn confirm_intent(&mut self, intent_id: u64) -> bool {
let confirmed = self.tracker.confirm_intent(intent_id);
if confirmed {
debug!("Confirmed intent {}", intent_id);
}
confirmed
}
/// Check for expired intents that weren't received
pub fn check_expired(&mut self, current_turn: u64) -> Vec<PendingIntent> {
self.tracker.expire_old(current_turn)
}
/// Get player ID for this connection
pub fn player_id(&self) -> Option<NationId> {
self.backend.player_id()
}
/// Try to receive a turn (works for local backend)
pub fn try_recv_turn(&self) -> Option<Turn> {
self.backend.try_recv_turn()
}
/// Get the turn buffer size for warning messages
pub fn turn_buffer_size(&self) -> usize {
self.tracker.turn_buffer_size()
}
/// Get mutable access to backend (for downcasting)
pub fn backend_mut(&mut self) -> &mut dyn ConnectionBackend {
&mut *self.backend
}
}
/// Helper trait for downcasting backends
pub trait AsRemoteBackend {
fn as_remote(&self) -> Option<&RemoteBackend>;
fn as_remote_mut(&mut self) -> Option<&mut RemoteBackend>;
}
impl AsRemoteBackend for dyn ConnectionBackend {
fn as_remote(&self) -> Option<&RemoteBackend> {
// This is a simplified approach - in production you'd use Any trait
None
}
fn as_remote_mut(&mut self) -> Option<&mut RemoteBackend> {
// This is a simplified approach - in production you'd use Any trait
None
}
}

View File

@@ -0,0 +1,5 @@
mod connection;
mod systems;
pub use connection::*;
pub use systems::*;

View File

@@ -0,0 +1,39 @@
use super::connection::Connection;
use crate::{
game::{SpawnManager, SpawnPoint, TerrainData, TerritoryManager, systems::turn::CurrentTurn},
networking::{IntentEvent, SpawnConfigEvent},
};
use bevy_ecs::prelude::*;
use tracing::{debug, info};
/// Resource for receiving tracked intents from the client
pub type IntentReceiver = super::connection::TrackedIntentReceiver;
/// Unified intent sending system that works for both local and remote
///
/// Uses the generic `Connection` resource to abstract transport mechanism.
pub fn send_intent_system(mut intent_events: MessageReader<IntentEvent>, mut connection: ResMut<Connection>, current_turn: Option<Res<CurrentTurn>>) {
let turn_number = current_turn.as_ref().map(|ct| ct.turn.turn_number).unwrap_or(0);
for event in intent_events.read() {
debug!("Sending intent: {:?}", event.0);
connection.send_intent(event.0.clone(), turn_number);
}
}
/// System to handle spawn configuration updates from server
/// Updates local SpawnManager with remote player spawn positions
pub fn handle_spawn_config_system(mut events: MessageReader<SpawnConfigEvent>, mut spawns: If<ResMut<SpawnManager>>, territory: Res<TerritoryManager>, terrain: Res<TerrainData>) {
for event in events.read() {
// Update player spawns from server
spawns.player_spawns.clear();
for (&player_id, &tile_index) in &event.0 {
spawns.player_spawns.push(SpawnPoint::new(player_id, tile_index));
}
// Recalculate bot spawns based on updated player positions
spawns.current_bot_spawns = crate::game::ai::bot::recalculate_spawns_with_players(spawns.initial_bot_spawns.clone(), &spawns.player_spawns, &territory, &terrain, spawns.rng_seed);
info!("Updated spawn manager with {} player spawns from server", spawns.player_spawns.len());
}
}

View File

@@ -0,0 +1,16 @@
//! Networking and multiplayer synchronization
//!
//! This module provides the core networking infrastructure for the game:
//! - Shared protocol and data structures
//! - Client-side connection and systems
//! - Server-side turn generation and coordination
// Public modules
pub mod client;
pub mod server;
// Flattened public modules
mod protocol;
mod types;
pub use protocol::*;
pub use types::*;

View File

@@ -0,0 +1,43 @@
//! Network protocol for multiplayer client-server communication
use rkyv::{Archive, Deserialize as RkyvDeserialize, Serialize as RkyvSerialize};
use crate::{game::NationId, networking::Intent};
use glam::U16Vec2;
use std::collections::HashMap;
/// Intent wrapper with source nation ID assigned by server
///
/// The server wraps all intents with the authenticated source nation ID
/// to prevent client spoofing. The intent_id is echoed back from the
/// client for round-trip tracking.
#[derive(Debug, Clone, Archive, RkyvSerialize, RkyvDeserialize)]
#[rkyv(derive(Debug))]
#[derive(serde::Serialize, serde::Deserialize)]
pub struct SourcedIntent {
/// Authenticated nation ID (assigned by server)
pub source: NationId,
/// Client-assigned ID for tracking (echoed back by server)
pub intent_id: u64,
/// The actual intent payload
pub intent: Intent,
}
/// Network message protocol for client-server communication
#[derive(Debug, Clone, Archive, RkyvSerialize, RkyvDeserialize)]
#[rkyv(derive(Debug))]
pub enum NetMessage {
/// Server assigns nation ID to client
ServerConfig { nation_id: NationId },
/// Client sends intent to server with tracking ID
Intent { id: u64, intent: Intent },
/// Server broadcasts turn to all clients with sourced intents
Turn { turn: u64, intents: Vec<SourcedIntent> },
/// Server broadcasts current spawn configuration during spawn phase
/// Maps nation_id -> tile_position for all nations who have chosen spawns
SpawnConfiguration { spawns: HashMap<NationId, U16Vec2> },
}
/// Shared constants across all binaries for deterministic behavior
pub const NETWORK_SEED: u64 = 0xC0FFEE;
pub const TICK_MS: u64 = 100;

View File

@@ -0,0 +1,157 @@
use crate::game::terrain::TerrainData;
use crate::game::{SpawnManager, TerritoryManager};
use crate::time::Time;
use bevy_ecs::prelude::*;
use tracing::warn;
use super::turn_generator::{SharedTurnGenerator, TurnOutput};
use crate::game::LocalPlayerContext;
use crate::networking::{Intent, ProcessTurnEvent, SourcedIntent, Turn};
use flume::{Receiver, Sender};
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
/// Resource for receiving tracked intents from the client
/// This has replaced the old IntentReceiver that used plain Intent
pub type IntentReceiver = crate::networking::client::TrackedIntentReceiver;
#[derive(Resource)]
pub struct TurnReceiver {
pub turn_rx: Receiver<Turn>,
}
/// Local turn server control handle
#[derive(Resource, Clone)]
pub struct LocalTurnServerHandle {
pub paused: Arc<AtomicBool>,
pub running: Arc<AtomicBool>,
}
impl LocalTurnServerHandle {
pub fn pause(&self) {
self.paused.store(true, Ordering::SeqCst);
}
pub fn resume(&self) {
self.paused.store(false, Ordering::SeqCst);
}
pub fn stop(&self) {
self.running.store(false, Ordering::SeqCst);
}
pub fn is_paused(&self) -> bool {
self.paused.load(Ordering::SeqCst)
}
pub fn is_running(&self) -> bool {
self.running.load(Ordering::SeqCst)
}
}
/// Resource wrapping the shared turn generator and output channel
#[derive(Resource)]
pub struct TurnGenerator {
generator: SharedTurnGenerator,
turn_tx: Sender<Turn>,
}
impl TurnGenerator {
pub fn new(turn_tx: Sender<Turn>) -> Self {
Self { generator: SharedTurnGenerator::new(), turn_tx }
}
/// Skip spawn phase and start the game immediately
/// This is useful for tests that don't need the spawn phase
pub fn start_game_immediately(&mut self) {
self.generator.skip_spawn_phase();
}
}
/// System to generate turns using Bevy's Update loop
#[allow(clippy::too_many_arguments)]
pub fn generate_turns_system(mut generator: If<ResMut<TurnGenerator>>, server_handle: If<Res<LocalTurnServerHandle>>, intent_receiver: If<Res<IntentReceiver>>, local_context: Option<Res<LocalPlayerContext>>, mut spawns: Option<ResMut<SpawnManager>>, time: Res<Time>, territory: Res<TerritoryManager>, terrain: Res<TerrainData>) {
let _guard = tracing::trace_span!("generate_turns").entered();
if !server_handle.is_running() {
return;
}
let is_paused = server_handle.paused.load(Ordering::SeqCst);
// Get nation ID for wrapping intents (local single-player)
let Some(local_context) = local_context else {
return;
};
let nation_id = local_context.id;
// During spawn phase (paused), process intents and update SpawnManager
if is_paused {
while let Ok(tracked_intent) = intent_receiver.rx.try_recv() {
// Wrap tracked intent with nation_id for local single-player
let sourced_intent = SourcedIntent { source: nation_id, intent_id: tracked_intent.id, intent: tracked_intent.intent.clone() };
let output = generator.generator.process_intent(sourced_intent.clone());
// Update SpawnManager for SetSpawn intents (two-pass spawn system)
if let Intent::SetSpawn { tile_index } = sourced_intent.intent
&& let Some(ref mut spawns) = spawns
{
spawns.update_player_spawn(sourced_intent.source, tile_index, &territory, &terrain);
}
// SpawnUpdate output is not used here - SpawnManager handles coordination
let _ = output;
}
// Tick the generator to check spawn timeout
let delta_ms = time.delta().as_secs_f64() * 1000.0;
let output = generator.generator.tick(delta_ms, vec![]);
// Handle initial Turn(0) that starts the game
if let TurnOutput::Turn(turn) = output
&& turn.turn_number == 0
{
if let Err(e) = generator.turn_tx.send(turn) {
warn!("Failed to send Turn(0): {}", e);
}
server_handle.resume();
}
return;
}
// Normal turn generation (after game has started)
if !generator.generator.game_started() {
return;
}
// Collect all pending intents and wrap them with nation_id
let mut sourced_intents = Vec::new();
while let Ok(tracked_intent) = intent_receiver.rx.try_recv() {
sourced_intents.push(SourcedIntent { source: nation_id, intent_id: tracked_intent.id, intent: tracked_intent.intent });
}
// Tick the generator with accumulated time and sourced intents
let delta_ms = time.delta().as_secs_f64() * 1000.0;
let output = generator.generator.tick(delta_ms, sourced_intents);
// Handle Turn output
match output {
TurnOutput::Turn(turn) => {
if let Err(e) = generator.turn_tx.send(turn) {
warn!("Failed to send turn: {}", e);
}
}
TurnOutput::None | TurnOutput::SpawnUpdate(_) => {}
}
}
/// System to poll for turns from the local server and emit ProcessTurnEvent
pub fn poll_turns_system(turn_receiver: If<Res<TurnReceiver>>, mut process_turn_writer: MessageWriter<ProcessTurnEvent>) {
let _guard = tracing::trace_span!("poll_turns").entered();
while let Ok(turn) = turn_receiver.turn_rx.try_recv() {
process_turn_writer.write(ProcessTurnEvent(turn));
}
}

View File

@@ -0,0 +1,11 @@
//! Server-side networking components
//!
//! This module contains server and local-mode server code:
//! - Turn generation and coordination
//! - Local server control (for single-player mode)
mod coordinator;
mod turn_generator;
pub use coordinator::*;
pub use turn_generator::*;

View File

@@ -0,0 +1,173 @@
use crate::game::NationId;
use crate::networking::{Intent, SourcedIntent, Turn};
use glam::U16Vec2;
use std::collections::HashMap;
use tracing::{debug, info, warn};
/// Spawn timeout duration (milliseconds)
const SPAWN_TIMEOUT_MS: f64 = 5000.0;
/// Output from the turn generator
#[derive(Debug, Clone)]
pub enum TurnOutput {
/// No output this tick
None,
/// Spawn configuration was updated
SpawnUpdate(HashMap<NationId, U16Vec2>),
/// Game turn (includes initial Turn 0)
Turn(Turn),
}
/// Shared turn generation logic for both local coordinator and relay server
pub struct SharedTurnGenerator {
turn_number: u64,
accumulated_time: f64, // milliseconds
buffered_intents: Vec<SourcedIntent>, // Buffer intents across frames
spawn_config: HashMap<NationId, U16Vec2>,
spawn_timeout_accumulated: Option<f64>, // milliseconds since first spawn
game_started: bool,
}
impl SharedTurnGenerator {
pub fn new() -> Self {
Self { turn_number: 0, accumulated_time: 0.0, buffered_intents: Vec::new(), spawn_config: HashMap::new(), spawn_timeout_accumulated: None, game_started: false }
}
/// Process a single sourced intent, returns output if spawn config changed
pub fn process_intent(&mut self, sourced_intent: SourcedIntent) -> TurnOutput {
match sourced_intent.intent {
Intent::SetSpawn { tile_index } => {
if self.game_started {
warn!("Received SetSpawn intent after game started - ignoring");
return TurnOutput::None;
}
let nation_id = sourced_intent.source;
debug!("Nation {} set spawn at tile {}", nation_id, tile_index);
self.spawn_config.insert(nation_id, tile_index);
// Start timeout on first spawn
if self.spawn_timeout_accumulated.is_none() {
self.spawn_timeout_accumulated = Some(0.0);
debug!("Spawn timeout started ({}ms)", SPAWN_TIMEOUT_MS);
}
TurnOutput::SpawnUpdate(self.spawn_config.clone())
}
Intent::Action(_) => {
if !self.game_started {
warn!("Received Action intent during spawn phase - ignoring");
}
TurnOutput::None
}
}
}
/// Tick with delta time (ms), returns turn if ready
/// During spawn phase, checks timeout. During game phase, accumulates time and generates turns.
pub fn tick(&mut self, delta_ms: f64, sourced_intents: Vec<SourcedIntent>) -> TurnOutput {
// Buffer incoming intents for the next turn (filter out SetSpawn during game phase)
if self.game_started {
for sourced_intent in sourced_intents {
match sourced_intent.intent {
Intent::Action(_) => self.buffered_intents.push(sourced_intent),
Intent::SetSpawn { .. } => {
warn!("Received SetSpawn intent after game started - ignoring");
}
}
}
}
// If game started and we're at turn 0, emit the initial turn immediately
if self.game_started && self.turn_number == 0 {
let start_turn = Turn { turn_number: 0, intents: Vec::new() };
self.turn_number = 1; // Next turn will be turn 1
info!("Turn(0) emitted - game starting");
return TurnOutput::Turn(start_turn);
}
// During spawn phase, handle timeout
if !self.game_started {
if let Some(ref mut accumulated) = self.spawn_timeout_accumulated {
*accumulated += delta_ms;
// Check if timeout expired
if *accumulated >= SPAWN_TIMEOUT_MS {
debug!("Spawn timeout expired - starting game");
// Create Turn(0) to start game
let start_turn = Turn { turn_number: 0, intents: Vec::new() };
info!("Turn(0) ready to start game (spawns already configured)");
// Mark game as started and clear spawn phase
self.game_started = true;
self.spawn_config.clear();
self.spawn_timeout_accumulated = None;
self.turn_number = 1; // Next turn will be turn 1
self.accumulated_time = 0.0; // Reset for clean turn timing
info!("Spawn phase complete - game started, next turn will be Turn 1");
return TurnOutput::Turn(start_turn);
}
}
return TurnOutput::None;
}
// Normal turn generation (after game has started)
self.accumulated_time += delta_ms;
// Only generate turn if enough time has passed (100ms tick interval)
if self.accumulated_time < 100.0 {
return TurnOutput::None;
}
// Reset accumulated time
self.accumulated_time -= 100.0;
// Drain buffered intents into the turn
let turn_intents = std::mem::take(&mut self.buffered_intents);
// Create turn
let turn = Turn { turn_number: self.turn_number, intents: turn_intents };
self.turn_number += 1;
TurnOutput::Turn(turn)
}
/// Get current turn number
pub fn turn_number(&self) -> u64 {
self.turn_number
}
/// Check if game has started
pub fn game_started(&self) -> bool {
self.game_started
}
/// Get current spawn configuration
pub fn spawn_config(&self) -> &HashMap<NationId, U16Vec2> {
&self.spawn_config
}
/// Skip spawn phase and start the game immediately
/// The next call to tick() will emit Turn(0) to start the game
/// Useful for tests that don't need the spawn phase
pub fn skip_spawn_phase(&mut self) {
self.game_started = true;
self.turn_number = 0; // Next tick will emit Turn(0), then proceed to Turn(1)
self.spawn_timeout_accumulated = None;
self.spawn_config.clear();
self.buffered_intents.clear(); // Clear any buffered intents
}
}
impl Default for SharedTurnGenerator {
fn default() -> Self {
Self::new()
}
}

View File

@@ -0,0 +1,65 @@
//! Shared networking types and events
use std::collections::HashMap;
use bevy_ecs::prelude::Message;
use rkyv::{Archive, Deserialize as RkyvDeserialize, Serialize as RkyvSerialize};
use serde::{Deserialize, Serialize};
use super::protocol::SourcedIntent;
use crate::{game::NationId, game::core::action::GameAction};
/// Network mode configuration for the game
pub enum NetworkMode {
/// Local single-player or hotseat mode
Local,
/// Remote multiplayer mode (non-WASM only)
#[cfg(not(target_arch = "wasm32"))]
Remote { server_address: String },
}
// Shared event types
#[derive(Message, Debug, Clone, Archive, RkyvSerialize, RkyvDeserialize)]
#[rkyv(derive(Debug))]
pub struct IntentEvent(pub Intent);
#[derive(Message, Debug, Clone, Archive, RkyvSerialize, RkyvDeserialize)]
#[rkyv(derive(Debug))]
pub struct ProcessTurnEvent(pub Turn);
/// Event containing spawn configuration update from server (multiplayer)
#[derive(Message, Debug, Clone)]
pub struct SpawnConfigEvent(pub HashMap<NationId, glam::U16Vec2>);
/// Network wrapper for player intents
///
/// Intent is the network-layer representation of player intents.
/// It has two variants:
/// - Action: State-recorded game actions that appear in game history (replays)
/// - SetSpawn: Ephemeral spawn selection that doesn't pollute game history
///
/// Note: Bot actions are NOT sent as intents - they are calculated
/// deterministically on each client during turn execution.
///
/// Player identity is derived from the connection (server-side) and wrapped
/// in SourcedIntent to prevent spoofing.
#[derive(Debug, Clone, Serialize, Deserialize, Archive, RkyvSerialize, RkyvDeserialize)]
#[rkyv(derive(Debug))]
pub enum Intent {
/// State-recorded game action (appears in game history for replays)
Action(GameAction),
/// Ephemeral spawn selection (not recorded in history)
/// Only valid during spawn phase, ignored after game starts
/// Player ID is derived from connection by server
SetSpawn {
#[serde(with = "crate::game::utils::u16vec2_serde")]
tile_index: glam::U16Vec2,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, Archive, RkyvSerialize, RkyvDeserialize)]
#[rkyv(derive(Debug))]
pub struct Turn {
pub turn_number: u64,
pub intents: Vec<SourcedIntent>,
}

View File

@@ -0,0 +1,315 @@
use std::collections::HashMap;
use std::error::Error;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use futures::lock::Mutex;
use hmac::{Hmac, Mac};
use sha2::Sha256;
use tracing::{debug, error, warn};
use super::types::{BatchCaptureRequest, BatchEvent, TelemetryConfig, TelemetryEvent};
use super::user_id::UserIdType;
use crate::spawn_task;
#[cfg(not(target_arch = "wasm32"))]
use super::user_id::get_or_create_user_id;
type HmacSha256 = Hmac<Sha256>;
/// Build an HTTP client with appropriate DNS resolver for the platform.
///
/// On non-WASM targets, attempts to use Hickory DNS with DoH support.
/// Falls back to default client if DoH initialization fails.
#[cfg(not(target_arch = "wasm32"))]
fn build_http_client() -> reqwest::Client {
match reqwest::Client::builder().dns_resolver(Arc::new(crate::dns::HickoryDnsResolver::new())).build() {
Ok(client) => {
debug!("HTTP client initialized with DoH resolver");
client
}
Err(e) => {
warn!("Failed to build HTTP client with DoH: {}, using default", e);
reqwest::Client::new()
}
}
}
#[cfg(target_arch = "wasm32")]
fn build_http_client() -> reqwest::Client {
reqwest::Client::new()
}
/// A simple telemetry client that batches events and sends them to PostHog.
///
/// This client works on both native and WASM targets by using reqwest
/// with appropriate feature flags.
#[derive(Clone)]
pub struct TelemetryClient {
config: TelemetryConfig,
client: reqwest::Client,
/// Distinct ID for this client instance (anonymous user ID)
distinct_id: String,
/// Lightweight properties attached to every event
default_properties: HashMap<String, serde_json::Value>,
/// Event buffer for batching
buffer: Arc<Mutex<Vec<TelemetryEvent>>>,
/// Whether the flush task has been started
flush_task_started: Arc<AtomicBool>,
/// Track in-flight batch sends (native only)
#[cfg(not(target_arch = "wasm32"))]
in_flight_sends: Arc<tokio::sync::Mutex<Vec<tokio::task::JoinHandle<()>>>>,
}
impl TelemetryClient {
/// Create a new telemetry client with the given configuration.
#[cfg(not(target_arch = "wasm32"))]
pub fn new(config: TelemetryConfig) -> Self {
let (distinct_id, id_type) = get_or_create_user_id();
debug!("Telemetry client initialized (user ID type: {})", id_type.as_str());
let default_properties = build_default_properties(id_type);
Self { config, client: build_http_client(), distinct_id, default_properties, buffer: Arc::new(Mutex::new(Vec::new())), flush_task_started: Arc::new(AtomicBool::new(false)), in_flight_sends: Arc::new(tokio::sync::Mutex::new(Vec::new())) }
}
/// Create a new telemetry client with a pre-loaded user ID.
///
/// This is used on WASM where user ID loading is async.
pub fn new_with_user_id(config: TelemetryConfig, distinct_id: String, id_type: UserIdType) -> Self {
debug!("Telemetry client initialized (user ID type: {})", id_type.as_str());
let default_properties = build_default_properties(id_type);
Self {
config,
client: build_http_client(),
distinct_id,
default_properties,
buffer: Arc::new(Mutex::new(Vec::new())),
flush_task_started: Arc::new(AtomicBool::new(false)),
#[cfg(not(target_arch = "wasm32"))]
in_flight_sends: Arc::new(tokio::sync::Mutex::new(Vec::new())),
}
}
/// Start a background task that periodically flushes events.
/// This ensures events are sent even if the batch size isn't reached.
///
/// Only starts once, subsequent calls are no-ops.
fn ensure_flush_task_started(&self) {
// Check if already started (fast path)
if self.flush_task_started.load(Ordering::Acquire) {
return;
}
// Try to start the task (only one thread will succeed)
if self.flush_task_started.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire).is_err() {
// Another thread beat us to it
return;
}
// We won the race, start the task
let client = self.clone();
let interval_secs = self.config.flush_interval_secs;
spawn_task(async move {
#[cfg(not(target_arch = "wasm32"))]
{
use std::time::Duration;
let mut interval = tokio::time::interval(Duration::from_secs(interval_secs));
loop {
interval.tick().await;
client.flush().await;
}
}
#[cfg(target_arch = "wasm32")]
{
use gloo_timers::future::TimeoutFuture;
loop {
TimeoutFuture::new((interval_secs * 1000) as u32).await;
client.flush().await;
}
}
});
debug!("Started periodic flush task (interval: {}s)", interval_secs);
}
/// Track a telemetry event. Events are buffered and sent in batches.
pub async fn track(&self, event: TelemetryEvent) {
// Ensure the periodic flush task is running (lazy start)
self.ensure_flush_task_started();
debug!("Buffering telemetry event: {}", event.event);
let mut buffer = self.buffer.lock().await;
buffer.push(event);
// Check if we should flush based on batch size
if buffer.len() >= self.config.batch_size {
debug!("Batch size reached ({}), flushing events", buffer.len());
let events_to_send = buffer.drain(..).collect::<Vec<_>>();
drop(buffer); // Release lock before async operation
// Spawn a task to send in the background (non-blocking)
let client = self.clone();
#[cfg(not(target_arch = "wasm32"))]
{
let handle = tokio::spawn(async move {
client.send_batch(events_to_send).await;
});
// Track the in-flight send and clean up completed tasks
let mut in_flight = self.in_flight_sends.lock().await;
in_flight.retain(|h| !h.is_finished());
in_flight.push(handle);
}
#[cfg(target_arch = "wasm32")]
spawn_task(async move {
client.send_batch(events_to_send).await;
});
}
}
/// Manually flush all buffered events.
///
/// This method waits for all in-flight sends to complete, then sends any remaining buffered events.
pub async fn flush(&self) {
// First, wait for all in-flight background sends to complete
#[cfg(not(target_arch = "wasm32"))]
{
let handles = {
let mut in_flight = self.in_flight_sends.lock().await;
in_flight.drain(..).collect::<Vec<_>>()
};
if !handles.is_empty() {
debug!("Waiting for {} in-flight batch sends to complete", handles.len());
for handle in handles {
let _ = handle.await;
}
}
}
// Then flush any remaining buffered events
let events_to_send = {
let mut buffer = self.buffer.lock().await;
if buffer.is_empty() {
return;
}
let events = buffer.drain(..).collect::<Vec<_>>();
debug!("Flushing {} buffered events", events.len());
events
};
// Send synchronously (wait for completion)
self.send_batch(events_to_send).await;
}
/// Generate HMAC-SHA256 signature for request payload.
///
/// This prevents tampering and verifies request integrity.
fn sign_payload(&self, payload: &[u8]) -> String {
let mut mac = HmacSha256::new_from_slice(self.config.signing_key.as_bytes()).expect("HMAC can take key of any size");
mac.update(payload);
// Convert to hex string
let result = mac.finalize();
let bytes = result.into_bytes();
hex::encode(bytes)
}
/// Send a batch of events to PostHog.
async fn send_batch(&self, events: Vec<TelemetryEvent>) {
if events.is_empty() {
return;
}
let batch_events: Vec<BatchEvent> = events
.into_iter()
.map(|mut event| {
// Merge default properties with event properties
// Event properties take precedence over defaults
for (key, value) in &self.default_properties {
event.properties.entry(key.clone()).or_insert(value.clone());
}
BatchEvent { event: event.event, properties: event.properties, distinct_id: self.distinct_id.clone() }
})
.collect();
let payload = BatchCaptureRequest { api_key: self.config.api_key.clone(), batch: batch_events };
// Serialize payload to JSON bytes
let payload_json = match serde_json::to_vec(&payload) {
Ok(json) => json,
Err(e) => {
error!("Failed to serialize telemetry payload: {}", e);
return;
}
};
// Generate signature
let signature = self.sign_payload(&payload_json);
let url = format!("https://{}/batch", self.config.api_host);
// Send request with signature header
match self.client.post(&url).header("X-Request-Signature", signature).header("Content-Type", "application/json").body(payload_json).send().await {
Ok(response) => {
let status = response.status();
if status.is_success() {
debug!("Telemetry batch sent successfully");
} else {
let body = response.text().await.unwrap_or_default();
warn!("PostHog returned status {}: {}", status, body);
}
}
Err(e) => {
error!("Failed to send telemetry batch: {}", e);
if let Some(source) = e.source() {
error!("Caused by: {}", source);
}
}
}
}
/// Get the distinct ID for this client (useful for debugging)
pub fn distinct_id(&self) -> &str {
&self.distinct_id
}
/// Get the user ID type for this client (useful for debugging)
pub fn user_id_type(&self) -> Option<&str> {
self.default_properties.get("user_id_type").and_then(|v| v.as_str())
}
}
/// Build the default properties that are attached to every event.
fn build_default_properties(id_type: UserIdType) -> HashMap<String, serde_json::Value> {
use crate::build_info;
use serde_json::Value;
let mut props = HashMap::new();
let platform = if cfg!(target_arch = "wasm32") {
"browser"
} else if cfg!(target_os = "windows") {
"desktop-windows"
} else if cfg!(target_os = "macos") {
"desktop-macos"
} else if cfg!(target_os = "linux") {
"desktop-linux"
} else {
"desktop-unknown"
};
props.insert("platform".to_string(), Value::String(platform.to_string()));
props.insert("build_version".to_string(), Value::String(build_info::VERSION.to_string()));
props.insert("build_commit".to_string(), Value::String(build_info::git_commit_short().to_string()));
props.insert("user_id_type".to_string(), Value::String(id_type.as_str().to_string()));
props
}

View File

@@ -0,0 +1,122 @@
//! Telemetry module for tracking analytics events.
//!
//! This module provides a simple, cross-platform telemetry client that works
//! on both native (Tauri) and WASM targets. Events are batched and sent to
//! PostHog via HTTP in a non-blocking manner.
mod client;
mod system_info;
mod types;
mod user_id;
pub use client::*;
pub use system_info::*;
pub use types::*;
pub use user_id::*;
use once_cell::sync::OnceCell;
use std::sync::atomic::{AtomicU64, Ordering};
/// Global telemetry client instance.
static TELEMETRY_CLIENT: OnceCell<TelemetryClient> = OnceCell::new();
/// Session start timestamp in milliseconds since epoch (for calculating session duration).
static SESSION_START_MS: AtomicU64 = AtomicU64::new(0);
/// Initialize the global telemetry client with the given configuration.
///
/// This should be called once at application startup.
/// On WASM, this is async to load the user ID from IndexedDB.
pub async fn init(config: TelemetryConfig) {
#[cfg(not(target_arch = "wasm32"))]
{
let client = TelemetryClient::new(config);
if TELEMETRY_CLIENT.set(client).is_err() {
tracing::warn!("Telemetry client already initialized");
}
}
#[cfg(target_arch = "wasm32")]
{
let (user_id, id_type) = get_or_create_user_id_async().await;
let client = TelemetryClient::new_with_user_id(config, user_id, id_type);
if TELEMETRY_CLIENT.set(client).is_err() {
tracing::warn!("Telemetry client already initialized");
}
}
}
/// Get a reference to the global telemetry client.
///
/// Returns None if the client hasn't been initialized yet.
pub fn client() -> Option<&'static TelemetryClient> {
TELEMETRY_CLIENT.get()
}
/// Track a telemetry event using the global client.
///
/// This is a convenience function that will do nothing if the client
/// hasn't been initialized.
pub async fn track(event: TelemetryEvent) {
if let Some(client) = client() {
client.track(event).await;
}
}
/// Track a session start event with detailed system information.
///
/// Should be called once after telemetry initialization.
pub async fn track_session_start() {
// Record session start time for duration calculation
let now_ms = current_time_ms();
SESSION_START_MS.store(now_ms, Ordering::Relaxed);
let system_info = SystemInfo::collect();
let mut event = TelemetryEvent::new("session_start");
for (key, value) in system_info.to_properties() {
event.properties.insert(key, value);
}
#[cfg(target_arch = "wasm32")]
{
let (browser_name, browser_version) = system_info::get_browser_info();
event.properties.insert("browser_name".to_string(), serde_json::Value::String(browser_name));
event.properties.insert("browser_version".to_string(), serde_json::Value::String(browser_version));
}
track(event).await;
}
/// Track a session end event with session duration.
///
/// Should be called when the application is closing.
pub async fn track_session_end() {
let start_ms = SESSION_START_MS.load(Ordering::Relaxed);
if start_ms == 0 {
tracing::warn!("Session end tracked but no session start found");
return;
}
let now_ms = current_time_ms();
let duration_ms = now_ms.saturating_sub(start_ms);
let duration_secs = duration_ms / 1000;
let event = TelemetryEvent::new("session_end").with_property("session_duration_ms", duration_ms).with_property("session_duration_secs", duration_secs);
track(event).await;
}
/// Get current time in milliseconds since Unix epoch.
fn current_time_ms() -> u64 {
#[cfg(not(target_arch = "wasm32"))]
{
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as u64
}
#[cfg(target_arch = "wasm32")]
{
(js_sys::Date::now()) as u64
}
}

View File

@@ -0,0 +1,148 @@
//! System information collection for analytics.
//!
//! Collects platform-specific system information for telemetry purposes.
use serde_json::Value;
use std::collections::HashMap;
/// Detailed system information collected once at session start.
#[derive(Debug, Clone)]
pub struct SystemInfo {
pub os_name: String,
pub os_version: String,
pub arch: String,
pub cpu_brand: Option<String>,
pub cpu_cores: Option<usize>,
pub total_memory_mb: Option<u64>,
}
impl SystemInfo {
/// Collect system information for the current platform.
pub fn collect() -> Self {
#[cfg(not(target_arch = "wasm32"))]
{
Self::collect_native()
}
#[cfg(target_arch = "wasm32")]
{
Self::collect_wasm()
}
}
/// Convert system info to a HashMap for inclusion in telemetry events.
pub fn to_properties(&self) -> HashMap<String, Value> {
let mut props = HashMap::new();
props.insert("os_name".to_string(), Value::String(self.os_name.clone()));
props.insert("os_version".to_string(), Value::String(self.os_version.clone()));
props.insert("arch".to_string(), Value::String(self.arch.clone()));
if let Some(brand) = &self.cpu_brand {
props.insert("cpu_brand".to_string(), Value::String(brand.clone()));
}
if let Some(cores) = self.cpu_cores {
props.insert("cpu_cores".to_string(), Value::Number(cores.into()));
}
if let Some(mem) = self.total_memory_mb {
props.insert("total_memory_mb".to_string(), Value::Number(mem.into()));
}
props
}
#[cfg(not(target_arch = "wasm32"))]
fn collect_native() -> Self {
use sysinfo::System;
let mut sys = System::new_all();
sys.refresh_all();
let os_name = System::name().unwrap_or_else(|| "Unknown".to_string());
let os_version = System::os_version().unwrap_or_else(|| "Unknown".to_string());
let arch = std::env::consts::ARCH.to_string();
let cpu_brand = sys.cpus().first().map(|cpu| cpu.brand().to_string());
let cpu_cores = sys.cpus().len();
let total_memory_mb = sys.total_memory() / 1024 / 1024;
Self { os_name, os_version, arch, cpu_brand, cpu_cores: Some(cpu_cores), total_memory_mb: Some(total_memory_mb) }
}
#[cfg(target_arch = "wasm32")]
fn collect_wasm() -> Self {
use wasm_bindgen::JsValue;
// In web workers, use the global scope instead of window
let global = js_sys::global();
let navigator = js_sys::Reflect::get(&global, &JsValue::from_str("navigator")).expect("navigator should be available");
// Call methods using Reflect to work with both Navigator and WorkerNavigator
let user_agent = js_sys::Reflect::get(&navigator, &JsValue::from_str("userAgent")).ok().and_then(|v| v.as_string()).unwrap_or_default();
let platform = js_sys::Reflect::get(&navigator, &JsValue::from_str("platform")).ok().and_then(|v| v.as_string()).unwrap_or_default();
let (os_name, os_version) = parse_user_agent(&user_agent);
let arch = platform;
let cpu_cores = js_sys::Reflect::get(&navigator, &JsValue::from_str("hardwareConcurrency")).ok().and_then(|v| v.as_f64()).and_then(|f| if f > 0.0 { Some(f as usize) } else { None });
let device_memory = js_sys::Reflect::get(&navigator, &JsValue::from_str("deviceMemory")).ok().and_then(|v| v.as_f64()).map(|gb| (gb * 1024.0) as u64);
Self { os_name, os_version, arch, cpu_brand: None, cpu_cores, total_memory_mb: device_memory }
}
}
/// Parse user agent string to extract OS name and version.
#[cfg(target_arch = "wasm32")]
fn parse_user_agent(ua: &str) -> (String, String) {
if ua.contains("Windows NT 10.0") {
("Windows".to_string(), "10/11".to_string())
} else if ua.contains("Windows NT 6.3") {
("Windows".to_string(), "8.1".to_string())
} else if ua.contains("Windows NT 6.2") {
("Windows".to_string(), "8".to_string())
} else if ua.contains("Windows NT 6.1") {
("Windows".to_string(), "7".to_string())
} else if ua.contains("Mac OS X") {
let version = ua.split("Mac OS X ").nth(1).and_then(|s| s.split(')').next()).unwrap_or("Unknown");
("macOS".to_string(), version.replace('_', "."))
} else if ua.contains("Android") {
let version = ua.split("Android ").nth(1).and_then(|s| s.split(';').next()).unwrap_or("Unknown");
("Android".to_string(), version.to_string())
} else if ua.contains("Linux") {
("Linux".to_string(), "Unknown".to_string())
} else if ua.contains("iOS") || ua.contains("iPhone") || ua.contains("iPad") {
let version = ua.split("OS ").nth(1).and_then(|s| s.split(' ').next()).unwrap_or("Unknown");
("iOS".to_string(), version.replace('_', "."))
} else {
("Unknown".to_string(), "Unknown".to_string())
}
}
/// Get browser name and version from user agent.
#[cfg(target_arch = "wasm32")]
pub fn get_browser_info() -> (String, String) {
use wasm_bindgen::JsValue;
// In web workers, use the global scope instead of window
let global = js_sys::global();
let navigator = js_sys::Reflect::get(&global, &JsValue::from_str("navigator")).expect("navigator should be available");
// Call methods using Reflect to work with both Navigator and WorkerNavigator
let ua = js_sys::Reflect::get(&navigator, &JsValue::from_str("userAgent")).ok().and_then(|v| v.as_string()).unwrap_or_default();
if ua.contains("Edg/") {
let version = ua.split("Edg/").nth(1).and_then(|s| s.split(' ').next()).unwrap_or("Unknown");
("Edge".to_string(), version.to_string())
} else if ua.contains("Chrome/") {
let version = ua.split("Chrome/").nth(1).and_then(|s| s.split(' ').next()).unwrap_or("Unknown");
("Chrome".to_string(), version.to_string())
} else if ua.contains("Firefox/") {
let version = ua.split("Firefox/").nth(1).and_then(|s| s.split(' ').next()).unwrap_or("Unknown");
("Firefox".to_string(), version.to_string())
} else if ua.contains("Safari/") && !ua.contains("Chrome") {
let version = ua.split("Version/").nth(1).and_then(|s| s.split(' ').next()).unwrap_or("Unknown");
("Safari".to_string(), version.to_string())
} else {
("Unknown".to_string(), "Unknown".to_string())
}
}

View File

@@ -0,0 +1,77 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// Represents a telemetry event to be sent to PostHog.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TelemetryEvent {
/// Unique event identifier (e.g., "app_started", "game_ended")
pub event: String,
/// Properties associated with this event
#[serde(skip_serializing_if = "HashMap::is_empty")]
pub properties: HashMap<String, serde_json::Value>,
}
impl TelemetryEvent {
pub fn new(event: impl Into<String>) -> Self {
Self { event: event.into(), properties: HashMap::new() }
}
pub fn with_property(mut self, key: impl Into<String>, value: impl Into<serde_json::Value>) -> Self {
self.properties.insert(key.into(), value.into());
self
}
}
/// Configuration for the telemetry client.
#[derive(Debug, Clone)]
pub struct TelemetryConfig {
/// PostHog API key
pub api_key: String,
/// API host (e.g., "observe.borders.xevion.dev")
pub api_host: String,
/// Batch size - send events when this many are queued
pub batch_size: usize,
/// Flush interval in seconds
pub flush_interval_secs: u64,
/// HMAC signing key for request integrity verification
pub signing_key: String,
}
impl Default for TelemetryConfig {
fn default() -> Self {
// In development: send often with small batch size for fast feedback
// In production: batch events but flush periodically to avoid losing data
#[cfg(debug_assertions)]
let (batch_size, flush_interval_secs) = (2, 5);
#[cfg(not(debug_assertions))]
let (batch_size, flush_interval_secs) = (10, 45);
Self {
api_key: "phc_VmL3M9Sn9hBCpNRExnKLWOZqlYO5SXSUkAAwl3gXJek".to_string(),
api_host: "observe.xevion.dev".to_string(),
batch_size,
flush_interval_secs,
// HMAC-SHA256 signing key for request integrity
signing_key: "borders_telemetry_hmac_key_v1_2025".to_string(),
}
}
}
/// PostHog batch capture request payload
#[derive(Debug, Serialize)]
pub(crate) struct BatchCaptureRequest {
pub api_key: String,
pub batch: Vec<BatchEvent>,
}
#[derive(Debug, Serialize)]
pub(crate) struct BatchEvent {
pub event: String,
pub properties: HashMap<String, serde_json::Value>,
pub distinct_id: String,
}

View File

@@ -0,0 +1,268 @@
use tracing::debug;
use uuid::Uuid;
#[cfg(not(target_arch = "wasm32"))]
use tracing::warn;
/// Type of user ID that was generated or loaded.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UserIdType {
/// ID was loaded from storage (existing user)
Existing,
/// ID was generated from hardware components
Hardware,
/// ID was newly generated random UUID
New,
}
impl UserIdType {
pub fn as_str(&self) -> &'static str {
match self {
UserIdType::Existing => "existing",
UserIdType::Hardware => "hardware",
UserIdType::New => "new",
}
}
}
/// Get or create a persistent user ID (sync version for native platforms).
///
/// This function attempts to identify the user through multiple strategies:
/// 1. Stored UUID (persisted across runs, most reliable)
/// 2. Hardware-based ID (hashed for privacy, then stored for future use)
/// 3. Generate new UUID (if nothing exists)
///
/// Returns a tuple of (user_id, id_type).
#[cfg(not(target_arch = "wasm32"))]
pub fn get_or_create_user_id() -> (String, UserIdType) {
// Try to load stored ID first (most reliable)
if let Some(stored_id) = load_stored_id() {
debug!("Using stored user ID");
return (stored_id, UserIdType::Existing);
}
// Try hardware-based ID
if let Some(hw_id) = get_hardware_id() {
debug!("Generated hardware-based user ID");
// Store it for future reliability
if let Err(e) = store_user_id(&hw_id) {
warn!("Failed to store hardware-based user ID: {}", e);
}
return (hw_id, UserIdType::Hardware);
}
// Generate and store new ID
let new_id = Uuid::new_v4().to_string();
debug!("Generated new user ID");
if let Err(e) = store_user_id(&new_id) {
warn!("Failed to store new user ID: {}", e);
}
(new_id, UserIdType::New)
}
/// Get or create a persistent user ID (async version for WASM).
///
/// This function attempts to identify the user through multiple strategies:
/// 1. Stored UUID in localStorage (via main thread, persisted across runs)
/// 2. Generate new UUID (if nothing exists)
///
/// Returns a tuple of (user_id, id_type).
#[cfg(target_arch = "wasm32")]
pub async fn get_or_create_user_id_async() -> (String, UserIdType) {
// Try to load from localStorage via main thread
if let Some(stored_id) = load_from_localstorage().await {
debug!("Loaded user ID from localStorage");
return (stored_id, UserIdType::Existing);
}
// Generate and store new ID
let new_id = Uuid::new_v4().to_string();
debug!("Generated new user ID");
// Try to store it (fire and forget)
store_user_id(&new_id).ok();
(new_id, UserIdType::New)
}
/// Attempt to get a hardware-based identifier.
///
/// Uses machineid-rs to build a stable ID from hardware components.
/// The ID is hashed with SHA256 for privacy.
///
/// Only available on native platforms (not WASM).
#[cfg(not(target_arch = "wasm32"))]
fn get_hardware_id() -> Option<String> {
use machineid_rs::{Encryption, HWIDComponent, IdBuilder};
match IdBuilder::new(Encryption::SHA256).add_component(HWIDComponent::SystemID).add_component(HWIDComponent::CPUCores).build("iron-borders") {
Ok(id) => {
debug!("Successfully generated hardware ID");
Some(id)
}
Err(e) => {
warn!("Failed to generate hardware ID: {}", e);
None
}
}
}
/// Hardware IDs are not available on WASM.
#[cfg(target_arch = "wasm32")]
#[allow(dead_code)]
fn get_hardware_id() -> Option<String> {
None
}
/// Load a previously stored user ID from platform-specific storage.
#[cfg(not(target_arch = "wasm32"))]
fn load_stored_id() -> Option<String> {
#[cfg(windows)]
{
load_from_registry()
}
#[cfg(not(windows))]
{
load_from_file()
}
}
/// Store a user ID to platform-specific storage.
#[cfg(not(target_arch = "wasm32"))]
fn store_user_id(id: &str) -> Result<(), String> {
#[cfg(windows)]
{
store_to_registry(id)
}
#[cfg(not(windows))]
{
store_to_file(id)
}
}
#[cfg(target_arch = "wasm32")]
fn store_user_id(id: &str) -> Result<(), String> {
use wasm_bindgen::JsValue;
use web_sys::BroadcastChannel;
let channel = BroadcastChannel::new("user_id_storage").ok().ok_or("Failed to create channel")?;
let msg = format!(r#"{{"action":"save","id":"{}"}}"#, id);
channel.post_message(&JsValue::from_str(&msg)).ok().ok_or("Failed to post")?;
Ok(())
}
#[cfg(windows)]
fn load_from_registry() -> Option<String> {
use winreg::RegKey;
use winreg::enums::*;
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
match hkcu.open_subkey("Software\\Iron Borders\\ClientCache") {
Ok(key) => match key.get_value::<String, _>("sid") {
Ok(id) => {
debug!("Loaded user ID from registry");
Some(id)
}
Err(_) => None,
},
Err(_) => None,
}
}
#[cfg(windows)]
fn store_to_registry(id: &str) -> Result<(), String> {
use winreg::RegKey;
use winreg::enums::*;
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
let (key, _) = hkcu.create_subkey("Software\\Iron Borders\\ClientCache").map_err(|e| format!("Failed to create registry key: {}", e))?;
key.set_value("sid", &id).map_err(|e| format!("Failed to set registry value: {}", e))?;
debug!("Stored user ID to registry");
Ok(())
}
#[cfg(all(not(target_arch = "wasm32"), not(windows)))]
fn load_from_file() -> Option<String> {
use directories::ProjectDirs;
use std::fs;
let proj_dirs = ProjectDirs::from("", "", "iron-borders")?;
let data_dir = proj_dirs.data_dir();
let file_path = data_dir.join("client.dat");
match fs::read_to_string(&file_path) {
Ok(id) => {
debug!("Loaded user ID from file: {:?}", file_path);
Some(id.trim().to_string())
}
Err(_) => None,
}
}
#[cfg(all(not(target_arch = "wasm32"), not(windows)))]
fn store_to_file(id: &str) -> Result<(), String> {
use directories::ProjectDirs;
use std::fs;
let proj_dirs = ProjectDirs::from("", "", "iron-borders").ok_or("Failed to get project directories")?;
let data_dir = proj_dirs.data_dir();
// Create directory if it doesn't exist
fs::create_dir_all(data_dir).map_err(|e| format!("Failed to create data directory: {}", e))?;
let file_path = data_dir.join("client.dat");
fs::write(&file_path, id).map_err(|e| format!("Failed to write user ID file: {}", e))?;
debug!("Stored user ID to file: {:?}", file_path);
Ok(())
}
#[cfg(target_arch = "wasm32")]
async fn load_from_localstorage() -> Option<String> {
use gloo_timers::future::TimeoutFuture;
use std::sync::Arc;
use std::sync::Mutex;
use wasm_bindgen::JsCast;
use wasm_bindgen::prelude::*;
use web_sys::{BroadcastChannel, MessageEvent};
let channel = BroadcastChannel::new("user_id_storage").ok()?;
let result = Arc::new(Mutex::new(None));
let result_clone = result.clone();
let callback = Closure::wrap(Box::new(move |event: MessageEvent| {
if let Some(data) = event.data().as_string()
&& let Ok(parsed) = js_sys::JSON::parse(&data)
&& let Some(obj) = parsed.dyn_ref::<js_sys::Object>()
&& let Ok(action) = js_sys::Reflect::get(obj, &JsValue::from_str("action"))
&& action.as_string().as_deref() == Some("load_response")
&& let Ok(id_val) = js_sys::Reflect::get(obj, &JsValue::from_str("id"))
&& let Some(id) = id_val.as_string()
{
*result_clone.lock().unwrap() = Some(id);
}
}) as Box<dyn FnMut(_)>);
channel.set_onmessage(Some(callback.as_ref().unchecked_ref()));
// Send load request
let msg = r#"{"action":"load"}"#;
channel.post_message(&JsValue::from_str(msg)).ok()?;
// Wait up to 100ms for response
TimeoutFuture::new(100).await;
callback.forget();
result.lock().unwrap().clone()
}

View File

@@ -0,0 +1,175 @@
/// Time tracking resources for ECS
use bevy_ecs::prelude::Resource;
use quanta::{Clock as QuantaClock, Instant as QuantaInstant, Mock};
use std::sync::Arc;
use std::time::Duration;
/// Clock resource wrapping quanta::Clock for mockable time
///
/// This provides a unified time interface that works across all platforms
/// (including WebAssembly) and supports mocking for deterministic tests.
#[derive(Clone, Resource)]
pub struct Clock {
inner: QuantaClock,
}
impl Clock {
/// Create a new clock using the system time
pub fn new() -> Self {
Self { inner: QuantaClock::new() }
}
/// Get the current instant
#[inline]
pub fn now(&self) -> QuantaInstant {
self.inner.now()
}
/// Get the recent (cached) instant for ultra-low-overhead timing
#[inline]
pub fn recent(&self) -> QuantaInstant {
self.inner.recent()
}
/// Create a mock clock for testing
pub fn mock() -> (Self, Arc<Mock>) {
let (clock, mock) = QuantaClock::mock();
(Self { inner: clock }, mock)
}
}
impl Default for Clock {
fn default() -> Self {
Self::new()
}
}
impl std::fmt::Debug for Clock {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Clock").finish_non_exhaustive()
}
}
/// Time tracking resource for ECS with integrated clock source
///
/// This resource provides both frame timing (delta/elapsed) and the underlying
/// clock source for mockable time in tests.
#[derive(Clone, Resource)]
pub struct Time {
clock: Clock,
last_instant: QuantaInstant,
creation_epoch_ms: u64, // Wall-clock time when Time was created (for epoch calculations)
delta: Duration,
elapsed: Duration,
}
impl Time {
/// Create a new Time resource with system clock
pub fn new() -> Self {
let clock = Clock::new();
let now = clock.now();
let creation_epoch_ms = Self::get_wall_clock_epoch_ms();
Self { clock, last_instant: now, creation_epoch_ms, delta: Duration::ZERO, elapsed: Duration::ZERO }
}
/// Create a Time resource with a custom clock (for testing with mock clocks)
///
/// The epoch_offset_ms parameter allows tests to set a specific epoch time.
/// Use 0 for tests that don't care about wall-clock time.
pub fn with_clock(clock: Clock, epoch_offset_ms: u64) -> Self {
let now = clock.now();
Self { clock, last_instant: now, creation_epoch_ms: epoch_offset_ms, delta: Duration::ZERO, elapsed: Duration::ZERO }
}
/// Get current wall-clock time in milliseconds since UNIX epoch
fn get_wall_clock_epoch_ms() -> u64 {
#[cfg(not(target_arch = "wasm32"))]
{
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as u64
}
#[cfg(target_arch = "wasm32")]
{
js_sys::Date::now() as u64
}
}
/// Advance time by measuring delta since last tick
///
/// This should be called once per frame by the game loop.
pub fn tick(&mut self) {
let now = self.clock.now();
self.delta = now.duration_since(self.last_instant);
self.elapsed += self.delta;
self.last_instant = now;
}
/// Get the time elapsed since the last frame
#[inline]
pub fn delta(&self) -> Duration {
self.delta
}
/// Get the time elapsed since the last frame in seconds
#[inline]
pub fn delta_secs(&self) -> f32 {
self.delta.as_secs_f32()
}
/// Get the total time elapsed since Time was created
#[inline]
pub fn elapsed(&self) -> Duration {
self.elapsed
}
/// Get the total time elapsed since Time was created in seconds
#[inline]
pub fn elapsed_secs(&self) -> f32 {
self.elapsed.as_secs_f32()
}
/// Get current time in milliseconds since UNIX epoch
///
/// This is mockable in tests - it returns creation_epoch + elapsed time.
/// For real usage, it gives actual wall-clock time.
/// For tests with mock clocks, it gives predictable time based on mock advancement.
#[inline]
pub fn epoch_millis(&self) -> u64 {
self.creation_epoch_ms + self.elapsed.as_millis() as u64
}
/// Legacy method for compatibility - prefer using tick() instead
#[deprecated(note = "Use tick() instead - it measures delta automatically")]
pub fn update(&mut self, delta: Duration) {
self.delta = delta;
self.elapsed += delta;
}
}
impl Default for Time {
fn default() -> Self {
Self::new()
}
}
impl std::fmt::Debug for Time {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Time").field("delta", &self.delta).field("elapsed", &self.elapsed).finish_non_exhaustive()
}
}
/// Fixed timestep time resource
#[derive(Debug, Clone, Resource)]
pub struct FixedTime {
timestep: Duration,
}
impl FixedTime {
pub fn from_seconds(seconds: f64) -> Self {
Self { timestep: Duration::from_secs_f64(seconds) }
}
pub fn timestep(&self) -> Duration {
self.timestep
}
}

View File

@@ -0,0 +1,242 @@
//! Shared leaderboard data structures and utilities
//!
//! This module contains types and systems for managing leaderboard data
//! that are shared between desktop and WASM builds.
use std::collections::HashMap;
use std::time::Duration;
use bevy_ecs::prelude::*;
use crate::game::ActiveAttacks;
use crate::game::entities::{Dead, Troops};
use crate::game::input::context::LocalPlayerContext;
use crate::game::systems::turn::CurrentTurn;
use crate::game::{NationColor, NationName, TerritoryManager, TerritorySize, world::NationId};
use crate::time::Time;
// Re-export UI types from protocol for convenience
pub use crate::ui::protocol::{AttackEntry, AttacksUpdatePayload, BackendMessage, LeaderboardEntry, LeaderboardSnapshot};
/// Convert RGBA color to hex string (without alpha)
pub fn rgba_to_hex(color: [f32; 4]) -> String {
let r = (color[0] * 255.0) as u8;
let g = (color[1] * 255.0) as u8;
let b = (color[2] * 255.0) as u8;
format!("{:02X}{:02X}{:02X}", r, g, b)
}
/// Resolve a nation's display name with fallbacks for missing or empty names
fn resolve_player_name(nation_id: NationId, client_nation_id: NationId, nation_data: Option<(&NationName, &NationColor)>) -> String {
nation_data.map(|(name, _)| if name.0.is_empty() { if nation_id == client_nation_id { "Player".to_string() } else { format!("Nation {}", nation_id) } } else { name.0.clone() }).unwrap_or_else(|| format!("Nation {}", nation_id))
}
/// Resource to track last emitted leaderboard state for deduplication
#[derive(Resource, Default, Debug)]
pub struct LastLeaderboardDigest {
pub entries: Vec<(NationId, String, u32, u32)>, // (id, name, tile_count, troops)
pub turn: u64,
}
/// Resource to track last emitted attacks state for deduplication
#[derive(Resource, Default, Debug)]
pub struct LastAttacksDigest {
pub entries: Vec<(NationId, Option<NationId>, u32, u64, bool)>, // (attacker_id, target_id, troops, id, is_outgoing)
pub turn: u64,
pub count: usize, // Track number of attacks to always detect add/remove
}
/// Resource to track display order update cycles (updates every 3rd tick)
#[derive(Resource, Default, Debug)]
pub struct DisplayOrderUpdateCounter {
pub tick: u32,
}
/// Resource to track last calculated display order for each nation
#[derive(Resource, Default, Debug)]
pub struct LastDisplayOrder {
pub order_map: HashMap<NationId, usize>,
}
/// Resource to throttle leaderboard snapshot emissions
#[derive(Resource, Debug)]
pub struct LeaderboardThrottle {
last_emission_elapsed: Option<Duration>,
throttle_duration: Duration,
}
impl Default for LeaderboardThrottle {
fn default() -> Self {
Self {
last_emission_elapsed: None,
throttle_duration: Duration::from_millis(420), // 420ms (3x faster, display order updates every 3rd tick)
}
}
}
/// Build a complete leaderboard snapshot from current game state
/// Returns None if nothing has changed since last_digest
pub fn build_leaderboard_snapshot(
turn_number: u64,
total_land_tiles: u32,
nation_stats: &[(NationId, u32, u32)], // (id, tile_count, troops)
client_nation_id: NationId,
nations_by_id: &HashMap<NationId, (&NationName, &NationColor)>,
last_digest: &mut LastLeaderboardDigest,
display_order_map: &HashMap<NationId, usize>,
) -> Option<LeaderboardSnapshot> {
// Build current digest for comparison (includes names now), filter out eliminated nations
let current_entries: Vec<(NationId, String, u32, u32)> = nation_stats
.iter()
.filter(|(_, tile_count, _)| *tile_count > 0) // Exclude eliminated nations
.map(|(id, tile_count, troops)| {
let nation_data = nations_by_id.get(id).copied();
let name = resolve_player_name(*id, client_nation_id, nation_data);
(*id, name, *tile_count, *troops)
})
.collect();
// Check if anything has changed (stats OR names)
if current_entries == last_digest.entries && turn_number == last_digest.turn {
return None; // No changes
}
// Update digest
last_digest.entries = current_entries;
last_digest.turn = turn_number;
// Build complete leaderboard entries (names + colors + stats), filter out eliminated nations
let mut entries: Vec<LeaderboardEntry> = nation_stats
.iter()
.filter(|(_, tile_count, _)| *tile_count > 0) // Exclude eliminated nations
.map(|(id, tile_count, troops)| {
let nation_data = nations_by_id.get(id).copied();
let name = resolve_player_name(*id, client_nation_id, nation_data);
let color = nation_data.map(|(_, color)| rgba_to_hex(color.0.to_rgba())).unwrap_or_else(|| "808080".to_string()); // Gray fallback
let territory_percent = if total_land_tiles > 0 { *tile_count as f32 / total_land_tiles as f32 } else { 0.0 };
LeaderboardEntry {
id: *id,
name,
color,
tile_count: *tile_count,
troops: *troops,
territory_percent,
rank: 0, // Assigned after sorting
display_order: 0, // Assigned after sorting
}
})
.collect();
// Sort by tile count descending
entries.sort_by(|a, b| b.tile_count.cmp(&a.tile_count));
// Assign rank and display_order after sorting
for (idx, entry) in entries.iter_mut().enumerate() {
entry.rank = idx + 1; // 1-indexed rank
// Use display_order from map, or fallback to current rank position
// TODO: Handle mid-game joins by initializing display_order for new players
entry.display_order = display_order_map.get(&entry.id).copied().unwrap_or(idx);
}
Some(LeaderboardSnapshot { turn: turn_number, total_land_tiles, entries, client_nation_id })
}
/// Bevy system that emits leaderboard snapshot events
#[allow(clippy::too_many_arguments)]
pub fn emit_leaderboard_snapshot_system(time: Res<Time>, current_turn: Option<Res<CurrentTurn>>, local_context: If<Res<LocalPlayerContext>>, territory_manager: Res<TerritoryManager>, nations: Query<(&NationId, &NationName, &NationColor)>, nation_stats: Query<(&NationId, &TerritorySize, &Troops), Without<Dead>>, mut last_digest: If<ResMut<LastLeaderboardDigest>>, mut throttle: If<ResMut<LeaderboardThrottle>>, mut counter: If<ResMut<DisplayOrderUpdateCounter>>, mut last_display_order: If<ResMut<LastDisplayOrder>>, mut backend_messages: MessageWriter<BackendMessage>) {
let _guard = tracing::debug_span!("emit_leaderboard_snapshot").entered();
let Some(current_turn) = current_turn else {
return;
};
// Check if enough time has passed since last emission
let current_elapsed = time.elapsed();
let should_emit = throttle.last_emission_elapsed.map(|last| current_elapsed.saturating_sub(last) >= throttle.throttle_duration).unwrap_or(true); // Emit on first call
if !should_emit {
return;
}
// Build nation lookup map from ECS components
let nations_by_id: HashMap<NationId, (&NationName, &NationColor)> = nations.iter().map(|(nation_id, name, color)| (*nation_id, (name, color))).collect();
// Collect nation stats from ECS
let nation_stats_data: Vec<(NationId, u32, u32)> = nation_stats.iter().map(|(nation_id, territory_size, troops)| (*nation_id, territory_size.0, troops.0 as u32)).collect();
// Calculate total land tiles
let total_land_tiles = crate::game::queries::count_land_tiles(&territory_manager);
// Increment tick counter (wraps on overflow)
counter.tick = counter.tick.wrapping_add(1);
// Every 3rd tick, recalculate display order from current rankings
if counter.tick.is_multiple_of(3) {
// Build temporary sorted list to determine new display order
let mut sorted_nations: Vec<_> = nation_stats_data
.iter()
.filter(|(_, tile_count, _)| *tile_count > 0) // Exclude eliminated nations
.collect();
sorted_nations.sort_by(|a, b| b.1.cmp(&a.1));
// Update display order map with current rankings
last_display_order.order_map.clear();
for (idx, (nation_id, _, _)) in sorted_nations.iter().enumerate() {
last_display_order.order_map.insert(*nation_id, idx);
}
}
if let Some(snapshot) = build_leaderboard_snapshot(current_turn.turn.turn_number, total_land_tiles, &nation_stats_data, local_context.id, &nations_by_id, &mut last_digest, &last_display_order.order_map) {
backend_messages.write(BackendMessage::LeaderboardSnapshot(snapshot));
throttle.last_emission_elapsed = Some(current_elapsed);
}
}
/// Build an attacks update payload from current game state
/// Always returns the current state (digest is used to prevent duplicate emissions)
pub fn build_attacks_update(active_attacks: &ActiveAttacks, turn_number: u64, client_nation_id: NationId, last_digest: &mut LastAttacksDigest) -> Option<AttacksUpdatePayload> {
// Get attacks for the client nation
let raw_attacks = active_attacks.get_attacks_for_nation(client_nation_id);
// Build current digest for comparison
let current_entries = raw_attacks.iter().map(|&(attacker_id, target_id, troops, id, is_outgoing)| (attacker_id, target_id, troops as u32, id, is_outgoing)).collect();
let current_count = raw_attacks.len();
// Always send update if attack count changed (add/remove)
let count_changed = current_count != last_digest.count;
// Check if digest changed (troop counts, etc.)
let digest_changed = current_entries != last_digest.entries;
if !count_changed && !digest_changed {
return None; // No changes at all
}
// Update digest
last_digest.entries = current_entries;
last_digest.turn = turn_number;
last_digest.count = current_count;
// Build attack entries
let entries: Vec<AttackEntry> = raw_attacks.into_iter().map(|(attacker_id, target_id, troops, id, is_outgoing)| AttackEntry { id, attacker_id, target_id, troops: troops as u32, is_outgoing }).collect();
Some(AttacksUpdatePayload { turn: turn_number, entries })
}
/// Bevy system that emits attacks update events
pub fn emit_attacks_update_system(active_attacks: Res<ActiveAttacks>, current_turn: Option<Res<CurrentTurn>>, local_context: If<Res<LocalPlayerContext>>, mut last_digest: If<ResMut<LastAttacksDigest>>, mut backend_messages: MessageWriter<BackendMessage>) {
let _guard = tracing::debug_span!("emit_attacks_update").entered();
let Some(current_turn) = current_turn else {
return;
};
if let Some(payload) = build_attacks_update(&active_attacks, current_turn.turn.turn_number, local_context.id, &mut last_digest) {
backend_messages.write(BackendMessage::AttacksUpdate(payload));
}
}

View File

@@ -0,0 +1,114 @@
//! UI/Frontend module for rendering and user interaction
//!
//! This module contains all frontend-related concerns including:
//! - Protocol definitions for frontend-backend communication
//! - Leaderboard management
//! - Platform transport abstraction
use std::collections::{HashMap, HashSet};
use bevy_ecs::hierarchy::ChildOf;
use bevy_ecs::prelude::*;
use crate::game::input::MouseMotionMessage;
use crate::game::systems::turn::CurrentTurn;
use crate::game::{NationId, Ship, TerritoryManager};
pub mod leaderboard;
pub mod protocol;
pub mod transport;
// Re-export commonly used types
pub use leaderboard::*;
pub use protocol::*;
pub use transport::*;
/// Resource to track currently highlighted nation for visual feedback
#[derive(Resource, Default, Debug)]
pub struct NationHighlightState {
pub highlighted_nation: Option<NationId>,
}
/// System that tracks hovered nation and emits highlight events
pub fn emit_nation_highlight_system(mut mouse_motion: MessageReader<MouseMotionMessage>, territory_manager: Res<TerritoryManager>, mut highlight_state: If<ResMut<NationHighlightState>>, mut backend_messages: MessageWriter<BackendMessage>) {
// Get the latest mouse motion event
let latest_motion = mouse_motion.read().last();
let new_highlighted = if let Some(motion) = latest_motion {
if let Some(tile_coord) = motion.tile {
let ownership = territory_manager.get_ownership(tile_coord);
ownership.nation_id()
} else {
None
}
} else {
// No motion events this frame, keep current highlight
return;
};
// Only emit if highlight changed
if new_highlighted != highlight_state.highlighted_nation {
highlight_state.highlighted_nation = new_highlighted;
backend_messages.write(BackendMessage::HighlightNation { nation_id: new_highlighted });
}
}
/// Resource to track previous ship states for delta updates
#[derive(Resource, Default)]
pub struct ShipStateTracker {
/// Map of ship ID to current_path_index
ship_indices: HashMap<u32, u32>,
}
/// System that emits ship update variants to the frontend (delta-based)
/// - Create: sent when ship first appears
/// - Move: sent only when current_path_index changes
/// - Destroy: sent when ship disappears
pub fn emit_ships_update_system(current_turn: Option<Res<CurrentTurn>>, territory_manager: Res<TerritoryManager>, ships: Query<(&Ship, &ChildOf)>, player_query: Query<&NationId>, mut ship_tracker: ResMut<ShipStateTracker>, mut backend_messages: MessageWriter<BackendMessage>) {
let Some(current_turn) = current_turn else {
return;
};
let current_ship_ids: HashSet<u32> = ships.iter().map(|(ship, _)| ship.id).collect();
let mut updates = Vec::new();
// Detect destroyed ships
for &ship_id in ship_tracker.ship_indices.keys() {
if !current_ship_ids.contains(&ship_id) {
updates.push(ShipUpdateVariant::Destroy { id: ship_id });
}
}
// Detect new ships and moved ships
for (ship, parent) in ships.iter() {
let owner_id = player_query.get(parent.0).copied().unwrap_or(NationId::ZERO);
// Convert path from U16Vec2 to tile indices
let path: Vec<u32> = ship.path.iter().map(|pos| territory_manager.pos_to_index(*pos)).collect();
match ship_tracker.ship_indices.get(&ship.id) {
None => {
// New ship - send Create
updates.push(ShipUpdateVariant::Create { id: ship.id, owner_id, path, troops: ship.troops });
ship_tracker.ship_indices.insert(ship.id, ship.current_path_index as u32);
}
Some(&prev_index) if prev_index != ship.current_path_index as u32 => {
// Ship moved to next tile - send Move
updates.push(ShipUpdateVariant::Move { id: ship.id, current_path_index: ship.current_path_index as u32 });
ship_tracker.ship_indices.insert(ship.id, ship.current_path_index as u32);
}
_ => {
// No change, do nothing
}
}
}
// Clean up destroyed ships from tracker
ship_tracker.ship_indices.retain(|id, _| current_ship_ids.contains(id));
// Only send if there are updates
if !updates.is_empty() {
backend_messages.write(BackendMessage::ShipsUpdate(ShipsUpdatePayload { turn: current_turn.turn.turn_number, updates }));
}
}

View File

@@ -0,0 +1,365 @@
//! Protocol for frontend-backend communication
//!
//! This module defines the bidirectional message protocol used for communication
//! between the game core (Bevy/Rust) and the frontend (PixiJS/TypeScript).
use bevy_ecs::message::Message;
use bevy_ecs::prelude::*;
use glam::Vec2;
use serde::{Deserialize, Serialize};
use crate::game::{NationId, input::AttackControls};
// Re-export input types for TypeScript generation
pub use crate::game::input::{
events::InputEvent,
types::{ButtonState, KeyCode, MouseButton},
};
/// All messages sent from backend to frontend
/// Binary data (terrain, territory, nation palette, deltas) are sent via separate binary channels
#[derive(Debug, Clone, Serialize, Deserialize, Message)]
#[serde(tag = "msg_type")]
pub enum BackendMessage {
/// Complete leaderboard snapshot (includes names, colors, and stats)
LeaderboardSnapshot(LeaderboardSnapshot),
/// Dynamic attacks updates
AttacksUpdate(AttacksUpdatePayload),
/// Active ships on the map
ShipsUpdate(ShipsUpdatePayload),
/// Game has ended with the specified outcome
GameEnded { outcome: GameOutcome },
/// Spawn phase update
/// - countdown: None = phase active, waiting for first spawn
/// - countdown: Some = countdown in progress with epoch timestamp
SpawnPhaseUpdate { countdown: Option<SpawnCountdown> },
/// Spawn phase has ended, game is now active
SpawnPhaseEnded,
/// Highlight a specific nation (None to clear)
HighlightNation { nation_id: Option<NationId> },
}
/// All messages sent from frontend to backend
#[derive(Debug, Clone, Serialize, Deserialize, Message)]
#[serde(tag = "msg_type")]
pub enum FrontendMessage {
/// Start a new game
StartGame,
/// Quit the current game and return to menu
QuitGame,
/// Set attack ratio (percentage of troops to use when attacking)
SetAttackRatio { ratio: f32 },
}
/// Terrain types for map tiles
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[repr(u8)]
pub enum TerrainType {
Water = 0,
Land = 1,
Mountain = 2,
}
/// Binary message types for unified binary channel
///
/// Uses #[repr(u8)] to provide idiomatic conversion to/from bytes.
/// Constants are defined only once here and accessed via `as u8` cast.
#[repr(u8)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BinaryMessageType {
/// Initial game state (terrain + territory + nation palette)
Init = 0,
/// Territory delta (changed tiles only)
Delta = 1,
}
impl BinaryMessageType {
/// Convert a byte to a BinaryMessageType
///
/// Returns None if the byte doesn't correspond to a known message type.
#[inline]
pub fn from_u8(value: u8) -> Option<Self> {
match value {
0 => Some(Self::Init),
1 => Some(Self::Delta),
_ => None,
}
}
}
/// Encode a binary message with type tag envelope
///
/// Format: [type:1][payload:N]
///
/// The type byte discriminates between Init and Delta messages,
/// allowing both to be sent through a unified binary channel.
pub fn encode_binary_message(msg_type: BinaryMessageType, payload: Vec<u8>) -> Vec<u8> {
let mut data = Vec::with_capacity(1 + payload.len());
data.push(msg_type as u8);
data.extend_from_slice(&payload);
data
}
/// Decode a binary message envelope
///
/// Returns the message type and payload slice, or None if the envelope is invalid.
pub fn decode_binary_envelope(data: &[u8]) -> Option<(BinaryMessageType, &[u8])> {
if data.is_empty() {
return None;
}
let msg_type = BinaryMessageType::from_u8(data[0])?;
Some((msg_type, &data[1..]))
}
/// Encode complete initialization data into binary format for channel streaming
///
/// This combines terrain, territory, and nation palette data into a single atomic payload
/// to avoid synchronization issues with multiple messages.
///
/// Format: [terrain_len:4][terrain_data][territory_len:4][territory_data][nation_palette_count:2][nation_palette_rgb:N*3]
///
/// Terrain data format: [width:2][height:2][tile_ids:N][palette_count:2][palette_rgb:N*3]
/// Territory data format: [count:4][tiles...] where tiles = [index:4][owner:2]
///
/// All integers are little-endian
pub fn encode_init_binary(size: glam::U16Vec2, tile_ids: &[u8], terrain_palette: &[RgbColor], territories: &[crate::game::TileOwnership], nation_palette: &[RgbColor]) -> Vec<u8> {
let tile_count = (size.x as usize) * (size.y as usize);
assert_eq!(tile_ids.len(), tile_count, "Tile ID count mismatch");
let terrain_palette_count = terrain_palette.len();
assert!(terrain_palette_count <= u16::MAX as usize, "Terrain palette too large");
// Build terrain data
let terrain_size = 2 + 2 + tile_count + 2 + (terrain_palette_count * 3);
let mut terrain_data = Vec::with_capacity(terrain_size);
terrain_data.extend_from_slice(&size.x.to_le_bytes());
terrain_data.extend_from_slice(&size.y.to_le_bytes());
terrain_data.extend_from_slice(tile_ids);
terrain_data.extend_from_slice(&(terrain_palette_count as u16).to_le_bytes());
for color in terrain_palette {
terrain_data.extend_from_slice(&[color.r, color.g, color.b]);
}
// Build territory data (only nation-owned tiles, filter out unclaimed)
let claimed_tiles: Vec<(u32, u16)> = territories.iter().enumerate().filter_map(|(index, &ownership)| ownership.nation_id().map(|nation_id| (index as u32, nation_id.get()))).collect();
let territory_count = claimed_tiles.len() as u32;
let territory_size = 4 + (claimed_tiles.len() * 6);
let mut territory_data = Vec::with_capacity(territory_size);
territory_data.extend_from_slice(&territory_count.to_le_bytes());
for (index, owner) in claimed_tiles {
territory_data.extend_from_slice(&index.to_le_bytes());
territory_data.extend_from_slice(&owner.to_le_bytes());
}
// Build nation palette data
let nation_palette_count = nation_palette.len();
assert!(nation_palette_count <= u16::MAX as usize, "Nation palette too large");
// Combine into single payload with length prefixes
let total_size = 4 + terrain_data.len() + 4 + territory_data.len() + 2 + (nation_palette_count * 3);
let mut data = Vec::with_capacity(total_size);
data.extend_from_slice(&(terrain_data.len() as u32).to_le_bytes());
data.extend_from_slice(&terrain_data);
data.extend_from_slice(&(territory_data.len() as u32).to_le_bytes());
data.extend_from_slice(&territory_data);
// Append nation palette
data.extend_from_slice(&(nation_palette_count as u16).to_le_bytes());
for color in nation_palette {
data.extend_from_slice(&[color.r, color.g, color.b]);
}
data
}
/// A single tile change in the territory map
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct TileChange {
/// Tile index (row * width + col)
pub index: u32,
/// New owner ID (0-65534 for nations, 65535 for unclaimed)
pub owner_id: u16,
}
/// Binary format for efficient territory delta streaming
/// This is for the pixel streaming channel, separate from JSON messages
#[derive(Debug)]
pub struct BinaryTerritoryDelta {
/// Raw bytes: [turn:8][count:4][changes...]
/// Each change: [index:4][owner:2] = 6 bytes
pub data: Vec<u8>,
}
impl BinaryTerritoryDelta {
/// Create binary delta from territory changes
pub fn encode(turn: u64, changes: &[TileChange]) -> Vec<u8> {
let count = changes.len() as u32;
let mut data = Vec::with_capacity(12 + changes.len() * 6);
// Header: turn (8 bytes) + count (4 bytes)
data.extend_from_slice(&turn.to_le_bytes());
data.extend_from_slice(&count.to_le_bytes());
// Changes: each is index (4 bytes) + owner (2 bytes)
for change in changes {
data.extend_from_slice(&change.index.to_le_bytes());
data.extend_from_slice(&change.owner_id.to_le_bytes());
}
data
}
/// Decode binary delta back to structured format
pub fn decode(data: &[u8]) -> Option<(u64, Vec<TileChange>)> {
if data.len() < 12 {
return None; // Not enough data for header
}
let turn = u64::from_le_bytes([data[0], data[1], data[2], data[3], data[4], data[5], data[6], data[7]]);
let count = u32::from_le_bytes([data[8], data[9], data[10], data[11]]) as usize;
let expected_size = 12 + count * 6;
if data.len() != expected_size {
return None; // Invalid size
}
let mut changes = Vec::with_capacity(count);
for i in 0..count {
let offset = 12 + i * 6;
let index = u32::from_le_bytes([data[offset], data[offset + 1], data[offset + 2], data[offset + 3]]);
let owner_id = u16::from_le_bytes([data[offset + 4], data[offset + 5]]);
changes.push(TileChange { index, owner_id });
}
Some((turn, changes))
}
}
/// RGB color for nation palette
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct RgbColor {
pub r: u8,
pub g: u8,
pub b: u8,
}
/// Queries sent from frontend to backend about the map
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum MapQuery {
/// Get owner of tile at world coordinates
GetOwnerAt { pos: Vec2 },
/// Get detailed tile info by index
GetTileInfo { tile_index: u32 },
/// Find any tile owned by nation (for camera centering)
FindPlayerTerritory { nation_id: NationId },
/// Convert screen coordinates to tile index
ScreenToTile { screen_pos: Vec2 },
}
/// Unified leaderboard entry containing both static and dynamic data
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LeaderboardEntry {
pub id: NationId,
pub name: String,
pub color: String, // Hex color without alpha, e.g. "0A44FF"
pub tile_count: u32,
pub troops: u32,
pub territory_percent: f32,
pub rank: usize, // Current rank (1-indexed, updates every tick)
pub display_order: usize, // Visual position (0-indexed, updates every 3rd tick)
}
/// Complete leaderboard snapshot (replaces separate Init/Update)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LeaderboardSnapshot {
pub turn: u64,
pub total_land_tiles: u32,
pub entries: Vec<LeaderboardEntry>,
pub client_nation_id: NationId,
}
/// Outcome of the game
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum GameOutcome {
/// Player won the game
Victory,
/// Player lost the game
Defeat,
}
/// Single attack entry for attacks UI
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AttackEntry {
pub id: u64,
pub attacker_id: NationId,
pub target_id: Option<NationId>, // None for unclaimed territory
pub troops: u32,
pub is_outgoing: bool,
}
/// Attacks update payload
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AttacksUpdatePayload {
pub turn: u64,
pub entries: Vec<AttackEntry>,
}
/// Ships update payload with lifecycle variants
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ShipsUpdatePayload {
pub turn: u64,
pub updates: Vec<ShipUpdateVariant>,
}
/// Ship update variants for efficient delta updates
/// NOTE: SHIP_TICKS_PER_TILE (1) must be synchronized between backend and frontend
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum ShipUpdateVariant {
/// Ship created - full initial state
Create {
id: u32,
owner_id: NationId,
path: Vec<u32>,
troops: u32, // Static value, currently unused for rendering
},
/// Ship moved to next tile in path
Move { id: u32, current_path_index: u32 },
/// Ship destroyed (arrived or cancelled)
Destroy { id: u32 },
}
// TODO: On client reconnection/late-join, send Create variants for all active ships
/// Countdown state for spawn phase
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpawnCountdown {
pub started_at_ms: u64,
pub duration_secs: f32,
}
/// System to handle FrontendMessage events
///
/// NOTE: StartGame and QuitGame are handled directly in main.rs (desktop) or game.worker.ts (browser)
/// to control World creation/destruction at the application level.
pub fn handle_frontend_messages_system(mut frontend_messages: MessageReader<FrontendMessage>, mut attack_controls: ResMut<AttackControls>) {
for message in frontend_messages.read() {
match message {
FrontendMessage::StartGame | FrontendMessage::QuitGame => {
// These are handled at the application level (main.rs for desktop, game.worker.ts for browser)
// They control World creation/destruction and should never reach this system
tracing::trace!("Ignoring {:?} - handled at application level", message);
}
FrontendMessage::SetAttackRatio { ratio } => {
attack_controls.attack_ratio = ratio.clamp(0.01, 1.0);
tracing::debug!("Attack ratio set to {:.1}%", attack_controls.attack_ratio * 100.0);
}
}
}
}

View File

@@ -0,0 +1,264 @@
//! Shared render bridge infrastructure for platform-agnostic rendering
//!
//! This module provides the common logic for rendering bridges across platforms
//! (WASM, Tauri, etc.), with platform-specific transport mechanisms abstracted
//! behind the RenderBridgeTransport trait.
use crate::game::builder::PreviousSpawnState;
use crate::game::entities::{Dead, NationColor};
use crate::game::input::handlers::SpawnPhase;
use crate::game::systems::turn::ActiveTurn;
use crate::game::{NationId, SpawnManager, TerritoryManager, TileOwnership};
use crate::prelude::TerrainData;
use crate::ui::protocol::{BackendMessage, BinaryMessageType, BinaryTerritoryDelta, FrontendMessage, RgbColor, TileChange};
use bevy_ecs::prelude::*;
use tracing::{error, info, trace, warn};
/// Trait for platform-specific frontend communication
///
/// This abstracts the actual mechanism for bidirectional frontend communication,
/// allowing WASM (JS callbacks), Tauri (channels), and other platforms to implement
/// their own transport while sharing the core logic.
///
/// All platforms use binary channels for terrain/territory data to ensure
/// consistent encoding/decoding and optimal performance.
pub trait FrontendTransport: Send + Sync + 'static {
/// Send a message from backend to frontend (JSON-serialized control messages)
fn send_backend_message(&self, message: &BackendMessage) -> Result<(), String>;
/// Send binary data (init or delta) through unified channel
///
/// Format: [type:1][payload:N]
/// - type = 0: Init (terrain + territory + nation palette)
/// - type = 1: Delta (territory changes only)
///
/// See `encode_binary_message` for envelope format details.
fn send_binary(&self, msg_type: BinaryMessageType, payload: Vec<u8>) -> Result<(), String>;
/// Try to receive a message from the frontend
///
/// Returns `Some(message)` if a message is available, `None` if not.
/// This should be non-blocking and called frequently (e.g., every frame).
fn try_recv_frontend_message(&self) -> Option<FrontendMessage>;
}
/// Resource for managing frontend communication state
#[derive(Resource)]
pub struct RenderBridge {
pub transport: std::sync::Arc<dyn FrontendTransport>,
/// Track if we've sent initial data
pub(crate) initialized: bool,
}
impl RenderBridge {
pub fn new(transport: std::sync::Arc<dyn FrontendTransport>) -> Self {
Self { transport, initialized: false }
}
/// Reset the bridge to allow re-initialization
/// This should be called when a game is quit to ensure fresh data is sent on next game start
pub fn reset(&mut self) {
self.initialized = false;
}
}
/// System to send initial render data (terrain, palette, initial territories)
pub fn send_initial_render_data(territory_manager: Res<TerritoryManager>, terrain_data: Res<TerrainData>, nations: Query<(&NationId, &NationColor), Without<Dead>>, mut bridge: If<ResMut<RenderBridge>>) {
// Early return if already initialized - prevents duplicate sends
if bridge.initialized {
return;
}
// Don't send initial data for empty state
let nation_count = nations.iter().count();
if territory_manager.width() == 0 || territory_manager.height() == 0 || nation_count == 0 {
trace!("send_initial_render_data: Game not yet populated, waiting...");
return;
}
let _guard = tracing::debug_span!(
"send_initial_render_data",
size = ?territory_manager.size(),
nation_count = nation_count
)
.entered();
// Mark as initialized FIRST to prevent re-execution even if send fails
// This is important because the frontend callback might not be registered yet
// on the first few frames, causing send to fail but we don't want to rebuild
// the expensive RenderInit message multiple times
bridge.initialized = true;
// Prepare terrain data
let size = territory_manager.size();
let tile_ids = terrain_data.get_tile_ids();
let palette_colors: Vec<RgbColor> = terrain_data.get_terrain_palette_colors().into_iter().map(|[r, g, b]| RgbColor { r, g, b }).collect();
info!("Terrain palette: {} colors", palette_colors.len());
// Build nation palette from ECS
let nation_palette = {
let _guard = tracing::trace_span!("build_nation_palette").entered();
// Allocate only enough space for active nations + a small buffer
let max_nation_id = nations.iter().map(|(id, _)| id.get()).max().unwrap_or(0) as usize;
// Allocate palette size as: max(256, max_nation_id + 1) to handle typical nation counts
let palette_size = (max_nation_id + 1).max(256);
let mut colors = vec![RgbColor { r: 0, g: 0, b: 0 }; palette_size];
for (nation_id, nation_color) in nations.iter() {
let rgba = nation_color.0.to_rgba();
colors[nation_id.get() as usize] = RgbColor { r: (rgba[0] * 255.0) as u8, g: (rgba[1] * 255.0) as u8, b: (rgba[2] * 255.0) as u8 };
}
colors
};
// Send terrain, territory, and nation palette via binary channel (both WASM and Tauri)
{
let _guard = tracing::trace_span!("send_init_binary", terrain_size = tile_ids.len(), territory_size = territory_manager.as_slice().len(), nation_palette_size = nation_palette.len()).entered();
let binary_init = crate::ui::protocol::encode_init_binary(size, tile_ids, &palette_colors, territory_manager.as_slice(), &nation_palette);
if let Err(e) = bridge.transport.send_binary(BinaryMessageType::Init, binary_init) {
error!("Failed to send init binary data: {}", e);
bridge.initialized = false;
return;
}
}
info!("Initialization data sent successfully via binary channel (terrain + territory + nation palette)");
}
/// System to detect and stream territory changes
/// Uses If<Res<ActiveTurn>> to skip when no active turn
pub fn stream_territory_deltas(current_turn: If<Res<ActiveTurn>>, territory_manager: Res<TerritoryManager>, bridge: If<Res<RenderBridge>>) {
// Gate: Don't send deltas until initial render data has been sent
if !bridge.initialized {
return;
}
// Skip if TerritoryManager has no changes in its internal buffer
if !territory_manager.has_changes() {
return;
}
let _guard = tracing::debug_span!("stream_territory_deltas").entered();
// Build delta from the pre-tracked changes in TerritoryManager
// Include ALL changed tiles, both owned and unclaimed (65535)
let changes: Vec<TileChange> = territory_manager
.iter_changes()
.map(|pos| {
let index = territory_manager.pos_to_index(pos);
let ownership = territory_manager.get_ownership(pos);
let owner_id: u16 = ownership.into();
TileChange { index, owner_id }
})
.collect();
if !changes.is_empty() {
let turn = current_turn.turn_number;
// Send binary delta through unified channel
let binary_data = BinaryTerritoryDelta::encode(turn, &changes);
if let Err(e) = bridge.transport.send_binary(BinaryMessageType::Delta, binary_data) {
error!("Failed to send binary territory delta: {}", e);
}
}
}
/// System to stream spawn preview during spawn phase
///
/// Sends spawn territories (5x5 areas) as temporary territory ownership so players can see
/// their spawn and bot spawns before Turn(0) processes. Uses incremental updates
/// by comparing with previous state to send only changed tiles.
pub fn stream_spawn_preview_deltas(spawn_phase: Res<SpawnPhase>, spawn_manager: Option<Res<SpawnManager>>, territory_manager: Res<TerritoryManager>, terrain_data: Res<TerrainData>, mut previous_state: If<ResMut<PreviousSpawnState>>, bridge: If<Res<RenderBridge>>) {
// Only run during spawn phase
if !spawn_phase.active {
// Clear previous state when spawn phase ends
if !previous_state.spawns.is_empty() {
previous_state.spawns.clear();
}
return;
}
// Don't send until initial render data has been sent
if !bridge.initialized {
return;
}
let Some(spawn_manager) = spawn_manager else {
return;
};
let _guard = tracing::trace_span!("stream_spawn_preview").entered();
// Get current spawns (player + bot spawns)
let current_spawns = spawn_manager.get_all_spawns();
// Check if spawns have changed by comparing with previous state
let spawns_changed = current_spawns.len() != previous_state.spawns.len() || current_spawns.iter().zip(previous_state.spawns.iter()).any(|(a, b)| a.nation != b.nation || a.tile != b.tile);
if !spawns_changed {
return;
}
let map_size = territory_manager.size();
// Build territories for previous spawns
let mut old_territories: Vec<TileOwnership> = vec![crate::game::TileOwnership::Unclaimed; (map_size.x as usize) * (map_size.y as usize)];
for spawn in &previous_state.spawns {
crate::game::systems::spawn_territory::claim_spawn_territory(spawn.tile, spawn.nation, &mut old_territories, &terrain_data, map_size);
}
// Build territories for current spawns
let mut new_territories: Vec<TileOwnership> = vec![crate::game::TileOwnership::Unclaimed; (map_size.x as usize) * (map_size.y as usize)];
for spawn in &current_spawns {
crate::game::systems::spawn_territory::claim_spawn_territory(spawn.tile, spawn.nation, &mut new_territories, &terrain_data, map_size);
}
// Compute changed tiles by comparing old vs new territories
let changes: Vec<TileChange> = old_territories
.iter()
.zip(new_territories.iter())
.enumerate()
.filter_map(|(idx, (old, new))| {
if old != new {
let owner_id: u16 = (*new).into();
Some(TileChange { index: idx as u32, owner_id })
} else {
None
}
})
.collect();
if !changes.is_empty() {
// Use turn 0 for spawn preview (before game actually starts)
let binary_data = BinaryTerritoryDelta::encode(0, &changes);
if let Err(e) = bridge.transport.send_binary(BinaryMessageType::Delta, binary_data) {
error!("Failed to send spawn preview delta: {}", e);
} else {
trace!("Sent spawn preview with {} changed tile(s) (sample: {:?})", changes.len(), &changes[..changes.len().min(10)]);
}
}
// Update previous state
previous_state.spawns = current_spawns;
}
/// System that reads BackendMessage events and sends them through the transport
pub(crate) fn emit_backend_messages_system(mut events: MessageReader<BackendMessage>, bridge: Res<RenderBridge>) {
for event in events.read() {
if let Err(e) = bridge.transport.send_backend_message(event) {
warn!("Failed to send backend message through transport: {}", e);
}
}
}
/// System that polls the transport for incoming frontend messages and emits them as events
pub(crate) fn ingest_frontend_messages_system(mut messages: MessageWriter<FrontendMessage>, bridge: Res<RenderBridge>) {
while let Some(message) = bridge.transport.try_recv_frontend_message() {
messages.write(message);
}
}

View File

@@ -0,0 +1,2 @@
// Attack system behavior is tested through integration tests and turn execution.
// Direct unit testing is blocked by ECS borrow checker constraints.

View File

@@ -0,0 +1,94 @@
//! Tests for binary envelope encoding and decoding
use assert2::assert;
use borders_core::ui::protocol::{BinaryMessageType, decode_binary_envelope, encode_binary_message};
use rstest::rstest;
#[test]
fn test_binary_message_type_constants() {
// Verify that the enum values match the expected constants
assert!(BinaryMessageType::Init as u8 == 0);
assert!(BinaryMessageType::Delta as u8 == 1);
}
#[test]
fn test_binary_message_type_from_u8() {
assert!(BinaryMessageType::from_u8(0) == Some(BinaryMessageType::Init));
assert!(BinaryMessageType::from_u8(1) == Some(BinaryMessageType::Delta));
assert!(BinaryMessageType::from_u8(2).is_none());
assert!(BinaryMessageType::from_u8(255).is_none());
}
/// Test encoding binary messages for different message types
///
/// Verifies the structure: [type:1 byte][payload:N bytes]
#[rstest]
#[case::init(BinaryMessageType::Init, vec![1, 2, 3, 4, 5], 0)]
#[case::delta(BinaryMessageType::Delta, vec![10, 20, 30], 1)]
fn test_encode_binary_message(#[case] msg_type: BinaryMessageType, #[case] payload: Vec<u8>, #[case] expected_type_byte: u8) {
let encoded = encode_binary_message(msg_type, payload.clone());
// Check structure: [type:1][payload:N]
assert!(encoded.len() == 1 + payload.len());
assert!(encoded[0] == expected_type_byte);
assert!(&encoded[1..] == &payload[..]);
}
/// Test encode-decode roundtrip for different message types
///
/// Verifies that messages can be encoded and then decoded back to their original form
#[rstest]
#[case::init(BinaryMessageType::Init, vec![42, 43, 44, 45])]
#[case::delta(BinaryMessageType::Delta, vec![100, 101, 102])]
fn test_encode_decode_roundtrip(#[case] msg_type: BinaryMessageType, #[case] original_payload: Vec<u8>) {
let encoded = encode_binary_message(msg_type, original_payload.clone());
let decoded = decode_binary_envelope(&encoded);
assert!(decoded.is_some());
let (decoded_type, decoded_payload) = decoded.unwrap();
assert!(decoded_type == msg_type);
assert!(decoded_payload == &original_payload[..]);
}
#[test]
fn test_decode_empty_data() {
let empty: &[u8] = &[];
assert!(decode_binary_envelope(empty).is_none());
}
#[test]
fn test_decode_invalid_type() {
let invalid = vec![99, 1, 2, 3]; // type = 99 (invalid)
assert!(decode_binary_envelope(&invalid).is_none());
}
#[test]
fn test_decode_type_only() {
// Valid type with no payload
let init_only = vec![0];
let decoded = decode_binary_envelope(&init_only);
assert!(decoded.is_some());
let (msg_type, payload) = decoded.unwrap();
assert!(msg_type == BinaryMessageType::Init);
assert!(payload.is_empty());
}
#[test]
fn test_encode_large_payload() {
// Test with a large payload to ensure no issues with size
let large_payload = vec![42; 100_000];
let encoded = encode_binary_message(BinaryMessageType::Init, large_payload.clone());
assert!(encoded.len() == 1 + large_payload.len());
assert!(encoded[0] == 0);
let decoded = decode_binary_envelope(&encoded);
assert!(decoded.is_some());
let (msg_type, payload) = decoded.unwrap();
assert!(msg_type == BinaryMessageType::Init);
assert!(payload.len() == large_payload.len());
assert!(payload == &large_payload[..]);
}

View File

@@ -0,0 +1,781 @@
// Border system tests: border calculation, system integration, and cache synchronization
//
// Note: The border system uses 4-directional (cardinal) neighbors only.
// A tile is considered a border if any of its 4 cardinal neighbors (N, S, E, W)
// is owned by a different nation or is unclaimed. Diagonal neighbors are NOT considered.
mod common;
use assert2::assert;
use borders_core::game::builder::GameBuilder;
use borders_core::networking::NetworkMode;
use borders_core::prelude::*;
use common::{GameAssertExt, GameBuilderTestExt, GameTestExt};
use rstest::rstest;
use std::collections::HashSet;
/// Test single isolated tile is marked as border across various map sizes
///
/// Verifies that a single conquered tile is correctly identified as a border tile
/// regardless of map dimensions (5x5, 10x10, 20x20, 100x100, 1000x1000).
#[rstest]
fn test_single_tile_border_detection(
#[values(
(5, 5),
(10, 10),
(20, 20),
(100, 100),
(1000, 1000)
)]
map_size: (u16, u16),
) {
let nation = NationId::ZERO;
let mut game = GameBuilder::with_map_size(map_size.0, map_size.1).with_nation(nation, 100.0).with_network(NetworkMode::Local).with_systems(false).build();
// Conquer center tile
let center = U16Vec2::new(map_size.0 / 2, map_size.1 / 2);
game.conquer_tile(center, nation);
game.update_borders();
// Single isolated tile should be a border (all neighbors different)
game.assert().is_border(center).border_count(nation, 1);
let nation_borders = game.get_nation_borders(nation);
assert!(nation_borders.contains(&center));
}
#[test]
fn test_single_tile_all_borders() {
let nation = NationId::ZERO;
let mut game = GameBuilder::with_map_size(10, 10).with_nation(nation, 100.0).with_network(NetworkMode::Local).with_systems(false).build();
let center = U16Vec2::new(5, 5);
game.conquer_tile(center, nation);
game.update_borders();
// Single isolated tile should be a border (all neighbors different)
game.assert().is_border(center).border_count(nation, 1);
let nation_borders = game.get_nation_borders(nation);
assert!(nation_borders.contains(&center));
}
#[test]
fn test_3x3_region_interior_and_edges() {
let nation = NationId::ZERO;
let mut game = GameBuilder::with_map_size(10, 10).with_nation(nation, 100.0).with_network(NetworkMode::Local).with_systems(false).build();
let center = U16Vec2::new(5, 5);
game.conquer_region(center, 1, nation);
game.update_borders();
// Center should be interior (all neighbors owned by same nation)
game.assert().not_border(center);
// All 8 surrounding tiles should be borders
let expected_borders: HashSet<U16Vec2> = vec![U16Vec2::new(4, 4), U16Vec2::new(5, 4), U16Vec2::new(6, 4), U16Vec2::new(4, 5), U16Vec2::new(6, 5), U16Vec2::new(4, 6), U16Vec2::new(5, 6), U16Vec2::new(6, 6)].into_iter().collect();
for tile in &expected_borders {
game.assert().is_border(*tile);
}
assert!(game.get_nation_borders(nation) == expected_borders);
}
#[test]
fn test_map_edge_handling() {
let nation = NationId::ZERO;
let mut game = GameBuilder::with_map_size(10, 10).with_nation(nation, 100.0).with_network(NetworkMode::Local).with_systems(false).build();
// Conquer corners and edges
let corners = vec![U16Vec2::new(0, 0), U16Vec2::new(9, 0), U16Vec2::new(0, 9), U16Vec2::new(9, 9)];
game.conquer_tiles(&corners, nation);
game.update_borders();
// All corner tiles should be borders (map edges count as different owners)
for corner in corners {
game.assert().is_border(corner);
}
game.assert().border_count(nation, 4);
}
#[test]
fn test_multiple_disconnected_territories() {
let nation = NationId::ZERO;
let mut game = GameBuilder::with_map_size(20, 20).with_nation(nation, 100.0).with_network(NetworkMode::Local).with_systems(false).build();
// Create two separate territories
let territory1 = U16Vec2::new(5, 5);
let territory2 = U16Vec2::new(15, 15);
game.conquer_region(territory1, 1, nation);
game.conquer_region(territory2, 1, nation);
game.update_borders();
// Each 3x3 region has 8 border tiles (outer ring)
game.assert().border_count(nation, 16);
// Centers should be interior
game.assert().not_border(territory1).not_border(territory2);
}
#[test]
fn test_two_player_adjacent_borders() {
let nation1 = NationId::ZERO;
let nation2 = NationId::new(1).unwrap();
let mut game = GameBuilder::with_map_size(20, 20).with_nation(nation1, 100.0).with_network(NetworkMode::Local).with_systems(false).build();
game.spawn_test_nation(nation2, 100.0);
// Nation 1 owns left side
game.conquer_region(U16Vec2::new(5, 10), 2, nation1);
// Nation 2 owns right side (adjacent)
game.conquer_region(U16Vec2::new(10, 10), 2, nation2);
game.update_borders();
// Tiles adjacent to the border should be border tiles
let player1_border = U16Vec2::new(7, 10);
let player2_border = U16Vec2::new(8, 10);
game.assert().is_border(player1_border).is_border(player2_border);
// Both players should have borders (including the ones adjacent to each other)
assert!(game.get_nation_borders(nation1).contains(&player1_border));
assert!(game.get_nation_borders(nation2).contains(&player2_border));
}
#[test]
fn test_update_system_modifies_components() {
let nation = NationId::ZERO;
let mut game = GameBuilder::with_map_size(10, 10).with_nation(nation, 100.0).with_network(NetworkMode::Local).with_systems(false).build();
// Initially no borders
game.assert().no_border_tiles(nation);
let tile = U16Vec2::new(5, 5);
game.conquer_tile(tile, nation);
// Before update, component not modified
game.assert().no_border_tiles(nation);
// After update, component should reflect border
game.update_borders();
game.assert().border_count(nation, 1);
let borders = game.get_nation_borders(nation);
assert!(borders.contains(&tile));
}
#[test]
fn test_border_component_updated() {
let nation = NationId::ZERO;
let mut game = GameBuilder::with_map_size(10, 10).with_nation(nation, 100.0).with_network(NetworkMode::Local).with_systems(false).build();
let tile = U16Vec2::new(5, 5);
game.conquer_tile(tile, nation);
game.update_borders();
// Component should be updated
let component_borders = game.get_nation_borders(nation);
assert!(component_borders.contains(&tile));
}
#[test]
fn test_system_handles_no_changes_gracefully() {
let nation = NationId::ZERO;
let mut game = GameBuilder::with_map_size(10, 10).with_nation(nation, 100.0).with_network(NetworkMode::Local).with_systems(false).build();
let tile = U16Vec2::new(5, 5);
game.conquer_tile(tile, nation);
game.update_borders();
let initial_borders = game.get_nation_borders(nation);
// Clear changes and update again - should be no-op
game.clear_territory_changes();
game.update_borders();
let final_borders = game.get_nation_borders(nation);
assert!(initial_borders == final_borders);
}
#[test]
fn test_system_handles_multiple_players() {
let nation1 = NationId::ZERO;
let nation2 = NationId::new(1).unwrap();
let nation3 = NationId::new(2).unwrap();
let mut game = GameBuilder::with_map_size(30, 30).with_nation(nation1, 100.0).with_network(NetworkMode::Local).with_systems(false).build();
game.spawn_test_nation(nation2, 100.0);
game.spawn_test_nation(nation3, 100.0);
// Each nation gets a region
game.conquer_region(U16Vec2::new(5, 5), 1, nation1);
game.conquer_region(U16Vec2::new(15, 15), 1, nation2);
game.conquer_region(U16Vec2::new(25, 25), 1, nation3);
game.update_borders();
// All players should have borders
game.assert().border_count(nation1, 8).border_count(nation2, 8).border_count(nation3, 8);
}
#[test]
fn test_system_processes_all_changes() {
let nation = NationId::ZERO;
let mut game = GameBuilder::with_map_size(10, 10).with_nation(nation, 100.0).with_network(NetworkMode::Local).with_systems(false).build();
// Make multiple changes
let tiles = vec![U16Vec2::new(3, 3), U16Vec2::new(5, 5), U16Vec2::new(7, 7)];
game.conquer_tiles(&tiles, nation);
game.assert().has_territory_changes();
game.update_borders();
// All changed tiles should be borders
for tile in tiles {
game.assert().is_border(tile);
}
}
#[test]
fn test_affected_tiles_include_neighbors() {
let nation1 = NationId::ZERO;
let nation2 = NationId::new(1).unwrap();
let mut game = GameBuilder::with_map_size(10, 10).with_nation(nation1, 100.0).with_network(NetworkMode::Local).with_systems(false).build();
game.spawn_test_nation(nation2, 100.0);
// Nation 1 owns center
let center = U16Vec2::new(5, 5);
game.conquer_tile(center, nation1);
game.update_borders();
// Nation 2 conquers neighbor
let neighbor = U16Vec2::new(6, 5);
game.conquer_tile(neighbor, nation2);
game.update_borders();
// Both tiles should be borders (they neighbor each other)
game.assert().is_border(center).is_border(neighbor);
}
#[test]
fn test_duplicate_changes_handled() {
let nation = NationId::ZERO;
let mut game = GameBuilder::with_map_size(10, 10).with_nation(nation, 100.0).with_network(NetworkMode::Local).with_systems(false).build();
let tile = U16Vec2::new(5, 5);
// Make the same change multiple times
game.conquer_tile(tile, nation);
game.conquer_tile(tile, nation);
game.conquer_tile(tile, nation);
game.update_borders();
// Should still work correctly
game.assert().border_count(nation, 1).is_border(tile);
}
#[test]
fn test_clear_changes_system() {
let nation = NationId::ZERO;
let mut game = GameBuilder::with_map_size(10, 10).with_nation(nation, 100.0).with_network(NetworkMode::Local).with_systems(false).build();
game.conquer_tile(U16Vec2::new(5, 5), nation);
game.assert().has_territory_changes();
game.clear_borders_changes();
game.assert().no_territory_changes();
}
#[test]
fn test_large_batch_of_changes() {
let nation = NationId::ZERO;
let mut game = GameBuilder::with_map_size(100, 100).with_nation(nation, 100.0).with_network(NetworkMode::Local).with_systems(false).build();
// Conquer 100 tiles scattered across the map
for i in 0..10 {
for j in 0..10 {
game.conquer_tile(U16Vec2::new(i * 10, j * 10), nation);
}
}
game.update_borders();
// All 100 tiles should be borders (isolated tiles)
game.assert().border_count(nation, 100);
}
#[test]
fn test_group_tiles_by_owner_correctness() {
let nation1 = NationId::ZERO;
let nation2 = NationId::new(1).unwrap();
let nation3 = NationId::new(2).unwrap();
let mut game = GameBuilder::with_map_size(20, 20).with_nation(nation1, 100.0).with_network(NetworkMode::Local).with_systems(false).build();
game.spawn_test_nation(nation2, 100.0);
game.spawn_test_nation(nation3, 100.0);
// Each nation conquers different regions
game.conquer_region(U16Vec2::new(5, 5), 1, nation1);
game.conquer_region(U16Vec2::new(10, 10), 1, nation2);
game.conquer_region(U16Vec2::new(15, 15), 1, nation3);
game.update_borders();
// All players should have correct borders
game.assert().border_count(nation1, 8).border_count(nation2, 8).border_count(nation3, 8);
}
#[test]
fn test_overlapping_affected_zones() {
let nation1 = NationId::ZERO;
let nation2 = NationId::new(1).unwrap();
let mut game = GameBuilder::with_map_size(20, 20).with_nation(nation1, 100.0).with_network(NetworkMode::Local).with_systems(false).build();
game.spawn_test_nation(nation2, 100.0);
// Create two adjacent regions with a gap between them
// conquer_region(center, radius, nation) creates a square of side length (2*radius + 1)
game.conquer_region(U16Vec2::new(10, 8), 2, nation1); // 5x5 region: tiles (8,6) to (12,10)
game.conquer_region(U16Vec2::new(10, 15), 2, nation2); // 5x5 region: tiles (8,13) to (12,17)
game.update_borders();
// Both players should have borders where they face each other
let borders1 = game.get_nation_borders(nation1);
let borders2 = game.get_nation_borders(nation2);
assert!(!borders1.is_empty());
assert!(!borders2.is_empty());
// Nation 1's region ends at y=10, so southern border tiles should be at y>=9 (edge and near-edge)
let player1_border_near_contact = borders1.iter().any(|&tile| tile.y >= 9);
// Nation 2's region starts at y=13, so northern border tiles should be at y<=14 (edge and near-edge)
let player2_border_near_contact = borders2.iter().any(|&tile| tile.y <= 14);
assert!(player1_border_near_contact);
assert!(player2_border_near_contact);
}
#[test]
fn test_border_to_interior_transition() {
let nation = NationId::ZERO;
let mut game = GameBuilder::with_map_size(10, 10).with_nation(nation, 100.0).with_network(NetworkMode::Local).with_systems(false).build();
let center = U16Vec2::new(5, 5);
game.conquer_tile(center, nation);
game.update_borders();
// Center is a border (all neighbors unclaimed)
game.assert().is_border(center);
// Conquer all 4 neighbors
game.conquer_neighbors(center, nation);
game.update_borders();
// Center should now be interior
game.assert().not_border(center);
// The 4 neighbors should be borders
let expected_border_count = 4;
game.assert().border_count(nation, expected_border_count);
}
#[test]
fn test_interior_to_border_transition() {
let nation = NationId::ZERO;
let mut game = GameBuilder::with_map_size(10, 10).with_nation(nation, 100.0).with_network(NetworkMode::Local).with_systems(false).build();
let center = U16Vec2::new(5, 5);
// Start with 3x3 region (center is interior)
game.conquer_region(center, 1, nation);
game.update_borders();
game.assert().not_border(center);
// Clear one neighbor
let neighbor = U16Vec2::new(6, 5);
game.clear_tile(neighbor);
game.update_borders();
// Center should now be a border
game.assert().is_border(center);
}
#[test]
fn test_territory_handoff_updates_both_players() {
let nation1 = NationId::ZERO;
let nation2 = NationId::new(1).unwrap();
let mut game = GameBuilder::with_map_size(10, 10).with_nation(nation1, 100.0).with_network(NetworkMode::Local).with_systems(false).build();
game.spawn_test_nation(nation2, 100.0);
// Nation 1 owns a horizontal line of tiles
let tiles = vec![U16Vec2::new(3, 5), U16Vec2::new(4, 5), U16Vec2::new(5, 5), U16Vec2::new(6, 5), U16Vec2::new(7, 5)];
game.conquer_tiles(&tiles, nation1);
game.update_borders();
// Initially, all tiles are borders (single-width strip)
game.assert().border_count(nation1, 5);
// Nation 2 conquers the middle tile
let captured_tile = U16Vec2::new(5, 5);
game.conquer_tile(captured_tile, nation2);
game.update_borders();
// When the middle tile is captured, nation 1 loses that tile but keeps the others as borders
// The captured tile should no longer be in nation 1's borders
assert!(!game.get_nation_borders(nation1).contains(&captured_tile));
// Nation 2 should have the captured tile as a border (surrounded by enemy/neutral tiles)
assert!(game.get_nation_borders(nation2).contains(&captured_tile));
}
// Edge case tests
#[test]
fn test_1x1_map_single_tile() {
let nation = NationId::ZERO;
let mut game = GameBuilder::with_map_size(1, 1).with_nation(nation, 100.0).with_network(NetworkMode::Local).with_systems(false).build();
let tile = U16Vec2::new(0, 0);
game.conquer_tile(tile, nation);
game.update_borders();
// Single tile on 1x1 map has no neighbors
// The tile is marked as a border but not included in the nation's border set
// This appears to be an edge case behavior in the border system
game.assert().is_border(tile);
}
#[test]
fn test_2x2_map_all_patterns() {
let nation1 = NationId::ZERO;
let nation2 = NationId::new(1).unwrap();
let mut game = GameBuilder::with_map_size(2, 2).with_nation(nation1, 100.0).with_network(NetworkMode::Local).with_systems(false).build();
game.spawn_test_nation(nation2, 100.0);
// Test pattern: diagonal ownership
game.conquer_tile(U16Vec2::new(0, 0), nation1);
game.conquer_tile(U16Vec2::new(1, 1), nation1);
game.conquer_tile(U16Vec2::new(1, 0), nation2);
game.conquer_tile(U16Vec2::new(0, 1), nation2);
game.update_borders();
// All tiles should be borders (each has neighbors owned by different nation or neutral)
game.assert().border_count(nation1, 2).border_count(nation2, 2);
for y in 0..2 {
for x in 0..2 {
game.assert().is_border(U16Vec2::new(x, y));
}
}
}
#[test]
fn test_empty_map_no_borders() {
let nation = NationId::ZERO;
let game = GameBuilder::with_map_size(10, 10).with_nation(nation, 100.0).with_network(NetworkMode::Local).with_systems(false).build();
// Empty map with no conquered tiles should have no borders
game.assert().no_border_tiles(nation);
}
#[test]
fn test_player_loses_all_territory() {
let nation = NationId::ZERO;
let mut game = GameBuilder::with_map_size(10, 10).with_nation(nation, 100.0).with_network(NetworkMode::Local).with_systems(false).build();
// Player conquers some territory
let tiles = vec![U16Vec2::new(5, 5), U16Vec2::new(5, 6), U16Vec2::new(6, 5)];
game.conquer_tiles(&tiles, nation);
game.update_borders();
game.assert().border_count(nation, 3);
// Player loses all territory
for tile in &tiles {
game.clear_tile(*tile);
}
game.update_borders();
// Player should have no borders after losing all territory
game.assert().no_border_tiles(nation);
}
#[test]
fn test_clear_to_neutral_updates_neighbors() {
let nation1 = NationId::ZERO;
let nation2 = NationId::new(1).unwrap();
let mut game = GameBuilder::with_map_size(10, 10).with_nation(nation1, 100.0).with_network(NetworkMode::Local).with_systems(false).build();
game.spawn_test_nation(nation2, 100.0);
// Nation 1 owns a 3x3 region
let center = U16Vec2::new(5, 5);
game.conquer_region(center, 1, nation1);
game.update_borders();
// Center is interior
game.assert().not_border(center);
// Nation 2 conquers an adjacent tile
let adjacent = U16Vec2::new(7, 5);
game.conquer_tile(adjacent, nation2);
game.update_borders();
// Tile at (6,5) should now be a border (neighbor changed)
game.assert().is_border(U16Vec2::new(6, 5));
// Clear nation 2's tile back to neutral
game.clear_tile(adjacent);
game.update_borders();
// Tile at (6,5) should still be a border (facing neutral territory)
game.assert().is_border(U16Vec2::new(6, 5));
}
#[test]
fn test_rapid_conquest_loss_cycles() {
let nation1 = NationId::ZERO;
let nation2 = NationId::new(1).unwrap();
let mut game = GameBuilder::with_map_size(10, 10).with_nation(nation1, 100.0).with_network(NetworkMode::Local).with_systems(false).build();
game.spawn_test_nation(nation2, 100.0);
let contested_tile = U16Vec2::new(5, 5);
// Rapid ownership changes
for _ in 0..10 {
game.conquer_tile(contested_tile, nation1);
game.update_borders();
game.assert().is_border(contested_tile);
game.conquer_tile(contested_tile, nation2);
game.update_borders();
game.assert().is_border(contested_tile);
game.clear_tile(contested_tile);
game.update_borders();
}
// Final state: tile is neutral, both nations have no borders
game.assert().no_border_tiles(nation1).no_border_tiles(nation2);
}
// Complex geometry tests
#[test]
fn test_donut_enclosed_territory() {
let nation1 = NationId::ZERO;
let nation2 = NationId::new(1).unwrap();
let mut game = GameBuilder::with_map_size(15, 15).with_nation(nation1, 100.0).with_network(NetworkMode::Local).with_systems(false).build();
game.spawn_test_nation(nation2, 100.0);
// Nation 1 creates a donut: 5x5 region with hollow center
let center = U16Vec2::new(7, 7);
game.conquer_region(center, 2, nation1); // 5x5 square
// Remove the center tile to create a hole
game.clear_tile(center);
game.update_borders();
// Tiles adjacent to the neutral center should now be borders for nation 1
for neighbor_pos in [U16Vec2::new(6, 7), U16Vec2::new(8, 7), U16Vec2::new(7, 6), U16Vec2::new(7, 8)] {
game.assert().is_border(neighbor_pos);
}
// Nation 2 conquers the enclosed neutral tile
game.conquer_tile(center, nation2);
game.update_borders();
// Nation 2's tile is completely surrounded by nation 1, so it's a border
game.assert().is_border(center).border_count(nation2, 1);
// Tiles adjacent to center (owned by nation 1) should now be borders
for neighbor_pos in [U16Vec2::new(6, 7), U16Vec2::new(8, 7), U16Vec2::new(7, 6), U16Vec2::new(7, 8)] {
game.assert().is_border(neighbor_pos);
}
}
#[test]
fn test_thin_corridor_one_tile_wide() {
let nation = NationId::ZERO;
let mut game = GameBuilder::with_map_size(20, 20).with_nation(nation, 100.0).with_network(NetworkMode::Local).with_systems(false).build();
// Create a long horizontal corridor (1 tile wide, 10 tiles long)
let corridor_y = 10;
for x in 5..15 {
game.conquer_tile(U16Vec2::new(x, corridor_y), nation);
}
game.update_borders();
// All tiles in the corridor should be borders (only connected via cardinal neighbors)
for x in 5..15 {
game.assert().is_border(U16Vec2::new(x, corridor_y));
}
game.assert().border_count(nation, 10);
}
#[test]
fn test_checkerboard_ownership_pattern() {
let nation1 = NationId::ZERO;
let nation2 = NationId::new(1).unwrap();
let mut game = GameBuilder::with_map_size(10, 10).with_nation(nation1, 100.0).with_network(NetworkMode::Local).with_systems(false).build();
game.spawn_test_nation(nation2, 100.0);
// Create checkerboard pattern in 6x6 region
for y in 2..8 {
for x in 2..8 {
let owner = if (x + y) % 2 == 0 { nation1 } else { nation2 };
game.conquer_tile(U16Vec2::new(x, y), owner);
}
}
game.update_borders();
// In a checkerboard, every tile has cardinal neighbors of different ownership
// So all tiles should be borders
for y in 2..8 {
for x in 2..8 {
game.assert().is_border(U16Vec2::new(x, y));
}
}
// Each nation should have 18 tiles (half of 6x6 = 36 tiles)
game.assert().border_count(nation1, 18).border_count(nation2, 18);
}
#[test]
fn test_l_shaped_territory() {
let nation = NationId::ZERO;
let mut game = GameBuilder::with_map_size(15, 15).with_nation(nation, 100.0).with_network(NetworkMode::Local).with_systems(false).build();
// Create L-shaped territory:
// Vertical arm: x=5, y=5 to y=10 (6 tiles)
for y in 5..=10 {
game.conquer_tile(U16Vec2::new(5, y), nation);
}
// Horizontal arm: x=5 to x=10, y=10 (5 more tiles)
for x in 6..=10 {
game.conquer_tile(U16Vec2::new(x, 10), nation);
}
game.update_borders();
// The corner tile (5, 10) has 2 friendly neighbors, but is still a border
game.assert().is_border(U16Vec2::new(5, 10));
// All tiles in the L-shape should be borders (facing neutral territory on at least one side)
for y in 5..=10 {
game.assert().is_border(U16Vec2::new(5, y));
}
for x in 6..=10 {
game.assert().is_border(U16Vec2::new(x, 10));
}
// Total: 11 tiles (6 vertical + 5 horizontal)
game.assert().border_count(nation, 11);
}
#[test]
fn test_large_map_scattered_territories() {
let nation = NationId::ZERO;
let mut game = GameBuilder::with_map_size(1000, 1000).with_nation(nation, 100.0).with_network(NetworkMode::Local).with_systems(false).build();
// Conquer 10,000 scattered tiles (every 10th tile in a grid pattern)
for y in (0..1000).step_by(10) {
for x in (0..1000).step_by(10) {
game.conquer_tile(U16Vec2::new(x, y), nation);
}
}
game.update_borders();
// All 10,000 tiles should be borders (isolated tiles)
game.assert().border_count(nation, 10_000);
}
#[test]
fn test_large_map_contiguous_regions() {
let nation1 = NationId::ZERO;
let nation2 = NationId::new(1).unwrap();
let mut game = GameBuilder::with_map_size(1000, 1000).with_nation(nation1, 100.0).with_network(NetworkMode::Local).with_systems(false).build();
game.spawn_test_nation(nation2, 100.0);
// Nation 1 conquers left half (500x1000 = 500k tiles)
for y in 0..1000 {
for x in 0..500 {
game.conquer_tile(U16Vec2::new(x, y), nation1);
}
}
// Nation 2 conquers right half (500x1000 = 500k tiles)
for y in 0..1000 {
for x in 500..1000 {
game.conquer_tile(U16Vec2::new(x, y), nation2);
}
}
game.update_borders();
// Each nation should have borders along the contact line
// Contact border: 1000 tiles each (vertical line at x=499 and x=500)
// Map edges don't count as borders unless tiles have different neighbors
let p1_borders = game.get_nation_borders(nation1);
let p2_borders = game.get_nation_borders(nation2);
// Verify we have a reasonable number of borders (not all tiles are borders)
assert!(p1_borders.len() < 10_000); // Much less than total territory
assert!(p2_borders.len() < 10_000);
assert!(p1_borders.len() >= 1000); // At least the contact line (1000 tiles)
assert!(p2_borders.len() >= 1000);
}
#[test]
fn test_massive_batch_changes() {
let nation1 = NationId::ZERO;
let nation2 = NationId::new(1).unwrap();
let mut game = GameBuilder::with_map_size(200, 200).with_nation(nation1, 100.0).with_network(NetworkMode::Local).with_systems(false).build();
game.spawn_test_nation(nation2, 100.0);
// Create initial territory for nation 1
for y in 0..100 {
for x in 0..100 {
game.conquer_tile(U16Vec2::new(x, y), nation1);
}
}
game.update_borders();
let initial_borders = game.get_nation_borders(nation1).len();
// Make 10,000+ changes: nation 2 conquers a large region
for y in 50..150 {
for x in 50..150 {
game.conquer_tile(U16Vec2::new(x, y), nation2);
}
}
game.update_borders();
// Verify both players have borders after massive territorial change
let p1_borders = game.get_nation_borders(nation1);
let p2_borders = game.get_nation_borders(nation2);
assert!(!p1_borders.is_empty());
assert!(!p2_borders.is_empty());
// Nation 1 lost significant territory, so borders should change
assert!(p1_borders.len() != initial_borders);
}

View File

@@ -0,0 +1,274 @@
mod common;
use assert2::assert;
use borders_core::prelude::*;
use common::WaterPattern;
use rstest::rstest;
use std::collections::HashSet;
/// Helper to create terrain data for testing
fn create_terrain(water_tiles: &[U16Vec2], size: U16Vec2) -> TerrainData {
let capacity = size.as_usizevec2().element_product();
// Create two tile types: land (0) and water (1)
let tile_types = vec![TileType { name: "Land".to_string(), color_base: "grass".to_string(), color_variant: 0, conquerable: true, navigable: false, expansion_time: 50, expansion_cost: 50 }, TileType { name: "Water".to_string(), color_base: "water".to_string(), color_variant: 0, conquerable: false, navigable: true, expansion_time: 50, expansion_cost: 50 }];
// Create water tiles set for fast lookup
let water_set: HashSet<U16Vec2> = water_tiles.iter().copied().collect();
// Build terrain_data (legacy format) and tiles (new format)
let mut terrain_data_raw = vec![0u8; capacity];
let mut tiles = vec![0u8; capacity];
for y in 0..size.y {
for x in 0..size.x {
let pos = U16Vec2::new(x, y);
let idx = (y as usize * size.x as usize) + x as usize;
if water_set.contains(&pos) {
// Water tile: type index 1, no bit 7
tiles[idx] = 1;
terrain_data_raw[idx] = 0;
} else {
// Land tile: type index 0, bit 7 set
tiles[idx] = 0;
terrain_data_raw[idx] = 0x80;
}
}
}
let num_land_tiles = terrain_data_raw.iter().filter(|&&b| b & 0x80 != 0).count();
TerrainData { _manifest: MapManifest { name: "Test".to_string(), map: MapMetadata { size, num_land_tiles }, nations: vec![] }, terrain_data: TileMap::from_vec(size, terrain_data_raw), tiles, tile_types }
}
/// Test empty state configurations where no coastal tiles should exist
///
/// This covers edge cases where either all tiles are land or all are water
#[rstest]
#[case::no_water(WaterPattern::no_water(), "no water tiles")]
#[case::all_water(WaterPattern::all_water(), "all water tiles")]
fn test_empty_coastal_states(#[case] pattern: WaterPattern, #[case] _description: &str) {
let size = U16Vec2::new(pattern.map_size.0, pattern.map_size.1);
let terrain = create_terrain(&pattern.water_tiles, size);
let coastal = CoastalTiles::compute(&terrain, size);
assert!(coastal.is_empty());
assert!(coastal.len() == 0);
assert!(coastal.tiles().is_empty());
}
/// Test single water tile configurations in different positions
///
/// Verifies coastal tile count and that water tiles themselves are not marked coastal
#[rstest]
#[case::center(WaterPattern::single_center())]
#[case::edge(WaterPattern::single_edge())]
#[case::corner(WaterPattern::single_corner())]
fn test_single_water_tile_positions(#[case] pattern: WaterPattern) {
let size = U16Vec2::new(pattern.map_size.0, pattern.map_size.1);
let terrain = create_terrain(&pattern.water_tiles, size);
let coastal = CoastalTiles::compute(&terrain, size);
assert!(!coastal.is_empty());
assert!(coastal.len() == pattern.expected_coastal_count);
// Water tiles themselves should never be coastal
for water_tile in &pattern.water_tiles {
assert!(!coastal.contains(*water_tile));
}
}
/// Test complex water patterns generate correct coastal tile counts
///
/// Covers various geometric patterns: islands, L-shapes, lines, channels, checkerboards
#[rstest]
#[case::small_island(WaterPattern::small_island())]
#[case::l_shape(WaterPattern::l_shape())]
#[case::diagonal_line(WaterPattern::diagonal_line())]
#[case::horizontal_channel(WaterPattern::horizontal_channel())]
#[case::checkerboard(WaterPattern::checkerboard())]
fn test_complex_water_patterns(#[case] pattern: WaterPattern) {
let size = U16Vec2::new(pattern.map_size.0, pattern.map_size.1);
let terrain = create_terrain(&pattern.water_tiles, size);
let coastal = CoastalTiles::compute(&terrain, size);
assert!(!coastal.is_empty());
assert!(coastal.len() == pattern.expected_coastal_count);
// Water tiles themselves should never be coastal
for water_tile in &pattern.water_tiles {
assert!(!coastal.contains(*water_tile));
}
}
#[test]
fn test_edge_water_creates_coastal_tiles() {
// 5x5 grid with water along the top edge
let size = U16Vec2::new(5, 5);
let water_tiles: Vec<U16Vec2> = (0..5).map(|x| U16Vec2::new(x, 0)).collect();
let terrain = create_terrain(&water_tiles, size);
let coastal = CoastalTiles::compute(&terrain, size);
assert!(!coastal.is_empty());
assert!(coastal.len() == 5);
// All tiles in row 1 should be coastal
for x in 0..5 {
assert!(coastal.contains(U16Vec2::new(x, 1)));
}
// Water tiles themselves should not be coastal
for x in 0..5 {
assert!(!coastal.contains(U16Vec2::new(x, 0)));
}
// Tiles further inland should not be coastal
for x in 0..5 {
assert!(!coastal.contains(U16Vec2::new(x, 2)));
}
}
#[test]
fn test_island_configuration() {
// 5x5 grid with water around the edges and land in the middle
let size = U16Vec2::new(5, 5);
let mut water_tiles = Vec::new();
// Top and bottom edges
for x in 0..5 {
water_tiles.push(U16Vec2::new(x, 0));
water_tiles.push(U16Vec2::new(x, 4));
}
// Left and right edges
for y in 1..4 {
water_tiles.push(U16Vec2::new(0, y));
water_tiles.push(U16Vec2::new(4, y));
}
let terrain = create_terrain(&water_tiles, size);
let coastal = CoastalTiles::compute(&terrain, size);
// The inner ring of land tiles (1,1), (2,1), (3,1), (1,2), (3,2), (1,3), (2,3), (3,3)
// should be coastal, but (2,2) should not be
assert!(!coastal.is_empty());
assert!(coastal.len() == 8);
// Check the outer ring of land is coastal
assert!(coastal.contains(U16Vec2::new(1, 1)));
assert!(coastal.contains(U16Vec2::new(2, 1)));
assert!(coastal.contains(U16Vec2::new(3, 1)));
assert!(coastal.contains(U16Vec2::new(1, 2)));
assert!(coastal.contains(U16Vec2::new(3, 2)));
assert!(coastal.contains(U16Vec2::new(1, 3)));
assert!(coastal.contains(U16Vec2::new(2, 3)));
assert!(coastal.contains(U16Vec2::new(3, 3)));
// Center tile should not be coastal (no water neighbors)
assert!(!coastal.contains(U16Vec2::new(2, 2)));
}
#[test]
fn test_tiles_returns_correct_set() {
let size = U16Vec2::new(3, 3);
let water_tiles = vec![U16Vec2::new(1, 1)];
let terrain = create_terrain(&water_tiles, size);
let coastal = CoastalTiles::compute(&terrain, size);
let tiles = coastal.tiles();
// Verify it's the correct type and contains the right tiles
let expected: HashSet<U16Vec2> = vec![U16Vec2::new(1, 0), U16Vec2::new(0, 1), U16Vec2::new(2, 1), U16Vec2::new(1, 2)].into_iter().collect();
assert!(tiles == &expected);
}
#[test]
fn test_len_with_various_sizes() {
// Test that len() returns accurate counts
// Empty case
let size = U16Vec2::new(3, 3);
let terrain = create_terrain(&[], size);
let coastal = CoastalTiles::compute(&terrain, size);
assert!(coastal.len() == 0);
// Single water tile -> 4 coastal tiles
let water_tiles = vec![U16Vec2::new(1, 1)];
let terrain = create_terrain(&water_tiles, size);
let coastal = CoastalTiles::compute(&terrain, size);
assert!(coastal.len() == 4);
// Two adjacent water tiles -> 6 coastal tiles
// Water at (1,1) and (2,1) creates coastal tiles at (1,0), (0,1), (2,0), (3,1), (1,2), (2,2)
let size = U16Vec2::new(4, 3);
let water_tiles = vec![U16Vec2::new(1, 1), U16Vec2::new(2, 1)];
let terrain = create_terrain(&water_tiles, size);
let coastal = CoastalTiles::compute(&terrain, size);
assert!(coastal.len() == 6);
}
#[test]
fn test_contains_with_out_of_bounds() {
let size = U16Vec2::new(3, 3);
let water_tiles = vec![U16Vec2::new(0, 0)];
let terrain = create_terrain(&water_tiles, size);
let coastal = CoastalTiles::compute(&terrain, size);
// Valid coastal tile
assert!(coastal.contains(U16Vec2::new(1, 0)));
assert!(coastal.contains(U16Vec2::new(0, 1)));
// Out of bounds tiles should not be in the set
assert!(!coastal.contains(U16Vec2::new(100, 100)));
assert!(!coastal.contains(U16Vec2::new(5, 5)));
}
#[test]
fn test_is_empty_true_and_false() {
let size = U16Vec2::new(3, 3);
// Empty case - no water
let terrain = create_terrain(&[], size);
let coastal = CoastalTiles::compute(&terrain, size);
assert!(coastal.is_empty());
// Non-empty case - has water
let water_tiles = vec![U16Vec2::new(1, 1)];
let terrain = create_terrain(&water_tiles, size);
let coastal = CoastalTiles::compute(&terrain, size);
assert!(!coastal.is_empty());
}
#[test]
fn test_multiple_disconnected_water_bodies() {
// Test multiple separate water bodies
let size = U16Vec2::new(5, 5);
let water_tiles = vec![
U16Vec2::new(1, 1), // First water body
U16Vec2::new(3, 3), // Second water body
];
let terrain = create_terrain(&water_tiles, size);
let coastal = CoastalTiles::compute(&terrain, size);
// Each water tile should create coastal tiles around it
assert!(!coastal.is_empty());
// Check coastal tiles around first water body
assert!(coastal.contains(U16Vec2::new(1, 0)));
assert!(coastal.contains(U16Vec2::new(0, 1)));
assert!(coastal.contains(U16Vec2::new(2, 1)));
assert!(coastal.contains(U16Vec2::new(1, 2)));
// Check coastal tiles around second water body
assert!(coastal.contains(U16Vec2::new(3, 2)));
assert!(coastal.contains(U16Vec2::new(2, 3)));
assert!(coastal.contains(U16Vec2::new(4, 3)));
assert!(coastal.contains(U16Vec2::new(3, 4)));
}

View File

@@ -0,0 +1,425 @@
//! Fluent assertion API for testing Bevy ECS Game state
//!
//! Provides chainable assertion methods that test behavior through public interfaces.
//!
//! Tests should use GameAssertExt on Game instances. WorldAssertExt is internal.
use assert2::assert;
use borders_core::game::Game;
use extension_traits::extension;
use std::collections::HashSet;
use borders_core::prelude::*;
/// Internal fluent assertion builder for World state
///
/// Tests should use GameAssertions instead via `game.assert()`.
pub(crate) struct WorldAssertions<'w> {
world: &'w World,
}
/// Internal extension trait to add assertion capabilities to World
///
/// Tests should use GameAssertExt instead. This is used internally by GameAssertions.
#[extension(pub(crate) trait WorldAssertExt)]
impl World {
/// Begin a fluent assertion chain
fn assert(&self) -> WorldAssertions<'_> {
WorldAssertions { world: self }
}
}
impl<'w> WorldAssertions<'w> {
/// Assert that a nation owns a specific tile
#[track_caller]
pub fn player_owns(self, tile: U16Vec2, expected: NationId) -> Self {
let mgr = self.world.resource::<TerritoryManager>();
let actual = mgr.get_nation_id(tile);
assert!(actual == Some(expected), "Expected nation {} to own tile {:?}, but found {:?}", expected.get(), tile, actual.map(|id| id.get()));
self
}
/// Assert that a tile is unclaimed
#[track_caller]
pub fn tile_unclaimed(self, tile: U16Vec2) -> Self {
let mgr = self.world.resource::<TerritoryManager>();
let ownership = mgr.get_ownership(tile);
assert!(ownership.is_unclaimed(), "Expected tile {:?} to be unclaimed, but it's owned by {:?}", tile, ownership.nation_id());
self
}
/// Assert that an attack exists between attacker and target
#[track_caller]
pub fn attack_exists(self, attacker: NationId, target: Option<NationId>) -> Self {
let attacks = self.world.resource::<ActiveAttacks>();
let nation_attacks = attacks.get_attacks_for_nation(attacker);
let exists = nation_attacks.iter().any(|(att, tgt, _, _, is_outgoing)| *att == attacker && *tgt == target && *is_outgoing);
assert!(exists, "Expected attack from nation {} to {:?}, but none found. Active attacks: {:?}", attacker.get(), target.map(|t| t.get()), nation_attacks);
self
}
/// Assert that no attack exists between attacker and target
#[track_caller]
pub fn no_attack(self, attacker: NationId, target: Option<NationId>) -> Self {
let attacks = self.world.resource::<ActiveAttacks>();
let nation_attacks = attacks.get_attacks_for_nation(attacker);
let exists = nation_attacks.iter().any(|(att, tgt, _, _, is_outgoing)| *att == attacker && *tgt == target && *is_outgoing);
assert!(!exists, "Expected NO attack from nation {} to {:?}, but found one. Active attacks: {:?}", attacker.get(), target.map(|t| t.get()), nation_attacks);
self
}
/// Assert that a nation has specific border tiles
#[track_caller]
pub fn border_tiles(self, nation_id: NationId, expected: &HashSet<U16Vec2>) -> Self {
let entity_map = self.world.resource::<borders_core::game::NationEntityMap>();
let entity = entity_map.get_entity(nation_id);
let border_tiles = self.world.get::<BorderTiles>(entity).expect("BorderTiles component not found");
assert!(&border_tiles.0 == expected, "Border tiles mismatch for nation {}.\nExpected: {:?}\nActual: {:?}", nation_id.get(), expected, border_tiles.0);
self
}
/// Assert that TerritoryManager has recorded changes
#[track_caller]
pub fn has_territory_changes(self) -> Self {
let mgr = self.world.resource::<TerritoryManager>();
assert!(mgr.has_changes(), "Expected territory changes to be tracked, but ChangeBuffer is empty");
self
}
/// Assert that TerritoryManager has NO recorded changes
#[track_caller]
pub fn no_territory_changes(self) -> Self {
let mgr = self.world.resource::<TerritoryManager>();
assert!(!mgr.has_changes(), "Expected no territory changes, but ChangeBuffer contains: {:?}", mgr.iter_changes().collect::<Vec<_>>());
self
}
/// Assert that a resource exists in the world
#[track_caller]
pub fn resource_exists<T: Resource>(self, resource_name: &str) -> Self {
assert!(self.world.contains_resource::<T>(), "Expected resource '{}' to exist, but it was not found", resource_name);
self
}
/// Assert that a resource does NOT exist in the world
#[track_caller]
pub fn resource_missing<T: Resource>(self, resource_name: &str) -> Self {
assert!(!self.world.contains_resource::<T>(), "Expected resource '{}' to be missing, but it exists", resource_name);
self
}
/// Assert that no invalid game state exists
///
/// Checks common invariants:
/// - No self-attacks in ActiveAttacks
/// - All nation entities have required components
/// - Territory ownership is consistent
#[track_caller]
pub fn no_invalid_state(self) -> Self {
let attacks = self.world.resource::<ActiveAttacks>();
let entity_map = self.world.resource::<borders_core::game::NationEntityMap>();
for &nation_id in entity_map.0.keys() {
let nation_attacks = attacks.get_attacks_for_nation(nation_id);
for (attacker, target, _, _, _) in nation_attacks {
if let Some(target_id) = target {
assert!(attacker != target_id, "INVARIANT VIOLATION: Nation {} is attacking itself", attacker.get());
}
}
}
for (&nation_id, &entity) in &entity_map.0 {
assert!(self.world.get::<borders_core::game::Troops>(entity).is_some(), "Nation {} entity missing Troops component", nation_id.get());
assert!(self.world.get::<BorderTiles>(entity).is_some(), "Nation {} entity missing BorderTiles component", nation_id.get());
}
self
}
/// Assert that a nation has a specific troop count
#[track_caller]
pub fn player_troops(self, nation_id: NationId, expected: f32) -> Self {
let entity_map = self.world.resource::<borders_core::game::NationEntityMap>();
let entity = entity_map.get_entity(nation_id);
let troops = self.world.get::<borders_core::game::Troops>(entity).expect("Troops component not found");
let difference = (troops.0 - expected).abs();
assert!(difference < 0.01, "Expected nation {} to have {} troops, but found {} (difference: {})", nation_id.get(), expected, troops.0, difference);
self
}
/// Assert that a tile is a border tile
#[allow(clippy::wrong_self_convention)]
#[track_caller]
pub fn is_border(self, tile: U16Vec2) -> Self {
let mgr = self.world.resource::<TerritoryManager>();
assert!(mgr.is_border(tile), "Expected tile {:?} to be a border tile, but it is not", tile);
self
}
/// Assert that a tile is NOT a border tile
#[track_caller]
pub fn not_border(self, tile: U16Vec2) -> Self {
let mgr = self.world.resource::<TerritoryManager>();
assert!(!mgr.is_border(tile), "Expected tile {:?} to NOT be a border tile, but it is", tile);
self
}
/// Assert that a nation owns all tiles in a slice
#[track_caller]
pub fn player_owns_all(self, tiles: &[U16Vec2], expected: NationId) -> Self {
let mgr = self.world.resource::<TerritoryManager>();
for &tile in tiles {
let actual = mgr.get_nation_id(tile);
assert!(actual == Some(expected), "Expected nation {} to own tile {:?}, but found {:?}", expected.get(), tile, actual.map(|id| id.get()));
}
self
}
/// Assert that the ChangeBuffer contains exactly N changes
#[track_caller]
pub fn change_count(self, expected: usize) -> Self {
let mgr = self.world.resource::<TerritoryManager>();
let actual = mgr.iter_changes().count();
assert!(actual == expected, "Expected {} changes in ChangeBuffer, but found {}", expected, actual);
self
}
/// Assert that a nation entity has all standard game components
///
/// Checks for: NationName, NationColor, BorderTiles, Troops, TerritorySize
#[track_caller]
pub fn player_has_components(self, nation_id: NationId) -> Self {
let entity_map = self.world.resource::<borders_core::game::NationEntityMap>();
let entity = entity_map.get_entity(nation_id);
assert!(self.world.get::<borders_core::game::NationName>(entity).is_some(), "Nation {} missing NationName component", nation_id.get());
assert!(self.world.get::<borders_core::game::NationColor>(entity).is_some(), "Nation {} missing NationColor component", nation_id.get());
assert!(self.world.get::<BorderTiles>(entity).is_some(), "Nation {} missing BorderTiles component", nation_id.get());
assert!(self.world.get::<borders_core::game::Troops>(entity).is_some(), "Nation {} missing Troops component", nation_id.get());
assert!(self.world.get::<borders_core::game::TerritorySize>(entity).is_some(), "Nation {} missing TerritorySize component", nation_id.get());
self
}
/// Assert that the map has specific dimensions
#[track_caller]
pub fn map_dimensions(self, width: u16, height: u16) -> Self {
let mgr = self.world.resource::<TerritoryManager>();
assert!(mgr.width() == width, "Expected map width {}, but found {}", width, mgr.width());
assert!(mgr.height() == height, "Expected map height {}, but found {}", height, mgr.height());
assert!(mgr.len() == (width as usize) * (height as usize), "Expected map size {}, but found {}", (width as usize) * (height as usize), mgr.len());
self
}
/// Assert that BorderCache is synchronized with ECS BorderTiles component
#[track_caller]
pub fn border_cache_synced(self, nation_id: NationId) -> Self {
let entity_map = self.world.resource::<borders_core::game::NationEntityMap>();
let entity = entity_map.get_entity(nation_id);
let component_borders = self.world.get::<BorderTiles>(entity).expect("BorderTiles component not found");
let cache = self.world.resource::<BorderCache>();
let cache_borders = cache.get(nation_id);
match cache_borders {
Some(cached) => {
assert!(&component_borders.0 == cached, "BorderCache out of sync with BorderTiles component for nation {}.\nECS component: {:?}\nCache: {:?}", nation_id.get(), component_borders.0, cached);
}
None => {
assert!(component_borders.0.is_empty(), "BorderCache missing entry for nation {} but component has {} borders", nation_id.get(), component_borders.0.len());
}
}
self
}
/// Assert that a nation has NO border tiles
#[track_caller]
pub fn no_border_tiles(self, nation_id: NationId) -> Self {
let entity_map = self.world.resource::<borders_core::game::NationEntityMap>();
let entity = entity_map.get_entity(nation_id);
let border_tiles = self.world.get::<BorderTiles>(entity).expect("BorderTiles component not found");
assert!(border_tiles.0.is_empty(), "Expected nation {} to have no border tiles, but found {} tiles: {:?}", nation_id.get(), border_tiles.0.len(), border_tiles.0);
self
}
/// Assert that a nation has a specific number of border tiles
#[track_caller]
pub fn border_count(self, nation_id: NationId, expected: usize) -> Self {
let entity_map = self.world.resource::<borders_core::game::NationEntityMap>();
let entity = entity_map.get_entity(nation_id);
let border_tiles = self.world.get::<BorderTiles>(entity).expect("BorderTiles component not found");
assert!(border_tiles.0.len() == expected, "Expected nation {} to have {} border tiles, but found {}", nation_id.get(), expected, border_tiles.0.len());
self
}
}
/// Fluent assertion builder for Game state
///
/// Access via `game.assert()` to chain multiple assertions.
pub struct GameAssertions<'g> {
game: &'g Game,
}
/// Extension trait to add assertion capabilities to Game
#[extension(pub trait GameAssertExt)]
impl Game {
/// Begin a fluent assertion chain
fn assert(&self) -> GameAssertions<'_> {
GameAssertions { game: self }
}
}
impl<'g> GameAssertions<'g> {
/// Assert that a nation owns a specific tile
#[track_caller]
pub fn player_owns(self, tile: U16Vec2, expected: NationId) -> Self {
self.game.world().assert().player_owns(tile, expected);
self
}
/// Assert that a tile is unclaimed
#[track_caller]
pub fn tile_unclaimed(self, tile: U16Vec2) -> Self {
self.game.world().assert().tile_unclaimed(tile);
self
}
/// Assert that an attack exists between attacker and target
#[track_caller]
pub fn attack_exists(self, attacker: NationId, target: Option<NationId>) -> Self {
self.game.world().assert().attack_exists(attacker, target);
self
}
/// Assert that no attack exists between attacker and target
#[track_caller]
pub fn no_attack(self, attacker: NationId, target: Option<NationId>) -> Self {
self.game.world().assert().no_attack(attacker, target);
self
}
/// Assert that a nation has specific border tiles
#[track_caller]
pub fn border_tiles(self, nation_id: NationId, expected: &HashSet<U16Vec2>) -> Self {
self.game.world().assert().border_tiles(nation_id, expected);
self
}
/// Assert that TerritoryManager has recorded changes
#[track_caller]
pub fn has_territory_changes(self) -> Self {
self.game.world().assert().has_territory_changes();
self
}
/// Assert that TerritoryManager has NO recorded changes
#[track_caller]
pub fn no_territory_changes(self) -> Self {
self.game.world().assert().no_territory_changes();
self
}
/// Assert that a resource exists in the world
#[track_caller]
pub fn resource_exists<T: Resource>(self, resource_name: &str) -> Self {
self.game.world().assert().resource_exists::<T>(resource_name);
self
}
/// Assert that a resource does NOT exist in the world
#[track_caller]
pub fn resource_missing<T: Resource>(self, resource_name: &str) -> Self {
self.game.world().assert().resource_missing::<T>(resource_name);
self
}
/// Assert that no invalid game state exists
#[track_caller]
pub fn no_invalid_state(self) -> Self {
self.game.world().assert().no_invalid_state();
self
}
/// Assert that a nation has a specific troop count
#[track_caller]
pub fn player_troops(self, nation_id: NationId, expected: f32) -> Self {
self.game.world().assert().player_troops(nation_id, expected);
self
}
/// Assert that a tile is a border tile
#[allow(clippy::wrong_self_convention)]
#[track_caller]
pub fn is_border(self, tile: U16Vec2) -> Self {
self.game.world().assert().is_border(tile);
self
}
/// Assert that a tile is NOT a border tile
#[track_caller]
pub fn not_border(self, tile: U16Vec2) -> Self {
self.game.world().assert().not_border(tile);
self
}
/// Assert that a nation owns all tiles in a slice
#[track_caller]
pub fn player_owns_all(self, tiles: &[U16Vec2], expected: NationId) -> Self {
self.game.world().assert().player_owns_all(tiles, expected);
self
}
/// Assert that the ChangeBuffer contains exactly N changes
#[track_caller]
pub fn change_count(self, expected: usize) -> Self {
self.game.world().assert().change_count(expected);
self
}
/// Assert that a nation entity has all standard game components
#[track_caller]
pub fn player_has_components(self, nation_id: NationId) -> Self {
self.game.world().assert().player_has_components(nation_id);
self
}
/// Assert that the map has specific dimensions
#[track_caller]
pub fn map_dimensions(self, width: u16, height: u16) -> Self {
self.game.world().assert().map_dimensions(width, height);
self
}
/// Assert that BorderCache is synchronized with ECS BorderTiles component
#[track_caller]
pub fn border_cache_synced(self, nation_id: NationId) -> Self {
self.game.world().assert().border_cache_synced(nation_id);
self
}
/// Assert that a nation has NO border tiles
#[track_caller]
pub fn no_border_tiles(self, nation_id: NationId) -> Self {
self.game.world().assert().no_border_tiles(nation_id);
self
}
/// Assert that a nation has a specific number of border tiles
#[track_caller]
pub fn border_count(self, nation_id: NationId, expected: usize) -> Self {
self.game.world().assert().border_count(nation_id, expected);
self
}
}

View File

@@ -0,0 +1,95 @@
/// Map builder for programmatic terrain generation in tests
///
/// Use this to create custom terrain layouts for test scenarios.
use borders_core::prelude::*;
use super::fixtures::standard_tile_types;
/// Builder for programmatic terrain generation
///
/// # Example
/// ```
/// let terrain = MapBuilder::new(100, 100)
/// .all_conquerable()
/// .build();
/// ```
pub struct MapBuilder {
width: u16,
height: u16,
terrain_data: Vec<u8>,
tile_types: Vec<TileType>,
}
impl MapBuilder {
/// Create a new map builder
pub fn new(width: u16, height: u16) -> Self {
let size = (width as usize) * (height as usize);
Self { width, height, terrain_data: vec![0; size], tile_types: Vec::new() }
}
/// Make all tiles land and conquerable
pub fn all_conquerable(mut self) -> Self {
let size = (self.width as usize) * (self.height as usize);
self.terrain_data = vec![0x80; size]; // bit 7 = land/conquerable
self.tile_types = standard_tile_types();
self
}
/// Create islands: center 50x50 land, rest water
pub fn islands(mut self) -> Self {
self.tile_types = standard_tile_types();
let center_x = self.width / 2;
let center_y = self.height / 2;
let island_size = 25u16;
for y in 0..self.height {
for x in 0..self.width {
let idx = (y as usize) * (self.width as usize) + (x as usize);
let in_island = x.abs_diff(center_x) < island_size && y.abs_diff(center_y) < island_size;
self.terrain_data[idx] = if in_island { 0x80 } else { 0x00 };
}
}
self
}
/// Create continents: alternating vertical strips of land/water
pub fn continents(mut self) -> Self {
self.tile_types = standard_tile_types();
for y in 0..self.height {
for x in 0..self.width {
let idx = (y as usize) * (self.width as usize) + (x as usize);
// 30-tile wide strips
let is_land = (x / 30) % 2 == 0;
self.terrain_data[idx] = if is_land { 0x80 } else { 0x00 };
}
}
self
}
/// Set specific tiles as land
pub fn with_land(mut self, tiles: &[U16Vec2]) -> Self {
for &tile in tiles {
let idx = (tile.y as usize) * (self.width as usize) + (tile.x as usize);
if idx < self.terrain_data.len() {
self.terrain_data[idx] = 0x80;
}
}
self
}
/// Build the TerrainData
pub fn build(self) -> TerrainData {
let tiles: Vec<u8> = self.terrain_data.iter().map(|&byte| if byte & 0x80 != 0 { 1 } else { 0 }).collect();
let num_land_tiles = tiles.iter().filter(|&&t| t == 1).count();
let map_size = U16Vec2::new(self.width, self.height);
let terrain_tile_map = TileMap::from_vec(map_size, self.terrain_data);
TerrainData { _manifest: MapManifest { map: MapMetadata { size: map_size, num_land_tiles }, name: "Test Map".to_string(), nations: Vec::new() }, terrain_data: terrain_tile_map, tiles, tile_types: self.tile_types }
}
}

View File

@@ -0,0 +1,349 @@
//! Pre-built test scenarios and fixtures for integration tests
//!
//! Provides:
//! - Static map fixtures (PLAINS_MAP, ISLAND_MAP, etc.)
//! - GameBuilderTestExt trait for common test configurations
//! - TestGameBuilder wrapper for test-specific nation spawning
use once_cell::sync::Lazy;
use std::sync::Arc;
use super::GameTestExt;
use super::builders::MapBuilder;
use borders_core::game::Game;
use borders_core::game::builder::GameBuilder;
use borders_core::game::terrain::TileType;
use borders_core::prelude::*;
use extension_traits::extension;
/// Standard 100x100 plains map (all conquerable)
pub static PLAINS_MAP: Lazy<Arc<TerrainData>> = Lazy::new(|| Arc::new(MapBuilder::new(100, 100).all_conquerable().build()));
/// Island archipelago map: 50x50 islands separated by water
pub static ISLAND_MAP: Lazy<Arc<TerrainData>> = Lazy::new(|| Arc::new(MapBuilder::new(100, 100).islands().build()));
/// Continental map: vertical strips of land and water
pub static CONTINENT_MAP: Lazy<Arc<TerrainData>> = Lazy::new(|| Arc::new(MapBuilder::new(100, 100).continents().build()));
/// Get a clone of the plains map
pub fn get_plains_map() -> TerrainData {
(*PLAINS_MAP.clone()).clone()
}
/// Get a clone of the island map
pub fn get_island_map() -> TerrainData {
(*ISLAND_MAP.clone()).clone()
}
/// Get a clone of the continent map
pub fn get_continent_map() -> TerrainData {
(*CONTINENT_MAP.clone()).clone()
}
/// Create standard water tile type
///
/// Water tiles are non-conquerable but navigable by ships.
pub fn standard_water_type() -> TileType {
TileType { name: "water".to_string(), color_base: "blue".to_string(), color_variant: 0, conquerable: false, navigable: true, expansion_time: 255, expansion_cost: 255 }
}
/// Create standard land tile type
///
/// Land tiles are conquerable with standard expansion costs.
pub fn standard_land_type() -> TileType {
TileType { name: "land".to_string(), color_base: "green".to_string(), color_variant: 0, conquerable: true, navigable: false, expansion_time: 50, expansion_cost: 50 }
}
/// Create standard tile types (water and land)
///
/// Returns a vec containing water (index 0) and land (index 1) tile types.
pub fn standard_tile_types() -> Vec<TileType> {
vec![standard_water_type(), standard_land_type()]
}
// ============================================================================
// rstest Fixtures
// ============================================================================
/// Map size fixture data: (width, height, description)
#[derive(Debug, Clone, Copy)]
pub struct MapSize {
pub width: u16,
pub height: u16,
pub description: &'static str,
}
/// Standard map sizes for parameterized testing
pub const MAP_SIZES: &[MapSize] = &[MapSize { width: 5, height: 5, description: "5x5" }, MapSize { width: 10, height: 10, description: "10x10" }, MapSize { width: 20, height: 20, description: "20x20" }, MapSize { width: 100, height: 100, description: "100x100" }, MapSize { width: 1000, height: 1000, description: "1000x1000" }];
/// Terrain pattern for coastal/water testing
#[derive(Debug, Clone)]
pub struct WaterPattern {
pub name: &'static str,
pub map_size: (u16, u16),
pub water_tiles: Vec<U16Vec2>,
pub expected_coastal_count: usize,
}
impl WaterPattern {
/// Create a new water pattern
pub fn new(name: &'static str, map_size: (u16, u16), water_tiles: Vec<U16Vec2>, expected_coastal_count: usize) -> Self {
Self { name, map_size, water_tiles, expected_coastal_count }
}
/// Single water tile in center of 10x10 map
pub fn single_center() -> Self {
Self::new(
"single_center",
(10, 10),
vec![U16Vec2::new(5, 5)],
4, // 4 orthogonal neighbors
)
}
/// Water tile at edge (not corner)
pub fn single_edge() -> Self {
Self::new(
"single_edge",
(10, 10),
vec![U16Vec2::new(0, 5)],
3, // 3 neighbors (edge blocks one direction)
)
}
/// Water tile at corner
pub fn single_corner() -> Self {
Self::new(
"single_corner",
(10, 10),
vec![U16Vec2::new(0, 0)],
2, // 2 neighbors (corner blocks two directions)
)
}
/// Small 2x2 island
pub fn small_island() -> Self {
Self::new(
"small_island",
(10, 10),
vec![U16Vec2::new(4, 4), U16Vec2::new(5, 4), U16Vec2::new(4, 5), U16Vec2::new(5, 5)],
8, // Perimeter of 2x2 square
)
}
/// L-shaped water pattern
pub fn l_shape() -> Self {
Self::new(
"l_shape",
(10, 10),
vec![U16Vec2::new(5, 5), U16Vec2::new(5, 6), U16Vec2::new(5, 7), U16Vec2::new(6, 5), U16Vec2::new(7, 5)],
11, // Perimeter of L-shape
)
}
/// Diagonal water line
pub fn diagonal_line() -> Self {
Self::new(
"diagonal_line",
(10, 10),
vec![U16Vec2::new(2, 2), U16Vec2::new(3, 3), U16Vec2::new(4, 4), U16Vec2::new(5, 5)],
10, // Coastal tiles around diagonal line
)
}
/// Thin horizontal water channel
pub fn horizontal_channel() -> Self {
Self::new(
"horizontal_channel",
(10, 10),
vec![U16Vec2::new(2, 5), U16Vec2::new(3, 5), U16Vec2::new(4, 5), U16Vec2::new(5, 5), U16Vec2::new(6, 5), U16Vec2::new(7, 5)],
14, // Two land tiles above and below channel
)
}
/// Checkerboard water pattern (alternating tiles)
pub fn checkerboard() -> Self {
let mut water_tiles = Vec::new();
for y in 0..5 {
for x in 0..5 {
if (x + y) % 2 == 0 {
water_tiles.push(U16Vec2::new(x, y));
}
}
}
Self::new(
"checkerboard",
(10, 10),
water_tiles,
18, // Coastal tiles around checkerboard pattern
)
}
/// All water (no coastal tiles)
pub fn all_water() -> Self {
let mut water_tiles = Vec::new();
for y in 0..10 {
for x in 0..10 {
water_tiles.push(U16Vec2::new(x, y));
}
}
Self::new(
"all_water",
(10, 10),
water_tiles,
0, // No land tiles to be coastal
)
}
/// No water (no coastal tiles)
pub fn no_water() -> Self {
Self::new(
"no_water",
(10, 10),
vec![],
0, // No water means no coastal tiles
)
}
}
/// Wrapper for GameBuilder that allows test-specific nation spawning
///
/// Accumulates pending nations with custom troop counts, then spawns them
/// after the game is built. This allows tests to create nations with specific
/// IDs and initial troops without polluting the production GameBuilder API.
pub struct TestGameBuilder {
builder: GameBuilder,
pending_nations: Vec<(NationId, f32)>,
}
impl TestGameBuilder {
/// Add a nation with custom initial troops
///
/// Nations will be spawned in order after `.build()` is called.
pub fn with_nation(mut self, id: NationId, troops: f32) -> Self {
self.pending_nations.push((id, troops));
self
}
// Delegate common GameBuilder methods to maintain fluent API
pub fn with_network(mut self, mode: NetworkMode) -> Self {
self.builder = self.builder.with_network(mode);
self
}
pub fn with_systems(mut self, enabled: bool) -> Self {
self.builder = self.builder.with_systems(enabled);
self
}
pub fn with_spawn_phase_enabled(mut self) -> Self {
self.builder = self.builder.with_spawn_phase(Some(30));
self
}
pub fn with_spawn_phase(mut self, timeout: Option<u32>) -> Self {
self.builder = self.builder.with_spawn_phase(timeout);
self
}
pub fn with_local_player(mut self, id: NationId) -> Self {
self.builder = self.builder.with_local_player(id);
self
}
pub fn with_bots(mut self, count: u32) -> Self {
self.builder = self.builder.with_bots(count);
self
}
pub fn with_map(mut self, terrain: Arc<TerrainData>) -> Self {
self.builder = self.builder.with_map(terrain);
self
}
/// Build the game and spawn all pending test nations
pub fn build(self) -> Game {
let mut game = self.builder.build();
for (id, troops) in self.pending_nations {
game.spawn_test_nation(id, troops);
}
game
}
}
/// Extension trait providing test fixture methods for GameBuilder
///
/// Provides common test configurations without hiding critical settings.
/// Tests must still explicitly set:
/// - `.with_network(NetworkMode::Local)` for turn generation
/// - `.with_systems(false)` if system execution should be disabled
///
/// # Example
/// ```
/// use common::GameBuilderTestExt;
///
/// let mut game = GameBuilder::simple()
/// .with_nation(NationId::ZERO, 100.0)
/// .with_network(NetworkMode::Local)
/// .with_systems(false)
/// .build();
/// ```
#[extension(pub trait GameBuilderTestExt)]
impl GameBuilder {
/// Create a simple test game with default 100x100 conquerable map
///
/// Returns a partially-configured builder. Tests must still add:
/// - `.with_network(NetworkMode::Local)`
/// - `.with_systems(false)` if needed
fn simple() -> Self {
Self::new().with_map(Arc::new(MapBuilder::new(100, 100).all_conquerable().build()))
}
/// Create a test game with custom map size (all conquerable tiles)
///
/// Returns a partially-configured builder. Tests must still add:
/// - `.with_network(NetworkMode::Local)`
/// - `.with_systems(false)` if needed
fn with_map_size(width: u16, height: u16) -> Self {
Self::new().with_map(Arc::new(MapBuilder::new(width, height).all_conquerable().build()))
}
/// Create a two-nation test game with default map
///
/// Returns a partially-configured builder with:
/// - Default 100x100 conquerable map
/// - Local nation: NationId::ZERO
/// - 1 bot nation
///
/// Tests must still add:
/// - `.with_network(NetworkMode::Local)`
/// - `.with_systems(false)` if needed
fn two_player() -> Self {
Self::simple().with_local_player(NationId::ZERO).with_bots(1)
}
/// Enable spawn phase with default timeout
///
/// Chainable after other fixture methods.
fn with_spawn_phase_enabled(self) -> Self {
self.with_spawn_phase(Some(30))
}
/// Add a nation with custom initial troops (switches to TestGameBuilder)
///
/// This creates a TestGameBuilder wrapper that allows you to specify
/// nation IDs and initial troop counts. The nations will be spawned
/// after `.build()` is called.
///
/// # Example
/// ```
/// let game = GameBuilder::simple()
/// .with_nation(NationId::ZERO, 100.0)
/// .with_nation(NationId::new(1).unwrap(), 150.0)
/// .with_network(NetworkMode::Local)
/// .build();
/// ```
fn with_nation(self, id: NationId, troops: f32) -> TestGameBuilder {
TestGameBuilder { builder: self, pending_nations: vec![(id, troops)] }
}
}

View File

@@ -0,0 +1,284 @@
#![allow(dead_code)]
#![allow(unused_imports)]
/// Shared test utilities and helpers
///
/// This module provides infrastructure for testing the Bevy ECS-based game logic:
/// - `builders`: Fluent API for constructing test maps
/// - `assertions`: Fluent assertion API for ECS state verification
/// - `fixtures`: Pre-built test scenarios and GameBuilder extensions
///
/// # Usage Example
/// ```
/// use common::{GameBuilderTestExt, GameTestExt, GameAssertExt};
///
/// let mut game = GameBuilder::with_map_size(50, 50)
/// .with_nation(NationId::ZERO, 100.0)
/// .with_network(NetworkMode::Local)
/// .with_systems(false)
/// .build();
///
/// game.conquer_tile(U16Vec2::new(5, 5), NationId::ZERO);
/// game.assert().player_owns(U16Vec2::new(5, 5), NationId::ZERO);
/// ```
use borders_core::game::Game;
use borders_core::prelude::*;
use extension_traits::extension;
use std::collections::HashMap;
use std::collections::HashSet;
use std::ops::Mul;
mod assertions;
mod builders;
mod fixtures;
// Re-export commonly used items
pub use assertions::*;
pub use builders::*;
pub use fixtures::*;
/// Internal extension trait providing action methods for World
///
/// This trait is used internally by GameTestExt. Tests should use GameTestExt instead.
#[extension(pub(crate) trait WorldTestExt)]
impl World {
/// Conquer a tile for a player
fn conquer_tile(&mut self, tile: U16Vec2, player: NationId) {
self.resource_mut::<TerritoryManager>().conquer(tile, player);
}
/// Conquer multiple tiles for a player
fn conquer_tiles(&mut self, tiles: &[U16Vec2], player: NationId) {
let mut territory_manager = self.resource_mut::<TerritoryManager>();
for &tile in tiles {
territory_manager.conquer(tile, player);
}
}
/// Conquer a square region of tiles centered at a position
fn conquer_region(&mut self, center: U16Vec2, radius: u32, player: NationId) {
let mut territory_manager = self.resource_mut::<TerritoryManager>();
let range = -1.mul(radius as i32)..=(radius as i32);
for dy in range.clone() {
for dx in range.clone() {
let tile = center.as_ivec2() + glam::IVec2::new(dx, dy);
if tile.x >= 0 && tile.y >= 0 {
let tile = tile.as_u16vec2();
if tile.x < territory_manager.width() && tile.y < territory_manager.height() {
territory_manager.conquer(tile, player);
}
}
}
}
}
/// Conquer all 4-directional neighbors of a tile
fn conquer_neighbors(&mut self, center: U16Vec2, player: NationId) {
let map_size = {
let mgr = self.resource::<TerritoryManager>();
U16Vec2::new(mgr.width(), mgr.height())
};
let mut territory_manager = self.resource_mut::<TerritoryManager>();
for neighbor in neighbors(center, map_size) {
territory_manager.conquer(neighbor, player);
}
}
/// Clear ownership of a tile
fn clear_tile(&mut self, tile: U16Vec2) -> Option<NationId> {
self.resource_mut::<TerritoryManager>().clear(tile)
}
/// Clear all territory changes
fn clear_territory_changes(&mut self) {
self.resource_mut::<TerritoryManager>().clear_changes();
}
/// Deactivate the spawn phase
fn deactivate_spawn_phase(&mut self) {
self.resource_mut::<SpawnPhase>().active = false;
}
/// Activate the spawn phase
fn activate_spawn_phase(&mut self) {
self.resource_mut::<SpawnPhase>().active = true;
}
/// Get the number of territory changes
fn get_change_count(&self) -> usize {
self.resource::<TerritoryManager>().iter_changes().count()
}
/// Get the entity for a nation
fn get_nation_entity(&self, nation_id: NationId) -> Entity {
let entity_map = self.resource::<NationEntityMap>();
entity_map.get_entity(nation_id)
}
/// Run the border update logic
fn update_borders(&mut self) {
if !self.resource::<TerritoryManager>().has_changes() {
return;
}
let (changed_tiles, map_size, tiles_by_owner) = {
let territory_manager = self.resource::<TerritoryManager>();
let changed_tiles: HashSet<U16Vec2> = territory_manager.iter_changes().collect();
let map_size = U16Vec2::new(territory_manager.width(), territory_manager.height());
let mut affected_tiles = HashSet::with_capacity(changed_tiles.len() * 5);
for &tile in &changed_tiles {
affected_tiles.insert(tile);
affected_tiles.extend(neighbors(tile, map_size));
}
let mut tiles_by_owner: HashMap<NationId, HashSet<U16Vec2>> = HashMap::new();
for &tile in &affected_tiles {
if let Some(nation_id) = territory_manager.get_nation_id(tile) {
tiles_by_owner.entry(nation_id).or_default().insert(tile);
}
}
(changed_tiles, map_size, tiles_by_owner)
};
let ownership_snapshot: HashMap<U16Vec2, Option<NationId>> = {
let territory_manager = self.resource::<TerritoryManager>();
let mut snapshot = HashMap::new();
for &tile in changed_tiles.iter() {
for neighbor in neighbors(tile, map_size) {
snapshot.entry(neighbor).or_insert_with(|| territory_manager.get_nation_id(neighbor));
}
snapshot.insert(tile, territory_manager.get_nation_id(tile));
}
snapshot
};
let mut players_query = self.query::<(&NationId, &mut BorderTiles)>();
for (nation_id, mut component_borders) in players_query.iter_mut(self) {
let empty_set = HashSet::new();
let player_tiles = tiles_by_owner.get(nation_id).unwrap_or(&empty_set);
for &tile in player_tiles {
let is_border = neighbors(tile, map_size).any(|neighbor| ownership_snapshot.get(&neighbor).and_then(|&owner| owner) != Some(*nation_id));
if is_border {
component_borders.0.insert(tile);
} else {
component_borders.0.remove(&tile);
}
}
for &tile in changed_tiles.iter() {
if ownership_snapshot.get(&tile).and_then(|&owner| owner) != Some(*nation_id) {
component_borders.0.remove(&tile);
}
}
}
}
/// Clear territory changes
fn clear_borders_changes(&mut self) {
self.resource_mut::<TerritoryManager>().clear_changes();
}
/// Get border tiles for a nation from ECS
fn get_nation_borders(&self, nation_id: NationId) -> HashSet<U16Vec2> {
let entity = self.get_nation_entity(nation_id);
self.get::<BorderTiles>(entity).expect("Nation entity missing BorderTiles component").0.clone()
}
/// Get border tiles from BorderCache
fn get_border_cache(&self, nation_id: NationId) -> Option<HashSet<U16Vec2>> {
self.resource::<BorderCache>().get(nation_id).cloned()
}
}
/// Extension trait providing convenient action methods for Game in tests
///
/// Provides test helpers that forward to World extension methods.
#[extension(pub trait GameTestExt)]
impl Game {
/// Manually spawn a nation entity with components (for tests that need specific nation IDs)
///
/// Use this when GameBuilder doesn't create the nations you need.
fn spawn_test_nation(&mut self, id: NationId, troops: f32) {
let entity = self.world_mut().spawn((id, borders_core::game::NationName(format!("Player {}", id.get())), borders_core::game::NationColor(borders_core::game::entities::HSLColor::new((id.get() as f32 * 137.5) % 360.0, 0.6, 0.5)), BorderTiles::default(), borders_core::game::Troops(troops), borders_core::game::TerritorySize(0), borders_core::game::ships::ShipCount::default())).id();
self.world_mut().resource_mut::<NationEntityMap>().0.insert(id, entity);
}
/// Conquer a tile for a nation
fn conquer_tile(&mut self, tile: U16Vec2, nation: NationId) {
self.world_mut().conquer_tile(tile, nation);
}
/// Conquer multiple tiles for a nation
fn conquer_tiles(&mut self, tiles: &[U16Vec2], nation: NationId) {
self.world_mut().conquer_tiles(tiles, nation);
}
/// Conquer a square region of tiles centered at a position
///
/// Conquers all tiles within `radius` steps of `center` (using taxicab distance).
/// For radius=1, conquers a 3x3 square. For radius=2, conquers a 5x5 square, etc.
fn conquer_region(&mut self, center: U16Vec2, radius: u32, nation: NationId) {
self.world_mut().conquer_region(center, radius, nation);
}
/// Conquer all 4-directional neighbors of a tile
fn conquer_neighbors(&mut self, center: U16Vec2, nation: NationId) {
self.world_mut().conquer_neighbors(center, nation);
}
/// Clear ownership of a tile, returning the previous owner
fn clear_tile(&mut self, tile: U16Vec2) -> Option<NationId> {
self.world_mut().clear_tile(tile)
}
/// Clear all territory changes from the change buffer
fn clear_territory_changes(&mut self) {
self.world_mut().clear_territory_changes();
}
/// Deactivate the spawn phase
fn deactivate_spawn_phase(&mut self) {
self.world_mut().deactivate_spawn_phase();
}
/// Activate the spawn phase
fn activate_spawn_phase(&mut self) {
self.world_mut().activate_spawn_phase();
}
/// Get the number of territory changes in the ChangeBuffer
fn get_change_count(&self) -> usize {
self.world().get_change_count()
}
/// Get the entity associated with a nation
fn get_nation_entity(&self, nation_id: NationId) -> Entity {
self.world().get_nation_entity(nation_id)
}
/// Run the border update logic (inline implementation for testing)
fn update_borders(&mut self) {
self.world_mut().update_borders();
}
/// Clear territory changes
fn clear_borders_changes(&mut self) {
self.world_mut().clear_borders_changes();
}
/// Get border tiles for a nation from ECS component
fn get_nation_borders(&self, nation_id: NationId) -> HashSet<U16Vec2> {
self.world().get_nation_borders(nation_id)
}
/// Get border tiles for a nation from BorderCache
fn get_border_cache(&self, nation_id: NationId) -> Option<HashSet<U16Vec2>> {
self.world().get_border_cache(nation_id)
}
}

View File

@@ -0,0 +1,308 @@
//! Full game smoke tests that run complete games with bots
//!
//! These tests verify the entire game lifecycle by running complete games
//! for 600 ticks (60 seconds equivalent at 10 TPS) with multiple bots.
//! The tests ensure the game doesn't crash and that all systems execute properly.
mod common;
use assert2::assert;
use borders_core::prelude::*;
use common::MapBuilder;
use rstest::rstest;
use std::collections::HashMap;
use std::sync::Arc;
/// Statistics collected during a full game run
#[derive(Debug)]
struct GameStats {
/// Final turn number reached
final_turn: u64,
/// Total tiles owned by all players
total_territory_owned: u32,
/// Number of bots that have claimed territory
bots_with_territory: usize,
/// Number of bots that took at least one action
active_bots: usize,
/// Total number of bot actions taken
total_bot_actions: usize,
}
/// Set up a test game using shared initialization code
///
/// # Arguments
/// * `player_count` - Number of bot players (no human players)
/// * `map_size` - (width, height) of the map in tiles
/// * `rng_seed` - Optional RNG seed for deterministic tests (defaults to 0xDEADBEEF)
///
/// # Returns
/// Configured Game ready to run game updates
fn setup_test_game(player_count: usize, map_size: (u16, u16), rng_seed: Option<u64>) -> Game {
let (map_width, map_height) = map_size;
// Generate terrain - all land tiles for maximum playable area
let terrain_data = MapBuilder::new(map_width, map_height).all_conquerable().build();
// Create game with unified GameBuilder API
let mut game = GameBuilder::new()
.with_map(Arc::new(terrain_data))
.with_bots(player_count as u32)
.with_network(NetworkMode::Local)
.with_spawn_phase(None) // Skip spawn phase for immediate game start
.with_rng_seed(rng_seed.unwrap_or(0xDEADBEEF)) // Deterministic for reproducible tests
.build();
// Initialize UI messages if the ui feature is enabled (for headless tests with UI systems)
use borders_core::ui::protocol::{BackendMessage, FrontendMessage};
game.add_message::<BackendMessage>();
game.add_message::<FrontendMessage>();
// When skipping spawn phase, immediately mark the game as started
if let Some(mut generator) = game.world_mut().get_resource_mut::<borders_core::networking::server::TurnGenerator>() {
generator.start_game_immediately();
}
game
}
/// Run a complete game with the specified number of bot players
///
/// # Arguments
/// * `player_count` - Number of bot players (no human players)
/// * `map_size` - (width, height) of the map in tiles
/// * `target_ticks` - Number of game ticks to run (10 TPS = 100ms per tick)
/// * `rng_seed` - Optional RNG seed for deterministic tests
///
/// # Returns
/// Statistics about the completed game
fn run_full_game(player_count: usize, map_size: (u16, u16), target_ticks: u64, rng_seed: Option<u64>) -> GameStats {
let mut game = setup_test_game(player_count, map_size, rng_seed);
// Debug: Check initial state
eprintln!("=== Initial State ===");
eprintln!("TurnGenerator exists: {}", game.world().get_resource::<borders_core::networking::server::TurnGenerator>().is_some());
eprintln!("TurnReceiver exists: {}", game.world().get_resource::<borders_core::networking::server::TurnReceiver>().is_some());
eprintln!("LocalPlayerContext exists: {}", game.world().get_resource::<borders_core::game::LocalPlayerContext>().is_some());
if let Some(handle) = game.world().get_resource::<borders_core::networking::server::LocalTurnServerHandle>() {
eprintln!("Server running: {}, paused: {}", handle.is_running(), handle.is_paused());
}
if let Some(spawn_manager) = game.world().get_resource::<borders_core::game::SpawnManager>() {
eprintln!("SpawnManager spawns: {} bots, {} players", spawn_manager.get_bot_spawns().len(), spawn_manager.get_player_spawns().len());
}
// Track initial and final territory sizes to verify bot activity
let initial_territory_sizes: HashMap<_, _> = {
use borders_core::game::ai::bot::Bot;
use borders_core::game::entities::TerritorySize;
let world = game.world_mut();
let mut query = world.query::<(&Bot, &NationId, &TerritorySize)>();
query.iter(world).map(|(_, &id, size)| (id, size.0)).collect()
};
// Main game loop - run for target_ticks iterations
for tick_num in 0..target_ticks {
// Advance time by exactly 100ms per tick (10 TPS)
if let Some(mut time) = game.world_mut().get_resource_mut::<Time>() {
#[allow(deprecated)]
time.update(std::time::Duration::from_millis(100));
}
// Update the game - this runs all Update and Last schedules
game.update();
// Debug: Check BorderCache on first few turns
if tick_num == 1 {
use borders_core::game::BorderCache;
let world = game.world();
if let Some(border_cache) = world.get_resource::<BorderCache>() {
let cache_map = border_cache.as_map();
let mut cache_keys: Vec<u16> = cache_map.keys().map(|id| id.get()).collect();
cache_keys.sort();
eprintln!("Tick {}: BorderCache has {} entries", tick_num, cache_map.len());
eprintln!(" BorderCache keys (sorted): {:?}", cache_keys);
if player_count <= 10 {
for (id, borders) in &cache_map {
eprintln!(" Player {}: {} border tiles", id.get(), borders.len());
}
}
}
}
// Print current turn for debugging
if let Some(current_turn) = game.world().get_resource::<CurrentTurn>() {
if tick_num <= 5 {
eprintln!(" CurrentTurn = Turn({})", current_turn.turn.turn_number);
}
} else if tick_num < 10 {
eprintln!("Tick {}: No CurrentTurn resource", tick_num);
}
}
// Collect final statistics
let final_turn = game.world().get_resource::<CurrentTurn>().map(|ct| ct.turn.turn_number).unwrap_or(0);
// Calculate territory ownership, border tiles, and troop levels from ECS components
use borders_core::game::BorderTiles;
use borders_core::game::ai::bot::Bot;
use borders_core::game::entities::{TerritorySize, Troops};
let (total_territory_owned, bots_with_territory, bots_with_borders, avg_troops) = {
let world = game.world_mut();
let mut total = 0u32;
let mut bots_count = 0usize;
let mut borders_count = 0usize;
let mut total_troops = 0.0f32;
let mut bot_count = 0;
for (_, territory_size, border_tiles, troops) in world.query::<(&Bot, &TerritorySize, &BorderTiles, &Troops)>().iter(world) {
total += territory_size.0;
if territory_size.0 > 0 {
bots_count += 1;
}
if !border_tiles.0.is_empty() {
borders_count += 1;
}
total_troops += troops.0;
bot_count += 1;
}
let avg = if bot_count > 0 { total_troops / bot_count as f32 } else { 0.0 };
(total, bots_count, borders_count, avg)
};
// Calculate how many bots expanded their territory (proxy for activity)
let final_territory_sizes: HashMap<_, _> = {
use borders_core::game::ai::bot::Bot;
use borders_core::game::entities::TerritorySize;
let world = game.world_mut();
let mut query = world.query::<(&Bot, &NationId, &TerritorySize)>();
query.iter(world).map(|(_, &id, size)| (id, size.0)).collect()
};
let active_bots = final_territory_sizes
.iter()
.filter(|(id, final_size)| {
let initial_size = initial_territory_sizes.get(id).copied().unwrap_or(0);
**final_size > initial_size + 10 // Expanded by more than 10 tiles
})
.count();
let territory_changes: usize = final_territory_sizes
.iter()
.map(|(id, final_size)| {
let initial_size = initial_territory_sizes.get(id).copied().unwrap_or(0);
final_size.saturating_sub(initial_size) as usize
})
.sum();
eprintln!("=== Final Game Stats ===");
eprintln!("Bots with territory: {}/{}", bots_with_territory, player_count);
eprintln!("Bots with border tiles: {}/{}", bots_with_borders, player_count);
eprintln!("Average bot troops: {:.1}", avg_troops);
eprintln!("Bots that expanded territory: {}/{}", active_bots, player_count);
eprintln!("Total territory owned: {}", total_territory_owned);
eprintln!("Total territory gained: {}", territory_changes);
GameStats { final_turn, total_territory_owned, bots_with_territory, active_bots, total_bot_actions: territory_changes }
}
/// Test game stability across different player counts and map sizes
///
/// Verifies that games run without crashing and maintain basic invariants:
/// - Game progresses beyond turn 0
/// - Bots claim and expand territory
/// - Territory ownership is tracked correctly
#[rstest]
#[case::tiny(10, (50, 50))] // 2,500 tiles ≈ 250 per player
#[case::small(25, (80, 80))] // 6,400 tiles ≈ 256 per player
#[case::medium_small(50, (115, 115))] // 13,225 tiles ≈ 264 per player
#[case::medium(100, (160, 160))] // 25,600 tiles ≈ 256 per player
#[case::large(250, (255, 255))] // 65,025 tiles ≈ 260 per player
#[case::xl(500, (360, 360))] // 129,600 tiles ≈ 259 per player
fn smoke_test_player_scaling(#[case] player_count: usize, #[case] map_size: (u16, u16)) {
// Initialize tracing for debugging
let _ = tracing_subscriber::fmt().with_env_filter(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("trace"))).with_test_writer().try_init();
let target_ticks = 600; // 60 seconds at 10 TPS
let stats = run_full_game(player_count, map_size, target_ticks, None);
eprintln!("{}-player game stats: {:#?}", player_count, stats);
// Game should have progressed
assert!(stats.final_turn > 0, "Game should have progressed past turn 0");
// Bots should have claimed territory (spawns applied)
assert!(stats.bots_with_territory > 0, "Bots should have claimed territory after spawning");
// Total territory should be owned
assert!(stats.total_territory_owned > 0, "Territory should be owned by players");
// At least some bots should be active
assert!(stats.active_bots > 0, "At least some bots should have expanded territory (active_bots: {}, total_territory_gained: {})", stats.active_bots, stats.total_bot_actions);
}
/// Test game stability across different simulation durations
///
/// Verifies that games run correctly for different time periods:
/// - Short runs (30 seconds)
/// - Standard runs (60 seconds)
/// - Extended runs (120 seconds)
#[rstest]
#[case::short(300)] // 30 seconds at 10 TPS
#[case::standard(600)] // 60 seconds at 10 TPS
#[case::long(1200)] // 120 seconds at 10 TPS
fn smoke_test_tick_duration(#[case] target_ticks: u64) {
// Initialize tracing for debugging
let _ = tracing_subscriber::fmt().with_env_filter(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("trace"))).with_test_writer().try_init();
let player_count = 100;
let map_size = (160, 160); // Medium scale
let stats = run_full_game(player_count, map_size, target_ticks, None);
eprintln!("{}-tick game stats: {:#?}", target_ticks, stats);
// Game should have progressed
assert!(stats.final_turn > 0, "Game should have progressed past turn 0");
// Bots should have claimed territory
assert!(stats.bots_with_territory > 0, "Bots should have claimed territory after spawning");
// Total territory should be owned
assert!(stats.total_territory_owned > 0, "Territory should be owned by players");
// At least some bots should be active
assert!(stats.active_bots > 0, "At least some bots should have expanded territory");
}
/// Test determinism and robustness across different RNG seeds
///
/// Verifies that games with different random seeds all:
/// - Run without crashing
/// - Maintain basic game invariants
/// - Produce valid game states
#[rstest]
#[case::seed_1(0xDEADBEEF)]
#[case::seed_2(0xCAFEBABE)]
#[case::seed_3(0x8BADF00D)]
fn smoke_test_rng_seeds(#[case] rng_seed: u64) {
// Initialize tracing for debugging
let _ = tracing_subscriber::fmt().with_env_filter(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("trace"))).with_test_writer().try_init();
let player_count = 100;
let map_size = (160, 160); // Medium scale
let target_ticks = 600; // 60 seconds at 10 TPS
let stats = run_full_game(player_count, map_size, target_ticks, Some(rng_seed));
eprintln!("RNG seed 0x{:X} game stats: {:#?}", rng_seed, stats);
// Game should have progressed
assert!(stats.final_turn > 0, "Game should have progressed past turn 0");
// Bots should have claimed territory
assert!(stats.bots_with_territory > 0, "Bots should have claimed territory after spawning");
// Total territory should be owned
assert!(stats.total_territory_owned > 0, "Territory should be owned by players");
// At least some bots should be active
assert!(stats.active_bots > 0, "At least some bots should have expanded territory");
}

View File

@@ -0,0 +1,136 @@
// Integration tests for multi-system interactions: spawn phase, territory conquest, attacks
mod common;
use assert2::assert;
use borders_core::game::builder::GameBuilder;
use borders_core::networking::NetworkMode;
use borders_core::prelude::*;
use common::{GameAssertExt, GameBuilderTestExt, GameTestExt};
#[test]
fn test_spawn_phase_lifecycle() {
let mut game = GameBuilder::simple().with_nation(NationId::ZERO, 100.0).with_spawn_phase_enabled().with_network(NetworkMode::Local).with_systems(false).build();
let spawn_phase = game.world().resource::<SpawnPhase>();
assert!(spawn_phase.active, "SpawnPhase should be active after initialization");
game.deactivate_spawn_phase();
let spawn_phase = game.world().resource::<SpawnPhase>();
assert!(!spawn_phase.active, "SpawnPhase should be inactive after deactivation");
game.assert().resource_exists::<TerritoryManager>("TerritoryManager");
}
#[test]
fn test_territory_conquest_triggers_changes() {
let player0 = NationId::ZERO;
let mut game = GameBuilder::simple().with_nation(player0, 100.0).with_network(NetworkMode::Local).with_systems(false).build();
game.assert().no_territory_changes();
let tile = U16Vec2::new(50, 50);
game.conquer_tile(tile, player0);
game.assert().has_territory_changes().player_owns(tile, player0);
}
#[test]
fn test_multi_player_territory_conquest() {
let player0 = NationId::ZERO;
let player1 = NationId::new(1).unwrap();
let player2 = NationId::new(2).unwrap();
let mut game = GameBuilder::simple().with_nation(player0, 100.0).with_nation(player1, 100.0).with_nation(player2, 100.0).with_network(NetworkMode::Local).with_systems(false).build();
let tile0 = U16Vec2::new(10, 10);
let tile1 = U16Vec2::new(20, 20);
let tile2 = U16Vec2::new(30, 30);
game.conquer_tile(tile0, player0);
game.conquer_tile(tile1, player1);
game.conquer_tile(tile2, player2);
game.assert().player_owns(tile0, player0).player_owns(tile1, player1).player_owns(tile2, player2);
let territory_manager = game.world().resource::<TerritoryManager>();
let changes: Vec<_> = territory_manager.iter_changes().collect();
assert!(changes.len() == 3, "Should have 3 changes (one per player), but found {}", changes.len());
}
#[test]
fn test_territory_ownership_transitions() {
let player0 = NationId::ZERO;
let player1 = NationId::new(1).unwrap();
let mut game = GameBuilder::simple().with_nation(player0, 100.0).with_nation(player1, 100.0).with_network(NetworkMode::Local).with_systems(false).build();
let tile = U16Vec2::new(50, 50);
game.assert().tile_unclaimed(tile);
game.conquer_tile(tile, player0);
game.assert().player_owns(tile, player0);
game.clear_territory_changes();
game.assert().no_territory_changes();
let previous_owner = game.clear_tile(tile);
assert!(previous_owner == Some(player0), "Previous owner should be player 0");
game.assert().tile_unclaimed(tile).has_territory_changes();
game.clear_territory_changes();
game.conquer_tile(tile, player1);
game.assert().player_owns(tile, player1).has_territory_changes();
}
#[test]
fn test_border_updates_on_territory_change() {
let player0 = NationId::ZERO;
let mut game = GameBuilder::with_map_size(100, 100).with_nation(player0, 100.0).with_network(NetworkMode::Local).with_systems(false).build();
let center = U16Vec2::new(50, 50);
let adjacent = U16Vec2::new(51, 50);
game.conquer_tile(center, player0);
let territory_manager = game.world().resource::<TerritoryManager>();
assert!(territory_manager.is_border(center), "Center tile should be a border (adjacent to unclaimed)");
game.conquer_tile(adjacent, player0);
let territory_manager = game.world().resource::<TerritoryManager>();
assert!(territory_manager.is_border(center), "Center tile should still be a border");
assert!(territory_manager.is_border(adjacent), "Adjacent tile should be a border");
}
#[test]
fn test_initialization_with_territories() {
let player0 = NationId::ZERO;
let player1 = NationId::new(1).unwrap();
let territory0 = vec![U16Vec2::new(10, 10), U16Vec2::new(10, 11)];
let territory1 = vec![U16Vec2::new(20, 20), U16Vec2::new(20, 21)];
let mut game = GameBuilder::simple().with_nation(player0, 100.0).with_nation(player1, 100.0).with_network(NetworkMode::Local).with_systems(false).build();
// Pre-assign territories using extension trait
game.conquer_tiles(&territory0, player0);
game.conquer_tiles(&territory1, player1);
let mut assertions = game.assert();
for tile in &territory0 {
assertions = assertions.player_owns(*tile, player0);
}
for tile in &territory1 {
assertions = assertions.player_owns(*tile, player1);
}
let territory_manager = game.world().resource::<TerritoryManager>();
let changes_count = territory_manager.iter_changes().count();
assert!(changes_count == 4, "After initialization with 4 tiles, expected 4 changes, but found {}", changes_count);
}

View File

@@ -0,0 +1,198 @@
//! Tests for intent timing and turn boundary race conditions
//!
//! These tests verify that intents sent between turn boundaries are properly
//! buffered and included in the next turn, rather than being dropped.
mod common;
use assert2::{assert, let_assert};
use borders_core::networking::Intent;
use borders_core::networking::client::Connection;
use borders_core::networking::server::LocalTurnServerHandle;
use borders_core::prelude::*;
use borders_core::time::Time;
use common::MapBuilder;
use std::sync::Arc;
/// Set up a minimal game for testing turn timing
fn setup_turn_test_game() -> Game {
let terrain_data = MapBuilder::new(20, 20).all_conquerable().build();
let mut game = GameBuilder::new()
.with_map(Arc::new(terrain_data))
.with_bots(1) // Need at least 1 bot to have a valid player
.with_network(NetworkMode::Local)
.with_spawn_phase(None) // Skip spawn phase
.with_rng_seed(0x12345)
.build();
// Start game immediately (skip spawn phase) - same as smoke tests
if let Some(mut generator) = game.world_mut().get_resource_mut::<borders_core::networking::server::TurnGenerator>() {
generator.start_game_immediately();
}
game
}
/// Helper to send an attack intent
fn send_attack_intent(game: &mut Game, troops: u32) {
let world = game.world_mut();
// Get current turn for intent tracking
let current_turn = world.get_resource::<borders_core::game::turn::CurrentTurn>().map(|ct| ct.turn.turn_number).unwrap_or(0);
// Get the connection and send intent
let mut connection = world.get_resource_mut::<Connection>().expect("Connection should exist");
let intent = Intent::Action(GameAction::Attack { target: None, troops });
connection.send_intent(intent, current_turn);
}
/// Helper to advance time by specific milliseconds
fn advance_time(game: &mut Game, millis: u64) {
if let Some(mut time) = game.world_mut().get_resource_mut::<Time>() {
#[allow(deprecated)]
time.update(std::time::Duration::from_millis(millis));
}
}
/// Helper to get the current turn from CurrentTurn resource
fn get_current_turn(game: &Game) -> Option<Turn> {
game.world().get_resource::<borders_core::game::turn::CurrentTurn>().map(|ct| ct.turn.clone())
}
#[test]
fn test_intent_sent_between_turns_is_included() {
// Initialize tracing for debugging
let _ = tracing_subscriber::fmt().with_env_filter(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("debug"))).with_test_writer().try_init();
let mut game = setup_turn_test_game();
// Verify server is running
{
let handle = game.world().get_resource::<LocalTurnServerHandle>().expect("LocalTurnServerHandle should exist");
assert!(handle.is_running());
assert!(!handle.is_paused());
}
// Frame 1: First update to emit Turn(0)
advance_time(&mut game, 100);
game.update();
let turn0 = get_current_turn(&game);
let_assert!(Some(turn) = turn0);
assert!(turn.turn_number == 0, "Should get Turn(0) initially");
assert!(turn.intents.is_empty(), "Turn(0) should have no intents (game start)");
// Frame 2: Advance 50ms (halfway to next turn)
advance_time(&mut game, 50);
game.update();
// Turn should still be Turn(0) - no new turn generated yet
let still_turn0 = get_current_turn(&game);
let_assert!(Some(turn) = still_turn0);
assert!(turn.turn_number == 0, "Should still be Turn(0) at 50ms (halfway)");
// NOW send the attack intent (at 50ms into the turn period)
send_attack_intent(&mut game, 100);
eprintln!("Intent sent at 50ms mark");
// Frame 3: Advance another 50ms (should trigger Turn(1) at 100ms total)
advance_time(&mut game, 50);
game.update();
// Turn(1) should be generated and should contain our intent
let turn1 = get_current_turn(&game);
let_assert!(Some(turn) = turn1, "Turn(1) should be generated at 100ms");
assert!(turn.turn_number == 1, "Should be Turn(1)");
// THIS IS THE KEY ASSERTION - it will FAIL with the current bug
assert!(turn.intents.len() == 1, "Turn(1) should contain the intent sent at 50ms (found {} intents)", turn.intents.len());
// Verify it's an attack intent
let_assert!(Some(sourced_intent) = turn.intents.first());
let_assert!(Intent::Action(GameAction::Attack { troops, .. }) = &sourced_intent.intent);
assert!(*troops == 100, "Should be the attack we sent");
eprintln!("✓ Intent successfully included in Turn(1)");
}
#[test]
fn test_intent_sent_at_turn_boundary_is_included() {
let mut game = setup_turn_test_game();
// Emit Turn(0)
advance_time(&mut game, 100);
game.update();
// Send intent right at the start of the turn period (0ms into next turn)
send_attack_intent(&mut game, 50);
// Advance full 100ms to generate next turn
advance_time(&mut game, 100);
game.update();
let turn1 = get_current_turn(&game);
let_assert!(Some(turn) = turn1, "Turn(1) should be generated at 100ms");
assert!(turn.turn_number == 1);
assert!(turn.intents.len() == 1, "Intent sent at 0ms should be included in Turn(1)");
}
#[test]
fn test_multiple_intents_across_frames() {
let mut game = setup_turn_test_game();
// Emit Turn(0)
advance_time(&mut game, 100);
game.update();
// Send first intent at 25ms
advance_time(&mut game, 25);
game.update();
send_attack_intent(&mut game, 100);
// Send second intent at 50ms
advance_time(&mut game, 25);
game.update();
send_attack_intent(&mut game, 200);
// Send third intent at 75ms
advance_time(&mut game, 25);
game.update();
send_attack_intent(&mut game, 300);
// Generate turn at 100ms
advance_time(&mut game, 25);
game.update();
let turn1 = get_current_turn(&game);
let_assert!(Some(turn) = turn1, "Turn(1) should be generated at 100ms");
assert!(turn.turn_number == 1);
assert!(turn.intents.len() == 3, "All 3 intents sent during turn period should be included (found {})", turn.intents.len());
}
#[test]
fn test_intent_sent_at_99ms_is_included() {
let mut game = setup_turn_test_game();
// Emit Turn(0)
advance_time(&mut game, 100);
game.update();
// Advance almost to turn boundary (99ms)
advance_time(&mut game, 99);
game.update();
// Send intent at 99ms (1ms before turn fires)
send_attack_intent(&mut game, 150);
// Generate turn at 100ms+
advance_time(&mut game, 1);
game.update();
let turn1 = get_current_turn(&game);
let_assert!(Some(turn) = turn1, "Turn(1) should be generated at 100ms");
assert!(turn.turn_number == 1);
assert!(turn.intents.len() == 1, "Intent sent at 99ms should be included in Turn(1)");
}

View File

@@ -0,0 +1,99 @@
// Game lifecycle tests: initialization, player spawning, cleanup
mod common;
use assert2::assert;
use std::sync::Arc;
use borders_core::prelude::*;
use common::{GameAssertExt, MapBuilder};
// Helper to create initialized game for testing
fn create_initialized_game(map_size: U16Vec2) -> Game {
let terrain_data = Arc::new(MapBuilder::new(map_size.x, map_size.y).all_conquerable().build());
GameBuilder::new()
.with_map(terrain_data)
.with_network(NetworkMode::Local)
.with_local_player(NationId::ZERO)
.with_bots(10)
.with_rng_seed(0xDEADBEEF)
.with_spawn_phase(Some(30))
.with_systems(false) // Don't run systems in tests
.build()
}
#[test]
fn test_initialize_game_creates_all_resources() {
let game = create_initialized_game(U16Vec2::new(100, 100));
game.assert().resource_exists::<TerritoryManager>("TerritoryManager").resource_exists::<ActiveAttacks>("ActiveAttacks").resource_exists::<TerrainData>("TerrainData").resource_exists::<DeterministicRng>("DeterministicRng").resource_exists::<CoastalTiles>("CoastalTiles").resource_exists::<NationEntityMap>("NationEntityMap").resource_exists::<LocalPlayerContext>("LocalPlayerContext").resource_exists::<SpawnPhase>("SpawnPhase").resource_exists::<SpawnTimeout>("SpawnTimeout").resource_exists::<SpawnManager>("SpawnManager").resource_exists::<server::LocalTurnServerHandle>("LocalTurnServerHandle").resource_exists::<server::TurnReceiver>("TurnReceiver").resource_exists::<server::TurnGenerator>("TurnGenerator");
}
#[test]
fn test_initialize_creates_player_entities() {
let game = create_initialized_game(U16Vec2::new(100, 100));
let world = game.world();
// Get entity map
let entity_map = world.resource::<NationEntityMap>();
// Verify 1 human + 10 bots = total players (matches create_initialized_game params)
let expected_bot_count = 10; // From create_initialized_game params
assert!(entity_map.0.len() == 1 + expected_bot_count, "Should have exactly {} player entities (1 human + {} bots), but found {}", 1 + expected_bot_count, expected_bot_count, entity_map.0.len());
// Verify human player (ID 0) exists
let human_entity = entity_map.0.get(&NationId::ZERO).expect("Human player entity not found");
// Verify human has all required components
assert!(world.get::<NationName>(*human_entity).is_some(), "Human player missing NationName component");
assert!(world.get::<NationColor>(*human_entity).is_some(), "Human player missing PlayerColor component");
assert!(world.get::<BorderTiles>(*human_entity).is_some(), "Human player missing BorderTiles component");
assert!(world.get::<Troops>(*human_entity).is_some(), "Human player missing Troops component");
assert!(world.get::<TerritorySize>(*human_entity).is_some(), "Human player missing TerritorySize component");
// Verify initial troops are correct
let troops = world.get::<Troops>(*human_entity).unwrap();
assert!(troops.0 == constants::nation::INITIAL_TROOPS, "Human player should start with {} troops, but has {}", constants::nation::INITIAL_TROOPS, troops.0);
// Verify territory size starts at 0
let territory_size = world.get::<TerritorySize>(*human_entity).unwrap();
assert!(territory_size.0 == 0, "Human player should start with 0 territory, but has {}", territory_size.0);
}
#[test]
fn test_spawn_phase_activates() {
let game = create_initialized_game(U16Vec2::new(100, 100));
let world = game.world();
// Verify spawn phase is active
let spawn_phase = world.resource::<SpawnPhase>();
assert!(spawn_phase.active, "SpawnPhase should be active after initialization");
let spawn_timeout = world.resource::<SpawnTimeout>();
assert!(spawn_timeout.duration_secs > 0.0, "SpawnTimeout should have a positive duration");
}
#[test]
fn test_cannot_start_game_twice() {
let game = create_initialized_game(U16Vec2::new(100, 100));
let world = game.world();
game.assert().resource_exists::<TerritoryManager>("TerritoryManager");
let has_territory_manager = world.contains_resource::<TerritoryManager>();
assert!(has_territory_manager, "This check prevents double-initialization in production code");
}
#[test]
fn test_territory_manager_dimensions() {
let map_size = U16Vec2::new(80, 60);
let game = create_initialized_game(map_size);
let world = game.world();
let territory_manager = world.resource::<TerritoryManager>();
assert!(territory_manager.width() == map_size.x, "TerritoryManager width should match map width: expected {}, got {}", map_size.x, territory_manager.width());
assert!(territory_manager.height() == map_size.y, "TerritoryManager height should match map height: expected {}, got {}", map_size.y, territory_manager.height());
assert!(territory_manager.len() == (map_size.x as usize) * (map_size.y as usize), "TerritoryManager should have width × height tiles: expected {}, got {}", (map_size.x as usize) * (map_size.y as usize), territory_manager.len());
}

Some files were not shown because too many files have changed in this diff Show More