Update source files
11
.cargo/config.toml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[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"
|
||||||
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"
|
||||||
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
|
||||||
33
.gitignore
vendored
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
target/
|
||||||
|
pkg/
|
||||||
|
*.pem
|
||||||
|
/*.io/
|
||||||
|
releases/
|
||||||
|
|
||||||
|
# Build-copied shader files
|
||||||
|
frontend/public/assets/
|
||||||
|
|
||||||
|
# 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
@@ -0,0 +1 @@
|
|||||||
|
a4d0597beda33b4e9da45dc29d66bf6ccff15e37
|
||||||
7806
Cargo.lock
generated
Normal file
47
Cargo.toml
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
[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
|
||||||
|
|
||||||
|
# Enable a large amount of optimization in the dev profile for dependencies.
|
||||||
|
[profile.dev.package."*"]
|
||||||
|
opt-level = 3
|
||||||
|
|
||||||
|
# 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
|
||||||
92
Justfile
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
set shell := ["powershell"]
|
||||||
|
|
||||||
|
default:
|
||||||
|
just --list
|
||||||
|
|
||||||
|
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"; \
|
||||||
|
if ($wasRebuilt -or -not $pkgExists -or ($isRelease -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..."; \
|
||||||
|
wasm-opt -Oz --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 ($isRelease) { \
|
||||||
|
Write-Host "Running frontend build..."; \
|
||||||
|
pnpm -C frontend build:browser; \
|
||||||
|
}; \
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
desktop-release:
|
||||||
|
cargo tauri build
|
||||||
|
target/release/borders-desktop.exe
|
||||||
|
|
||||||
|
desktop-dev:
|
||||||
|
cargo tauri build --debug
|
||||||
|
target/debug/borders-desktop.exe
|
||||||
|
|
||||||
|
# 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
@@ -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.
|
||||||
89
crates/borders-core/Cargo.toml
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
[package]
|
||||||
|
name = "borders-core"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
|
||||||
|
[package.metadata.cargo-machete]
|
||||||
|
ignored = ["serde_bytes", "chrono"]
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["ui"]
|
||||||
|
ui = []
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1.0"
|
||||||
|
async-trait = "0.1"
|
||||||
|
bevy_ecs = { version = "0.17", default-features = false, features = ["std"] }
|
||||||
|
bincode = { version = "2.0.1", features = ["serde"] }
|
||||||
|
flume = "0.11"
|
||||||
|
futures = "0.3"
|
||||||
|
futures-lite = "2.6.1"
|
||||||
|
glam = "0.30"
|
||||||
|
hex = "0.4"
|
||||||
|
hmac = "0.12"
|
||||||
|
image = "0.25"
|
||||||
|
once_cell = "1.20"
|
||||||
|
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"
|
||||||
|
url = "2.5.0"
|
||||||
|
web-transport = "0.9"
|
||||||
|
|
||||||
|
# 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 = "5.0"
|
||||||
|
rustls-pemfile = "2.2.0"
|
||||||
|
ring = "0.17.14"
|
||||||
|
pem = "3.0.5"
|
||||||
|
sysinfo = "0.33"
|
||||||
|
|
||||||
|
[target.'cfg(windows)'.dependencies]
|
||||||
|
winreg = "0.52"
|
||||||
|
|
||||||
|
[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",
|
||||||
|
] }
|
||||||
|
web-time = "1.1"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
chrono = "0.4"
|
||||||
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
|
After Width: | Height: | Size: 185 KiB |
28
crates/borders-core/build.rs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
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();
|
||||||
|
|
||||||
|
// Read git commit from .source-commit file
|
||||||
|
let source_commit_path = workspace_root.join(".source-commit");
|
||||||
|
let git_commit = if source_commit_path.exists() {
|
||||||
|
fs::read_to_string(&source_commit_path).unwrap_or_else(|_| "unknown".to_string()).trim().to_string()
|
||||||
|
} else {
|
||||||
|
// Fallback to git command if file doesn't exist (local development)
|
||||||
|
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()).unwrap_or_else(|| "unknown".to_string())
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get current build time in UTC
|
||||||
|
let build_time = chrono::Utc::now().to_rfc3339();
|
||||||
|
|
||||||
|
// Set environment variables for compile-time access
|
||||||
|
println!("cargo:rustc-env=BUILD_GIT_COMMIT={}", git_commit);
|
||||||
|
println!("cargo:rustc-env=BUILD_TIME={}", build_time);
|
||||||
|
|
||||||
|
// Re-run if .source-commit changes
|
||||||
|
println!("cargo:rerun-if-changed={}", source_commit_path.display());
|
||||||
|
}
|
||||||
140
crates/borders-core/src/app.rs
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
//! Minimal ECS app wrapper to replace Bevy's App
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
#[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;
|
||||||
|
|
||||||
|
#[derive(Debug, Hash, PartialEq, Eq, Clone, ScheduleLabel)]
|
||||||
|
pub struct Fixed;
|
||||||
|
|
||||||
|
pub struct App {
|
||||||
|
world: World,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let mut world = World::new();
|
||||||
|
|
||||||
|
// Initialize schedules
|
||||||
|
let mut schedules = Schedules::new();
|
||||||
|
schedules.insert(Schedule::new(Startup));
|
||||||
|
schedules.insert(Schedule::new(Update));
|
||||||
|
schedules.insert(Schedule::new(Last));
|
||||||
|
schedules.insert(Schedule::new(Fixed));
|
||||||
|
|
||||||
|
world.insert_resource(schedules);
|
||||||
|
|
||||||
|
Self { world }
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update(&mut self) {
|
||||||
|
// 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) {
|
||||||
|
schedule.run(&mut self.world);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run Last schedule (includes event updates)
|
||||||
|
if let Some(schedule) = schedules.get_mut(Last) {
|
||||||
|
schedule.run(&mut self.world);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-insert schedules
|
||||||
|
self.world.insert_resource(schedules);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run_startup(&mut self) {
|
||||||
|
// 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();
|
||||||
|
|
||||||
|
for (_, schedule) in schedules.iter_mut() {
|
||||||
|
schedule.graph_mut().initialize(&mut self.world);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.world.insert_resource(schedules);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cleanup(&mut self) {
|
||||||
|
// Any cleanup needed before running
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for App {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plugin trait for modular setup
|
||||||
|
pub trait Plugin {
|
||||||
|
fn build(&self, app: &mut App);
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
8
crates/borders-core/src/constants.rs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/// Game tick interval in milliseconds (10 TPS = 100ms per turn)
|
||||||
|
pub const TICK_INTERVAL: u64 = 100;
|
||||||
|
|
||||||
|
/// Number of bot players
|
||||||
|
pub const BOT_COUNT: usize = 100;
|
||||||
|
|
||||||
|
/// Spawn timeout duration in seconds
|
||||||
|
pub const SPAWN_TIMEOUT_SECS: f32 = 2.0;
|
||||||
40
crates/borders-core/src/game/action.rs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
//! 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 serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// 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:
|
||||||
|
/// - Human players (via input systems → intents → network)
|
||||||
|
/// - AI bots (calculated deterministically during turn execution)
|
||||||
|
///
|
||||||
|
/// All actions are processed in a deterministic order during `GameInstance::execute_turn()`.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, bincode::Encode, bincode::Decode)]
|
||||||
|
pub enum GameAction {
|
||||||
|
/// Spawn a player's initial territory at a specific tile
|
||||||
|
///
|
||||||
|
/// TODO: Remove this, it should be handled by a Turn0 special configuration AND the Intent::SetSpawn
|
||||||
|
Spawn { player_id: u16, tile_index: u32 },
|
||||||
|
/// Attack a target tile with a percentage of the player's total troops
|
||||||
|
Attack { player_id: u16, target_tile: u32, troops_ratio: f32 },
|
||||||
|
/// Launch a transport ship to attack across water
|
||||||
|
LaunchShip {
|
||||||
|
player_id: u16,
|
||||||
|
target_tile: u32,
|
||||||
|
/// Troops as a percentage (0-100) to avoid float precision issues
|
||||||
|
troops_percent: u32,
|
||||||
|
},
|
||||||
|
// Future action types:
|
||||||
|
// BuildStructure { player_id: u16, tile_index: u32, structure_type: StructureType },
|
||||||
|
// LaunchNuke { player_id: u16, target_tile: u32 },
|
||||||
|
// RequestAlliance { player_id: u16, target_player: u16 },
|
||||||
|
// DeclareWar { player_id: u16, target_player: u16 },
|
||||||
|
}
|
||||||
211
crates/borders-core/src/game/borders.rs
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
/// Border tile management
|
||||||
|
///
|
||||||
|
/// This module manages border tiles for all players. 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 player borders on the map)
|
||||||
|
/// - Ship launching (find coastal borders for naval operations)
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use bevy_ecs::prelude::*;
|
||||||
|
|
||||||
|
use crate::game::{
|
||||||
|
GameInstance,
|
||||||
|
player::{BorderTiles, PlayerId},
|
||||||
|
territory_manager::TerritoryManager,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Result of a border transition
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct BorderTransitionResult {
|
||||||
|
/// Tiles that became interior (not borders anymore)
|
||||||
|
pub territory: Vec<usize>,
|
||||||
|
/// Tiles that are now attacker borders
|
||||||
|
pub attacker: Vec<usize>,
|
||||||
|
/// Tiles that are now defender borders
|
||||||
|
pub defender: Vec<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manages border tiles for all players
|
||||||
|
///
|
||||||
|
/// A border tile is a tile adjacent to a tile with a different owner.
|
||||||
|
/// This resource maintains efficient lookup of border tiles for attack
|
||||||
|
/// targeting and UI rendering.
|
||||||
|
///
|
||||||
|
/// # Border Detection Algorithm
|
||||||
|
///
|
||||||
|
/// A tile is a border if any of its 4-directional neighbors has a different owner:
|
||||||
|
/// - Water tiles have no owner and are never borders
|
||||||
|
/// - Unclaimed tiles have no owner and are never borders
|
||||||
|
/// - A tile surrounded entirely by same-owner tiles is interior (not a border)
|
||||||
|
///
|
||||||
|
/// # Future ECS Migration
|
||||||
|
///
|
||||||
|
/// This will eventually become a `BorderTiles` component on player entities,
|
||||||
|
/// with a system to update borders after territory changes.
|
||||||
|
#[derive(Resource)]
|
||||||
|
pub struct BorderManager {
|
||||||
|
border_tiles: Vec<HashSet<usize>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for BorderManager {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BorderManager {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self { border_tiles: Vec::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resets the border manager
|
||||||
|
///
|
||||||
|
/// Should only be called when a new game is started
|
||||||
|
pub fn reset(&mut self, _width: u32, _height: u32, player_count: usize) {
|
||||||
|
self.border_tiles = vec![HashSet::new(); player_count];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update borders after territory changes (e.g., conquest)
|
||||||
|
///
|
||||||
|
/// A tile is a border if any neighbor has a different owner.
|
||||||
|
/// This method checks all affected tiles (conquered tiles + their neighbors)
|
||||||
|
/// and updates border sets accordingly.
|
||||||
|
pub fn update_after_conquest(&mut self, tiles: &HashSet<usize>, attacker: usize, defender: Option<usize>, territory_manager: &TerritoryManager) -> BorderTransitionResult {
|
||||||
|
let _ = tracing::debug_span!("border_transition");
|
||||||
|
let _span = tracing::trace_span!("border_transition", tile_count = tiles.len()).entered();
|
||||||
|
|
||||||
|
let mut result = BorderTransitionResult { territory: Vec::new(), attacker: Vec::new(), defender: Vec::new() };
|
||||||
|
|
||||||
|
// Collect all tiles to check: conquered tiles + all their neighbors
|
||||||
|
let mut tiles_to_check = HashSet::new();
|
||||||
|
|
||||||
|
for &tile in tiles {
|
||||||
|
tiles_to_check.insert(tile);
|
||||||
|
|
||||||
|
// Add all neighbors
|
||||||
|
territory_manager.on_neighbor_indices(tile, |neighbor| {
|
||||||
|
tiles_to_check.insert(neighbor);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove conquered tiles from defender borders
|
||||||
|
if let Some(defender_id) = defender {
|
||||||
|
for &tile in tiles {
|
||||||
|
self.border_tiles[defender_id].remove(&tile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update borders for all affected tiles
|
||||||
|
for &tile in &tiles_to_check {
|
||||||
|
// Skip if tile has no owner (water or unclaimed)
|
||||||
|
if !territory_manager.has_owner(tile) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let owner_id = territory_manager.get_owner(tile);
|
||||||
|
let owner = owner_id as usize;
|
||||||
|
|
||||||
|
// Determine if this tile is a border
|
||||||
|
let is_border = Self::is_border_tile(tile, owner_id, territory_manager);
|
||||||
|
|
||||||
|
if is_border {
|
||||||
|
// Add to border set if not already present
|
||||||
|
if self.border_tiles[owner].insert(tile) {
|
||||||
|
// Track for result
|
||||||
|
if owner == attacker {
|
||||||
|
result.attacker.push(tile);
|
||||||
|
} else if Some(owner) == defender {
|
||||||
|
result.defender.push(tile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Remove from border set if present
|
||||||
|
if self.border_tiles[owner].remove(&tile) {
|
||||||
|
// Tile became interior
|
||||||
|
if owner == attacker {
|
||||||
|
result.territory.push(tile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if tiles.len() > 50 {
|
||||||
|
tracing::trace!(tile_count = tiles.len(), "Large border transition");
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a tile is a border tile using simple neighbor logic
|
||||||
|
///
|
||||||
|
/// A tile is a border if it has any neighbor with a different owner
|
||||||
|
fn is_border_tile(tile: usize, owner: u16, territory_manager: &TerritoryManager) -> bool {
|
||||||
|
let mut is_border = false;
|
||||||
|
|
||||||
|
territory_manager.on_neighbor_indices(tile, |neighbor| {
|
||||||
|
if territory_manager.get_owner(neighbor) != owner {
|
||||||
|
is_border = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
is_border
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the border tiles of a player
|
||||||
|
pub fn get_border_tiles(&self, player: usize) -> &HashSet<usize> {
|
||||||
|
&self.border_tiles[player]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update all player 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 players.
|
||||||
|
pub fn update_player_borders_system(mut players: Query<(&PlayerId, &mut BorderTiles)>, mut game_instance: ResMut<GameInstance>) {
|
||||||
|
if !game_instance.territory_manager.has_changes() {
|
||||||
|
return; // Early exit - no work needed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect all changed tiles at once
|
||||||
|
let changed_tiles: HashSet<usize> = game_instance.territory_manager.iter_changes().collect();
|
||||||
|
|
||||||
|
// Build affected tiles (changed + all neighbors)
|
||||||
|
let mut affected_tiles = HashSet::with_capacity(changed_tiles.len() * 5);
|
||||||
|
for &tile in &changed_tiles {
|
||||||
|
affected_tiles.insert(tile);
|
||||||
|
game_instance.territory_manager.on_neighbor_indices(tile, |n| {
|
||||||
|
affected_tiles.insert(n);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update each player's borders (dual-write to resource and components)
|
||||||
|
for (player_id, mut component_borders) in &mut players {
|
||||||
|
update_borders_for_player(player_id.0, &mut component_borders.0, &affected_tiles, &game_instance.territory_manager);
|
||||||
|
|
||||||
|
// Dual-write: sync to resource for backward compatibility
|
||||||
|
game_instance.border_manager.border_tiles[player_id.0 as usize] = component_borders.0.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update borders for a single player based on affected tiles
|
||||||
|
fn update_borders_for_player(player_id: u16, borders: &mut HashSet<usize>, affected_tiles: &HashSet<usize>, territory: &TerritoryManager) {
|
||||||
|
for &tile in affected_tiles {
|
||||||
|
let owner = territory.get_owner(tile);
|
||||||
|
|
||||||
|
// Skip tiles we don't own
|
||||||
|
if owner != player_id {
|
||||||
|
borders.remove(&tile);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a border (early exit when different owner found)
|
||||||
|
let is_border = territory.any_neighbor_has_different_owner(tile, player_id);
|
||||||
|
|
||||||
|
if is_border {
|
||||||
|
borders.insert(tile);
|
||||||
|
} else {
|
||||||
|
borders.remove(&tile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
521
crates/borders-core/src/game/bot.rs
Normal file
@@ -0,0 +1,521 @@
|
|||||||
|
use rand::rngs::StdRng;
|
||||||
|
use rand::{Rng, SeedableRng};
|
||||||
|
|
||||||
|
use crate::game::action::GameAction;
|
||||||
|
use crate::game::borders::BorderManager;
|
||||||
|
use crate::game::player::Player;
|
||||||
|
use crate::game::territory_manager::TerritoryManager;
|
||||||
|
|
||||||
|
/// Simple bot AI
|
||||||
|
pub struct BotPlayer {
|
||||||
|
last_action_tick: u64,
|
||||||
|
action_cooldown: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for BotPlayer {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BotPlayer {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let mut rng = rand::rng();
|
||||||
|
Self {
|
||||||
|
last_action_tick: 0,
|
||||||
|
action_cooldown: rng.random_range(0..10), // 0-1 seconds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tick the bot AI - now deterministic based on turn number and RNG seed
|
||||||
|
pub fn tick(&mut self, turn_number: u64, player: &Player, territory_manager: &TerritoryManager, border_manager: &BorderManager, 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, player ID, and global seed
|
||||||
|
let seed = rng_seed.wrapping_add(turn_number).wrapping_add(player.id as u64);
|
||||||
|
let mut rng = StdRng::seed_from_u64(seed);
|
||||||
|
self.action_cooldown = rng.random_range(3..15);
|
||||||
|
|
||||||
|
// Decide action: expand into wilderness or attack a neighbor
|
||||||
|
let action_type: f32 = rng.random();
|
||||||
|
|
||||||
|
let action = if action_type < 0.6 {
|
||||||
|
// Expand into wilderness (60% chance)
|
||||||
|
self.expand_wilderness(player, territory_manager, border_manager, &mut rng)
|
||||||
|
} else {
|
||||||
|
// Attack a neighbor (40% chance)
|
||||||
|
self.attack_neighbor(player, territory_manager, border_manager, &mut rng)
|
||||||
|
};
|
||||||
|
|
||||||
|
let _span = tracing::debug_span!("bot_tick", player_id = player.id, has_action = action.is_some()).entered();
|
||||||
|
action
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Expand into unclaimed territory
|
||||||
|
fn expand_wilderness(&self, player: &Player, territory_manager: &TerritoryManager, border_manager: &BorderManager, rng: &mut StdRng) -> Option<GameAction> {
|
||||||
|
let border_tiles = border_manager.get_border_tiles(player.id);
|
||||||
|
|
||||||
|
// Find a valid, unclaimed neighbor tile to attack
|
||||||
|
for &tile in border_tiles {
|
||||||
|
let x = tile as u32 % territory_manager.width();
|
||||||
|
let y = tile as u32 / territory_manager.width();
|
||||||
|
|
||||||
|
let neighbors = [(x > 0).then_some(tile - 1), (x < territory_manager.width() - 1).then_some(tile + 1), (y > 0).then_some(tile - territory_manager.width() as usize), (y < territory_manager.height() - 1).then_some(tile + territory_manager.width() as usize)];
|
||||||
|
|
||||||
|
for neighbor in neighbors.iter().flatten() {
|
||||||
|
if !territory_manager.has_owner(*neighbor) && !territory_manager.is_water(*neighbor) {
|
||||||
|
// Found unclaimed land
|
||||||
|
let troop_percentage: f32 = rng.random_range(0.1..0.3);
|
||||||
|
return Some(GameAction::Attack { player_id: player.id as u16, target_tile: *neighbor as u32, troops_ratio: troop_percentage });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::trace!(player_id = player.id, "No wilderness target found");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attack a neighboring player
|
||||||
|
fn attack_neighbor(&self, player: &Player, territory_manager: &TerritoryManager, border_manager: &BorderManager, rng: &mut StdRng) -> Option<GameAction> {
|
||||||
|
// Find neighboring players
|
||||||
|
let mut neighbors = std::collections::HashSet::new();
|
||||||
|
let border_tiles = border_manager.get_border_tiles(player.id);
|
||||||
|
|
||||||
|
for &tile in border_tiles {
|
||||||
|
let x = tile as u32 % territory_manager.width();
|
||||||
|
let y = tile as u32 / territory_manager.width();
|
||||||
|
|
||||||
|
// Check all neighbors
|
||||||
|
if x > 0 {
|
||||||
|
let neighbor = tile - 1;
|
||||||
|
let ownership = territory_manager.get_ownership(neighbor);
|
||||||
|
if let Some(nation_id) = ownership.nation_id()
|
||||||
|
&& nation_id != player.id as u16
|
||||||
|
{
|
||||||
|
neighbors.insert(nation_id as usize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if x < territory_manager.width() - 1 {
|
||||||
|
let neighbor = tile + 1;
|
||||||
|
let ownership = territory_manager.get_ownership(neighbor);
|
||||||
|
if let Some(nation_id) = ownership.nation_id()
|
||||||
|
&& nation_id != player.id as u16
|
||||||
|
{
|
||||||
|
neighbors.insert(nation_id as usize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if y > 0 {
|
||||||
|
let neighbor = tile - territory_manager.width() as usize;
|
||||||
|
let ownership = territory_manager.get_ownership(neighbor);
|
||||||
|
if let Some(nation_id) = ownership.nation_id()
|
||||||
|
&& nation_id != player.id as u16
|
||||||
|
{
|
||||||
|
neighbors.insert(nation_id as usize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if y < territory_manager.height() - 1 {
|
||||||
|
let neighbor = tile + territory_manager.width() as usize;
|
||||||
|
let ownership = territory_manager.get_ownership(neighbor);
|
||||||
|
if let Some(nation_id) = ownership.nation_id()
|
||||||
|
&& nation_id != player.id as u16
|
||||||
|
{
|
||||||
|
neighbors.insert(nation_id as usize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if neighbors.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick a random neighbor to attack
|
||||||
|
let neighbor_vec: Vec<_> = neighbors.into_iter().collect();
|
||||||
|
let target_id = neighbor_vec[rng.random_range(0..neighbor_vec.len())];
|
||||||
|
|
||||||
|
// To attack a player, we need to pick a specific tile.
|
||||||
|
// Let's find a border tile of the target player that is adjacent to us.
|
||||||
|
let target_border = border_manager.get_border_tiles(target_id);
|
||||||
|
for &target_tile in target_border {
|
||||||
|
let x = target_tile as u32 % territory_manager.width();
|
||||||
|
let y = target_tile as u32 / territory_manager.width();
|
||||||
|
let neighbor_indices = [(x > 0).then_some(target_tile - 1), (x < territory_manager.width() - 1).then_some(target_tile + 1), (y > 0).then_some(target_tile - territory_manager.width() as usize), (y < territory_manager.height() - 1).then_some(target_tile + territory_manager.width() as usize)];
|
||||||
|
|
||||||
|
for &neighbor_idx in neighbor_indices.iter().flatten() {
|
||||||
|
if territory_manager.get_owner(neighbor_idx) == player.id as u16 {
|
||||||
|
// This is a valid attack target
|
||||||
|
let troop_percentage: f32 = rng.random_range(0.2..0.5);
|
||||||
|
return Some(GameAction::Attack { player_id: player.id as u16, target_tile: target_tile as u32, troops_ratio: troop_percentage });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Minimum distance (in tiles) between any two spawn points
|
||||||
|
/// This ensures players and bots don't spawn too close together
|
||||||
|
const MIN_SPAWN_DISTANCE: f32 = 70.0;
|
||||||
|
|
||||||
|
/// Absolute minimum spawn distance to prevent exact overlaps
|
||||||
|
/// Used as final fallback when map is very crowded
|
||||||
|
const ABSOLUTE_MIN_DISTANCE: f32 = 5.0;
|
||||||
|
|
||||||
|
/// Distance reduction factor per adaptive wave (15% reduction)
|
||||||
|
const DISTANCE_REDUCTION_FACTOR: f32 = 0.85;
|
||||||
|
|
||||||
|
/// Spatial grid for fast spawn collision detection
|
||||||
|
/// Divides map into cells for O(1) neighbor queries instead of O(n)
|
||||||
|
struct SpawnGrid {
|
||||||
|
grid: std::collections::HashMap<(i32, i32), Vec<usize>>,
|
||||||
|
cell_size: f32,
|
||||||
|
map_width: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SpawnGrid {
|
||||||
|
fn new(cell_size: f32, map_width: u32) -> Self {
|
||||||
|
Self { grid: std::collections::HashMap::new(), cell_size, map_width }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert(&mut self, tile: usize) {
|
||||||
|
let cell = self.tile_to_cell(tile);
|
||||||
|
self.grid.entry(cell).or_default().push(tile);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tile_to_cell(&self, tile: usize) -> (i32, i32) {
|
||||||
|
let x = (tile as u32 % self.map_width) as f32;
|
||||||
|
let y = (tile as u32 / self.map_width) as f32;
|
||||||
|
((x / self.cell_size) as i32, (y / self.cell_size) as i32)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_nearby(&self, tile: usize, radius: f32, map_width: u32) -> bool {
|
||||||
|
let cell = self.tile_to_cell(tile);
|
||||||
|
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.0 + dx, cell.1 + dy);
|
||||||
|
if let Some(tiles) = self.grid.get(&check_cell) {
|
||||||
|
for &existing_tile in tiles {
|
||||||
|
if calculate_tile_distance(tile, existing_tile, map_width) < radius {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate Euclidean distance between two tiles
|
||||||
|
fn calculate_tile_distance(tile1: usize, tile2: usize, map_width: u32) -> f32 {
|
||||||
|
let x1 = (tile1 as u32 % map_width) as f32;
|
||||||
|
let y1 = (tile1 as u32 / map_width) as f32;
|
||||||
|
let x2 = (tile2 as u32 % map_width) as f32;
|
||||||
|
let y2 = (tile2 as u32 / map_width) as f32;
|
||||||
|
|
||||||
|
let dx = x1 - x2;
|
||||||
|
let dy = y1 - y2;
|
||||||
|
(dx * dx + dy * dy).sqrt()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manager for bot AI state and decision-making
|
||||||
|
///
|
||||||
|
/// BotManager is part of GameInstance and handles all bot decision-making
|
||||||
|
/// in a deterministic way. Unlike the old Bevy-based bot system, this is
|
||||||
|
/// part of the core game state and executes during turn processing.
|
||||||
|
pub struct BotManager {
|
||||||
|
bots: Vec<BotPlayer>,
|
||||||
|
bot_player_ids: Vec<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BotManager {
|
||||||
|
/// Create a new BotManager with the specified number of bots
|
||||||
|
pub fn new(bot_count: usize, human_player_count: usize) -> Self {
|
||||||
|
let bots = (0..bot_count).map(|_| BotPlayer::new()).collect();
|
||||||
|
|
||||||
|
// Bot player IDs start after human players
|
||||||
|
// Human player is ID 0, so first bot is ID 1
|
||||||
|
let first_bot_id = human_player_count;
|
||||||
|
let bot_player_ids = (first_bot_id..(first_bot_id + bot_count)).collect();
|
||||||
|
|
||||||
|
Self { bots, bot_player_ids }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the number of bots
|
||||||
|
pub fn bot_count(&self) -> usize {
|
||||||
|
self.bots.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get bot player IDs
|
||||||
|
pub fn bot_player_ids(&self) -> &[usize] {
|
||||||
|
&self.bot_player_ids
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<(player_id, tile_index)> for each bot
|
||||||
|
pub fn calculate_initial_spawns(&self, territory_manager: &TerritoryManager, rng_seed: u64) -> Vec<(usize, usize)> {
|
||||||
|
let width = territory_manager.width();
|
||||||
|
let height = territory_manager.height();
|
||||||
|
let map_size = (width * height) as usize;
|
||||||
|
|
||||||
|
let mut spawn_positions = Vec::with_capacity(self.bot_player_ids.len());
|
||||||
|
let mut grid = SpawnGrid::new(MIN_SPAWN_DISTANCE, width);
|
||||||
|
let mut current_min_distance = MIN_SPAWN_DISTANCE;
|
||||||
|
|
||||||
|
for (bot_index, &player_id) in self.bot_player_ids.iter().enumerate() {
|
||||||
|
// Deterministic RNG for spawn location
|
||||||
|
let seed = rng_seed.wrapping_add(player_id 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 (1000 attempts)
|
||||||
|
for _ in 0..1000 {
|
||||||
|
let tile = rng.random_range(0..map_size);
|
||||||
|
|
||||||
|
// Check if tile is valid land
|
||||||
|
if territory_manager.has_owner(tile) || territory_manager.is_water(tile) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check distance using spatial grid (O(1) instead of O(n))
|
||||||
|
if !grid.has_nearby(tile, current_min_distance, width) {
|
||||||
|
spawn_positions.push((player_id, tile));
|
||||||
|
grid.insert(tile);
|
||||||
|
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 * 0.8) as usize;
|
||||||
|
let mut attempts = 0;
|
||||||
|
for y in (0..height as usize).step_by(stride.max(1)) {
|
||||||
|
for x in (0..width as usize).step_by(stride.max(1)) {
|
||||||
|
let tile = y * width as usize + x;
|
||||||
|
if tile >= map_size {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if territory_manager.has_owner(tile) || territory_manager.is_water(tile) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !grid.has_nearby(tile, current_min_distance, width) {
|
||||||
|
spawn_positions.push((player_id, tile));
|
||||||
|
grid.insert(tile);
|
||||||
|
placed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
attempts += 1;
|
||||||
|
if attempts > 200 {
|
||||||
|
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..10000 {
|
||||||
|
let tile = rng.random_range(0..map_size);
|
||||||
|
if !territory_manager.has_owner(tile) && !territory_manager.is_water(tile) {
|
||||||
|
spawn_positions.push((player_id, tile));
|
||||||
|
grid.insert(tile);
|
||||||
|
placed = true;
|
||||||
|
tracing::warn!("Bot {} placed with fallback (no distance constraint)", player_id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !placed {
|
||||||
|
tracing::error!("Failed to place bot {} after all attempts", player_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
spawn_positions
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recalculate bot spawns considering player positions (second pass)
|
||||||
|
///
|
||||||
|
/// For any bot that is too close to a player 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
|
||||||
|
/// - `player_spawns`: Human player spawn positions
|
||||||
|
/// - `territory_manager`: For checking valid tiles
|
||||||
|
/// - `rng_seed`: For deterministic relocation
|
||||||
|
///
|
||||||
|
/// Returns updated Vec<(player_id, tile_index)> with relocated bots
|
||||||
|
pub fn recalculate_spawns_with_players(&self, initial_bot_spawns: Vec<(usize, usize)>, player_spawns: &[(usize, usize)], territory_manager: &TerritoryManager, rng_seed: u64) -> Vec<(usize, usize)> {
|
||||||
|
let width = territory_manager.width();
|
||||||
|
let height = territory_manager.height();
|
||||||
|
let map_size = (width * height) as usize;
|
||||||
|
|
||||||
|
// Build spatial grid from player spawns and bots we're keeping
|
||||||
|
let mut grid = SpawnGrid::new(MIN_SPAWN_DISTANCE, width);
|
||||||
|
for &(_, tile) in player_spawns {
|
||||||
|
grid.insert(tile);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Identify bots that need relocation
|
||||||
|
let mut bots_to_relocate = Vec::new();
|
||||||
|
let mut final_spawns = Vec::new();
|
||||||
|
|
||||||
|
for (player_id, tile) in initial_bot_spawns {
|
||||||
|
let mut needs_relocation = false;
|
||||||
|
|
||||||
|
// Check distance from all player spawns using grid
|
||||||
|
for &(_, player_tile) in player_spawns {
|
||||||
|
if calculate_tile_distance(tile, player_tile, width) < MIN_SPAWN_DISTANCE {
|
||||||
|
needs_relocation = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if needs_relocation {
|
||||||
|
bots_to_relocate.push(player_id);
|
||||||
|
} else {
|
||||||
|
final_spawns.push((player_id, tile));
|
||||||
|
grid.insert(tile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relocate bots using adaptive algorithm (same as calculate_initial_spawns)
|
||||||
|
let mut current_min_distance = MIN_SPAWN_DISTANCE;
|
||||||
|
|
||||||
|
for (reloc_index, &player_id) in bots_to_relocate.iter().enumerate() {
|
||||||
|
let seed = rng_seed.wrapping_add(player_id as u64).wrapping_add(0xDEADBEEF);
|
||||||
|
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 (1000 attempts)
|
||||||
|
for _ in 0..1000 {
|
||||||
|
let tile = rng.random_range(0..map_size);
|
||||||
|
|
||||||
|
// Check if tile is valid land
|
||||||
|
if territory_manager.has_owner(tile) || territory_manager.is_water(tile) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check distance using spatial grid (includes players + placed bots)
|
||||||
|
if !grid.has_nearby(tile, current_min_distance, width) {
|
||||||
|
final_spawns.push((player_id, tile));
|
||||||
|
grid.insert(tile);
|
||||||
|
placed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: Grid-guided fallback
|
||||||
|
if !placed {
|
||||||
|
let stride = (current_min_distance * 0.8) as usize;
|
||||||
|
let mut attempts = 0;
|
||||||
|
for y in (0..height as usize).step_by(stride.max(1)) {
|
||||||
|
for x in (0..width as usize).step_by(stride.max(1)) {
|
||||||
|
let tile = y * width as usize + x;
|
||||||
|
if tile >= map_size {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if territory_manager.has_owner(tile) || territory_manager.is_water(tile) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !grid.has_nearby(tile, current_min_distance, width) {
|
||||||
|
final_spawns.push((player_id, tile));
|
||||||
|
grid.insert(tile);
|
||||||
|
placed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
attempts += 1;
|
||||||
|
if attempts > 200 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if placed {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 3: Reduce minimum distance and retry
|
||||||
|
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: Place at any valid land tile (guaranteed)
|
||||||
|
if !placed {
|
||||||
|
for _ in 0..10000 {
|
||||||
|
let tile = rng.random_range(0..map_size);
|
||||||
|
if !territory_manager.has_owner(tile) && !territory_manager.is_water(tile) {
|
||||||
|
final_spawns.push((player_id, tile));
|
||||||
|
grid.insert(tile);
|
||||||
|
placed = true;
|
||||||
|
tracing::warn!("Bot {} relocated with fallback (no distance constraint)", player_id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !placed {
|
||||||
|
tracing::error!("Failed to relocate bot {} after all attempts", player_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final_spawns
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate action for a specific bot
|
||||||
|
///
|
||||||
|
/// This is deterministic - same inputs = same output
|
||||||
|
pub fn calculate_action(&mut self, bot_index: usize, turn_number: u64, player: &Player, territory_manager: &TerritoryManager, border_manager: &BorderManager, rng_seed: u64) -> Option<GameAction> {
|
||||||
|
if bot_index >= self.bots.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.bots[bot_index].tick(turn_number, player, territory_manager, border_manager, rng_seed)
|
||||||
|
}
|
||||||
|
}
|
||||||
256
crates/borders-core/src/game/combat/active.rs
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
/// 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::HashSet;
|
||||||
|
|
||||||
|
use bevy_ecs::prelude::*;
|
||||||
|
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::rng::DeterministicRng;
|
||||||
|
use crate::game::{borders::BorderManager, player::Player, territory_manager::TerritoryManager};
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
///
|
||||||
|
/// # Index Structures
|
||||||
|
///
|
||||||
|
/// Multiple index structures are maintained for fast lookup:
|
||||||
|
/// - `player_index`: [attacker][target] → set of attack keys
|
||||||
|
/// - `unclaimed_index`: [attacker] → attack key for unclaimed territory
|
||||||
|
/// - `player_attack_list`: [player] → attacks where player is attacker
|
||||||
|
/// - `target_attack_list`: [player] → attacks where player is target
|
||||||
|
///
|
||||||
|
/// Uses SlotMap for stable keys - no index shifting needed on removal.
|
||||||
|
/// Uses HashSet for O(1) lookups and removals.
|
||||||
|
#[derive(Resource)]
|
||||||
|
pub struct ActiveAttacks {
|
||||||
|
attacks: SlotMap<AttackKey, AttackExecutor>,
|
||||||
|
player_index: Vec<Vec<HashSet<AttackKey>>>, // [attacker][target] -> set of attack keys
|
||||||
|
unclaimed_index: Vec<Option<AttackKey>>, // [attacker] -> attack key for unclaimed
|
||||||
|
player_attack_list: Vec<HashSet<AttackKey>>, // [player] -> set of attack keys where player is attacker
|
||||||
|
target_attack_list: Vec<HashSet<AttackKey>>, // [player] -> set of attack keys where player is target
|
||||||
|
width: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ActiveAttacks {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveAttacks {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self { attacks: SlotMap::with_key(), player_index: Vec::new(), unclaimed_index: Vec::new(), player_attack_list: Vec::new(), target_attack_list: Vec::new(), width: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize the attack handler
|
||||||
|
pub fn init(&mut self, max_players: usize, width: u32) {
|
||||||
|
self.attacks.clear();
|
||||||
|
self.player_index = vec![vec![HashSet::new(); max_players]; max_players];
|
||||||
|
self.unclaimed_index = vec![None; max_players];
|
||||||
|
self.player_attack_list = vec![HashSet::new(); max_players];
|
||||||
|
self.target_attack_list = vec![HashSet::new(); max_players];
|
||||||
|
self.width = width;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Schedule an attack on unclaimed territory
|
||||||
|
///
|
||||||
|
/// If an attack on unclaimed territory already exists for this player,
|
||||||
|
/// the troops are added to it and borders are expanded.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn schedule_unclaimed(&mut self, player_id: usize, troops: f32, target_tile: usize, border_tiles: Option<&HashSet<usize>>, territory_manager: &TerritoryManager, border_manager: &BorderManager, turn_number: u64, rng: &DeterministicRng) {
|
||||||
|
// Check if there's already an attack on unclaimed territory
|
||||||
|
if let Some(attack_key) = self.unclaimed_index[player_id] {
|
||||||
|
// Add troops to existing attack
|
||||||
|
self.attacks[attack_key].modify_troops(troops);
|
||||||
|
|
||||||
|
// Add new borders to allow multi-region expansion
|
||||||
|
let borders = border_tiles.unwrap_or_else(|| border_manager.get_border_tiles(player_id));
|
||||||
|
self.attacks[attack_key].add_borders(borders, territory_manager, self.width, rng);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new attack
|
||||||
|
self.add_unclaimed(player_id, troops, target_tile, border_tiles, territory_manager, border_manager, turn_number, rng);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Schedule an attack on another player
|
||||||
|
///
|
||||||
|
/// 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, player_id: usize, target_id: usize, target_tile: usize, mut troops: f32, border_tiles: Option<&HashSet<usize>>, territory_manager: &TerritoryManager, border_manager: &BorderManager, turn_number: u64, rng: &DeterministicRng) {
|
||||||
|
// Check if there's already an attack on this target
|
||||||
|
if let Some(&attack_key) = self.player_index[player_id][target_id].iter().next() {
|
||||||
|
// Add troops to existing attack
|
||||||
|
self.attacks[attack_key].modify_troops(troops);
|
||||||
|
|
||||||
|
// Add new borders to allow multi-region expansion
|
||||||
|
let borders = border_tiles.unwrap_or_else(|| border_manager.get_border_tiles(player_id));
|
||||||
|
self.attacks[attack_key].add_borders(borders, territory_manager, self.width, rng);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for counter-attacks (opposite direction) - prevent mutual attacks
|
||||||
|
while !self.player_index[target_id][player_id].is_empty() {
|
||||||
|
let opposite_key = *self.player_index[target_id][player_id].iter().next().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(player_id, target_id, troops, target_tile, border_tiles, territory_manager, border_manager, 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 player.
|
||||||
|
pub fn tick(&mut self, players: &mut [Player], territory_manager: &mut TerritoryManager, border_manager: &mut BorderManager, rng: &DeterministicRng) {
|
||||||
|
let _span = tracing::trace_span!("attacks_tick", attack_count = self.attacks.len()).entered();
|
||||||
|
|
||||||
|
let mut attacks_to_remove = Vec::new();
|
||||||
|
|
||||||
|
for (attack_key, attack) in &mut self.attacks {
|
||||||
|
let _ = tracing::trace_span!("tick_attack");
|
||||||
|
let should_continue = attack.tick(players, territory_manager, border_manager, self.width, rng);
|
||||||
|
|
||||||
|
if !should_continue {
|
||||||
|
// Return remaining troops to player
|
||||||
|
let player_id = attack.player_id;
|
||||||
|
let remaining_troops = attack.get_troops();
|
||||||
|
tracing::trace!(player_id, remaining_troops, "Attack completed");
|
||||||
|
players[player_id].add_troops(remaining_troops);
|
||||||
|
|
||||||
|
// 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 player'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: usize, player_id: usize, territory_manager: &TerritoryManager, rng: &DeterministicRng) {
|
||||||
|
// Notify all attacks where this player is the attacker
|
||||||
|
for &attack_key in &self.player_attack_list[player_id] {
|
||||||
|
self.attacks[attack_key].handle_player_tile_add(tile, territory_manager, self.width, rng);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify all attacks where this player is the target
|
||||||
|
for &attack_key in &self.target_attack_list[player_id] {
|
||||||
|
self.attacks[attack_key].handle_target_tile_add(tile, territory_manager, self.width, rng);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add an attack on unclaimed territory
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn add_unclaimed(&mut self, player_id: usize, troops: f32, target_tile: usize, border_tiles: Option<&HashSet<usize>>, territory_manager: &TerritoryManager, border_manager: &BorderManager, turn_number: u64, rng: &DeterministicRng) {
|
||||||
|
let attack = AttackExecutor::new(AttackConfig { player_id, target_id: None, troops, target_tile, border_tiles, territory_manager, border_manager, width: self.width, turn_number }, rng);
|
||||||
|
|
||||||
|
let attack_key = self.attacks.insert(attack);
|
||||||
|
self.unclaimed_index[player_id] = Some(attack_key);
|
||||||
|
self.player_attack_list[player_id].insert(attack_key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add an attack on a player
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn add_attack(&mut self, player_id: usize, target_id: usize, troops: f32, target_tile: usize, border_tiles: Option<&HashSet<usize>>, territory_manager: &TerritoryManager, border_manager: &BorderManager, turn_number: u64, rng: &DeterministicRng) {
|
||||||
|
let attack = AttackExecutor::new(AttackConfig { player_id, target_id: Some(target_id), troops, target_tile, border_tiles, territory_manager, border_manager, width: self.width, turn_number }, rng);
|
||||||
|
|
||||||
|
let attack_key = self.attacks.insert(attack);
|
||||||
|
self.player_index[player_id][target_id].insert(attack_key);
|
||||||
|
self.player_attack_list[player_id].insert(attack_key);
|
||||||
|
self.target_attack_list[target_id].insert(attack_key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all attacks involving a specific player (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_player(&self, player_id: usize) -> Vec<(usize, Option<usize>, f32, u64, bool)> {
|
||||||
|
let mut attacks = Vec::new();
|
||||||
|
|
||||||
|
// Add outgoing attacks (player is attacker)
|
||||||
|
for &attack_key in &self.player_attack_list[player_id] {
|
||||||
|
let attack = &self.attacks[attack_key];
|
||||||
|
attacks.push((
|
||||||
|
attack.player_id,
|
||||||
|
attack.target_id,
|
||||||
|
attack.get_troops(),
|
||||||
|
attack.get_start_turn(),
|
||||||
|
true, // outgoing
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add incoming attacks (player is target)
|
||||||
|
for &attack_key in &self.target_attack_list[player_id] {
|
||||||
|
let attack = &self.attacks[attack_key];
|
||||||
|
attacks.push((
|
||||||
|
attack.player_id,
|
||||||
|
attack.target_id,
|
||||||
|
attack.get_troops(),
|
||||||
|
attack.get_start_turn(),
|
||||||
|
false, // incoming
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by start_turn 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];
|
||||||
|
let player_id = attack.player_id;
|
||||||
|
let target_id = attack.target_id;
|
||||||
|
|
||||||
|
// Remove from player attack list (O(1))
|
||||||
|
self.player_attack_list[player_id].remove(&attack_key);
|
||||||
|
|
||||||
|
if let Some(target_id) = target_id {
|
||||||
|
// Remove from target attack list (O(1))
|
||||||
|
self.target_attack_list[target_id].remove(&attack_key);
|
||||||
|
|
||||||
|
// Remove from player index (O(1))
|
||||||
|
self.player_index[player_id][target_id].remove(&attack_key);
|
||||||
|
} else {
|
||||||
|
// Remove from unclaimed index
|
||||||
|
self.unclaimed_index[player_id] = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove attack from slot map - no index shifting needed!
|
||||||
|
self.attacks.remove(attack_key);
|
||||||
|
}
|
||||||
|
}
|
||||||
152
crates/borders-core/src/game/combat/calculator.rs
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
/// 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 crate::game::constants::combat::*;
|
||||||
|
use crate::game::player::Player;
|
||||||
|
use crate::game::territory_manager::TerritoryManager;
|
||||||
|
|
||||||
|
/// Parameters for combat result calculation
|
||||||
|
pub struct CombatParams<'a> {
|
||||||
|
pub attacker: &'a Player,
|
||||||
|
pub defender: Option<&'a Player>,
|
||||||
|
pub attacker_troops: f32,
|
||||||
|
pub tile: usize,
|
||||||
|
pub territory_manager: &'a TerritoryManager,
|
||||||
|
pub width: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
pub fn sigmoid(x: f32, decay_rate: f32, midpoint: f32) -> f32 {
|
||||||
|
1.0 / (1.0 + (-(x - midpoint) * decay_rate).exp())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clamp a value between min and max
|
||||||
|
pub fn clamp(value: f32, min: f32, max: f32) -> f32 {
|
||||||
|
value.max(min).min(max)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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) = params.defender {
|
||||||
|
// Attacking claimed territory
|
||||||
|
let attacker = params.attacker;
|
||||||
|
|
||||||
|
// 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, defender, params.territory_manager, params.width);
|
||||||
|
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.get_territory_size() as f32, DEFENSE_DEBUFF_DECAY_RATE, DEFENSE_DEBUFF_MIDPOINT);
|
||||||
|
let large_defender_speed_debuff = 0.7 + 0.3 * defense_sig;
|
||||||
|
let large_defender_attack_debuff = 0.7 + 0.3 * defense_sig;
|
||||||
|
|
||||||
|
let large_attacker_bonus = if attacker.get_territory_size() > LARGE_EMPIRE_THRESHOLD { (LARGE_EMPIRE_THRESHOLD as f32 / attacker.get_territory_size() as f32).sqrt().powf(0.7) } else { 1.0 };
|
||||||
|
|
||||||
|
let large_attacker_speed_bonus = if attacker.get_territory_size() > LARGE_EMPIRE_THRESHOLD { (LARGE_EMPIRE_THRESHOLD as f32 / attacker.get_territory_size() as f32).powf(0.6) } else { 1.0 };
|
||||||
|
|
||||||
|
// Calculate troop ratio
|
||||||
|
let troop_ratio = clamp(defender.get_troops() / params.attacker_troops.max(1.0), 0.6, 2.0);
|
||||||
|
|
||||||
|
// Final attacker loss
|
||||||
|
let attacker_loss = troop_ratio * mag * 0.8 * large_defender_attack_debuff * large_attacker_bonus;
|
||||||
|
|
||||||
|
// Defender loss (simple: troops per tile)
|
||||||
|
let defender_loss = defender.get_troops() / defender.get_territory_size().max(1) as f32;
|
||||||
|
|
||||||
|
// Tiles per tick cost for this tile
|
||||||
|
let tiles_per_tick_used = clamp(defender.get_troops() / (5.0 * params.attacker_troops.max(1.0)), 0.2, 1.5) * 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 / 5.0, // 16.0 for plains
|
||||||
|
defender_loss: 0.0,
|
||||||
|
tiles_per_tick_used: clamp((2000.0 * BASE_SPEED_PLAINS.max(10.0)) / params.attacker_troops.max(1.0), 5.0, 100.0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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: Option<&Player>, border_size: f32) -> f32 {
|
||||||
|
if let Some(defender) = defender {
|
||||||
|
// Dynamic based on troop ratio
|
||||||
|
let ratio = ((5.0 * attacker_troops) / defender.get_troops().max(1.0)) * 2.0;
|
||||||
|
let clamped_ratio = clamp(ratio, 0.01, 0.5);
|
||||||
|
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: usize, _defender: &Player, _territory_manager: &TerritoryManager, _width: u32) -> bool {
|
||||||
|
// Placeholder for future defense post implementation
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sigmoid_midpoint() {
|
||||||
|
let result = sigmoid(DEFENSE_DEBUFF_MIDPOINT, DEFENSE_DEBUFF_DECAY_RATE, DEFENSE_DEBUFF_MIDPOINT);
|
||||||
|
assert!((result - 0.5).abs() < 0.01, "Sigmoid should be ~0.5 at midpoint");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_clamp() {
|
||||||
|
assert_eq!(clamp(5.0, 0.0, 10.0), 5.0);
|
||||||
|
assert_eq!(clamp(-1.0, 0.0, 10.0), 0.0);
|
||||||
|
assert_eq!(clamp(15.0, 0.0, 10.0), 10.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_unclaimed_attack_fixed_losses() {
|
||||||
|
// Unclaimed territory should have fixed attacker loss
|
||||||
|
let result = CombatResult { attacker_loss: BASE_MAG_PLAINS / 5.0, defender_loss: 0.0, tiles_per_tick_used: 10.0 };
|
||||||
|
|
||||||
|
assert_eq!(result.attacker_loss, 16.0);
|
||||||
|
assert_eq!(result.defender_loss, 0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
333
crates/borders-core/src/game/combat/executor.rs
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
/// 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, HashSet};
|
||||||
|
|
||||||
|
use rand::Rng;
|
||||||
|
|
||||||
|
use super::calculator::{CombatParams, calculate_combat_result, calculate_tiles_per_tick};
|
||||||
|
use crate::game::constants::combat::*;
|
||||||
|
use crate::game::rng::DeterministicRng;
|
||||||
|
use crate::game::utils::for_each_neighbor;
|
||||||
|
use crate::game::{TileOwnership, borders::BorderManager, player::Player, territory_manager::TerritoryManager};
|
||||||
|
|
||||||
|
/// Priority queue entry for tile conquest
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
struct TilePriority {
|
||||||
|
tile: usize,
|
||||||
|
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.cmp(&other.tile))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configuration for creating an AttackExecutor
|
||||||
|
pub struct AttackConfig<'a> {
|
||||||
|
pub player_id: usize,
|
||||||
|
pub target_id: Option<usize>,
|
||||||
|
pub troops: f32,
|
||||||
|
pub target_tile: usize,
|
||||||
|
pub border_tiles: Option<&'a HashSet<usize>>,
|
||||||
|
pub territory_manager: &'a TerritoryManager,
|
||||||
|
pub border_manager: &'a BorderManager,
|
||||||
|
pub width: u32,
|
||||||
|
pub turn_number: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
|
pub player_id: usize,
|
||||||
|
pub target_id: Option<usize>,
|
||||||
|
troops: f32,
|
||||||
|
border_tiles: HashSet<usize>,
|
||||||
|
priority_queue: BinaryHeap<TilePriority>,
|
||||||
|
start_turn: u64,
|
||||||
|
current_turn: u64,
|
||||||
|
tiles_conquered: u64, // Counter for each tile conquered (for priority calculation)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AttackExecutor {
|
||||||
|
/// Create a new attack executor
|
||||||
|
pub fn new(config: AttackConfig, rng: &DeterministicRng) -> Self {
|
||||||
|
let mut executor = Self { player_id: config.player_id, target_id: config.target_id, troops: config.troops, border_tiles: HashSet::new(), priority_queue: BinaryHeap::new(), start_turn: config.turn_number, current_turn: config.turn_number, tiles_conquered: 0 };
|
||||||
|
|
||||||
|
executor.initialize_border(config.border_tiles, config.territory_manager, config.border_manager, config.width, 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<usize>, territory_manager: &TerritoryManager, width: u32, rng: &DeterministicRng) {
|
||||||
|
// Add neighbors from each new border tile
|
||||||
|
let height = territory_manager.len() as u32 / width;
|
||||||
|
for &tile in new_border_tiles {
|
||||||
|
for_each_neighbor(tile, width, height, |neighbor| {
|
||||||
|
let neighbor_is_target = if let Some(target_id) = self.target_id { territory_manager.is_owner(neighbor, target_id) } else { !territory_manager.has_owner(neighbor) && !territory_manager.is_water(neighbor) };
|
||||||
|
if neighbor_is_target && !self.border_tiles.contains(&neighbor) {
|
||||||
|
self.border_tiles.insert(neighbor);
|
||||||
|
let priority = self.calculate_tile_priority(neighbor, territory_manager, width, rng);
|
||||||
|
self.priority_queue.push(TilePriority { tile: neighbor, priority });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 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, players: &mut [Player], territory_manager: &mut TerritoryManager, border_manager: &mut BorderManager, width: u32, rng: &DeterministicRng) -> bool {
|
||||||
|
let _ = tracing::debug_span!("attack_tick");
|
||||||
|
let _span = tracing::trace_span!("attack_tick", player_id = self.player_id).entered();
|
||||||
|
|
||||||
|
self.current_turn += 1;
|
||||||
|
|
||||||
|
// Calculate how many tiles to conquer this tick
|
||||||
|
let mut tiles_per_tick = self.calculate_tiles_per_tick(players, rng);
|
||||||
|
|
||||||
|
// Process tiles from priority queue
|
||||||
|
while tiles_per_tick > 0.0 {
|
||||||
|
if self.troops < 1.0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.priority_queue.is_empty() {
|
||||||
|
// Refresh border tiles one last time before giving up
|
||||||
|
self.refresh_border(border_manager, territory_manager, width, rng);
|
||||||
|
|
||||||
|
// If still empty after refresh, attack is finished
|
||||||
|
if self.priority_queue.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let tile_priority = self.priority_queue.pop().unwrap();
|
||||||
|
let tile = tile_priority.tile;
|
||||||
|
self.border_tiles.remove(&tile);
|
||||||
|
|
||||||
|
// Check connectivity first
|
||||||
|
let mut on_border = false;
|
||||||
|
let height = territory_manager.len() as u32 / width;
|
||||||
|
for_each_neighbor(tile, width, height, |neighbor| {
|
||||||
|
if territory_manager.is_owner(neighbor, self.player_id) {
|
||||||
|
on_border = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if tile is still valid target
|
||||||
|
let tile_valid = if let Some(target_id) = self.target_id { territory_manager.is_owner(tile, target_id) } else { !territory_manager.has_owner(tile) && !territory_manager.is_water(tile) };
|
||||||
|
|
||||||
|
// Skip if either check fails
|
||||||
|
if !tile_valid || !on_border {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add neighbors BEFORE conquering (critical for correct expansion)
|
||||||
|
self.add_neighbors_to_border(tile, territory_manager, width, rng);
|
||||||
|
|
||||||
|
// Calculate losses for this tile
|
||||||
|
let combat_result = calculate_combat_result(CombatParams { attacker: &players[self.player_id], defender: self.target_id.map(|id| &players[id]), attacker_troops: self.troops, tile, territory_manager, width });
|
||||||
|
|
||||||
|
// Check if we still have enough troops to conquer this tile
|
||||||
|
if self.troops < combat_result.attacker_loss {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply troop losses
|
||||||
|
self.troops -= combat_result.attacker_loss;
|
||||||
|
if let Some(target_id) = self.target_id {
|
||||||
|
players[target_id].remove_troops(combat_result.defender_loss);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conquer the tile
|
||||||
|
let previous_owner = territory_manager.conquer(tile, self.player_id);
|
||||||
|
|
||||||
|
// Update player territory sizes
|
||||||
|
let previous_ownership = TileOwnership::from_u16(previous_owner);
|
||||||
|
if let Some(nation_id) = previous_ownership.nation_id() {
|
||||||
|
players[nation_id as usize].remove_tile(tile);
|
||||||
|
}
|
||||||
|
players[self.player_id].add_tile(tile);
|
||||||
|
|
||||||
|
// 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, players: &[Player], 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.player_id as u64);
|
||||||
|
let random_border_adjustment = context_rng.random_range(0..BORDER_RANDOM_ADJUSTMENT_MAX) as f32;
|
||||||
|
let border_size = self.border_tiles.len() as f32 + random_border_adjustment;
|
||||||
|
|
||||||
|
let defender = self.target_id.map(|id| &players[id]);
|
||||||
|
calculate_tiles_per_tick(self.troops, defender, border_size)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize border tiles from player's existing borders
|
||||||
|
fn initialize_border(&mut self, border_tiles: Option<&HashSet<usize>>, territory_manager: &TerritoryManager, border_manager: &BorderManager, width: u32, rng: &DeterministicRng) {
|
||||||
|
let borders = border_tiles.unwrap_or_else(|| border_manager.get_border_tiles(self.player_id));
|
||||||
|
|
||||||
|
// Find all target tiles adjacent to our borders
|
||||||
|
let height = territory_manager.len() as u32 / width;
|
||||||
|
for &tile in borders {
|
||||||
|
for_each_neighbor(tile, width, height, |neighbor| {
|
||||||
|
let neighbor_is_target = if let Some(target_id) = self.target_id { territory_manager.is_owner(neighbor, target_id) } else { !territory_manager.has_owner(neighbor) && !territory_manager.is_water(neighbor) };
|
||||||
|
if neighbor_is_target && !self.border_tiles.contains(&neighbor) {
|
||||||
|
self.border_tiles.insert(neighbor);
|
||||||
|
let priority = self.calculate_tile_priority(neighbor, territory_manager, width, rng);
|
||||||
|
self.priority_queue.push(TilePriority { tile: neighbor, priority });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refresh the attack border by re-scanning all player border tiles
|
||||||
|
///
|
||||||
|
/// This gives the attack one last chance to find conquerable tiles before ending
|
||||||
|
fn refresh_border(&mut self, border_manager: &BorderManager, territory_manager: &TerritoryManager, width: u32, rng: &DeterministicRng) {
|
||||||
|
// Clear existing border and queue
|
||||||
|
self.priority_queue.clear();
|
||||||
|
self.border_tiles.clear();
|
||||||
|
|
||||||
|
// Re-scan all player border tiles to find new targets
|
||||||
|
let player_borders = border_manager.get_border_tiles(self.player_id);
|
||||||
|
let height = territory_manager.len() as u32 / width;
|
||||||
|
for &tile in player_borders {
|
||||||
|
for_each_neighbor(tile, width, height, |neighbor| {
|
||||||
|
let neighbor_is_target = if let Some(target_id) = self.target_id { territory_manager.is_owner(neighbor, target_id) } else { !territory_manager.has_owner(neighbor) && !territory_manager.is_water(neighbor) };
|
||||||
|
if neighbor_is_target && !self.border_tiles.contains(&neighbor) {
|
||||||
|
self.border_tiles.insert(neighbor);
|
||||||
|
let priority = self.calculate_tile_priority(neighbor, territory_manager, width, rng);
|
||||||
|
self.priority_queue.push(TilePriority { tile: neighbor, priority });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add neighbors of a newly conquered tile to the border
|
||||||
|
fn add_neighbors_to_border(&mut self, tile: usize, territory_manager: &TerritoryManager, width: u32, rng: &DeterministicRng) {
|
||||||
|
let height = territory_manager.len() as u32 / width;
|
||||||
|
for_each_neighbor(tile, width, height, |neighbor| {
|
||||||
|
let neighbor_is_target = if let Some(target_id) = self.target_id { territory_manager.is_owner(neighbor, target_id) } else { !territory_manager.has_owner(neighbor) && !territory_manager.is_water(neighbor) };
|
||||||
|
if neighbor_is_target && !self.border_tiles.contains(&neighbor) {
|
||||||
|
self.border_tiles.insert(neighbor);
|
||||||
|
let priority = self.calculate_tile_priority(neighbor, territory_manager, width, rng);
|
||||||
|
self.priority_queue.push(TilePriority { tile: neighbor, priority });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate priority for a tile (lower = conquered sooner)
|
||||||
|
///
|
||||||
|
/// Uses tiles_conquered counter to ensure wave-like expansion
|
||||||
|
fn calculate_tile_priority(&self, tile: usize, territory_manager: &TerritoryManager, width: u32, rng: &DeterministicRng) -> i64 {
|
||||||
|
// Count how many neighbors are owned by attacker
|
||||||
|
let mut num_owned_by_attacker = 0;
|
||||||
|
let height = territory_manager.len() as u32 / width;
|
||||||
|
for_each_neighbor(tile, width, height, |neighbor| {
|
||||||
|
if territory_manager.is_owner(neighbor, self.player_id) {
|
||||||
|
num_owned_by_attacker += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Terrain magnitude (placeholder - always 1.0 for plains)
|
||||||
|
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 = 1.0 - (num_owned_by_attacker as f32 * 0.5) + (terrain_mag / 2.0);
|
||||||
|
(base * modifier) as i64 + self.tiles_conquered as i64
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle the addition of a tile to the player's territory
|
||||||
|
pub fn handle_player_tile_add(&mut self, tile: usize, territory_manager: &TerritoryManager, width: u32, rng: &DeterministicRng) {
|
||||||
|
// When player gains a tile, check its neighbors for new targets
|
||||||
|
self.add_neighbors_to_border(tile, territory_manager, width, rng);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle the addition of a tile to the target's territory
|
||||||
|
pub fn handle_target_tile_add(&mut self, tile: usize, territory_manager: &TerritoryManager, width: u32, rng: &DeterministicRng) {
|
||||||
|
// If target gains a tile that borders our territory, add it to attack
|
||||||
|
if Self::check_borders_tile(tile, self.player_id, territory_manager, width) && !self.border_tiles.contains(&tile) {
|
||||||
|
self.border_tiles.insert(tile);
|
||||||
|
let priority = self.calculate_tile_priority(tile, territory_manager, width, rng);
|
||||||
|
self.priority_queue.push(TilePriority { tile, priority });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a tile borders the player's territory
|
||||||
|
fn check_borders_tile(tile: usize, player_id: usize, territory_manager: &TerritoryManager, width: u32) -> bool {
|
||||||
|
let height = territory_manager.len() as u32 / width;
|
||||||
|
let mut has_border = false;
|
||||||
|
for_each_neighbor(tile, width, height, |neighbor| {
|
||||||
|
if territory_manager.is_owner(neighbor, player_id) {
|
||||||
|
has_border = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
has_border
|
||||||
|
}
|
||||||
|
}
|
||||||
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::*;
|
||||||
155
crates/borders-core/src/game/connectivity.rs
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
use crate::game::TileOwnership;
|
||||||
|
use crate::game::terrain::TerrainData;
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
|
/// Check if a target tile's region connects to any of the player'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: &[u16], target_tile: usize, player_id: u16, width: usize, height: usize) -> bool {
|
||||||
|
let target_ownership = TileOwnership::from_u16(territory[target_tile]);
|
||||||
|
|
||||||
|
// Can't connect to water
|
||||||
|
if target_ownership.is_water() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If target is owned by player, it's already connected
|
||||||
|
if target_ownership.is_owned_by(player_id) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flood-fill from target through tiles with same ownership
|
||||||
|
let mut queue = VecDeque::new();
|
||||||
|
let mut visited = vec![false; width * height];
|
||||||
|
|
||||||
|
queue.push_back(target_tile);
|
||||||
|
visited[target_tile] = true;
|
||||||
|
|
||||||
|
let directions = [(0, -1), (1, 0), (0, 1), (-1, 0)]; // Cardinal directions only
|
||||||
|
|
||||||
|
while let Some(current) = queue.pop_front() {
|
||||||
|
let x = current % width;
|
||||||
|
let y = current / width;
|
||||||
|
|
||||||
|
// Check all 4 neighbors
|
||||||
|
for (dx, dy) in directions {
|
||||||
|
let nx = x as i32 + dx;
|
||||||
|
let ny = y as i32 + dy;
|
||||||
|
|
||||||
|
// Check bounds
|
||||||
|
if nx < 0 || ny < 0 || nx >= width as i32 || ny >= height as i32 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let neighbor_idx = nx as usize + ny as usize * width;
|
||||||
|
|
||||||
|
// Skip if already visited
|
||||||
|
if visited[neighbor_idx] {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let neighbor_ownership = TileOwnership::from_u16(territory[neighbor_idx]);
|
||||||
|
|
||||||
|
// Check if we found a player tile - SUCCESS!
|
||||||
|
if neighbor_ownership.is_owned_by(player_id) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only continue through tiles matching target's ownership
|
||||||
|
if neighbor_ownership == target_ownership {
|
||||||
|
visited[neighbor_idx] = true;
|
||||||
|
queue.push_back(neighbor_idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exhausted search without finding player 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 index if found
|
||||||
|
pub fn find_coastal_tile_in_region(territory: &[u16], terrain: &TerrainData, target_tile: usize, width: usize, height: usize) -> Option<usize> {
|
||||||
|
let target_ownership = TileOwnership::from_u16(territory[target_tile]);
|
||||||
|
|
||||||
|
// Can't find coastal tile in water
|
||||||
|
if target_ownership.is_water() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if target itself is coastal
|
||||||
|
if is_coastal_tile(terrain, target_tile, width, height) {
|
||||||
|
return Some(target_tile);
|
||||||
|
}
|
||||||
|
|
||||||
|
// BFS from target through same-ownership tiles
|
||||||
|
let mut queue = VecDeque::new();
|
||||||
|
let mut visited = vec![false; width * height];
|
||||||
|
|
||||||
|
queue.push_back(target_tile);
|
||||||
|
visited[target_tile] = true;
|
||||||
|
|
||||||
|
let directions = [(0, -1), (1, 0), (0, 1), (-1, 0)];
|
||||||
|
|
||||||
|
while let Some(current) = queue.pop_front() {
|
||||||
|
let x = current % width;
|
||||||
|
let y = current / width;
|
||||||
|
|
||||||
|
for (dx, dy) in directions {
|
||||||
|
let nx = x as i32 + dx;
|
||||||
|
let ny = y as i32 + dy;
|
||||||
|
|
||||||
|
if nx < 0 || ny < 0 || nx >= width as i32 || ny >= height as i32 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let neighbor_idx = nx as usize + ny as usize * width;
|
||||||
|
|
||||||
|
if visited[neighbor_idx] {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let neighbor_ownership = TileOwnership::from_u16(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_idx, width, height) {
|
||||||
|
return Some(neighbor_idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
queue.push_back(neighbor_idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a tile is coastal (land tile adjacent to water)
|
||||||
|
fn is_coastal_tile(terrain: &TerrainData, tile: usize, width: usize, height: usize) -> bool {
|
||||||
|
let x = tile % width;
|
||||||
|
let y = tile / width;
|
||||||
|
|
||||||
|
// Must be land tile
|
||||||
|
if terrain.is_navigable((x as u32, y as u32)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if any neighbor is water (4-directional)
|
||||||
|
let directions = [(0, -1), (1, 0), (0, 1), (-1, 0)];
|
||||||
|
|
||||||
|
for (dx, dy) in directions {
|
||||||
|
let nx = x as i32 + dx;
|
||||||
|
let ny = y as i32 + dy;
|
||||||
|
|
||||||
|
if nx >= 0 && ny >= 0 && nx < width as i32 && ny < height as i32 && terrain.is_navigable((nx as u32, ny as u32)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
49
crates/borders-core/src/game/constants.rs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
/// 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 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: usize = 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod spawning {
|
||||||
|
/// Radius of tiles claimed around spawn point (creates 5x5 square)
|
||||||
|
pub const SPAWN_RADIUS: i32 = 2;
|
||||||
|
}
|
||||||
420
crates/borders-core/src/game/game_instance.rs
Normal file
@@ -0,0 +1,420 @@
|
|||||||
|
use crate::game::action::GameAction;
|
||||||
|
use crate::game::borders::BorderManager;
|
||||||
|
use crate::game::bot::BotManager;
|
||||||
|
use crate::game::combat::ActiveAttacks;
|
||||||
|
use crate::game::player_manager::PlayerManager;
|
||||||
|
use crate::game::rng::DeterministicRng;
|
||||||
|
use crate::game::ships::ShipManager;
|
||||||
|
use crate::game::terrain::TerrainData;
|
||||||
|
use crate::game::territory_manager::TerritoryManager;
|
||||||
|
use crate::networking::{Intent, Turn};
|
||||||
|
use bevy_ecs::prelude::*;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
/// Troop count specification for attacks
|
||||||
|
enum TroopCount {
|
||||||
|
/// Use a ratio of the player's current troops (0.0-1.0)
|
||||||
|
Ratio(f32),
|
||||||
|
/// Use an absolute troop count
|
||||||
|
Absolute(u32),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Game state resource - DETERMINISTIC, SHARED across ALL clients
|
||||||
|
///
|
||||||
|
/// **Important: This is GLOBAL/SHARED state that must be identical on all clients!**
|
||||||
|
///
|
||||||
|
/// This resource contains the authoritative game state that:
|
||||||
|
/// - Is identical across all clients (server, players, spectators)
|
||||||
|
/// - Processes turns deterministically (same input → same output)
|
||||||
|
/// - Is used for hash validation and network synchronization
|
||||||
|
/// - Continues running even when individual players are eliminated
|
||||||
|
///
|
||||||
|
/// What belongs here:
|
||||||
|
/// - Territory ownership, player stats, attacks, resources
|
||||||
|
/// - Turn number, RNG seed (for determinism)
|
||||||
|
/// - Any state that affects gameplay or must be validated
|
||||||
|
///
|
||||||
|
/// What does NOT belong here:
|
||||||
|
/// - Client-specific UI state (use LocalPlayerContext)
|
||||||
|
/// - Individual player outcomes like Victory/Defeat (use LocalPlayerContext)
|
||||||
|
/// - Rendering preferences, camera position, etc. (use local resources)
|
||||||
|
///
|
||||||
|
/// The game never "stops" based on a single player's outcome - it continues
|
||||||
|
/// until a global end condition is met (e.g., all players eliminated, turn limit).
|
||||||
|
#[derive(Resource)]
|
||||||
|
pub struct GameInstance {
|
||||||
|
pub player_manager: PlayerManager,
|
||||||
|
pub territory_manager: TerritoryManager,
|
||||||
|
pub active_attacks: ActiveAttacks,
|
||||||
|
pub border_manager: BorderManager,
|
||||||
|
pub bot_manager: BotManager,
|
||||||
|
pub ship_manager: ShipManager,
|
||||||
|
pub terrain: Arc<TerrainData>,
|
||||||
|
pub rng: DeterministicRng,
|
||||||
|
pub turn_number: u64,
|
||||||
|
/// Cached set of all coastal tile indices (precomputed once, never changes)
|
||||||
|
/// A coastal tile is a land tile adjacent to water
|
||||||
|
pub coastal_tiles: HashSet<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GameInstance {
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn new(player_manager: PlayerManager, territory_manager: TerritoryManager, active_attacks: ActiveAttacks, border_manager: BorderManager, bot_manager: BotManager, ship_manager: ShipManager, terrain: Arc<TerrainData>, rng_seed: u64) -> Self {
|
||||||
|
// Precompute coastal tiles (land tiles adjacent to water)
|
||||||
|
let width = territory_manager.width() as usize;
|
||||||
|
let height = territory_manager.height() as usize;
|
||||||
|
let coastal_tiles = Self::compute_coastal_tiles(&terrain, width, height);
|
||||||
|
|
||||||
|
Self { player_manager, territory_manager, active_attacks, border_manager, bot_manager, ship_manager, terrain, rng: DeterministicRng::new(rng_seed), turn_number: 0, coastal_tiles }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute all coastal tile indices (land tiles adjacent to water)
|
||||||
|
/// This is called once at initialization and cached
|
||||||
|
fn compute_coastal_tiles(terrain: &TerrainData, width: usize, height: usize) -> HashSet<usize> {
|
||||||
|
let mut coastal_tiles = HashSet::new();
|
||||||
|
let directions = [(0, -1), (1, 0), (0, 1), (-1, 0)];
|
||||||
|
|
||||||
|
for y in 0..height {
|
||||||
|
for x in 0..width {
|
||||||
|
let tile_idx = x + y * width;
|
||||||
|
|
||||||
|
// Skip water tiles
|
||||||
|
if terrain.is_navigable((x as u32, y as u32)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if any neighbor is water (4-directional)
|
||||||
|
for (dx, dy) in directions {
|
||||||
|
let nx = x as i32 + dx;
|
||||||
|
let ny = y as i32 + dy;
|
||||||
|
|
||||||
|
if nx >= 0 && ny >= 0 && nx < width as i32 && ny < height as i32 && terrain.is_navigable((nx as u32, ny as u32)) {
|
||||||
|
coastal_tiles.insert(tile_idx);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
coastal_tiles
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn execute_turn(&mut self, turn: &Turn) {
|
||||||
|
let _span = tracing::trace_span!("execute_turn", turn_number = self.turn_number, intent_count = turn.intents.len()).entered();
|
||||||
|
|
||||||
|
// Update RNG for this turn
|
||||||
|
self.rng.update_turn(self.turn_number);
|
||||||
|
|
||||||
|
// PHASE 1: Process bot actions (deterministic, based on turn N-1 state)
|
||||||
|
let bot_player_ids = self.bot_manager.bot_player_ids().to_vec();
|
||||||
|
for (bot_index, &player_id) in bot_player_ids.iter().enumerate() {
|
||||||
|
if let Some(player) = self.player_manager.get_player(player_id) {
|
||||||
|
if !player.is_alive() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(action) = self.bot_manager.calculate_action(bot_index, self.turn_number, player, &self.territory_manager, &self.border_manager, self.rng.turn_number()) {
|
||||||
|
tracing::trace!(bot_index, player_id, "Bot action executed");
|
||||||
|
self.apply_action(action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PHASE 2: Process player intents (from network)
|
||||||
|
for intent in &turn.intents {
|
||||||
|
match intent {
|
||||||
|
Intent::Action(action) => {
|
||||||
|
self.apply_action(action.clone());
|
||||||
|
}
|
||||||
|
Intent::SetSpawn { .. } => {
|
||||||
|
// SetSpawn intents should not appear in Turn messages
|
||||||
|
// They are only valid during spawn phase and handled separately
|
||||||
|
// If we see one here, it's likely a bug or late arrival - ignore it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PHASE 3: Update ships and process arrivals
|
||||||
|
let ship_arrivals = self.ship_manager.update_ships();
|
||||||
|
for (owner_id, target_tile, troops) in ship_arrivals {
|
||||||
|
tracing::debug!(owner_id, target_tile, troops, "Ship arrived at destination, establishing beachhead");
|
||||||
|
|
||||||
|
let player_id = owner_id as usize;
|
||||||
|
let target_tile_usize = target_tile;
|
||||||
|
|
||||||
|
// Step 1: Force-claim the landing tile as beachhead
|
||||||
|
let previous_owner = self.territory_manager.conquer(target_tile_usize, player_id);
|
||||||
|
|
||||||
|
// Border updates now handled by update_player_borders_system (batched at end of turn)
|
||||||
|
|
||||||
|
// Step 2: Update player stats
|
||||||
|
let previous_ownership = crate::game::TileOwnership::from_u16(previous_owner);
|
||||||
|
if let Some(nation_id) = previous_ownership.nation_id()
|
||||||
|
&& let Some(prev_owner) = self.player_manager.get_player_mut(nation_id as usize)
|
||||||
|
{
|
||||||
|
prev_owner.remove_tile(target_tile_usize);
|
||||||
|
}
|
||||||
|
if let Some(player) = self.player_manager.get_player_mut(player_id) {
|
||||||
|
player.add_tile(target_tile_usize);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Notify active attacks of territory change
|
||||||
|
self.active_attacks.handle_territory_add(target_tile_usize, player_id, &self.territory_manager, &self.rng);
|
||||||
|
|
||||||
|
// Step 5: Create attack from beachhead to expand
|
||||||
|
// Note: Do NOT add troops back - they were deducted at ship launch
|
||||||
|
if let Some(player) = self.player_manager.get_player_mut(player_id) {
|
||||||
|
let _ = player;
|
||||||
|
|
||||||
|
// Find tiles adjacent to the beachhead
|
||||||
|
let width = self.territory_manager.width() as usize;
|
||||||
|
let height = self.territory_manager.height() as usize;
|
||||||
|
let beachhead_x = target_tile_usize % width;
|
||||||
|
let beachhead_y = target_tile_usize / width;
|
||||||
|
|
||||||
|
let directions = [(0, -1), (1, 0), (0, 1), (-1, 0)];
|
||||||
|
let mut adjacent_tiles = Vec::new();
|
||||||
|
|
||||||
|
for (dx, dy) in directions {
|
||||||
|
let nx = beachhead_x as i32 + dx;
|
||||||
|
let ny = beachhead_y as i32 + dy;
|
||||||
|
|
||||||
|
if nx >= 0 && ny >= 0 && (nx as usize) < width && (ny as usize) < height {
|
||||||
|
let neighbor_idx = nx as usize + ny as usize * width;
|
||||||
|
adjacent_tiles.push(neighbor_idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find valid attack targets (not water, not our own tiles)
|
||||||
|
let valid_targets: Vec<usize> = adjacent_tiles.iter().filter(|&&tile| !self.territory_manager.is_water(tile) && self.territory_manager.get_owner(tile) as usize != player_id).copied().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 = self.turn_number.wrapping_mul(31).wrapping_add(target_tile_usize as u64);
|
||||||
|
let index = (seed % valid_targets.len() as u64) as usize;
|
||||||
|
let attack_target = valid_targets[index];
|
||||||
|
|
||||||
|
// Create a beachhead border set containing just this tile for the attack
|
||||||
|
let beachhead_borders: HashSet<usize> = std::iter::once(target_tile_usize).collect();
|
||||||
|
|
||||||
|
// Use the ship's troops for the attack (already deducted at launch)
|
||||||
|
self.handle_attack_internal(
|
||||||
|
owner_id,
|
||||||
|
attack_target as u32,
|
||||||
|
TroopCount::Absolute(troops),
|
||||||
|
false, // Don't deduct - troops were already deducted at ship launch
|
||||||
|
Some(&beachhead_borders),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
tracing::debug!(player_id, target_tile_usize, "Ship landed but no valid attack targets found (all adjacent tiles are water or owned)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PHASE 4: Tick game systems (attacks, etc.)
|
||||||
|
// Note: Income is processed by process_player_income_system at 10 TPS (before this executes)
|
||||||
|
|
||||||
|
self.active_attacks.tick(self.player_manager.get_players_mut(), &mut self.territory_manager, &mut self.border_manager, &self.rng);
|
||||||
|
|
||||||
|
self.turn_number += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply a game action (used by both bots and player intents)
|
||||||
|
pub fn apply_action(&mut self, action: GameAction) {
|
||||||
|
match action {
|
||||||
|
GameAction::Spawn { player_id, tile_index } => {
|
||||||
|
self.handle_spawn(player_id, tile_index);
|
||||||
|
}
|
||||||
|
GameAction::Attack { player_id, target_tile, troops_ratio } => {
|
||||||
|
self.handle_attack(player_id, target_tile, troops_ratio);
|
||||||
|
}
|
||||||
|
GameAction::LaunchShip { player_id, target_tile, troops_percent } => {
|
||||||
|
self.handle_launch_ship(player_id, target_tile, troops_percent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle_spawn(&mut self, player_id: u16, tile_index: u32) {
|
||||||
|
let player_id = player_id as usize;
|
||||||
|
let width = self.territory_manager.width();
|
||||||
|
let height = self.territory_manager.height();
|
||||||
|
let tile = tile_index as usize;
|
||||||
|
|
||||||
|
if self.territory_manager.has_owner(tile) || self.territory_manager.is_water(tile) {
|
||||||
|
tracing::debug!(player_id, tile, "Spawn on occupied/water tile ignored");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let spawn_x = tile as u32 % width;
|
||||||
|
let spawn_y = tile as u32 / width;
|
||||||
|
|
||||||
|
let mut changed: HashSet<usize> = HashSet::new();
|
||||||
|
for dy in -2..=2 {
|
||||||
|
for dx in -2..=2 {
|
||||||
|
let x = (spawn_x as i32 + dx).clamp(0, width as i32 - 1) as u32;
|
||||||
|
let y = (spawn_y as i32 + dy).clamp(0, height as i32 - 1) as u32;
|
||||||
|
let idx = (x + y * width) as usize;
|
||||||
|
if !self.territory_manager.has_owner(idx) && !self.territory_manager.is_water(idx) {
|
||||||
|
self.territory_manager.conquer(idx, player_id);
|
||||||
|
changed.insert(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !changed.is_empty() {
|
||||||
|
// Border updates now handled by update_player_borders_system (batched at end of turn)
|
||||||
|
|
||||||
|
// Update player stats
|
||||||
|
if let Some(player) = self.player_manager.get_player_mut(player_id) {
|
||||||
|
for &t in &changed {
|
||||||
|
player.add_tile(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Notify active attacks that territory changed
|
||||||
|
for &t in &changed {
|
||||||
|
self.active_attacks.handle_territory_add(t, player_id, &self.territory_manager, &self.rng);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle_attack(&mut self, player_id: u16, target_tile: u32, troops_ratio: f32) {
|
||||||
|
self.handle_attack_internal(player_id, target_tile, TroopCount::Ratio(troops_ratio), true, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle attack with specific border tiles and troop allocation
|
||||||
|
fn handle_attack_internal(&mut self, player_id: u16, target_tile: u32, troop_count: TroopCount, deduct_from_player: bool, border_tiles: Option<&HashSet<usize>>) {
|
||||||
|
let player_id = player_id as usize;
|
||||||
|
let target_tile = target_tile as usize;
|
||||||
|
|
||||||
|
let target_owner = self.territory_manager.get_owner(target_tile);
|
||||||
|
|
||||||
|
if target_owner as usize == player_id {
|
||||||
|
tracing::debug!(player_id, target_tile, "Attack on own tile ignored");
|
||||||
|
return; // Can't attack self
|
||||||
|
}
|
||||||
|
|
||||||
|
let troops = match troop_count {
|
||||||
|
TroopCount::Ratio(ratio) => {
|
||||||
|
if let Some(player) = self.player_manager.get_player(player_id) {
|
||||||
|
player.get_troops() * ratio
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TroopCount::Absolute(count) => count as f32,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Deduct troops from the player's pool when creating the attack (if requested)
|
||||||
|
if deduct_from_player {
|
||||||
|
if let Some(player) = self.player_manager.get_player_mut(player_id) {
|
||||||
|
player.remove_troops(troops);
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let border_tiles_to_use = border_tiles.or_else(|| Some(self.border_manager.get_border_tiles(player_id)));
|
||||||
|
|
||||||
|
use crate::game::TileOwnership;
|
||||||
|
if TileOwnership::from_u16(target_owner).is_unclaimed() {
|
||||||
|
if self.player_manager.get_player(player_id).is_some() {
|
||||||
|
self.active_attacks.schedule_unclaimed(player_id, troops, target_tile, border_tiles_to_use, &self.territory_manager, &self.border_manager, self.turn_number, &self.rng);
|
||||||
|
}
|
||||||
|
} else if self.player_manager.get_player(target_owner as usize).is_some() && self.player_manager.get_player(player_id).is_some() {
|
||||||
|
self.active_attacks.schedule_attack(player_id, target_owner as usize, target_tile, troops, border_tiles_to_use, &self.territory_manager, &self.border_manager, self.turn_number, &self.rng);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle_launch_ship(&mut self, player_id: u16, target_tile: u32, troops_percent: u32) {
|
||||||
|
use crate::game::ships::{SHIP_TROOP_PERCENT, ship_pathfinding};
|
||||||
|
|
||||||
|
let player_id_usize = player_id as usize;
|
||||||
|
let target_tile_usize = target_tile as usize;
|
||||||
|
|
||||||
|
// Check if player exists and has troops
|
||||||
|
let player_troops = if let Some(player) = self.player_manager.get_player(player_id_usize) {
|
||||||
|
if !player.is_alive() {
|
||||||
|
tracing::debug!(player_id, "Dead player cannot launch ships");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
player.get_troops()
|
||||||
|
} else {
|
||||||
|
tracing::debug!(player_id, "Player not found");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if player_troops <= 0.0 {
|
||||||
|
tracing::debug!(player_id, "Player has no troops to launch ship");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate troop count: use provided percentage, or default to 20% if 0
|
||||||
|
let troops_to_send = if troops_percent > 0 {
|
||||||
|
// Clamp to reasonable range (1-100%)
|
||||||
|
let clamped = troops_percent.clamp(1, 100);
|
||||||
|
let calculated = (player_troops * (clamped as f32 / 100.0)).floor() as u32;
|
||||||
|
tracing::debug!(player_id, player_troops, troops_percent = clamped, troops_to_send = calculated, "Ship launch troop calculation");
|
||||||
|
calculated
|
||||||
|
} else {
|
||||||
|
// Default: 20% of troops
|
||||||
|
let calculated = (player_troops * SHIP_TROOP_PERCENT).floor() as u32;
|
||||||
|
tracing::debug!(player_id, player_troops, default_percent = SHIP_TROOP_PERCENT, troops_to_send = calculated, "Ship launch troop calculation (default)");
|
||||||
|
calculated
|
||||||
|
};
|
||||||
|
if troops_to_send == 0 {
|
||||||
|
tracing::debug!(player_id, "Not enough troops to launch ship");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let width = self.territory_manager.width() as usize;
|
||||||
|
let height = self.territory_manager.height() as usize;
|
||||||
|
|
||||||
|
// Find target's nearest coastal tile
|
||||||
|
let target_coastal_tile = crate::game::connectivity::find_coastal_tile_in_region(self.territory_manager.as_u16_slice(), &self.terrain, target_tile_usize, width, height);
|
||||||
|
|
||||||
|
let target_coastal_tile = match target_coastal_tile {
|
||||||
|
Some(tile) => tile,
|
||||||
|
None => {
|
||||||
|
tracing::debug!(player_id, target_tile, "No coastal tile found in target region");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Find player's nearest coastal tile
|
||||||
|
let player_border_tiles = self.border_manager.get_border_tiles(player_id_usize);
|
||||||
|
let launch_tile = ShipManager::find_nearest_player_coastal_tile(&self.coastal_tiles, player_border_tiles, target_coastal_tile, width);
|
||||||
|
|
||||||
|
let launch_tile = match launch_tile {
|
||||||
|
Some(tile) => tile,
|
||||||
|
None => {
|
||||||
|
tracing::debug!(player_id, target_tile, "Player has no coastal tiles to launch from");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate water path from launch tile to target coastal tile
|
||||||
|
let path = ship_pathfinding::find_water_path(&self.terrain, launch_tile, target_coastal_tile, crate::game::ships::SHIP_MAX_PATH_LENGTH);
|
||||||
|
|
||||||
|
let path = match path {
|
||||||
|
Some(p) => p, // Use full A* path for accurate frontend interpolation
|
||||||
|
None => {
|
||||||
|
tracing::debug!(player_id, target_tile, launch_tile, "No water path found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Launch the ship
|
||||||
|
if self.ship_manager.launch_ship(player_id, troops_to_send, path, self.turn_number).is_some() {
|
||||||
|
// Deduct troops from player
|
||||||
|
if let Some(player) = self.player_manager.get_player_mut(player_id_usize) {
|
||||||
|
player.remove_troops(troops_to_send as f32);
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::debug!(player_id, target_tile, troops_to_send, launch_tile, "Ship launched successfully");
|
||||||
|
} else {
|
||||||
|
tracing::debug!(player_id, "Failed to launch ship (likely at ship limit)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
52
crates/borders-core/src/game/income.rs
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
use bevy_ecs::prelude::*;
|
||||||
|
use tracing::trace;
|
||||||
|
|
||||||
|
use crate::game::{BorderTiles, BotPlayer, CurrentTurn, GameInstance, PlayerId, TerritorySize, Troops};
|
||||||
|
|
||||||
|
/// Process player income at 10 TPS (once per turn)
|
||||||
|
/// Only runs when turn_is_ready() condition is true
|
||||||
|
///
|
||||||
|
/// Uses Option<&BotPlayer> to distinguish bot vs human players:
|
||||||
|
/// - Some(&BotPlayer) = bot player (60% income, 33% max troops)
|
||||||
|
/// - None = human player (100% income, 100% max troops)
|
||||||
|
///
|
||||||
|
/// On first run, this system also registers player entities if they haven't been registered yet
|
||||||
|
pub fn process_player_income_system(current_turn: Res<CurrentTurn>, mut commands: Commands, mut game_instance: ResMut<GameInstance>, query: Query<(Entity, Option<&BotPlayer>)>) {
|
||||||
|
// Check if entities need to be registered (first run)
|
||||||
|
let needs_registration = {
|
||||||
|
let player_manager = &game_instance.player_manager;
|
||||||
|
player_manager.get_players().first().is_some_and(|p| p.entity.is_none())
|
||||||
|
};
|
||||||
|
|
||||||
|
if needs_registration {
|
||||||
|
// Register player entities on first run
|
||||||
|
let bot_player_ids: Vec<usize> = game_instance.bot_manager.bot_player_ids().to_vec();
|
||||||
|
|
||||||
|
for player in game_instance.player_manager.get_players_mut() {
|
||||||
|
let is_bot = bot_player_ids.contains(&player.id);
|
||||||
|
|
||||||
|
let entity = if is_bot { commands.spawn((BotPlayer, PlayerId(player.id as u16), BorderTiles::default(), Troops(player.get_troops()), TerritorySize(player.get_territory_size()))).id() } else { commands.spawn((PlayerId(player.id as u16), BorderTiles::default(), Troops(player.get_troops()), TerritorySize(player.get_territory_size()))).id() };
|
||||||
|
|
||||||
|
player.entity = Some(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
return; // Skip income processing on registration frame
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process income for all players
|
||||||
|
let player_manager = &mut game_instance.player_manager;
|
||||||
|
|
||||||
|
for player in player_manager.get_players_mut() {
|
||||||
|
if !player.is_alive() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine if this player is a bot by checking ECS entity
|
||||||
|
let is_bot = player.entity.and_then(|entity| query.get(entity).ok()).and_then(|(_, bot_marker)| bot_marker).is_some();
|
||||||
|
|
||||||
|
// Process income with bot modifier if applicable
|
||||||
|
player.income(is_bot);
|
||||||
|
}
|
||||||
|
|
||||||
|
trace!("Income processed for turn {}", current_turn.turn.turn_number);
|
||||||
|
}
|
||||||
266
crates/borders-core/src/game/input_handlers.rs
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
//! Platform-agnostic input handling systems
|
||||||
|
//!
|
||||||
|
//! These systems use InputState instead of Bevy's input queries,
|
||||||
|
//! making them work across both WASM and desktop platforms with
|
||||||
|
//! Pixi.js rendering.
|
||||||
|
|
||||||
|
use bevy_ecs::prelude::*;
|
||||||
|
use tracing::{debug, info, trace};
|
||||||
|
|
||||||
|
use crate::game::{GameAction, LocalPlayerContext, SpawnManager, TileOwnership};
|
||||||
|
use crate::networking::{GameView, Intent, IntentEvent};
|
||||||
|
use crate::ui::input::{InputState, KeyCode, MouseButton};
|
||||||
|
use crate::ui::protocol::CameraCommand;
|
||||||
|
|
||||||
|
/// 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: 0.5 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle placing the human spawn by clicking on valid land
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn handle_spawn_click_system(input_state: NonSend<std::sync::Arc<std::sync::Mutex<InputState>>>, spawn_phase: Res<SpawnPhase>, game_view: Option<ResMut<GameView>>, local_context: Option<Res<LocalPlayerContext>>, mut spawn_manager: Option<ResMut<SpawnManager>>, mut spawn_timeout: Option<ResMut<crate::game::SpawnTimeout>>, mut intent_writer: MessageWriter<IntentEvent>, game_instance: Option<Res<crate::game::GameInstance>>) {
|
||||||
|
if !spawn_phase.active {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Ok(input) = input_state.lock() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if !input.mouse_just_released(MouseButton::Left) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _span = tracing::trace_span!("spawn_click").entered();
|
||||||
|
|
||||||
|
// Frontend handles camera interaction filtering, but double-check here
|
||||||
|
if input.had_camera_interaction() {
|
||||||
|
trace!("Spawn click ignored - camera interaction detected");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(game_view) = game_view else {
|
||||||
|
debug!("Spawn click ignored - GameView not ready");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(local_context) = local_context else {
|
||||||
|
debug!("Spawn click ignored - LocalPlayerContext not ready");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Can't spawn if not allowed to send intents
|
||||||
|
if !local_context.can_send_intents {
|
||||||
|
debug!("Spawn click ignored - cannot send intents");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get tile from InputState (set by frontend)
|
||||||
|
let Some(tile_coord) = input.cursor_tile() else {
|
||||||
|
debug!("Spawn click ignored - cursor not over valid tile");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let tile = tile_coord.to_index(game_view.width);
|
||||||
|
|
||||||
|
let tile_ownership = TileOwnership::from_u16(game_view.get_owner(tile));
|
||||||
|
if tile_ownership.is_owned() || tile_ownership.is_water() {
|
||||||
|
debug!("Spawn click on tile {} ignored - occupied or water", tile);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Player has chosen a spawn location - send to server
|
||||||
|
info!("Player {} setting spawn at tile {}", local_context.my_player_id, tile);
|
||||||
|
|
||||||
|
// Check if this is the first spawn (timer not started yet)
|
||||||
|
let is_first_spawn = if let Some(ref 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 { player_id: local_context.my_player_id as u16, tile_index: tile as u32 }));
|
||||||
|
|
||||||
|
// Start spawn timeout on first spawn (spawn_phase plugin will emit countdown updates)
|
||||||
|
if is_first_spawn && let Some(ref mut 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(ref mut spawn_mgr) = spawn_manager
|
||||||
|
&& let Some(ref game_inst) = game_instance
|
||||||
|
{
|
||||||
|
// Update spawn manager (triggers bot spawn recalculation)
|
||||||
|
spawn_mgr.update_player_spawn(local_context.my_player_id, tile, &game_inst.bot_manager, &game_inst.territory_manager);
|
||||||
|
|
||||||
|
info!("Spawn manager updated with player {} spawn at tile {}", local_context.my_player_id, tile);
|
||||||
|
info!("Total spawns in manager: {}", spawn_mgr.get_all_spawns().len());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Center the camera on the client's spawn (hotkey C)
|
||||||
|
pub fn handle_center_camera_system(input_state: NonSend<std::sync::Arc<std::sync::Mutex<InputState>>>, game_view: Option<Res<GameView>>, local_context: Option<Res<LocalPlayerContext>>, mut camera_commands: MessageWriter<CameraCommand>) {
|
||||||
|
let Ok(input) = input_state.lock() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if !input.key_just_pressed(KeyCode::KeyC) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(game_view) = game_view else {
|
||||||
|
return; // GameView not ready yet
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(local_context) = local_context else {
|
||||||
|
return; // LocalPlayerContext not ready yet
|
||||||
|
};
|
||||||
|
|
||||||
|
// Find any owned tile to center on
|
||||||
|
if let Some(tile) = game_view.find_tile_owned_by(local_context.my_player_id as u16) {
|
||||||
|
camera_commands.write(CameraCommand::CenterOnTile { tile_index: tile as u32, animate: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// After spawn, clicking tiles triggers expansion/attack based on ownership
|
||||||
|
/// Automatically detects if a ship is needed for water attacks
|
||||||
|
pub fn handle_attack_click_system(input_state: NonSend<std::sync::Arc<std::sync::Mutex<InputState>>>, spawn_phase: Res<SpawnPhase>, game_view: Option<Res<GameView>>, game_instance: Option<Res<crate::game::GameInstance>>, local_context: Option<Res<LocalPlayerContext>>, attack_controls: Res<AttackControls>, mut intent_writer: MessageWriter<IntentEvent>) {
|
||||||
|
if spawn_phase.active {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Ok(input) = input_state.lock() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if !input.mouse_just_released(MouseButton::Left) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Frontend handles camera interaction filtering
|
||||||
|
if input.had_camera_interaction() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _span = tracing::trace_span!("attack_click").entered();
|
||||||
|
|
||||||
|
let Some(game_view) = game_view else {
|
||||||
|
return; // GameView not ready yet
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(game_instance) = game_instance else {
|
||||||
|
return; // GameInstance not ready yet
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(local_context) = local_context else {
|
||||||
|
return; // LocalPlayerContext not ready yet
|
||||||
|
};
|
||||||
|
|
||||||
|
// Can't attack if not allowed to send intents (defeated/spectating)
|
||||||
|
if !local_context.can_send_intents {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get tile from InputState (set by frontend)
|
||||||
|
let Some(tile_coord) = input.cursor_tile() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let tile = tile_coord.to_index(game_view.width);
|
||||||
|
let owner = game_view.get_owner(tile);
|
||||||
|
let player_id = local_context.my_player_id as u16;
|
||||||
|
|
||||||
|
// Can't attack own tiles
|
||||||
|
if owner == player_id {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if target is water - ignore water clicks
|
||||||
|
let width = game_view.width as usize;
|
||||||
|
if game_instance.terrain.is_navigable(((tile % width) as u32, (tile / width) as u32)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if target is connected to player's territory
|
||||||
|
let height = game_view.height as usize;
|
||||||
|
|
||||||
|
let is_connected = crate::game::connectivity::is_connected_to_player(game_view.territories.as_ref(), tile, player_id, width, height);
|
||||||
|
|
||||||
|
if is_connected {
|
||||||
|
// Target is connected to player's territory - use normal attack
|
||||||
|
intent_writer.write(IntentEvent(Intent::Action(GameAction::Attack { player_id, target_tile: tile as u32, troops_ratio: attack_controls.attack_ratio })));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Target is NOT connected - need to use ship
|
||||||
|
debug!("Target {} not connected to player territory, attempting ship launch", tile);
|
||||||
|
|
||||||
|
// Find target's nearest coastal tile
|
||||||
|
let target_coastal_tile = crate::game::connectivity::find_coastal_tile_in_region(game_view.territories.as_ref(), &game_instance.terrain, tile, width, height);
|
||||||
|
|
||||||
|
let Some(target_coastal_tile) = target_coastal_tile else {
|
||||||
|
debug!("No coastal tile found in target's region for tile {}", tile);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Find player's nearest coastal tile
|
||||||
|
let player_border_tiles = game_instance.border_manager.get_border_tiles(player_id as usize);
|
||||||
|
let launch_tile = crate::game::ships::ShipManager::find_nearest_player_coastal_tile(&game_instance.coastal_tiles, player_border_tiles, target_coastal_tile, width);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Try to find a water path from launch tile to target coastal tile
|
||||||
|
let path = crate::game::ships::ship_pathfinding::find_water_path(&game_instance.terrain, launch_tile, target_coastal_tile, crate::game::ships::SHIP_MAX_PATH_LENGTH);
|
||||||
|
|
||||||
|
if let Some(_path) = path {
|
||||||
|
// We can reach the target by ship!
|
||||||
|
// Convert attack_ratio (0.0-1.0) to troops_percent (0-100)
|
||||||
|
let troops_percent = (attack_controls.attack_ratio * 100.0) as u32;
|
||||||
|
|
||||||
|
debug!("Launching ship to target {} with {}% troops", tile, troops_percent);
|
||||||
|
|
||||||
|
intent_writer.write(IntentEvent(Intent::Action(GameAction::LaunchShip { player_id, target_tile: tile as u32, troops_percent })));
|
||||||
|
} else {
|
||||||
|
debug!("No water path found from {} to {}", launch_tile, target_coastal_tile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adjust attack ratio with keys 1/2
|
||||||
|
pub fn handle_attack_ratio_keys_system(input_state: NonSend<std::sync::Arc<std::sync::Mutex<InputState>>>, mut controls: ResMut<AttackControls>) {
|
||||||
|
let Ok(input) = input_state.lock() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut changed = false;
|
||||||
|
if input.key_just_pressed(KeyCode::Digit1) {
|
||||||
|
controls.attack_ratio = (controls.attack_ratio - 0.1).max(0.1);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if input.key_just_pressed(KeyCode::Digit2) {
|
||||||
|
controls.attack_ratio = (controls.attack_ratio + 0.1).min(1.0);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
|
debug!("Attack ratio changed to {:.1}", controls.attack_ratio);
|
||||||
|
}
|
||||||
|
}
|
||||||
171
crates/borders-core/src/game/lifecycle.rs
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
use bevy_ecs::prelude::*;
|
||||||
|
use rand::rngs::StdRng;
|
||||||
|
use rand::{Rng, SeedableRng};
|
||||||
|
use tracing::{debug, info};
|
||||||
|
|
||||||
|
use crate::constants::{BOT_COUNT, SPAWN_TIMEOUT_SECS};
|
||||||
|
use crate::game::{ActiveAttacks, BorderManager, BotManager, GameInstance, HSLColor, LocalPlayerContext, Player, PlayerManager, SpawnManager, SpawnPhase, SpawnTimeout, TerritoryManager};
|
||||||
|
use crate::networking::{GameView, LocalTurnServerHandle, PlayerView, TurnReceiver};
|
||||||
|
use flume::Receiver;
|
||||||
|
|
||||||
|
/// Parameters needed to initialize a new game
|
||||||
|
pub struct GameInitParams {
|
||||||
|
pub map_width: u32,
|
||||||
|
pub map_height: u32,
|
||||||
|
pub conquerable_tiles: Vec<bool>,
|
||||||
|
pub client_player_id: usize,
|
||||||
|
pub intent_rx: Receiver<crate::networking::Intent>,
|
||||||
|
pub terrain_data: std::sync::Arc<crate::game::terrain::TerrainData>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize all game resources when starting a new game
|
||||||
|
/// This should be called by the StartGame command handler
|
||||||
|
pub fn initialize_game_resources(commands: &mut Commands, params: GameInitParams) {
|
||||||
|
info!("Initializing game resources (map: {}x{}, player: {})", params.map_width, params.map_height, params.client_player_id);
|
||||||
|
|
||||||
|
// Initialize territory manager
|
||||||
|
let mut territory_manager = TerritoryManager::new(params.map_width, params.map_height);
|
||||||
|
territory_manager.reset(params.map_width, params.map_height, ¶ms.conquerable_tiles);
|
||||||
|
debug!("Territory manager initialized with {} tiles", params.conquerable_tiles.len());
|
||||||
|
|
||||||
|
// Initialize border manager
|
||||||
|
let mut border_manager = BorderManager::new();
|
||||||
|
border_manager.reset(params.map_width, params.map_height, 1 + BOT_COUNT);
|
||||||
|
|
||||||
|
// Initialize active attacks
|
||||||
|
let mut active_attacks = ActiveAttacks::new();
|
||||||
|
active_attacks.init(1 + BOT_COUNT, params.map_width);
|
||||||
|
|
||||||
|
// Initialize bot manager (1 human player + BOT_COUNT bots)
|
||||||
|
let bot_manager = BotManager::new(BOT_COUNT, 1);
|
||||||
|
debug!("BotManager initialized with {} bots", BOT_COUNT);
|
||||||
|
|
||||||
|
// Use a fixed seed for deterministic bot behavior and color generation
|
||||||
|
// In multiplayer, this should come from the server
|
||||||
|
let rng_seed = 0xDEADBEEF;
|
||||||
|
|
||||||
|
// Create RNG for deterministic color generation
|
||||||
|
let mut rng = StdRng::seed_from_u64(rng_seed);
|
||||||
|
|
||||||
|
// Create players: 1 human + BOT_COUNT bots
|
||||||
|
// Player IDs start at 0 (human), then 1, 2, 3... for bots
|
||||||
|
let mut players = Vec::new();
|
||||||
|
|
||||||
|
// Generate random hue offset for color spread
|
||||||
|
let hue_offset = rng.random_range(0.0..360.0);
|
||||||
|
|
||||||
|
// All players (including human) get deterministically generated colors
|
||||||
|
for i in 0..=BOT_COUNT {
|
||||||
|
let is_human = i == 0;
|
||||||
|
let id = i as f32;
|
||||||
|
|
||||||
|
// Use golden angle distribution with random offset for visually distinct colors
|
||||||
|
let hue = (id * 137.5 + hue_offset) % 360.0;
|
||||||
|
let saturation = rng.random_range(0.75..=0.95);
|
||||||
|
let lightness = rng.random_range(0.35..=0.65);
|
||||||
|
let color = HSLColor::new(hue, saturation, lightness);
|
||||||
|
|
||||||
|
if is_human {
|
||||||
|
players.push(Player::new(i, "Player".to_string(), color));
|
||||||
|
} else {
|
||||||
|
players.push(Player::new(i, format!("Bot {}", i), color));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize player manager
|
||||||
|
// Human player is always ID 0
|
||||||
|
let mut player_manager = PlayerManager::new();
|
||||||
|
let human_player_id = 0;
|
||||||
|
player_manager.init(players, human_player_id);
|
||||||
|
debug!("Player manager initialized with {} players (human: {}, bots: {})", 1 + BOT_COUNT, human_player_id, BOT_COUNT);
|
||||||
|
|
||||||
|
// Create ship manager
|
||||||
|
let ship_manager = crate::game::ships::ShipManager::new();
|
||||||
|
|
||||||
|
// Create game instance (bots won't be spawned until player chooses spawn)
|
||||||
|
let game_instance = GameInstance::new(player_manager, territory_manager, active_attacks, border_manager, bot_manager, ship_manager, params.terrain_data.clone(), rng_seed);
|
||||||
|
|
||||||
|
// Calculate initial bot spawn positions (first pass)
|
||||||
|
// These will be shown to the player, but not applied to game state yet
|
||||||
|
let initial_bot_spawns = game_instance.bot_manager.calculate_initial_spawns(&game_instance.territory_manager, rng_seed);
|
||||||
|
|
||||||
|
debug!("Calculated {} initial bot spawn positions (requested: {})", initial_bot_spawns.len(), BOT_COUNT);
|
||||||
|
|
||||||
|
if initial_bot_spawns.len() < BOT_COUNT {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create SpawnManager to track spawn positions during spawn phase
|
||||||
|
let spawn_manager = SpawnManager::new(initial_bot_spawns.clone(), rng_seed, params.map_width, params.map_height);
|
||||||
|
commands.insert_resource(spawn_manager);
|
||||||
|
|
||||||
|
// Initialize GameView with initial game state
|
||||||
|
// Calculate total land tiles once for caching (performance optimization)
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
let total_land_tiles = game_instance.territory_manager.as_slice().iter().filter(|ownership| !ownership.is_water()).count() as u32;
|
||||||
|
|
||||||
|
let game_view = GameView {
|
||||||
|
width: params.map_width,
|
||||||
|
height: params.map_height,
|
||||||
|
territories: Arc::from(game_instance.territory_manager.to_u16_vec().as_slice()),
|
||||||
|
turn_number: 0,
|
||||||
|
total_land_tiles,
|
||||||
|
changed_tiles: Vec::new(), // Empty on initialization
|
||||||
|
players: game_instance.player_manager.get_players().iter().map(|p| PlayerView { id: p.id as u16, color: p.color.to_rgba(), name: p.name.clone(), tile_count: p.get_territory_size() as u32, troops: p.get_troops() as u32, is_alive: p.is_alive() }).collect(),
|
||||||
|
ships: Vec::new(), // No ships at initialization
|
||||||
|
};
|
||||||
|
|
||||||
|
commands.insert_resource(game_instance);
|
||||||
|
commands.insert_resource(game_view);
|
||||||
|
debug!("GameInstance and GameView resources created");
|
||||||
|
|
||||||
|
// Initialize local player context
|
||||||
|
commands.insert_resource(LocalPlayerContext::new(0)); // Human player is ID 0
|
||||||
|
debug!("LocalPlayerContext created for player 0 (human)");
|
||||||
|
|
||||||
|
// Initialize spawn timeout
|
||||||
|
commands.insert_resource(SpawnTimeout::new(SPAWN_TIMEOUT_SECS));
|
||||||
|
debug!("SpawnTimeout initialized ({} seconds)", SPAWN_TIMEOUT_SECS);
|
||||||
|
|
||||||
|
// Initialize turn generation resources
|
||||||
|
let (turn_tx, turn_rx) = flume::unbounded();
|
||||||
|
let server_handle = LocalTurnServerHandle { paused: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)), running: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)) };
|
||||||
|
commands.insert_resource(server_handle);
|
||||||
|
commands.insert_resource(TurnReceiver { turn_rx });
|
||||||
|
commands.insert_resource(crate::networking::TurnGenerator { turn_number: 0, accumulated_time: 0.0, turn_tx, spawn_config: std::collections::HashMap::new(), spawn_timeout_accumulated: None, game_started: false });
|
||||||
|
|
||||||
|
debug!("Turn generator initialized (paused until player spawn)");
|
||||||
|
|
||||||
|
// Activate spawn phase (SpawnPhasePlugin will emit initial SpawnPhaseUpdate)
|
||||||
|
commands.insert_resource(SpawnPhase { active: true });
|
||||||
|
debug!("Spawn phase activated");
|
||||||
|
|
||||||
|
info!("Game resources initialized successfully - ready to start");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clean up all game resources when quitting a game
|
||||||
|
/// This should be called by the QuitGame command handler
|
||||||
|
pub fn cleanup_game_resources(world: &mut World) {
|
||||||
|
info!("Cleaning up game resources...");
|
||||||
|
|
||||||
|
// Stop local turn server if running
|
||||||
|
if let Some(server_handle) = world.get_resource::<LocalTurnServerHandle>() {
|
||||||
|
server_handle.stop();
|
||||||
|
world.remove_resource::<LocalTurnServerHandle>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove all game-specific resources
|
||||||
|
world.remove_resource::<GameInstance>();
|
||||||
|
world.remove_resource::<LocalPlayerContext>();
|
||||||
|
world.remove_resource::<TurnReceiver>();
|
||||||
|
world.remove_resource::<SpawnManager>();
|
||||||
|
world.remove_resource::<SpawnTimeout>();
|
||||||
|
world.remove_resource::<crate::networking::GameView>();
|
||||||
|
world.remove_resource::<crate::TerrainData>();
|
||||||
|
world.remove_resource::<crate::networking::TurnGenerator>();
|
||||||
|
|
||||||
|
// Note: SpawnPhase is a permanent resource (init_resource), not removed on quit
|
||||||
|
|
||||||
|
info!("Game resources cleaned up successfully");
|
||||||
|
}
|
||||||
71
crates/borders-core/src/game/local_context.rs
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
use bevy_ecs::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
///
|
||||||
|
/// This state is NOT synchronized across clients and is NOT part of
|
||||||
|
/// GameInstance/GameView which must be identical on all clients for determinism.
|
||||||
|
///
|
||||||
|
/// 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 (GameInstance) continues running regardless
|
||||||
|
#[derive(Resource)]
|
||||||
|
pub struct LocalPlayerContext {
|
||||||
|
/// The player ID for this client
|
||||||
|
pub my_player_id: usize,
|
||||||
|
|
||||||
|
/// 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(player_id: usize) -> Self {
|
||||||
|
Self { my_player_id: player_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
|
||||||
|
pub fn is_playing(&self) -> bool {
|
||||||
|
self.my_outcome.is_none() && self.can_send_intents
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if the local player is spectating (defeated but watching)
|
||||||
|
pub fn is_spectating(&self) -> bool {
|
||||||
|
!self.can_send_intents
|
||||||
|
}
|
||||||
|
}
|
||||||
51
crates/borders-core/src/game/mod.rs
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
pub mod action;
|
||||||
|
pub mod borders;
|
||||||
|
pub mod bot;
|
||||||
|
pub mod combat;
|
||||||
|
pub mod connectivity;
|
||||||
|
pub mod constants;
|
||||||
|
pub mod game_instance;
|
||||||
|
pub mod income;
|
||||||
|
pub mod input_handlers;
|
||||||
|
pub mod lifecycle;
|
||||||
|
pub mod local_context;
|
||||||
|
pub mod outcome;
|
||||||
|
pub mod player;
|
||||||
|
pub mod player_manager;
|
||||||
|
pub mod rng;
|
||||||
|
pub mod ships;
|
||||||
|
pub mod spawn_manager;
|
||||||
|
pub mod spawn_timeout;
|
||||||
|
pub mod terrain;
|
||||||
|
pub mod territory;
|
||||||
|
pub mod territory_manager;
|
||||||
|
pub mod tile_ownership;
|
||||||
|
pub mod tilemap;
|
||||||
|
pub mod tilemap_changes;
|
||||||
|
pub mod turn;
|
||||||
|
pub mod utils;
|
||||||
|
|
||||||
|
pub use action::*;
|
||||||
|
pub use borders::{BorderManager, BorderTransitionResult, update_player_borders_system};
|
||||||
|
pub use bot::*;
|
||||||
|
pub use combat::{ActiveAttacks, AttackConfig, AttackExecutor};
|
||||||
|
pub use connectivity::*;
|
||||||
|
pub use game_instance::*;
|
||||||
|
pub use income::process_player_income_system;
|
||||||
|
pub use input_handlers::*;
|
||||||
|
pub use lifecycle::*;
|
||||||
|
pub use local_context::*;
|
||||||
|
pub use outcome::*;
|
||||||
|
pub use player::{BorderTiles, BotPlayer, HSLColor, Player, PlayerId, TerritorySize, Troops};
|
||||||
|
pub use player_manager::*;
|
||||||
|
pub use rng::DeterministicRng;
|
||||||
|
pub use ships::*;
|
||||||
|
pub use spawn_manager::*;
|
||||||
|
pub use spawn_timeout::*;
|
||||||
|
pub use terrain::*;
|
||||||
|
pub use territory::*;
|
||||||
|
pub use territory_manager::*;
|
||||||
|
pub use tile_ownership::*;
|
||||||
|
pub use tilemap::*;
|
||||||
|
pub use tilemap_changes::*;
|
||||||
|
pub use turn::{CurrentTurn, turn_is_ready};
|
||||||
82
crates/borders-core/src/game/outcome.rs
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
use crate::game::local_context::LocalPlayerContext;
|
||||||
|
use crate::game::territory_manager::OWNER_WATER;
|
||||||
|
use crate::networking::GameView;
|
||||||
|
use crate::ui::protocol::{BackendMessage, GameOutcome};
|
||||||
|
use bevy_ecs::prelude::*;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
/// System that checks if the local player has won or lost
|
||||||
|
/// This is a NON-BLOCKING check - the game continues running regardless
|
||||||
|
pub fn check_local_player_outcome(mut local_context: ResMut<LocalPlayerContext>, game_view: Res<GameView>, mut backend_messages: MessageWriter<BackendMessage>) {
|
||||||
|
// Don't check if outcome already determined
|
||||||
|
if local_context.my_outcome.is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't check outcome until player has spawned
|
||||||
|
// Skip only if player has 0 tiles AND is_alive (hasn't spawned yet)
|
||||||
|
// If player has 0 tiles AND !is_alive, that's a real defeat
|
||||||
|
let my_player_id = local_context.my_player_id as u16;
|
||||||
|
let Some(my_player) = game_view.get_player(my_player_id) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if my_player.tile_count == 0 && my_player.is_alive {
|
||||||
|
// Player hasn't spawned yet - skip outcome check
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check defeat condition: I've been eliminated (0 tiles)
|
||||||
|
if !my_player.is_alive {
|
||||||
|
info!("Local player defeated - eliminated (0 tiles)");
|
||||||
|
local_context.mark_defeated();
|
||||||
|
backend_messages.write(BackendMessage::GameEnded { outcome: GameOutcome::Defeat });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate total claimable tiles for victory condition checks
|
||||||
|
let total_claimable_tiles = game_view.territories.iter().filter(|&&owner| owner != OWNER_WATER).count();
|
||||||
|
|
||||||
|
const WIN_THRESHOLD: f32 = 0.80;
|
||||||
|
|
||||||
|
if total_claimable_tiles > 0 {
|
||||||
|
let my_tiles = my_player.tile_count as usize;
|
||||||
|
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 player in &game_view.players {
|
||||||
|
if player.id != my_player_id && player.is_alive {
|
||||||
|
let opponent_tiles = player.tile_count as usize;
|
||||||
|
let opponent_occupation = opponent_tiles as f32 / total_claimable_tiles as f32;
|
||||||
|
|
||||||
|
if opponent_occupation >= WIN_THRESHOLD {
|
||||||
|
info!("Local player defeated - {} reached {:.1}% occupation ({}/{} claimable tiles, threshold: {:.0}%)", player.name, opponent_occupation * 100.0, opponent_tiles, 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
|
||||||
|
let all_opponents_dead = game_view
|
||||||
|
.players
|
||||||
|
.iter()
|
||||||
|
.filter(|p| p.id != my_player_id) // Exclude me
|
||||||
|
.all(|p| !p.is_alive);
|
||||||
|
|
||||||
|
if all_opponents_dead && my_player.is_alive {
|
||||||
|
info!("Local player victorious - all opponents eliminated");
|
||||||
|
local_context.mark_victorious();
|
||||||
|
backend_messages.write(BackendMessage::GameEnded { outcome: GameOutcome::Victory });
|
||||||
|
}
|
||||||
|
}
|
||||||
199
crates/borders-core/src/game/player.rs
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
use bevy_ecs::prelude::*;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
/// Marker component to identify bot players
|
||||||
|
/// Human players are identified by the ABSENCE of this component
|
||||||
|
/// Use Option<&BotPlayer> in queries to handle both types
|
||||||
|
#[derive(Component, Debug, Clone, Copy, Default)]
|
||||||
|
pub struct BotPlayer;
|
||||||
|
|
||||||
|
/// Player ID component for ECS queries
|
||||||
|
#[derive(Component, Debug, Clone, Copy)]
|
||||||
|
pub struct PlayerId(pub u16);
|
||||||
|
|
||||||
|
/// Border tiles component - tiles at the edge of a player's territory
|
||||||
|
#[derive(Component, Debug, Clone, Default)]
|
||||||
|
pub struct BorderTiles(pub HashSet<usize>);
|
||||||
|
|
||||||
|
/// 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 usize);
|
||||||
|
|
||||||
|
/// 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]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_rgb(r: u8, g: u8, b: u8) -> Self {
|
||||||
|
let r = r as f32 / 255.0;
|
||||||
|
let g = g as f32 / 255.0;
|
||||||
|
let b = b as f32 / 255.0;
|
||||||
|
|
||||||
|
let max = r.max(g).max(b);
|
||||||
|
let min = r.min(g).min(b);
|
||||||
|
let delta = max - min;
|
||||||
|
|
||||||
|
let l = (max + min) / 2.0;
|
||||||
|
|
||||||
|
if delta == 0.0 {
|
||||||
|
return Self { h: 0.0, s: 0.0, l };
|
||||||
|
}
|
||||||
|
|
||||||
|
let s = if l < 0.5 { delta / (max + min) } else { delta / (2.0 - max - min) };
|
||||||
|
|
||||||
|
let h = if max == r {
|
||||||
|
60.0 * (((g - b) / delta) % 6.0)
|
||||||
|
} else if max == g {
|
||||||
|
60.0 * (((b - r) / delta) + 2.0)
|
||||||
|
} else {
|
||||||
|
60.0 * (((r - g) / delta) + 4.0)
|
||||||
|
};
|
||||||
|
|
||||||
|
let h = if h < 0.0 { h + 360.0 } else { h };
|
||||||
|
|
||||||
|
Self { h, s, l }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nation in the game (controlled by either human player or bot AI)
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Nation {
|
||||||
|
pub id: usize,
|
||||||
|
pub name: String,
|
||||||
|
pub color: HSLColor,
|
||||||
|
troops: f32,
|
||||||
|
territory_size: usize,
|
||||||
|
alive: bool,
|
||||||
|
pub entity: Option<Entity>, // Link to ECS entity
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Nation {
|
||||||
|
pub fn new(id: usize, name: String, color: HSLColor) -> Self {
|
||||||
|
Self { id, name, color, troops: 2500.0, territory_size: 0, alive: true, entity: None }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a tile to the nation's territory
|
||||||
|
/// WARNING: Call this AFTER updating the territory manager
|
||||||
|
pub fn add_tile(&mut self, _tile: usize) {
|
||||||
|
self.territory_size += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a tile from the nation's territory
|
||||||
|
/// WARNING: Call this AFTER updating the territory manager
|
||||||
|
pub fn remove_tile(&mut self, _tile: usize) {
|
||||||
|
self.territory_size = self.territory_size.saturating_sub(1);
|
||||||
|
if self.territory_size == 0 {
|
||||||
|
self.alive = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate maximum troop capacity based on territory size
|
||||||
|
/// 2 * (tiles^0.6 * 1000 + 50000)
|
||||||
|
pub fn calculate_max_troops(&self, is_bot: bool) -> f32 {
|
||||||
|
let base_max = 2.0 * ((self.territory_size as f32).powf(0.6) * 1000.0 + 50_000.0);
|
||||||
|
|
||||||
|
if is_bot {
|
||||||
|
base_max * 0.33 // Bots get 33% max troops
|
||||||
|
} else {
|
||||||
|
base_max
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate income for this tick
|
||||||
|
pub fn calculate_income(&self, is_bot: bool) -> f32 {
|
||||||
|
let max_troops = self.calculate_max_troops(is_bot);
|
||||||
|
|
||||||
|
// Base income calculation
|
||||||
|
let mut income = 10.0 + (self.troops.powf(0.73) / 4.0);
|
||||||
|
|
||||||
|
// Soft cap as approaching max troops
|
||||||
|
let ratio = 1.0 - (self.troops / max_troops);
|
||||||
|
income *= ratio;
|
||||||
|
|
||||||
|
// Apply bot modifier
|
||||||
|
if is_bot {
|
||||||
|
income * 0.6 // Bots get 60% income
|
||||||
|
} else {
|
||||||
|
income
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process one tick worth of income
|
||||||
|
pub fn income(&mut self, is_bot: bool) {
|
||||||
|
let income = self.calculate_income(is_bot);
|
||||||
|
self.add_troops_internal(income, is_bot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Internal method to add troops with max cap
|
||||||
|
fn add_troops_internal(&mut self, amount: f32, is_bot: bool) {
|
||||||
|
let max_troops = self.calculate_max_troops(is_bot);
|
||||||
|
self.troops = (self.troops + amount).min(max_troops);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the amount of troops the nation has
|
||||||
|
pub fn get_troops(&self) -> f32 {
|
||||||
|
self.troops
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add troops to the nation
|
||||||
|
/// Troops will be capped based on territory size using the max troops formula
|
||||||
|
/// For internal use; external callers should use the income system
|
||||||
|
pub fn add_troops(&mut self, amount: f32) {
|
||||||
|
// For external calls (attacks, donations), always use human formula
|
||||||
|
// This ensures consistent behavior for troop transfers
|
||||||
|
self.add_troops_internal(amount, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove troops from the nation
|
||||||
|
pub fn remove_troops(&mut self, amount: f32) {
|
||||||
|
self.troops = (self.troops - amount).max(0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the size of the nation's territory (in tiles)
|
||||||
|
pub fn get_territory_size(&self) -> usize {
|
||||||
|
self.territory_size
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if the nation is alive
|
||||||
|
pub fn is_alive(&self) -> bool {
|
||||||
|
self.alive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Type alias for backward compatibility during ECS migration
|
||||||
|
pub type Player = Nation;
|
||||||
72
crates/borders-core/src/game/player_manager.rs
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
use crate::game::player::Player;
|
||||||
|
|
||||||
|
/// Manages all players in the game
|
||||||
|
pub struct PlayerManager {
|
||||||
|
players: Vec<Player>,
|
||||||
|
pub client_player_id: usize,
|
||||||
|
human_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PlayerManager {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PlayerManager {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self { players: Vec::new(), client_player_id: 0, human_count: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize the player manager
|
||||||
|
pub fn init(&mut self, players: Vec<Player>, client_player_id: usize) {
|
||||||
|
self.players = players;
|
||||||
|
self.client_player_id = client_player_id;
|
||||||
|
self.human_count = 1; // For now, only one human player
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register a new player
|
||||||
|
pub fn register_player(&mut self, player: Player) {
|
||||||
|
self.players.push(player);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a player by ID
|
||||||
|
pub fn get_player(&self, id: usize) -> Option<&Player> {
|
||||||
|
self.players.get(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a mutable player by ID
|
||||||
|
pub fn get_player_mut(&mut self, id: usize) -> Option<&mut Player> {
|
||||||
|
self.players.get_mut(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all players
|
||||||
|
pub fn get_players(&self) -> &[Player] {
|
||||||
|
&self.players
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all players mutably
|
||||||
|
pub fn get_players_mut(&mut self) -> &mut [Player] {
|
||||||
|
&mut self.players
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a player is a bot
|
||||||
|
pub fn is_bot(&self, player_id: usize) -> bool {
|
||||||
|
player_id >= self.human_count
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate a player ID
|
||||||
|
pub fn validate_player(&self, player_id: usize) -> bool {
|
||||||
|
self.players.get(player_id).is_some_and(|p| p.is_alive())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the number of players
|
||||||
|
pub fn player_count(&self) -> usize {
|
||||||
|
self.players.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the client player
|
||||||
|
pub fn get_client_player(&self) -> Option<&Player> {
|
||||||
|
self.get_player(self.client_player_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
121
crates/borders-core/src/game/rng.rs
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
use bevy_ecs::prelude::*;
|
||||||
|
use rand::SeedableRng;
|
||||||
|
use rand::rngs::StdRng;
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
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.
|
||||||
|
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 player's actions this turn
|
||||||
|
///
|
||||||
|
/// This is a convenience wrapper around `for_context` for player-specific randomness.
|
||||||
|
pub fn for_player(&self, player_id: usize) -> StdRng {
|
||||||
|
self.for_context(player_id 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_index: usize) -> StdRng {
|
||||||
|
// Use large offset to avoid collision with player IDs
|
||||||
|
self.for_context(1_000_000 + tile_index as u64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use rand::Rng;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_deterministic_same_turn_same_seed() {
|
||||||
|
let rng1 = DeterministicRng::new(12345);
|
||||||
|
let rng2 = DeterministicRng::new(12345);
|
||||||
|
|
||||||
|
let mut player_rng1 = rng1.for_player(0);
|
||||||
|
let mut player_rng2 = rng2.for_player(0);
|
||||||
|
|
||||||
|
assert_eq!(player_rng1.random::<u64>(), player_rng2.random::<u64>());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_deterministic_different_context() {
|
||||||
|
let rng = DeterministicRng::new(12345);
|
||||||
|
|
||||||
|
let mut player0_rng = rng.for_player(0);
|
||||||
|
let mut player1_rng = rng.for_player(1);
|
||||||
|
|
||||||
|
// Different contexts should produce different values
|
||||||
|
assert_ne!(player0_rng.random::<u64>(), player1_rng.random::<u64>());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_turn_update() {
|
||||||
|
let mut rng = DeterministicRng::new(12345);
|
||||||
|
|
||||||
|
let mut turn0_rng = rng.for_player(0);
|
||||||
|
let value_turn0 = turn0_rng.random::<u64>();
|
||||||
|
|
||||||
|
rng.update_turn(1);
|
||||||
|
let mut turn1_rng = rng.for_player(0);
|
||||||
|
let value_turn1 = turn1_rng.random::<u64>();
|
||||||
|
|
||||||
|
// Same player, different turns should produce different values
|
||||||
|
assert_ne!(value_turn0, value_turn1);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
crates/borders-core/src/game/ships/mod.rs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
pub mod ship_manager;
|
||||||
|
pub mod ship_pathfinding;
|
||||||
|
pub mod transport_ship;
|
||||||
|
|
||||||
|
pub use ship_manager::ShipManager;
|
||||||
|
pub use transport_ship::TransportShip;
|
||||||
|
|
||||||
|
// Ship-related constants
|
||||||
|
pub const MAX_SHIPS_PER_PLAYER: usize = 5;
|
||||||
|
pub const SHIP_TICKS_PER_TILE: u32 = 1; // How many ticks to move one tile (1 = fast speed)
|
||||||
|
pub const SHIP_MAX_PATH_LENGTH: usize = 1_000_000;
|
||||||
|
pub const SHIP_TROOP_PERCENT: f32 = 0.20;
|
||||||
158
crates/borders-core/src/game/ships/ship_manager.rs
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
use crate::game::ships::transport_ship::TransportShip;
|
||||||
|
use crate::game::ships::{MAX_SHIPS_PER_PLAYER, SHIP_MAX_PATH_LENGTH, SHIP_TICKS_PER_TILE};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use tracing::debug;
|
||||||
|
|
||||||
|
/// Manages all active ships in the game
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ShipManager {
|
||||||
|
/// All active ships, indexed by ship ID
|
||||||
|
ships: HashMap<u32, TransportShip>,
|
||||||
|
|
||||||
|
/// Track number of ships per player
|
||||||
|
ships_per_player: HashMap<u16, usize>,
|
||||||
|
|
||||||
|
/// Next available ship ID
|
||||||
|
next_ship_id: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ShipManager {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self { ships: HashMap::new(), ships_per_player: HashMap::new(), next_ship_id: 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Launch a new transport ship if possible
|
||||||
|
/// Returns Some(ship_id) if successful, None if launch failed
|
||||||
|
pub fn launch_ship(&mut self, owner_id: u16, troops: u32, path: Vec<usize>, launch_tick: u64) -> Option<u32> {
|
||||||
|
// Check if player has reached ship limit
|
||||||
|
let current_ships = *self.ships_per_player.get(&owner_id).unwrap_or(&0);
|
||||||
|
if current_ships >= MAX_SHIPS_PER_PLAYER {
|
||||||
|
debug!("Player {} cannot launch ship: already has {}/{} ships", owner_id, current_ships, MAX_SHIPS_PER_PLAYER);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check path length
|
||||||
|
if path.is_empty() {
|
||||||
|
debug!("Cannot launch ship with empty path");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if path.len() > SHIP_MAX_PATH_LENGTH {
|
||||||
|
debug!("Cannot launch ship: path too long ({} > {})", path.len(), SHIP_MAX_PATH_LENGTH);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the ship
|
||||||
|
let ship_id = self.next_ship_id;
|
||||||
|
self.next_ship_id += 1;
|
||||||
|
|
||||||
|
let ship = TransportShip::new(ship_id, owner_id, troops, path, SHIP_TICKS_PER_TILE, launch_tick);
|
||||||
|
|
||||||
|
self.ships.insert(ship_id, ship);
|
||||||
|
*self.ships_per_player.entry(owner_id).or_insert(0) += 1;
|
||||||
|
|
||||||
|
debug!("Launched ship {} for player {} with {} troops", ship_id, owner_id, troops);
|
||||||
|
|
||||||
|
Some(ship_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update all ships and return list of ships that arrived at destination
|
||||||
|
/// Returns: Vec<(owner_id, target_tile, troops)>
|
||||||
|
pub fn update_ships(&mut self) -> Vec<(u16, usize, u32)> {
|
||||||
|
let mut arrivals = Vec::new();
|
||||||
|
let mut ships_to_remove = Vec::new();
|
||||||
|
|
||||||
|
for (ship_id, ship) in self.ships.iter_mut() {
|
||||||
|
if ship.update() {
|
||||||
|
// Ship has arrived at destination
|
||||||
|
arrivals.push((ship.owner_id, ship.target_tile, ship.troops));
|
||||||
|
ships_to_remove.push(*ship_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove arrived ships
|
||||||
|
for ship_id in ships_to_remove {
|
||||||
|
if let Some(ship) = self.ships.remove(&ship_id) {
|
||||||
|
if let Some(count) = self.ships_per_player.get_mut(&ship.owner_id) {
|
||||||
|
*count = count.saturating_sub(1);
|
||||||
|
if *count == 0 {
|
||||||
|
self.ships_per_player.remove(&ship.owner_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
debug!("Ship {} arrived at destination with {} troops", ship_id, ship.troops);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
arrivals
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all active ships
|
||||||
|
pub fn get_ships(&self) -> impl Iterator<Item = &TransportShip> {
|
||||||
|
self.ships.values()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get ships for a specific player
|
||||||
|
pub fn get_player_ships(&self, player_id: u16) -> impl Iterator<Item = &TransportShip> {
|
||||||
|
self.ships.values().filter(move |ship| ship.owner_id == player_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get number of ships for a player
|
||||||
|
pub fn get_ship_count(&self, player_id: u16) -> usize {
|
||||||
|
*self.ships_per_player.get(&player_id).unwrap_or(&0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove all ships for a player (e.g., when player is eliminated)
|
||||||
|
pub fn remove_player_ships(&mut self, player_id: u16) {
|
||||||
|
let ships_to_remove: Vec<u32> = self.ships.iter().filter(|(_, ship)| ship.owner_id == player_id).map(|(id, _)| *id).collect();
|
||||||
|
|
||||||
|
for ship_id in ships_to_remove {
|
||||||
|
self.ships.remove(&ship_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.ships_per_player.remove(&player_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all ships
|
||||||
|
pub fn clear(&mut self) {
|
||||||
|
self.ships.clear();
|
||||||
|
self.ships_per_player.clear();
|
||||||
|
self.next_ship_id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the nearest coastal tile owned by a player to a target tile
|
||||||
|
/// Uses precomputed coastal tiles and player's border tiles for efficiency
|
||||||
|
/// Returns None if no valid coastal tile found
|
||||||
|
pub fn find_nearest_player_coastal_tile(coastal_tiles: &std::collections::HashSet<usize>, player_border_tiles: &std::collections::HashSet<usize>, target_tile: usize, width: usize) -> Option<usize> {
|
||||||
|
let target_x = target_tile % width;
|
||||||
|
let target_y = target_tile / width;
|
||||||
|
|
||||||
|
let mut best_tile = None;
|
||||||
|
let mut best_distance = usize::MAX;
|
||||||
|
|
||||||
|
// Filter player's border tiles for coastal ones
|
||||||
|
for &tile in player_border_tiles {
|
||||||
|
// Check if this tile is in the coastal set
|
||||||
|
if coastal_tiles.contains(&tile) {
|
||||||
|
let tile_x = tile % width;
|
||||||
|
let tile_y = tile / width;
|
||||||
|
|
||||||
|
// Calculate Manhattan distance to target
|
||||||
|
let dist = ((tile_x as i32 - target_x as i32).abs() + (tile_y as i32 - target_y as i32).abs()) as usize;
|
||||||
|
|
||||||
|
if dist < best_distance {
|
||||||
|
best_distance = dist;
|
||||||
|
best_tile = Some(tile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
best_tile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ShipManager {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
310
crates/borders-core/src/game/ships/ship_pathfinding.rs
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
use crate::game::terrain::TerrainData;
|
||||||
|
use std::cmp::Ordering;
|
||||||
|
use std::collections::{BinaryHeap, HashMap, HashSet};
|
||||||
|
use tracing::debug;
|
||||||
|
|
||||||
|
/// A node in the pathfinding search
|
||||||
|
#[derive(Clone, Eq, PartialEq)]
|
||||||
|
struct PathNode {
|
||||||
|
tile_idx: usize,
|
||||||
|
g_cost: u32, // Cost from start
|
||||||
|
h_cost: u32, // Heuristic cost to goal
|
||||||
|
f_cost: u32, // Total cost (g + h)
|
||||||
|
parent: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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: usize, target_tile: usize, max_path_length: usize) -> Option<Vec<usize>> {
|
||||||
|
let width = terrain.size().x as usize;
|
||||||
|
let height = terrain.size().y as usize;
|
||||||
|
|
||||||
|
// Check if target is reachable (must be coastal or water)
|
||||||
|
if !is_valid_ship_destination(terrain, target_tile, width, height) {
|
||||||
|
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, width, height)?;
|
||||||
|
debug!("Pathfinding: found water launch tile {}", water_start);
|
||||||
|
|
||||||
|
// Find water tiles adjacent to target if target is land
|
||||||
|
let target_x = target_tile % width;
|
||||||
|
let target_y = target_tile / width;
|
||||||
|
let water_targets = if terrain.is_navigable((target_x as u32, target_y as u32)) { vec![target_tile] } else { find_adjacent_water_tiles(terrain, target_tile, width, height) };
|
||||||
|
|
||||||
|
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<usize, usize> = HashMap::new();
|
||||||
|
let mut g_scores: HashMap<usize, u32> = HashMap::new();
|
||||||
|
|
||||||
|
// Initialize with start node
|
||||||
|
let start_h = heuristic_distance(water_start, water_targets[0], width);
|
||||||
|
open_set.push(PathNode { tile_idx: water_start, g_cost: 0, h_cost: start_h, f_cost: start_h, parent: None });
|
||||||
|
g_scores.insert(water_start, 0);
|
||||||
|
|
||||||
|
while let Some(current_node) = open_set.pop() {
|
||||||
|
let current = current_node.tile_idx;
|
||||||
|
|
||||||
|
// Check if we've reached any of the target tiles
|
||||||
|
if water_targets.contains(¤t) {
|
||||||
|
// Reconstruct path
|
||||||
|
let mut path = vec![current];
|
||||||
|
let mut current_tile = current;
|
||||||
|
|
||||||
|
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_x as u32, target_y as u32)) {
|
||||||
|
path.push(target_tile);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Some(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if already processed
|
||||||
|
if closed_set.contains(¤t) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
closed_set.insert(current);
|
||||||
|
|
||||||
|
// Check if we've exceeded max path length
|
||||||
|
if current_node.g_cost as usize > max_path_length {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explore neighbors
|
||||||
|
let neighbors = get_water_neighbors(terrain, current, width, height);
|
||||||
|
for neighbor in neighbors {
|
||||||
|
if closed_set.contains(&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);
|
||||||
|
g_scores.insert(neighbor, tentative_g);
|
||||||
|
|
||||||
|
// Find best heuristic to any target
|
||||||
|
let h_cost = water_targets.iter().map(|&t| heuristic_distance(neighbor, t, width)).min().unwrap_or(0);
|
||||||
|
|
||||||
|
let f_cost = tentative_g + h_cost;
|
||||||
|
|
||||||
|
open_set.push(PathNode { tile_idx: neighbor, g_cost: tentative_g, h_cost, f_cost, parent: Some(current) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!("Pathfinding failed: no path found from {} to {}", start_tile, target_tile);
|
||||||
|
None // No path found
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate heuristic distance between two tiles (Manhattan distance)
|
||||||
|
fn heuristic_distance(from: usize, to: usize, width: usize) -> u32 {
|
||||||
|
let from_x = from % width;
|
||||||
|
let from_y = from / width;
|
||||||
|
let to_x = to % width;
|
||||||
|
let to_y = to / width;
|
||||||
|
|
||||||
|
((from_x as i32 - to_x as i32).abs() + (from_y as i32 - to_y as i32).abs()) as u32
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get water neighbors of a tile (4-directional movement)
|
||||||
|
fn get_water_neighbors(terrain: &TerrainData, tile: usize, width: usize, height: usize) -> Vec<usize> {
|
||||||
|
let x = tile % width;
|
||||||
|
let y = tile / width;
|
||||||
|
let mut neighbors = Vec::with_capacity(4);
|
||||||
|
|
||||||
|
// Check 4 directions
|
||||||
|
let directions = [(0, -1), (1, 0), (0, 1), (-1, 0)];
|
||||||
|
|
||||||
|
for (dx, dy) in directions {
|
||||||
|
let nx = x as i32 + dx;
|
||||||
|
let ny = y as i32 + dy;
|
||||||
|
|
||||||
|
if nx >= 0 && ny >= 0 && (nx as usize) < width && (ny as usize) < height {
|
||||||
|
let neighbor_idx = nx as usize + ny as usize * width;
|
||||||
|
if terrain.is_navigable((nx as u32, ny as u32)) {
|
||||||
|
neighbors.push(neighbor_idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
neighbors
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find a water tile adjacent to a coastal land tile for ship launch
|
||||||
|
fn find_water_launch_tile(terrain: &TerrainData, coast_tile: usize, width: usize, height: usize) -> Option<usize> {
|
||||||
|
let x = coast_tile % width;
|
||||||
|
let y = coast_tile / width;
|
||||||
|
|
||||||
|
debug!("find_water_launch_tile: checking coastal tile {} at ({},{})", coast_tile, x, y);
|
||||||
|
|
||||||
|
// Check 4 directions for water
|
||||||
|
let directions = [(0, -1), (1, 0), (0, 1), (-1, 0)];
|
||||||
|
|
||||||
|
for (dx, dy) in directions {
|
||||||
|
let nx = x as i32 + dx;
|
||||||
|
let ny = y as i32 + dy;
|
||||||
|
|
||||||
|
if nx >= 0 && ny >= 0 && (nx as usize) < width && (ny as usize) < height {
|
||||||
|
let neighbor_idx = nx as usize + ny as usize * width;
|
||||||
|
let is_water = terrain.is_navigable((nx as u32, ny as u32));
|
||||||
|
debug!(" Checking neighbor ({},{}) -> tile {}: is_water={}", nx, ny, neighbor_idx, is_water);
|
||||||
|
if is_water {
|
||||||
|
debug!(" Found water launch tile {} at ({},{})", neighbor_idx, nx, ny);
|
||||||
|
return Some(neighbor_idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!(" No water launch tile found for coastal tile {}", coast_tile);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find all water tiles adjacent to a land tile
|
||||||
|
fn find_adjacent_water_tiles(terrain: &TerrainData, tile: usize, width: usize, height: usize) -> Vec<usize> {
|
||||||
|
let x = tile % width;
|
||||||
|
let y = tile / width;
|
||||||
|
let mut water_tiles = Vec::new();
|
||||||
|
|
||||||
|
// Check 4 directions for water
|
||||||
|
let directions = [(0, -1), (1, 0), (0, 1), (-1, 0)];
|
||||||
|
|
||||||
|
for (dx, dy) in directions {
|
||||||
|
let nx = x as i32 + dx;
|
||||||
|
let ny = y as i32 + dy;
|
||||||
|
|
||||||
|
if nx >= 0 && ny >= 0 && (nx as usize) < width && (ny as usize) < height {
|
||||||
|
let neighbor_idx = nx as usize + ny as usize * width;
|
||||||
|
if terrain.is_navigable((nx as u32, ny as u32)) {
|
||||||
|
water_tiles.push(neighbor_idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
water_tiles
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a tile is a valid ship destination (water or coastal land)
|
||||||
|
fn is_valid_ship_destination(terrain: &TerrainData, tile: usize, width: usize, height: usize) -> bool {
|
||||||
|
let x = tile % width;
|
||||||
|
let y = tile / width;
|
||||||
|
|
||||||
|
// If it's water, it's valid
|
||||||
|
if terrain.is_navigable((x as u32, y as u32)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's land, check if it's coastal
|
||||||
|
let directions = [(0, -1), (1, 0), (0, 1), (-1, 0)];
|
||||||
|
|
||||||
|
for (dx, dy) in directions {
|
||||||
|
let nx = x as i32 + dx;
|
||||||
|
let ny = y as i32 + dy;
|
||||||
|
|
||||||
|
if nx >= 0 && ny >= 0 && (nx as usize) < width && (ny as usize) < height && terrain.is_navigable((nx as u32, ny as u32)) {
|
||||||
|
return true; // It's coastal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simplify a path by removing unnecessary waypoints (path smoothing)
|
||||||
|
/// This maintains determinism as it's purely geometric
|
||||||
|
pub fn smooth_path(path: Vec<usize>, terrain: &TerrainData, width: usize) -> Vec<usize> {
|
||||||
|
if path.len() <= 2 {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut smoothed = vec![path[0]];
|
||||||
|
let mut current = 0;
|
||||||
|
|
||||||
|
while current < path.len() - 1 {
|
||||||
|
let mut farthest = current + 1;
|
||||||
|
|
||||||
|
// Find the farthest point we can see directly
|
||||||
|
for i in (current + 2)..path.len() {
|
||||||
|
if has_clear_water_line(terrain, path[current], path[i], width) {
|
||||||
|
farthest = i;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
smoothed.push(path[farthest]);
|
||||||
|
current = 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: usize, to: usize, width: usize) -> bool {
|
||||||
|
let x0 = (from % width) as i32;
|
||||||
|
let y0 = (from / width) as i32;
|
||||||
|
let x1 = (to % width) as i32;
|
||||||
|
let y1 = (to / width) 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((x as u32, y as u32)) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
88
crates/borders-core/src/game/ships/transport_ship.rs
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// A transport ship carrying troops across water
|
||||||
|
/// Uses fixed-point arithmetic for deterministic calculations
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TransportShip {
|
||||||
|
/// Unique identifier for this ship
|
||||||
|
pub id: u32,
|
||||||
|
|
||||||
|
/// Player who owns this ship
|
||||||
|
pub owner_id: u16,
|
||||||
|
|
||||||
|
/// Number of troops being transported (stored as integer)
|
||||||
|
pub troops: u32,
|
||||||
|
|
||||||
|
/// Pre-calculated path of tile indices
|
||||||
|
pub path: Vec<usize>,
|
||||||
|
|
||||||
|
/// Index of the current tile in the path
|
||||||
|
pub current_path_index: usize,
|
||||||
|
|
||||||
|
/// Movement speed in ticks per tile (e.g., 2 = move one tile every 2 ticks)
|
||||||
|
pub ticks_per_tile: u32,
|
||||||
|
|
||||||
|
/// Ticks since last tile transition
|
||||||
|
pub ticks_since_move: u32,
|
||||||
|
|
||||||
|
/// The tick when the ship was launched
|
||||||
|
pub launch_tick: u64,
|
||||||
|
|
||||||
|
/// The target tile (final destination)
|
||||||
|
pub target_tile: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TransportShip {
|
||||||
|
/// Create a new transport ship
|
||||||
|
pub fn new(id: u32, owner_id: u16, troops: u32, path: Vec<usize>, ticks_per_tile: u32, launch_tick: u64) -> Self {
|
||||||
|
let target_tile = *path.last().unwrap_or(&path[0]);
|
||||||
|
|
||||||
|
Self { id, owner_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;
|
||||||
|
|
||||||
|
// Check if it's time to move to the next tile
|
||||||
|
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
|
||||||
|
pub fn get_current_tile(&self) -> usize {
|
||||||
|
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
|
||||||
|
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)
|
||||||
|
/// This is only for visual interpolation and doesn't affect game logic
|
||||||
|
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)
|
||||||
|
pub fn get_next_tile(&self) -> Option<usize> {
|
||||||
|
if self.current_path_index + 1 < self.path.len() { Some(self.path[self.current_path_index + 1]) } else { None }
|
||||||
|
}
|
||||||
|
}
|
||||||
67
crates/borders-core/src/game/spawn_manager.rs
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
use bevy_ecs::prelude::*;
|
||||||
|
|
||||||
|
/// Manages spawn positions during the pre-game spawn phase
|
||||||
|
///
|
||||||
|
/// This resource tracks bot and player spawn positions before the game starts ticking.
|
||||||
|
/// It allows for dynamic recalculation of bot positions when players 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 (player_id, tile_index)
|
||||||
|
pub initial_bot_spawns: Vec<(usize, usize)>,
|
||||||
|
|
||||||
|
/// Current bot spawn positions after recalculation (player_id, tile_index)
|
||||||
|
/// These are updated whenever a player chooses/changes their spawn
|
||||||
|
pub current_bot_spawns: Vec<(usize, usize)>,
|
||||||
|
|
||||||
|
/// Player spawn positions (player_id, tile_index)
|
||||||
|
/// Tracks human player spawn selections
|
||||||
|
pub player_spawns: Vec<(usize, usize)>,
|
||||||
|
|
||||||
|
/// RNG seed for deterministic spawn calculations
|
||||||
|
pub rng_seed: u64,
|
||||||
|
|
||||||
|
/// Map dimensions for distance calculations
|
||||||
|
pub map_width: u32,
|
||||||
|
pub map_height: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SpawnManager {
|
||||||
|
/// Create a new SpawnManager with initial bot spawns
|
||||||
|
pub fn new(initial_bot_spawns: Vec<(usize, usize)>, rng_seed: u64, map_width: u32, map_height: u32) -> Self {
|
||||||
|
Self { current_bot_spawns: initial_bot_spawns.clone(), initial_bot_spawns, player_spawns: Vec::new(), rng_seed, map_width, map_height }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update a player'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 player position.
|
||||||
|
pub fn update_player_spawn(&mut self, player_id: usize, tile_index: usize, bot_manager: &crate::game::BotManager, territory_manager: &crate::game::TerritoryManager) {
|
||||||
|
// Update or add player spawn
|
||||||
|
if let Some(entry) = self.player_spawns.iter_mut().find(|(pid, _)| *pid == player_id) {
|
||||||
|
entry.1 = tile_index;
|
||||||
|
} else {
|
||||||
|
self.player_spawns.push((player_id, tile_index));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recalculate bot spawns with updated player positions
|
||||||
|
self.current_bot_spawns = bot_manager.recalculate_spawns_with_players(self.initial_bot_spawns.clone(), &self.player_spawns, territory_manager, self.rng_seed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all current spawn positions (players + bots)
|
||||||
|
pub fn get_all_spawns(&self) -> Vec<(usize, usize)> {
|
||||||
|
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) -> &[(usize, usize)] {
|
||||||
|
&self.current_bot_spawns
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get only player spawn positions
|
||||||
|
pub fn get_player_spawns(&self) -> &[(usize, usize)] {
|
||||||
|
&self.player_spawns
|
||||||
|
}
|
||||||
|
}
|
||||||
74
crates/borders-core/src/game/spawn_timeout.rs
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
use bevy_ecs::prelude::*;
|
||||||
|
|
||||||
|
/// 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
pub fn has_expired(&self) -> bool {
|
||||||
|
!self.active && self.remaining_secs <= 0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
270
crates/borders-core/src/game/terrain.rs
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
use bevy_ecs::prelude::Resource;
|
||||||
|
use glam::UVec2;
|
||||||
|
use image::GenericImageView;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fs;
|
||||||
|
use tracing::{debug, info};
|
||||||
|
|
||||||
|
use crate::game::territory::get_idx;
|
||||||
|
use crate::game::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 width: usize,
|
||||||
|
pub height: usize,
|
||||||
|
pub num_land_tiles: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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>> {
|
||||||
|
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 = serde_json::from_slice(MAP_JSON)?;
|
||||||
|
|
||||||
|
// Load PNG image
|
||||||
|
let png = image::load_from_memory(MAP_PNG)?;
|
||||||
|
let (width, height) = png.dimensions();
|
||||||
|
|
||||||
|
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 mut tiles = vec![0u8; (width * height) as usize];
|
||||||
|
let mut terrain_data_raw = vec![0u8; (width * height) as usize];
|
||||||
|
|
||||||
|
// Match each pixel to nearest tile type by color
|
||||||
|
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 = 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 { width: width as usize, height: height as usize, num_land_tiles }, nations: vec![] }, terrain_data: TileMap::from_vec(width, height, terrain_data_raw), tiles, tile_types })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load a map from the resources directory
|
||||||
|
pub fn load(map_name: &str) -> Result<Self, Box<dyn std::error::Error>> {
|
||||||
|
let base_path = format!("resources/maps/{}", map_name);
|
||||||
|
|
||||||
|
// Load manifest
|
||||||
|
let manifest_path = format!("{}/manifest.json", base_path);
|
||||||
|
let manifest_json = fs::read_to_string(&manifest_path)?;
|
||||||
|
let manifest: MapManifest = serde_json::from_str(&manifest_json)?;
|
||||||
|
|
||||||
|
// Load binary map data
|
||||||
|
let map_path = format!("{}/map.bin", base_path);
|
||||||
|
let terrain_data_raw = fs::read(&map_path)?;
|
||||||
|
|
||||||
|
let width = manifest.map.width as u32;
|
||||||
|
let height = manifest.map.height as u32;
|
||||||
|
|
||||||
|
// Verify data size
|
||||||
|
if terrain_data_raw.len() != (width * height) as usize {
|
||||||
|
return Err(format!("Map data size mismatch: expected {} bytes, got {}", width * height, terrain_data_raw.len()).into());
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Loaded map '{}' ({}x{})", manifest.name, width, height);
|
||||||
|
debug!("Land tiles: {}/{}", manifest.map.num_land_tiles, width * height);
|
||||||
|
|
||||||
|
// Create default tile types for legacy format
|
||||||
|
let tile_types = vec![TileType { name: "water".to_string(), color_base: "water".to_string(), color_variant: 0, conquerable: false, navigable: true, expansion_time: 255, expansion_cost: 255 }, TileType { name: "land".to_string(), color_base: "grass".to_string(), color_variant: 0, conquerable: true, navigable: false, expansion_time: 50, expansion_cost: 50 }];
|
||||||
|
|
||||||
|
// Convert legacy format to tile indices
|
||||||
|
let tiles: Vec<u8> = terrain_data_raw.iter().map(|&byte| if byte & 0x80 != 0 { 1 } else { 0 }).collect();
|
||||||
|
|
||||||
|
// Create TileMap from terrain data
|
||||||
|
let terrain_data = TileMap::from_vec(width, height, terrain_data_raw);
|
||||||
|
|
||||||
|
Ok(Self { _manifest: manifest, terrain_data, tiles, tile_types })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the size of the map
|
||||||
|
pub fn size(&self) -> UVec2 {
|
||||||
|
UVec2::new(self.terrain_data.width(), self.terrain_data.height())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_value<T: Into<UVec2>>(&self, pos: T) -> u8 {
|
||||||
|
self.terrain_data[get_idx(pos, self.terrain_data.width())]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a tile is land (bit 7 set)
|
||||||
|
pub fn is_land<T: Into<UVec2>>(&self, pos: T) -> bool {
|
||||||
|
self.get_value(pos) & 0x80 != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get terrain magnitude (bits 0-4)
|
||||||
|
pub fn terrain_magnitude<T: Into<UVec2>>(&self, pos: T) -> u8 {
|
||||||
|
self.get_value(pos) & 0b00011111
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get tile type at position
|
||||||
|
pub fn get_tile_type<T: Into<UVec2>>(&self, pos: T) -> &TileType {
|
||||||
|
let idx = get_idx(pos, self.terrain_data.width());
|
||||||
|
&self.tile_types[self.tiles[idx] as usize]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a tile is conquerable
|
||||||
|
pub fn is_conquerable<T: Into<UVec2>>(&self, pos: T) -> bool {
|
||||||
|
self.get_tile_type(pos).conquerable
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a tile is navigable (water)
|
||||||
|
pub fn is_navigable<T: Into<UVec2>>(&self, pos: T) -> bool {
|
||||||
|
self.get_tile_type(pos).navigable
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get expansion time for a tile
|
||||||
|
pub fn get_expansion_time<T: Into<UVec2>>(&self, pos: T) -> u8 {
|
||||||
|
self.get_tile_type(pos).expansion_time
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get expansion cost for a tile
|
||||||
|
pub fn get_expansion_cost<T: Into<UVec2>>(&self, pos: T) -> u8 {
|
||||||
|
self.get_tile_type(pos).expansion_cost
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
21
crates/borders-core/src/game/territory.rs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
use glam::{IVec2, UVec2};
|
||||||
|
|
||||||
|
/// Convert 2D coordinates to a flat array index
|
||||||
|
pub fn get_idx<T: Into<UVec2>>(pos: T, width: u32) -> usize {
|
||||||
|
let pos = pos.into();
|
||||||
|
pos.x as usize + pos.y as usize * width as usize
|
||||||
|
}
|
||||||
|
|
||||||
|
const CARDINAL_DIRECTIONS: [IVec2; 4] = [IVec2::new(0, 1), IVec2::new(1, 0), IVec2::new(0, -1), IVec2::new(-1, 0)];
|
||||||
|
|
||||||
|
/// Returns an iterator over the valid cardinal neighbors of a tile.
|
||||||
|
///
|
||||||
|
/// Requires the width and height of the map to be passed in to ensure the neighbor is within bounds.
|
||||||
|
pub fn get_neighbors(pos: UVec2, width: u32, height: u32) -> impl Iterator<Item = UVec2> {
|
||||||
|
let in_bounds = move |neighbor: IVec2| (0..width).contains(&(neighbor.x as u32)) && (0..height).contains(&(neighbor.y as u32));
|
||||||
|
|
||||||
|
CARDINAL_DIRECTIONS.into_iter().filter_map(move |dir| {
|
||||||
|
let neighbor = pos.as_ivec2().saturating_add(dir);
|
||||||
|
in_bounds(neighbor).then_some(neighbor.as_uvec2())
|
||||||
|
})
|
||||||
|
}
|
||||||
202
crates/borders-core/src/game/territory_manager.rs
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
use crate::game::tile_ownership::{ENCODED_WATER, TileOwnership};
|
||||||
|
use crate::game::tilemap::TileMap;
|
||||||
|
use crate::game::tilemap_changes::ChangeBuffer;
|
||||||
|
|
||||||
|
/// Deprecated: Use TileOwnership::Water instead
|
||||||
|
/// Kept for backward compatibility during migration
|
||||||
|
pub const OWNER_WATER: u16 = ENCODED_WATER;
|
||||||
|
|
||||||
|
/// Manages territory ownership for all tiles
|
||||||
|
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(width: u32, height: u32) -> Self {
|
||||||
|
let size = (width * height) as usize;
|
||||||
|
Self { tile_owners: TileMap::with_default(width, height, TileOwnership::Unclaimed), changes: ChangeBuffer::with_capacity((width * height / 100) as usize), u16_cache: vec![0; size], cache_dirty: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resets the territory manager
|
||||||
|
/// Should only be called when a new game is started
|
||||||
|
pub fn reset(&mut self, width: u32, height: u32, conquerable_tiles: &[bool]) {
|
||||||
|
self.tile_owners = TileMap::with_default(width, height, TileOwnership::Unclaimed);
|
||||||
|
self.changes.clear();
|
||||||
|
|
||||||
|
for (i, &conquerable) in conquerable_tiles.iter().enumerate() {
|
||||||
|
if !conquerable {
|
||||||
|
self.tile_owners[i] = TileOwnership::Water;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let size = (width * height) 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: usize) -> bool {
|
||||||
|
let pos = self.tile_owners.index_to_pos(tile);
|
||||||
|
let owner = self.tile_owners[tile];
|
||||||
|
|
||||||
|
// Border if on map edge
|
||||||
|
if pos.x == 0 || pos.x == self.tile_owners.width() - 1 || pos.y == 0 || pos.y == self.tile_owners.height() - 1 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Border if any neighbor has different owner
|
||||||
|
for neighbor_pos in self.tile_owners.neighbors(pos) {
|
||||||
|
if self.tile_owners[neighbor_pos] != owner {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks if a tile has an owner
|
||||||
|
pub fn has_owner(&self, tile: usize) -> bool {
|
||||||
|
self.tile_owners[tile].is_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks if a tile is owned by a specific player
|
||||||
|
pub fn is_owner(&self, tile: usize, owner: usize) -> bool {
|
||||||
|
self.tile_owners[tile].is_owned_by(owner as u16)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the owner of a tile as u16 (for compatibility)
|
||||||
|
/// Returns the encoded u16 value (nation_id, or ENCODED_UNCLAIMED/ENCODED_WATER)
|
||||||
|
pub fn get_owner(&self, tile: usize) -> u16 {
|
||||||
|
self.tile_owners[tile].to_u16()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the ownership enum for a tile
|
||||||
|
pub fn get_ownership(&self, tile: usize) -> TileOwnership {
|
||||||
|
self.tile_owners[tile]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks if a tile is water
|
||||||
|
pub fn is_water(&self, tile: usize) -> bool {
|
||||||
|
self.tile_owners[tile].is_water()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Conquers a tile for a player
|
||||||
|
/// If the tile is already owned by a player, that player will lose the tile
|
||||||
|
/// Only records a change if the owner actually changed
|
||||||
|
pub fn conquer(&mut self, tile: usize, owner: usize) -> u16 {
|
||||||
|
let previous_owner = self.tile_owners[tile];
|
||||||
|
let new_ownership = TileOwnership::Owned(owner as u16);
|
||||||
|
|
||||||
|
// Only update and track change if the owner actually changed
|
||||||
|
if previous_owner != new_ownership {
|
||||||
|
self.tile_owners[tile] = new_ownership;
|
||||||
|
self.changes.push(tile);
|
||||||
|
self.cache_dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
previous_owner.to_u16()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears a tile (removes ownership)
|
||||||
|
pub fn clear(&mut self, tile: usize) -> Option<u16> {
|
||||||
|
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 width of the map
|
||||||
|
pub fn width(&self) -> u32 {
|
||||||
|
self.tile_owners.width()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get height of the map
|
||||||
|
pub fn height(&self) -> u32 {
|
||||||
|
self.tile_owners.height()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a reference to the underlying tile ownership data as a slice of enums
|
||||||
|
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 {
|
||||||
|
for (i, ownership) in self.tile_owners.as_slice().iter().enumerate() {
|
||||||
|
self.u16_cache[i] = ownership.to_u16();
|
||||||
|
}
|
||||||
|
self.cache_dirty = false;
|
||||||
|
}
|
||||||
|
&self.u16_cache
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts tile ownership to a Vec<u16> for serialization (immutable)
|
||||||
|
/// Use this when you need a Vec for creating Arc<[u16]>
|
||||||
|
pub fn to_u16_vec(&self) -> Vec<u16> {
|
||||||
|
self.tile_owners.as_slice().iter().map(|ownership| ownership.to_u16()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the number of tiles in the map
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.tile_owners.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if the map has no tiles
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.tile_owners.len() == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns an iterator over changed tile indices without consuming them
|
||||||
|
/// Use this to read changes without clearing the buffer
|
||||||
|
pub fn iter_changes(&self) -> impl Iterator<Item = usize> + '_ {
|
||||||
|
self.changes.iter()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drains all changed tile indices, returning an iterator and clearing the change buffer
|
||||||
|
pub fn drain_changes(&mut self) -> impl Iterator<Item = usize> + '_ {
|
||||||
|
self.changes.drain()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if any territory changes have been recorded since last drain
|
||||||
|
pub fn has_changes(&self) -> bool {
|
||||||
|
self.changes.has_changes()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears all tracked changes without returning them
|
||||||
|
pub fn clear_changes(&mut self) {
|
||||||
|
self.changes.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calls a closure for each neighbor using tile indices
|
||||||
|
pub fn on_neighbor_indices<F>(&self, index: usize, closure: F)
|
||||||
|
where
|
||||||
|
F: FnMut(usize),
|
||||||
|
{
|
||||||
|
self.tile_owners.on_neighbor_indices(index, closure)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks if any neighbor has a different owner than the specified owner
|
||||||
|
/// This is optimized for border detection with early exit
|
||||||
|
pub fn any_neighbor_has_different_owner(&self, tile: usize, owner: u16) -> bool {
|
||||||
|
let mut has_different = false;
|
||||||
|
self.on_neighbor_indices(tile, |neighbor| {
|
||||||
|
if !has_different && self.tile_owners[neighbor].to_u16() != owner {
|
||||||
|
has_different = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
has_different
|
||||||
|
}
|
||||||
|
}
|
||||||
159
crates/borders-core/src/game/tile_ownership.rs
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
//! Tile ownership representation
|
||||||
|
//!
|
||||||
|
//! This module defines how tiles are owned and what their terrain type is.
|
||||||
|
//! It separates the concept of "who owns this tile" from "what type of terrain is this".
|
||||||
|
//!
|
||||||
|
//! ## Encoding
|
||||||
|
//!
|
||||||
|
//! For frontend serialization, TileOwnership is encoded as a u16:
|
||||||
|
//! - 0-65533: Nation IDs (supports 400+ nations)
|
||||||
|
//! - 65534: Unclaimed land
|
||||||
|
//! - 65535: Water (unconquerable terrain)
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Encoded value for unclaimed land in u16 representation
|
||||||
|
pub const ENCODED_UNCLAIMED: u16 = 65534;
|
||||||
|
|
||||||
|
/// Encoded value for water in u16 representation
|
||||||
|
/// Kept at 65535 for backward compatibility with OWNER_WATER
|
||||||
|
pub const ENCODED_WATER: u16 = 65535;
|
||||||
|
|
||||||
|
/// Represents the ownership/state of a single tile
|
||||||
|
///
|
||||||
|
/// This enum clearly separates nation ownership from terrain type,
|
||||||
|
/// allowing nation ID 0 to be valid without confusion with wilderness.
|
||||||
|
#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize, Deserialize, Default)]
|
||||||
|
pub enum TileOwnership {
|
||||||
|
/// Owned by a nation (0-65533, supporting 65,534 possible nations)
|
||||||
|
Owned(u16),
|
||||||
|
/// Unclaimed but conquerable land
|
||||||
|
#[default]
|
||||||
|
Unclaimed,
|
||||||
|
/// Water (unconquerable terrain)
|
||||||
|
Water,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TileOwnership {
|
||||||
|
/// Convert to u16 for frontend serialization
|
||||||
|
pub fn to_u16(self) -> u16 {
|
||||||
|
match self {
|
||||||
|
TileOwnership::Owned(nation_id) => nation_id,
|
||||||
|
TileOwnership::Unclaimed => ENCODED_UNCLAIMED,
|
||||||
|
TileOwnership::Water => ENCODED_WATER,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert from u16 (frontend serialization format)
|
||||||
|
pub fn from_u16(value: u16) -> Self {
|
||||||
|
match value {
|
||||||
|
ENCODED_WATER => TileOwnership::Water,
|
||||||
|
ENCODED_UNCLAIMED => TileOwnership::Unclaimed,
|
||||||
|
nation_id => TileOwnership::Owned(nation_id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if this tile is owned by any nation
|
||||||
|
pub fn is_owned(self) -> bool {
|
||||||
|
matches!(self, TileOwnership::Owned(_))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if this tile is unclaimed land
|
||||||
|
pub fn is_unclaimed(self) -> bool {
|
||||||
|
matches!(self, TileOwnership::Unclaimed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if this tile is water
|
||||||
|
pub fn is_water(self) -> bool {
|
||||||
|
matches!(self, TileOwnership::Water)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the nation ID if this tile is owned, otherwise None
|
||||||
|
pub fn nation_id(self) -> Option<u16> {
|
||||||
|
match self {
|
||||||
|
TileOwnership::Owned(id) => Some(id),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if this tile is owned by a specific nation
|
||||||
|
pub fn is_owned_by(self, nation_id: u16) -> bool {
|
||||||
|
matches!(self, TileOwnership::Owned(id) if id == nation_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<u16> for TileOwnership {
|
||||||
|
fn from(value: u16) -> Self {
|
||||||
|
Self::from_u16(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<TileOwnership> for u16 {
|
||||||
|
fn from(ownership: TileOwnership) -> Self {
|
||||||
|
ownership.to_u16()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_encoding_nation_ids() {
|
||||||
|
for nation_id in [0, 1, 100, 1000, 65533] {
|
||||||
|
let ownership = TileOwnership::Owned(nation_id);
|
||||||
|
let encoded = ownership.to_u16();
|
||||||
|
assert_eq!(encoded, nation_id);
|
||||||
|
assert_eq!(TileOwnership::from_u16(encoded), ownership);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_encoding_unclaimed() {
|
||||||
|
let ownership = TileOwnership::Unclaimed;
|
||||||
|
let encoded = ownership.to_u16();
|
||||||
|
assert_eq!(encoded, ENCODED_UNCLAIMED);
|
||||||
|
assert_eq!(TileOwnership::from_u16(encoded), ownership);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_encoding_water() {
|
||||||
|
let ownership = TileOwnership::Water;
|
||||||
|
let encoded = ownership.to_u16();
|
||||||
|
assert_eq!(encoded, ENCODED_WATER);
|
||||||
|
assert_eq!(TileOwnership::from_u16(encoded), ownership);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_owned() {
|
||||||
|
assert!(TileOwnership::Owned(0).is_owned());
|
||||||
|
assert!(TileOwnership::Owned(100).is_owned());
|
||||||
|
assert!(!TileOwnership::Unclaimed.is_owned());
|
||||||
|
assert!(!TileOwnership::Water.is_owned());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_owned_by() {
|
||||||
|
assert!(TileOwnership::Owned(5).is_owned_by(5));
|
||||||
|
assert!(!TileOwnership::Owned(5).is_owned_by(6));
|
||||||
|
assert!(!TileOwnership::Unclaimed.is_owned_by(5));
|
||||||
|
assert!(!TileOwnership::Water.is_owned_by(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_nation_id() {
|
||||||
|
assert_eq!(TileOwnership::Owned(42).nation_id(), Some(42));
|
||||||
|
assert_eq!(TileOwnership::Unclaimed.nation_id(), None);
|
||||||
|
assert_eq!(TileOwnership::Water.nation_id(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_nation_zero_is_valid() {
|
||||||
|
let ownership = TileOwnership::Owned(0);
|
||||||
|
assert!(ownership.is_owned());
|
||||||
|
assert!(ownership.is_owned_by(0));
|
||||||
|
assert_eq!(ownership.nation_id(), Some(0));
|
||||||
|
assert_ne!(ownership, TileOwnership::Unclaimed);
|
||||||
|
assert_ne!(ownership, TileOwnership::Water);
|
||||||
|
}
|
||||||
|
}
|
||||||
441
crates/borders-core/src/game/tilemap.rs
Normal file
@@ -0,0 +1,441 @@
|
|||||||
|
use glam::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 (UVec2) while maintaining
|
||||||
|
/// cache-friendly contiguous memory layout. Supports generic tile types that implement Copy.
|
||||||
|
///
|
||||||
|
/// # Type Parameters
|
||||||
|
/// * `T` - The tile value type. Must implement `Copy` for efficient access.
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
/// ```
|
||||||
|
/// use glam::UVec2;
|
||||||
|
/// use borders_core::game::TileMap;
|
||||||
|
///
|
||||||
|
/// let mut map = TileMap::<u8>::new(10, 10);
|
||||||
|
/// map[UVec2::new(5, 5)] = 42;
|
||||||
|
/// assert_eq!(map[UVec2::new(5, 5)], 42);
|
||||||
|
/// ```
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct TileMap<T: Copy> {
|
||||||
|
tiles: Box<[T]>,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Copy> TileMap<T> {
|
||||||
|
/// Creates a new TileMap with the specified dimensions and default value.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `width` - The width of the map in tiles
|
||||||
|
/// * `height` - The height of the map in tiles
|
||||||
|
/// * `default` - The default value to initialize all tiles with
|
||||||
|
pub fn with_default(width: u32, height: u32, default: T) -> Self {
|
||||||
|
let capacity = (width * height) as usize;
|
||||||
|
let tiles = vec![default; capacity].into_boxed_slice();
|
||||||
|
Self { tiles, width, height }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a TileMap from an existing vector of tile data.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `width` - The width of the map in tiles
|
||||||
|
/// * `height` - The height of the map in tiles
|
||||||
|
/// * `data` - Vector containing tile data in row-major order
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
/// Panics if `data.len() != width * height`
|
||||||
|
pub fn from_vec(width: u32, height: u32, data: Vec<T>) -> Self {
|
||||||
|
assert_eq!(data.len(), (width * height) as usize, "Data length must match width * height");
|
||||||
|
Self { tiles: data.into_boxed_slice(), width, height }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts the position to a flat array index.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// Debug builds will assert that the position is in bounds.
|
||||||
|
/// Release builds skip the check for performance.
|
||||||
|
#[inline]
|
||||||
|
pub fn pos_to_index(&self, pos: UVec2) -> usize {
|
||||||
|
debug_assert!(pos.x < self.width && pos.y < self.height);
|
||||||
|
(pos.y * self.width + pos.x) as usize
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts a flat array index to a 2D position.
|
||||||
|
#[inline]
|
||||||
|
pub fn index_to_pos(&self, index: usize) -> UVec2 {
|
||||||
|
debug_assert!(index < self.tiles.len());
|
||||||
|
UVec2::new((index as u32) % self.width, (index as u32) / self.width)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks if a position is within the map bounds.
|
||||||
|
#[inline]
|
||||||
|
pub fn in_bounds(&self, pos: UVec2) -> bool {
|
||||||
|
pos.x < self.width && pos.y < self.height
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the tile value at the specified position.
|
||||||
|
///
|
||||||
|
/// Returns `None` if the position is out of bounds.
|
||||||
|
pub fn get(&self, pos: UVec2) -> Option<T> {
|
||||||
|
if self.in_bounds(pos) { Some(self.tiles[self.pos_to_index(pos)]) } 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(&mut self, pos: UVec2, tile: T) -> bool {
|
||||||
|
if self.in_bounds(pos) {
|
||||||
|
let idx = self.pos_to_index(pos);
|
||||||
|
self.tiles[idx] = tile;
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the width of the map.
|
||||||
|
#[inline]
|
||||||
|
pub fn width(&self) -> u32 {
|
||||||
|
self.width
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the height of the map.
|
||||||
|
#[inline]
|
||||||
|
pub fn height(&self) -> u32 {
|
||||||
|
self.height
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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(&self, pos: UVec2) -> impl Iterator<Item = UVec2> + '_ {
|
||||||
|
const CARDINAL_DIRECTIONS: [(i32, i32); 4] = [(0, 1), (1, 0), (0, -1), (-1, 0)];
|
||||||
|
|
||||||
|
let pos_i32 = (pos.x as i32, pos.y as i32);
|
||||||
|
let width = self.width;
|
||||||
|
let height = self.height;
|
||||||
|
|
||||||
|
CARDINAL_DIRECTIONS.iter().filter_map(move |(dx, dy)| {
|
||||||
|
let nx = pos_i32.0 + dx;
|
||||||
|
let ny = pos_i32.1 + dy;
|
||||||
|
if nx >= 0 && ny >= 0 && nx < width as i32 && ny < height as i32 { Some(UVec2::new(nx as u32, ny as u32)) } else { None }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calls a closure for each valid cardinal neighbor of a position.
|
||||||
|
///
|
||||||
|
/// This is more efficient than using the `neighbors()` iterator when you don't
|
||||||
|
/// need to collect the neighbors.
|
||||||
|
pub fn on_neighbors<F>(&self, pos: UVec2, mut closure: F)
|
||||||
|
where
|
||||||
|
F: FnMut(UVec2),
|
||||||
|
{
|
||||||
|
if pos.x > 0 {
|
||||||
|
closure(UVec2::new(pos.x - 1, pos.y));
|
||||||
|
}
|
||||||
|
if pos.x < self.width - 1 {
|
||||||
|
closure(UVec2::new(pos.x + 1, pos.y));
|
||||||
|
}
|
||||||
|
if pos.y > 0 {
|
||||||
|
closure(UVec2::new(pos.x, pos.y - 1));
|
||||||
|
}
|
||||||
|
if pos.y < self.height - 1 {
|
||||||
|
closure(UVec2::new(pos.x, pos.y + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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: usize, mut closure: F)
|
||||||
|
where
|
||||||
|
F: FnMut(usize),
|
||||||
|
{
|
||||||
|
let width = self.width as usize;
|
||||||
|
let height = self.height as usize;
|
||||||
|
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 = (UVec2, T)> + '_ {
|
||||||
|
(0..self.height).flat_map(move |y| {
|
||||||
|
(0..self.width).map(move |x| {
|
||||||
|
let pos = UVec2::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 = UVec2> + '_ {
|
||||||
|
(0..self.height).flat_map(move |y| (0..self.width).map(move |x| UVec2::new(x, y)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns an iterator over tile indices, positions, and values.
|
||||||
|
pub fn enumerate(&self) -> impl Iterator<Item = (usize, UVec2, T)> + '_ {
|
||||||
|
self.tiles.iter().enumerate().map(move |(idx, &value)| {
|
||||||
|
let pos = self.index_to_pos(idx);
|
||||||
|
(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(width: u32, height: u32) -> Self {
|
||||||
|
Self::with_default(width, height, T::default())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Copy> Index<UVec2> for TileMap<T> {
|
||||||
|
type Output = T;
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn index(&self, pos: UVec2) -> &Self::Output {
|
||||||
|
&self.tiles[self.pos_to_index(pos)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Copy> IndexMut<UVec2> for TileMap<T> {
|
||||||
|
#[inline]
|
||||||
|
fn index_mut(&mut self, pos: UVec2) -> &mut Self::Output {
|
||||||
|
let idx = self.pos_to_index(pos);
|
||||||
|
&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]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_new_with_default() {
|
||||||
|
let map = TileMap::<u8>::with_default(10, 10, 42);
|
||||||
|
assert_eq!(map.width(), 10);
|
||||||
|
assert_eq!(map.height(), 10);
|
||||||
|
assert_eq!(map[UVec2::new(0, 0)], 42);
|
||||||
|
assert_eq!(map[UVec2::new(9, 9)], 42);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_from_vec() {
|
||||||
|
let data = vec![1u8, 2, 3, 4];
|
||||||
|
let map = TileMap::from_vec(2, 2, data);
|
||||||
|
assert_eq!(map[UVec2::new(0, 0)], 1);
|
||||||
|
assert_eq!(map[UVec2::new(1, 0)], 2);
|
||||||
|
assert_eq!(map[UVec2::new(0, 1)], 3);
|
||||||
|
assert_eq!(map[UVec2::new(1, 1)], 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_pos_to_index() {
|
||||||
|
let map = TileMap::<u8>::with_default(10, 10, 0);
|
||||||
|
assert_eq!(map.pos_to_index(UVec2::new(0, 0)), 0);
|
||||||
|
assert_eq!(map.pos_to_index(UVec2::new(5, 0)), 5);
|
||||||
|
assert_eq!(map.pos_to_index(UVec2::new(0, 1)), 10);
|
||||||
|
assert_eq!(map.pos_to_index(UVec2::new(3, 2)), 23);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_index_to_pos() {
|
||||||
|
let map = TileMap::<u8>::with_default(10, 10, 0);
|
||||||
|
assert_eq!(map.index_to_pos(0), UVec2::new(0, 0));
|
||||||
|
assert_eq!(map.index_to_pos(5), UVec2::new(5, 0));
|
||||||
|
assert_eq!(map.index_to_pos(10), UVec2::new(0, 1));
|
||||||
|
assert_eq!(map.index_to_pos(23), UVec2::new(3, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_in_bounds() {
|
||||||
|
let map = TileMap::<u8>::with_default(10, 10, 0);
|
||||||
|
assert!(map.in_bounds(UVec2::new(0, 0)));
|
||||||
|
assert!(map.in_bounds(UVec2::new(9, 9)));
|
||||||
|
assert!(!map.in_bounds(UVec2::new(10, 0)));
|
||||||
|
assert!(!map.in_bounds(UVec2::new(0, 10)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_set() {
|
||||||
|
let mut map = TileMap::<u8>::with_default(10, 10, 0);
|
||||||
|
assert_eq!(map.get(UVec2::new(5, 5)), Some(0));
|
||||||
|
|
||||||
|
assert!(map.set(UVec2::new(5, 5), 42));
|
||||||
|
assert_eq!(map.get(UVec2::new(5, 5)), Some(42));
|
||||||
|
|
||||||
|
assert!(!map.set(UVec2::new(10, 10), 99));
|
||||||
|
assert_eq!(map.get(UVec2::new(10, 10)), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_index_operators() {
|
||||||
|
let mut map = TileMap::<u8>::with_default(10, 10, 0);
|
||||||
|
map[UVec2::new(5, 5)] = 42;
|
||||||
|
assert_eq!(map[UVec2::new(5, 5)], 42);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_index_by_usize() {
|
||||||
|
let mut map = TileMap::<u8>::with_default(10, 10, 0);
|
||||||
|
map[23] = 42;
|
||||||
|
assert_eq!(map[23], 42);
|
||||||
|
assert_eq!(map[UVec2::new(3, 2)], 42);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_neighbors_center() {
|
||||||
|
let map = TileMap::<u8>::with_default(10, 10, 0);
|
||||||
|
let neighbors: Vec<_> = map.neighbors(UVec2::new(5, 5)).collect();
|
||||||
|
assert_eq!(neighbors.len(), 4);
|
||||||
|
assert!(neighbors.contains(&UVec2::new(5, 6)));
|
||||||
|
assert!(neighbors.contains(&UVec2::new(6, 5)));
|
||||||
|
assert!(neighbors.contains(&UVec2::new(5, 4)));
|
||||||
|
assert!(neighbors.contains(&UVec2::new(4, 5)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_neighbors_corner() {
|
||||||
|
let map = TileMap::<u8>::with_default(10, 10, 0);
|
||||||
|
let neighbors: Vec<_> = map.neighbors(UVec2::new(0, 0)).collect();
|
||||||
|
assert_eq!(neighbors.len(), 2);
|
||||||
|
assert!(neighbors.contains(&UVec2::new(1, 0)));
|
||||||
|
assert!(neighbors.contains(&UVec2::new(0, 1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_neighbors_edge() {
|
||||||
|
let map = TileMap::<u8>::with_default(10, 10, 0);
|
||||||
|
let neighbors: Vec<_> = map.neighbors(UVec2::new(0, 5)).collect();
|
||||||
|
assert_eq!(neighbors.len(), 3);
|
||||||
|
assert!(neighbors.contains(&UVec2::new(0, 6)));
|
||||||
|
assert!(neighbors.contains(&UVec2::new(1, 5)));
|
||||||
|
assert!(neighbors.contains(&UVec2::new(0, 4)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_on_neighbors() {
|
||||||
|
let map = TileMap::<u8>::with_default(10, 10, 0);
|
||||||
|
let mut count = 0;
|
||||||
|
map.on_neighbors(UVec2::new(5, 5), |_| count += 1);
|
||||||
|
assert_eq!(count, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_on_neighbor_indices() {
|
||||||
|
let map = TileMap::<u8>::with_default(10, 10, 0);
|
||||||
|
let center_idx = map.pos_to_index(UVec2::new(5, 5));
|
||||||
|
let mut count = 0;
|
||||||
|
map.on_neighbor_indices(center_idx, |_| count += 1);
|
||||||
|
assert_eq!(count, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_iter() {
|
||||||
|
let map = TileMap::<u8>::with_default(2, 2, 0);
|
||||||
|
let positions: Vec<_> = map.iter().map(|(pos, _)| pos).collect();
|
||||||
|
assert_eq!(positions.len(), 4);
|
||||||
|
assert!(positions.contains(&UVec2::new(0, 0)));
|
||||||
|
assert!(positions.contains(&UVec2::new(1, 1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_iter_values() {
|
||||||
|
let map = TileMap::<u8>::with_default(2, 2, 42);
|
||||||
|
let values: Vec<_> = map.iter_values().collect();
|
||||||
|
assert_eq!(values, vec![42, 42, 42, 42]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_positions() {
|
||||||
|
let map = TileMap::<u8>::with_default(2, 2, 0);
|
||||||
|
let positions: Vec<_> = map.positions().collect();
|
||||||
|
assert_eq!(positions.len(), 4);
|
||||||
|
assert_eq!(positions[0], UVec2::new(0, 0));
|
||||||
|
assert_eq!(positions[3], UVec2::new(1, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_enumerate() {
|
||||||
|
let mut map = TileMap::<u8>::with_default(2, 2, 0);
|
||||||
|
map[UVec2::new(1, 1)] = 42;
|
||||||
|
let entries: Vec<_> = map.enumerate().collect();
|
||||||
|
assert_eq!(entries.len(), 4);
|
||||||
|
assert_eq!(entries[3], (3, UVec2::new(1, 1), 42));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_generic_u16() {
|
||||||
|
let mut map = TileMap::<u16>::with_default(5, 5, 0);
|
||||||
|
assert_eq!(map[UVec2::new(0, 0)], 0);
|
||||||
|
map[UVec2::new(2, 2)] = 65535;
|
||||||
|
assert_eq!(map[UVec2::new(2, 2)], 65535);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_generic_f32() {
|
||||||
|
let mut map = TileMap::<f32>::with_default(5, 5, 1.5);
|
||||||
|
assert_eq!(map[UVec2::new(0, 0)], 1.5);
|
||||||
|
map[UVec2::new(2, 2)] = 2.7;
|
||||||
|
assert_eq!(map[UVec2::new(2, 2)], 2.7);
|
||||||
|
}
|
||||||
|
}
|
||||||
179
crates/borders-core/src/game/tilemap_changes.rs
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
/// Lightweight change tracking buffer for tile mutations.
|
||||||
|
///
|
||||||
|
/// Stores only the indices of changed tiles, avoiding allocations in the hot path
|
||||||
|
/// by reusing Vec capacity across frames. This enables efficient delta updates
|
||||||
|
/// for GPU rendering and network synchronization.
|
||||||
|
///
|
||||||
|
/// # Design
|
||||||
|
/// - Records tile index changes as they occur
|
||||||
|
/// - Reuses Vec capacity to avoid allocations
|
||||||
|
/// - O(1) push, O(changes) iteration
|
||||||
|
/// - Optional: can be cleared/ignored when tracking not needed
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
/// ```
|
||||||
|
/// use borders_core::game::ChangeBuffer;
|
||||||
|
///
|
||||||
|
/// let mut changes = ChangeBuffer::new();
|
||||||
|
/// changes.push(10);
|
||||||
|
/// changes.push(25);
|
||||||
|
/// assert_eq!(changes.len(), 2);
|
||||||
|
///
|
||||||
|
/// let indices: Vec<_> = changes.drain().collect();
|
||||||
|
/// assert_eq!(indices, vec![10, 25]);
|
||||||
|
/// assert_eq!(changes.len(), 0);
|
||||||
|
/// ```
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ChangeBuffer {
|
||||||
|
changed_indices: Vec<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChangeBuffer {
|
||||||
|
/// Creates a new empty ChangeBuffer.
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self { changed_indices: Vec::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: Vec::with_capacity(capacity) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records a tile index as changed.
|
||||||
|
///
|
||||||
|
/// Does not check for duplicates - the same index can be pushed multiple times.
|
||||||
|
/// Consumers should handle deduplication if needed.
|
||||||
|
#[inline]
|
||||||
|
pub fn push(&mut self, index: usize) {
|
||||||
|
self.changed_indices.push(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 = usize> + '_ {
|
||||||
|
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 = usize> + '_ {
|
||||||
|
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 super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_new() {
|
||||||
|
let buffer = ChangeBuffer::new();
|
||||||
|
assert!(buffer.is_empty());
|
||||||
|
assert_eq!(buffer.len(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_with_capacity() {
|
||||||
|
let buffer = ChangeBuffer::with_capacity(100);
|
||||||
|
assert_eq!(buffer.capacity(), 100);
|
||||||
|
assert!(buffer.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_push_and_drain() {
|
||||||
|
let mut buffer = ChangeBuffer::new();
|
||||||
|
buffer.push(10);
|
||||||
|
buffer.push(25);
|
||||||
|
buffer.push(42);
|
||||||
|
|
||||||
|
assert_eq!(buffer.len(), 3);
|
||||||
|
assert!(buffer.has_changes());
|
||||||
|
|
||||||
|
let changes: Vec<_> = buffer.drain().collect();
|
||||||
|
assert_eq!(changes, vec![10, 25, 42]);
|
||||||
|
assert!(buffer.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_clear() {
|
||||||
|
let mut buffer = ChangeBuffer::new();
|
||||||
|
buffer.push(1);
|
||||||
|
buffer.push(2);
|
||||||
|
buffer.push(3);
|
||||||
|
|
||||||
|
assert_eq!(buffer.len(), 3);
|
||||||
|
buffer.clear();
|
||||||
|
assert_eq!(buffer.len(), 0);
|
||||||
|
assert!(buffer.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_duplicate_indices() {
|
||||||
|
let mut buffer = ChangeBuffer::new();
|
||||||
|
buffer.push(10);
|
||||||
|
buffer.push(10);
|
||||||
|
buffer.push(10);
|
||||||
|
|
||||||
|
assert_eq!(buffer.len(), 3); // Does not deduplicate
|
||||||
|
let changes: Vec<_> = buffer.drain().collect();
|
||||||
|
assert_eq!(changes, vec![10, 10, 10]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_capacity_retained_after_drain() {
|
||||||
|
let mut buffer = ChangeBuffer::with_capacity(100);
|
||||||
|
buffer.push(1);
|
||||||
|
buffer.push(2);
|
||||||
|
|
||||||
|
let initial_capacity = buffer.capacity();
|
||||||
|
let _: Vec<_> = buffer.drain().collect();
|
||||||
|
|
||||||
|
// Capacity should be retained after drain
|
||||||
|
assert!(buffer.capacity() >= initial_capacity);
|
||||||
|
assert!(buffer.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
34
crates/borders-core/src/game/turn.rs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
use bevy_ecs::prelude::*;
|
||||||
|
|
||||||
|
use crate::networking::Turn;
|
||||||
|
|
||||||
|
/// 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 turn: Turn,
|
||||||
|
/// Flag indicating if this turn has been processed by gameplay systems
|
||||||
|
/// Set to false when turn arrives, set to true after all systems run
|
||||||
|
pub processed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CurrentTurn {
|
||||||
|
pub fn new(turn: Turn) -> Self {
|
||||||
|
Self { turn, processed: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark turn as processed
|
||||||
|
pub fn mark_processed(&mut self) {
|
||||||
|
self.processed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if turn is ready to process (not yet processed)
|
||||||
|
pub fn is_ready(&self) -> bool {
|
||||||
|
!self.processed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run condition: only run when a turn is ready to process
|
||||||
|
pub fn turn_is_ready(current_turn: Option<Res<CurrentTurn>>) -> bool {
|
||||||
|
current_turn.is_some_and(|ct| ct.is_ready())
|
||||||
|
}
|
||||||
66
crates/borders-core/src/game/utils.rs
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
/// Utility functions for tile operations
|
||||||
|
///
|
||||||
|
/// Call a closure for each neighbor of a tile
|
||||||
|
///
|
||||||
|
/// Handles boundary checks for the 4-connected grid (up, down, left, right)
|
||||||
|
pub fn for_each_neighbor<F>(tile: usize, width: u32, height: u32, mut closure: F)
|
||||||
|
where
|
||||||
|
F: FnMut(usize),
|
||||||
|
{
|
||||||
|
let x = tile as u32 % width;
|
||||||
|
let y = tile as u32 / width;
|
||||||
|
|
||||||
|
// Left neighbor
|
||||||
|
if x > 0 {
|
||||||
|
closure(tile - 1);
|
||||||
|
}
|
||||||
|
// Right neighbor
|
||||||
|
if x < width - 1 {
|
||||||
|
closure(tile + 1);
|
||||||
|
}
|
||||||
|
// Top neighbor
|
||||||
|
if y > 0 {
|
||||||
|
closure(tile - width as usize);
|
||||||
|
}
|
||||||
|
// Bottom neighbor
|
||||||
|
if y < height - 1 {
|
||||||
|
closure(tile + width as usize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a tile has at least one neighbor matching the given predicate
|
||||||
|
pub fn has_neighbor_owned_by(tile: usize, width: u32, height: u32, is_owner: impl Fn(usize) -> bool) -> bool {
|
||||||
|
let mut has_neighbor = false;
|
||||||
|
for_each_neighbor(tile, width, height, |neighbor| {
|
||||||
|
if is_owner(neighbor) {
|
||||||
|
has_neighbor = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
has_neighbor
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_corner_tile_neighbors() {
|
||||||
|
let mut neighbors = Vec::new();
|
||||||
|
for_each_neighbor(0, 10, 10, |n| neighbors.push(n));
|
||||||
|
assert_eq!(neighbors, vec![1, 10]); // Right and down only
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_edge_tile_neighbors() {
|
||||||
|
let mut neighbors = Vec::new();
|
||||||
|
for_each_neighbor(5, 10, 10, |n| neighbors.push(n));
|
||||||
|
assert_eq!(neighbors, vec![4, 6, 15]); // Left, right, and down
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_center_tile_neighbors() {
|
||||||
|
let mut neighbors = Vec::new();
|
||||||
|
for_each_neighbor(55, 10, 10, |n| neighbors.push(n));
|
||||||
|
assert_eq!(neighbors, vec![54, 56, 45, 65]); // All 4 directions
|
||||||
|
}
|
||||||
|
}
|
||||||
18
crates/borders-core/src/lib.rs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
pub mod app;
|
||||||
|
pub mod build_info;
|
||||||
|
pub mod constants;
|
||||||
|
pub mod game;
|
||||||
|
pub mod networking;
|
||||||
|
pub mod platform;
|
||||||
|
pub mod plugin;
|
||||||
|
pub mod telemetry;
|
||||||
|
pub mod time;
|
||||||
|
#[cfg(feature = "ui")]
|
||||||
|
pub mod ui;
|
||||||
|
|
||||||
|
pub use constants::*;
|
||||||
|
pub use game::*;
|
||||||
|
pub use networking::*;
|
||||||
|
pub use plugin::*;
|
||||||
|
#[cfg(feature = "ui")]
|
||||||
|
pub use ui::{LastAttacksDigest, LastLeaderboardDigest, NationHighlightState, input};
|
||||||
103
crates/borders-core/src/networking/client.rs
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
use crate::networking::{GameView, Intent, IntentEvent, ProcessTurnEvent, protocol::NetMessage};
|
||||||
|
use bevy_ecs::prelude::*;
|
||||||
|
use flume::{Receiver, Sender};
|
||||||
|
use tracing::{debug, error, info, warn};
|
||||||
|
|
||||||
|
#[derive(Resource)]
|
||||||
|
pub struct ClientConnection {
|
||||||
|
pub intent_tx: Sender<Intent>,
|
||||||
|
pub game_view_rx: Receiver<GameView>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Resource)]
|
||||||
|
pub struct RemoteClientConnection {
|
||||||
|
pub intent_tx: Sender<NetMessage>,
|
||||||
|
pub net_message_rx: Receiver<NetMessage>,
|
||||||
|
pub player_id: Option<u16>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn receive_game_view_system(client: Res<ClientConnection>, mut game_view: ResMut<GameView>) {
|
||||||
|
while let Ok(new_view) = client.game_view_rx.try_recv() {
|
||||||
|
*game_view = new_view;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn send_intent_system(mut intent_events: MessageReader<IntentEvent>, client: Res<ClientConnection>) {
|
||||||
|
for event in intent_events.read() {
|
||||||
|
debug!("Sending intent to local server: {:?}", event.0);
|
||||||
|
if let Err(e) = client.intent_tx.try_send(event.0.clone()) {
|
||||||
|
error!("Failed to send intent: {:?}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// System for remote clients to handle NetMessage protocol
|
||||||
|
pub fn receive_net_message_system(remote_client: Res<RemoteClientConnection>, mut process_turn_writer: MessageWriter<ProcessTurnEvent>, mut spawn_config_writer: MessageWriter<crate::networking::SpawnConfigEvent>) {
|
||||||
|
let mut message_count = 0;
|
||||||
|
while let Ok(message) = remote_client.net_message_rx.try_recv() {
|
||||||
|
message_count += 1;
|
||||||
|
match message {
|
||||||
|
NetMessage::ServerConfig { player_id } => {
|
||||||
|
info!("Received server config: player_id={}", player_id);
|
||||||
|
// Store player_id in the resource (would need to make it mutable)
|
||||||
|
}
|
||||||
|
NetMessage::Turn { turn, intents } => {
|
||||||
|
info!("Received turn {} with {} intents", turn, intents.len());
|
||||||
|
// Convert to ProcessTurnEvent
|
||||||
|
let turn_event = ProcessTurnEvent(crate::networking::Turn { turn_number: turn, intents });
|
||||||
|
process_turn_writer.write(turn_event);
|
||||||
|
}
|
||||||
|
NetMessage::Intent(_) => {
|
||||||
|
warn!("Received Intent message on client side");
|
||||||
|
}
|
||||||
|
NetMessage::SpawnConfiguration { spawns } => {
|
||||||
|
info!("Received spawn configuration with {} spawns", spawns.len());
|
||||||
|
spawn_config_writer.write(crate::networking::SpawnConfigEvent(spawns));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if message_count > 0 {
|
||||||
|
let _span = tracing::debug_span!("receive_net_messages", message_count).entered();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// System for remote clients to send intents as NetMessage
|
||||||
|
pub fn send_net_intent_system(mut intent_events: MessageReader<IntentEvent>, remote_client: Res<RemoteClientConnection>) {
|
||||||
|
let mut intent_count = 0;
|
||||||
|
for event in intent_events.read() {
|
||||||
|
intent_count += 1;
|
||||||
|
let net_message = NetMessage::Intent(event.0.clone());
|
||||||
|
if let Err(e) = remote_client.intent_tx.try_send(net_message) {
|
||||||
|
error!("Failed to send net intent: {:?}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if intent_count > 0 {
|
||||||
|
let _span = tracing::debug_span!("send_net_intents", intent_count).entered();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// System to handle spawn configuration updates from server
|
||||||
|
/// Updates local SpawnManager with remote player spawn positions
|
||||||
|
pub fn handle_spawn_config_system(mut spawn_config_events: MessageReader<crate::networking::SpawnConfigEvent>, mut spawn_manager: Option<ResMut<crate::game::SpawnManager>>, game_instance: Option<Res<crate::game::GameInstance>>) {
|
||||||
|
for event in spawn_config_events.read() {
|
||||||
|
let Some(ref mut spawn_mgr) = spawn_manager else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(ref game_inst) = game_instance else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update player spawns from server
|
||||||
|
spawn_mgr.player_spawns.clear();
|
||||||
|
for (&player_id, &tile_index) in &event.0 {
|
||||||
|
spawn_mgr.player_spawns.push((player_id as usize, tile_index as usize));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recalculate bot spawns based on updated player positions
|
||||||
|
spawn_mgr.current_bot_spawns = game_inst.bot_manager.recalculate_spawns_with_players(spawn_mgr.initial_bot_spawns.clone(), &spawn_mgr.player_spawns, &game_inst.territory_manager, spawn_mgr.rng_seed);
|
||||||
|
|
||||||
|
info!("Updated spawn manager with {} player spawns from server", spawn_mgr.player_spawns.len());
|
||||||
|
}
|
||||||
|
}
|
||||||
189
crates/borders-core/src/networking/coordinator.rs
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
use crate::time::Time;
|
||||||
|
use bevy_ecs::prelude::*;
|
||||||
|
use tracing::{debug, info, trace, warn};
|
||||||
|
|
||||||
|
use crate::constants::{SPAWN_TIMEOUT_SECS, TICK_INTERVAL};
|
||||||
|
use crate::networking::{Intent, ProcessTurnEvent, Turn};
|
||||||
|
use flume::{Receiver, Sender};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{
|
||||||
|
Arc,
|
||||||
|
atomic::{AtomicBool, Ordering},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Resource)]
|
||||||
|
pub struct IntentReceiver {
|
||||||
|
pub intent_rx: Receiver<Intent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn timeout duration for local games (milliseconds)
|
||||||
|
const SPAWN_TIMEOUT_MS: u64 = (SPAWN_TIMEOUT_SECS * 1000.0) as u64;
|
||||||
|
|
||||||
|
/// Resource to track turn generation state
|
||||||
|
#[derive(Resource)]
|
||||||
|
pub struct TurnGenerator {
|
||||||
|
pub turn_number: u64,
|
||||||
|
pub accumulated_time: f64, // milliseconds
|
||||||
|
pub turn_tx: Sender<Turn>,
|
||||||
|
// Spawn phase tracking
|
||||||
|
pub spawn_config: HashMap<u16, u32>,
|
||||||
|
pub spawn_timeout_accumulated: Option<f64>, // milliseconds since first spawn
|
||||||
|
pub game_started: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// System to generate turns using Bevy's Update loop
|
||||||
|
pub fn generate_turns_system(mut generator: ResMut<TurnGenerator>, server_handle: Res<LocalTurnServerHandle>, intent_receiver: Res<IntentReceiver>, mut spawn_manager: Option<ResMut<crate::game::SpawnManager>>, game_instance: Option<Res<crate::game::GameInstance>>, time: Res<Time>) {
|
||||||
|
use crate::game::GameAction;
|
||||||
|
|
||||||
|
let _span = tracing::trace_span!("generate_turns").entered();
|
||||||
|
if !server_handle.is_running() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let is_paused = server_handle.paused.load(Ordering::SeqCst);
|
||||||
|
|
||||||
|
// During spawn phase (paused), handle SetSpawn intents and track timeout
|
||||||
|
if is_paused {
|
||||||
|
// Collect SetSpawn intents during spawn phase
|
||||||
|
while let Ok(intent) = intent_receiver.intent_rx.try_recv() {
|
||||||
|
match intent {
|
||||||
|
Intent::SetSpawn { player_id, tile_index } => {
|
||||||
|
debug!("Player {} set spawn at tile {}", player_id, tile_index);
|
||||||
|
generator.spawn_config.insert(player_id, tile_index);
|
||||||
|
|
||||||
|
// Update SpawnManager to recalculate bot positions (two-pass spawn system)
|
||||||
|
if let (Some(ref mut spawn_mgr), Some(game_inst)) = (spawn_manager.as_mut(), game_instance.as_ref()) {
|
||||||
|
spawn_mgr.update_player_spawn(player_id as usize, tile_index as usize, &game_inst.bot_manager, &game_inst.territory_manager);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start timeout on first spawn
|
||||||
|
if generator.spawn_timeout_accumulated.is_none() {
|
||||||
|
generator.spawn_timeout_accumulated = Some(0.0);
|
||||||
|
debug!("Spawn timeout started ({}ms)", SPAWN_TIMEOUT_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Intent::Action(_) => {
|
||||||
|
// Action intents during spawn phase are ignored
|
||||||
|
warn!("Received Action intent during spawn phase - ignoring");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update spawn timeout if started
|
||||||
|
if let Some(ref mut accumulated) = generator.spawn_timeout_accumulated {
|
||||||
|
*accumulated += time.delta().as_secs_f64() * 1000.0;
|
||||||
|
|
||||||
|
// Check if timeout expired
|
||||||
|
if *accumulated >= SPAWN_TIMEOUT_MS as f64 {
|
||||||
|
debug!("Spawn timeout expired - starting game");
|
||||||
|
|
||||||
|
// Create Turn(0) with all spawn actions
|
||||||
|
let spawn_intents: Vec<Intent> = generator.spawn_config.iter().map(|(&player_id, &tile_index)| Intent::Action(GameAction::Spawn { player_id, tile_index })).collect();
|
||||||
|
|
||||||
|
let start_turn = Turn { turn_number: 0, intents: spawn_intents.clone() };
|
||||||
|
|
||||||
|
info!("Sending Turn(0) with {} spawns", spawn_intents.len());
|
||||||
|
if let Err(e) = generator.turn_tx.send(start_turn) {
|
||||||
|
warn!("Failed to send Turn(0): {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark game as started and clear spawn phase
|
||||||
|
generator.game_started = true;
|
||||||
|
generator.spawn_config.clear();
|
||||||
|
generator.spawn_timeout_accumulated = None;
|
||||||
|
generator.turn_number = 1; // Next turn will be turn 1
|
||||||
|
generator.accumulated_time = 0.0; // Reset accumulated time for clean turn timing
|
||||||
|
|
||||||
|
server_handle.resume();
|
||||||
|
info!("Spawn phase complete - game started, server resumed, accumulated_time reset, next turn will be Turn 1");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal turn generation (after game has started)
|
||||||
|
if !generator.game_started {
|
||||||
|
return; // Wait for spawn phase to complete
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accumulate time (delta is in seconds, convert to milliseconds)
|
||||||
|
let delta_ms = time.delta().as_secs_f64() * 1000.0;
|
||||||
|
generator.accumulated_time += delta_ms;
|
||||||
|
|
||||||
|
// Only generate turn if enough time has passed
|
||||||
|
if generator.accumulated_time < TICK_INTERVAL as f64 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset accumulated time
|
||||||
|
generator.accumulated_time -= TICK_INTERVAL as f64;
|
||||||
|
|
||||||
|
// Collect all pending Action intents (ignore SetSpawn after game starts)
|
||||||
|
let mut action_intents = Vec::new();
|
||||||
|
while let Ok(intent) = intent_receiver.intent_rx.try_recv() {
|
||||||
|
match intent {
|
||||||
|
Intent::Action(action) => {
|
||||||
|
action_intents.push(Intent::Action(action));
|
||||||
|
}
|
||||||
|
Intent::SetSpawn { .. } => {
|
||||||
|
// SetSpawn intents after game start are ignored
|
||||||
|
warn!("Received SetSpawn intent after game started - ignoring");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create turn
|
||||||
|
let turn = Turn { turn_number: generator.turn_number, intents: action_intents.clone() };
|
||||||
|
|
||||||
|
// Send turn
|
||||||
|
if let Err(e) = generator.turn_tx.send(turn) {
|
||||||
|
warn!("Failed to send turn {}: {}", generator.turn_number, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
generator.turn_number += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// System to poll for turns from the local server and emit ProcessTurnEvent
|
||||||
|
/// This replaces the old FixedUpdate create_turns system
|
||||||
|
pub fn poll_turns_system(turn_receiver: Res<TurnReceiver>, mut process_turn_writer: MessageWriter<ProcessTurnEvent>) {
|
||||||
|
let _span = tracing::trace_span!("poll_turns").entered();
|
||||||
|
while let Ok(turn) = turn_receiver.turn_rx.try_recv() {
|
||||||
|
trace!("Received Turn {} from channel", turn.turn_number);
|
||||||
|
process_turn_writer.write(ProcessTurnEvent(turn));
|
||||||
|
}
|
||||||
|
}
|
||||||
98
crates/borders-core/src/networking/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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
119
crates/borders-core/src/networking/mod.rs
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
pub mod client;
|
||||||
|
pub mod coordinator;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub mod dns;
|
||||||
|
pub mod network;
|
||||||
|
pub mod protocol;
|
||||||
|
pub mod server;
|
||||||
|
|
||||||
|
// Re-export coordinator types for easier access
|
||||||
|
pub use coordinator::{IntentReceiver, LocalTurnServerHandle, TurnGenerator, TurnReceiver, generate_turns_system, poll_turns_system};
|
||||||
|
|
||||||
|
use bevy_ecs::prelude::{Message, Resource};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::game::action::GameAction;
|
||||||
|
|
||||||
|
#[derive(Message, Debug, Clone, bincode::Encode, bincode::Decode)]
|
||||||
|
pub struct IntentEvent(pub Intent);
|
||||||
|
|
||||||
|
#[derive(Message, Debug, Clone, bincode::Encode, bincode::Decode)]
|
||||||
|
pub struct ProcessTurnEvent(pub Turn);
|
||||||
|
|
||||||
|
/// Event containing spawn configuration update from server (multiplayer)
|
||||||
|
#[derive(Message, Debug, Clone)]
|
||||||
|
pub struct SpawnConfigEvent(pub std::collections::HashMap<u16, u32>);
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, bincode::Encode, bincode::Decode)]
|
||||||
|
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
|
||||||
|
SetSpawn { player_id: u16, tile_index: u32 },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, bincode::Encode, bincode::Decode)]
|
||||||
|
pub struct Turn {
|
||||||
|
pub turn_number: u64,
|
||||||
|
pub intents: Vec<Intent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read-only snapshot of game state for rendering - DETERMINISTIC, SHARED
|
||||||
|
///
|
||||||
|
/// **Important: This is GLOBAL/SHARED state identical across all clients!**
|
||||||
|
///
|
||||||
|
/// This is a read-only snapshot of GameInstance state, updated after each turn.
|
||||||
|
/// It provides:
|
||||||
|
/// - Safe, immutable access to game state for rendering and input systems
|
||||||
|
/// - Serializable format for network transmission
|
||||||
|
/// - Same view for all clients (server, players, spectators)
|
||||||
|
///
|
||||||
|
/// Systems should prefer using GameView over direct GameInstance access
|
||||||
|
/// to maintain clean separation between game logic and rendering/input.
|
||||||
|
#[derive(Resource, Default, Debug, Clone, Serialize, Deserialize, bincode::Encode, bincode::Decode)]
|
||||||
|
pub struct GameView {
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
/// Owner of each tile. Uses Arc for zero-copy sharing with rendering.
|
||||||
|
pub territories: Arc<[u16]>,
|
||||||
|
pub players: Vec<PlayerView>,
|
||||||
|
pub turn_number: u64,
|
||||||
|
/// Total number of conquerable (non-water) tiles on the map.
|
||||||
|
/// Cached for performance - calculated once at initialization.
|
||||||
|
pub total_land_tiles: u32,
|
||||||
|
/// Indices of tiles that changed ownership this turn (from TerritoryManager's ChangeBuffer).
|
||||||
|
/// Used for efficient delta rendering without full map scans.
|
||||||
|
pub changed_tiles: Vec<usize>,
|
||||||
|
/// Active ships on the map
|
||||||
|
pub ships: Vec<ShipView>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GameView {
|
||||||
|
/// Get the owner of a specific tile
|
||||||
|
pub fn get_owner(&self, tile_index: usize) -> u16 {
|
||||||
|
self.territories.get(tile_index).copied().unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a player by ID
|
||||||
|
pub fn get_player(&self, player_id: u16) -> Option<&PlayerView> {
|
||||||
|
self.players.iter().find(|p| p.id == player_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find any tile owned by a specific player (useful for camera centering)
|
||||||
|
pub fn find_tile_owned_by(&self, player_id: u16) -> Option<usize> {
|
||||||
|
self.territories.iter().position(|&owner| owner == player_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Default, Debug, Serialize, Deserialize, bincode::Encode, bincode::Decode)]
|
||||||
|
pub struct PlayerView {
|
||||||
|
pub id: u16,
|
||||||
|
pub color: [f32; 4],
|
||||||
|
pub name: String,
|
||||||
|
pub tile_count: u32,
|
||||||
|
pub troops: u32,
|
||||||
|
pub is_alive: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize, bincode::Encode, bincode::Decode)]
|
||||||
|
pub struct ShipView {
|
||||||
|
pub id: u32,
|
||||||
|
pub owner_id: u16,
|
||||||
|
pub current_tile: u32,
|
||||||
|
pub target_tile: u32,
|
||||||
|
pub troops: u32,
|
||||||
|
pub path_progress: u32,
|
||||||
|
pub ticks_until_move: u32,
|
||||||
|
pub path: Vec<u32>,
|
||||||
|
}
|
||||||
244
crates/borders-core/src/networking/network.rs
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
use crate::networking::{GameView, Intent};
|
||||||
|
use anyhow::Result;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use flume::{Receiver, Sender};
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use crate::networking::protocol::NetMessage;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use url::Url;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use web_transport::{ClientBuilder, RecvStream, SendStream};
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
|
||||||
|
pub trait Transceiver: Send + Sync {
|
||||||
|
async fn send(&mut self, intent: Intent) -> Result<()>;
|
||||||
|
async fn receive(&mut self) -> Result<GameView>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
|
||||||
|
pub trait ServerTransceiver: Send + Sync {
|
||||||
|
async fn send(&mut self, game_view: GameView) -> Result<()>;
|
||||||
|
async fn receive(&mut self) -> Result<Intent>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub struct RemoteSender {
|
||||||
|
send_stream: SendStream,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub struct RemoteReceiver {
|
||||||
|
recv_stream: RecvStream,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RemoteConnection;
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
impl RemoteConnection {
|
||||||
|
/// Establishes a connection to a remote server and returns separate sender/receiver
|
||||||
|
pub async fn connect(server_address: &str) -> Result<(RemoteSender, RemoteReceiver)> {
|
||||||
|
let url = Url::parse(server_address)?;
|
||||||
|
let mut session = {
|
||||||
|
let client = ClientBuilder::new().with_system_roots()?;
|
||||||
|
client.connect(url).await?
|
||||||
|
};
|
||||||
|
|
||||||
|
let (send_stream, recv_stream) = session.open_bi().await?;
|
||||||
|
|
||||||
|
let sender = RemoteSender { send_stream };
|
||||||
|
let receiver = RemoteReceiver { recv_stream };
|
||||||
|
|
||||||
|
Ok((sender, receiver))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
impl RemoteSender {
|
||||||
|
pub async fn send(&mut self, intent: Intent) -> Result<()> {
|
||||||
|
let config = bincode::config::standard();
|
||||||
|
let bytes = bincode::encode_to_vec(intent, config)?;
|
||||||
|
self.send_bytes(&bytes).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn send_net_message(&mut self, message: NetMessage) -> Result<()> {
|
||||||
|
let config = bincode::config::standard();
|
||||||
|
let bytes = bincode::encode_to_vec(message, config)?;
|
||||||
|
self.send_bytes(&bytes).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_bytes(&mut self, data: &[u8]) -> Result<()> {
|
||||||
|
let len = data.len() as u64;
|
||||||
|
self.send_stream.write(&len.to_be_bytes()).await?;
|
||||||
|
self.send_stream.write(data).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
impl RemoteReceiver {
|
||||||
|
pub async fn receive(&mut self) -> Result<GameView> {
|
||||||
|
let bytes = self.receive_bytes().await?;
|
||||||
|
let config = bincode::config::standard();
|
||||||
|
let (game_view, _) = bincode::decode_from_slice(&bytes, config)?;
|
||||||
|
Ok(game_view)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn receive_net_message(&mut self) -> Result<NetMessage> {
|
||||||
|
let bytes = self.receive_bytes().await?;
|
||||||
|
let config = bincode::config::standard();
|
||||||
|
let (net_message, _) = bincode::decode_from_slice(&bytes, config)?;
|
||||||
|
Ok(net_message)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn receive_bytes(&mut self) -> Result<Vec<u8>> {
|
||||||
|
// Read length prefix (8 bytes)
|
||||||
|
let mut len_bytes = Vec::new();
|
||||||
|
while len_bytes.len() < 8 {
|
||||||
|
let remaining = 8 - len_bytes.len();
|
||||||
|
if let Some(chunk) = self.recv_stream.read(remaining).await? {
|
||||||
|
len_bytes.extend_from_slice(&chunk);
|
||||||
|
} else {
|
||||||
|
anyhow::bail!("Stream closed before reading length prefix");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let len = u64::from_be_bytes(len_bytes[0..8].try_into()?) as usize;
|
||||||
|
|
||||||
|
// Read message data
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
while buffer.len() < len {
|
||||||
|
let remaining = len - buffer.len();
|
||||||
|
if let Some(chunk) = self.recv_stream.read(remaining).await? {
|
||||||
|
buffer.extend_from_slice(&chunk);
|
||||||
|
} else {
|
||||||
|
anyhow::bail!("Stream closed before reading full message");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(buffer[0..len].to_vec())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub struct RemoteServerSender {
|
||||||
|
send_stream: SendStream,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub struct RemoteServerReceiver {
|
||||||
|
recv_stream: RecvStream,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RemoteConnectionMirror;
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
impl RemoteConnectionMirror {
|
||||||
|
/// Creates server-side sender/receiver from an accepted connection stream
|
||||||
|
pub fn from_streams(send_stream: SendStream, recv_stream: RecvStream) -> (RemoteServerSender, RemoteServerReceiver) {
|
||||||
|
let sender = RemoteServerSender { send_stream };
|
||||||
|
let receiver = RemoteServerReceiver { recv_stream };
|
||||||
|
(sender, receiver)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
impl RemoteServerSender {
|
||||||
|
pub async fn send(&mut self, game_view: GameView) -> Result<()> {
|
||||||
|
let config = bincode::config::standard();
|
||||||
|
let bytes = bincode::encode_to_vec(game_view, config)?;
|
||||||
|
self.send_bytes(&bytes).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_bytes(&mut self, data: &[u8]) -> Result<()> {
|
||||||
|
let len = data.len() as u64;
|
||||||
|
self.send_stream.write(&len.to_be_bytes()).await?;
|
||||||
|
self.send_stream.write(data).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
impl RemoteServerReceiver {
|
||||||
|
pub async fn receive(&mut self) -> Result<Intent> {
|
||||||
|
let bytes = self.receive_bytes().await?;
|
||||||
|
let config = bincode::config::standard();
|
||||||
|
let (intent, _) = bincode::decode_from_slice(&bytes, config)?;
|
||||||
|
Ok(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn receive_bytes(&mut self) -> Result<Vec<u8>> {
|
||||||
|
// Read length prefix (8 bytes)
|
||||||
|
let mut len_bytes = Vec::new();
|
||||||
|
while len_bytes.len() < 8 {
|
||||||
|
let remaining = 8 - len_bytes.len();
|
||||||
|
if let Some(chunk) = self.recv_stream.read(remaining).await? {
|
||||||
|
len_bytes.extend_from_slice(&chunk);
|
||||||
|
} else {
|
||||||
|
anyhow::bail!("Stream closed before reading length prefix");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let len = u64::from_be_bytes(len_bytes[0..8].try_into()?) as usize;
|
||||||
|
|
||||||
|
// Read message data
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
while buffer.len() < len {
|
||||||
|
let remaining = len - buffer.len();
|
||||||
|
if let Some(chunk) = self.recv_stream.read(remaining).await? {
|
||||||
|
buffer.extend_from_slice(&chunk);
|
||||||
|
} else {
|
||||||
|
anyhow::bail!("Stream closed before reading full message");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(buffer[0..len].to_vec())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct LocalConnection {
|
||||||
|
pub intent_tx: Sender<Intent>,
|
||||||
|
pub game_view_rx: Receiver<GameView>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LocalConnection {
|
||||||
|
pub fn new() -> (Self, LocalConnectionMirror) {
|
||||||
|
let (intent_tx, intent_rx) = flume::unbounded();
|
||||||
|
let (game_view_tx, game_view_rx) = flume::unbounded();
|
||||||
|
|
||||||
|
let client_end = Self { intent_tx, game_view_rx };
|
||||||
|
|
||||||
|
let server_end = LocalConnectionMirror { intent_rx, game_view_tx };
|
||||||
|
|
||||||
|
(client_end, server_end)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Transceiver for LocalConnection {
|
||||||
|
async fn send(&mut self, intent: Intent) -> Result<()> {
|
||||||
|
self.intent_tx.send_async(intent).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn receive(&mut self) -> Result<GameView> {
|
||||||
|
let game_view = self.game_view_rx.recv_async().await?;
|
||||||
|
Ok(game_view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ServerTransceiver for LocalConnectionMirror {
|
||||||
|
async fn send(&mut self, game_view: GameView) -> Result<()> {
|
||||||
|
self.game_view_tx.send_async(game_view).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn receive(&mut self) -> Result<Intent> {
|
||||||
|
let intent = self.intent_rx.recv_async().await?;
|
||||||
|
Ok(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct LocalConnectionMirror {
|
||||||
|
pub intent_rx: Receiver<Intent>,
|
||||||
|
pub game_view_tx: Sender<GameView>,
|
||||||
|
}
|
||||||
23
crates/borders-core/src/networking/protocol.rs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
//! Network protocol for multiplayer client-server communication
|
||||||
|
|
||||||
|
use crate::networking::Intent;
|
||||||
|
use bincode::{Decode, Encode};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
/// Network message protocol for client-server communication
|
||||||
|
#[derive(Encode, Decode, Debug, Clone)]
|
||||||
|
pub enum NetMessage {
|
||||||
|
/// Server assigns player ID to client
|
||||||
|
ServerConfig { player_id: u16 },
|
||||||
|
/// Client sends intent to server
|
||||||
|
Intent(Intent),
|
||||||
|
/// Server broadcasts turn to all clients
|
||||||
|
Turn { turn: u64, intents: Vec<Intent> },
|
||||||
|
/// Server broadcasts current spawn configuration during spawn phase
|
||||||
|
/// Maps player_id -> tile_index for all players who have chosen spawns
|
||||||
|
SpawnConfiguration { spawns: HashMap<u16, u32> },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared constants across all binaries for deterministic behavior
|
||||||
|
pub const NETWORK_SEED: u64 = 0xC0FFEE;
|
||||||
|
pub const TICK_MS: u64 = 100;
|
||||||
58
crates/borders-core/src/networking/server/broadcast.rs
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
use crate::game::GameInstance;
|
||||||
|
use crate::networking::{GameView, Intent, IntentEvent, PlayerView, protocol::NetMessage};
|
||||||
|
use bevy_ecs::prelude::*;
|
||||||
|
use flume::Sender;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
#[derive(Resource)]
|
||||||
|
pub struct ServerChannels {
|
||||||
|
pub broadcast_tx: Sender<GameView>,
|
||||||
|
pub net_message_tx: Sender<NetMessage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn broadcast_game_state_system(server_channels: Res<ServerChannels>, mut game: ResMut<GameInstance>) {
|
||||||
|
let game_view = create_game_view(&mut game);
|
||||||
|
let _ = server_channels.broadcast_tx.try_send(game_view);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// System to broadcast turns to all connected clients
|
||||||
|
pub fn broadcast_turn_system(server_channels: Res<ServerChannels>, mut turn_events: MessageReader<crate::networking::ProcessTurnEvent>) {
|
||||||
|
for event in turn_events.read() {
|
||||||
|
let turn_message = NetMessage::Turn { turn: event.0.turn_number, intents: event.0.intents.clone() };
|
||||||
|
let _ = server_channels.net_message_tx.try_send(turn_message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resource to track spawn configurations during spawn phase
|
||||||
|
#[derive(Resource, Default)]
|
||||||
|
pub struct SpawnConfigTracker {
|
||||||
|
pub spawns: HashMap<u16, u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// System to broadcast spawn configuration when players set their spawn
|
||||||
|
/// Only runs during multiplayer server mode
|
||||||
|
pub fn broadcast_spawn_config_system(mut intent_events: MessageReader<IntentEvent>, mut spawn_tracker: ResMut<SpawnConfigTracker>, server_channels: Res<ServerChannels>) {
|
||||||
|
for event in intent_events.read() {
|
||||||
|
if let Intent::SetSpawn { player_id, tile_index } = event.0 {
|
||||||
|
// Track this spawn
|
||||||
|
spawn_tracker.spawns.insert(player_id, tile_index);
|
||||||
|
|
||||||
|
// Broadcast updated spawn configuration to all clients
|
||||||
|
let spawn_message = NetMessage::SpawnConfiguration { spawns: spawn_tracker.spawns.clone() };
|
||||||
|
let _ = server_channels.net_message_tx.try_send(spawn_message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_game_view(game: &mut GameInstance) -> GameView {
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
let total_land_tiles = game.territory_manager.as_slice().iter().filter(|ownership| !ownership.is_water()).count() as u32;
|
||||||
|
|
||||||
|
let changed_tiles: Vec<usize> = game.territory_manager.drain_changes().collect();
|
||||||
|
|
||||||
|
// Convert ships to ShipView format
|
||||||
|
let ships = game.ship_manager.get_ships().map(|ship| crate::networking::ShipView { id: ship.id, owner_id: ship.owner_id, current_tile: ship.get_current_tile() as u32, target_tile: ship.target_tile as u32, troops: ship.troops, path_progress: ship.current_path_index as u32, ticks_until_move: ship.ticks_per_tile.saturating_sub(ship.ticks_since_move), path: ship.path.iter().map(|&tile| tile as u32).collect() }).collect();
|
||||||
|
|
||||||
|
GameView { width: game.territory_manager.width(), height: game.territory_manager.height(), territories: Arc::from(game.territory_manager.to_u16_vec().as_slice()), turn_number: game.turn_number, total_land_tiles, changed_tiles, players: game.player_manager.get_players().iter().map(|p| PlayerView { id: p.id as u16, color: p.color.to_rgba(), name: p.name.clone(), tile_count: p.get_territory_size() as u32, troops: p.get_troops() as u32, is_alive: p.is_alive() }).collect(), ships }
|
||||||
|
}
|
||||||
208
crates/borders-core/src/networking/server/connection_handler.rs
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use crate::networking::{Intent, protocol::NetMessage, server::registry::ServerRegistry};
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use anyhow::Result;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use flume::Sender;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use std::sync::Arc;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use tracing::{error, info, instrument, warn};
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use web_transport::quinn::{RecvStream, SendStream, ServerBuilder};
|
||||||
|
|
||||||
|
/// Handle a single client connection over WebTransport
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
#[instrument(skip_all)]
|
||||||
|
pub async fn handle_client_connection(mut send_stream: SendStream, mut recv_stream: RecvStream, intent_tx: Sender<Intent>, registry: Arc<RwLock<ServerRegistry>>) -> Result<()> {
|
||||||
|
info!("New client connected, starting message handling");
|
||||||
|
|
||||||
|
// Create a per-client channel for receiving broadcast messages
|
||||||
|
let (client_tx, client_rx) = flume::unbounded::<NetMessage>();
|
||||||
|
|
||||||
|
// Register this client with the server registry and get assigned player ID
|
||||||
|
let player_id = { registry.write().await.add_client(client_tx) };
|
||||||
|
info!(player_id = player_id, "Client registered");
|
||||||
|
|
||||||
|
// Send initial server config
|
||||||
|
let server_config = NetMessage::ServerConfig { player_id };
|
||||||
|
let config_bytes = bincode::encode_to_vec(server_config, bincode::config::standard())?;
|
||||||
|
let len_bytes = (config_bytes.len() as u64).to_be_bytes();
|
||||||
|
|
||||||
|
// Send length prefix
|
||||||
|
let mut written = 0;
|
||||||
|
while written < len_bytes.len() {
|
||||||
|
let bytes_written = send_stream.write(&len_bytes[written..]).await?;
|
||||||
|
written += bytes_written;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send config bytes
|
||||||
|
let mut written = 0;
|
||||||
|
while written < config_bytes.len() {
|
||||||
|
let bytes_written = send_stream.write(&config_bytes[written..]).await?;
|
||||||
|
written += bytes_written;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn task to handle incoming intents from this client
|
||||||
|
let intent_tx_clone = intent_tx.clone();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
// Read length prefix (8 bytes)
|
||||||
|
let mut len_bytes = Vec::new();
|
||||||
|
while len_bytes.len() < 8 {
|
||||||
|
let remaining = 8 - len_bytes.len();
|
||||||
|
if let Ok(maybe_chunk) = recv_stream.read_chunk(remaining, true).await {
|
||||||
|
if let Some(chunk) = maybe_chunk {
|
||||||
|
len_bytes.extend_from_slice(&chunk.bytes);
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
error!("Stream closed before reading length prefix");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let len = u64::from_be_bytes(len_bytes[0..8].try_into().unwrap()) as usize;
|
||||||
|
|
||||||
|
// Read message data
|
||||||
|
let mut message_bytes = Vec::new();
|
||||||
|
while message_bytes.len() < len {
|
||||||
|
let remaining = len - message_bytes.len();
|
||||||
|
if let Ok(maybe_chunk) = recv_stream.read_chunk(remaining, true).await {
|
||||||
|
if let Some(chunk) = maybe_chunk {
|
||||||
|
message_bytes.extend_from_slice(&chunk.bytes);
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
error!("Stream closed before reading full message");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode message
|
||||||
|
match bincode::decode_from_slice(&message_bytes, bincode::config::standard()) {
|
||||||
|
Ok((net_message, _)) => match net_message {
|
||||||
|
NetMessage::Intent(intent) => {
|
||||||
|
if let Err(e) = intent_tx_clone.send(intent) {
|
||||||
|
error!(error = %e, "Failed to forward intent");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => warn!("Received unexpected message type from client"),
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
error!(error = %e, "Failed to decode message");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
info!("Client intent receiver task ended");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle outgoing messages to this client
|
||||||
|
let registry_clone = registry.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
while let Ok(message) = client_rx.recv_async().await {
|
||||||
|
match bincode::encode_to_vec(message, bincode::config::standard()) {
|
||||||
|
Ok(message_bytes) => {
|
||||||
|
let len_bytes = (message_bytes.len() as u64).to_be_bytes();
|
||||||
|
|
||||||
|
// Send length prefix
|
||||||
|
let mut written = 0;
|
||||||
|
while written < len_bytes.len() {
|
||||||
|
match send_stream.write(&len_bytes[written..]).await {
|
||||||
|
Ok(bytes_written) => written += bytes_written,
|
||||||
|
Err(e) => {
|
||||||
|
error!(
|
||||||
|
player_id = player_id,
|
||||||
|
error = %e,
|
||||||
|
"Failed to send length prefix"
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send message bytes
|
||||||
|
let mut written = 0;
|
||||||
|
while written < message_bytes.len() {
|
||||||
|
match send_stream.write(&message_bytes[written..]).await {
|
||||||
|
Ok(bytes_written) => written += bytes_written,
|
||||||
|
Err(e) => {
|
||||||
|
error!(player_id = player_id, error = %e, "Failed to send message");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!(player_id = player_id, error = %e, "Failed to encode message");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Remove client from registry when sender task ends
|
||||||
|
info!(player_id = player_id, "Client message sender task ended, removing from registry");
|
||||||
|
registry_clone.write().await.remove_client(player_id);
|
||||||
|
});
|
||||||
|
|
||||||
|
info!(player_id = player_id, "Client connection handler setup complete");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start the WebTransport server and accept connections
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
#[instrument(skip_all, fields(bind_address = %bind_address))]
|
||||||
|
pub async fn start_server(bind_address: &str, intent_tx: Sender<Intent>, registry: Arc<RwLock<ServerRegistry>>) -> Result<()> {
|
||||||
|
info!("Starting WebTransport server");
|
||||||
|
|
||||||
|
// Load development certificate and key
|
||||||
|
let cert_path = "dev-cert.pem";
|
||||||
|
let key_path = "dev-key.pem";
|
||||||
|
|
||||||
|
let cert_data = std::fs::read(cert_path).map_err(|e| anyhow::anyhow!("Failed to read certificate file {}: {}", cert_path, e))?;
|
||||||
|
let key_data = std::fs::read(key_path).map_err(|e| anyhow::anyhow!("Failed to read key file {}: {}", key_path, e))?;
|
||||||
|
|
||||||
|
// Parse certificate and key
|
||||||
|
let certs = rustls_pemfile::certs(&mut &cert_data[..]).collect::<Result<Vec<_>, _>>().map_err(|e| anyhow::anyhow!("Failed to parse certificate: {}", e))?;
|
||||||
|
|
||||||
|
let key = rustls_pemfile::private_key(&mut &key_data[..]).map_err(|e| anyhow::anyhow!("Failed to parse private key: {}", e))?.ok_or_else(|| anyhow::anyhow!("No private key found"))?;
|
||||||
|
|
||||||
|
let mut server = ServerBuilder::new().with_addr(bind_address.parse()?).with_certificate(certs, key)?;
|
||||||
|
info!("WebTransport server listening for connections");
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match server.accept().await {
|
||||||
|
Some(connection) => {
|
||||||
|
info!("New client connected");
|
||||||
|
|
||||||
|
let intent_tx_clone = intent_tx.clone();
|
||||||
|
let registry_clone = registry.clone();
|
||||||
|
|
||||||
|
let session = connection.ok().await?;
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
// Accept bidirectional stream from client
|
||||||
|
match session.accept_bi().await {
|
||||||
|
Ok((send_stream, recv_stream)) => {
|
||||||
|
if let Err(e) = handle_client_connection(send_stream, recv_stream, intent_tx_clone, registry_clone).await {
|
||||||
|
error!(error = %e, "Error handling client connection");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!(error = %e, "Failed to accept bidirectional stream from client");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
error!("Failed to accept connection");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
crates/borders-core/src/networking/server/mod.rs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
//! Server components for multiplayer networking
|
||||||
|
|
||||||
|
pub mod broadcast;
|
||||||
|
pub mod connection_handler;
|
||||||
|
pub mod registry;
|
||||||
|
|
||||||
|
// Re-export commonly used types
|
||||||
|
pub use broadcast::*;
|
||||||
|
pub use registry::ServerRegistry;
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub use connection_handler::{handle_client_connection, start_server};
|
||||||
72
crates/borders-core/src/networking/server/registry.rs
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
use crate::networking::protocol::NetMessage;
|
||||||
|
use bevy_ecs::prelude::Resource;
|
||||||
|
use flume::Sender;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Arc, RwLock};
|
||||||
|
use tracing::error;
|
||||||
|
|
||||||
|
/// Connection information for a client
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ClientConnection {
|
||||||
|
pub id: u16,
|
||||||
|
pub tx: Sender<NetMessage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Registry for managing client connections and broadcasting messages
|
||||||
|
#[derive(Resource)]
|
||||||
|
pub struct ServerRegistry {
|
||||||
|
connections: Arc<RwLock<HashMap<u16, ClientConnection>>>,
|
||||||
|
next_player_id: Arc<RwLock<u16>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ServerRegistry {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ServerRegistry {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
connections: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
next_player_id: Arc::new(RwLock::new(1)), // Start from 1, 0 reserved
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a new client connection and return assigned player ID
|
||||||
|
pub fn add_client(&self, tx: Sender<NetMessage>) -> u16 {
|
||||||
|
let mut next_id = self.next_player_id.write().unwrap();
|
||||||
|
let player_id = *next_id;
|
||||||
|
*next_id += 1;
|
||||||
|
|
||||||
|
let connection = ClientConnection { id: player_id, tx };
|
||||||
|
self.connections.write().unwrap().insert(player_id, connection);
|
||||||
|
|
||||||
|
player_id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a client connection
|
||||||
|
pub fn remove_client(&self, player_id: u16) {
|
||||||
|
self.connections.write().unwrap().remove(&player_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Broadcast a message to all connected clients
|
||||||
|
pub fn broadcast(&self, message: NetMessage) {
|
||||||
|
let connections = self.connections.read().unwrap();
|
||||||
|
for connection in connections.values() {
|
||||||
|
if let Err(e) = connection.tx.send(message.clone()) {
|
||||||
|
error!("Failed to send message to client {}: {}", connection.id, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the number of connected clients
|
||||||
|
pub fn client_count(&self) -> usize {
|
||||||
|
self.connections.read().unwrap().len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all client IDs
|
||||||
|
pub fn client_ids(&self) -> Vec<u16> {
|
||||||
|
self.connections.read().unwrap().keys().cloned().collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
59
crates/borders-core/src/platform.rs
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn an async task that returns a value on the appropriate runtime.
|
||||||
|
///
|
||||||
|
/// On native targets, uses tokio::spawn and returns a JoinHandle.
|
||||||
|
/// On WASM targets, uses wasm_bindgen_futures::spawn_local and immediately returns None
|
||||||
|
/// since WASM doesn't support waiting on spawned tasks.
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub fn spawn_task_with_handle<F, T>(future: F) -> tokio::task::JoinHandle<T>
|
||||||
|
where
|
||||||
|
F: Future<Output = T> + Send + 'static,
|
||||||
|
T: Send + 'static,
|
||||||
|
{
|
||||||
|
tokio::spawn(future)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub fn spawn_task_with_handle<F, T>(future: F)
|
||||||
|
where
|
||||||
|
F: Future<Output = T> + 'static,
|
||||||
|
T: 'static,
|
||||||
|
{
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
let _ = future.await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extension trait to convert any Result into anyhow::Result with string error conversion.
|
||||||
|
///
|
||||||
|
/// This is useful for WASM where some error types don't implement std::error::Error.
|
||||||
|
pub trait IntoAnyhow<T> {
|
||||||
|
fn into_anyhow(self) -> anyhow::Result<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T, E: std::fmt::Display> IntoAnyhow<T> for Result<T, E> {
|
||||||
|
fn into_anyhow(self) -> anyhow::Result<T> {
|
||||||
|
self.map_err(|e| anyhow::anyhow!(e.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
739
crates/borders-core/src/plugin.rs
Normal file
@@ -0,0 +1,739 @@
|
|||||||
|
//! Consolidated game plugin integrating all core systems
|
||||||
|
//!
|
||||||
|
//! This module provides the main `GamePlugin` which sets up all game logic including:
|
||||||
|
//! - Networking (local or remote)
|
||||||
|
//! - Spawn phase management
|
||||||
|
//! - Core game systems and event handling
|
||||||
|
//! - Turn execution and processing
|
||||||
|
|
||||||
|
use bevy_ecs::prelude::*;
|
||||||
|
use bevy_ecs::schedule::common_conditions::resource_exists;
|
||||||
|
use tracing::{debug, info, trace};
|
||||||
|
|
||||||
|
use crate::app::{App, Last, Plugin, Update};
|
||||||
|
use crate::time::{FixedTime, Time};
|
||||||
|
|
||||||
|
use crate::constants::TICK_INTERVAL;
|
||||||
|
use crate::game::{AttackControls, CurrentTurn, GameInstance, SpawnPhase, SpawnTimeout, turn_is_ready};
|
||||||
|
use crate::networking::{
|
||||||
|
GameView, IntentEvent, IntentReceiver, ProcessTurnEvent, TurnReceiver,
|
||||||
|
coordinator::{generate_turns_system, poll_turns_system},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
use web_time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
// Re-export protocol types for convenience
|
||||||
|
#[cfg(feature = "ui")]
|
||||||
|
use crate::ui::protocol::{BackendMessage, CameraCommand, SpawnCountdown};
|
||||||
|
|
||||||
|
/// 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 },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Main game plugin that consolidates all core game logic
|
||||||
|
///
|
||||||
|
/// This plugin sets up:
|
||||||
|
/// - Network channels (local or remote)
|
||||||
|
/// - Spawn phase management
|
||||||
|
/// - Core game systems
|
||||||
|
/// - Turn processing
|
||||||
|
/// - Input handling
|
||||||
|
pub struct GamePlugin {
|
||||||
|
pub network_mode: NetworkMode,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GamePlugin {
|
||||||
|
pub fn new(network_mode: NetworkMode) -> Self {
|
||||||
|
Self { network_mode }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Plugin for GamePlugin {
|
||||||
|
fn build(&self, app: &mut App) {
|
||||||
|
// Setup networking based on mode
|
||||||
|
match &self.network_mode {
|
||||||
|
NetworkMode::Local => {
|
||||||
|
info!("Initializing GamePlugin in Local mode");
|
||||||
|
|
||||||
|
// Local mode: use direct channels
|
||||||
|
let (intent_tx, intent_rx) = flume::unbounded();
|
||||||
|
let (game_view_tx, game_view_rx) = flume::unbounded();
|
||||||
|
|
||||||
|
app.insert_resource(crate::networking::client::ClientConnection { intent_tx, game_view_rx })
|
||||||
|
.insert_resource(crate::networking::server::ServerChannels {
|
||||||
|
broadcast_tx: game_view_tx,
|
||||||
|
net_message_tx: flume::unbounded().0, // Dummy channel for local mode
|
||||||
|
})
|
||||||
|
.insert_resource(IntentReceiver { intent_rx })
|
||||||
|
.add_systems(Update, (crate::networking::client::send_intent_system, crate::networking::client::receive_game_view_system.run_if(resource_exists::<GameView>)))
|
||||||
|
.add_systems(Update, (poll_turns_system.run_if(resource_exists::<TurnReceiver>), crate::networking::server::broadcast_game_state_system.run_if(resource_exists::<GameInstance>).run_if(|spawn_phase: Option<Res<SpawnPhase>>| spawn_phase.is_none_or(|sp| !sp.active)).after(crate::game::update_player_borders_system)));
|
||||||
|
}
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
NetworkMode::Remote { server_address } => {
|
||||||
|
info!("Initializing GamePlugin in Remote mode (server: {})", server_address);
|
||||||
|
|
||||||
|
// Remote mode: use NetMessage protocol
|
||||||
|
let (net_intent_tx, net_intent_rx) = flume::unbounded();
|
||||||
|
let (net_message_tx, net_message_rx) = flume::unbounded();
|
||||||
|
|
||||||
|
app.insert_resource(crate::networking::client::RemoteClientConnection { intent_tx: net_intent_tx, net_message_rx, player_id: None }).add_systems(Update, (crate::networking::client::send_net_intent_system, crate::networking::client::receive_net_message_system, crate::networking::client::handle_spawn_config_system));
|
||||||
|
|
||||||
|
// Spawn networking thread
|
||||||
|
let server_addr = server_address.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
|
||||||
|
|
||||||
|
runtime.block_on(async move {
|
||||||
|
use crate::networking::protocol::NetMessage;
|
||||||
|
use tracing::error;
|
||||||
|
|
||||||
|
info!("Connecting to remote server at {}", server_addr);
|
||||||
|
|
||||||
|
// Load server certificate for validation
|
||||||
|
let cert_path = "dev-cert.pem";
|
||||||
|
let cert_data = match std::fs::read(cert_path) {
|
||||||
|
Ok(data) => data,
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to read certificate file {}: {}", cert_path, e);
|
||||||
|
error!("Please run the `generate-dev-cert.ps1` script first.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let pem = pem::parse(&cert_data).expect("Failed to parse PEM certificate");
|
||||||
|
let cert_hash = ring::digest::digest(&ring::digest::SHA256, pem.contents()).as_ref().to_vec();
|
||||||
|
|
||||||
|
let client = web_transport::ClientBuilder::new().with_server_certificate_hashes(vec![cert_hash]).expect("Failed to create client with certificate hash");
|
||||||
|
|
||||||
|
let mut connection = match client.connect(server_addr.parse().unwrap()).await {
|
||||||
|
Ok(conn) => {
|
||||||
|
info!("Connected to server successfully");
|
||||||
|
conn
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to connect to server: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let (mut send_stream, mut recv_stream) = match connection.open_bi().await {
|
||||||
|
Ok(streams) => {
|
||||||
|
info!("Opened bidirectional stream");
|
||||||
|
streams
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to open bidirectional stream: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Read initial ServerConfig
|
||||||
|
info!("Reading initial server config...");
|
||||||
|
let mut len_bytes = Vec::new();
|
||||||
|
while len_bytes.len() < 8 {
|
||||||
|
let remaining = 8 - len_bytes.len();
|
||||||
|
match recv_stream.read(remaining).await {
|
||||||
|
Ok(Some(chunk)) => {
|
||||||
|
len_bytes.extend_from_slice(&chunk);
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
error!("Stream closed before reading server config length");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to read server config length: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let len = u64::from_be_bytes(len_bytes[0..8].try_into().unwrap()) as usize;
|
||||||
|
|
||||||
|
let mut message_bytes = Vec::new();
|
||||||
|
while message_bytes.len() < len {
|
||||||
|
let remaining = len - message_bytes.len();
|
||||||
|
match recv_stream.read(remaining).await {
|
||||||
|
Ok(Some(chunk)) => {
|
||||||
|
message_bytes.extend_from_slice(&chunk);
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
error!("Stream closed before reading server config data");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to read server config data: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match bincode::decode_from_slice(&message_bytes, bincode::config::standard()) {
|
||||||
|
Ok((net_message, _)) => {
|
||||||
|
info!("Received server config: {:?}", net_message);
|
||||||
|
match net_message {
|
||||||
|
NetMessage::ServerConfig { player_id } => {
|
||||||
|
info!("Assigned player ID: {}", player_id);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
error!("Expected ServerConfig, got: {:?}", net_message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to decode server config: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send intents to server
|
||||||
|
let send_task = async {
|
||||||
|
while let Ok(net_message) = net_intent_rx.recv_async().await {
|
||||||
|
match bincode::encode_to_vec(net_message, bincode::config::standard()) {
|
||||||
|
Ok(message_bytes) => {
|
||||||
|
let len_bytes = (message_bytes.len() as u64).to_be_bytes();
|
||||||
|
|
||||||
|
let mut written = 0;
|
||||||
|
while written < len_bytes.len() {
|
||||||
|
match send_stream.write(&len_bytes[written..]).await {
|
||||||
|
Ok(bytes_written) => written += bytes_written,
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to send length prefix: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut written = 0;
|
||||||
|
while written < message_bytes.len() {
|
||||||
|
match send_stream.write(&message_bytes[written..]).await {
|
||||||
|
Ok(bytes_written) => written += bytes_written,
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to send message: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to encode message: {}", e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Receive messages from server
|
||||||
|
let recv_task = async {
|
||||||
|
loop {
|
||||||
|
let mut len_bytes = Vec::new();
|
||||||
|
while len_bytes.len() < 8 {
|
||||||
|
let remaining = 8 - len_bytes.len();
|
||||||
|
if let Ok(maybe_chunk) = recv_stream.read(remaining).await {
|
||||||
|
if let Some(chunk) = maybe_chunk {
|
||||||
|
len_bytes.extend_from_slice(&chunk);
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
error!("Stream closed before reading length prefix");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let len = u64::from_be_bytes(len_bytes[0..8].try_into().unwrap()) as usize;
|
||||||
|
|
||||||
|
let mut message_bytes = Vec::new();
|
||||||
|
while message_bytes.len() < len {
|
||||||
|
let remaining = len - message_bytes.len();
|
||||||
|
if let Ok(maybe_chunk) = recv_stream.read(remaining).await {
|
||||||
|
if let Some(chunk) = maybe_chunk {
|
||||||
|
message_bytes.extend_from_slice(&chunk);
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
error!("Stream closed before reading full message");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match bincode::decode_from_slice(&message_bytes, bincode::config::standard()) {
|
||||||
|
Ok((net_message, _)) => {
|
||||||
|
if net_message_tx.send_async(net_message).await.is_err() {
|
||||||
|
error!("Failed to forward message to client");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to decode message: {}", e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
futures_lite::future::zip(send_task, recv_task).await;
|
||||||
|
error!("Connection to server closed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure fixed timestep for game logic (10 TPS = 100ms)
|
||||||
|
app.insert_resource(FixedTime::from_seconds(TICK_INTERVAL as f64 / 1000.0));
|
||||||
|
|
||||||
|
// Core multiplayer events and resources
|
||||||
|
app.add_message::<IntentEvent>().add_message::<ProcessTurnEvent>().add_message::<crate::networking::SpawnConfigEvent>().init_resource::<GameView>();
|
||||||
|
|
||||||
|
// UI-related events and resources (feature-gated)
|
||||||
|
#[cfg(feature = "ui")]
|
||||||
|
{
|
||||||
|
app.add_message::<CameraCommand>().init_resource::<crate::ui::LastLeaderboardDigest>().init_resource::<crate::ui::LastAttacksDigest>().init_resource::<crate::ui::LeaderboardThrottle>().init_resource::<crate::ui::NationHighlightState>().init_resource::<crate::ui::ShipStateTracker>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input-related resources
|
||||||
|
app.init_resource::<SpawnPhase>().init_resource::<AttackControls>();
|
||||||
|
|
||||||
|
// Spawn phase management
|
||||||
|
app.init_resource::<SpawnPhaseInitialized>().init_resource::<PreviousSpawnState>().add_systems(Update, (emit_initial_spawn_phase_system.run_if(resource_exists::<SpawnPhase>), manage_spawn_phase_system, update_spawn_preview_system.run_if(resource_exists::<SpawnPhase>)));
|
||||||
|
|
||||||
|
// Core game logic systems (run in Update, event-driven)
|
||||||
|
app.add_systems(
|
||||||
|
Update,
|
||||||
|
(
|
||||||
|
// Step 1: Receive turn events and update CurrentTurn resource
|
||||||
|
update_current_turn_system,
|
||||||
|
// Step 2: Execute gameplay systems only when turn is ready (10 TPS)
|
||||||
|
(crate::game::process_player_income_system, execute_turn_gameplay_system, crate::game::update_player_borders_system, crate::game::check_local_player_outcome)
|
||||||
|
.chain() // Ensure income runs before turn execution
|
||||||
|
.run_if(turn_is_ready)
|
||||||
|
.run_if(resource_exists::<GameInstance>)
|
||||||
|
.run_if(resource_exists::<GameView>)
|
||||||
|
.run_if(resource_exists::<SpawnPhase>),
|
||||||
|
)
|
||||||
|
.chain(), // Ensure update_current_turn_system completes before execution systems
|
||||||
|
);
|
||||||
|
|
||||||
|
// UI update systems (feature-gated)
|
||||||
|
#[cfg(feature = "ui")]
|
||||||
|
app.add_systems(Update, (crate::ui::emit_leaderboard_snapshot_system, crate::ui::emit_attacks_update_system, crate::ui::emit_ships_update_system, crate::ui::emit_nation_highlight_system).run_if(resource_exists::<GameInstance>).run_if(resource_exists::<GameView>));
|
||||||
|
|
||||||
|
// Command handlers
|
||||||
|
#[cfg(feature = "ui")]
|
||||||
|
app.add_systems(Update, handle_frontend_messages_system);
|
||||||
|
|
||||||
|
// Platform-agnostic input systems
|
||||||
|
app.add_systems(Update, (crate::game::handle_spawn_click_system, crate::game::handle_attack_click_system, crate::game::handle_center_camera_system, crate::game::handle_attack_ratio_keys_system).run_if(resource_exists::<GameInstance>));
|
||||||
|
|
||||||
|
// Input state frame update
|
||||||
|
app.add_systems(Last, clear_input_state_system);
|
||||||
|
|
||||||
|
// Turn generation system
|
||||||
|
app.add_systems(Update, generate_turns_system.run_if(resource_exists::<crate::networking::TurnGenerator>));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resource to track if we've emitted the initial spawn phase event
|
||||||
|
#[derive(Resource, Default)]
|
||||||
|
struct SpawnPhaseInitialized {
|
||||||
|
emitted_initial: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resource to track previous spawn state for incremental updates
|
||||||
|
#[derive(Resource, Default)]
|
||||||
|
struct PreviousSpawnState {
|
||||||
|
spawns: Vec<(usize, usize)>, // (player_id, tile_index)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// System to emit initial SpawnPhaseUpdate when game starts
|
||||||
|
#[cfg(feature = "ui")]
|
||||||
|
fn emit_initial_spawn_phase_system(mut initialized: ResMut<SpawnPhaseInitialized>, spawn_phase: Res<SpawnPhase>, game_instance: Option<Res<GameInstance>>, mut backend_messages: MessageWriter<BackendMessage>) {
|
||||||
|
if initialized.emitted_initial || !spawn_phase.active || game_instance.is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
backend_messages.write(BackendMessage::SpawnPhaseUpdate { countdown: None });
|
||||||
|
initialized.emitted_initial = true;
|
||||||
|
debug!("Emitted initial SpawnPhaseUpdate (no countdown)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "ui"))]
|
||||||
|
fn emit_initial_spawn_phase_system() {}
|
||||||
|
|
||||||
|
/// System to manage spawn timeout and emit countdown updates
|
||||||
|
#[cfg(feature = "ui")]
|
||||||
|
fn manage_spawn_phase_system(mut spawn_timeout: Option<ResMut<SpawnTimeout>>, spawn_phase: Option<Res<SpawnPhase>>, time: Res<Time>, mut backend_messages: MessageWriter<BackendMessage>) {
|
||||||
|
let Some(spawn_phase) = spawn_phase else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(ref mut spawn_timeout) = spawn_timeout else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if !spawn_phase.active || !spawn_timeout.active {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
spawn_timeout.update(time.delta_secs());
|
||||||
|
|
||||||
|
let started_at_ms = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_millis() as u64 - (spawn_timeout.elapsed_secs * 1000.0) as u64;
|
||||||
|
|
||||||
|
backend_messages.write(BackendMessage::SpawnPhaseUpdate { countdown: Some(SpawnCountdown { started_at_ms, duration_secs: spawn_timeout.duration_secs }) });
|
||||||
|
|
||||||
|
trace!("SpawnPhaseUpdate: remaining {:.1}s", spawn_timeout.remaining_secs);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "ui"))]
|
||||||
|
fn manage_spawn_phase_system() {}
|
||||||
|
|
||||||
|
/// System to update GameView with spawn preview during spawn phase
|
||||||
|
/// This shows territory data to the frontend BEFORE Turn(0) is executed
|
||||||
|
/// Only processes changed spawns incrementally for better performance
|
||||||
|
fn update_spawn_preview_system(spawn_phase: Res<SpawnPhase>, spawn_manager: Option<Res<crate::game::SpawnManager>>, mut game_view: Option<ResMut<GameView>>, game_instance: Option<Res<GameInstance>>, mut previous_state: ResMut<PreviousSpawnState>) {
|
||||||
|
if !spawn_phase.active {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(ref spawn_mgr) = spawn_manager else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only update if SpawnManager has changed
|
||||||
|
if !spawn_mgr.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(ref mut game_view) = game_view else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(ref game_inst) = game_instance else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let width = game_view.width;
|
||||||
|
let height = game_view.height;
|
||||||
|
|
||||||
|
// Get current spawns
|
||||||
|
let current_spawns = spawn_mgr.get_all_spawns();
|
||||||
|
|
||||||
|
// Find spawns that were removed and added
|
||||||
|
let previous_spawns = &previous_state.spawns;
|
||||||
|
let removed_spawns: Vec<_> = previous_spawns.iter().filter(|prev| !current_spawns.contains(prev)).copied().collect();
|
||||||
|
let added_spawns: Vec<_> = current_spawns.iter().filter(|curr| !previous_spawns.contains(curr)).copied().collect();
|
||||||
|
|
||||||
|
// If nothing changed, return early
|
||||||
|
if removed_spawns.is_empty() && added_spawns.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone territories only if we have changes to process
|
||||||
|
use std::sync::Arc;
|
||||||
|
let old_territories: Vec<crate::game::TileOwnership> = game_view.territories.iter().map(|&u| crate::game::TileOwnership::from_u16(u)).collect();
|
||||||
|
let mut territories = old_territories.clone();
|
||||||
|
let base_territories = game_inst.territory_manager.as_slice();
|
||||||
|
let mut changed_tiles = std::collections::HashSet::new();
|
||||||
|
|
||||||
|
// Process removed spawns: revert their 5x5 areas to base state
|
||||||
|
for &(player_id, tile_index) in &removed_spawns {
|
||||||
|
let spawn_x = tile_index as u32 % width;
|
||||||
|
let spawn_y = tile_index as u32 / width;
|
||||||
|
|
||||||
|
for dy in -2..=2 {
|
||||||
|
for dx in -2..=2 {
|
||||||
|
let x = (spawn_x as i32 + dx).clamp(0, width as i32 - 1) as u32;
|
||||||
|
let y = (spawn_y as i32 + dy).clamp(0, height as i32 - 1) as u32;
|
||||||
|
let idx = (x + y * width) as usize;
|
||||||
|
|
||||||
|
// Check if this tile belongs to the removed spawn
|
||||||
|
if territories[idx].is_owned_by(player_id as u16) {
|
||||||
|
// Recalculate from scratch for this tile
|
||||||
|
let mut new_owner = base_territories[idx];
|
||||||
|
|
||||||
|
// Check if any other spawn claims this tile
|
||||||
|
for &(other_player_id, other_tile_index) in ¤t_spawns {
|
||||||
|
let other_x = other_tile_index as u32 % width;
|
||||||
|
let other_y = other_tile_index as u32 / width;
|
||||||
|
let dx_other = x as i32 - other_x as i32;
|
||||||
|
let dy_other = y as i32 - other_y as i32;
|
||||||
|
|
||||||
|
if dx_other.abs() <= 2 && dy_other.abs() <= 2 && base_territories[idx].is_unclaimed() {
|
||||||
|
new_owner = crate::game::TileOwnership::Owned(other_player_id as u16);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if territories[idx] != new_owner {
|
||||||
|
territories[idx] = new_owner;
|
||||||
|
changed_tiles.insert(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process added spawns: mark their 5x5 areas
|
||||||
|
for &(player_id, tile_index) in &added_spawns {
|
||||||
|
let spawn_x = tile_index as u32 % width;
|
||||||
|
let spawn_y = tile_index as u32 / width;
|
||||||
|
|
||||||
|
for dy in -2..=2 {
|
||||||
|
for dx in -2..=2 {
|
||||||
|
let x = (spawn_x as i32 + dx).clamp(0, width as i32 - 1) as u32;
|
||||||
|
let y = (spawn_y as i32 + dy).clamp(0, height as i32 - 1) as u32;
|
||||||
|
let idx = (x + y * width) as usize;
|
||||||
|
|
||||||
|
// Only claim if base territory is unclaimed
|
||||||
|
if base_territories[idx].is_unclaimed() {
|
||||||
|
let old_value = territories[idx];
|
||||||
|
let new_owner = crate::game::TileOwnership::Owned(player_id as u16);
|
||||||
|
if old_value != new_owner {
|
||||||
|
territories[idx] = new_owner;
|
||||||
|
changed_tiles.insert(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert back to u16 and update game view
|
||||||
|
let territories_u16: Vec<u16> = territories.iter().map(|o| o.to_u16()).collect();
|
||||||
|
game_view.territories = Arc::from(territories_u16.as_slice());
|
||||||
|
game_view.changed_tiles = changed_tiles.into_iter().collect();
|
||||||
|
|
||||||
|
// Update player tile counts incrementally by tracking deltas from changed tiles
|
||||||
|
// This is O(changed_tiles) instead of O(total_tiles) = ~100 ops instead of 6.5M ops
|
||||||
|
let mut players_map = std::collections::HashMap::new();
|
||||||
|
for player in &game_view.players {
|
||||||
|
players_map.insert(player.id, player.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track tile count changes per player
|
||||||
|
let mut tile_deltas: std::collections::HashMap<u16, i32> = std::collections::HashMap::new();
|
||||||
|
|
||||||
|
// For each changed tile, update the delta counters
|
||||||
|
for &idx in &game_view.changed_tiles {
|
||||||
|
let old_owner = old_territories[idx];
|
||||||
|
let new_owner = territories[idx];
|
||||||
|
|
||||||
|
// Decrement count for previous owner (if any)
|
||||||
|
if let crate::game::TileOwnership::Owned(old_owner_id) = old_owner
|
||||||
|
&& old_owner_id != 0
|
||||||
|
{
|
||||||
|
*tile_deltas.entry(old_owner_id).or_insert(0) -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment count for new owner (if any)
|
||||||
|
if let crate::game::TileOwnership::Owned(new_owner_id) = new_owner
|
||||||
|
&& new_owner_id != 0
|
||||||
|
{
|
||||||
|
*tile_deltas.entry(new_owner_id).or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply deltas to player tile counts
|
||||||
|
for (player_id, delta) in tile_deltas {
|
||||||
|
if let Some(player) = players_map.get_mut(&player_id) {
|
||||||
|
player.tile_count = (player.tile_count as i32 + delta).max(0) as u32;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
game_view.players = players_map.into_values().collect();
|
||||||
|
|
||||||
|
// Update previous state
|
||||||
|
previous_state.spawns = current_spawns.clone();
|
||||||
|
|
||||||
|
trace!("Spawn preview updated: {} removed, {} added, {} changed tiles", removed_spawns.len(), added_spawns.len(), game_view.changed_tiles.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// System to clear per-frame input state data
|
||||||
|
fn clear_input_state_system(input: Option<NonSend<std::sync::Arc<std::sync::Mutex<crate::ui::input::InputState>>>>) {
|
||||||
|
if let Some(input) = input
|
||||||
|
&& let Ok(mut state) = input.lock()
|
||||||
|
{
|
||||||
|
state.clear_frame_data();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// System to receive turn events and update CurrentTurn resource
|
||||||
|
pub fn update_current_turn_system(mut turn_events: MessageReader<ProcessTurnEvent>, mut current_turn: Option<ResMut<CurrentTurn>>, mut commands: Commands) {
|
||||||
|
// Read all turn events (should only be one per frame at 10 TPS)
|
||||||
|
let turns: Vec<_> = turn_events.read().map(|e| e.0.clone()).collect();
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
trace!("Received turn {} with {} intents", turn.turn_number, turn.intents.len());
|
||||||
|
|
||||||
|
if let Some(ref mut current_turn_res) = current_turn {
|
||||||
|
// Update existing resource
|
||||||
|
current_turn_res.turn = turn;
|
||||||
|
current_turn_res.processed = false; // Mark as ready for processing
|
||||||
|
} else {
|
||||||
|
// Initialize resource on first turn
|
||||||
|
commands.insert_resource(CurrentTurn::new(turn));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Execute turn gameplay logic
|
||||||
|
/// Only runs when turn_is_ready() returns true (once per turn at 10 TPS)
|
||||||
|
pub fn execute_turn_gameplay_system(mut current_turn: ResMut<CurrentTurn>, mut game_instance: ResMut<GameInstance>, mut game_view: Option<ResMut<GameView>>, spawn_manager: Option<Res<crate::game::SpawnManager>>, mut spawn_phase: ResMut<SpawnPhase>, #[cfg(feature = "ui")] mut backend_messages: MessageWriter<BackendMessage>, server_handle: Option<Res<crate::networking::LocalTurnServerHandle>>) {
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
let Some(ref mut game_view) = game_view else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let turn = ¤t_turn.turn;
|
||||||
|
|
||||||
|
trace!("Executing turn {} with {} intents", turn.turn_number, turn.intents.len());
|
||||||
|
|
||||||
|
game_instance.execute_turn(turn);
|
||||||
|
|
||||||
|
if turn.turn_number == 0
|
||||||
|
&& let Some(ref spawn_mgr) = spawn_manager
|
||||||
|
{
|
||||||
|
let bot_spawns = spawn_mgr.get_bot_spawns();
|
||||||
|
for &(player_id, tile_index) in bot_spawns {
|
||||||
|
game_instance.handle_spawn(player_id as u16, tile_index as u32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let total_land_tiles = game_instance.territory_manager.as_slice().iter().filter(|ownership| !ownership.is_water()).count() as u32;
|
||||||
|
|
||||||
|
// Convert ships to ShipView format
|
||||||
|
let ships = game_instance.ship_manager.get_ships().map(|ship| crate::networking::ShipView { id: ship.id, owner_id: ship.owner_id, current_tile: ship.get_current_tile() as u32, target_tile: ship.target_tile as u32, troops: ship.troops, path_progress: ship.current_path_index as u32, ticks_until_move: ship.ticks_per_tile.saturating_sub(ship.ticks_since_move), path: ship.path.iter().map(|&tile| tile as u32).collect() }).collect();
|
||||||
|
|
||||||
|
**game_view = GameView { width: game_instance.territory_manager.width(), height: game_instance.territory_manager.height(), territories: Arc::from(game_instance.territory_manager.to_u16_vec().as_slice()), turn_number: game_instance.turn_number, total_land_tiles, changed_tiles: Vec::new(), players: game_instance.player_manager.get_players().iter().map(|p| crate::networking::PlayerView { id: p.id as u16, color: p.color.to_rgba(), name: p.name.clone(), tile_count: p.get_territory_size() as u32, troops: p.get_troops() as u32, is_alive: p.is_alive() }).collect(), ships };
|
||||||
|
|
||||||
|
trace!("GameView updated: turn {}, {} players", game_view.turn_number, game_view.players.len());
|
||||||
|
|
||||||
|
if turn.turn_number == 0 && spawn_phase.active {
|
||||||
|
spawn_phase.active = false;
|
||||||
|
|
||||||
|
#[cfg(feature = "ui")]
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark turn as processed to prevent re-execution
|
||||||
|
current_turn.mark_processed();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// System to handle FrontendMessage events
|
||||||
|
#[cfg(feature = "ui")]
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn handle_frontend_messages_system(mut commands: Commands, mut frontend_messages: MessageReader<crate::ui::protocol::FrontendMessage>, game_instance: Option<Res<GameInstance>>, intent_receiver: Option<Res<IntentReceiver>>, mut attack_controls: Option<ResMut<AttackControls>>, mut spawn_phase: ResMut<SpawnPhase>, mut spawn_phase_init: ResMut<SpawnPhaseInitialized>, mut previous_spawn_state: ResMut<PreviousSpawnState>) {
|
||||||
|
use crate::ui::protocol::FrontendMessage;
|
||||||
|
use tracing::{debug, error, info};
|
||||||
|
|
||||||
|
for message in frontend_messages.read() {
|
||||||
|
match message {
|
||||||
|
FrontendMessage::StartGame => {
|
||||||
|
info!("Processing StartGame command");
|
||||||
|
|
||||||
|
if game_instance.is_some() {
|
||||||
|
error!("Game already running - ignoring StartGame");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(ref intent_receiver) = intent_receiver else {
|
||||||
|
error!("IntentReceiver not available - cannot start game");
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let terrain_data = match crate::game::TerrainData::load_world_map() {
|
||||||
|
Ok(data) => data,
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to load World map: {}", e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let terrain_arc = std::sync::Arc::new(terrain_data.clone());
|
||||||
|
commands.insert_resource(terrain_data);
|
||||||
|
|
||||||
|
let size = terrain_arc.size();
|
||||||
|
let width = size.x;
|
||||||
|
let height = size.y;
|
||||||
|
let mut conquerable_tiles = Vec::with_capacity((width * height) as usize);
|
||||||
|
|
||||||
|
for y in 0..height {
|
||||||
|
for x in 0..width {
|
||||||
|
conquerable_tiles.push(terrain_arc.is_conquerable((x, y)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let params = crate::game::GameInitParams {
|
||||||
|
map_width: width,
|
||||||
|
map_height: height,
|
||||||
|
conquerable_tiles,
|
||||||
|
client_player_id: 0, // Human player is ID 0
|
||||||
|
intent_rx: intent_receiver.intent_rx.clone(),
|
||||||
|
terrain_data: terrain_arc,
|
||||||
|
};
|
||||||
|
|
||||||
|
crate::game::initialize_game_resources(&mut commands, params);
|
||||||
|
info!("Game initialized successfully");
|
||||||
|
}
|
||||||
|
|
||||||
|
FrontendMessage::QuitGame => {
|
||||||
|
info!("Processing QuitGame command");
|
||||||
|
if game_instance.is_some() {
|
||||||
|
// Remove all game-specific resources
|
||||||
|
commands.remove_resource::<GameInstance>();
|
||||||
|
commands.remove_resource::<crate::game::LocalPlayerContext>();
|
||||||
|
commands.remove_resource::<TurnReceiver>();
|
||||||
|
commands.remove_resource::<crate::game::SpawnManager>();
|
||||||
|
commands.remove_resource::<crate::game::SpawnTimeout>();
|
||||||
|
commands.remove_resource::<GameView>();
|
||||||
|
commands.remove_resource::<crate::TerrainData>();
|
||||||
|
commands.remove_resource::<crate::networking::TurnGenerator>();
|
||||||
|
|
||||||
|
// Reset permanent resources to default state
|
||||||
|
spawn_phase.active = false;
|
||||||
|
spawn_phase_init.emitted_initial = false;
|
||||||
|
previous_spawn_state.spawns.clear();
|
||||||
|
|
||||||
|
// Note: LocalTurnServerHandle cleanup requires World access
|
||||||
|
// It will be cleaned up automatically when the resource is dropped
|
||||||
|
|
||||||
|
info!("Game stopped and resources cleaned up");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FrontendMessage::PauseGame | FrontendMessage::ResumeGame => {
|
||||||
|
// TODO: Implement pause/resume functionality
|
||||||
|
}
|
||||||
|
|
||||||
|
FrontendMessage::SetAttackRatio { ratio } => {
|
||||||
|
if let Some(ref mut controls) = attack_controls {
|
||||||
|
controls.attack_ratio = ratio.clamp(0.01, 1.0);
|
||||||
|
debug!("Attack ratio set to {:.1}%", controls.attack_ratio * 100.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
311
crates/borders-core/src/telemetry/client.rs
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
use super::types::{BatchCaptureRequest, BatchEvent, TelemetryConfig, TelemetryEvent};
|
||||||
|
use super::user_id::UserIdType;
|
||||||
|
use crate::platform::spawn_task;
|
||||||
|
use futures::lock::Mutex;
|
||||||
|
use hmac::{Hmac, Mac};
|
||||||
|
use sha2::Sha256;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::error::Error;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tracing::{debug, error, warn};
|
||||||
|
|
||||||
|
#[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::networking::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<std::sync::atomic::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(std::sync::atomic::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(std::sync::atomic::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(std::sync::atomic::Ordering::Acquire) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to start the task (only one thread will succeed)
|
||||||
|
if self.flush_task_started.compare_exchange(false, true, std::sync::atomic::Ordering::AcqRel, std::sync::atomic::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"))]
|
||||||
|
{
|
||||||
|
let mut interval = tokio::time::interval(std::time::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
|
||||||
|
}
|
||||||
128
crates/borders-core/src/telemetry/mod.rs
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
//! 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::TelemetryClient;
|
||||||
|
pub use system_info::SystemInfo;
|
||||||
|
pub use types::{TelemetryConfig, TelemetryEvent};
|
||||||
|
pub use user_id::UserIdType;
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub use user_id::get_or_create_user_id;
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub use user_id::get_or_create_user_id_async;
|
||||||
|
|
||||||
|
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
@@ -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
@@ -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.borders.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
@@ -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()
|
||||||
|
}
|
||||||
58
crates/borders-core/src/time.rs
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
/// Simple time tracking resource for ECS
|
||||||
|
use bevy_ecs::prelude::Resource;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Resource)]
|
||||||
|
pub struct Time {
|
||||||
|
delta: Duration,
|
||||||
|
elapsed: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Time {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self { delta: Duration::ZERO, elapsed: Duration::ZERO }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update(&mut self, delta: Duration) {
|
||||||
|
self.delta = delta;
|
||||||
|
self.elapsed += delta;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delta(&self) -> Duration {
|
||||||
|
self.delta
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delta_secs(&self) -> f32 {
|
||||||
|
self.delta.as_secs_f32()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn elapsed(&self) -> Duration {
|
||||||
|
self.elapsed
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn elapsed_secs(&self) -> f32 {
|
||||||
|
self.elapsed.as_secs_f32()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Time {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
350
crates/borders-core/src/ui/input.rs
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
//! Platform-agnostic input handling for the game
|
||||||
|
//!
|
||||||
|
//! This module provides input types and utilities that work across
|
||||||
|
//! all platforms (WASM, Tauri) without depending on Bevy's input system.
|
||||||
|
|
||||||
|
use bevy_ecs::prelude::Resource;
|
||||||
|
|
||||||
|
/// Mouse button identifier
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub enum MouseButton {
|
||||||
|
Left = 0,
|
||||||
|
Middle = 1,
|
||||||
|
Right = 2,
|
||||||
|
Back = 3,
|
||||||
|
Forward = 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MouseButton {
|
||||||
|
pub fn from_u8(button: u8) -> Option<Self> {
|
||||||
|
match button {
|
||||||
|
0 => Some(Self::Left),
|
||||||
|
1 => Some(Self::Middle),
|
||||||
|
2 => Some(Self::Right),
|
||||||
|
3 => Some(Self::Back),
|
||||||
|
4 => Some(Self::Forward),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keyboard key codes (subset we actually use)
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub enum KeyCode {
|
||||||
|
KeyW,
|
||||||
|
KeyA,
|
||||||
|
KeyS,
|
||||||
|
KeyD,
|
||||||
|
KeyC,
|
||||||
|
Digit1,
|
||||||
|
Digit2,
|
||||||
|
Space,
|
||||||
|
Escape,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KeyCode {
|
||||||
|
pub fn from_string(key: &str) -> Option<Self> {
|
||||||
|
match key {
|
||||||
|
"KeyW" | "w" => Some(Self::KeyW),
|
||||||
|
"KeyA" | "a" => Some(Self::KeyA),
|
||||||
|
"KeyS" | "s" => Some(Self::KeyS),
|
||||||
|
"KeyD" | "d" => Some(Self::KeyD),
|
||||||
|
"KeyC" | "c" => Some(Self::KeyC),
|
||||||
|
"Digit1" | "1" => Some(Self::Digit1),
|
||||||
|
"Digit2" | "2" => Some(Self::Digit2),
|
||||||
|
"Space" | " " => Some(Self::Space),
|
||||||
|
"Escape" => Some(Self::Escape),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Button state (pressed or released)
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ButtonState {
|
||||||
|
Pressed,
|
||||||
|
Released,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// World coordinates (in game units)
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct WorldPos {
|
||||||
|
pub x: f32,
|
||||||
|
pub y: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Screen coordinates (in pixels)
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct ScreenPos {
|
||||||
|
pub x: f32,
|
||||||
|
pub y: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tile coordinates on the map
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct TileCoord {
|
||||||
|
pub x: u32,
|
||||||
|
pub y: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TileCoord {
|
||||||
|
/// Convert to linear tile index
|
||||||
|
pub fn to_index(&self, map_width: u32) -> usize {
|
||||||
|
(self.y * map_width + self.x) as usize
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create from linear tile index
|
||||||
|
pub fn from_index(index: usize, map_width: u32) -> Self {
|
||||||
|
Self { x: (index as u32) % map_width, y: (index as u32) / map_width }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Camera state for coordinate conversions
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct CameraState {
|
||||||
|
/// Camera position in world coordinates
|
||||||
|
pub x: f32,
|
||||||
|
pub y: f32,
|
||||||
|
/// Camera zoom level (1.0 = normal)
|
||||||
|
pub zoom: f32,
|
||||||
|
/// Viewport width in pixels
|
||||||
|
pub viewport_width: f32,
|
||||||
|
/// Viewport height in pixels
|
||||||
|
pub viewport_height: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Input event from the frontend
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum InputEvent {
|
||||||
|
MouseButton { button: MouseButton, state: ButtonState, world_pos: Option<WorldPos>, tile: Option<TileCoord> },
|
||||||
|
MouseMove { world_pos: WorldPos, screen_pos: ScreenPos, tile: Option<TileCoord> },
|
||||||
|
MouseWheel { delta_x: f32, delta_y: f32 },
|
||||||
|
KeyPress { key: KeyCode, state: ButtonState },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Resource)]
|
||||||
|
pub struct InputState {
|
||||||
|
// Mouse state
|
||||||
|
mouse_buttons: Vec<(MouseButton, ButtonState)>,
|
||||||
|
cursor_world_pos: Option<WorldPos>,
|
||||||
|
cursor_tile: Option<TileCoord>,
|
||||||
|
mouse_wheel_delta: (f32, f32),
|
||||||
|
|
||||||
|
// Keyboard state
|
||||||
|
keys_pressed: Vec<KeyCode>,
|
||||||
|
keys_just_pressed: Vec<KeyCode>,
|
||||||
|
keys_just_released: Vec<KeyCode>,
|
||||||
|
|
||||||
|
// Track if camera was interacted with (for click filtering)
|
||||||
|
camera_interaction: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InputState {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear per-frame data (call at start of frame)
|
||||||
|
pub fn clear_frame_data(&mut self) {
|
||||||
|
self.mouse_buttons.clear();
|
||||||
|
self.keys_just_pressed.clear();
|
||||||
|
self.keys_just_released.clear();
|
||||||
|
self.mouse_wheel_delta = (0.0, 0.0);
|
||||||
|
self.camera_interaction = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process an input event
|
||||||
|
pub fn handle_event(&mut self, event: InputEvent) {
|
||||||
|
match event {
|
||||||
|
InputEvent::MouseButton { button, state, world_pos, tile } => {
|
||||||
|
self.mouse_buttons.push((button, state));
|
||||||
|
if world_pos.is_some() {
|
||||||
|
self.cursor_world_pos = world_pos;
|
||||||
|
}
|
||||||
|
if tile.is_some() {
|
||||||
|
self.cursor_tile = tile;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
InputEvent::MouseMove { world_pos, tile, .. } => {
|
||||||
|
self.cursor_world_pos = Some(world_pos);
|
||||||
|
self.cursor_tile = tile;
|
||||||
|
}
|
||||||
|
InputEvent::MouseWheel { delta_x, delta_y } => {
|
||||||
|
self.mouse_wheel_delta.0 += delta_x;
|
||||||
|
self.mouse_wheel_delta.1 += delta_y;
|
||||||
|
// Mouse wheel = camera interaction
|
||||||
|
if delta_x.abs() > 0.0 || delta_y.abs() > 0.0 {
|
||||||
|
self.camera_interaction = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
InputEvent::KeyPress { key, state } => match state {
|
||||||
|
ButtonState::Pressed => {
|
||||||
|
if !self.keys_pressed.contains(&key) {
|
||||||
|
self.keys_pressed.push(key);
|
||||||
|
self.keys_just_pressed.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ButtonState::Released => {
|
||||||
|
self.keys_pressed.retain(|&k| k != key);
|
||||||
|
self.keys_just_released.push(key);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a mouse button was just pressed this frame
|
||||||
|
pub fn mouse_just_pressed(&self, button: MouseButton) -> bool {
|
||||||
|
self.mouse_buttons.iter().any(|&(b, s)| b == button && s == ButtonState::Pressed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a mouse button was just released this frame
|
||||||
|
pub fn mouse_just_released(&self, button: MouseButton) -> bool {
|
||||||
|
self.mouse_buttons.iter().any(|&(b, s)| b == button && s == ButtonState::Released)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a key is currently pressed
|
||||||
|
pub fn key_pressed(&self, key: KeyCode) -> bool {
|
||||||
|
self.keys_pressed.contains(&key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a key was just pressed this frame
|
||||||
|
pub fn key_just_pressed(&self, key: KeyCode) -> bool {
|
||||||
|
self.keys_just_pressed.contains(&key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a key was just released this frame
|
||||||
|
pub fn key_just_released(&self, key: KeyCode) -> bool {
|
||||||
|
self.keys_just_released.contains(&key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current cursor position in world coordinates
|
||||||
|
pub fn cursor_world_pos(&self) -> Option<WorldPos> {
|
||||||
|
self.cursor_world_pos
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current tile under cursor
|
||||||
|
pub fn cursor_tile(&self) -> Option<TileCoord> {
|
||||||
|
self.cursor_tile
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get mouse wheel delta for this frame
|
||||||
|
pub fn mouse_wheel_delta(&self) -> (f32, f32) {
|
||||||
|
self.mouse_wheel_delta
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if camera was interacted with (for filtering clicks)
|
||||||
|
pub fn had_camera_interaction(&self) -> bool {
|
||||||
|
self.camera_interaction
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark that camera was interacted with
|
||||||
|
pub fn set_camera_interaction(&mut self) {
|
||||||
|
self.camera_interaction = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Coordinate conversion utilities
|
||||||
|
pub mod coords {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Convert screen position to world position
|
||||||
|
pub fn screen_to_world(screen: ScreenPos, camera: &CameraState) -> WorldPos {
|
||||||
|
// Adjust for camera position and zoom
|
||||||
|
let world_x = (screen.x - camera.viewport_width / 2.0) / camera.zoom + camera.x;
|
||||||
|
let world_y = (screen.y - camera.viewport_height / 2.0) / camera.zoom + camera.y;
|
||||||
|
WorldPos { x: world_x, y: world_y }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert world position to screen position
|
||||||
|
pub fn world_to_screen(world: WorldPos, camera: &CameraState) -> ScreenPos {
|
||||||
|
let screen_x = (world.x - camera.x) * camera.zoom + camera.viewport_width / 2.0;
|
||||||
|
let screen_y = (world.y - camera.y) * camera.zoom + camera.viewport_height / 2.0;
|
||||||
|
ScreenPos { x: screen_x, y: screen_y }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert world position to tile coordinates
|
||||||
|
pub fn world_to_tile(world: WorldPos, map_width: u32, map_height: u32, pixel_scale: f32) -> Option<TileCoord> {
|
||||||
|
// Adjust for centered map
|
||||||
|
let half_width = (map_width as f32 * pixel_scale) / 2.0;
|
||||||
|
let half_height = (map_height as f32 * pixel_scale) / 2.0;
|
||||||
|
|
||||||
|
let adjusted_x = world.x + half_width;
|
||||||
|
let adjusted_y = world.y + half_height;
|
||||||
|
|
||||||
|
let tile_x = (adjusted_x / pixel_scale) as i32;
|
||||||
|
let tile_y = (adjusted_y / pixel_scale) as i32;
|
||||||
|
|
||||||
|
if tile_x >= 0 && tile_x < map_width as i32 && tile_y >= 0 && tile_y < map_height as i32 { Some(TileCoord { x: tile_x as u32, y: tile_y as u32 }) } else { None }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert tile coordinates to world position (center of tile)
|
||||||
|
pub fn tile_to_world(tile: TileCoord, map_width: u32, map_height: u32, pixel_scale: f32) -> WorldPos {
|
||||||
|
let half_width = (map_width as f32 * pixel_scale) / 2.0;
|
||||||
|
let half_height = (map_height as f32 * pixel_scale) / 2.0;
|
||||||
|
|
||||||
|
WorldPos { x: (tile.x as f32 + 0.5) * pixel_scale - half_width, y: (tile.y as f32 + 0.5) * pixel_scale - half_height }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert tile index to world position
|
||||||
|
pub fn tile_index_to_world(index: usize, map_width: u32, map_height: u32, pixel_scale: f32) -> WorldPos {
|
||||||
|
let tile = TileCoord::from_index(index, map_width);
|
||||||
|
tile_to_world(tile, map_width, map_height, pixel_scale)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert screen position directly to tile (combines screen_to_world and world_to_tile)
|
||||||
|
pub fn screen_to_tile(screen: ScreenPos, camera: &CameraState, map_width: u32, map_height: u32, pixel_scale: f32) -> Option<TileCoord> {
|
||||||
|
let world = screen_to_world(screen, camera);
|
||||||
|
world_to_tile(world, map_width, map_height, pixel_scale)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tile_coord_conversion() {
|
||||||
|
let tile = TileCoord { x: 5, y: 3 };
|
||||||
|
let index = tile.to_index(10);
|
||||||
|
assert_eq!(index, 35); // 3 * 10 + 5
|
||||||
|
|
||||||
|
let tile2 = TileCoord::from_index(35, 10);
|
||||||
|
assert_eq!(tile2.x, 5);
|
||||||
|
assert_eq!(tile2.y, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_input_state() {
|
||||||
|
let mut state = InputState::new();
|
||||||
|
|
||||||
|
// Test key press
|
||||||
|
state.handle_event(InputEvent::KeyPress { key: KeyCode::KeyC, state: ButtonState::Pressed });
|
||||||
|
assert!(state.key_just_pressed(KeyCode::KeyC));
|
||||||
|
assert!(state.key_pressed(KeyCode::KeyC));
|
||||||
|
|
||||||
|
// Clear frame data
|
||||||
|
state.clear_frame_data();
|
||||||
|
assert!(!state.key_just_pressed(KeyCode::KeyC));
|
||||||
|
assert!(state.key_pressed(KeyCode::KeyC)); // Still pressed
|
||||||
|
|
||||||
|
// Release key
|
||||||
|
state.handle_event(InputEvent::KeyPress { key: KeyCode::KeyC, state: ButtonState::Released });
|
||||||
|
assert!(state.key_just_released(KeyCode::KeyC));
|
||||||
|
assert!(!state.key_pressed(KeyCode::KeyC));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_coordinate_conversion() {
|
||||||
|
let camera = CameraState { x: 100.0, y: 100.0, zoom: 2.0, viewport_width: 800.0, viewport_height: 600.0 };
|
||||||
|
|
||||||
|
let screen = ScreenPos { x: 400.0, y: 300.0 };
|
||||||
|
let world = coords::screen_to_world(screen, &camera);
|
||||||
|
assert_eq!(world.x, 100.0); // Center of screen = camera position
|
||||||
|
assert_eq!(world.y, 100.0);
|
||||||
|
|
||||||
|
// Test round trip
|
||||||
|
let screen2 = coords::world_to_screen(world, &camera);
|
||||||
|
assert!((screen2.x - screen.x).abs() < 0.001);
|
||||||
|
assert!((screen2.y - screen.y).abs() < 0.001);
|
||||||
|
}
|
||||||
|
}
|
||||||
160
crates/borders-core/src/ui/leaderboard.rs
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
//! 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 bevy_ecs::prelude::*;
|
||||||
|
|
||||||
|
use crate::game::GameInstance;
|
||||||
|
use crate::networking::GameView;
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
use web_time::Instant;
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resource to track last emitted leaderboard state for deduplication
|
||||||
|
#[derive(Resource, Default, Debug)]
|
||||||
|
pub struct LastLeaderboardDigest {
|
||||||
|
pub entries: Vec<(u16, 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<(usize, Option<usize>, u32, u64, bool)>, // (attacker_id, target_id, troops, start_turn, is_outgoing)
|
||||||
|
pub turn: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resource to throttle leaderboard snapshot emissions
|
||||||
|
#[derive(Resource, Debug)]
|
||||||
|
pub struct LeaderboardThrottle {
|
||||||
|
last_emission: Option<Instant>,
|
||||||
|
throttle_duration: core::time::Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for LeaderboardThrottle {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
last_emission: None,
|
||||||
|
throttle_duration: core::time::Duration::from_millis(1250), // 1.25 seconds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a complete leaderboard snapshot from current game state
|
||||||
|
/// Returns None if nothing has changed since last_digest
|
||||||
|
pub fn build_leaderboard_snapshot(game_view: &GameView, game: &GameInstance, last_digest: &mut LastLeaderboardDigest) -> Option<LeaderboardSnapshot> {
|
||||||
|
// Use cached total_land_tiles from GameView (performance optimization)
|
||||||
|
let total_land_tiles = game_view.total_land_tiles;
|
||||||
|
|
||||||
|
// Get player manager to look up names/colors
|
||||||
|
let players_by_id: std::collections::HashMap<usize, &crate::game::Player> = game.player_manager.get_players().iter().map(|p| (p.id, p)).collect();
|
||||||
|
|
||||||
|
// Build current digest for comparison (includes names now)
|
||||||
|
let current_entries: Vec<(u16, String, u32, u32)> = game_view
|
||||||
|
.players
|
||||||
|
.iter()
|
||||||
|
.map(|p| {
|
||||||
|
let player = players_by_id.get(&(p.id as usize));
|
||||||
|
let name = player.map(|pl| if pl.name.is_empty() { if pl.id == game.player_manager.client_player_id { "Player".to_string() } else { format!("Nation {}", pl.id) } } else { pl.name.clone() }).unwrap_or_else(|| format!("Nation {}", p.id));
|
||||||
|
(p.id, name, p.tile_count, p.troops)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Check if anything has changed (stats OR names)
|
||||||
|
if current_entries == last_digest.entries && game.turn_number == last_digest.turn {
|
||||||
|
return None; // No changes
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update digest
|
||||||
|
last_digest.entries = current_entries;
|
||||||
|
last_digest.turn = game.turn_number;
|
||||||
|
|
||||||
|
// Build complete leaderboard entries (names + colors + stats)
|
||||||
|
let mut entries: Vec<LeaderboardEntry> = game_view
|
||||||
|
.players
|
||||||
|
.iter()
|
||||||
|
.map(|player| {
|
||||||
|
let player_data = players_by_id.get(&(player.id as usize));
|
||||||
|
|
||||||
|
let name = player_data.map(|p| if p.name.is_empty() { if p.id == game.player_manager.client_player_id { "Player".to_string() } else { format!("Nation {}", p.id) } } else { p.name.clone() }).unwrap_or_else(|| format!("Nation {}", player.id));
|
||||||
|
|
||||||
|
let color = player_data.map(|p| rgba_to_hex(p.color.to_rgba())).unwrap_or_else(|| "808080".to_string()); // Gray fallback
|
||||||
|
|
||||||
|
let territory_percent = if total_land_tiles > 0 { player.tile_count as f32 / total_land_tiles as f32 } else { 0.0 };
|
||||||
|
|
||||||
|
LeaderboardEntry { id: player.id, name, color, tile_count: player.tile_count, troops: player.troops, territory_percent }
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Sort by tile count descending
|
||||||
|
entries.sort_by(|a, b| b.tile_count.cmp(&a.tile_count));
|
||||||
|
|
||||||
|
Some(LeaderboardSnapshot { turn: game.turn_number, total_land_tiles, entries, client_player_id: game.player_manager.client_player_id as u16 })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bevy system that emits leaderboard snapshot events
|
||||||
|
pub fn emit_leaderboard_snapshot_system(game_view: Res<GameView>, game: Res<GameInstance>, mut last_digest: ResMut<LastLeaderboardDigest>, mut throttle: ResMut<LeaderboardThrottle>, mut backend_messages: MessageWriter<BackendMessage>) {
|
||||||
|
let _span = tracing::debug_span!("emit_leaderboard_snapshot").entered();
|
||||||
|
|
||||||
|
// Check if enough time has passed since last emission
|
||||||
|
let now = Instant::now();
|
||||||
|
let should_emit = throttle.last_emission.map(|last| now.duration_since(last) >= throttle.throttle_duration).unwrap_or(true); // Emit on first call
|
||||||
|
|
||||||
|
if !should_emit {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(snapshot) = build_leaderboard_snapshot(&game_view, &game, &mut last_digest) {
|
||||||
|
backend_messages.write(BackendMessage::LeaderboardSnapshot(snapshot));
|
||||||
|
throttle.last_emission = Some(now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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(game: &GameInstance, client_player_id: usize, last_digest: &mut LastAttacksDigest) -> Option<AttacksUpdatePayload> {
|
||||||
|
// Get attacks for the client player
|
||||||
|
let raw_attacks = game.active_attacks.get_attacks_for_player(client_player_id);
|
||||||
|
|
||||||
|
// Build current digest for comparison
|
||||||
|
let current_entries: Vec<(usize, Option<usize>, u32, u64, bool)> = raw_attacks.iter().map(|&(attacker_id, target_id, troops, start_turn, is_outgoing)| (attacker_id, target_id, troops as u32, start_turn, is_outgoing)).collect();
|
||||||
|
|
||||||
|
// Check if anything has changed
|
||||||
|
if current_entries == last_digest.entries {
|
||||||
|
return None; // No changes
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update digest
|
||||||
|
last_digest.entries = current_entries;
|
||||||
|
last_digest.turn = game.turn_number;
|
||||||
|
|
||||||
|
// Build attack entries
|
||||||
|
let entries: Vec<AttackEntry> = raw_attacks.into_iter().map(|(attacker_id, target_id, troops, start_turn, is_outgoing)| AttackEntry { attacker_id: attacker_id as u16, target_id: target_id.map(|id| id as u16), troops: troops as u32, start_turn, is_outgoing }).collect();
|
||||||
|
|
||||||
|
Some(AttacksUpdatePayload { turn: game.turn_number, entries })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bevy system that emits attacks update events
|
||||||
|
pub fn emit_attacks_update_system(game: Res<GameInstance>, mut last_digest: ResMut<LastAttacksDigest>, mut backend_messages: MessageWriter<BackendMessage>) {
|
||||||
|
let _span = tracing::debug_span!("emit_attacks_update").entered();
|
||||||
|
let client_player_id = game.player_manager.client_player_id;
|
||||||
|
|
||||||
|
if let Some(payload) = build_attacks_update(&game, client_player_id, &mut last_digest) {
|
||||||
|
backend_messages.write(BackendMessage::AttacksUpdate(payload));
|
||||||
|
}
|
||||||
|
}
|
||||||
104
crates/borders-core/src/ui/mod.rs
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
//! UI/Frontend module for rendering and user interaction
|
||||||
|
//!
|
||||||
|
//! This module contains all frontend-related concerns including:
|
||||||
|
//! - Protocol definitions for frontend-backend communication
|
||||||
|
//! - Input handling
|
||||||
|
//! - Leaderboard management
|
||||||
|
//! - Platform transport abstraction
|
||||||
|
|
||||||
|
pub mod input;
|
||||||
|
pub mod leaderboard;
|
||||||
|
pub mod plugin;
|
||||||
|
pub mod protocol;
|
||||||
|
pub mod transport;
|
||||||
|
|
||||||
|
// Re-export commonly used types
|
||||||
|
pub use input::{InputEvent, InputState, KeyCode, MouseButton, TileCoord, WorldPos};
|
||||||
|
pub use leaderboard::{LastAttacksDigest, LastLeaderboardDigest, LeaderboardThrottle, build_attacks_update, build_leaderboard_snapshot, emit_attacks_update_system, emit_leaderboard_snapshot_system};
|
||||||
|
pub use plugin::FrontendPlugin;
|
||||||
|
pub use protocol::{AttackEntry, AttacksUpdatePayload, BackendMessage, CameraCommand, CameraStateUpdate, FrontendMessage, GameOutcome, LeaderboardEntry, LeaderboardSnapshot, MapQuery, MapQueryResponse, PaletteInit, RenderInit, RenderInputEvent, RgbColor, ShipUpdateVariant, ShipsUpdatePayload, SpawnCountdown, TerrainInit, TerrainPalette, TerrainType, TerritoryDelta, TerritorySnapshot, TileChange};
|
||||||
|
pub use transport::{FrontendTransport, RenderBridge, handle_camera_update, handle_render_input};
|
||||||
|
|
||||||
|
use crate::networking::GameView;
|
||||||
|
use bevy_ecs::prelude::*;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
/// Resource to track currently highlighted nation for visual feedback
|
||||||
|
#[derive(Resource, Default, Debug)]
|
||||||
|
pub struct NationHighlightState {
|
||||||
|
pub highlighted_nation: Option<u16>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// System that tracks hovered nation and emits highlight events
|
||||||
|
pub fn emit_nation_highlight_system(input_state: NonSend<std::sync::Arc<std::sync::Mutex<InputState>>>, game_view: Res<GameView>, mut highlight_state: ResMut<NationHighlightState>, mut backend_messages: MessageWriter<BackendMessage>) {
|
||||||
|
let Ok(input) = input_state.lock() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let new_highlighted = if let Some(tile_coord) = input.cursor_tile() {
|
||||||
|
let tile_index = tile_coord.to_index(game_view.width);
|
||||||
|
let owner_id = game_view.get_owner(tile_index);
|
||||||
|
|
||||||
|
// Water (65535) and unclaimed (65534) should clear highlight
|
||||||
|
if owner_id >= 65534 { None } else { Some(owner_id) }
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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(game_view: Res<GameView>, mut ship_tracker: ResMut<ShipStateTracker>, mut backend_messages: MessageWriter<BackendMessage>) {
|
||||||
|
let current_ship_ids: std::collections::HashSet<u32> = game_view.ships.iter().map(|s| s.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 in &game_view.ships {
|
||||||
|
match ship_tracker.ship_indices.get(&ship.id) {
|
||||||
|
None => {
|
||||||
|
// New ship - send Create
|
||||||
|
updates.push(ShipUpdateVariant::Create { id: ship.id, owner_id: ship.owner_id, path: ship.path.clone(), troops: ship.troops });
|
||||||
|
ship_tracker.ship_indices.insert(ship.id, ship.path_progress);
|
||||||
|
}
|
||||||
|
Some(&prev_index) if prev_index != ship.path_progress => {
|
||||||
|
// Ship moved to next tile - send Move
|
||||||
|
updates.push(ShipUpdateVariant::Move { id: ship.id, current_path_index: ship.path_progress });
|
||||||
|
ship_tracker.ship_indices.insert(ship.id, ship.path_progress);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// 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: game_view.turn_number, updates }));
|
||||||
|
}
|
||||||
|
}
|
||||||
50
crates/borders-core/src/ui/plugin.rs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
//! Frontend plugin for UI/rendering integration
|
||||||
|
//!
|
||||||
|
//! This module provides the FrontendPlugin which handles all frontend communication
|
||||||
|
//! including rendering, input, and UI updates.
|
||||||
|
|
||||||
|
use bevy_ecs::prelude::*;
|
||||||
|
use bevy_ecs::schedule::common_conditions::resource_exists;
|
||||||
|
|
||||||
|
use crate::app::{App, Plugin, Update};
|
||||||
|
use crate::networking::GameView;
|
||||||
|
use crate::ui::protocol::{BackendMessage, FrontendMessage};
|
||||||
|
use crate::ui::transport::{FrontendTransport, RenderBridge, emit_backend_messages_system, ingest_frontend_messages_system, send_initial_render_data, stream_territory_deltas};
|
||||||
|
|
||||||
|
/// Plugin to add frontend communication and UI systems to Bevy
|
||||||
|
pub struct FrontendPlugin<T: FrontendTransport> {
|
||||||
|
transport: T,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: FrontendTransport> FrontendPlugin<T> {
|
||||||
|
pub fn new(transport: T) -> Self {
|
||||||
|
Self { transport }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: FrontendTransport> Plugin for FrontendPlugin<T> {
|
||||||
|
fn build(&self, app: &mut App) {
|
||||||
|
// Register message event types
|
||||||
|
app.add_message::<BackendMessage>();
|
||||||
|
app.add_message::<FrontendMessage>();
|
||||||
|
|
||||||
|
// Insert the bridge resource
|
||||||
|
app.insert_resource(RenderBridge::new(self.transport.clone()));
|
||||||
|
|
||||||
|
// Add render systems
|
||||||
|
app.add_systems(Update, (send_initial_render_data::<T>.run_if(resource_exists::<GameView>), stream_territory_deltas::<T>.run_if(resource_exists::<GameView>)).chain());
|
||||||
|
|
||||||
|
// Add communication systems
|
||||||
|
app.add_systems(Update, (emit_backend_messages_system::<T>, ingest_frontend_messages_system::<T>, reset_bridge_on_quit_system::<T>));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// System to reset the render bridge when a game is quit
|
||||||
|
/// This ensures fresh initialization data is sent when starting a new game
|
||||||
|
fn reset_bridge_on_quit_system<T: FrontendTransport>(game_view: Option<Res<GameView>>, mut bridge: ResMut<RenderBridge<T>>) {
|
||||||
|
// If GameView doesn't exist but bridge is initialized, reset it
|
||||||
|
if game_view.is_none() && bridge.initialized {
|
||||||
|
bridge.reset();
|
||||||
|
tracing::debug!("RenderBridge reset - ready for next game initialization");
|
||||||
|
}
|
||||||
|
}
|
||||||
518
crates/borders-core/src/ui/protocol.rs
Normal file
@@ -0,0 +1,518 @@
|
|||||||
|
//! 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 serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// All messages sent from backend to frontend
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Message)]
|
||||||
|
#[serde(tag = "msg_type")]
|
||||||
|
pub enum BackendMessage {
|
||||||
|
/// Atomic initialization message containing terrain, palette, and initial territories
|
||||||
|
RenderInit(RenderInit),
|
||||||
|
|
||||||
|
/// Full territory snapshot (typically only sent at initialization)
|
||||||
|
TerritorySnapshot(TerritorySnapshot),
|
||||||
|
/// Incremental territory changes (sent each turn)
|
||||||
|
TerritoryDelta(TerritoryDelta),
|
||||||
|
|
||||||
|
/// Initial terrain data (typically sent once, now part of RenderInit)
|
||||||
|
TerrainInit(TerrainInit),
|
||||||
|
/// Terrain color palette (typically sent once, now part of RenderInit)
|
||||||
|
TerrainPalette(TerrainPalette),
|
||||||
|
/// Player color palette (typically sent once, now part of RenderInit)
|
||||||
|
PaletteInit(PaletteInit),
|
||||||
|
|
||||||
|
/// Camera control commands from backend to frontend
|
||||||
|
CameraCommand(CameraCommand),
|
||||||
|
|
||||||
|
/// Response to map queries
|
||||||
|
MapQueryResponse(MapQueryResponse),
|
||||||
|
|
||||||
|
/// 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<u16> },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All messages sent from frontend to backend
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Message)]
|
||||||
|
#[serde(tag = "msg_type")]
|
||||||
|
pub enum FrontendMessage {
|
||||||
|
/// Input event (mouse clicks, keyboard, hover)
|
||||||
|
InputEvent(RenderInputEvent),
|
||||||
|
|
||||||
|
/// Camera state update from frontend
|
||||||
|
CameraStateUpdate(CameraStateUpdate),
|
||||||
|
|
||||||
|
/// Query about the map state
|
||||||
|
MapQuery(MapQuery),
|
||||||
|
|
||||||
|
/// Start a new game
|
||||||
|
StartGame,
|
||||||
|
/// Quit the current game and return to menu
|
||||||
|
QuitGame,
|
||||||
|
/// Pause the game (local/singleplayer only)
|
||||||
|
PauseGame,
|
||||||
|
/// Resume the game (local/singleplayer only)
|
||||||
|
ResumeGame,
|
||||||
|
/// 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initial terrain data for the entire map
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TerrainInit {
|
||||||
|
/// Map width in tiles
|
||||||
|
pub width: u32,
|
||||||
|
/// Map height in tiles
|
||||||
|
pub height: u32,
|
||||||
|
/// Tile type IDs (one u8 per tile, referencing TerrainPalette)
|
||||||
|
/// Each value is an index into the terrain_palette colors array
|
||||||
|
#[serde(with = "serde_bytes")]
|
||||||
|
pub terrain_data: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TerrainInit {
|
||||||
|
/// Create terrain data from tile type IDs
|
||||||
|
pub fn from_tile_ids(width: u32, height: u32, tile_ids: Vec<u8>) -> Self {
|
||||||
|
assert_eq!(tile_ids.len(), (width * height) as usize, "Terrain data size mismatch");
|
||||||
|
Self { width, height, terrain_data: tile_ids }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create terrain data from a legacy terrain enum array (for backward compatibility)
|
||||||
|
pub fn from_terrain(width: u32, height: u32, terrain: &[TerrainType]) -> Self {
|
||||||
|
let terrain_data: Vec<u8> = terrain.iter().map(|&t| t as u8).collect();
|
||||||
|
Self { width, height, terrain_data }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract tile type ID for a specific tile
|
||||||
|
pub fn get_tile_id(&self, index: usize) -> u8 {
|
||||||
|
self.terrain_data.get(index).copied().unwrap_or(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Terrain palette defining colors for each terrain shade/type
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TerrainPalette {
|
||||||
|
/// Color definitions for each terrain shade
|
||||||
|
/// Index in this array corresponds to the shade value in terrain_data
|
||||||
|
pub colors: Vec<RgbColor>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 player ID (0 = unclaimed)
|
||||||
|
pub owner_id: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delta update containing changed tiles for efficient streaming
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TerritoryDelta {
|
||||||
|
/// Turn number this delta applies to
|
||||||
|
pub turn: u64,
|
||||||
|
/// List of changed tiles since last update
|
||||||
|
pub changes: Vec<TileChange>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full territory snapshot for initial state (sparse binary format)
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TerritorySnapshot {
|
||||||
|
/// Turn number for this snapshot
|
||||||
|
pub turn: u64,
|
||||||
|
/// Binary-encoded sparse territory data
|
||||||
|
/// Format: [count:4][changes...]
|
||||||
|
/// where changes = [index:4][owner:2] repeated count times
|
||||||
|
/// All tiles not in this list default to owner_id=0 (unclaimed)
|
||||||
|
#[serde(with = "serde_bytes")]
|
||||||
|
pub data: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TerritorySnapshot {
|
||||||
|
/// Create sparse binary snapshot from full territory array
|
||||||
|
/// Only includes claimed tiles (owner_id != 0)
|
||||||
|
pub fn encode(turn: u64, territories: &[u16]) -> Self {
|
||||||
|
// Collect claimed tiles (non-zero owner IDs)
|
||||||
|
let claimed_tiles: Vec<(u32, u16)> = territories.iter().enumerate().filter(|&(_, &owner)| owner != 0).map(|(index, &owner)| (index as u32, owner)).collect();
|
||||||
|
|
||||||
|
let count = claimed_tiles.len() as u32;
|
||||||
|
let mut data = Vec::with_capacity(4 + claimed_tiles.len() * 6);
|
||||||
|
|
||||||
|
// Encode count (4 bytes)
|
||||||
|
data.extend_from_slice(&count.to_le_bytes());
|
||||||
|
|
||||||
|
// Encode each claimed tile (6 bytes each)
|
||||||
|
for (index, owner) in claimed_tiles {
|
||||||
|
data.extend_from_slice(&index.to_le_bytes());
|
||||||
|
data.extend_from_slice(&owner.to_le_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
Self { turn, data }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode binary snapshot back to list of claimed tiles
|
||||||
|
/// Returns list of (tile_index, owner_id) pairs
|
||||||
|
pub fn decode(&self) -> Option<Vec<(u32, u16)>> {
|
||||||
|
if self.data.len() < 4 {
|
||||||
|
return None; // Not enough data for count
|
||||||
|
}
|
||||||
|
|
||||||
|
let count = u32::from_le_bytes([self.data[0], self.data[1], self.data[2], self.data[3]]) as usize;
|
||||||
|
|
||||||
|
let expected_size = 4 + count * 6;
|
||||||
|
if self.data.len() != expected_size {
|
||||||
|
return None; // Invalid size
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut tiles = Vec::with_capacity(count);
|
||||||
|
for i in 0..count {
|
||||||
|
let offset = 4 + i * 6;
|
||||||
|
let index = u32::from_le_bytes([self.data[offset], self.data[offset + 1], self.data[offset + 2], self.data[offset + 3]]);
|
||||||
|
let owner = u16::from_le_bytes([self.data[offset + 4], self.data[offset + 5]]);
|
||||||
|
tiles.push((index, owner));
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(tiles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Binary format for efficient territory delta streaming (Tauri)
|
||||||
|
/// 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 player palette
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||||
|
pub struct RgbColor {
|
||||||
|
pub r: u8,
|
||||||
|
pub g: u8,
|
||||||
|
pub b: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initial palette data mapping player IDs to colors
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PaletteInit {
|
||||||
|
/// Player ID to color mapping
|
||||||
|
/// Index in the vec corresponds to player_id
|
||||||
|
pub colors: Vec<RgbColor>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Commands sent from backend to control the camera
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, bevy_ecs::message::Message)]
|
||||||
|
#[serde(tag = "type")]
|
||||||
|
pub enum CameraCommand {
|
||||||
|
/// Center camera on a specific tile
|
||||||
|
CenterOnTile {
|
||||||
|
tile_index: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
animate: bool,
|
||||||
|
},
|
||||||
|
/// Highlight a rectangular region
|
||||||
|
HighlightRegion {
|
||||||
|
x: u32,
|
||||||
|
y: u32,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
#[serde(default = "default_highlight_duration")]
|
||||||
|
duration_ms: u32,
|
||||||
|
},
|
||||||
|
/// Set camera zoom level
|
||||||
|
SetZoom {
|
||||||
|
zoom: f32,
|
||||||
|
#[serde(default)]
|
||||||
|
animate: bool,
|
||||||
|
},
|
||||||
|
/// Pan camera by offset
|
||||||
|
PanBy {
|
||||||
|
dx: f32,
|
||||||
|
dy: f32,
|
||||||
|
#[serde(default)]
|
||||||
|
animate: bool,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_highlight_duration() -> u32 {
|
||||||
|
3000 // 3 seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Camera state update sent from frontend to backend
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CameraStateUpdate {
|
||||||
|
/// Current camera X position (world coordinates)
|
||||||
|
pub x: f32,
|
||||||
|
/// Current camera Y position (world coordinates)
|
||||||
|
pub y: f32,
|
||||||
|
/// Current zoom level (1.0 = normal)
|
||||||
|
pub zoom: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 { x: f32, y: f32 },
|
||||||
|
/// Get detailed tile info by index
|
||||||
|
GetTileInfo { tile_index: u32 },
|
||||||
|
/// Find any tile owned by player (for camera centering)
|
||||||
|
FindPlayerTerritory { player_id: u16 },
|
||||||
|
/// Convert screen coordinates to tile index
|
||||||
|
ScreenToTile { screen_x: f32, screen_y: f32 },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response to map queries
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type")]
|
||||||
|
pub enum MapQueryResponse {
|
||||||
|
/// Response to GetOwnerAt
|
||||||
|
Owner { owner_id: u16, tile_index: Option<u32> },
|
||||||
|
/// Response to GetTileInfo
|
||||||
|
TileInfo { tile_index: u32, owner_id: u16, terrain: TerrainType, troops: u32 },
|
||||||
|
/// Response to FindPlayerTerritory
|
||||||
|
PlayerTerritory { tile_index: Option<u32> },
|
||||||
|
/// Response to ScreenToTile
|
||||||
|
TileIndex { index: Option<u32> },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Input event sent from frontend to backend
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type")]
|
||||||
|
pub enum RenderInputEvent {
|
||||||
|
/// Mouse click on the map
|
||||||
|
MapClick {
|
||||||
|
/// Tile index clicked (if over a valid tile)
|
||||||
|
tile_index: Option<u32>,
|
||||||
|
/// World coordinates of click
|
||||||
|
world_x: f32,
|
||||||
|
world_y: f32,
|
||||||
|
/// Mouse button (0=left, 1=middle, 2=right)
|
||||||
|
button: u8,
|
||||||
|
},
|
||||||
|
/// Key press event
|
||||||
|
KeyPress {
|
||||||
|
/// Key code as string (e.g., "KeyC", "Digit1")
|
||||||
|
key: String,
|
||||||
|
/// Whether key is pressed (true) or released (false)
|
||||||
|
pressed: bool,
|
||||||
|
},
|
||||||
|
/// Mouse moved over map
|
||||||
|
MapHover {
|
||||||
|
/// Tile index under cursor (if any)
|
||||||
|
tile_index: Option<u32>,
|
||||||
|
/// World coordinates
|
||||||
|
world_x: f32,
|
||||||
|
world_y: f32,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atomic initialization message containing all data needed to start rendering
|
||||||
|
/// This ensures terrain, palette, and initial territory state arrive together
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct RenderInit {
|
||||||
|
pub terrain: TerrainInit,
|
||||||
|
pub terrain_palette: TerrainPalette,
|
||||||
|
pub palette: PaletteInit,
|
||||||
|
pub initial_territories: TerritorySnapshot,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unified leaderboard entry containing both static and dynamic data
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct LeaderboardEntry {
|
||||||
|
pub id: u16,
|
||||||
|
pub name: String,
|
||||||
|
pub color: String, // Hex color without alpha, e.g. "0A44FF"
|
||||||
|
pub tile_count: u32,
|
||||||
|
pub troops: u32,
|
||||||
|
pub territory_percent: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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_player_id: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 attacker_id: u16,
|
||||||
|
pub target_id: Option<u16>, // None for unclaimed territory
|
||||||
|
pub troops: u32,
|
||||||
|
pub start_turn: u64,
|
||||||
|
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: u16,
|
||||||
|
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
|
||||||
|
|
||||||
|
/// Legacy ship data (deprecated - remove after migration)
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[deprecated(note = "Use ShipUpdateVariant instead")]
|
||||||
|
pub struct ShipData {
|
||||||
|
pub id: u32,
|
||||||
|
pub owner_id: u16,
|
||||||
|
pub current_tile: u32,
|
||||||
|
pub target_tile: u32,
|
||||||
|
pub troops: u32,
|
||||||
|
pub path_progress: u32,
|
||||||
|
pub ticks_until_move: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Countdown state for spawn phase
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SpawnCountdown {
|
||||||
|
pub started_at_ms: u64,
|
||||||
|
pub duration_secs: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_terrain_tile_ids() {
|
||||||
|
let terrain = vec![TerrainType::Water, TerrainType::Land, TerrainType::Mountain, TerrainType::Land, TerrainType::Water];
|
||||||
|
|
||||||
|
let init = TerrainInit::from_terrain(5, 1, &terrain);
|
||||||
|
assert_eq!(init.terrain_data.len(), 5);
|
||||||
|
|
||||||
|
assert_eq!(init.get_tile_id(0), TerrainType::Water as u8);
|
||||||
|
assert_eq!(init.get_tile_id(1), TerrainType::Land as u8);
|
||||||
|
assert_eq!(init.get_tile_id(2), TerrainType::Mountain as u8);
|
||||||
|
assert_eq!(init.get_tile_id(3), TerrainType::Land as u8);
|
||||||
|
assert_eq!(init.get_tile_id(4), TerrainType::Water as u8);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_binary_delta_encoding() {
|
||||||
|
let changes = vec![TileChange { index: 100, owner_id: 1 }, TileChange { index: 200, owner_id: 2 }, TileChange { index: 300, owner_id: 0 }];
|
||||||
|
|
||||||
|
let encoded = BinaryTerritoryDelta::encode(42, &changes);
|
||||||
|
assert_eq!(encoded.len(), 12 + 3 * 6);
|
||||||
|
|
||||||
|
let decoded = BinaryTerritoryDelta::decode(&encoded).unwrap();
|
||||||
|
assert_eq!(decoded.0, 42);
|
||||||
|
assert_eq!(decoded.1.len(), 3);
|
||||||
|
assert_eq!(decoded.1[0].index, 100);
|
||||||
|
assert_eq!(decoded.1[0].owner_id, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
236
crates/borders-core/src/ui/transport.rs
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
//! 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::TerrainData;
|
||||||
|
use crate::networking::GameView;
|
||||||
|
use crate::ui::protocol::{BackendMessage, BinaryTerritoryDelta, CameraStateUpdate, FrontendMessage, PaletteInit, RenderInit, RenderInputEvent, RgbColor, TerrainInit, TerrainPalette, TerritoryDelta, TerritorySnapshot, 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 (events), and other platforms to implement
|
||||||
|
/// their own transport while sharing the core logic.
|
||||||
|
pub trait FrontendTransport: Send + Sync + Clone + 'static {
|
||||||
|
/// Send a message from backend to frontend
|
||||||
|
fn send_backend_message(&self, message: &BackendMessage) -> Result<(), String>;
|
||||||
|
|
||||||
|
/// Send binary territory delta data (optional, mainly for Tauri)
|
||||||
|
///
|
||||||
|
/// Default implementation does nothing - platforms that support
|
||||||
|
/// binary streaming can override this.
|
||||||
|
fn send_binary_delta(&self, _data: Vec<u8>) -> Result<(), String> {
|
||||||
|
Ok(()) // No-op by default
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<T: FrontendTransport> {
|
||||||
|
pub transport: T,
|
||||||
|
/// Track if we've sent initial data
|
||||||
|
pub(crate) initialized: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: FrontendTransport> RenderBridge<T> {
|
||||||
|
pub fn new(transport: T) -> 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<T: FrontendTransport>(game_view: Option<Res<GameView>>, terrain_data: Option<Res<TerrainData>>, mut bridge: Option<ResMut<RenderBridge<T>>>) {
|
||||||
|
let Some(game_view) = game_view else {
|
||||||
|
trace!("send_initial_render_data: GameView not available yet");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(terrain_data) = terrain_data else {
|
||||||
|
trace!("send_initial_render_data: TerrainData not available yet");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(ref mut bridge) = bridge else {
|
||||||
|
error!("send_initial_render_data: RenderBridge not available");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Early return if already initialized - prevents duplicate sends
|
||||||
|
if bridge.initialized {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't send initial data for empty game view
|
||||||
|
if game_view.width == 0 || game_view.height == 0 || game_view.players.is_empty() {
|
||||||
|
trace!("send_initial_render_data: GameView not yet populated, waiting...");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Building atomic RenderInit message (map: {}x{}, {} players)", game_view.width, game_view.height, game_view.players.len());
|
||||||
|
|
||||||
|
// Build terrain component from TerrainData
|
||||||
|
let width = game_view.width;
|
||||||
|
let height = game_view.height;
|
||||||
|
let tile_ids = terrain_data.get_tile_ids().to_vec();
|
||||||
|
let terrain = TerrainInit::from_tile_ids(width, height, tile_ids);
|
||||||
|
|
||||||
|
// Build terrain palette from TerrainData
|
||||||
|
let palette_colors = terrain_data.get_terrain_palette_colors();
|
||||||
|
let terrain_palette = TerrainPalette { colors: palette_colors.into_iter().map(|[r, g, b]| RgbColor { r, g, b }).collect() };
|
||||||
|
|
||||||
|
info!("Terrain palette: {} colors", terrain_palette.colors.len());
|
||||||
|
|
||||||
|
// Build palette component
|
||||||
|
// Allocate only enough space for active players + a small buffer
|
||||||
|
// This avoids the wasteful 65K allocation (1MB+ of memory)
|
||||||
|
let max_player_id = game_view.players.iter().map(|p| p.id).max().unwrap_or(0) as usize;
|
||||||
|
|
||||||
|
// Allocate palette size as: max(256, max_player_id + 1) to handle typical player counts
|
||||||
|
// Most games have <100 players, so this is ~256 bytes instead of 1MB+
|
||||||
|
let palette_size = (max_player_id + 1).max(256);
|
||||||
|
let mut colors = vec![RgbColor { r: 0, g: 0, b: 0 }; palette_size];
|
||||||
|
|
||||||
|
for player in &game_view.players {
|
||||||
|
// Player color is already in [r, g, b, a] format as f32
|
||||||
|
colors[player.id as usize] = RgbColor { r: (player.color[0] * 255.0) as u8, g: (player.color[1] * 255.0) as u8, b: (player.color[2] * 255.0) as u8 };
|
||||||
|
}
|
||||||
|
let palette = PaletteInit { colors };
|
||||||
|
|
||||||
|
// Build initial territory snapshot (sparse binary format)
|
||||||
|
let initial_territories = TerritorySnapshot::encode(game_view.turn_number, &game_view.territories);
|
||||||
|
|
||||||
|
// Create atomic initialization message
|
||||||
|
let render_init = RenderInit { terrain, terrain_palette, palette, initial_territories };
|
||||||
|
|
||||||
|
// Send single atomic message - all or nothing
|
||||||
|
if let Err(e) = bridge.transport.send_backend_message(&BackendMessage::RenderInit(render_init)) {
|
||||||
|
error!("Failed to send atomic RenderInit message: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only mark as initialized if send succeeded
|
||||||
|
bridge.initialized = true;
|
||||||
|
info!("Atomic RenderInit sent successfully");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// System to detect and stream territory changes
|
||||||
|
pub fn stream_territory_deltas<T: FrontendTransport>(game_view: Option<Res<GameView>>, bridge: Option<Res<RenderBridge<T>>>) {
|
||||||
|
let Some(game_view) = game_view else {
|
||||||
|
return; // GameView not ready yet
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(bridge) = bridge else {
|
||||||
|
return; // Bridge not initialized
|
||||||
|
};
|
||||||
|
|
||||||
|
// Gate: Don't send deltas until initial render data has been sent
|
||||||
|
if !bridge.initialized {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if GameView hasn't changed
|
||||||
|
if !game_view.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _span = tracing::debug_span!("stream_territory_deltas").entered();
|
||||||
|
|
||||||
|
// Use efficient changed_tiles from TerritoryManager's ChangeBuffer instead of scanning
|
||||||
|
if !game_view.changed_tiles.is_empty() {
|
||||||
|
let turn = game_view.turn_number;
|
||||||
|
|
||||||
|
// Build delta from the pre-tracked changes
|
||||||
|
let changes: Vec<TileChange> = game_view.changed_tiles.iter().map(|&index| TileChange { index: index as u32, owner_id: game_view.territories[index] }).collect();
|
||||||
|
|
||||||
|
// Send binary format for platforms that support it (e.g., Tauri)
|
||||||
|
let binary_data = BinaryTerritoryDelta::encode(turn, &changes);
|
||||||
|
if let Err(e) = bridge.transport.send_binary_delta(binary_data) {
|
||||||
|
error!("Failed to send binary territory delta: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send structured format
|
||||||
|
let delta = TerritoryDelta { turn, changes };
|
||||||
|
if let Err(e) = bridge.transport.send_backend_message(&BackendMessage::TerritoryDelta(delta)) {
|
||||||
|
error!("Failed to send territory delta: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle render input events from the frontend
|
||||||
|
///
|
||||||
|
/// This function processes input events and updates the shared InputState.
|
||||||
|
/// It should be called from platform-specific command handlers.
|
||||||
|
pub fn handle_render_input(event: &RenderInputEvent, input_state: &mut crate::ui::input::InputState, map_width: u32) -> Result<(), String> {
|
||||||
|
match event {
|
||||||
|
RenderInputEvent::MapClick { tile_index, world_x, world_y, button } => {
|
||||||
|
if let Some(button) = crate::ui::input::MouseButton::from_u8(*button) {
|
||||||
|
let world_pos = crate::ui::input::WorldPos { x: *world_x, y: *world_y };
|
||||||
|
|
||||||
|
let tile_coord = tile_index.map(|idx| crate::ui::input::TileCoord::from_index(idx as usize, map_width));
|
||||||
|
|
||||||
|
input_state.handle_event(crate::ui::input::InputEvent::MouseButton { button, state: crate::ui::input::ButtonState::Released, world_pos: Some(world_pos), tile: tile_coord });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RenderInputEvent::KeyPress { key, pressed } => {
|
||||||
|
if let Some(key_code) = crate::ui::input::KeyCode::from_string(key) {
|
||||||
|
let button_state = if *pressed { crate::ui::input::ButtonState::Pressed } else { crate::ui::input::ButtonState::Released };
|
||||||
|
|
||||||
|
input_state.handle_event(crate::ui::input::InputEvent::KeyPress { key: key_code, state: button_state });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RenderInputEvent::MapHover { tile_index, world_x, world_y } => {
|
||||||
|
let world_pos = crate::ui::input::WorldPos { x: *world_x, y: *world_y };
|
||||||
|
|
||||||
|
let tile_coord = tile_index.map(|idx| crate::ui::input::TileCoord::from_index(idx as usize, map_width));
|
||||||
|
|
||||||
|
input_state.handle_event(crate::ui::input::InputEvent::MouseMove {
|
||||||
|
world_pos,
|
||||||
|
screen_pos: crate::ui::input::ScreenPos { x: 0.0, y: 0.0 }, // Not used
|
||||||
|
tile: tile_coord,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle camera state updates from the frontend
|
||||||
|
///
|
||||||
|
/// This is a simple wrapper for consistency. Camera state is typically stored
|
||||||
|
/// in a shared Arc<Mutex<Option<CameraStateUpdate>>> resource.
|
||||||
|
pub fn handle_camera_update(update: CameraStateUpdate, camera_state: &mut Option<CameraStateUpdate>) -> Result<(), String> {
|
||||||
|
*camera_state = Some(update);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// System that reads BackendMessage events and sends them through the transport
|
||||||
|
pub(crate) fn emit_backend_messages_system<T: FrontendTransport>(mut events: MessageReader<BackendMessage>, bridge: Res<RenderBridge<T>>) {
|
||||||
|
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<T: FrontendTransport>(mut messages: MessageWriter<FrontendMessage>, bridge: Res<RenderBridge<T>>) {
|
||||||
|
while let Some(message) = bridge.transport.try_recv_frontend_message() {
|
||||||
|
messages.write(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
50
crates/borders-core/tests/terrain.rs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
use borders_core::game::terrain::{MapManifest, MapMetadata, TerrainData, TileType};
|
||||||
|
use borders_core::game::tilemap::TileMap;
|
||||||
|
|
||||||
|
fn create_test_terrain(width: usize, height: usize) -> TerrainData {
|
||||||
|
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 mut terrain_data_raw = vec![0; width * height];
|
||||||
|
terrain_data_raw[5] = 0x80; // Make position 5 land
|
||||||
|
terrain_data_raw[10] = 0x85; // Make position 10 land with magnitude 5
|
||||||
|
|
||||||
|
let tiles: Vec<u8> = terrain_data_raw.iter().map(|&byte| if byte & 0x80 != 0 { 1 } else { 0 }).collect();
|
||||||
|
|
||||||
|
let terrain_data = TileMap::from_vec(width as u32, height as u32, terrain_data_raw);
|
||||||
|
|
||||||
|
TerrainData { _manifest: MapManifest { map: MapMetadata { width, height, num_land_tiles: 2 }, name: "Test".to_string(), nations: Vec::new() }, terrain_data, tiles, tile_types }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_land() {
|
||||||
|
let terrain = create_test_terrain(10, 10);
|
||||||
|
assert!(!terrain.is_land((0, 0)));
|
||||||
|
assert!(terrain.is_land((5, 0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_conquerable() {
|
||||||
|
let terrain = create_test_terrain(10, 10);
|
||||||
|
assert!(!terrain.is_conquerable((0, 0)));
|
||||||
|
assert!(terrain.is_conquerable((5, 0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_navigable() {
|
||||||
|
let terrain = create_test_terrain(10, 10);
|
||||||
|
assert!(terrain.is_navigable((0, 0)));
|
||||||
|
assert!(!terrain.is_navigable((5, 0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_terrain_magnitude() {
|
||||||
|
let terrain = create_test_terrain(10, 10);
|
||||||
|
assert_eq!(terrain.terrain_magnitude((0, 1)), 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_expansion_properties() {
|
||||||
|
let terrain = create_test_terrain(10, 10);
|
||||||
|
assert_eq!(terrain.get_expansion_time((5, 0)), 50);
|
||||||
|
assert_eq!(terrain.get_expansion_cost((5, 0)), 50);
|
||||||
|
}
|
||||||
7
crates/borders-desktop/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Generated by Cargo
|
||||||
|
# will have compiled files and executables
|
||||||
|
/target/
|
||||||
|
|
||||||
|
# Generated by Tauri
|
||||||
|
# will have schema files for capabilities auto-completion
|
||||||
|
/gen/schemas
|
||||||
29
crates/borders-desktop/Cargo.toml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
[package]
|
||||||
|
name = "borders-desktop"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
tracy = ["dep:tracing-tracy", "dep:tracy-client"]
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
bevy_ecs = { version = "0.17", default-features = false, features = ["std"] }
|
||||||
|
borders-core = { path = "../borders-core", features = ["ui"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
tauri = { version = "2", features = [] }
|
||||||
|
tauri-plugin-opener = "2"
|
||||||
|
tauri-plugin-process = "2"
|
||||||
|
tokio = { version = "1", features = ["time"] }
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
tracing-tracy = { version = "0.11", default-features = false, optional = true }
|
||||||
|
tracy-client = { version = "0.18.2", optional = true }
|
||||||
|
|
||||||
|
[package.metadata.cargo-machete]
|
||||||
|
ignored = ["tauri-build"]
|
||||||
3
crates/borders-desktop/build.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
17
crates/borders-desktop/capabilities/default.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "Capability for the main window",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": [
|
||||||
|
"core:default",
|
||||||
|
{
|
||||||
|
"identifier": "opener:allow-open-url",
|
||||||
|
"allow": [
|
||||||
|
{
|
||||||
|
"url": "https://github.com/Xevion"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
crates/borders-desktop/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
crates/borders-desktop/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
crates/borders-desktop/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 974 B |
BIN
crates/borders-desktop/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
crates/borders-desktop/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
crates/borders-desktop/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
crates/borders-desktop/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
crates/borders-desktop/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 903 B |
BIN
crates/borders-desktop/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
crates/borders-desktop/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
crates/borders-desktop/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
crates/borders-desktop/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
crates/borders-desktop/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
crates/borders-desktop/icons/icon.icns
Normal file
BIN
crates/borders-desktop/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
crates/borders-desktop/icons/icon.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
51
crates/borders-desktop/src/analytics.rs
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
use borders_core::telemetry::{self, TelemetryEvent};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use tauri::Manager;
|
||||||
|
|
||||||
|
/// Analytics event from the frontend
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AnalyticsEventPayload {
|
||||||
|
pub event: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub properties: HashMap<String, serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tauri command to track analytics events from the frontend
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn track_analytics_event(payload: AnalyticsEventPayload) -> Result<(), String> {
|
||||||
|
tracing::debug!("Tracking analytics event: {}", payload.event);
|
||||||
|
|
||||||
|
let event = TelemetryEvent { event: payload.event, properties: payload.properties };
|
||||||
|
|
||||||
|
// Track the event asynchronously (Tauri handles the async context)
|
||||||
|
telemetry::track(event).await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tauri command to flush pending analytics events
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn flush_analytics() -> Result<(), String> {
|
||||||
|
if let Some(client) = telemetry::client() {
|
||||||
|
client.flush().await;
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err("Telemetry client not initialized".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tauri command to request app exit
|
||||||
|
///
|
||||||
|
/// Simply closes the window - analytics flush happens in ExitRequested event handler
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn request_exit(app_handle: tauri::AppHandle) -> Result<(), String> {
|
||||||
|
tracing::debug!("Exit requested via command");
|
||||||
|
|
||||||
|
// Close the window (will trigger ExitRequested event → analytics flush)
|
||||||
|
if let Some(window) = app_handle.get_webview_window("main") {
|
||||||
|
window.close().map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
81
crates/borders-desktop/src/main.rs
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
use crate::plugin::{TauriPlugin, generate_tauri_context};
|
||||||
|
use borders_core::app::App;
|
||||||
|
use borders_core::time::Time;
|
||||||
|
|
||||||
|
mod analytics;
|
||||||
|
mod plugin;
|
||||||
|
mod render_bridge;
|
||||||
|
|
||||||
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
|
pub fn run() {
|
||||||
|
let mut app = App::new();
|
||||||
|
|
||||||
|
// Initialize time tracking
|
||||||
|
app.insert_resource(Time::new());
|
||||||
|
|
||||||
|
TauriPlugin::new(|| tauri::Builder::default().plugin(tauri_plugin_opener::init()).plugin(tauri_plugin_process::init()).invoke_handler(tauri::generate_handler![render_bridge::send_frontend_message, render_bridge::handle_render_input, render_bridge::handle_camera_update, render_bridge::handle_map_query, render_bridge::get_game_state, analytics::track_analytics_event, analytics::flush_analytics, analytics::request_exit,]).build(generate_tauri_context()).expect("error while building tauri application")).build_and_run(app);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
// Initialize tracing before Bevy
|
||||||
|
#[cfg(feature = "tracy")]
|
||||||
|
{
|
||||||
|
// Initialize Tracy profiler client
|
||||||
|
let _client = tracy_client::Client::start();
|
||||||
|
|
||||||
|
use tracing_subscriber::layer::SubscriberExt;
|
||||||
|
|
||||||
|
tracing::subscriber::set_global_default(tracing_subscriber::registry().with(tracing_tracy::TracyLayer::default())).expect("setup tracy layer");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "tracy"))]
|
||||||
|
{
|
||||||
|
use tracing_subscriber::fmt::time::FormatTime;
|
||||||
|
use tracing_subscriber::layer::SubscriberExt;
|
||||||
|
use tracing_subscriber::util::SubscriberInitExt;
|
||||||
|
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
let log_filter = "borders_core=debug,borders_protocol=debug,borders_desktop=debug,iron_borders=debug,info";
|
||||||
|
|
||||||
|
#[cfg(not(debug_assertions))]
|
||||||
|
let log_filter = "borders_core=warn,borders_protocol=warn,iron_borders=warn,error";
|
||||||
|
|
||||||
|
struct CustomTimeFormat;
|
||||||
|
|
||||||
|
impl FormatTime for CustomTimeFormat {
|
||||||
|
fn format_time(&self, w: &mut tracing_subscriber::fmt::format::Writer<'_>) -> std::fmt::Result {
|
||||||
|
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap();
|
||||||
|
let total_secs = now.as_secs();
|
||||||
|
let nanos = now.subsec_nanos();
|
||||||
|
|
||||||
|
let secs_in_day = total_secs % 86400;
|
||||||
|
let hours = secs_in_day / 3600;
|
||||||
|
let minutes = (secs_in_day % 3600) / 60;
|
||||||
|
let seconds = secs_in_day % 60;
|
||||||
|
let millis = nanos / 1_000_000;
|
||||||
|
let micros = (nanos / 1_000) % 1_000;
|
||||||
|
|
||||||
|
write!(w, "{:02}:{:02}:{:02}.{:03}{:03}", hours, minutes, seconds, millis, micros)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing_subscriber::registry().with(tracing_subscriber::EnvFilter::new(log_filter)).with(tracing_subscriber::fmt::layer().with_timer(CustomTimeFormat)).init();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log build information
|
||||||
|
tracing::info!("Iron Borders v{}", borders_core::build_info::VERSION);
|
||||||
|
tracing::info!("Git: {} | Built: {}", borders_core::build_info::git_commit_short(), borders_core::build_info::BUILD_TIME);
|
||||||
|
tracing::info!("© 2025 Ryan Walters. All Rights Reserved.");
|
||||||
|
|
||||||
|
// Initialize telemetry
|
||||||
|
tokio::runtime::Runtime::new().unwrap().block_on(async {
|
||||||
|
borders_core::telemetry::init(borders_core::telemetry::TelemetryConfig::default()).await;
|
||||||
|
borders_core::telemetry::track_session_start().await;
|
||||||
|
});
|
||||||
|
tracing::info!("Telemetry initialized");
|
||||||
|
|
||||||
|
run();
|
||||||
|
}
|
||||||
176
crates/borders-desktop/src/plugin.rs
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
//! Tauri-Bevy integration plugin
|
||||||
|
//!
|
||||||
|
//! This module provides the main integration between Tauri and Bevy, handling
|
||||||
|
//! the main application loop and event bridging.
|
||||||
|
|
||||||
|
use borders_core::app::{App, Plugin, Update};
|
||||||
|
use borders_core::time::Time;
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
|
use tauri::{Manager, RunEvent};
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use std::time::Instant;
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
use web_time::Instant;
|
||||||
|
|
||||||
|
use crate::render_bridge::{TauriRenderBridgeTransport, cache_leaderboard_snapshot_system};
|
||||||
|
|
||||||
|
const TARGET_FPS: f64 = 60.0;
|
||||||
|
|
||||||
|
pub fn generate_tauri_context() -> tauri::Context {
|
||||||
|
tauri::generate_context!()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn setup_tauri_integration(app: &mut App, tauri_app: &tauri::AppHandle, shared_render_state: Arc<Mutex<Option<borders_core::ui::protocol::RenderInit>>>, shared_leaderboard_state: Arc<Mutex<Option<borders_core::ui::protocol::LeaderboardSnapshot>>>) {
|
||||||
|
tracing::debug!("Setup tauri integration");
|
||||||
|
|
||||||
|
// Register state for render bridge commands
|
||||||
|
tauri_app.manage(Arc::new(Mutex::new(None::<borders_core::ui::protocol::CameraStateUpdate>)));
|
||||||
|
tauri_app.manage(Arc::new(Mutex::new(None::<borders_core::networking::GameView>)));
|
||||||
|
|
||||||
|
// InputState - shared between Tauri commands and ECS systems
|
||||||
|
let input_state_shared = Arc::new(Mutex::new(borders_core::ui::input::InputState::new()));
|
||||||
|
tauri_app.manage(input_state_shared.clone());
|
||||||
|
app.insert_non_send_resource(input_state_shared);
|
||||||
|
|
||||||
|
// Register shared state with Tauri (for get_game_state command)
|
||||||
|
tauri_app.manage(shared_render_state.clone());
|
||||||
|
tauri_app.manage(shared_leaderboard_state.clone());
|
||||||
|
|
||||||
|
// Get the message queue from the transport (already added as plugin)
|
||||||
|
let transport = app.world().get_resource::<borders_core::ui::RenderBridge<TauriRenderBridgeTransport>>().expect("RenderBridge should be added by plugin");
|
||||||
|
let message_queue = transport.transport.inbound_messages();
|
||||||
|
tauri_app.manage(message_queue);
|
||||||
|
|
||||||
|
// Store shared states in world
|
||||||
|
app.insert_non_send_resource(shared_leaderboard_state);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TauriPlugin {
|
||||||
|
setup: Box<dyn Fn() -> tauri::App + Send + Sync>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TauriPlugin {
|
||||||
|
pub fn new<F>(setup: F) -> Self
|
||||||
|
where
|
||||||
|
F: Fn() -> tauri::App + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
Self { setup: Box::new(setup) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TauriPlugin {
|
||||||
|
pub fn build_and_run(self, mut app: App) -> ! {
|
||||||
|
let tauri_app = (self.setup)();
|
||||||
|
|
||||||
|
// Create shared state for game state recovery
|
||||||
|
let shared_render_state = Arc::new(Mutex::new(None::<borders_core::ui::protocol::RenderInit>));
|
||||||
|
let shared_leaderboard_state = Arc::new(Mutex::new(None::<borders_core::ui::protocol::LeaderboardSnapshot>));
|
||||||
|
|
||||||
|
// Create transport for Tauri frontend (handles both render and UI communication)
|
||||||
|
let transport = TauriRenderBridgeTransport::new(tauri_app.handle().clone(), shared_render_state.clone());
|
||||||
|
|
||||||
|
// Add the render bridge plugin to handle all frontend communication
|
||||||
|
borders_core::ui::FrontendPlugin::new(transport).build(&mut app);
|
||||||
|
|
||||||
|
// Set up Tauri integration directly (no startup system needed)
|
||||||
|
setup_tauri_integration(&mut app, tauri_app.handle(), shared_render_state, shared_leaderboard_state);
|
||||||
|
|
||||||
|
// Add the leaderboard caching system
|
||||||
|
app.add_systems(Update, cache_leaderboard_snapshot_system);
|
||||||
|
|
||||||
|
// Run the app
|
||||||
|
run_tauri_app(app, tauri_app);
|
||||||
|
std::process::exit(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run_tauri_app(app: App, tauri_app: tauri::App) {
|
||||||
|
let app_rc = Rc::new(RefCell::new(app));
|
||||||
|
let mut tauri_app = tauri_app;
|
||||||
|
let mut is_initialized = false;
|
||||||
|
let mut last_frame_time = Instant::now();
|
||||||
|
|
||||||
|
let target_frame_duration = Duration::from_secs_f64(1.0 / TARGET_FPS);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let frame_start = Instant::now();
|
||||||
|
|
||||||
|
#[allow(deprecated)]
|
||||||
|
tauri_app.run_iteration(move |_app_handle, event: RunEvent| {
|
||||||
|
match event {
|
||||||
|
tauri::RunEvent::Ready => {
|
||||||
|
// Event acknowledged, actual setup happens below
|
||||||
|
}
|
||||||
|
tauri::RunEvent::ExitRequested { .. } => {
|
||||||
|
// Track session end and flush analytics before exit
|
||||||
|
if borders_core::telemetry::client().is_some() {
|
||||||
|
tracing::debug!("ExitRequested: tracking session end and flushing analytics");
|
||||||
|
|
||||||
|
// Create a minimal runtime for blocking operations
|
||||||
|
let runtime = tokio::runtime::Builder::new_current_thread().enable_time().enable_io().build().expect("Failed to create tokio runtime for flush");
|
||||||
|
|
||||||
|
runtime.block_on(async {
|
||||||
|
// Track session end event
|
||||||
|
borders_core::telemetry::track_session_end().await;
|
||||||
|
|
||||||
|
// Flush all pending events (the batch-triggered send is now synchronous)
|
||||||
|
if let Some(client) = borders_core::telemetry::client() {
|
||||||
|
let timeout = std::time::Duration::from_millis(500);
|
||||||
|
match tokio::time::timeout(timeout, client.flush()).await {
|
||||||
|
Ok(_) => {
|
||||||
|
tracing::debug!("Analytics flushed successfully before exit")
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
tracing::warn!("Analytics flush timed out after 500ms")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => (),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if tauri_app.webview_windows().is_empty() {
|
||||||
|
tauri_app.cleanup_before_exit();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize game plugin on first iteration after Tauri is ready
|
||||||
|
if !is_initialized {
|
||||||
|
let mut app = app_rc.borrow_mut();
|
||||||
|
|
||||||
|
// Add core game plugin
|
||||||
|
borders_core::GamePlugin::new(borders_core::plugin::NetworkMode::Local).build(&mut app);
|
||||||
|
|
||||||
|
app.run_startup();
|
||||||
|
app.finish();
|
||||||
|
app.cleanup();
|
||||||
|
|
||||||
|
is_initialized = true;
|
||||||
|
last_frame_time = Instant::now(); // Reset timer after initialization
|
||||||
|
tracing::info!("Game initialized");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update time resource with delta from PREVIOUS frame
|
||||||
|
let mut app = app_rc.borrow_mut();
|
||||||
|
let delta = frame_start.duration_since(last_frame_time);
|
||||||
|
if let Some(mut time) = app.world_mut().get_resource_mut::<Time>() {
|
||||||
|
time.update(delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let frame_duration = frame_start.elapsed();
|
||||||
|
if frame_duration < target_frame_duration {
|
||||||
|
std::thread::sleep(target_frame_duration - frame_duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
last_frame_time = frame_start;
|
||||||
|
}
|
||||||
|
}
|
||||||
162
crates/borders-desktop/src/render_bridge.rs
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
//! Tauri-specific frontend transport and command handlers
|
||||||
|
//!
|
||||||
|
//! This module provides the Tauri implementation of FrontendTransport,
|
||||||
|
//! along with Tauri command handlers for input events, camera updates, and
|
||||||
|
//! state recovery.
|
||||||
|
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use bevy_ecs::message::MessageReader;
|
||||||
|
use bevy_ecs::system::NonSend;
|
||||||
|
use borders_core::networking::GameView;
|
||||||
|
use borders_core::ui::FrontendTransport;
|
||||||
|
use borders_core::ui::protocol::{BackendMessage, CameraStateUpdate, FrontendMessage, LeaderboardSnapshot, MapQuery, MapQueryResponse, RenderInit, RenderInputEvent, TerrainType};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tauri::{AppHandle, Emitter};
|
||||||
|
use tracing::trace;
|
||||||
|
|
||||||
|
/// Tauri-specific frontend transport using Tauri events
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct TauriRenderBridgeTransport {
|
||||||
|
app_handle: AppHandle,
|
||||||
|
/// Shared state for RenderInit (accessible from Tauri commands)
|
||||||
|
shared_render_state: Arc<Mutex<Option<RenderInit>>>,
|
||||||
|
/// Inbound messages from the frontend
|
||||||
|
inbound_messages: Arc<Mutex<VecDeque<FrontendMessage>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TauriRenderBridgeTransport {
|
||||||
|
pub fn new(app_handle: AppHandle, shared_render_state: Arc<Mutex<Option<RenderInit>>>) -> Self {
|
||||||
|
Self { app_handle, shared_render_state, inbound_messages: Arc::new(Mutex::new(VecDeque::new())) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a reference to the inbound messages queue (for Tauri command handler)
|
||||||
|
pub fn inbound_messages(&self) -> Arc<Mutex<VecDeque<FrontendMessage>>> {
|
||||||
|
self.inbound_messages.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FrontendTransport for TauriRenderBridgeTransport {
|
||||||
|
fn send_backend_message(&self, message: &BackendMessage) -> Result<(), String> {
|
||||||
|
// Cache RenderInit for state recovery on reload
|
||||||
|
if let BackendMessage::RenderInit(render_init) = message
|
||||||
|
&& let Ok(mut state) = self.shared_render_state.lock()
|
||||||
|
{
|
||||||
|
*state = Some(render_init.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
self.app_handle.emit("backend:message", message).map_err(|e| format!("Failed to emit backend message: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_binary_delta(&self, data: Vec<u8>) -> Result<(), String> {
|
||||||
|
self.app_handle.emit("render:pixel_stream", &data).map_err(|e| format!("Failed to emit pixel stream: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_recv_frontend_message(&self) -> Option<FrontendMessage> {
|
||||||
|
if let Ok(mut messages) = self.inbound_messages.lock() { messages.pop_front() } else { None }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tauri command handler for receiving frontend messages
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn send_frontend_message(message: FrontendMessage, bridge: tauri::State<Arc<Mutex<VecDeque<FrontendMessage>>>>) -> Result<(), String> {
|
||||||
|
tracing::info!("Frontend sent message: {:?}", message);
|
||||||
|
if let Ok(mut messages) = bridge.lock() {
|
||||||
|
messages.push_back(message);
|
||||||
|
tracing::debug!("Message queued, queue size: {}", messages.len());
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
tracing::error!("Failed to acquire lock on message queue");
|
||||||
|
Err("Failed to acquire lock on message queue".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle input events from the frontend
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn handle_render_input(event: RenderInputEvent, input_state: tauri::State<Arc<Mutex<borders_core::ui::input::InputState>>>) -> Result<(), String> {
|
||||||
|
let mut state = input_state.lock().map_err(|e| format!("Failed to lock input state: {}", e))?;
|
||||||
|
|
||||||
|
// TODO: Get actual map width from GameView or TerrainData
|
||||||
|
let map_width = 2560; // Placeholder
|
||||||
|
|
||||||
|
borders_core::ui::handle_render_input(&event, &mut state, map_width)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle camera state updates from the frontend
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn handle_camera_update(update: CameraStateUpdate, bridge: tauri::State<Arc<Mutex<Option<CameraStateUpdate>>>>) -> Result<(), String> {
|
||||||
|
let mut state = bridge.lock().map_err(|e| format!("Failed to lock camera state: {}", e))?;
|
||||||
|
|
||||||
|
borders_core::ui::handle_camera_update(update, &mut state)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle map queries from the frontend
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn handle_map_query(query: MapQuery, game_view: tauri::State<Arc<Mutex<Option<GameView>>>>) -> Result<MapQueryResponse, String> {
|
||||||
|
let view = game_view.lock().map_err(|e| format!("Failed to lock game view: {}", e))?;
|
||||||
|
|
||||||
|
let Some(ref view) = *view else {
|
||||||
|
return Err("Game view not available".to_string());
|
||||||
|
};
|
||||||
|
|
||||||
|
match query {
|
||||||
|
MapQuery::GetOwnerAt { x: _, y: _ } => {
|
||||||
|
// This query is not used with Pixi.js frontend - frontend sends tile indices directly
|
||||||
|
Ok(MapQueryResponse::Owner { owner_id: 0, tile_index: None })
|
||||||
|
}
|
||||||
|
MapQuery::GetTileInfo { tile_index } => {
|
||||||
|
let index = tile_index as usize;
|
||||||
|
let owner_id = view.get_owner(index);
|
||||||
|
|
||||||
|
// TODO: Get actual terrain and troop data
|
||||||
|
Ok(MapQueryResponse::TileInfo {
|
||||||
|
tile_index,
|
||||||
|
owner_id,
|
||||||
|
terrain: TerrainType::Land, // Placeholder
|
||||||
|
troops: 0, // Placeholder
|
||||||
|
})
|
||||||
|
}
|
||||||
|
MapQuery::FindPlayerTerritory { player_id } => {
|
||||||
|
let tile_index = view.find_tile_owned_by(player_id).map(|i| i as u32);
|
||||||
|
Ok(MapQueryResponse::PlayerTerritory { tile_index })
|
||||||
|
}
|
||||||
|
MapQuery::ScreenToTile { screen_x: _, screen_y: _ } => {
|
||||||
|
// This would need camera state to work properly
|
||||||
|
Ok(MapQueryResponse::TileIndex { index: None })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Combined state for recovery after reload
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct GameStateRecovery {
|
||||||
|
pub render_init: Option<RenderInit>,
|
||||||
|
pub leaderboard_snapshot: Option<LeaderboardSnapshot>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current game state for frontend recovery after reload
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_game_state(render_state: tauri::State<Arc<Mutex<Option<RenderInit>>>>, leaderboard_state: tauri::State<Arc<Mutex<Option<LeaderboardSnapshot>>>>) -> Result<GameStateRecovery, String> {
|
||||||
|
let render_init = render_state.lock().map_err(|e| format!("Failed to lock render state: {}", e))?.clone();
|
||||||
|
|
||||||
|
let leaderboard_snapshot = leaderboard_state.lock().map_err(|e| format!("Failed to lock leaderboard state: {}", e))?.clone();
|
||||||
|
|
||||||
|
Ok(GameStateRecovery { render_init, leaderboard_snapshot })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// System to cache leaderboard snapshots for state recovery
|
||||||
|
pub fn cache_leaderboard_snapshot_system(mut events: MessageReader<BackendMessage>, shared_leaderboard_state: Option<NonSend<Arc<Mutex<Option<LeaderboardSnapshot>>>>>) {
|
||||||
|
let Some(shared_state) = shared_leaderboard_state else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
for event in events.read() {
|
||||||
|
if let BackendMessage::LeaderboardSnapshot(snapshot) = event
|
||||||
|
&& let Ok(mut state) = shared_state.lock()
|
||||||
|
{
|
||||||
|
*state = Some(snapshot.clone());
|
||||||
|
trace!("Cached leaderboard snapshot for state recovery");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
40
crates/borders-desktop/tauri.conf.json
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"productName": "iron-borders",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"identifier": "com.xevion.iron-borders",
|
||||||
|
"build": {
|
||||||
|
"beforeDevCommand": "pnpm dev",
|
||||||
|
"devUrl": "http://localhost:1420",
|
||||||
|
"beforeBuildCommand": "pnpm build:desktop",
|
||||||
|
"frontendDist": "../../frontend/dist/client"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"title": "Iron Borders",
|
||||||
|
"width": 1280,
|
||||||
|
"height": 720
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": {
|
||||||
|
"csp": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"plugins": {
|
||||||
|
"process": {
|
||||||
|
"all": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"targets": ["appimage", "deb", "rpm", "dmg"],
|
||||||
|
"icon": [
|
||||||
|
"icons/32x32.png",
|
||||||
|
"icons/128x128.png",
|
||||||
|
"icons/128x128@2x.png",
|
||||||
|
"icons/icon.icns",
|
||||||
|
"icons/icon.ico"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||