Update source files

This commit is contained in:
2025-10-20 01:09:08 -05:00
commit f7b7faa60f
194 changed files with 29945 additions and 0 deletions

11
.cargo/config.toml Normal file
View 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
View File

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

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

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

33
.gitignore vendored Normal file
View 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
View File

@@ -0,0 +1 @@
a284b2b0792df4a5e4badd477f970c8d5b9252e1

7898
Cargo.lock generated Normal file
View File

File diff suppressed because it is too large Load Diff

72
Cargo.toml Normal file
View File

@@ -0,0 +1,72 @@
[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
[profile.dev.package.borders-desktop]
opt-level = 1
[profile.dev.package.borders-wasm]
opt-level = 1
[profile.dev.package.borders-server]
opt-level = 1
[profile.dev.package.borders-core]
opt-level = 1
# Enable more optimization in the release profile at the cost of compile time.
[profile.release]
# Compile the entire crate as one unit.
# Slows compile times, marginal improvements.
codegen-units = 1
# Do a second optimization pass over the entire program, including dependencies.
# Slows compile times, marginal improvements.
lto = "thin"
# Development profile for WASM builds (faster compile times)
[profile.wasm-dev]
inherits = "dev"
opt-level = 1
panic = "abort"
# Size optimization profile for WASM builds
[profile.wasm-release]
inherits = "release"
incremental = false
debug = false
opt-level = "s" # Optimize for size
lto = true # Link-time optimization
codegen-units = 1 # Single codegen unit for better optimization
panic = "abort" # Smaller panic implementation
strip = true # Remove debug symbols
# Performance profiling profile for WASM builds (fast with debug symbols)
[profile.wasm-debug]
inherits = "release"
incremental = false
debug = true # Preserve debug symbols for profiling
opt-level = 3 # Full optimization for performance
lto = "thin" # Link-time optimization without aggressive stripping
codegen-units = 1 # Single codegen unit for better optimization
panic = "abort" # Smaller panic implementation
strip = "none" # Keep all debug symbols

111
Justfile Normal file
View File

@@ -0,0 +1,111 @@
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"; \
$isDebug = $profile -eq "wasm-debug"; \
$needsFrontendBuild = $isRelease -or $isDebug; \
if ($wasRebuilt -or -not $pkgExists -or ($needsFrontendBuild -and -not $frontendPkgExists)) { \
Write-Host "Running wasm-bindgen..."; \
wasm-bindgen --out-dir pkg --out-name borders --target web $wasmFile; \
if ($isRelease) { \
Write-Host "Running wasm-opt -Oz..."; \
wasm-opt -Oz --enable-bulk-memory --enable-threads --all-features pkg/borders_bg.wasm -o pkg/borders_bg.wasm; \
} elseif ($isDebug) { \
Write-Host "Running wasm-opt -O3 with debug info..."; \
wasm-opt -O3 --debuginfo --enable-bulk-memory --enable-threads --all-features pkg/borders_bg.wasm -o pkg/borders_bg.wasm; \
}; \
} else { \
Write-Host "WASM not rebuilt, skipping wasm-bindgen"; \
} \
New-Item -ItemType Directory -Force -Path 'frontend/pkg' | Out-Null; \
Copy-Item -Recurse -Force 'pkg/*' 'frontend/pkg/'; \
if ($needsFrontendBuild) { \
Write-Host "Running frontend build..."; \
if ($isDebug) { \
$env:VITE_DEBUG_BUILD = "true"; \
}; \
pnpm -C frontend build:browser; \
if ($isDebug) { \
Remove-Item env:VITE_DEBUG_BUILD; \
}; \
}; \
# Development WASM build, unoptimized
wasm-dev-build:
@just _wasm-build wasm-dev
# Release WASM build, optimized
wasm-release-build:
@just _wasm-build wasm-release
wasm-release: wasm-release-build
@echo "Visit http://localhost:8080 to play"
caddy file-server --listen :8080 --root frontend/dist/browser/client --browse
# Debug WASM build, optimized with debug symbols
wasm-debug-build:
@just _wasm-build wasm-debug
wasm-debug: wasm-debug-build
@echo "Visit http://localhost:8080 to play"
caddy file-server --listen :8080 --root frontend/dist/browser/client --browse
desktop-release:
cargo tauri build
target/release/borders-desktop.exe
desktop-dev:
cargo tauri build --debug
target/debug/borders-desktop.exe
# Run release manager CLI (handles setup specially)
release *args:
@$firstArg = "{{ args }}".Split()[0]; \
if ($firstArg -eq "setup") { \
Write-Host "Installing release manager dependencies..."; \
pnpm install -C scripts/release-manager; \
} else { \
pnpm --silent -C scripts/release-manager exec tsx cli.ts {{ args }}; \
}

13
LICENSE Normal file
View File

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

View File

@@ -0,0 +1,86 @@
[package]
name = "borders-core"
version.workspace = true
edition.workspace = true
authors.workspace = true
[package.metadata.cargo-machete]
ignored = ["serde_bytes", "chrono"]
[features]
default = ["ui", "bevy_debug"]
bevy_debug = ["bevy_ecs/detailed_trace"]
ui = []
[dependencies]
bevy_ecs = { version = "0.17", default-features = false, features = ["std"] }
flume = "0.11"
futures = "0.3"
futures-lite = "2.6.1"
glam = { version = "0.30", features = ["serde", "rkyv"] }
rkyv = { version = "0.8", features = ["hashbrown-0_15"] }
hex = "0.4"
hmac = "0.12"
image = "0.25"
once_cell = "1.20"
rand = "0.9"
serde = { version = "1.0", features = ["derive", "rc"] }
slotmap = "1.0"
serde_bytes = "0.11"
serde_json = "1.0"
sha2 = "0.10"
tracing = "0.1"
web-transport = "0.9"
# 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"
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"

View File

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

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

View File

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

View File

@@ -0,0 +1,146 @@
//! 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;
pub struct App {
world: World,
}
impl App {
pub fn new() -> Self {
let mut world = World::new();
// Initialize schedules with proper ordering
let mut schedules = Schedules::new();
schedules.insert(Schedule::new(Startup));
schedules.insert(Schedule::new(Update));
schedules.insert(Schedule::new(Last));
world.insert_resource(schedules);
Self { world }
}
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) {
let _guard = tracing::trace_span!("app_update").entered();
// Remove schedules temporarily to avoid resource_scope conflicts
let mut schedules = self.world.remove_resource::<Schedules>().unwrap();
// Run Update schedule
if let Some(schedule) = schedules.get_mut(Update) {
let _guard = tracing::trace_span!("update_schedule").entered();
schedule.run(&mut self.world);
}
// Run Last schedule (includes event updates)
if let Some(schedule) = schedules.get_mut(Last) {
let _guard = tracing::trace_span!("last_schedule").entered();
schedule.run(&mut self.world);
}
// Re-insert schedules
self.world.insert_resource(schedules);
}
pub fn run_startup(&mut self) {
let _guard = tracing::trace_span!("run_startup_schedule").entered();
// Remove schedules temporarily to avoid resource_scope conflicts
let mut schedules = self.world.remove_resource::<Schedules>().unwrap();
// Run Startup schedule
if let Some(schedule) = schedules.get_mut(Startup) {
schedule.run(&mut self.world);
}
// Re-insert schedules
self.world.insert_resource(schedules);
}
pub fn finish(&mut self) {
// Finalize schedules
let mut schedules = self.world.remove_resource::<Schedules>().unwrap();
let system_count: usize = schedules.iter().map(|(_, schedule)| schedule.systems().map(|iter| iter.count()).unwrap_or(0)).sum();
let _guard = tracing::trace_span!("finish_schedules", system_count = system_count).entered();
for (_, schedule) in schedules.iter_mut() {
schedule.graph_mut().initialize(&mut self.world);
}
self.world.insert_resource(schedules);
}
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);
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
//! AI and bot player logic
//!
//! This module contains the bot manager and AI decision-making logic.
pub mod bot;
pub use bot::*;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
/// Troop count specification for attacks
pub enum TroopCount {
/// Use a ratio of the player's current troops (0.0-1.0)
Ratio(f32),
/// Use an absolute troop count
Absolute(u32),
}

View File

@@ -0,0 +1,200 @@
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use bevy_ecs::prelude::*;
use rand::rngs::StdRng;
use rand::{Rng, SeedableRng};
use tracing::{debug, info};
use crate::game::ai::bot;
use crate::game::core::constants::colors::*;
use crate::game::core::constants::game::BOT_COUNT;
use crate::game::core::constants::player::INITIAL_TROOPS;
use crate::game::core::constants::spawning::SPAWN_TIMEOUT_SECS;
use crate::game::core::rng::DeterministicRng;
use crate::game::view::{GameView, PlayerView};
use crate::game::{ActiveAttacks, CoastalTiles, HSLColor, LocalPlayerContext, SpawnManager, SpawnPhase, SpawnTimeout, TerritoryManager};
use crate::game::{NationId, TerrainData};
use crate::networking::server::{LocalTurnServerHandle, TurnGenerator, TurnReceiver};
use flume::Receiver;
/// Parameters needed to initialize a new game
pub struct GameInitParams {
pub map_width: u16,
pub map_height: u16,
pub conquerable_tiles: Vec<bool>,
pub client_player_id: NationId,
pub intent_rx: Receiver<crate::networking::client::TrackedIntent>,
pub terrain_data: 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) {
let _guard = tracing::trace_span!("game_initialization", map_width = params.map_width, map_height = params.map_height).entered();
info!("Initializing game resources (map: {}x{}, player: {})", params.map_width, params.map_height, params.client_player_id.get());
// Initialize territory manager
let mut territory_manager = TerritoryManager::new(params.map_width, params.map_height);
territory_manager.reset(params.map_width, params.map_height, &params.conquerable_tiles);
debug!("Territory manager initialized with {} tiles", params.conquerable_tiles.len());
// Initialize active attacks
let mut active_attacks = ActiveAttacks::new();
active_attacks.init(1 + 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);
// Generate player metadata: 1 human + BOT_COUNT bots
// Player IDs start at 0 (human), then 1, 2, 3... for bots
let mut player_metadata = Vec::new();
// Generate random hue offset for color spread
let hue_offset = rng.random_range(HUE_MIN..HUE_MAX);
// All players (including human) get deterministically generated colors
for i in 0..=BOT_COUNT {
let is_human = i == 0;
let player_id = NationId::new(i as u16).expect("valid player ID");
// Use golden angle distribution with random offset for visually distinct colors
let hue = (player_id.get() as f32 * GOLDEN_ANGLE + hue_offset) % HUE_MAX;
let saturation = rng.random_range(SATURATION_MIN..=SATURATION_MAX);
let lightness = rng.random_range(LIGHTNESS_MIN..=LIGHTNESS_MAX);
let color = HSLColor::new(hue, saturation, lightness);
let name = if is_human { "Player".to_string() } else { format!("Bot {}", i) };
player_metadata.push((player_id, name, color));
}
debug!("Player metadata generated for {} players (human: 0, bots: {})", 1 + BOT_COUNT, BOT_COUNT);
// Spawn player entities with ECS components first
// This ensures entities exist from the start for update_player_borders_system
// Extract bot player IDs from metadata for spawn calculation
let bot_player_ids: Vec<NationId> = player_metadata
.iter()
.skip(1) // Skip human player (index 0)
.map(|(id, _, _)| *id)
.collect();
// 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 = bot::calculate_initial_spawns(&bot_player_ids, &territory_manager, &params.terrain_data, 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);
commands.insert_resource(spawn_manager);
// Count total land tiles from conquerable tiles data
let total_land_tiles = params.conquerable_tiles.iter().filter(|&&is_land| is_land).count() as u32;
// Create entity map for O(1) player_id -> Entity lookups
let mut entity_map = crate::game::PlayerEntityMap::default();
// Initial troops and territory for each player
let initial_troops = INITIAL_TROOPS;
let initial_territory_size = 0;
for (nation_id, name, color) in &player_metadata {
let is_bot = bot_player_ids.contains(nation_id);
let entity = if is_bot { commands.spawn((crate::game::ai::bot::Bot::new(), *nation_id, crate::game::PlayerName(name.clone()), crate::game::PlayerColor(*color), crate::game::BorderTiles::default(), crate::game::Troops(initial_troops), crate::game::TerritorySize(initial_territory_size), crate::game::ships::ShipCount::default())).id() } else { commands.spawn((*nation_id, crate::game::PlayerName(name.clone()), crate::game::PlayerColor(*color), crate::game::BorderTiles::default(), crate::game::Troops(initial_troops), crate::game::TerritorySize(initial_territory_size), crate::game::ships::ShipCount::default())).id() };
entity_map.0.insert(*nation_id, entity);
}
debug!("Player entities spawned with ECS components ({} total)", 1 + BOT_COUNT);
// Build initial GameView by reading from the ECS entities we just created
let game_view = GameView {
size: glam::U16Vec2::new(params.map_width, params.map_height),
territories: Arc::from(territory_manager.as_slice()),
turn_number: 0,
total_land_tiles,
changed_tiles: Vec::new(), // Empty on initialization
players: player_metadata.iter().map(|(nation_id, name, color)| PlayerView { id: *nation_id, color: color.to_rgba(), name: name.clone(), tile_count: initial_territory_size, troops: initial_troops as u32, is_alive: true }).collect(),
ships: Vec::new(), // No ships at initialization
};
// Compute coastal tiles once
let map_size = glam::U16Vec2::new(params.map_width, params.map_height);
let coastal_tiles = CoastalTiles::compute(&params.terrain_data, map_size);
debug!("Computed {} coastal tiles", coastal_tiles.len());
// Insert all individual game resources
commands.insert_resource(entity_map);
commands.insert_resource(crate::game::ClientPlayerId(params.client_player_id));
commands.insert_resource(crate::game::HumanPlayerCount(1));
commands.insert_resource(crate::game::ships::ShipIdCounter::new());
commands.insert_resource(territory_manager);
commands.insert_resource(active_attacks);
commands.insert_resource(params.terrain_data.as_ref().clone());
commands.insert_resource(DeterministicRng::new(rng_seed));
commands.insert_resource(coastal_tiles);
commands.insert_resource(game_view);
// Initialize local player context
commands.insert_resource(LocalPlayerContext::new(NationId::ZERO));
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: Arc::new(AtomicBool::new(true)), running: Arc::new(AtomicBool::new(true)) };
commands.insert_resource(server_handle);
commands.insert_resource(TurnReceiver { turn_rx });
commands.insert_resource(TurnGenerator::new(turn_tx));
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
pub fn cleanup_game_resources(world: &mut World) {
let _guard = tracing::trace_span!("game_cleanup").entered();
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::<TerritoryManager>();
world.remove_resource::<ActiveAttacks>();
world.remove_resource::<TerrainData>();
world.remove_resource::<DeterministicRng>();
world.remove_resource::<CoastalTiles>();
world.remove_resource::<LocalPlayerContext>();
world.remove_resource::<TurnReceiver>();
world.remove_resource::<SpawnManager>();
world.remove_resource::<SpawnTimeout>();
world.remove_resource::<GameView>();
world.remove_resource::<TurnGenerator>();
info!("Game resources cleaned up successfully");
}

View File

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

View File

@@ -0,0 +1,81 @@
use crate::game::core::constants::outcome::WIN_THRESHOLD;
use crate::game::input::context::LocalPlayerContext;
use crate::game::view::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: If<ResMut<LocalPlayerContext>>, game_view: If<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.id;
let Some(my_player) = game_view.get_nation_id(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
// Filter out unclaimed tiles
let total_claimable_tiles = game_view.territories.iter().filter(|ownership| ownership.is_owned()).count();
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 });
}
}

View File

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

View File

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

View File

@@ -0,0 +1,82 @@
use glam::U16Vec2;
/// Serde helper for U16Vec2 serialization
pub mod u16vec2_serde {
use serde::{Deserialize, Deserializer, Serialize, Serializer};
pub fn serialize<S>(vec: &glam::U16Vec2, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
(vec.x, vec.y).serialize(serializer)
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<glam::U16Vec2, D::Error>
where
D: Deserializer<'de>,
{
let (x, y) = <(u16, u16)>::deserialize(deserializer)?;
Ok(glam::U16Vec2::new(x, y))
}
}
/// Returns an iterator over all valid cardinal neighbors of a tile position.
///
/// Yields positions for left, right, up, and down neighbors that are within bounds.
/// Handles boundary checks for the 4-connected grid.
///
/// # Examples
/// ```
/// use glam::U16Vec2;
/// use borders_core::game::utils::neighbors;
///
/// let size = U16Vec2::new(10, 10);
/// let tile = U16Vec2::new(5, 5);
/// let neighbor_count = neighbors(tile, size).count();
/// assert_eq!(neighbor_count, 4);
/// ```
pub fn neighbors(tile: U16Vec2, size: U16Vec2) -> impl Iterator<Item = U16Vec2> {
const CARDINAL_DIRECTIONS: [(i32, i32); 4] = [(-1, 0), (1, 0), (0, -1), (0, 1)];
let tile_i32 = (tile.x as i32, tile.y as i32);
let width = size.x as i32;
let height = size.y as i32;
CARDINAL_DIRECTIONS.into_iter().filter_map(move |(dx, dy)| {
let nx = tile_i32.0 + dx;
let ny = tile_i32.1 + dy;
if nx >= 0 && ny >= 0 && nx < width && ny < height { Some(U16Vec2::new(nx as u16, ny as u16)) } else { None }
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_corner_tile_neighbors() {
let neighbor_vec: Vec<_> = neighbors(U16Vec2::new(0, 0), U16Vec2::new(10, 10)).collect();
assert_eq!(neighbor_vec.len(), 2);
assert!(neighbor_vec.contains(&U16Vec2::new(1, 0)));
assert!(neighbor_vec.contains(&U16Vec2::new(0, 1)));
}
#[test]
fn test_edge_tile_neighbors() {
let neighbor_vec: Vec<_> = neighbors(U16Vec2::new(5, 0), U16Vec2::new(10, 10)).collect();
assert_eq!(neighbor_vec.len(), 3);
assert!(neighbor_vec.contains(&U16Vec2::new(4, 0)));
assert!(neighbor_vec.contains(&U16Vec2::new(6, 0)));
assert!(neighbor_vec.contains(&U16Vec2::new(5, 1)));
}
#[test]
fn test_center_tile_neighbors() {
let neighbor_vec: Vec<_> = neighbors(U16Vec2::new(5, 5), U16Vec2::new(10, 10)).collect();
assert_eq!(neighbor_vec.len(), 4);
assert!(neighbor_vec.contains(&U16Vec2::new(4, 5)));
assert!(neighbor_vec.contains(&U16Vec2::new(6, 5)));
assert!(neighbor_vec.contains(&U16Vec2::new(5, 4)));
assert!(neighbor_vec.contains(&U16Vec2::new(5, 6)));
}
}

View File

@@ -0,0 +1,119 @@
use bevy_ecs::prelude::*;
use std::collections::HashSet;
use std::ops::{Deref, DerefMut};
use crate::game::core::constants::player::*;
/// Marker component to identify dead players
/// Alive players are identified by the ABSENCE of this component
/// Use Without<Dead> in queries to filter for alive players
#[derive(Component, Debug, Clone, Copy, Default)]
pub struct Dead;
/// Player name component
#[derive(Component, Debug, Clone)]
pub struct PlayerName(pub String);
/// Player color component
#[derive(Component, Debug, Clone, Copy)]
pub struct PlayerColor(pub HSLColor);
/// Border tiles component - tiles at the edge of a player's territory
#[derive(Component, Debug, Clone, Default)]
pub struct BorderTiles(pub HashSet<glam::U16Vec2>);
impl Deref for BorderTiles {
type Target = HashSet<glam::U16Vec2>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl DerefMut for BorderTiles {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
/// Troops component - current troop count
#[derive(Component, Debug, Clone, Copy)]
pub struct Troops(pub f32);
/// Territory size component - number of tiles owned
#[derive(Component, Debug, Clone, Copy)]
pub struct TerritorySize(pub u32);
/// HSL Color representation
#[derive(Debug, Clone, Copy)]
pub struct HSLColor {
pub h: f32, // Hue: 0-360
pub s: f32, // Saturation: 0-1
pub l: f32, // Lightness: 0-1
}
impl HSLColor {
pub fn new(h: f32, s: f32, l: f32) -> Self {
Self { h, s, l }
}
pub fn to_rgba(&self) -> [f32; 4] {
let c = (1.0 - (2.0 * self.l - 1.0).abs()) * self.s;
let h_prime = self.h / 60.0;
let x = c * (1.0 - ((h_prime % 2.0) - 1.0).abs());
let (r1, g1, b1) = if h_prime < 1.0 {
(c, x, 0.0)
} else if h_prime < 2.0 {
(x, c, 0.0)
} else if h_prime < 3.0 {
(0.0, c, x)
} else if h_prime < 4.0 {
(0.0, x, c)
} else if h_prime < 5.0 {
(x, 0.0, c)
} else {
(c, 0.0, x)
};
let m = self.l - c / 2.0;
[r1 + m, g1 + m, b1 + m, 1.0]
}
}
/// Calculate maximum troop capacity based on territory size
#[inline]
pub fn calculate_max_troops(territory_size: u32, is_bot: bool) -> f32 {
let base_max = MAX_TROOPS_MULTIPLIER * ((territory_size as f32).powf(MAX_TROOPS_POWER) * MAX_TROOPS_SCALE + MAX_TROOPS_BASE);
if is_bot { base_max * BOT_MAX_TROOPS_MULTIPLIER } else { base_max }
}
/// Calculate income for this tick based on current troops and territory
#[inline]
pub fn calculate_income(troops: f32, territory_size: u32, is_bot: bool) -> f32 {
let max_troops = calculate_max_troops(territory_size, is_bot);
// Base income calculation
let mut income = BASE_INCOME + (troops.powf(INCOME_POWER) / INCOME_DIVISOR);
// Soft cap as approaching max troops
let ratio = 1.0 - (troops / max_troops);
income *= ratio;
// Apply bot modifier
if is_bot { income * BOT_INCOME_MULTIPLIER } else { income }
}
/// Add troops with max cap enforcement
#[inline]
pub fn add_troops_capped(current: f32, amount: f32, territory_size: u32, is_bot: bool) -> f32 {
let max_troops = calculate_max_troops(territory_size, is_bot);
(current + amount).min(max_troops)
}
/// Remove troops, ensuring non-negative result
#[inline]
pub fn remove_troops(current: f32, amount: f32) -> f32 {
(current - amount).max(0.0)
}

View File

@@ -0,0 +1,25 @@
use bevy_ecs::prelude::*;
use std::collections::HashMap;
use crate::game::world::NationId;
/// Maps nation IDs to their ECS entities for O(1) lookup
///
/// This resource enables systems to quickly find a player's entity
/// by their nation_id without iterating through all entities.
#[derive(Resource, Default)]
pub struct PlayerEntityMap(pub HashMap<NationId, Entity>);
/// The nation ID of the local client
///
/// This identifies which player entity corresponds to the local human player
/// for client-specific UI and game state.
#[derive(Resource, Debug, Clone, Copy)]
pub struct ClientPlayerId(pub NationId);
/// The number of human players in the game
///
/// Used to determine if a player is a bot (player_id >= human_count).
/// Currently always 1, but kept for future multiplayer support.
#[derive(Resource, Debug, Clone, Copy)]
pub struct HumanPlayerCount(pub u16);

View File

@@ -0,0 +1,9 @@
//! Entity management module
//!
//! This module contains all player/entity-related types and management.
pub mod components;
pub mod entity_map;
pub use components::*;
pub use entity_map::*;

View File

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

View File

@@ -0,0 +1,285 @@
//! 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 std::sync::{Arc, Mutex};
use bevy_ecs::prelude::*;
use tracing::{debug, info, trace};
use crate::game::core::constants::input::*;
use crate::game::view::GameView;
use crate::game::{GameAction, LocalPlayerContext, SpawnManager};
use crate::networking::{Intent, IntentEvent};
use crate::ui::input::{InputState, KeyCode, MouseButton};
/// Resource tracking whether spawn phase is active
#[derive(Resource, Default)]
pub struct SpawnPhase {
pub active: bool,
}
/// Resource for attack control settings
#[derive(Resource)]
pub struct AttackControls {
pub attack_ratio: f32,
}
impl Default for AttackControls {
fn default() -> Self {
Self { attack_ratio: DEFAULT_ATTACK_RATIO }
}
}
/// Handle placing the human spawn by clicking on valid land
#[allow(clippy::too_many_arguments)]
pub fn handle_spawn_click_system(input_state: NonSend<Arc<Mutex<InputState>>>, spawn_phase: If<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>, territory_manager: Option<Res<crate::game::TerritoryManager>>, terrain: Option<Res<crate::game::terrain::TerrainData>>) {
if !spawn_phase.active {
return;
}
let Ok(input) = input_state.lock() else {
return;
};
if !input.mouse_just_released(MouseButton::Left) {
return;
}
let _guard = 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_idx = crate::ui::tile_to_index(tile_coord, game_view.width()) as u32;
let tile_ownership = game_view.get_ownership(tile_idx);
if tile_ownership.is_owned() {
debug!("Spawn click on tile {:?} ignored - occupied", tile_coord);
return;
}
// Check if tile is water/unconquerable
if let Some(ref terrain_data) = terrain
&& !terrain_data.is_conquerable(tile_coord)
{
debug!("Spawn click on tile {:?} ignored - water or unconquerable", tile_coord);
return;
}
// Player has chosen a spawn location - send to server
info!("Player {} setting spawn at tile {:?}", local_context.id.get(), tile_coord);
// Check if this is the first spawn (timer not started yet)
let is_first_spawn = if let Some(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 { tile_index: tile_coord }));
// 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 territory_mgr) = territory_manager
&& let Some(ref terrain_data) = terrain
{
// Update spawn manager (triggers bot spawn recalculation)
spawn_mgr.update_player_spawn(local_context.id, tile_coord, territory_mgr, terrain_data);
info!("Spawn manager updated with player {} spawn at tile {:?}", local_context.id.get(), tile_coord);
info!("Total spawns in manager: {}", spawn_mgr.get_all_spawns().len());
}
}
/// Center the camera on the client's spawn (hotkey C)
/// Note: Camera commands are not currently implemented in the backend
pub fn handle_center_camera_system(input_state: NonSend<Arc<Mutex<InputState>>>, game_view: Option<Res<GameView>>, local_context: Option<Res<LocalPlayerContext>>) {
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.id) {
// TODO: Implement camera centering when camera commands are added
tracing::debug!("Camera center requested (not implemented)");
}
}
/// After spawn, clicking tiles triggers expansion/attack based on ownership
/// Automatically detects if a ship is needed for water attacks
#[allow(clippy::too_many_arguments)]
pub fn handle_attack_click_system(input_state: NonSend<Arc<Mutex<InputState>>>, spawn_phase: If<Res<SpawnPhase>>, game_view: If<Res<GameView>>, terrain: If<Res<crate::game::terrain::TerrainData>>, coastal_tiles: If<Res<crate::game::CoastalTiles>>, local_context: If<Res<LocalPlayerContext>>, attack_controls: If<Res<AttackControls>>, mut intent_writer: MessageWriter<IntentEvent>, entity_map: If<Res<crate::game::PlayerEntityMap>>, border_query: Query<&crate::game::BorderTiles>, troops_query: Query<&crate::game::Troops>) {
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 _guard = tracing::trace_span!("attack_click").entered();
// 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_idx = crate::ui::tile_to_index(tile_coord, game_view.width()) as u32;
let tile_ownership = game_view.get_ownership(tile_idx);
let player_id = local_context.id;
// Can't attack own tiles
if tile_ownership.is_owned_by(player_id) {
return;
}
// Check if target is water - ignore water clicks
let size = game_view.size();
if terrain.is_navigable(tile_coord) {
return;
}
// Check if target is connected to player's territory
let territories: Vec<crate::game::TileOwnership> = game_view.territories.iter().copied().collect();
let is_connected = crate::game::connectivity::is_connected_to_player(&territories, &terrain, tile_coord, player_id, size);
if is_connected {
// Target is connected to player's territory - use normal attack
// Calculate absolute troop count from ratio
let troops = if let Some(&entity) = (*entity_map).0.get(&player_id)
&& let Ok(troops_comp) = troops_query.get(entity)
{
(troops_comp.0 * attack_controls.attack_ratio).floor() as u32
} else {
0
};
intent_writer.write(IntentEvent(Intent::Action(GameAction::Attack { target: tile_ownership.nation_id(), troops })));
return;
}
// Target is NOT connected - need to use ship
debug!("Target {:?} not connected to player territory, attempting ship launch", tile_coord);
// Find target's nearest coastal tile
let target_coastal_tile = crate::game::connectivity::find_coastal_tile_in_region(&territories, &terrain, tile_coord, size);
let Some(target_coastal_tile) = target_coastal_tile else {
debug!("No coastal tile found in target's region for tile {:?}", tile_coord);
return;
};
// Find player's nearest coastal tile using O(1) entity lookup
let player_border_tiles = (*entity_map).0.get(&player_id).and_then(|&entity| border_query.get(entity).ok());
let launch_tile = player_border_tiles.and_then(|tiles| crate::game::ships::pathfinding::find_nearest_player_coastal_tile(coastal_tiles.tiles(), tiles, target_coastal_tile));
let Some(launch_tile) = launch_tile else {
debug!("Player has no coastal tiles to launch ship from");
return;
};
debug!("Found launch tile {:?} and target coastal tile {:?} for target {:?}", launch_tile, target_coastal_tile, tile_coord);
// Try to find a water path from launch tile to target coastal tile
let path = crate::game::ships::pathfinding::find_water_path(&terrain, launch_tile, target_coastal_tile, crate::game::ships::MAX_PATH_LENGTH);
if let Some(_path) = path {
// We can reach the target by ship!
// Calculate absolute troop count from ratio
let troops = if let Some(&entity) = (*entity_map).0.get(&player_id)
&& let Ok(troops_comp) = troops_query.get(entity)
{
(troops_comp.0 * attack_controls.attack_ratio).floor() as u32
} else {
0
};
debug!("Launching ship to target {:?} with {} troops", tile_coord, troops);
intent_writer.write(IntentEvent(Intent::Action(GameAction::LaunchShip { target_tile: tile_coord, troops })));
} else {
debug!("No water path found from {:?} to {:?}", launch_tile, target_coastal_tile);
}
}
/// Adjust attack ratio with keys 1/2
pub fn handle_attack_ratio_keys_system(input_state: NonSend<Arc<Mutex<InputState>>>, mut controls: If<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 - ATTACK_RATIO_STEP).max(ATTACK_RATIO_MIN);
changed = true;
}
if input.key_just_pressed(KeyCode::Digit2) {
controls.attack_ratio = (controls.attack_ratio + ATTACK_RATIO_STEP).min(ATTACK_RATIO_MAX);
changed = true;
}
if changed {
debug!("Attack ratio changed to {:.1}", controls.attack_ratio);
}
}

View File

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

View File

@@ -0,0 +1,27 @@
//! Game logic and state management
//!
//! This module contains all game-related functionality organized by domain.
// Core modules
pub mod ai;
pub mod combat;
pub mod core;
pub mod entities;
pub mod input;
pub mod ships;
pub mod systems;
pub mod terrain;
pub mod view;
pub mod world;
// Re-exports from submodules
pub use combat::*;
pub use core::*;
pub use entities::*;
pub use input::*;
pub use ships::*;
pub use systems::*;
pub use terrain::*;
pub use view::*;
pub use world::NationId;
pub use world::*;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,18 @@
//! Game systems that run each tick/turn
//!
//! This module contains systems that execute game logic.
pub mod borders;
pub mod income;
pub mod spawn;
pub mod spawn_territory;
pub mod spawn_timeout;
pub mod turn;
// Re-export system functions and types
pub use borders::*;
pub use income::*;
pub use spawn::*;
pub use spawn_territory::*;
pub use spawn_timeout::*;
pub use turn::*;

View File

@@ -0,0 +1,80 @@
use bevy_ecs::prelude::*;
use crate::game::NationId;
/// Represents a spawn point for a player or bot
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SpawnPoint {
pub nation: NationId,
pub tile: glam::U16Vec2,
}
impl SpawnPoint {
pub fn new(nation: NationId, tile: glam::U16Vec2) -> Self {
Self { nation, tile }
}
}
/// Manages spawn positions during the pre-game spawn phase
///
/// This resource tracks bot and 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
pub initial_bot_spawns: Vec<SpawnPoint>,
/// Current bot spawn positions after recalculation
/// These are updated whenever a player chooses/changes their spawn
pub current_bot_spawns: Vec<SpawnPoint>,
/// Player spawn positions
/// Tracks human player spawn selections
pub player_spawns: Vec<SpawnPoint>,
/// RNG seed for deterministic spawn calculations
pub rng_seed: u64,
}
impl SpawnManager {
/// Create a new SpawnManager with initial bot spawns
pub fn new(initial_bot_spawns: Vec<SpawnPoint>, rng_seed: u64) -> Self {
Self { current_bot_spawns: initial_bot_spawns.clone(), initial_bot_spawns, player_spawns: Vec::new(), rng_seed }
}
/// Update a 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: NationId, tile_index: glam::U16Vec2, territory_manager: &crate::game::TerritoryManager, terrain: &crate::game::terrain::TerrainData) {
let spawn_point = SpawnPoint::new(player_id, tile_index);
// Update or add player spawn
if let Some(entry) = self.player_spawns.iter_mut().find(|spawn| spawn.nation == player_id) {
*entry = spawn_point;
} else {
self.player_spawns.push(spawn_point);
}
// Recalculate bot spawns with updated player positions
self.current_bot_spawns = crate::game::ai::bot::recalculate_spawns_with_players(self.initial_bot_spawns.clone(), &self.player_spawns, territory_manager, terrain, self.rng_seed);
}
/// Get all current spawn positions (players + bots)
pub fn get_all_spawns(&self) -> Vec<SpawnPoint> {
let mut all_spawns = self.player_spawns.clone();
all_spawns.extend(self.current_bot_spawns.iter().copied());
all_spawns
}
/// Get only bot spawn positions
pub fn get_bot_spawns(&self) -> &[SpawnPoint] {
&self.current_bot_spawns
}
/// Get only player spawn positions
pub fn get_player_spawns(&self) -> &[SpawnPoint] {
&self.player_spawns
}
}

View File

@@ -0,0 +1,119 @@
//! Spawn territory claiming logic
//!
//! Provides utilities for claiming 5x5 territories around spawn points.
use glam::U16Vec2;
use std::collections::HashSet;
use crate::game::terrain::data::TerrainData;
use crate::game::world::{NationId, TileOwnership};
/// Claims a 5x5 territory around a spawn point
///
/// Claims all unclaimed, conquerable tiles within 2 tiles of the spawn center
/// and returns the set of tiles that were successfully claimed.
#[inline]
pub fn claim_spawn_territory(spawn_center: U16Vec2, nation: NationId, territories: &mut [TileOwnership], terrain: &TerrainData, map_size: U16Vec2) -> HashSet<U16Vec2> {
let width = map_size.x as usize;
(-2..=2)
.flat_map(|dy| (-2..=2).map(move |dx| (dx, dy)))
.filter_map(|(dx, dy)| {
let x = (spawn_center.x as i32 + dx).clamp(0, map_size.x as i32 - 1) as usize;
let y = (spawn_center.y as i32 + dy).clamp(0, map_size.y as i32 - 1) as usize;
let tile_pos = U16Vec2::new(x as u16, y as u16);
let idx = y * width + x;
if territories[idx].is_unclaimed() && terrain.is_conquerable(tile_pos) {
territories[idx] = TileOwnership::Owned(nation);
Some(tile_pos)
} else {
None
}
})
.collect()
}
/// Clears spawn territory for a specific nation within a 5x5 area
///
/// Reverts all tiles owned by the given nation within the 5x5 area back to unclaimed
/// and returns the set of tiles that were cleared.
#[inline]
pub fn clear_spawn_territory(spawn_center: U16Vec2, nation: NationId, territories: &mut [TileOwnership], map_size: U16Vec2) -> HashSet<U16Vec2> {
let width = map_size.x as usize;
(-2..=2)
.flat_map(|dy| (-2..=2).map(move |dx| (dx, dy)))
.filter_map(|(dx, dy)| {
let x = (spawn_center.x as i32 + dx).clamp(0, map_size.x as i32 - 1) as usize;
let y = (spawn_center.y as i32 + dy).clamp(0, map_size.y as i32 - 1) as usize;
let tile_pos = U16Vec2::new(x as u16, y as u16);
let idx = y * width + x;
if territories[idx].is_owned_by(nation) {
territories[idx] = TileOwnership::Unclaimed;
Some(tile_pos)
} else {
None
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::game::terrain::data::TerrainData;
#[test]
fn test_claim_spawn_territory_basic() {
let map_size = U16Vec2::new(10, 10);
let mut territories = vec![TileOwnership::Unclaimed; 100];
// Mock terrain - all conquerable
let terrain_data = vec![0x80u8; 100]; // bit 7 = conquerable
let terrain = TerrainData { _manifest: crate::game::terrain::data::MapManifest { name: "Test".to_string(), map: crate::game::terrain::data::MapMetadata { size: map_size, num_land_tiles: 100 }, nations: vec![] }, terrain_data: crate::game::world::tilemap::TileMap::from_vec(10, 10, terrain_data), tiles: vec![0; 100], tile_types: vec![crate::game::terrain::data::TileType { name: "land".to_string(), color_base: "grass".to_string(), color_variant: 0, conquerable: true, navigable: false, expansion_cost: 50, expansion_time: 50 }] };
let nation = NationId::new(0).unwrap();
let spawn_center = U16Vec2::new(5, 5);
let claimed = claim_spawn_territory(spawn_center, nation, &mut territories, &terrain, map_size);
// Should claim 5x5 = 25 tiles (all within bounds and conquerable)
assert_eq!(claimed.len(), 25);
// Verify the center tile is claimed
let center_idx = 5 * 10 + 5;
assert!(territories[center_idx].is_owned_by(nation));
// Verify corners are claimed
let corner_idx = 3 * 10 + 3; // top-left corner
assert!(territories[corner_idx].is_owned_by(nation));
}
#[test]
fn test_clear_spawn_territory() {
let map_size = U16Vec2::new(10, 10);
let mut territories = vec![TileOwnership::Unclaimed; 100];
let nation = NationId::new(0).unwrap();
let spawn_center = U16Vec2::new(5, 5);
// First claim territory
for y in 3..=7 {
for x in 3..=7 {
let idx = y * 10 + x;
territories[idx] = TileOwnership::Owned(nation);
}
}
// Now clear it
let cleared = clear_spawn_territory(spawn_center, nation, &mut territories, map_size);
assert_eq!(cleared.len(), 25);
// Verify center is unclaimed
let center_idx = 5 * 10 + 5;
assert!(territories[center_idx].is_unclaimed());
}
}

View File

@@ -0,0 +1,75 @@
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
#[inline]
pub fn has_expired(&self) -> bool {
!self.active && self.remaining_secs <= 0.0
}
}

View File

@@ -0,0 +1,36 @@
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
#[inline]
pub fn mark_processed(&mut self) {
self.processed = true;
}
/// Check if turn is ready to process (not yet processed)
#[inline]
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())
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,109 @@
//! Game state view for rendering and UI
//!
//! This module provides read-only snapshots of game state that are:
//! - Deterministic and identical across all clients
//! - Safe for use in rendering and input systems
//! - Serializable for network transmission
use std::sync::Arc;
use bevy_ecs::prelude::Resource;
use rkyv::{Archive, Deserialize as RkyvDeserialize, Serialize as RkyvSerialize};
use serde::{Deserialize, Serialize};
use crate::game::NationId;
/// 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 game state (TerritoryManager, player stats, etc.), 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 access to game resources
/// to maintain clean separation between game logic and rendering/input.
#[derive(Resource, Default, Debug, Clone, Serialize, Deserialize)]
pub struct GameView {
pub size: glam::U16Vec2,
/// Owner of each tile. Uses Arc for zero-copy sharing with rendering.
pub territories: Arc<[crate::game::TileOwnership]>,
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.
/// Uses u32 for JavaScript compatibility at the API boundary.
pub changed_tiles: Vec<u32>,
/// Active ships on the map
pub ships: Vec<ShipView>,
}
impl GameView {
/// Get the size of the map as U16Vec2
#[inline]
pub fn size(&self) -> glam::U16Vec2 {
self.size
}
/// Get the width of the map
#[inline]
pub fn width(&self) -> u16 {
self.size.x
}
/// Get the height of the map
#[inline]
pub fn height(&self) -> u16 {
self.size.y
}
/// Get the ownership of a specific tile (accepts u32 for JS compatibility)
pub fn get_ownership(&self, tile_index: u32) -> crate::game::TileOwnership {
self.territories.get(tile_index as usize).copied().unwrap_or(crate::game::TileOwnership::Unclaimed)
}
/// Get the owner of a specific tile as u16 for IPC/serialization (accepts u32 for JS compatibility)
pub fn get_owner_u16(&self, tile_index: u32) -> u16 {
self.get_ownership(tile_index).into()
}
/// Get a player by ID
pub fn get_nation_id(&self, id: NationId) -> Option<&PlayerView> {
self.players.iter().find(|player| player.id == id)
}
/// Find any tile owned by a specific player (useful for camera centering)
/// Returns u32 for JavaScript compatibility
pub fn find_tile_owned_by(&self, player_id: NationId) -> Option<u32> {
self.territories.iter().position(|ownership| ownership.is_owned_by(player_id)).map(|idx| idx as u32)
}
}
#[derive(Clone, Debug, Serialize, Deserialize, Archive, RkyvSerialize, RkyvDeserialize)]
#[rkyv(derive(Debug))]
pub struct PlayerView {
pub id: NationId,
pub color: [f32; 4],
pub name: String,
pub tile_count: u32,
pub troops: u32,
pub is_alive: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize, Archive, RkyvSerialize, RkyvDeserialize)]
#[rkyv(derive(Debug))]
pub struct ShipView {
pub id: u32,
pub owner_id: NationId,
pub current_tile: u32,
pub target_tile: u32,
pub troops: u32,
pub path_progress: u32,
pub ticks_until_move: u32,
pub path: Vec<u32>,
}

View File

@@ -0,0 +1,184 @@
use std::collections::HashSet;
use glam::U16Vec2;
/// Lightweight change tracking buffer for tile mutations.
///
/// Stores only the indices of changed tiles, using a HashSet to automatically
/// deduplicate when the same tile changes multiple times per turn. This enables
/// efficient delta updates for GPU rendering and network synchronization.
///
/// # Design
/// - Records tile index changes as they occur
/// - Automatically deduplicates tile indices
/// - O(1) average insert, O(changes) iteration
/// - Optional: can be cleared/ignored when tracking not needed
///
/// # 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.len(), 2);
/// assert_eq!(changes.len(), 0);
/// ```
#[derive(Debug, Clone)]
pub struct ChangeBuffer {
changed_indices: HashSet<U16Vec2>,
}
impl ChangeBuffer {
/// Creates a new empty ChangeBuffer.
pub fn new() -> Self {
Self { changed_indices: HashSet::new() }
}
/// Creates a new ChangeBuffer with pre-allocated capacity.
///
/// Use this when you know the approximate number of changes to avoid reallocations.
pub fn with_capacity(capacity: usize) -> Self {
Self { changed_indices: HashSet::with_capacity(capacity) }
}
/// Records a tile index as changed.
///
/// Automatically deduplicates - pushing the same index multiple times
/// only records it once. This is O(1) average case.
#[inline]
pub fn push(&mut self, position: U16Vec2) {
self.changed_indices.insert(position);
}
/// Returns an iterator over changed indices without consuming them.
///
/// Use this when you need to read changes without clearing the buffer.
/// The buffer will still contain all changes after iteration.
pub fn iter(&self) -> impl Iterator<Item = U16Vec2> + '_ {
self.changed_indices.iter().copied()
}
/// Drains all changed indices, returning an iterator and clearing the buffer.
///
/// The buffer retains its capacity for reuse.
pub fn drain(&mut self) -> impl Iterator<Item = U16Vec2> + '_ {
self.changed_indices.drain()
}
/// Clears all tracked changes without returning them.
///
/// The buffer retains its capacity for reuse.
pub fn clear(&mut self) {
self.changed_indices.clear();
}
/// Returns true if any changes have been recorded.
#[inline]
pub fn has_changes(&self) -> bool {
!self.changed_indices.is_empty()
}
/// Returns the number of changes recorded.
///
/// Note: This may include duplicate indices if the same tile was changed multiple times.
#[inline]
pub fn len(&self) -> usize {
self.changed_indices.len()
}
/// Returns true if no changes have been recorded.
#[inline]
pub fn is_empty(&self) -> bool {
self.changed_indices.is_empty()
}
/// Returns the current capacity of the internal buffer.
#[inline]
pub fn capacity(&self) -> usize {
self.changed_indices.capacity()
}
}
impl Default for ChangeBuffer {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use 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(U16Vec2::new(10, 10));
buffer.push(U16Vec2::new(25, 25));
buffer.push(U16Vec2::new(42, 42));
assert_eq!(buffer.len(), 3);
assert!(buffer.has_changes());
let changes: Vec<_> = buffer.drain().collect();
assert_eq!(changes, vec![U16Vec2::new(10, 10), U16Vec2::new(25, 25), U16Vec2::new(42, 42)]);
assert!(buffer.is_empty());
}
#[test]
fn test_clear() {
let mut buffer = ChangeBuffer::new();
buffer.push(U16Vec2::new(1, 1));
buffer.push(U16Vec2::new(2, 2));
buffer.push(U16Vec2::new(3, 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(U16Vec2::new(10, 10));
buffer.push(U16Vec2::new(10, 10));
buffer.push(U16Vec2::new(10, 10));
assert_eq!(buffer.len(), 1); // Automatically deduplicates
let changes: Vec<_> = buffer.drain().collect();
assert_eq!(changes.len(), 1); // Only one unique entry
assert!(changes.contains(&U16Vec2::new(10, 10)));
}
#[test]
fn test_capacity_retained_after_drain() {
let mut buffer = ChangeBuffer::with_capacity(100);
buffer.push(U16Vec2::new(1, 1));
buffer.push(U16Vec2::new(2, 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());
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,121 @@
//! Tile ownership representation
use rkyv::{Archive, Deserialize as RkyvDeserialize, Serialize as RkyvSerialize};
use serde::{Deserialize, Serialize};
use super::NationId;
/// Represents the ownership state of a single tile.
///
/// Terrain type (water, land, mountain, etc.) is stored separately in TerrainData.
/// This enum only tracks whether a tile is owned by a nation or unclaimed.
#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize, Deserialize, Default, Archive, RkyvSerialize, RkyvDeserialize)]
#[rkyv(derive(Debug))]
pub enum TileOwnership {
/// Owned by a specific nation
Owned(NationId),
/// Unclaimed but potentially conquerable land
#[default]
Unclaimed,
}
impl TileOwnership {
/// Check if this tile is owned by any nation
#[inline]
pub fn is_owned(self) -> bool {
matches!(self, TileOwnership::Owned(_))
}
/// Check if this tile is unclaimed land
#[inline]
pub fn is_unclaimed(self) -> bool {
matches!(self, TileOwnership::Unclaimed)
}
/// Get the nation ID if this tile is owned, otherwise None
#[inline]
pub fn nation_id(self) -> Option<NationId> {
match self {
TileOwnership::Owned(id) => Some(id),
TileOwnership::Unclaimed => None,
}
}
/// Check if this tile is owned by a specific nation
#[inline]
pub fn is_owned_by(self, nation_id: NationId) -> bool {
matches!(self, TileOwnership::Owned(id) if id == nation_id)
}
}
impl From<u16> for TileOwnership {
fn from(value: u16) -> Self {
if value == 65535 { TileOwnership::Unclaimed } else { NationId::new(value).map(TileOwnership::Owned).unwrap_or(TileOwnership::Unclaimed) }
}
}
impl From<TileOwnership> for u16 {
fn from(ownership: TileOwnership) -> Self {
match ownership {
TileOwnership::Owned(id) => id.get(),
TileOwnership::Unclaimed => 65535,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_encoding_nation_ids() {
for raw_id in [0, 1, 100, 1000, 65534] {
let nation_id = NationId::new(raw_id).unwrap();
let ownership = TileOwnership::Owned(nation_id);
let encoded: u16 = ownership.into();
assert_eq!(encoded, raw_id);
assert_eq!(TileOwnership::from(encoded), ownership);
}
}
#[test]
fn test_encoding_unclaimed() {
let ownership = TileOwnership::Unclaimed;
let encoded: u16 = ownership.into();
assert_eq!(encoded, 65535);
assert_eq!(TileOwnership::from(encoded), ownership);
}
#[test]
fn test_is_owned() {
assert!(TileOwnership::Owned(NationId::new(0).unwrap()).is_owned());
assert!(TileOwnership::Owned(NationId::new(100).unwrap()).is_owned());
assert!(!TileOwnership::Unclaimed.is_owned());
}
#[test]
fn test_is_owned_by() {
let id5 = NationId::new(5).unwrap();
let id6 = NationId::new(6).unwrap();
assert!(TileOwnership::Owned(id5).is_owned_by(id5));
assert!(!TileOwnership::Owned(id5).is_owned_by(id6));
assert!(!TileOwnership::Unclaimed.is_owned_by(id5));
}
#[test]
fn test_nation_id() {
let id42 = NationId::new(42).unwrap();
assert_eq!(TileOwnership::Owned(id42).nation_id(), Some(id42));
assert_eq!(TileOwnership::Unclaimed.nation_id(), None);
}
#[test]
fn test_nation_zero_is_valid() {
let id0 = NationId::new(0).unwrap();
let ownership = TileOwnership::Owned(id0);
assert!(ownership.is_owned());
assert!(ownership.is_owned_by(id0));
assert_eq!(ownership.nation_id(), Some(id0));
assert_ne!(ownership, TileOwnership::Unclaimed);
}
}

View File

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

View File

@@ -0,0 +1,14 @@
pub mod app;
pub mod build_info;
pub mod game;
pub mod networking;
pub mod platform;
pub mod plugin;
pub mod telemetry;
pub mod time;
#[cfg(not(target_arch = "wasm32"))]
pub mod dns;
#[cfg(feature = "ui")]
pub mod ui;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,222 @@
use crate::game::NationId;
use crate::networking::{Intent, SourcedIntent, Turn};
use glam::U16Vec2;
use std::collections::HashMap;
use tracing::{debug, info, warn};
/// Spawn timeout duration (milliseconds)
const SPAWN_TIMEOUT_MS: f64 = 5000.0;
/// Output from the turn generator
#[derive(Debug, Clone)]
pub enum TurnOutput {
/// No output this tick
None,
/// Spawn configuration was updated
SpawnUpdate(HashMap<NationId, U16Vec2>),
/// Game starting with Turn 0
GameStart(Turn),
/// Regular game turn
Turn(Turn),
}
/// Shared turn generation logic for both local coordinator and relay server
pub struct SharedTurnGenerator {
turn_number: u64,
accumulated_time: f64, // milliseconds
spawn_config: HashMap<NationId, U16Vec2>,
spawn_timeout_accumulated: Option<f64>, // milliseconds since first spawn
game_started: bool,
}
impl SharedTurnGenerator {
pub fn new() -> Self {
Self { turn_number: 0, accumulated_time: 0.0, spawn_config: HashMap::new(), spawn_timeout_accumulated: None, game_started: false }
}
/// Process a single sourced intent, returns output if spawn config changed
pub fn process_intent(&mut self, sourced_intent: SourcedIntent) -> TurnOutput {
match sourced_intent.intent {
Intent::SetSpawn { tile_index } => {
if self.game_started {
warn!("Received SetSpawn intent after game started - ignoring");
return TurnOutput::None;
}
let player_id = sourced_intent.source;
debug!("Player {} set spawn at tile {}", player_id, tile_index);
self.spawn_config.insert(player_id, tile_index);
// Start timeout on first spawn
if self.spawn_timeout_accumulated.is_none() {
self.spawn_timeout_accumulated = Some(0.0);
debug!("Spawn timeout started ({}ms)", SPAWN_TIMEOUT_MS);
}
TurnOutput::SpawnUpdate(self.spawn_config.clone())
}
Intent::Action(_) => {
if !self.game_started {
warn!("Received Action intent during spawn phase - ignoring");
}
TurnOutput::None
}
}
}
/// Tick with delta time (ms), returns turn if ready
/// During spawn phase, checks timeout. During game phase, accumulates time and generates turns.
pub fn tick(&mut self, delta_ms: f64, sourced_intents: Vec<SourcedIntent>) -> TurnOutput {
// During spawn phase, handle timeout
if !self.game_started {
if let Some(ref mut accumulated) = self.spawn_timeout_accumulated {
*accumulated += delta_ms;
// Check if timeout expired
if *accumulated >= SPAWN_TIMEOUT_MS {
debug!("Spawn timeout expired - starting game");
// Create Turn(0) to start game
let start_turn = Turn { turn_number: 0, intents: Vec::new() };
info!("Turn(0) ready to start game (spawns already configured)");
// Mark game as started and clear spawn phase
self.game_started = true;
self.spawn_config.clear();
self.spawn_timeout_accumulated = None;
self.turn_number = 1; // Next turn will be turn 1
self.accumulated_time = 0.0; // Reset for clean turn timing
info!("Spawn phase complete - game started, next turn will be Turn 1");
return TurnOutput::GameStart(start_turn);
}
}
return TurnOutput::None;
}
// Normal turn generation (after game has started)
self.accumulated_time += delta_ms;
// Only generate turn if enough time has passed (100ms tick interval)
if self.accumulated_time < 100.0 {
return TurnOutput::None;
}
// Reset accumulated time
self.accumulated_time -= 100.0;
// Filter out SetSpawn intents (they're ignored after game starts)
let action_intents: Vec<SourcedIntent> = sourced_intents
.into_iter()
.filter_map(|sourced_intent| match sourced_intent.intent {
Intent::Action(_) => Some(sourced_intent),
Intent::SetSpawn { .. } => {
warn!("Received SetSpawn intent after game started - ignoring");
None
}
})
.collect();
// Create turn
let turn = Turn { turn_number: self.turn_number, intents: action_intents };
self.turn_number += 1;
TurnOutput::Turn(turn)
}
/// Get current turn number
pub fn turn_number(&self) -> u64 {
self.turn_number
}
/// Check if game has started
pub fn game_started(&self) -> bool {
self.game_started
}
/// Get current spawn configuration
pub fn spawn_config(&self) -> &HashMap<NationId, U16Vec2> {
&self.spawn_config
}
}
impl Default for SharedTurnGenerator {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::game::core::action::GameAction;
#[test]
fn test_spawn_phase_timeout() {
let mut generator = SharedTurnGenerator::new();
// Process a spawn intent
let sourced_intent = SourcedIntent { source: NationId::new_unchecked(1), intent_id: 1, intent: Intent::SetSpawn { tile_index: U16Vec2::new(5, 5) } };
let output = generator.process_intent(sourced_intent);
assert!(matches!(output, TurnOutput::SpawnUpdate(_)));
// Tick with time less than timeout
let output = generator.tick(2000.0, vec![]);
assert!(matches!(output, TurnOutput::None));
// Tick with time to exceed timeout
let output = generator.tick(3500.0, vec![]);
assert!(matches!(output, TurnOutput::GameStart(_)));
assert!(generator.game_started());
}
#[test]
fn test_turn_generation() {
let mut generator = SharedTurnGenerator::new();
// Start game by setting spawn and waiting for timeout
generator.process_intent(SourcedIntent { source: NationId::new_unchecked(1), intent_id: 1, intent: Intent::SetSpawn { tile_index: U16Vec2::new(5, 5) } });
generator.tick(6000.0, vec![]); // Trigger game start
// Tick with time less than tick interval
let output = generator.tick(50.0, vec![]);
assert!(matches!(output, TurnOutput::None));
// Tick with time to exceed tick interval
let sourced_intents = vec![SourcedIntent { source: NationId::new_unchecked(1), intent_id: 2, intent: Intent::Action(GameAction::Attack { target: Some(NationId::new_unchecked(2)), troops: 100 }) }];
let output = generator.tick(60.0, sourced_intents);
if let TurnOutput::Turn(turn) = output {
assert_eq!(turn.turn_number, 1);
assert_eq!(turn.intents.len(), 1);
} else {
panic!("Expected TurnOutput::Turn");
}
}
#[test]
fn test_ignore_action_during_spawn() {
let mut generator = SharedTurnGenerator::new();
let sourced_intent = SourcedIntent { source: NationId::new_unchecked(1), intent_id: 1, intent: Intent::Action(GameAction::Attack { target: None, troops: 50 }) };
let output = generator.process_intent(sourced_intent);
assert!(matches!(output, TurnOutput::None));
}
#[test]
fn test_ignore_spawn_after_game_start() {
let mut generator = SharedTurnGenerator::new();
// Start game
generator.process_intent(SourcedIntent { source: NationId::new_unchecked(1), intent_id: 1, intent: Intent::SetSpawn { tile_index: U16Vec2::new(5, 5) } });
generator.tick(6000.0, vec![]); // Trigger game start
// Try to set spawn after game started
let sourced_intent = SourcedIntent { source: NationId::new_unchecked(2), intent_id: 2, intent: Intent::SetSpawn { tile_index: U16Vec2::new(10, 10) } };
let output = generator.process_intent(sourced_intent);
assert!(matches!(output, TurnOutput::None));
}
}

View File

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

View File

@@ -0,0 +1,21 @@
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);
}

View File

@@ -0,0 +1,801 @@
//! 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 std::collections::HashMap;
use std::sync::{Arc, Mutex};
#[cfg(not(target_arch = "wasm32"))]
use std::time::{SystemTime, UNIX_EPOCH};
use bevy_ecs::hierarchy::ChildOf;
use bevy_ecs::prelude::*;
use bevy_ecs::system::SystemParam;
use tracing::{debug, info, trace};
use crate::app::{App, Last, Plugin, Update};
use crate::game::ships::{LaunchShipEvent, ShipArrivalEvent, handle_ship_arrivals_system, launch_ship_system, update_ships_system};
use crate::game::{BorderCache, NationId, TerrainData, clear_territory_changes_system};
use crate::networking::SpawnConfigEvent;
use crate::networking::server::{LocalTurnServerHandle, TurnGenerator};
use crate::networking::{IntentEvent, ProcessTurnEvent};
use crate::time::{FixedTime, Time};
use crate::game::core::constants::game::TICK_INTERVAL;
use crate::game::view::GameView;
use crate::game::{AttackControls, CurrentTurn, SpawnPhase, SpawnTimeout, check_local_player_outcome, handle_attack_click_system, handle_attack_ratio_keys_system, handle_center_camera_system, handle_spawn_click_system, process_player_income_system, turn_is_ready, update_player_borders_system};
use crate::networking::client::IntentReceiver;
use crate::networking::server::{TurnReceiver, generate_turns_system, poll_turns_system};
#[cfg(feature = "ui")]
use crate::ui::{emit_attacks_update_system, emit_leaderboard_snapshot_system, emit_nation_highlight_system, emit_ships_update_system};
#[cfg(target_arch = "wasm32")]
use web_time::{SystemTime, UNIX_EPOCH};
// Re-export protocol types for convenience
#[cfg(feature = "ui")]
use crate::ui::protocol::{BackendMessage, 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) {
let _guard = tracing::debug_span!("game_plugin_build").entered();
// Setup networking based on mode
match &self.network_mode {
NetworkMode::Local => {
let _guard = tracing::trace_span!("network_setup", mode = "local").entered();
info!("Initializing GamePlugin in Local mode");
// Local mode: use tracked intent channel for turn coordination
let (tracked_intent_tx, tracked_intent_rx) = flume::unbounded();
// Note: Turn channels (turn_tx/turn_rx) are created later in initialize_game_resources
// when StartGame is called. Connection doesn't need turn_rx yet.
// Create a placeholder receiver that will be replaced when game starts
let (_placeholder_tx, placeholder_rx) = flume::unbounded();
// Create LocalBackend for single-player mode
let backend = crate::networking::client::LocalBackend::new(
tracked_intent_tx,
placeholder_rx,
NationId::ZERO, // Local player is always NationId::ZERO
);
// Create Connection resource with LocalBackend
let connection = crate::networking::client::Connection::new_local(backend);
app.insert_resource(connection).insert_resource(IntentReceiver { rx: tracked_intent_rx }).add_systems(Update, (poll_turns_system, crate::networking::client::send_intent_system)).add_systems(Last, clear_territory_changes_system);
}
#[cfg(not(target_arch = "wasm32"))]
NetworkMode::Remote { server_address: _ } => {
// TODO: Remote networking currently disabled due to bincode incompatibility with glam types
unimplemented!("Remote networking temporarily disabled");
/*
let _guard = tracing::trace_span!("network_setup", mode = "remote", server = %server_address).entered();
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::systems::send_net_intent_system,
crate::networking::client::systems::receive_net_message_system,
crate::networking::client::systems::handle_spawn_config_system,
),
);
// Spawn networking thread
let server_addr = server_address.clone();
let server_addr_span = server_addr.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");
}
.instrument(
tracing::trace_span!("remote_connection", server = %server_addr_span),
),
);
});
*/
}
}
// 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::<SpawnConfigEvent>().add_message::<LaunchShipEvent>().add_message::<ShipArrivalEvent>().init_resource::<GameView>();
// UI-related events and resources (feature-gated)
#[cfg(feature = "ui")]
{
use crate::ui::{DisplayOrderUpdateCounter, LastAttacksDigest, LastDisplayOrder, LastLeaderboardDigest, LeaderboardThrottle, NationHighlightState, ShipStateTracker};
app.init_resource::<LastLeaderboardDigest>().init_resource::<LastAttacksDigest>().init_resource::<LeaderboardThrottle>().init_resource::<DisplayOrderUpdateCounter>().init_resource::<LastDisplayOrder>().init_resource::<NationHighlightState>().init_resource::<ShipStateTracker>();
}
// Input-related resources
app.init_resource::<SpawnPhase>().init_resource::<AttackControls>().init_resource::<BorderCache>();
// Spawn phase management
app.init_resource::<SpawnPhaseInitialized>().init_resource::<PreviousSpawnState>().add_systems(Update, (emit_initial_spawn_phase_system, manage_spawn_phase_system, update_spawn_preview_system));
// 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)
process_player_income_system.run_if(turn_is_ready),
)
.chain(),
);
app.add_systems(Update, (execute_turn_gameplay_system, update_ship_views_system, launch_ship_system, update_ships_system, handle_ship_arrivals_system, check_local_player_outcome, update_player_borders_system).chain().run_if(turn_is_ready));
// UI update systems (feature-gated)
#[cfg(feature = "ui")]
app.add_systems(Update, (emit_leaderboard_snapshot_system, emit_attacks_update_system, emit_ships_update_system, emit_nation_highlight_system));
// Command handlers
#[cfg(feature = "ui")]
app.add_systems(Update, handle_frontend_messages_system);
// Platform-agnostic input systems
app.add_systems(Update, (handle_spawn_click_system, handle_attack_click_system, handle_center_camera_system, handle_attack_ratio_keys_system));
// Input state frame update
app.add_systems(Last, clear_input_state_system);
// Turn generation system
app.add_systems(Update, generate_turns_system);
}
}
/// 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<crate::game::SpawnPoint>,
}
/// System to emit initial SpawnPhaseUpdate when game starts
#[cfg(feature = "ui")]
fn emit_initial_spawn_phase_system(mut initialized: If<ResMut<SpawnPhaseInitialized>>, spawn_phase: If<Res<SpawnPhase>>, territory_manager: Option<Res<crate::game::TerritoryManager>>, mut backend_messages: MessageWriter<BackendMessage>) {
if initialized.emitted_initial || !spawn_phase.active || territory_manager.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: If<ResMut<SpawnTimeout>>, spawn_phase: If<Res<SpawnPhase>>, time: Res<Time>, mut backend_messages: MessageWriter<BackendMessage>) {
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: If<Res<SpawnPhase>>, spawns: If<Res<crate::game::SpawnManager>>, mut game_view: If<ResMut<GameView>>, territory: If<Res<crate::game::TerritoryManager>>, terrain: If<Res<TerrainData>>, mut previous_state: ResMut<PreviousSpawnState>) {
if !spawn_phase.active {
return;
}
// Only update if SpawnManager has changed
if !spawns.is_changed() {
return;
}
let size = game_view.size();
let current_spawns = spawns.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;
}
// Start from empty base state (TerritoryManager is empty during spawn phase)
// We build spawn preview from scratch each time
let base_territories = territory.as_slice();
let old_territories: Vec<_> = game_view.territories.iter().copied().collect();
let mut territories: Vec<_> = base_territories.to_vec();
// Apply all current spawns to get final state
// This is simpler than tracking incremental changes and handles overlaps correctly
for spawn in &current_spawns {
crate::game::systems::spawn_territory::claim_spawn_territory(spawn.tile, spawn.nation, &mut territories, &terrain, size);
}
// Compute changed tiles by comparing old GameView state with new state
// This ensures frontend receives updates for both cleared and newly claimed tiles
let changed_tile_indices: Vec<u32> = territories
.iter()
.enumerate()
.filter_map(|(idx, &new_ownership)| {
let old_ownership = old_territories.get(idx).copied().unwrap_or(crate::game::TileOwnership::Unclaimed);
if old_ownership != new_ownership { Some(idx as u32) } else { None }
})
.collect();
// Update game view territories
game_view.territories = Arc::from(territories.as_slice());
game_view.changed_tiles = changed_tile_indices;
// Recalculate player tile counts from scratch (simple and correct)
update_player_tile_counts(&mut game_view, &territories);
// Update previous state
previous_state.spawns = current_spawns.clone();
debug!("Spawn preview: {} removed, {} added, {} changed tiles (sample: {:?})", removed_spawns.len(), added_spawns.len(), game_view.changed_tiles.len(), &game_view.changed_tiles[..game_view.changed_tiles.len().min(10)]);
}
/// Recalculates player tile counts from territory ownership data
fn update_player_tile_counts(game_view: &mut GameView, territories: &[crate::game::TileOwnership]) {
// Count tiles per nation
let mut tile_counts: HashMap<NationId, u32> = HashMap::new();
for ownership in territories {
if let Some(nation_id) = ownership.nation_id() {
*tile_counts.entry(nation_id).or_insert(0) += 1;
}
}
// Update player views
for player in &mut game_view.players {
player.tile_count = tile_counts.get(&player.id).copied().unwrap_or(0);
}
}
/// System to clear per-frame input state data
fn clear_input_state_system(input: Option<NonSend<Arc<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();
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));
}
}
/// SystemParam to group read-only game resources
#[derive(SystemParam)]
pub struct GameResources<'w> {
border_cache: Res<'w, crate::game::BorderCache>,
player_entity_map: Res<'w, crate::game::PlayerEntityMap>,
human_count: Res<'w, crate::game::HumanPlayerCount>,
terrain: If<Res<'w, TerrainData>>,
}
/// Execute turn gameplay logic
/// Only runs when turn_is_ready() returns true (once per turn at 10 TPS)
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
pub fn execute_turn_gameplay_system(mut current_turn: ResMut<CurrentTurn>, mut game_view: Option<ResMut<GameView>>, mut territory_manager: If<ResMut<crate::game::TerritoryManager>>, mut active_attacks: ResMut<crate::game::ActiveAttacks>, mut rng: ResMut<crate::game::DeterministicRng>, spawn_manager: Option<Res<crate::game::SpawnManager>>, mut spawn_phase: ResMut<SpawnPhase>, #[cfg(feature = "ui")] mut backend_messages: MessageWriter<BackendMessage>, server_handle: Option<Res<LocalTurnServerHandle>>, resources: GameResources, mut player_queries: ParamSet<(Query<(&mut crate::game::Troops, &mut crate::game::TerritorySize)>, Query<(&crate::game::world::NationId, &crate::game::PlayerName, &crate::game::PlayerColor, &crate::game::Troops, &crate::game::TerritorySize), Without<crate::game::Dead>>, Query<(&crate::game::world::NationId, &crate::game::Troops, &crate::game::TerritorySize, &mut crate::game::ai::bot::Bot), Without<crate::game::Dead>>)>, mut launch_ship_writer: MessageWriter<LaunchShipEvent>, mut commands: Commands) {
use std::sync::Arc;
let Some(ref mut game_view) = game_view else {
return;
};
let turn = &current_turn.turn;
let _guard = tracing::trace_span!("execute_turn_gameplay", turn_number = turn.turn_number, intent_count = turn.intents.len()).entered();
trace!("Executing turn {} with {} intents", turn.turn_number, turn.intents.len());
// Use BorderCache for border data (avoids per-turn HashMap reconstruction)
// BorderCache is updated by update_player_borders_system after territory changes
let player_borders = resources.border_cache.as_map();
// Process bot AI to generate actions (must be done before execute_turn to avoid query conflicts)
let bot_actions = crate::game::process_bot_actions(turn.turn_number, &territory_manager, &resources.terrain, &player_borders, rng.turn_number(), &mut player_queries.p2());
// Execute turn using standalone function
crate::game::execute_turn(turn, turn.turn_number, bot_actions, &mut territory_manager, &resources.terrain, &mut active_attacks, &mut rng, &player_borders, &resources.player_entity_map, &mut player_queries.p0(), &mut commands, &resources.human_count, &mut launch_ship_writer);
if turn.turn_number == 0
&& let Some(ref spawn_mgr) = spawn_manager
{
// Apply ALL spawns (both human player and bots) to game state on Turn(0)
let all_spawns = spawn_mgr.get_all_spawns();
tracing::debug!("Applying {} spawns to game state on Turn(0)", all_spawns.len());
for spawn in all_spawns {
crate::game::handle_spawn(spawn.nation, spawn.tile, &mut territory_manager, &resources.terrain, &mut active_attacks, &rng, &resources.player_entity_map, &mut player_queries.p0(), &mut commands);
}
}
let total_land_tiles = territory_manager.as_slice().iter().filter(|ownership| ownership.is_owned()).count() as u32;
// Collect changed tiles from territory manager for delta rendering
// Use iter_changes() to preserve changes for update_player_borders_system
let changed_tiles: Vec<u32> = territory_manager.iter_changes().map(|pos| territory_manager.pos_to_index(pos)).collect();
{
let _guard = tracing::trace_span!("create_game_view_in_execute_turn").entered();
// Build players list from ECS components (source of truth)
// Use p1() to access the read-only query after mutations are done
let players_view: Vec<crate::game::view::PlayerView> = player_queries
.p1()
.iter()
.map(|(nation_id, name, color, troops, territory_size)| {
crate::game::view::PlayerView {
id: *nation_id,
color: color.0.to_rgba(),
name: name.0.clone(),
tile_count: territory_size.0,
troops: troops.0 as u32,
is_alive: true, // Query filters out Dead, so all remaining are alive
}
})
.collect();
**game_view = GameView {
size: territory_manager.size(),
territories: Arc::from(territory_manager.as_slice()),
turn_number: turn.turn_number,
total_land_tiles,
changed_tiles,
players: players_view,
ships: Vec::new(), // Will be populated by update_ship_views_system
};
}
trace!("GameView updated: turn {}", game_view.turn_number);
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();
}
/// Update ship views in GameView
/// Runs after execute_turn_gameplay_system to populate ship data
pub fn update_ship_views_system(mut game_view: Option<ResMut<GameView>>, territory_manager: If<Res<crate::game::TerritoryManager>>, ships: Query<(&crate::game::ships::Ship, &ChildOf)>, player_query: Query<&crate::game::world::NationId>) {
let Some(ref mut game_view) = game_view else {
return;
};
let ship_views = ships
.iter()
.map(|(ship, parent)| {
let current_tile_pos = ship.get_current_tile();
let current_tile_idx = territory_manager.pos_to_index(current_tile_pos);
let target_tile_idx = territory_manager.pos_to_index(ship.target_tile);
let owner_id = player_query.get(parent.0).copied().unwrap_or(NationId::ZERO);
crate::game::view::ShipView { id: ship.id, owner_id, current_tile: current_tile_idx, target_tile: target_tile_idx, 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_pos| territory_manager.pos_to_index(tile_pos)).collect() }
})
.collect();
game_view.ships = ship_views;
}
/// 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>, territory_manager: Option<Res<crate::game::TerritoryManager>>, 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 => {
let _guard = tracing::debug_span!("handle_start_game").entered();
info!("Processing StartGame command");
if territory_manager.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 = {
let _guard = tracing::debug_span!("terrain_loading").entered();
match crate::game::TerrainData::load_world_map() {
Ok(data) => data,
Err(e) => {
error!("Failed to load World map: {}", e);
continue;
}
}
};
let terrain_arc = Arc::new(terrain_data.clone());
commands.insert_resource(terrain_data);
let size = terrain_arc.size();
let width = size.x;
let height = size.y;
let tile_count = (width as usize) * (height as usize);
let mut conquerable_tiles = Vec::with_capacity(tile_count);
{
let _guard = tracing::trace_span!("conquerable_tiles_calculation", tile_count = tile_count).entered();
for y in 0..height {
for x in 0..width {
conquerable_tiles.push(terrain_arc.is_conquerable(glam::U16Vec2::new(x, y)));
}
}
}
let params = crate::game::GameInitParams { map_width: width, map_height: height, conquerable_tiles, client_player_id: NationId::ZERO, intent_rx: intent_receiver.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 territory_manager.is_some() {
// Remove all game-specific resources (refactored standalone resources)
commands.remove_resource::<crate::game::TerritoryManager>();
commands.remove_resource::<crate::game::ActiveAttacks>();
commands.remove_resource::<crate::game::DeterministicRng>();
commands.remove_resource::<crate::game::CoastalTiles>();
commands.remove_resource::<crate::game::PlayerEntityMap>();
commands.remove_resource::<crate::game::ClientPlayerId>();
commands.remove_resource::<crate::game::HumanPlayerCount>();
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::<TerrainData>();
commands.remove_resource::<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::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);
}
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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
}
}

View 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 (u16, supporting maps up to 65535x65535)
///
/// This is now a type alias to glam::U16Vec2 for better vector operations.
/// Use `tile_to_index()` and `tile_from_index()` for index conversions.
pub type TileCoord = glam::U16Vec2;
/// Convert tile coordinates to linear tile index
#[inline]
pub fn tile_to_index(tile: TileCoord, map_width: u16) -> usize {
(tile.y as usize) * (map_width as usize) + (tile.x as usize)
}
/// Create tile coordinates from linear tile index
#[inline]
pub fn tile_from_index(index: usize, map_width: u16) -> TileCoord {
let width_usize = map_width as usize;
TileCoord::new((index % width_usize) as u16, (index / width_usize) as u16)
}
/// 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: u16, map_height: u16, 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::new(tile_x as u16, tile_y as u16)) } else { None }
}
/// Convert tile coordinates to world position (center of tile)
pub fn tile_to_world(tile: TileCoord, map_width: u16, map_height: u16, 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: u16, map_height: u16, pixel_scale: f32) -> WorldPos {
let tile = crate::ui::input::tile_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: u16, map_height: u16, 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::new(5, 3);
let index = tile_to_index(tile, 10);
assert_eq!(index, 35); // 3 * 10 + 5
let tile2 = tile_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);
}
}

View File

@@ -0,0 +1,239 @@
//! Shared leaderboard data structures and utilities
//!
//! This module contains types and systems for managing leaderboard data
//! that are shared between desktop and WASM builds.
use std::collections::HashMap;
use bevy_ecs::prelude::*;
use crate::game::view::GameView;
use crate::game::{ClientPlayerId, PlayerColor, PlayerName, world::NationId};
#[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)
}
/// Resolve a player's display name with fallbacks for missing or empty names
fn resolve_player_name(player_id: NationId, client_player_id: NationId, player_data: Option<(&PlayerName, &PlayerColor)>) -> String {
player_data.map(|(name, _)| if name.0.is_empty() { if player_id == client_player_id { "Player".to_string() } else { format!("Nation {}", player_id) } } else { name.0.clone() }).unwrap_or_else(|| format!("Nation {}", player_id))
}
/// Resource to track last emitted leaderboard state for deduplication
#[derive(Resource, Default, Debug)]
pub struct LastLeaderboardDigest {
pub entries: Vec<(NationId, String, u32, u32)>, // (id, name, tile_count, troops)
pub turn: u64,
}
/// Resource to track last emitted attacks state for deduplication
#[derive(Resource, Default, Debug)]
pub struct LastAttacksDigest {
pub entries: Vec<(NationId, Option<NationId>, u32, u64, bool)>, // (attacker_id, target_id, troops, id, is_outgoing)
pub turn: u64,
pub count: usize, // Track number of attacks to always detect add/remove
}
/// Resource to track display order update cycles (updates every 3rd tick)
#[derive(Resource, Default, Debug)]
pub struct DisplayOrderUpdateCounter {
pub tick: u32,
}
/// Resource to track last calculated display order for each nation
#[derive(Resource, Default, Debug)]
pub struct LastDisplayOrder {
pub order_map: HashMap<NationId, usize>,
}
/// Resource to throttle leaderboard snapshot emissions
#[derive(Resource, Debug)]
pub struct LeaderboardThrottle {
last_emission: 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(420), // 420ms (3x faster, display order updates every 3rd tick)
}
}
}
/// Build a complete leaderboard snapshot from current game state
/// Returns None if nothing has changed since last_digest
pub fn build_leaderboard_snapshot(game_view: &GameView, client_player_id: NationId, players_by_id: &HashMap<NationId, (&PlayerName, &PlayerColor)>, last_digest: &mut LastLeaderboardDigest, display_order_map: &HashMap<NationId, usize>) -> Option<LeaderboardSnapshot> {
let total_land_tiles = game_view.total_land_tiles;
// Build current digest for comparison (includes names now), filter out eliminated players
let current_entries: Vec<(NationId, String, u32, u32)> = game_view
.players
.iter()
.filter(|p| p.tile_count > 0) // Exclude eliminated players
.map(|p| {
let player_data = players_by_id.get(&p.id).copied();
let name = resolve_player_name(p.id, client_player_id, player_data);
(p.id, name, p.tile_count, p.troops)
})
.collect();
// Check if anything has changed (stats OR names)
if current_entries == last_digest.entries && game_view.turn_number == last_digest.turn {
return None; // No changes
}
// Update digest
last_digest.entries = current_entries;
last_digest.turn = game_view.turn_number;
// Build complete leaderboard entries (names + colors + stats), filter out eliminated players
let mut entries: Vec<LeaderboardEntry> = game_view
.players
.iter()
.filter(|p| p.tile_count > 0) // Exclude eliminated players
.map(|player| {
let player_data = players_by_id.get(&player.id).copied();
let name = resolve_player_name(player.id, client_player_id, player_data);
let color = player_data.map(|(_, color)| rgba_to_hex(color.0.to_rgba())).unwrap_or_else(|| "808080".to_string()); // Gray fallback
let territory_percent = if total_land_tiles > 0 { 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,
rank: 0, // Assigned after sorting
display_order: 0, // Assigned after sorting
}
})
.collect();
// Sort by tile count descending
entries.sort_by(|a, b| b.tile_count.cmp(&a.tile_count));
// Assign rank and display_order after sorting
for (idx, entry) in entries.iter_mut().enumerate() {
entry.rank = idx + 1; // 1-indexed rank
// Use display_order from map, or fallback to current rank position
// TODO: Handle mid-game joins by initializing display_order for new players
entry.display_order = display_order_map.get(&entry.id).copied().unwrap_or(idx);
}
Some(LeaderboardSnapshot { turn: game_view.turn_number, total_land_tiles, entries, client_player_id })
}
/// Bevy system that emits leaderboard snapshot events
#[allow(clippy::too_many_arguments)]
pub fn emit_leaderboard_snapshot_system(game_view: If<Res<GameView>>, client_player_id: Option<Res<ClientPlayerId>>, players: Query<(&NationId, &PlayerName, &PlayerColor)>, mut last_digest: If<ResMut<LastLeaderboardDigest>>, mut throttle: If<ResMut<LeaderboardThrottle>>, mut counter: If<ResMut<DisplayOrderUpdateCounter>>, mut last_display_order: If<ResMut<LastDisplayOrder>>, mut backend_messages: MessageWriter<BackendMessage>) {
let _guard = tracing::debug_span!("emit_leaderboard_snapshot").entered();
let Some(ref client_player_id) = client_player_id else {
return;
};
// 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;
}
// Build player lookup map from ECS components
let players_by_id: HashMap<NationId, (&PlayerName, &PlayerColor)> = players.iter().map(|(nation_id, name, color)| (*nation_id, (name, color))).collect();
// Increment tick counter (wraps on overflow)
counter.tick = counter.tick.wrapping_add(1);
// Every 3rd tick, recalculate display order from current rankings
if counter.tick.is_multiple_of(3) {
// Build temporary sorted list to determine new display order
let mut sorted_players: Vec<_> = game_view
.players
.iter()
.filter(|p| p.tile_count > 0) // Exclude eliminated players
.collect();
sorted_players.sort_by(|a, b| b.tile_count.cmp(&a.tile_count));
// Update display order map with current rankings
last_display_order.order_map.clear();
for (idx, player) in sorted_players.iter().enumerate() {
last_display_order.order_map.insert(player.id, idx);
}
}
if let Some(snapshot) = build_leaderboard_snapshot(&game_view, client_player_id.0, &players_by_id, &mut last_digest, &last_display_order.order_map) {
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(active_attacks: &crate::game::ActiveAttacks, turn_number: u64, client_player_id: NationId, last_digest: &mut LastAttacksDigest) -> Option<AttacksUpdatePayload> {
// Get attacks for the client player
let raw_attacks = active_attacks.get_attacks_for_player(client_player_id);
// Build current digest for comparison
let current_entries = raw_attacks.iter().map(|&(attacker_id, target_id, troops, id, is_outgoing)| (attacker_id, target_id, troops as u32, id, is_outgoing)).collect();
let current_count = raw_attacks.len();
// Always send update if attack count changed (add/remove)
let count_changed = current_count != last_digest.count;
// Check if digest changed (troop counts, etc.)
let digest_changed = current_entries != last_digest.entries;
if !count_changed && !digest_changed {
return None; // No changes at all
}
// Update digest
last_digest.entries = current_entries;
last_digest.turn = turn_number;
last_digest.count = current_count;
// Build attack entries
let entries: Vec<AttackEntry> = raw_attacks.into_iter().map(|(attacker_id, target_id, troops, id, is_outgoing)| AttackEntry { id, attacker_id, target_id, troops: troops as u32, is_outgoing }).collect();
Some(AttacksUpdatePayload { turn: turn_number, entries })
}
/// Bevy system that emits attacks update events
pub fn emit_attacks_update_system(active_attacks: Option<Res<crate::game::ActiveAttacks>>, game_view: If<Res<GameView>>, client_player_id: Option<Res<ClientPlayerId>>, mut last_digest: If<ResMut<LastAttacksDigest>>, mut backend_messages: MessageWriter<BackendMessage>) {
let _guard = tracing::debug_span!("emit_attacks_update").entered();
let Some(ref active_attacks) = active_attacks else {
return;
};
let Some(ref client_player_id) = client_player_id else {
return;
};
if let Some(payload) = build_attacks_update(active_attacks, game_view.turn_number, client_player_id.0, &mut last_digest) {
backend_messages.write(BackendMessage::AttacksUpdate(payload));
}
}

View File

@@ -0,0 +1,106 @@
//! 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
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Mutex};
use bevy_ecs::prelude::*;
use crate::game::NationId;
use crate::game::view::GameView;
pub mod input;
pub mod leaderboard;
pub mod plugin;
pub mod protocol;
pub mod transport;
// Re-export commonly used types
pub use input::*;
pub use leaderboard::*;
pub use plugin::*;
pub use protocol::*;
pub use transport::*;
/// Resource to track currently highlighted nation for visual feedback
#[derive(Resource, Default, Debug)]
pub struct NationHighlightState {
pub highlighted_nation: Option<NationId>,
}
/// System that tracks hovered nation and emits highlight events
pub fn emit_nation_highlight_system(input_state: NonSend<Arc<Mutex<InputState>>>, game_view: If<Res<GameView>>, mut highlight_state: If<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_to_index(tile_coord, game_view.width());
let ownership = game_view.get_ownership(tile_index as u32);
ownership.nation_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: If<Res<GameView>>, mut ship_tracker: ResMut<ShipStateTracker>, mut backend_messages: MessageWriter<BackendMessage>) {
let current_ship_ids: 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 }));
}
}

View 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 crate::app::{App, Plugin, Update};
use crate::game::view::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};
use bevy_ecs::prelude::*;
/// 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) {
let _guard = tracing::trace_span!("frontend_plugin_build").entered();
// 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>, stream_territory_deltas::<T>).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");
}
}

View File

@@ -0,0 +1,345 @@
//! 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};
use crate::game::NationId;
/// All messages sent from backend to frontend
/// Binary data (terrain, territory, nation palette, deltas) are sent via separate binary channels
#[derive(Debug, Clone, Serialize, Deserialize, Message)]
#[serde(tag = "msg_type")]
pub enum BackendMessage {
/// Complete leaderboard snapshot (includes names, colors, and stats)
LeaderboardSnapshot(LeaderboardSnapshot),
/// Dynamic attacks updates
AttacksUpdate(AttacksUpdatePayload),
/// Active ships on the map
ShipsUpdate(ShipsUpdatePayload),
/// Game has ended with the specified outcome
GameEnded { outcome: GameOutcome },
/// Spawn phase update
/// - countdown: None = phase active, waiting for first spawn
/// - countdown: Some = countdown in progress with epoch timestamp
SpawnPhaseUpdate { countdown: Option<SpawnCountdown> },
/// Spawn phase has ended, game is now active
SpawnPhaseEnded,
/// Highlight a specific nation (None to clear)
HighlightNation { nation_id: Option<NationId> },
}
/// All messages sent from frontend to backend
#[derive(Debug, Clone, Serialize, Deserialize, Message)]
#[serde(tag = "msg_type")]
pub enum FrontendMessage {
/// Start a new game
StartGame,
/// Quit the current game and return to menu
QuitGame,
/// Set attack ratio (percentage of troops to use when attacking)
SetAttackRatio { ratio: f32 },
}
/// Terrain types for map tiles
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[repr(u8)]
pub enum TerrainType {
Water = 0,
Land = 1,
Mountain = 2,
}
/// Encode complete initialization data into binary format for channel streaming
///
/// This combines terrain, territory, and nation palette data into a single atomic payload
/// to avoid synchronization issues with multiple messages.
///
/// Format: [terrain_len:4][terrain_data][territory_len:4][territory_data][nation_palette_count:2][nation_palette_rgb:N*3]
///
/// Terrain data format: [width:2][height:2][tile_ids:N][palette_count:2][palette_rgb:N*3]
/// Territory data format: [count:4][tiles...] where tiles = [index:4][owner:2]
///
/// All integers are little-endian
pub fn encode_init_binary(size: glam::U16Vec2, tile_ids: &[u8], terrain_palette: &[RgbColor], territories: &[crate::game::TileOwnership], nation_palette: &[RgbColor]) -> Vec<u8> {
let tile_count = (size.x as usize) * (size.y as usize);
assert_eq!(tile_ids.len(), tile_count, "Tile ID count mismatch");
let terrain_palette_count = terrain_palette.len();
assert!(terrain_palette_count <= u16::MAX as usize, "Terrain palette too large");
// Build terrain data
let terrain_size = 2 + 2 + tile_count + 2 + (terrain_palette_count * 3);
let mut terrain_data = Vec::with_capacity(terrain_size);
terrain_data.extend_from_slice(&size.x.to_le_bytes());
terrain_data.extend_from_slice(&size.y.to_le_bytes());
terrain_data.extend_from_slice(tile_ids);
terrain_data.extend_from_slice(&(terrain_palette_count as u16).to_le_bytes());
for color in terrain_palette {
terrain_data.extend_from_slice(&[color.r, color.g, color.b]);
}
// Build territory data (only player-owned tiles, filter out unclaimed)
let claimed_tiles: Vec<(u32, u16)> = territories.iter().enumerate().filter_map(|(index, &ownership)| ownership.nation_id().map(|nation_id| (index as u32, nation_id.get()))).collect();
let territory_count = claimed_tiles.len() as u32;
let territory_size = 4 + (claimed_tiles.len() * 6);
let mut territory_data = Vec::with_capacity(territory_size);
territory_data.extend_from_slice(&territory_count.to_le_bytes());
for (index, owner) in claimed_tiles {
territory_data.extend_from_slice(&index.to_le_bytes());
territory_data.extend_from_slice(&owner.to_le_bytes());
}
// Build nation palette data
let nation_palette_count = nation_palette.len();
assert!(nation_palette_count <= u16::MAX as usize, "Nation palette too large");
// Combine into single payload with length prefixes
let total_size = 4 + terrain_data.len() + 4 + territory_data.len() + 2 + (nation_palette_count * 3);
let mut data = Vec::with_capacity(total_size);
data.extend_from_slice(&(terrain_data.len() as u32).to_le_bytes());
data.extend_from_slice(&terrain_data);
data.extend_from_slice(&(territory_data.len() as u32).to_le_bytes());
data.extend_from_slice(&territory_data);
// Append nation palette
data.extend_from_slice(&(nation_palette_count as u16).to_le_bytes());
for color in nation_palette {
data.extend_from_slice(&[color.r, color.g, color.b]);
}
data
}
/// A single tile change in the territory map
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct TileChange {
/// Tile index (row * width + col)
pub index: u32,
/// New owner ID (0-65534 for nations, 65535 for unclaimed)
pub owner_id: u16,
}
/// Binary format for efficient territory delta streaming
/// This is for the pixel streaming channel, separate from JSON messages
#[derive(Debug)]
pub struct BinaryTerritoryDelta {
/// Raw bytes: [turn:8][count:4][changes...]
/// Each change: [index:4][owner:2] = 6 bytes
pub data: Vec<u8>,
}
impl BinaryTerritoryDelta {
/// Create binary delta from territory changes
pub fn encode(turn: u64, changes: &[TileChange]) -> Vec<u8> {
let count = changes.len() as u32;
let mut data = Vec::with_capacity(12 + changes.len() * 6);
// Header: turn (8 bytes) + count (4 bytes)
data.extend_from_slice(&turn.to_le_bytes());
data.extend_from_slice(&count.to_le_bytes());
// Changes: each is index (4 bytes) + owner (2 bytes)
for change in changes {
data.extend_from_slice(&change.index.to_le_bytes());
data.extend_from_slice(&change.owner_id.to_le_bytes());
}
data
}
/// Decode binary delta back to structured format
pub fn decode(data: &[u8]) -> Option<(u64, Vec<TileChange>)> {
if data.len() < 12 {
return None; // Not enough data for header
}
let turn = u64::from_le_bytes([data[0], data[1], data[2], data[3], data[4], data[5], data[6], data[7]]);
let count = u32::from_le_bytes([data[8], data[9], data[10], data[11]]) as usize;
let expected_size = 12 + count * 6;
if data.len() != expected_size {
return None; // Invalid size
}
let mut changes = Vec::with_capacity(count);
for i in 0..count {
let offset = 12 + i * 6;
let index = u32::from_le_bytes([data[offset], data[offset + 1], data[offset + 2], data[offset + 3]]);
let owner_id = u16::from_le_bytes([data[offset + 4], data[offset + 5]]);
changes.push(TileChange { index, owner_id });
}
Some((turn, changes))
}
}
/// RGB color for player palette
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct RgbColor {
pub r: u8,
pub g: u8,
pub b: u8,
}
/// Queries sent from frontend to backend about the map
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum MapQuery {
/// Get owner of tile at world coordinates
GetOwnerAt { 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: NationId },
/// Convert screen coordinates to tile index
ScreenToTile { screen_x: f32, screen_y: f32 },
}
/// 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,
},
}
/// Unified leaderboard entry containing both static and dynamic data
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LeaderboardEntry {
pub id: NationId,
pub name: String,
pub color: String, // Hex color without alpha, e.g. "0A44FF"
pub tile_count: u32,
pub troops: u32,
pub territory_percent: f32,
pub rank: usize, // Current rank (1-indexed, updates every tick)
pub display_order: usize, // Visual position (0-indexed, updates every 3rd tick)
}
/// Complete leaderboard snapshot (replaces separate Init/Update)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LeaderboardSnapshot {
pub turn: u64,
pub total_land_tiles: u32,
pub entries: Vec<LeaderboardEntry>,
pub client_player_id: NationId,
}
/// Outcome of the game
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum GameOutcome {
/// Player won the game
Victory,
/// Player lost the game
Defeat,
}
/// Single attack entry for attacks UI
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AttackEntry {
pub id: u64,
pub attacker_id: NationId,
pub target_id: Option<NationId>, // None for unclaimed territory
pub troops: u32,
pub is_outgoing: bool,
}
/// Attacks update payload
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AttacksUpdatePayload {
pub turn: u64,
pub entries: Vec<AttackEntry>,
}
/// Ships update payload with lifecycle variants
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ShipsUpdatePayload {
pub turn: u64,
pub updates: Vec<ShipUpdateVariant>,
}
/// Ship update variants for efficient delta updates
/// NOTE: SHIP_TICKS_PER_TILE (1) must be synchronized between backend and frontend
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum ShipUpdateVariant {
/// Ship created - full initial state
Create {
id: u32,
owner_id: NationId,
path: Vec<u32>,
troops: u32, // Static value, currently unused for rendering
},
/// Ship moved to next tile in path
Move { id: u32, current_path_index: u32 },
/// Ship destroyed (arrived or cancelled)
Destroy { id: u32 },
}
// TODO: On client reconnection/late-join, send Create variants for all active ships
/// Countdown state for spawn phase
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpawnCountdown {
pub started_at_ms: u64,
pub duration_secs: f32,
}
#[cfg(test)]
mod tests {
use super::*;
#[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 },
TileChange {
index: 400,
owner_id: 65535, // Unclaimed
},
];
let encoded = BinaryTerritoryDelta::encode(42, &changes);
assert_eq!(encoded.len(), 12 + 4 * 6);
let decoded = BinaryTerritoryDelta::decode(&encoded).unwrap();
assert_eq!(decoded.0, 42);
assert_eq!(decoded.1.len(), 4);
assert_eq!(decoded.1[0].index, 100);
assert_eq!(decoded.1[0].owner_id, 1);
assert_eq!(decoded.1[3].owner_id, 65535); // Unclaimed
}
}

View File

@@ -0,0 +1,219 @@
//! Shared render bridge infrastructure for platform-agnostic rendering
//!
//! This module provides the common logic for rendering bridges across platforms
//! (WASM, Tauri, etc.), with platform-specific transport mechanisms abstracted
//! behind the RenderBridgeTransport trait.
use crate::game::TerrainData;
use crate::game::view::GameView;
use crate::ui::protocol::{BackendMessage, BinaryTerritoryDelta, FrontendMessage, RenderInputEvent, RgbColor, TileChange};
use bevy_ecs::prelude::*;
use tracing::{error, info, trace, warn};
/// Trait for platform-specific frontend communication
///
/// This abstracts the actual mechanism for bidirectional frontend communication,
/// allowing WASM (JS callbacks), Tauri (events), and other platforms to implement
/// their own transport while sharing the core logic.
///
/// All platforms use binary channels for terrain/territory data to ensure
/// consistent encoding/decoding and optimal performance.
pub trait FrontendTransport: Send + Sync + Clone + 'static {
/// Send a message from backend to frontend (JSON-serialized control messages)
fn send_backend_message(&self, message: &BackendMessage) -> Result<(), String>;
/// Send binary initialization data (terrain + territory) for initial load
///
/// Format: [terrain_len:4][terrain_data][territory_len:4][territory_data]
/// See `encode_init_binary` for details.
fn send_init_binary(&self, data: Vec<u8>) -> Result<(), String>;
/// Send binary territory delta data
///
/// Format: [turn:8][count:4][changes...]
/// See `BinaryTerritoryDelta::encode` for details.
fn send_binary_delta(&self, data: Vec<u8>) -> Result<(), String>;
/// Try to receive a message from the frontend
///
/// Returns `Some(message)` if a message is available, `None` if not.
/// This should be non-blocking and called frequently (e.g., every frame).
fn try_recv_frontend_message(&self) -> Option<FrontendMessage>;
}
/// Resource for managing frontend communication state
#[derive(Resource)]
pub struct RenderBridge<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: If<Res<GameView>>, terrain_data: If<Res<TerrainData>>, mut bridge: If<ResMut<RenderBridge<T>>>) {
// 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;
}
let _guard = tracing::debug_span!(
"send_initial_render_data",
size = ?game_view.size(),
player_count = game_view.players.len()
)
.entered();
// Mark as initialized FIRST to prevent re-execution even if send fails
// This is important because the frontend callback might not be registered yet
// on the first few frames, causing send to fail but we don't want to rebuild
// the expensive RenderInit message multiple times
bridge.initialized = true;
// Prepare terrain data
let size = game_view.size();
let tile_ids = terrain_data.get_tile_ids();
let palette_colors: Vec<RgbColor> = terrain_data.get_terrain_palette_colors().into_iter().map(|[r, g, b]| RgbColor { r, g, b }).collect();
info!("Terrain palette: {} colors", palette_colors.len());
// Build nation palette
let nation_palette = {
let _guard = tracing::trace_span!("build_player_palette").entered();
// Allocate only enough space for active players + a small buffer
let max_player_id = game_view.players.iter().map(|p| p.id.get()).max().unwrap_or(0) as usize;
// Allocate palette size as: max(256, max_player_id + 1) to handle typical player counts
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 {
colors[player.id.get() 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 };
}
colors
};
// Send terrain, territory, and nation palette via binary channel (both WASM and Tauri)
{
let _guard = tracing::trace_span!("send_init_binary", terrain_size = tile_ids.len(), territory_size = game_view.territories.len(), nation_palette_size = nation_palette.len()).entered();
let binary_init = crate::ui::protocol::encode_init_binary(size, tile_ids, &palette_colors, &game_view.territories, &nation_palette);
if let Err(e) = bridge.transport.send_init_binary(binary_init) {
error!("Failed to send init binary data: {}", e);
bridge.initialized = false;
return;
}
}
info!("Initialization data sent successfully via binary channel (terrain + territory + nation palette)");
}
/// System to detect and stream territory changes
pub fn stream_territory_deltas<T: FrontendTransport>(game_view: If<Res<GameView>>, bridge: If<Res<RenderBridge<T>>>) {
// 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 _guard = tracing::debug_span!("stream_territory_deltas").entered();
if !game_view.changed_tiles.is_empty() {
let turn = game_view.turn_number;
// Build delta from the pre-tracked changes
// Include ALL changed tiles, both owned and unclaimed (65535)
let changes: Vec<TileChange> = game_view
.changed_tiles
.iter()
.map(|&index| {
let ownership = game_view.get_ownership(index);
let owner_id: u16 = ownership.into();
TileChange { index, owner_id }
})
.collect();
// Send binary delta (used by both WASM and 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);
}
}
}
/// 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: u16) -> 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::tile_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::tile_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(())
}
/// 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);
}
}

View File

@@ -0,0 +1,37 @@
use borders_core::game::terrain::data::{MapManifest, MapMetadata, TerrainData, TileType};
use borders_core::game::world::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 u16, height as u16, terrain_data_raw);
TerrainData { _manifest: MapManifest { map: MapMetadata { size: glam::U16Vec2::new(width as u16, height as u16), 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(glam::U16Vec2::new(0, 0)));
assert!(terrain.is_land(glam::U16Vec2::new(5, 0)));
}
#[test]
fn test_is_conquerable() {
let terrain = create_test_terrain(10, 10);
assert!(!terrain.is_conquerable(glam::U16Vec2::new(0, 0)));
assert!(terrain.is_conquerable(glam::U16Vec2::new(5, 0)));
}
#[test]
fn test_is_navigable() {
let terrain = create_test_terrain(10, 10);
assert!(terrain.is_navigable(glam::U16Vec2::new(0, 0)));
assert!(!terrain.is_navigable(glam::U16Vec2::new(5, 0)));
}

7
crates/borders-desktop/.gitignore vendored Normal file
View 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

View 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"]

View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View 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"
}
]
}
]
}

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

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