mirror of
https://github.com/Xevion/smart-rgb.git
synced 2025-12-05 23:16:23 -06:00
Update source files
This commit is contained in:
18
.cargo/config.toml
Normal file
18
.cargo/config.toml
Normal 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
17
.cargo/mutants.toml
Normal 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
2
.cargo/nextest.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
[profile.default]
|
||||
fail-fast = false
|
||||
177
.github/workflows/builds.yml
vendored
Normal file
177
.github/workflows/builds.yml
vendored
Normal 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
66
.github/workflows/coverage.yml
vendored
Normal 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
84
.github/workflows/quality.yml
vendored
Normal 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
39
.github/workflows/tests.yml
vendored
Normal 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
32
.gitignore
vendored
Normal 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
1
.source-commit
Normal file
@@ -0,0 +1 @@
|
||||
4f842e4ad8b999d408857d532230bf326dd1a434
|
||||
8022
Cargo.lock
generated
Normal file
8022
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
97
Cargo.toml
Normal file
97
Cargo.toml
Normal 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
175
Justfile
Normal 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
13
LICENSE
Normal 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.
|
||||
96
crates/borders-core/Cargo.toml
Normal file
96
crates/borders-core/Cargo.toml
Normal 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"
|
||||
76
crates/borders-core/assets/maps/World.json
Normal file
76
crates/borders-core/assets/maps/World.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
crates/borders-core/assets/maps/World.png
Normal file
BIN
crates/borders-core/assets/maps/World.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 185 KiB |
244
crates/borders-core/benches/game_benchmarks.rs
Normal file
244
crates/borders-core/benches/game_benchmarks.rs
Normal 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);
|
||||
74
crates/borders-core/build.rs
Normal file
74
crates/borders-core/build.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
21
crates/borders-core/src/build_info.rs
Normal file
21
crates/borders-core/src/build_info.rs
Normal 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)
|
||||
}
|
||||
98
crates/borders-core/src/dns.rs
Normal file
98
crates/borders-core/src/dns.rs
Normal 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
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
454
crates/borders-core/src/game/ai/bot.rs
Normal file
454
crates/borders-core/src/game/ai/bot.rs
Normal 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
|
||||
}
|
||||
2
crates/borders-core/src/game/ai/mod.rs
Normal file
2
crates/borders-core/src/game/ai/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod bot;
|
||||
pub use bot::*;
|
||||
361
crates/borders-core/src/game/builder.rs
Normal file
361
crates/borders-core/src/game/builder.rs
Normal 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>,
|
||||
}
|
||||
375
crates/borders-core/src/game/combat/active.rs
Normal file
375
crates/borders-core/src/game/combat/active.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
118
crates/borders-core/src/game/combat/calculator.rs
Normal file
118
crates/borders-core/src/game/combat/calculator.rs
Normal 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
|
||||
}
|
||||
396
crates/borders-core/src/game/combat/executor.rs
Normal file
396
crates/borders-core/src/game/combat/executor.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
7
crates/borders-core/src/game/combat/mod.rs
Normal file
7
crates/borders-core/src/game/combat/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
pub mod active;
|
||||
pub mod calculator;
|
||||
pub mod executor;
|
||||
|
||||
pub use active::*;
|
||||
pub use calculator::*;
|
||||
pub use executor::*;
|
||||
57
crates/borders-core/src/game/core/action.rs
Normal file
57
crates/borders-core/src/game/core/action.rs
Normal 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),
|
||||
}
|
||||
255
crates/borders-core/src/game/core/constants.rs
Normal file
255
crates/borders-core/src/game/core/constants.rs
Normal 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;
|
||||
}
|
||||
18
crates/borders-core/src/game/core/mod.rs
Normal file
18
crates/borders-core/src/game/core/mod.rs
Normal 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::*;
|
||||
78
crates/borders-core/src/game/core/outcome.rs
Normal file
78
crates/borders-core/src/game/core/outcome.rs
Normal 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 });
|
||||
}
|
||||
}
|
||||
85
crates/borders-core/src/game/core/rng.rs
Normal file
85
crates/borders-core/src/game/core/rng.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
190
crates/borders-core/src/game/core/turn_execution.rs
Normal file
190
crates/borders-core/src/game/core/turn_execution.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
45
crates/borders-core/src/game/core/utils.rs
Normal file
45
crates/borders-core/src/game/core/utils.rs
Normal 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 }
|
||||
})
|
||||
}
|
||||
146
crates/borders-core/src/game/entities.rs
Normal file
146
crates/borders-core/src/game/entities.rs
Normal 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)
|
||||
}
|
||||
66
crates/borders-core/src/game/input/context.rs
Normal file
66
crates/borders-core/src/game/input/context.rs
Normal 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
|
||||
}
|
||||
}
|
||||
68
crates/borders-core/src/game/input/events.rs
Normal file
68
crates/borders-core/src/game/input/events.rs
Normal 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,
|
||||
}
|
||||
213
crates/borders-core/src/game/input/handlers.rs
Normal file
213
crates/borders-core/src/game/input/handlers.rs
Normal 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)");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
17
crates/borders-core/src/game/input/mod.rs
Normal file
17
crates/borders-core/src/game/input/mod.rs
Normal 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::*;
|
||||
70
crates/borders-core/src/game/input/processor.rs
Normal file
70
crates/borders-core/src/game/input/processor.rs
Normal 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);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
40
crates/borders-core/src/game/input/queue.rs
Normal file
40
crates/borders-core/src/game/input/queue.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
34
crates/borders-core/src/game/input/types.rs
Normal file
34
crates/borders-core/src/game/input/types.rs
Normal 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,
|
||||
}
|
||||
206
crates/borders-core/src/game/mod.rs
Normal file
206
crates/borders-core/src/game/mod.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
19
crates/borders-core/src/game/queries.rs
Normal file
19
crates/borders-core/src/game/queries.rs
Normal 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
|
||||
}
|
||||
100
crates/borders-core/src/game/ships/components.rs
Normal file
100
crates/borders-core/src/game/ships/components.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
15
crates/borders-core/src/game/ships/mod.rs
Normal file
15
crates/borders-core/src/game/ships/mod.rs
Normal 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::*;
|
||||
248
crates/borders-core/src/game/ships/pathfinding.rs
Normal file
248
crates/borders-core/src/game/ships/pathfinding.rs
Normal 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(¤t_pos) {
|
||||
// Reconstruct path
|
||||
let mut path = vec![current_pos];
|
||||
let mut current_tile = current_pos;
|
||||
|
||||
while let Some(&parent) = came_from.get(¤t_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(¤t_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()
|
||||
}
|
||||
286
crates/borders-core/src/game/ships/systems.rs
Normal file
286
crates/borders-core/src/game/ships/systems.rs
Normal 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)");
|
||||
}
|
||||
}
|
||||
}
|
||||
166
crates/borders-core/src/game/systems/borders.rs
Normal file
166
crates/borders-core/src/game/systems/borders.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
30
crates/borders-core/src/game/systems/income.rs
Normal file
30
crates/borders-core/src/game/systems/income.rs
Normal 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);
|
||||
}
|
||||
39
crates/borders-core/src/game/systems/mod.rs
Normal file
39
crates/borders-core/src/game/systems/mod.rs
Normal 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>,
|
||||
}
|
||||
80
crates/borders-core/src/game/systems/spawn.rs
Normal file
80
crates/borders-core/src/game/systems/spawn.rs
Normal 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
|
||||
}
|
||||
}
|
||||
61
crates/borders-core/src/game/systems/spawn_territory.rs
Normal file
61
crates/borders-core/src/game/systems/spawn_territory.rs
Normal 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()
|
||||
}
|
||||
107
crates/borders-core/src/game/systems/spawn_timeout.rs
Normal file
107
crates/borders-core/src/game/systems/spawn_timeout.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
68
crates/borders-core/src/game/systems/turn.rs
Normal file
68
crates/borders-core/src/game/systems/turn.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
54
crates/borders-core/src/game/systems/turn_actions.rs
Normal file
54
crates/borders-core/src/game/systems/turn_actions.rs
Normal 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 = ¤t_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 { .. } => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
21
crates/borders-core/src/game/systems/turn_attacks.rs
Normal file
21
crates/borders-core/src/game/systems/turn_attacks.rs
Normal 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);
|
||||
}
|
||||
51
crates/borders-core/src/game/systems/turn_spawns.rs
Normal file
51
crates/borders-core/src/game/systems/turn_spawns.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
124
crates/borders-core/src/game/terrain/connectivity.rs
Normal file
124
crates/borders-core/src/game/terrain/connectivity.rs
Normal 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))
|
||||
}
|
||||
250
crates/borders-core/src/game/terrain/data.rs
Normal file
250
crates/borders-core/src/game/terrain/data.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
9
crates/borders-core/src/game/terrain/mod.rs
Normal file
9
crates/borders-core/src/game/terrain/mod.rs
Normal 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::*;
|
||||
254
crates/borders-core/src/game/world/changes.rs
Normal file
254
crates/borders-core/src/game/world/changes.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
73
crates/borders-core/src/game/world/coastal.rs
Normal file
73
crates/borders-core/src/game/world/coastal.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
200
crates/borders-core/src/game/world/manager.rs
Normal file
200
crates/borders-core/src/game/world/manager.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
17
crates/borders-core/src/game/world/mod.rs
Normal file
17
crates/borders-core/src/game/world/mod.rs
Normal 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::*;
|
||||
100
crates/borders-core/src/game/world/nation_id.rs
Normal file
100
crates/borders-core/src/game/world/nation_id.rs
Normal 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)))
|
||||
}
|
||||
}
|
||||
64
crates/borders-core/src/game/world/ownership.rs
Normal file
64
crates/borders-core/src/game/world/ownership.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
268
crates/borders-core/src/game/world/tilemap.rs
Normal file
268
crates/borders-core/src/game/world/tilemap.rs
Normal 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]
|
||||
}
|
||||
}
|
||||
43
crates/borders-core/src/lib.rs
Normal file
43
crates/borders-core/src/lib.rs
Normal 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::*;
|
||||
}
|
||||
270
crates/borders-core/src/networking/client/connection.rs
Normal file
270
crates/borders-core/src/networking/client/connection.rs
Normal 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
|
||||
}
|
||||
}
|
||||
5
crates/borders-core/src/networking/client/mod.rs
Normal file
5
crates/borders-core/src/networking/client/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
mod connection;
|
||||
mod systems;
|
||||
|
||||
pub use connection::*;
|
||||
pub use systems::*;
|
||||
39
crates/borders-core/src/networking/client/systems.rs
Normal file
39
crates/borders-core/src/networking/client/systems.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
16
crates/borders-core/src/networking/mod.rs
Normal file
16
crates/borders-core/src/networking/mod.rs
Normal 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::*;
|
||||
43
crates/borders-core/src/networking/protocol.rs
Normal file
43
crates/borders-core/src/networking/protocol.rs
Normal 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;
|
||||
157
crates/borders-core/src/networking/server/coordinator.rs
Normal file
157
crates/borders-core/src/networking/server/coordinator.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
11
crates/borders-core/src/networking/server/mod.rs
Normal file
11
crates/borders-core/src/networking/server/mod.rs
Normal 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::*;
|
||||
173
crates/borders-core/src/networking/server/turn_generator.rs
Normal file
173
crates/borders-core/src/networking/server/turn_generator.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
65
crates/borders-core/src/networking/types.rs
Normal file
65
crates/borders-core/src/networking/types.rs
Normal 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>,
|
||||
}
|
||||
315
crates/borders-core/src/telemetry/client.rs
Normal file
315
crates/borders-core/src/telemetry/client.rs
Normal 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
|
||||
}
|
||||
122
crates/borders-core/src/telemetry/mod.rs
Normal file
122
crates/borders-core/src/telemetry/mod.rs
Normal 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
|
||||
}
|
||||
}
|
||||
148
crates/borders-core/src/telemetry/system_info.rs
Normal file
148
crates/borders-core/src/telemetry/system_info.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
77
crates/borders-core/src/telemetry/types.rs
Normal file
77
crates/borders-core/src/telemetry/types.rs
Normal 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,
|
||||
}
|
||||
268
crates/borders-core/src/telemetry/user_id.rs
Normal file
268
crates/borders-core/src/telemetry/user_id.rs
Normal 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()
|
||||
}
|
||||
175
crates/borders-core/src/time.rs
Normal file
175
crates/borders-core/src/time.rs
Normal 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
|
||||
}
|
||||
}
|
||||
242
crates/borders-core/src/ui/leaderboard.rs
Normal file
242
crates/borders-core/src/ui/leaderboard.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
114
crates/borders-core/src/ui/mod.rs
Normal file
114
crates/borders-core/src/ui/mod.rs
Normal 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 }));
|
||||
}
|
||||
}
|
||||
365
crates/borders-core/src/ui/protocol.rs
Normal file
365
crates/borders-core/src/ui/protocol.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
264
crates/borders-core/src/ui/transport.rs
Normal file
264
crates/borders-core/src/ui/transport.rs
Normal 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 ¤t_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);
|
||||
}
|
||||
}
|
||||
2
crates/borders-core/tests/attack_system_tests.rs
Normal file
2
crates/borders-core/tests/attack_system_tests.rs
Normal 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.
|
||||
94
crates/borders-core/tests/binary_envelope_tests.rs
Normal file
94
crates/borders-core/tests/binary_envelope_tests.rs
Normal 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[..]);
|
||||
}
|
||||
781
crates/borders-core/tests/border_system_tests.rs
Normal file
781
crates/borders-core/tests/border_system_tests.rs
Normal 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(¢er));
|
||||
}
|
||||
|
||||
#[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(¢er));
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
274
crates/borders-core/tests/coastal_tiles_test.rs
Normal file
274
crates/borders-core/tests/coastal_tiles_test.rs
Normal 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)));
|
||||
}
|
||||
425
crates/borders-core/tests/common/assertions.rs
Normal file
425
crates/borders-core/tests/common/assertions.rs
Normal 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
|
||||
}
|
||||
}
|
||||
95
crates/borders-core/tests/common/builders.rs
Normal file
95
crates/borders-core/tests/common/builders.rs
Normal 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 }
|
||||
}
|
||||
}
|
||||
349
crates/borders-core/tests/common/fixtures.rs
Normal file
349
crates/borders-core/tests/common/fixtures.rs
Normal 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)] }
|
||||
}
|
||||
}
|
||||
284
crates/borders-core/tests/common/mod.rs
Normal file
284
crates/borders-core/tests/common/mod.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
308
crates/borders-core/tests/full_game_smoke_tests.rs
Normal file
308
crates/borders-core/tests/full_game_smoke_tests.rs
Normal 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");
|
||||
}
|
||||
136
crates/borders-core/tests/integration_tests.rs
Normal file
136
crates/borders-core/tests/integration_tests.rs
Normal 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);
|
||||
}
|
||||
198
crates/borders-core/tests/intent_timing_tests.rs
Normal file
198
crates/borders-core/tests/intent_timing_tests.rs
Normal 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)");
|
||||
}
|
||||
99
crates/borders-core/tests/lifecycle_tests.rs
Normal file
99
crates/borders-core/tests/lifecycle_tests.rs
Normal 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
Reference in New Issue
Block a user