Update source files

This commit is contained in:
2025-10-15 14:08:33 -05:00
commit 0838663e48
182 changed files with 30794 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 @@
435be7e355e0870604dc0c00939c011cddb5e135

7807
Cargo.lock generated Normal file
View File

File diff suppressed because it is too large Load Diff

47
Cargo.toml Normal file
View File

@@ -0,0 +1,47 @@
[workspace]
members = [
"crates/borders-core",
"crates/borders-desktop",
"crates/borders-wasm",
"crates/borders-server",
]
resolver = "2"
[workspace.package]
authors = ["Xevion"]
edition = "2024"
version = "0.6.1"
# Enable a small amount of optimization in the dev profile.
[profile.dev]
opt-level = 1
# Enable a large amount of optimization in the dev profile for dependencies.
[profile.dev.package."*"]
opt-level = 3
# Enable more optimization in the release profile at the cost of compile time.
[profile.release]
# Compile the entire crate as one unit.
# Slows compile times, marginal improvements.
codegen-units = 1
# Do a second optimization pass over the entire program, including dependencies.
# Slows compile times, marginal improvements.
lto = "thin"
# Development profile for WASM builds (faster compile times)
[profile.wasm-dev]
inherits = "dev"
opt-level = 1
panic = "abort"
# Size optimization profile for WASM builds
[profile.wasm-release]
inherits = "release"
incremental = false
debug = false
opt-level = "s" # Optimize for size
lto = true # Link-time optimization
codegen-units = 1 # Single codegen unit for better optimization
panic = "abort" # Smaller panic implementation
strip = true # Remove debug symbols

92
Justfile Normal file
View File

@@ -0,0 +1,92 @@
set shell := ["powershell"]
default:
just --list
check:
@echo "Running clippy (native)..."
@cargo clippy --all-targets --all-features --workspace -- -D warnings
@echo "Running cargo check (native)..."
@cargo check --all-targets --all-features --workspace
@echo "Running clippy (wasm32-unknown-unknown)..."
@cargo clippy --target wasm32-unknown-unknown --all-features -p borders-wasm -- -D warnings
@echo "Running cargo check (wasm32-unknown-unknown)..."
@cargo check --target wasm32-unknown-unknown --all-features -p borders-wasm
@echo "Running cargo machete..."
@cargo machete --with-metadata
@echo "All checks passed"
check-ts:
@just _wasm-build wasm-dev
@echo "Running frontend checks..."
@pnpm run -C frontend check
fix:
@echo "Running cargo fix..."
cargo fix --all-targets --all-features --workspace --allow-dirty
wasm-dev: wasm-dev-build
pnpm -C frontend dev:browser --port 1421
# Build WASM with the specified profile (wasm-dev or wasm-release)
_wasm-build profile:
@$profile = "{{ profile }}"; \
$wasmFile = "target/wasm32-unknown-unknown/$profile/borders_wasm.wasm"; \
$pkgJs = "pkg/borders.js"; \
$pkgWasm = "pkg/borders_bg.wasm"; \
$frontendPkgJs = "frontend/pkg/borders.js"; \
$frontendPkgWasm = "frontend/pkg/borders_bg.wasm"; \
$beforeTime = if (Test-Path $wasmFile) { (Get-Item $wasmFile).LastWriteTime } else { $null }; \
cargo build -p borders-wasm --profile $profile --target wasm32-unknown-unknown; \
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; \
$afterTime = if (Test-Path $wasmFile) { (Get-Item $wasmFile).LastWriteTime } else { $null }; \
$wasRebuilt = ($beforeTime -eq $null) -or ($afterTime -ne $beforeTime); \
$pkgExists = (Test-Path $pkgJs) -and (Test-Path $pkgWasm); \
$frontendPkgExists = (Test-Path $frontendPkgJs) -and (Test-Path $frontendPkgWasm); \
$isRelease = $profile -eq "wasm-release"; \
if ($wasRebuilt -or -not $pkgExists -or ($isRelease -and -not $frontendPkgExists)) { \
Write-Host "Running wasm-bindgen..."; \
wasm-bindgen --out-dir pkg --out-name borders --target web $wasmFile; \
if ($isRelease) { \
Write-Host "Running wasm-opt..."; \
wasm-opt -Oz --enable-bulk-memory --enable-threads --all-features pkg/borders_bg.wasm -o pkg/borders_bg.wasm; \
}; \
} else { \
Write-Host "WASM not rebuilt, skipping wasm-bindgen"; \
} \
New-Item -ItemType Directory -Force -Path 'frontend/pkg' | Out-Null; \
Copy-Item -Recurse -Force 'pkg/*' 'frontend/pkg/'; \
if ($isRelease) { \
Write-Host "Running frontend build..."; \
pnpm -C frontend build:browser; \
}; \
# Development WASM build, unoptimized
wasm-dev-build:
@just _wasm-build wasm-dev
# Release WASM build, optimized
wasm-release-build:
@just _wasm-build wasm-release
wasm-release: wasm-release-build
@echo "Visit http://localhost:8080 to play"
caddy file-server --listen :8080 --root frontend/dist/browser/client --browse
desktop-release:
cargo tauri build
target/release/borders-desktop.exe
desktop-dev:
cargo tauri build --debug
target/debug/borders-desktop.exe
# Run release manager CLI (handles setup specially)
release *args:
@$firstArg = "{{ args }}".Split()[0]; \
if ($firstArg -eq "setup") { \
Write-Host "Installing release manager dependencies..."; \
pnpm install -C scripts/release-manager; \
} else { \
pnpm --silent -C scripts/release-manager exec tsx cli.ts {{ args }}; \
}

13
LICENSE Normal file
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,90 @@
[package]
name = "borders-core"
version.workspace = true
edition.workspace = true
authors.workspace = true
[package.metadata.cargo-machete]
ignored = ["serde_bytes", "chrono"]
[features]
default = ["ui"]
ui = []
[dependencies]
anyhow = "1.0"
async-trait = "0.1"
bevy_ecs = { version = "0.17", default-features = false, features = ["std"] }
bincode = { version = "2.0.1", features = ["serde"] }
flume = "0.11"
futures = "0.3"
futures-lite = "2.6.1"
glam = { version = "0.30", features = ["serde"] }
hex = "0.4"
hmac = "0.12"
image = "0.25"
once_cell = "1.20"
rand = "0.9"
serde = { version = "1.0", features = ["derive", "rc"] }
slotmap = "1.0"
serde_bytes = "0.11"
serde_json = "1.0"
sha2 = "0.10"
tracing = "0.1"
url = "2.5.0"
web-transport = "0.9"
base64 = "0.22"
# Target-specific dependencies to keep WASM builds compatible
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
tokio = { version = "1", features = [
"rt-multi-thread",
"macros",
"time",
"io-util",
"sync",
] }
reqwest = { version = "0.12", default-features = false, features = [
"json",
"rustls-tls",
"brotli",
"gzip",
"deflate",
"zstd",
] }
hickory-resolver = { version = "0.25", features = [
"tls-ring",
"https-ring",
"quic-ring",
"h3-ring",
"webpki-roots",
] }
uuid = { version = "1.11", features = ["v4", "serde"] }
machineid-rs = "1.2"
directories = "5.0"
rustls-pemfile = "2.2.0"
ring = "0.17.14"
pem = "3.0.5"
sysinfo = "0.33"
[target.'cfg(windows)'.dependencies]
winreg = "0.52"
[target.'cfg(target_arch = "wasm32")'.dependencies]
tokio = { version = "1", features = ["rt", "macros", "time", "io-util"] }
reqwest = { version = "0.12", default-features = false, features = ["json"] }
uuid = { version = "1.11", features = ["v4", "serde", "js"] }
js-sys = "0.3"
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
gloo-timers = { version = "0.3", features = ["futures"] }
web-sys = { version = "0.3", features = [
"BroadcastChannel",
"MessageEvent",
"Navigator",
"Window",
] }
web-time = "1.1"
[build-dependencies]
chrono = "0.4"

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,150 @@
//! Minimal ECS app wrapper to replace Bevy's App
use bevy_ecs::message::{Message, Messages};
use bevy_ecs::prelude::*;
use bevy_ecs::schedule::{IntoScheduleConfigs, ScheduleLabel, Schedules};
use bevy_ecs::system::ScheduleSystem;
use std::fmt::Debug;
#[derive(Debug, Hash, PartialEq, Eq, Clone, ScheduleLabel)]
pub struct Startup;
#[derive(Debug, Hash, PartialEq, Eq, Clone, ScheduleLabel)]
pub struct Update;
#[derive(Debug, Hash, PartialEq, Eq, Clone, ScheduleLabel)]
pub struct Last;
#[derive(Debug, Hash, PartialEq, Eq, Clone, ScheduleLabel)]
pub struct Fixed;
pub struct App {
world: World,
}
impl App {
pub fn new() -> Self {
let mut world = World::new();
// Initialize schedules
let mut schedules = Schedules::new();
schedules.insert(Schedule::new(Startup));
schedules.insert(Schedule::new(Update));
schedules.insert(Schedule::new(Last));
schedules.insert(Schedule::new(Fixed));
world.insert_resource(schedules);
Self { world }
}
pub fn world(&self) -> &World {
&self.world
}
pub fn world_mut(&mut self) -> &mut World {
&mut self.world
}
pub fn insert_resource<R: Resource>(&mut self, resource: R) -> &mut Self {
self.world.insert_resource(resource);
self
}
pub fn init_resource<R: Resource + FromWorld>(&mut self) -> &mut Self {
self.world.init_resource::<R>();
self
}
pub fn insert_non_send_resource<R: 'static>(&mut self, resource: R) -> &mut Self {
self.world.insert_non_send_resource(resource);
self
}
pub fn add_message<M: Message>(&mut self) -> &mut Self {
if !self.world.contains_resource::<Messages<M>>() {
self.world.init_resource::<Messages<M>>();
// Add system to update this message type each frame
self.add_systems(Last, |mut messages: ResMut<Messages<M>>| {
messages.update();
});
}
self
}
pub fn add_systems<M>(&mut self, schedule: impl ScheduleLabel, systems: impl IntoScheduleConfigs<ScheduleSystem, M>) -> &mut Self {
let mut schedules = self.world.resource_mut::<Schedules>();
if let Some(schedule_inst) = schedules.get_mut(schedule) {
schedule_inst.add_systems(systems);
}
self
}
pub fn update(&mut self) {
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,8 @@
/// Game tick interval in milliseconds (10 TPS = 100ms per turn)
pub const TICK_INTERVAL: u64 = 100;
/// Number of bot players
pub const BOT_COUNT: usize = 500;
/// Spawn timeout duration in seconds
pub const SPAWN_TIMEOUT_SECS: f32 = 2.0;

View File

@@ -0,0 +1,39 @@
//! Game action system
//!
//! This module defines the core action types that can be performed in the game.
//! Actions represent discrete game events that can be initiated by both human players
//! and AI bots. They are processed deterministically during turn execution.
use serde::{Deserialize, Serialize};
/// Core game action type
///
/// This enum represents all possible actions that can be performed in the game.
/// Unlike `Intent`, which is a network-layer wrapper, `GameAction` is the actual
/// game-level operation.
///
/// Actions can originate from:
/// - Human players (via input systems → intents → network)
/// - AI bots (calculated deterministically during turn execution)
///
/// All actions are processed in a deterministic order during `GameInstance::execute_turn()`.
///
/// Note: Spawning is handled separately via Turn(0) and direct spawn manager updates,
/// not through the action system.
#[derive(Debug, Clone, Serialize, Deserialize, bincode::Encode, bincode::Decode)]
pub enum GameAction {
/// Attack a target tile with a percentage of the player's total troops
Attack { player_id: u16, target_tile: u32, troops_ratio: f32 },
/// Launch a transport ship to attack across water
LaunchShip {
player_id: u16,
target_tile: u32,
/// Troops as a percentage (0-100) to avoid float precision issues
troops_percent: u32,
},
// Future action types:
// BuildStructure { player_id: u16, tile_index: u32, structure_type: StructureType },
// LaunchNuke { player_id: u16, target_tile: u32 },
// RequestAlliance { player_id: u16, target_player: u16 },
// DeclareWar { player_id: u16, target_player: u16 },
}

View File

@@ -0,0 +1,257 @@
/// 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 crate::game::{
GameInstance,
player::{BorderTiles, PlayerId},
territory_manager::TerritoryManager,
};
/// Result of a border transition
#[derive(Debug)]
pub struct BorderTransitionResult {
/// Tiles that became interior (not borders anymore)
pub territory: Vec<usize>,
/// Tiles that are now attacker borders
pub attacker: Vec<usize>,
/// Tiles that are now defender borders
pub defender: Vec<usize>,
}
/// Manages border tiles for all players
///
/// A border tile is a tile adjacent to a tile with a different owner.
/// This resource maintains efficient lookup of border tiles for attack
/// targeting and UI rendering.
///
/// # Border Detection Algorithm
///
/// A tile is a border if any of its 4-directional neighbors has a different owner:
/// - Water tiles have no owner and are never borders
/// - Unclaimed tiles have no owner and are never borders
/// - A tile surrounded entirely by same-owner tiles is interior (not a border)
///
/// # Future ECS Migration
///
/// This will eventually become a `BorderTiles` component on player entities,
/// with a system to update borders after territory changes.
#[derive(Resource)]
pub struct BorderManager {
border_tiles: Vec<HashSet<usize>>,
}
impl Default for BorderManager {
fn default() -> Self {
Self::new()
}
}
impl BorderManager {
pub fn new() -> Self {
Self { border_tiles: Vec::new() }
}
/// Resets the border manager
///
/// Should only be called when a new game is started
pub fn reset(&mut self, _width: u16, _height: u16, player_count: usize) {
self.border_tiles = vec![HashSet::new(); player_count];
}
/// Update borders after territory changes (e.g., conquest)
///
/// A tile is a border if any neighbor has a different owner.
/// This method checks all affected tiles (conquered tiles + their neighbors)
/// and updates border sets accordingly.
pub fn update_after_conquest(&mut self, tiles: &HashSet<usize>, attacker: usize, defender: Option<usize>, territory_manager: &TerritoryManager) -> BorderTransitionResult {
let _guard = tracing::trace_span!("border_transition", tile_count = tiles.len()).entered();
let mut result = BorderTransitionResult { territory: Vec::new(), attacker: Vec::new(), defender: Vec::new() };
// Collect all tiles to check: conquered tiles + all their neighbors
let mut tiles_to_check = HashSet::new();
for &tile in tiles {
tiles_to_check.insert(tile);
// Add all neighbors
territory_manager.on_neighbor_indices(tile, |neighbor| {
tiles_to_check.insert(neighbor);
});
}
// Remove conquered tiles from defender borders
if let Some(defender_id) = defender {
for &tile in tiles {
self.border_tiles[defender_id].remove(&tile);
}
}
// Update borders for all affected tiles
for &tile in &tiles_to_check {
// Skip if tile has no owner (water or unclaimed)
if !territory_manager.has_owner(tile) {
continue;
}
let owner_id = territory_manager.get_owner(tile);
let owner = owner_id as usize;
// Determine if this tile is a border
let is_border = Self::is_border_tile(tile, owner_id, territory_manager);
if is_border {
// Add to border set if not already present
if self.border_tiles[owner].insert(tile) {
// Track for result
if owner == attacker {
result.attacker.push(tile);
} else if Some(owner) == defender {
result.defender.push(tile);
}
}
} else {
// Remove from border set if present
if self.border_tiles[owner].remove(&tile) {
// Tile became interior
if owner == attacker {
result.territory.push(tile);
}
}
}
}
if tiles.len() > 50 {
tracing::trace!(tile_count = tiles.len(), "Large border transition");
}
result
}
/// Check if a tile is a border tile using simple neighbor logic
///
/// A tile is a border if it has any neighbor with a different owner
fn is_border_tile(tile: usize, owner: u16, territory_manager: &TerritoryManager) -> bool {
let mut is_border = false;
territory_manager.on_neighbor_indices(tile, |neighbor| {
if territory_manager.get_owner(neighbor) != owner {
is_border = true;
}
});
is_border
}
/// Gets the border tiles of a player
pub fn get_border_tiles(&self, player: usize) -> &HashSet<usize> {
&self.border_tiles[player]
}
}
/// 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<usize>, territory: &TerritoryManager) -> HashMap<u16, HashSet<usize>> {
let _guard = tracing::trace_span!("group_tiles_by_owner", tile_count = affected_tiles.len()).entered();
let mut grouped: HashMap<u16, HashSet<usize>> = HashMap::new();
for &tile in affected_tiles {
let owner = territory.get_owner(tile);
grouped.entry(owner).or_default().insert(tile);
}
grouped
}
/// Update all player borders based on territory changes (batched system)
///
/// This system runs once per turn AFTER all territory changes (conquests, spawns, ships).
/// It drains the TerritoryManager's change buffer and updates borders for all affected players.
pub fn update_player_borders_system(mut players: Query<(&PlayerId, &mut BorderTiles)>, mut game_instance: ResMut<GameInstance>) {
if !game_instance.territory_manager.has_changes() {
return; // Early exit - no work needed
}
let _guard = tracing::trace_span!("update_player_borders").entered();
// Collect all changed tiles at once (draining to clear the buffer for next turn)
let (changed_tiles, raw_change_count): (HashSet<usize>, usize) = {
let _guard = tracing::trace_span!("collect_changed_tiles").entered();
let changes_vec: Vec<usize> = game_instance.territory_manager.drain_changes().collect();
let raw_count = changes_vec.len();
let unique_set: HashSet<usize> = 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);
for &tile in &changed_tiles {
affected_tiles.insert(tile);
game_instance.territory_manager.on_neighbor_indices(tile, |n| {
affected_tiles.insert(n);
});
}
affected_tiles
};
// Group tiles by owner for efficient per-player processing
let tiles_by_owner = group_tiles_by_owner(&affected_tiles, &game_instance.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 (dual-write to resource and components)
{
let _guard = tracing::trace_span!("update_all_player_borders", player_count = players.iter().len()).entered();
for (player_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(&player_id.0).unwrap_or(&empty_set);
update_borders_for_player(player_id.0, &mut component_borders.0, player_tiles, &game_instance.territory_manager);
// Dual-write: sync to resource for backward compatibility
{
let _guard = tracing::trace_span!("clone_borders_to_resource", player_id = player_id.0, border_count = component_borders.0.len()).entered();
game_instance.border_manager.border_tiles[player_id.0 as usize] = component_borders.0.clone();
}
}
}
}
/// 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(player_id: u16, borders: &mut HashSet<usize>, player_tiles: &HashSet<usize>, territory: &TerritoryManager) {
let _guard = tracing::trace_span!("update_borders_for_player", 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 (early exit when different owner found)
let is_border = territory.any_neighbor_has_different_owner(tile, player_id);
if is_border {
borders.insert(tile);
} else {
borders.remove(&tile);
}
}
}

View File

@@ -0,0 +1,593 @@
use rand::rngs::StdRng;
use rand::{Rng, SeedableRng};
use crate::game::action::GameAction;
use crate::game::borders::BorderManager;
use crate::game::player::Player;
use crate::game::territory_manager::TerritoryManager;
/// Simple bot AI
pub struct BotPlayer {
last_action_tick: u64,
action_cooldown: u64,
}
impl Default for BotPlayer {
fn default() -> Self {
Self::new()
}
}
impl BotPlayer {
pub fn new() -> Self {
let mut rng = rand::rng();
Self {
last_action_tick: 0,
action_cooldown: rng.random_range(0..10), // 0-1 seconds
}
}
/// Tick the bot AI - now deterministic based on turn number and RNG seed
pub fn tick(&mut self, turn_number: u64, player: &Player, territory_manager: &TerritoryManager, border_manager: &BorderManager, rng_seed: u64) -> Option<GameAction> {
// Only act every few ticks
if turn_number < self.last_action_tick + self.action_cooldown {
return None;
}
self.last_action_tick = turn_number;
// Deterministic RNG based on turn number, player ID, and global seed
let seed = rng_seed.wrapping_add(turn_number).wrapping_add(player.id as u64);
let mut rng = StdRng::seed_from_u64(seed);
self.action_cooldown = rng.random_range(3..15);
// Decide action: expand into wilderness or attack a neighbor
let _guard = tracing::trace_span!("bot_tick", player_id = player.id).entered();
let action_type: f32 = rng.random();
if action_type < 0.6 {
// Expand into wilderness (60% chance)
self.expand_wilderness(player, territory_manager, border_manager, &mut rng)
} else {
// Attack a neighbor (40% chance)
self.attack_neighbor(player, territory_manager, border_manager, &mut rng)
}
}
/// Expand into unclaimed territory
fn expand_wilderness(&self, player: &Player, territory_manager: &TerritoryManager, border_manager: &BorderManager, rng: &mut StdRng) -> Option<GameAction> {
let border_tiles = border_manager.get_border_tiles(player.id);
let border_count = border_tiles.len();
let _guard = tracing::trace_span!("expand_wilderness", border_count).entered();
let size = territory_manager.size();
let width = size.x as usize;
// Sample a random subset of borders to reduce O(n) iteration cost
// With many bots (1000+), checking all borders every turn is too expensive
const MAX_BORDER_SAMPLES: usize = 20;
let tiles_to_check = if border_count <= MAX_BORDER_SAMPLES {
border_tiles.iter().copied().collect::<Vec<_>>()
} else {
// Random sampling without replacement
let mut sampled = Vec::with_capacity(MAX_BORDER_SAMPLES);
let border_vec: Vec<usize> = border_tiles.iter().copied().collect();
let mut indices: Vec<usize> = (0..border_count).collect();
for _ in 0..MAX_BORDER_SAMPLES {
let idx = rng.random_range(0..indices.len());
sampled.push(border_vec[indices.swap_remove(idx)]);
}
sampled
};
// Find a valid, unclaimed neighbor tile to attack
for &tile in &tiles_to_check {
let x = tile % width;
let y = tile / width;
let neighbors = [(x > 0).then_some(tile - 1), (x < width - 1).then_some(tile + 1), (y > 0).then_some(tile - width), (y < size.y as usize - 1).then_some(tile + width)];
for neighbor in neighbors.iter().flatten() {
if !territory_manager.has_owner(*neighbor) && !territory_manager.is_water(*neighbor) {
// Found unclaimed land
let troop_percentage: f32 = rng.random_range(0.1..0.3);
return Some(GameAction::Attack { player_id: player.id as u16, target_tile: *neighbor as u32, troops_ratio: troop_percentage });
}
}
}
tracing::trace!(player_id = player.id, "No wilderness target found");
None
}
/// Attack a neighboring player
fn attack_neighbor(&self, player: &Player, territory_manager: &TerritoryManager, border_manager: &BorderManager, rng: &mut StdRng) -> Option<GameAction> {
let border_tiles = border_manager.get_border_tiles(player.id);
let border_count = border_tiles.len();
let _guard = tracing::trace_span!("attack_neighbor", border_count).entered();
// Find neighboring players
let mut neighbors = std::collections::HashSet::new();
let size = territory_manager.size();
let width = size.x as usize;
// Sample a random subset of borders to reduce O(n) iteration cost
const MAX_BORDER_SAMPLES: usize = 20;
let tiles_to_check = if border_count <= MAX_BORDER_SAMPLES {
border_tiles.iter().copied().collect::<Vec<_>>()
} else {
// Random sampling without replacement
let mut sampled = Vec::with_capacity(MAX_BORDER_SAMPLES);
let border_vec: Vec<usize> = border_tiles.iter().copied().collect();
let mut indices: Vec<usize> = (0..border_count).collect();
for _ in 0..MAX_BORDER_SAMPLES {
let idx = rng.random_range(0..indices.len());
sampled.push(border_vec[indices.swap_remove(idx)]);
}
sampled
};
for &tile in &tiles_to_check {
let x = tile % width;
let y = tile / width;
// Check all neighbors
if x > 0 {
let neighbor = tile - 1;
let ownership = territory_manager.get_ownership(neighbor);
if let Some(nation_id) = ownership.nation_id()
&& nation_id != player.id as u16
{
neighbors.insert(nation_id as usize);
}
}
if x < width - 1 {
let neighbor = tile + 1;
let ownership = territory_manager.get_ownership(neighbor);
if let Some(nation_id) = ownership.nation_id()
&& nation_id != player.id as u16
{
neighbors.insert(nation_id as usize);
}
}
if y > 0 {
let neighbor = tile - width;
let ownership = territory_manager.get_ownership(neighbor);
if let Some(nation_id) = ownership.nation_id()
&& nation_id != player.id as u16
{
neighbors.insert(nation_id as usize);
}
}
if y < size.y as usize - 1 {
let neighbor = tile + width;
let ownership = territory_manager.get_ownership(neighbor);
if let Some(nation_id) = ownership.nation_id()
&& nation_id != player.id as u16
{
neighbors.insert(nation_id as usize);
}
}
}
if neighbors.is_empty() {
return None;
}
// Pick a random neighbor to attack
let neighbor_vec: Vec<_> = neighbors.into_iter().collect();
let target_id = neighbor_vec[rng.random_range(0..neighbor_vec.len())];
// To attack a player, we need to pick a specific tile.
// Let's find a border tile of the target player that is adjacent to us.
let target_border = border_manager.get_border_tiles(target_id);
let target_border_count = target_border.len();
// Sample target borders as well to avoid another full iteration
let target_tiles_to_check = if target_border_count <= MAX_BORDER_SAMPLES {
target_border.iter().copied().collect::<Vec<_>>()
} else {
let mut sampled = Vec::with_capacity(MAX_BORDER_SAMPLES);
let target_vec: Vec<usize> = target_border.iter().copied().collect();
let mut indices: Vec<usize> = (0..target_border_count).collect();
for _ in 0..MAX_BORDER_SAMPLES {
if indices.is_empty() {
break;
}
let idx = rng.random_range(0..indices.len());
sampled.push(target_vec[indices.swap_remove(idx)]);
}
sampled
};
for &target_tile in &target_tiles_to_check {
let x = target_tile % width;
let y = target_tile / width;
let neighbor_indices = [(x > 0).then_some(target_tile - 1), (x < width - 1).then_some(target_tile + 1), (y > 0).then_some(target_tile - width), (y < size.y as usize - 1).then_some(target_tile + width)];
for &neighbor_idx in neighbor_indices.iter().flatten() {
if territory_manager.get_owner(neighbor_idx) == player.id as u16 {
// This is a valid attack target
let troop_percentage: f32 = rng.random_range(0.2..0.5);
return Some(GameAction::Attack { player_id: player.id as u16, target_tile: target_tile as u32, troops_ratio: troop_percentage });
}
}
}
None
}
}
/// Minimum distance (in tiles) between any two spawn points
/// This ensures players and bots don't spawn too close together
const MIN_SPAWN_DISTANCE: f32 = 70.0;
/// Absolute minimum spawn distance to prevent exact overlaps
/// Used as final fallback when map is very crowded
const ABSOLUTE_MIN_DISTANCE: f32 = 5.0;
/// Distance reduction factor per adaptive wave (15% reduction)
const DISTANCE_REDUCTION_FACTOR: f32 = 0.85;
/// Spatial grid for fast spawn collision detection
/// Divides map into cells for O(1) neighbor queries instead of O(n)
struct SpawnGrid {
grid: std::collections::HashMap<(i32, i32), Vec<usize>>,
cell_size: f32,
map_width: usize,
}
impl SpawnGrid {
fn new(cell_size: f32, map_width: u16) -> Self {
Self { grid: std::collections::HashMap::new(), cell_size, map_width: map_width as usize }
}
fn insert(&mut self, tile: usize) {
let cell = self.tile_to_cell(tile);
self.grid.entry(cell).or_default().push(tile);
}
fn tile_to_cell(&self, tile: usize) -> (i32, i32) {
let x = (tile % self.map_width) as f32;
let y = (tile / self.map_width) as f32;
((x / self.cell_size) as i32, (y / self.cell_size) as i32)
}
fn has_nearby(&self, tile: usize, radius: f32, map_width: u16) -> bool {
let cell = self.tile_to_cell(tile);
let cell_radius = (radius / self.cell_size).ceil() as i32;
let width = map_width as usize;
for dx in -cell_radius..=cell_radius {
for dy in -cell_radius..=cell_radius {
let check_cell = (cell.0 + dx, cell.1 + dy);
if let Some(tiles) = self.grid.get(&check_cell) {
for &existing_tile in tiles {
if calculate_tile_distance(tile, existing_tile, width) < radius {
return true;
}
}
}
}
}
false
}
}
/// Calculate Euclidean distance between two tiles
fn calculate_tile_distance(tile1: usize, tile2: usize, map_width: usize) -> f32 {
let width = map_width;
let x1 = (tile1 % width) as f32;
let y1 = (tile1 / width) as f32;
let x2 = (tile2 % width) as f32;
let y2 = (tile2 / width) as f32;
let dx = x1 - x2;
let dy = y1 - y2;
(dx * dx + dy * dy).sqrt()
}
/// Manager for bot AI state and decision-making
///
/// BotManager is part of GameInstance and handles all bot decision-making
/// in a deterministic way. Unlike the old Bevy-based bot system, this is
/// part of the core game state and executes during turn processing.
pub struct BotManager {
bots: Vec<BotPlayer>,
bot_player_ids: Vec<usize>,
}
impl BotManager {
/// Create a new BotManager with the specified number of bots
pub fn new(bot_count: usize, human_player_count: usize) -> Self {
let bots = (0..bot_count).map(|_| BotPlayer::new()).collect();
// Bot player IDs start after human players
// Human player is ID 0, so first bot is ID 1
let first_bot_id = human_player_count;
let bot_player_ids = (first_bot_id..(first_bot_id + bot_count)).collect();
Self { bots, bot_player_ids }
}
/// Get the number of bots
pub fn bot_count(&self) -> usize {
self.bots.len()
}
/// Get bot player IDs
pub fn bot_player_ids(&self) -> &[usize] {
&self.bot_player_ids
}
/// Calculate initial bot spawn positions (first pass)
///
/// Places bots at random valid locations with adaptive spacing.
/// Uses spatial grid for O(1) neighbor checks and adaptively reduces
/// minimum distance when map becomes crowded.
///
/// Guarantees all bots spawn (no silent drops). This is deterministic based on rng_seed.
///
/// Returns Vec<SpawnPoint> for each bot
pub fn calculate_initial_spawns(&self, territory_manager: &TerritoryManager, rng_seed: u64) -> Vec<crate::game::SpawnPoint> {
let _guard = tracing::trace_span!("calculate_initial_spawns", bot_count = self.bot_player_ids.len()).entered();
let size = territory_manager.size();
let width = size.x;
let map_size = (size.x as usize) * (size.y as usize);
let mut spawn_positions = Vec::with_capacity(self.bot_player_ids.len());
let mut grid = SpawnGrid::new(MIN_SPAWN_DISTANCE, width);
let mut current_min_distance = MIN_SPAWN_DISTANCE;
for (bot_index, &player_id) in self.bot_player_ids.iter().enumerate() {
// Deterministic RNG for spawn location
let seed = rng_seed.wrapping_add(player_id as u64).wrapping_add(bot_index as u64);
let mut rng = StdRng::seed_from_u64(seed);
let mut placed = false;
// Try with current minimum distance
while !placed && current_min_distance >= ABSOLUTE_MIN_DISTANCE {
// Phase 1: Random sampling (1000 attempts)
for _ in 0..1000 {
let tile = rng.random_range(0..map_size);
// Check if tile is valid land
if territory_manager.has_owner(tile) || territory_manager.is_water(tile) {
continue;
}
// Check distance using spatial grid (O(1) instead of O(n))
if !grid.has_nearby(tile, current_min_distance, width) {
spawn_positions.push(crate::game::SpawnPoint::new(player_id, tile));
grid.insert(tile);
placed = true;
break;
}
}
// Phase 2: Grid-guided fallback (if random sampling failed)
if !placed {
// Try a systematic grid search with stride
let stride = (current_min_distance * 0.8) as usize;
let mut attempts = 0;
for y in (0..size.y as usize).step_by(stride.max(1)) {
for x in (0..size.x as usize).step_by(stride.max(1)) {
let tile = y * size.x as usize + x;
if tile >= map_size {
continue;
}
if territory_manager.has_owner(tile) || territory_manager.is_water(tile) {
continue;
}
if !grid.has_nearby(tile, current_min_distance, width) {
spawn_positions.push(crate::game::SpawnPoint::new(player_id, tile));
grid.insert(tile);
placed = true;
break;
}
attempts += 1;
if attempts > 200 {
break;
}
}
if placed {
break;
}
}
}
// Phase 3: Reduce minimum distance and retry
if !placed {
current_min_distance *= DISTANCE_REDUCTION_FACTOR;
if bot_index % 100 == 0 && current_min_distance < MIN_SPAWN_DISTANCE {
tracing::debug!("Adaptive spawn: reduced min_distance to {:.1} for bot {}", current_min_distance, bot_index);
}
}
}
// Final fallback: Place at any valid land tile (guaranteed)
if !placed {
for _ in 0..10000 {
let tile = rng.random_range(0..map_size);
if !territory_manager.has_owner(tile) && !territory_manager.is_water(tile) {
spawn_positions.push(crate::game::SpawnPoint::new(player_id, tile));
grid.insert(tile);
placed = true;
tracing::warn!("Bot {} placed with fallback (no distance constraint)", player_id);
break;
}
}
}
if !placed {
tracing::error!("Failed to place bot {} after all attempts", player_id);
}
}
spawn_positions
}
/// Recalculate bot spawns considering player positions (second pass)
///
/// For any bot that is too close to a player spawn, find a new position.
/// Uses adaptive algorithm with grid acceleration to guarantee all displaced
/// bots find new positions. This maintains determinism while ensuring proper spawn spacing.
///
/// Arguments:
/// - `initial_bot_spawns`: Bot positions from first pass
/// - `player_spawns`: Human player spawn positions
/// - `territory_manager`: For checking valid tiles
/// - `rng_seed`: For deterministic relocation
///
/// Returns updated Vec<SpawnPoint> with relocated bots
pub fn recalculate_spawns_with_players(&self, initial_bot_spawns: Vec<crate::game::SpawnPoint>, player_spawns: &[crate::game::SpawnPoint], territory_manager: &TerritoryManager, rng_seed: u64) -> Vec<crate::game::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();
let width = size.x;
let map_size = (size.x as usize) * (size.y as usize);
// Build spatial grid from player spawns and bots we're keeping
let mut grid = SpawnGrid::new(MIN_SPAWN_DISTANCE, width);
for spawn in player_spawns {
grid.insert(spawn.tile_index);
}
// Identify bots that 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;
// Check distance from all player spawns using grid
for player_spawn in player_spawns {
if calculate_tile_distance(spawn.tile_index, player_spawn.tile_index, width as usize) < MIN_SPAWN_DISTANCE {
needs_relocation = true;
break;
}
}
if needs_relocation {
bots_to_relocate.push(spawn.player_id);
} else {
final_spawns.push(spawn);
grid.insert(spawn.tile_index);
}
}
// Relocate bots using adaptive algorithm (same as calculate_initial_spawns)
let mut current_min_distance = MIN_SPAWN_DISTANCE;
for (reloc_index, &player_id) in bots_to_relocate.iter().enumerate() {
let seed = rng_seed.wrapping_add(player_id as u64).wrapping_add(0xDEADBEEF);
let mut rng = StdRng::seed_from_u64(seed);
let mut placed = false;
// Try with current minimum distance
while !placed && current_min_distance >= ABSOLUTE_MIN_DISTANCE {
// Phase 1: Random sampling (1000 attempts)
for _ in 0..1000 {
let tile = rng.random_range(0..map_size);
// Check if tile is valid land
if territory_manager.has_owner(tile) || territory_manager.is_water(tile) {
continue;
}
// Check distance using spatial grid (includes players + placed bots)
if !grid.has_nearby(tile, current_min_distance, width) {
final_spawns.push(crate::game::SpawnPoint::new(player_id, tile));
grid.insert(tile);
placed = true;
break;
}
}
// Phase 2: Grid-guided fallback
if !placed {
let stride = (current_min_distance * 0.8) as usize;
let mut attempts = 0;
for y in (0..size.y as usize).step_by(stride.max(1)) {
for x in (0..size.x as usize).step_by(stride.max(1)) {
let tile = y * size.x as usize + x;
if tile >= map_size {
continue;
}
if territory_manager.has_owner(tile) || territory_manager.is_water(tile) {
continue;
}
if !grid.has_nearby(tile, current_min_distance, width) {
final_spawns.push(crate::game::SpawnPoint::new(player_id, tile));
grid.insert(tile);
placed = true;
break;
}
attempts += 1;
if attempts > 200 {
break;
}
}
if placed {
break;
}
}
}
// Phase 3: Reduce minimum distance and retry
if !placed {
current_min_distance *= DISTANCE_REDUCTION_FACTOR;
if reloc_index % 50 == 0 && current_min_distance < MIN_SPAWN_DISTANCE {
tracing::debug!("Adaptive relocation: reduced min_distance to {:.1} for bot {}", current_min_distance, reloc_index);
}
}
}
// Final fallback: Place at any valid land tile (guaranteed)
if !placed {
for _ in 0..10000 {
let tile = rng.random_range(0..map_size);
if !territory_manager.has_owner(tile) && !territory_manager.is_water(tile) {
final_spawns.push(crate::game::SpawnPoint::new(player_id, tile));
grid.insert(tile);
placed = true;
tracing::warn!("Bot {} relocated with fallback (no distance constraint)", player_id);
break;
}
}
}
if !placed {
tracing::error!("Failed to relocate bot {} after all attempts", player_id);
}
}
final_spawns
}
/// Calculate action for a specific bot
///
/// This is deterministic - same inputs = same output
pub fn calculate_action(&mut self, bot_index: usize, turn_number: u64, player: &Player, territory_manager: &TerritoryManager, border_manager: &BorderManager, rng_seed: u64) -> Option<GameAction> {
if bot_index >= self.bots.len() {
return None;
}
self.bots[bot_index].tick(turn_number, player, territory_manager, border_manager, rng_seed)
}
}

View File

@@ -0,0 +1,253 @@
/// Active attacks management
///
/// This module manages all ongoing attacks in the game. It provides efficient
/// lookup and coordination of attacks, ensuring proper merging of attacks on
/// the same target and handling counter-attacks.
use std::collections::HashSet;
use bevy_ecs::prelude::*;
use slotmap::{SlotMap, new_key_type};
new_key_type! {
/// Unique key for identifying attacks in the SlotMap
pub struct AttackKey;
}
use super::executor::{AttackConfig, AttackExecutor};
use crate::game::rng::DeterministicRng;
use crate::game::{borders::BorderManager, player::Player, territory_manager::TerritoryManager};
/// Manages all active attacks in the game
///
/// This resource tracks ongoing attacks and provides efficient lookup
/// by attacker/target relationships. Attacks progress over multiple turns
/// until they run out of troops or conquerable tiles.
///
/// # Index Structures
///
/// Multiple index structures are maintained for fast lookup:
/// - `player_index`: [attacker][target] → set of attack keys
/// - `unclaimed_index`: [attacker] → attack key for unclaimed territory
/// - `player_attack_list`: [player] → attacks where player is attacker
/// - `target_attack_list`: [player] → attacks where player is target
///
/// Uses SlotMap for stable keys - no index shifting needed on removal.
/// Uses HashSet for O(1) lookups and removals.
#[derive(Resource)]
pub struct ActiveAttacks {
attacks: SlotMap<AttackKey, AttackExecutor>,
player_index: Vec<Vec<HashSet<AttackKey>>>, // [attacker][target] -> set of attack keys
unclaimed_index: Vec<Option<AttackKey>>, // [attacker] -> attack key for unclaimed
player_attack_list: Vec<HashSet<AttackKey>>, // [player] -> set of attack keys where player is attacker
target_attack_list: Vec<HashSet<AttackKey>>, // [player] -> set of attack keys where player is target
}
impl Default for ActiveAttacks {
fn default() -> Self {
Self::new()
}
}
impl ActiveAttacks {
pub fn new() -> Self {
Self { attacks: SlotMap::with_key(), player_index: Vec::new(), unclaimed_index: Vec::new(), player_attack_list: Vec::new(), target_attack_list: Vec::new() }
}
/// Initialize the attack handler
pub fn init(&mut self, max_players: usize) {
self.attacks.clear();
self.player_index = vec![vec![HashSet::new(); max_players]; max_players];
self.unclaimed_index = vec![None; max_players];
self.player_attack_list = vec![HashSet::new(); max_players];
self.target_attack_list = vec![HashSet::new(); max_players];
}
/// Schedule an attack on unclaimed territory
///
/// If an attack on unclaimed territory already exists for this player,
/// the troops are added to it and borders are expanded.
#[allow(clippy::too_many_arguments)]
pub fn schedule_unclaimed(&mut self, player_id: usize, troops: f32, target_tile: usize, border_tiles: Option<&HashSet<usize>>, territory_manager: &TerritoryManager, border_manager: &BorderManager, turn_number: u64, rng: &DeterministicRng) {
// Check if there's already an attack on unclaimed territory
if let Some(attack_key) = self.unclaimed_index[player_id] {
// Add troops to existing attack
self.attacks[attack_key].modify_troops(troops);
// Add new borders to allow multi-region expansion
let borders = border_tiles.unwrap_or_else(|| border_manager.get_border_tiles(player_id));
self.attacks[attack_key].add_borders(borders, territory_manager, rng);
return;
}
// Create new attack
self.add_unclaimed(player_id, troops, target_tile, border_tiles, territory_manager, border_manager, turn_number, rng);
}
/// Schedule an attack on another player
///
/// Handles attack merging (if attacking same target) and counter-attacks
/// (opposite direction attacks are resolved first).
#[allow(clippy::too_many_arguments)]
pub fn schedule_attack(&mut self, player_id: usize, target_id: usize, target_tile: usize, mut troops: f32, border_tiles: Option<&HashSet<usize>>, territory_manager: &TerritoryManager, border_manager: &BorderManager, turn_number: u64, rng: &DeterministicRng) {
// Check if there's already an attack on this target
if let Some(&attack_key) = self.player_index[player_id][target_id].iter().next() {
// Add troops to existing attack
self.attacks[attack_key].modify_troops(troops);
// Add new borders to allow multi-region expansion
let borders = border_tiles.unwrap_or_else(|| border_manager.get_border_tiles(player_id));
self.attacks[attack_key].add_borders(borders, territory_manager, rng);
return;
}
// Check for counter-attacks (opposite direction) - prevent mutual attacks
while !self.player_index[target_id][player_id].is_empty() {
let opposite_key = *self.player_index[target_id][player_id].iter().next().unwrap();
if self.attacks[opposite_key].oppose(troops) {
// Counter-attack absorbed the new attack
return;
}
// Counter-attack was defeated, deduct its troops from the new attack
troops -= self.attacks[opposite_key].get_troops();
// Remove the defeated counter-attack
self.remove_attack(opposite_key);
}
// Create new attack
self.add_attack(player_id, target_id, troops, target_tile, border_tiles, territory_manager, border_manager, turn_number, rng);
}
/// Tick all active attacks
///
/// Progresses each attack by one turn. Attacks that run out of troops
/// or conquerable tiles are removed and their remaining troops are
/// returned to the attacking player.
pub fn tick(&mut self, players: &mut [Player], territory_manager: &mut TerritoryManager, border_manager: &mut BorderManager, rng: &DeterministicRng) {
let _guard = tracing::trace_span!("attacks_tick", attack_count = self.attacks.len()).entered();
let mut attacks_to_remove = Vec::new();
for (attack_key, attack) in &mut self.attacks {
let should_continue = attack.tick(players, territory_manager, border_manager, rng);
if !should_continue {
// Return remaining troops to player
let player_id = attack.player_id;
let remaining_troops = attack.get_troops();
tracing::trace!(player_id, remaining_troops, "Attack completed");
players[player_id].add_troops(remaining_troops);
// Mark attack for removal
attacks_to_remove.push(attack_key);
}
}
// Remove completed attacks
for attack_key in attacks_to_remove {
self.remove_attack(attack_key);
}
}
/// Handle a tile being added to a player's territory
///
/// Notifies all relevant attacks that territory has changed so they can
/// update their borders and targets.
pub fn handle_territory_add(&mut self, tile: usize, player_id: usize, territory_manager: &TerritoryManager, rng: &DeterministicRng) {
// Notify all attacks where this player is the attacker
for &attack_key in &self.player_attack_list[player_id] {
self.attacks[attack_key].handle_player_tile_add(tile, territory_manager, rng);
}
// Notify all attacks where this player is the target
for &attack_key in &self.target_attack_list[player_id] {
self.attacks[attack_key].handle_target_tile_add(tile, territory_manager, rng);
}
}
/// Add an attack on unclaimed territory
#[allow(clippy::too_many_arguments)]
fn add_unclaimed(&mut self, player_id: usize, troops: f32, target_tile: usize, border_tiles: Option<&HashSet<usize>>, territory_manager: &TerritoryManager, border_manager: &BorderManager, turn_number: u64, rng: &DeterministicRng) {
let attack = AttackExecutor::new(AttackConfig { player_id, target_id: None, troops, target_tile, border_tiles, territory_manager, border_manager, turn_number }, rng);
let attack_key = self.attacks.insert(attack);
self.unclaimed_index[player_id] = Some(attack_key);
self.player_attack_list[player_id].insert(attack_key);
}
/// Add an attack on a player
#[allow(clippy::too_many_arguments)]
fn add_attack(&mut self, player_id: usize, target_id: usize, troops: f32, target_tile: usize, border_tiles: Option<&HashSet<usize>>, territory_manager: &TerritoryManager, border_manager: &BorderManager, turn_number: u64, rng: &DeterministicRng) {
let attack = AttackExecutor::new(AttackConfig { player_id, target_id: Some(target_id), troops, target_tile, border_tiles, territory_manager, border_manager, turn_number }, rng);
let attack_key = self.attacks.insert(attack);
self.player_index[player_id][target_id].insert(attack_key);
self.player_attack_list[player_id].insert(attack_key);
self.target_attack_list[target_id].insert(attack_key);
}
/// Get all attacks involving a specific player (as attacker or target)
///
/// Returns a list of (attacker_id, target_id, troops, start_turn, is_outgoing)
/// sorted by start_turn descending (most recent first)
pub fn get_attacks_for_player(&self, player_id: usize) -> Vec<(usize, Option<usize>, f32, u64, bool)> {
let mut attacks = Vec::new();
// Add outgoing attacks (player is attacker)
for &attack_key in &self.player_attack_list[player_id] {
let attack = &self.attacks[attack_key];
attacks.push((
attack.player_id,
attack.target_id,
attack.get_troops(),
attack.get_start_turn(),
true, // outgoing
));
}
// Add incoming attacks (player is target)
for &attack_key in &self.target_attack_list[player_id] {
let attack = &self.attacks[attack_key];
attacks.push((
attack.player_id,
attack.target_id,
attack.get_troops(),
attack.get_start_turn(),
false, // incoming
));
}
// Sort by start_turn descending (most recent first)
attacks.sort_by(|a, b| b.3.cmp(&a.3));
attacks
}
/// Remove an attack and update all indices
///
/// With SlotMap, keys remain stable so no index shifting is needed.
/// HashSet provides O(1) removal without element shifting.
fn remove_attack(&mut self, attack_key: AttackKey) {
let attack = &self.attacks[attack_key];
let player_id = attack.player_id;
let target_id = attack.target_id;
// Remove from player attack list (O(1))
self.player_attack_list[player_id].remove(&attack_key);
if let Some(target_id) = target_id {
// Remove from target attack list (O(1))
self.target_attack_list[target_id].remove(&attack_key);
// Remove from player index (O(1))
self.player_index[player_id][target_id].remove(&attack_key);
} else {
// Remove from unclaimed index
self.unclaimed_index[player_id] = None;
}
// Remove attack from slot map - no index shifting needed!
self.attacks.remove(attack_key);
}
}

View File

@@ -0,0 +1,152 @@
/// Pure combat calculation functions
///
/// This module contains all combat mathematics extracted from the attack system.
/// All functions are pure (no side effects) and deterministic, making them
/// easy to test, reason about, and modify.
use crate::game::constants::combat::*;
use crate::game::player::Player;
use crate::game::territory_manager::TerritoryManager;
/// Parameters for combat result calculation
pub struct CombatParams<'a> {
pub attacker: &'a Player,
pub defender: Option<&'a Player>,
pub attacker_troops: f32,
pub tile: usize,
pub territory_manager: &'a TerritoryManager,
pub width: 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.
pub fn sigmoid(x: f32, decay_rate: f32, midpoint: f32) -> f32 {
1.0 / (1.0 + (-(x - midpoint) * decay_rate).exp())
}
/// Clamp a value between min and max
pub fn clamp(value: f32, min: f32, max: f32) -> f32 {
value.max(min).min(max)
}
/// Calculate combat result for conquering one tile
///
/// This function determines troop losses and conquest cost based on:
/// - Attacker and defender troop counts and empire sizes
/// - Terrain properties (currently plains baseline)
/// - Empire size balancing (prevents snowballing)
/// - Defense structures (placeholder for future implementation)
pub fn calculate_combat_result(params: CombatParams) -> CombatResult {
if let Some(defender) = params.defender {
// Attacking claimed territory
let attacker = params.attacker;
// Base terrain values (plains baseline)
let mut mag = BASE_MAG_PLAINS;
let mut speed = BASE_SPEED_PLAINS;
// Defense post check (placeholder - always false for now)
let has_defense_post = check_defense_post_nearby(params.tile, defender, params.territory_manager, params.width);
if has_defense_post {
mag *= DEFENSE_POST_MAG_MULTIPLIER;
speed *= DEFENSE_POST_SPEED_MULTIPLIER;
}
// Empire size balancing - prevents snowballing
// Large defenders get debuffed, large attackers get penalized
let defense_sig = 1.0 - sigmoid(defender.get_territory_size() as f32, DEFENSE_DEBUFF_DECAY_RATE, DEFENSE_DEBUFF_MIDPOINT);
let large_defender_speed_debuff = 0.7 + 0.3 * defense_sig;
let large_defender_attack_debuff = 0.7 + 0.3 * defense_sig;
let large_attacker_bonus = if attacker.get_territory_size() > LARGE_EMPIRE_THRESHOLD { (LARGE_EMPIRE_THRESHOLD as f32 / attacker.get_territory_size() as f32).sqrt().powf(0.7) } else { 1.0 };
let large_attacker_speed_bonus = if attacker.get_territory_size() > LARGE_EMPIRE_THRESHOLD { (LARGE_EMPIRE_THRESHOLD as f32 / attacker.get_territory_size() as f32).powf(0.6) } else { 1.0 };
// Calculate troop ratio
let troop_ratio = clamp(defender.get_troops() / params.attacker_troops.max(1.0), 0.6, 2.0);
// Final attacker loss
let attacker_loss = troop_ratio * mag * 0.8 * large_defender_attack_debuff * large_attacker_bonus;
// Defender loss (simple: troops per tile)
let defender_loss = defender.get_troops() / defender.get_territory_size().max(1) as f32;
// Tiles per tick cost for this tile
let tiles_per_tick_used = clamp(defender.get_troops() / (5.0 * params.attacker_troops.max(1.0)), 0.2, 1.5) * speed * large_defender_speed_debuff * large_attacker_speed_bonus;
CombatResult { attacker_loss, defender_loss, tiles_per_tick_used }
} else {
// Attacking unclaimed territory
CombatResult {
attacker_loss: BASE_MAG_PLAINS / 5.0, // 16.0 for plains
defender_loss: 0.0,
tiles_per_tick_used: clamp((2000.0 * BASE_SPEED_PLAINS.max(10.0)) / params.attacker_troops.max(1.0), 5.0, 100.0),
}
}
}
/// Calculate tiles conquered per tick based on troop ratio and border size
///
/// This determines how fast an attack progresses. It's based on:
/// - The attacker's troop advantage (or disadvantage)
/// - The size of the attack border
/// - Random variation for organic-looking expansion
pub fn calculate_tiles_per_tick(attacker_troops: f32, defender: Option<&Player>, border_size: f32) -> f32 {
if let Some(defender) = defender {
// Dynamic based on troop ratio
let ratio = ((5.0 * attacker_troops) / defender.get_troops().max(1.0)) * 2.0;
let clamped_ratio = clamp(ratio, 0.01, 0.5);
clamped_ratio * border_size * CLAIMED_TILES_PER_TICK_MULTIPLIER
} else {
// Fixed rate for unclaimed territory
border_size * UNCLAIMED_TILES_PER_TICK_MULTIPLIER
}
}
/// Check if defender has a defense post nearby (placeholder)
///
/// This will be implemented when defense structures are added to the game.
/// For now, always returns false.
fn check_defense_post_nearby(_tile: usize, _defender: &Player, _territory_manager: &TerritoryManager, _width: u16) -> bool {
// Placeholder for future defense post implementation
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sigmoid_midpoint() {
let result = sigmoid(DEFENSE_DEBUFF_MIDPOINT, DEFENSE_DEBUFF_DECAY_RATE, DEFENSE_DEBUFF_MIDPOINT);
assert!((result - 0.5).abs() < 0.01, "Sigmoid should be ~0.5 at midpoint");
}
#[test]
fn test_clamp() {
assert_eq!(clamp(5.0, 0.0, 10.0), 5.0);
assert_eq!(clamp(-1.0, 0.0, 10.0), 0.0);
assert_eq!(clamp(15.0, 0.0, 10.0), 10.0);
}
#[test]
fn test_unclaimed_attack_fixed_losses() {
// Unclaimed territory should have fixed attacker loss
let result = CombatResult { attacker_loss: BASE_MAG_PLAINS / 5.0, defender_loss: 0.0, tiles_per_tick_used: 10.0 };
assert_eq!(result.attacker_loss, 16.0);
assert_eq!(result.defender_loss, 0.0);
}
}

View File

@@ -0,0 +1,353 @@
/// Attack execution logic
///
/// This module contains the `AttackExecutor` which manages the progression
/// of a single attack over multiple turns. It handles tile prioritization,
/// border expansion, and conquest mechanics.
use std::collections::{BinaryHeap, HashSet};
use rand::Rng;
use super::calculator::{CombatParams, calculate_combat_result, calculate_tiles_per_tick};
use crate::game::constants::combat::*;
use crate::game::rng::DeterministicRng;
use crate::game::utils::for_each_neighbor;
use crate::game::{TileOwnership, borders::BorderManager, player::Player, territory_manager::TerritoryManager};
/// Priority queue entry for tile conquest
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct TilePriority {
tile: usize,
priority: i64, // Lower value = higher priority (conquered sooner)
}
impl PartialOrd for TilePriority {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for TilePriority {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
other.priority.cmp(&self.priority).then_with(|| self.tile.cmp(&other.tile))
}
}
/// Configuration for creating an AttackExecutor
pub struct AttackConfig<'a> {
pub player_id: usize,
pub target_id: Option<usize>,
pub troops: f32,
pub target_tile: usize,
pub border_tiles: Option<&'a HashSet<usize>>,
pub territory_manager: &'a TerritoryManager,
pub border_manager: &'a BorderManager,
pub turn_number: u64,
}
/// Executes a single ongoing attack (conquering tiles over time)
///
/// An attack progresses over multiple turns, conquering tiles based on:
/// - Available troops
/// - Troop ratio vs defender
/// - Border size and connectivity
/// - Combat formulas from the calculator module
///
/// The executor maintains a priority queue of tiles to conquer and updates
/// borders as it progresses.
pub struct AttackExecutor {
pub player_id: usize,
pub target_id: Option<usize>,
troops: f32,
border_tiles: HashSet<usize>,
priority_queue: BinaryHeap<TilePriority>,
start_turn: u64,
current_turn: u64,
tiles_conquered: u64, // Counter for each tile conquered (for priority calculation)
}
impl AttackExecutor {
/// Create a new attack executor
pub fn new(config: AttackConfig, rng: &DeterministicRng) -> Self {
let mut executor = Self { player_id: config.player_id, target_id: config.target_id, troops: config.troops, border_tiles: HashSet::new(), priority_queue: BinaryHeap::new(), start_turn: config.turn_number, current_turn: config.turn_number, tiles_conquered: 0 };
executor.initialize_border(config.border_tiles, config.territory_manager, config.border_manager, rng);
executor
}
/// Modify the amount of troops in the attack
pub fn modify_troops(&mut self, amount: f32) {
self.troops += amount;
}
/// Add new border tiles to the attack, allowing expansion from multiple fronts
///
/// This enables multi-region expansion when attacking the same target from different areas
pub fn add_borders(&mut self, new_border_tiles: &HashSet<usize>, territory_manager: &TerritoryManager, rng: &DeterministicRng) {
// Add neighbors from each new border tile
let size = territory_manager.size();
for &tile in new_border_tiles {
for_each_neighbor(tile, size, |neighbor| {
if self.is_valid_target(neighbor, territory_manager) && !self.border_tiles.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 amount of troops in the attack
pub fn get_troops(&self) -> f32 {
self.troops.max(0.0).floor()
}
/// Get the turn this attack started
pub fn get_start_turn(&self) -> u64 {
self.start_turn
}
/// Tick the attack executor
///
/// Returns true if the attack continues, false if it's finished
pub fn tick(&mut self, players: &mut [Player], territory_manager: &mut TerritoryManager, border_manager: &mut BorderManager, rng: &DeterministicRng) -> bool {
let _guard = tracing::trace_span!("attack_tick", player_id = self.player_id).entered();
self.current_turn += 1;
// Calculate how many tiles to conquer this tick
let mut tiles_per_tick = self.calculate_tiles_per_tick(players, rng);
// 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 {
return false;
}
if self.priority_queue.is_empty() {
// If we already refreshed this tick, stop to prevent infinite loop
if has_refreshed {
return false;
}
// Remember border size before refresh
let border_size_before = self.border_tiles.len();
// Refresh border tiles one last time before giving up
self.refresh_border(border_manager, territory_manager, rng);
has_refreshed = true;
// If refresh found no new tiles, attack is finished
if self.border_tiles.len() == border_size_before {
return false;
}
// If still empty after refresh (all tiles invalid), attack is finished
if self.priority_queue.is_empty() {
return false;
}
}
let tile_priority = self.priority_queue.pop().unwrap();
let tile = tile_priority.tile;
self.border_tiles.remove(&tile);
// Check connectivity 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);
// Skip if either check fails
if !tile_valid || !on_border {
continue;
}
// Add neighbors BEFORE conquering (critical for correct expansion)
self.add_neighbors_to_border(tile, territory_manager, rng);
// Calculate losses for this tile
let combat_result = { calculate_combat_result(CombatParams { attacker: &players[self.player_id], defender: self.target_id.map(|id| &players[id]), attacker_troops: self.troops, tile, territory_manager, width: territory_manager.width() }) };
// Check if we still have enough troops to conquer this tile
if self.troops < combat_result.attacker_loss {
return false;
}
// Apply troop losses
self.troops -= combat_result.attacker_loss;
if let Some(target_id) = self.target_id {
players[target_id].remove_troops(combat_result.defender_loss);
}
// Conquer the tile
let previous_owner = territory_manager.conquer(tile, self.player_id);
// Update player territory sizes
if let Some(nation_id) = TileOwnership::from_u16(previous_owner).nation_id() {
players[nation_id as usize].remove_tile(tile);
}
players[self.player_id].add_tile(tile);
// Increment tiles conquered counter (used for priority calculation)
self.tiles_conquered += 1;
// Decrement tiles per tick counter
tiles_per_tick -= combat_result.tiles_per_tick_used;
}
// Check if attack should continue
!self.priority_queue.is_empty() && self.troops >= 1.0
}
/// Calculate tiles conquered per tick based on troop ratio and border size
fn calculate_tiles_per_tick(&mut self, players: &[Player], rng: &DeterministicRng) -> f32 {
// Add random 0-4 to border size
// This introduces natural variation in expansion speed
let mut context_rng = rng.for_context(self.player_id as u64);
let random_border_adjustment = context_rng.random_range(0..BORDER_RANDOM_ADJUSTMENT_MAX) as f32;
let border_size = self.border_tiles.len() as f32 + random_border_adjustment;
let defender = self.target_id.map(|id| &players[id]);
calculate_tiles_per_tick(self.troops, defender, border_size)
}
/// Check if a tile is a valid target for this attack
fn is_valid_target(&self, tile: usize, territory_manager: &TerritoryManager) -> bool {
if let Some(target_id) = self.target_id { territory_manager.is_owner(tile, target_id) } else { !territory_manager.has_owner(tile) && !territory_manager.is_water(tile) }
}
/// Add a tile to the border with proper priority calculation
fn add_tile_to_border(&mut self, tile: usize, territory_manager: &TerritoryManager, rng: &DeterministicRng) {
self.border_tiles.insert(tile);
let priority = self.calculate_tile_priority(tile, territory_manager, rng);
self.priority_queue.push(TilePriority { tile, priority });
}
/// Execute a closure for each neighbor of a tile
fn for_each_neighbor_of<F>(&self, tile: usize, territory_manager: &TerritoryManager, closure: F)
where
F: FnMut(usize),
{
let size = territory_manager.size();
for_each_neighbor(tile, size, closure);
}
/// Initialize border tiles from player's existing borders
fn initialize_border(&mut self, border_tiles: Option<&HashSet<usize>>, territory_manager: &TerritoryManager, border_manager: &BorderManager, rng: &DeterministicRng) {
self.initialize_border_internal(border_tiles, territory_manager, border_manager, 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, border_manager: &BorderManager, territory_manager: &TerritoryManager, rng: &DeterministicRng) {
self.initialize_border_internal(None, territory_manager, border_manager, rng, true);
}
/// Internal method to initialize or refresh border tiles
fn initialize_border_internal(&mut self, border_tiles: Option<&HashSet<usize>>, territory_manager: &TerritoryManager, border_manager: &BorderManager, rng: &DeterministicRng, clear_first: bool) {
if clear_first {
self.priority_queue.clear();
self.border_tiles.clear();
}
let borders = border_tiles.unwrap_or_else(|| border_manager.get_border_tiles(self.player_id));
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
let size = territory_manager.size();
for &tile in borders {
for_each_neighbor(tile, size, |neighbor| {
if self.is_valid_target(neighbor, territory_manager) && !self.border_tiles.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: usize, territory_manager: &TerritoryManager, rng: &DeterministicRng) {
let size = territory_manager.size();
for_each_neighbor(tile, size, |neighbor| {
if self.is_valid_target(neighbor, territory_manager) && !self.border_tiles.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: usize, territory_manager: &TerritoryManager, rng: &DeterministicRng) -> i64 {
// Count how many neighbors are owned by attacker
let mut num_owned_by_attacker = 0;
self.for_each_neighbor_of(tile, territory_manager, |neighbor| {
if territory_manager.is_owner(neighbor, self.player_id) {
num_owned_by_attacker += 1;
}
});
// Terrain magnitude (placeholder - always 1.0 for plains)
let terrain_mag = 1.0;
// Random factor (0-7)
let mut tile_rng = rng.for_tile(tile);
let random_factor = tile_rng.random_range(0..TILE_PRIORITY_RANDOM_MAX);
// Priority calculation (lower = higher priority, conquered sooner)
// Base calculation: tiles surrounded by more attacker neighbors get LOWER modifier values
// Adding tiles_conquered ensures tiles discovered earlier get lower priority values
// This creates wave-like expansion: older tiles (lower priority) conquered before newer tiles (higher priority)
let base = (random_factor + 10) as f32;
let modifier = 1.0 - (num_owned_by_attacker as f32 * 0.5) + (terrain_mag / 2.0);
(base * modifier) as i64 + self.tiles_conquered as i64
}
/// Handle the addition of a tile to the player's territory
pub fn handle_player_tile_add(&mut self, tile: usize, territory_manager: &TerritoryManager, rng: &DeterministicRng) {
// When player gains a tile, check its neighbors for new targets
self.add_neighbors_to_border(tile, territory_manager, rng);
}
/// Handle the addition of a tile to the target's territory
pub fn handle_target_tile_add(&mut self, tile: usize, territory_manager: &TerritoryManager, 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.border_tiles.contains(&tile) {
self.border_tiles.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: usize, player_id: usize, territory_manager: &TerritoryManager) -> bool {
let size = territory_manager.size();
let mut has_border = false;
for_each_neighbor(tile, size, |neighbor| {
if territory_manager.is_owner(neighbor, player_id) {
has_border = true;
}
});
has_border
}
}

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,159 @@
use crate::game::TileOwnership;
use crate::game::terrain::TerrainData;
use glam::{I16Vec2, 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: &[u16], target_tile: usize, player_id: u16, size: U16Vec2) -> bool {
let size_usize = size.as_uvec2();
let target_ownership = TileOwnership::from_u16(territory[target_tile]);
// Can't connect to water
if target_ownership.is_water() {
return false;
}
// If target is owned by player, it's already connected
if target_ownership.is_owned_by(player_id) {
return true;
}
// Flood-fill from target through tiles with same ownership
let mut queue = VecDeque::new();
let mut visited = vec![false; size_usize.x as usize * size_usize.y as usize];
queue.push_back(target_tile);
visited[target_tile] = true;
let directions = [(0, -1), (1, 0), (0, 1), (-1, 0)]; // Cardinal directions only
while let Some(current) = queue.pop_front() {
let x = current % size_usize.x as usize;
let y = current / size_usize.x as usize;
// Check all 4 neighbors
for (dx, dy) in directions {
let nx = x as i32 + dx;
let ny = y as i32 + dy;
// Check bounds
if nx < 0 || ny < 0 || nx >= size_usize.x as i32 || ny >= size_usize.y as i32 {
continue;
}
let neighbor_idx = nx as usize + ny as usize * size_usize.x as usize;
// Skip if already visited
if visited[neighbor_idx] {
continue;
}
let neighbor_ownership = TileOwnership::from_u16(territory[neighbor_idx]);
// Check if we found a player tile - SUCCESS!
if neighbor_ownership.is_owned_by(player_id) {
return true;
}
// Only continue through tiles matching target's ownership
if neighbor_ownership == target_ownership {
visited[neighbor_idx] = true;
queue.push_back(neighbor_idx);
}
}
}
// Exhausted search without finding player tile
false
}
/// Find the nearest coastal tile in a region by flood-filling from target
/// Only expands through tiles matching the target's ownership
/// Returns coastal tile index if found
pub fn find_coastal_tile_in_region(territory: &[u16], terrain: &TerrainData, target_tile: usize, size: U16Vec2) -> Option<usize> {
let size_usize = size.as_uvec2();
let target_ownership = TileOwnership::from_u16(territory[target_tile]);
// Can't find coastal tile in water
if target_ownership.is_water() {
return None;
}
// Check if target itself is coastal
if is_coastal_tile(terrain, target_tile, size) {
return Some(target_tile);
}
// BFS from target through same-ownership tiles
let mut queue = VecDeque::new();
let mut visited = vec![false; size_usize.x as usize * size_usize.y as usize];
queue.push_back(target_tile);
visited[target_tile] = true;
let directions = [(0, -1), (1, 0), (0, 1), (-1, 0)];
while let Some(current) = queue.pop_front() {
let x = current % size_usize.x as usize;
let y = current / size_usize.x as usize;
for (dx, dy) in directions {
let nx = x as i32 + dx;
let ny = y as i32 + dy;
if nx < 0 || ny < 0 || nx >= size_usize.x as i32 || ny >= size_usize.y as i32 {
continue;
}
let neighbor_idx = nx as usize + ny as usize * size_usize.x as usize;
if visited[neighbor_idx] {
continue;
}
let neighbor_ownership = TileOwnership::from_u16(territory[neighbor_idx]);
// Only expand through matching ownership
if neighbor_ownership == target_ownership {
visited[neighbor_idx] = true;
// Check if this tile is coastal
if is_coastal_tile(terrain, neighbor_idx, size) {
return Some(neighbor_idx);
}
queue.push_back(neighbor_idx);
}
}
}
None
}
/// Check if a tile is coastal (land tile adjacent to water)
fn is_coastal_tile(terrain: &TerrainData, tile: usize, size: U16Vec2) -> bool {
let pos = crate::ui::tile_from_index(tile, size.x);
// Must be land tile
if terrain.is_navigable(pos) {
return false;
}
// Check if any neighbor is water (4-directional)
let directions = [I16Vec2::new(0, -1), I16Vec2::new(0, 1), I16Vec2::new(1, 0), I16Vec2::new(-1, 0)];
for dir in directions {
let neighbor = pos.checked_add_signed(dir);
if let Some(neighbor) = neighbor
&& neighbor.x < size.x
&& neighbor.y < size.y
&& terrain.is_navigable(neighbor)
{
return true;
}
}
false
}

View File

@@ -0,0 +1,49 @@
/// Game constants organized by domain
///
/// This module centralizes all game balance constants that were previously
/// scattered across multiple files. Constants are grouped by gameplay domain
/// for easy discovery and tuning.
pub mod combat {
/// Empire size balancing - prevents snowballing by large empires
/// Defense effectiveness decreases as empire grows beyond this threshold
pub const DEFENSE_DEBUFF_MIDPOINT: f32 = 150_000.0;
/// Rate of defense effectiveness decay for large empires
/// Uses natural log decay for smooth scaling
pub const DEFENSE_DEBUFF_DECAY_RATE: f32 = std::f32::consts::LN_2 / 50_000.0;
/// Base terrain magnitude cost for plains (baseline terrain)
/// Determines troop losses when conquering a tile
pub const BASE_MAG_PLAINS: f32 = 80.0;
/// Base terrain speed for plains (baseline terrain)
/// Affects how many tiles can be conquered per tick
pub const BASE_SPEED_PLAINS: f32 = 16.5;
/// Maximum random adjustment to border size when calculating expansion speed
/// Introduces natural variation in attack progression (0-4 range)
pub const BORDER_RANDOM_ADJUSTMENT_MAX: u32 = 5;
/// Multiplier for tiles conquered per tick when attacking unclaimed territory
pub const UNCLAIMED_TILES_PER_TICK_MULTIPLIER: f32 = 2.0;
/// Multiplier for tiles conquered per tick when attacking claimed territory
pub const CLAIMED_TILES_PER_TICK_MULTIPLIER: f32 = 3.0;
/// Large empire threshold for attack penalties (>100k tiles)
pub const LARGE_EMPIRE_THRESHOLD: usize = 100_000;
/// Random factor range for tile priority calculation (0-7)
pub const TILE_PRIORITY_RANDOM_MAX: u32 = 8;
/// Defense post magnitude multiplier (when implemented)
pub const DEFENSE_POST_MAG_MULTIPLIER: f32 = 5.0;
/// Defense post speed multiplier (when implemented)
pub const DEFENSE_POST_SPEED_MULTIPLIER: f32 = 3.0;
}
pub mod spawning {
/// Radius of tiles claimed around spawn point (creates 5x5 square)
pub const SPAWN_RADIUS: i32 = 2;
}

View File

@@ -0,0 +1,432 @@
use crate::game::action::GameAction;
use crate::game::borders::BorderManager;
use crate::game::bot::BotManager;
use crate::game::combat::ActiveAttacks;
use crate::game::player_manager::PlayerManager;
use crate::game::rng::DeterministicRng;
use crate::game::ships::ShipManager;
use crate::game::terrain::TerrainData;
use crate::game::territory_manager::TerritoryManager;
use crate::networking::{Intent, Turn};
use bevy_ecs::prelude::*;
use std::collections::HashSet;
use std::sync::Arc;
/// Troop count specification for attacks
enum TroopCount {
/// Use a ratio of the player's current troops (0.0-1.0)
Ratio(f32),
/// Use an absolute troop count
Absolute(u32),
}
/// Game state resource - DETERMINISTIC, SHARED across ALL clients
///
/// **Important: This is GLOBAL/SHARED state that must be identical on all clients!**
///
/// This resource contains the authoritative game state that:
/// - Is identical across all clients (server, players, spectators)
/// - Processes turns deterministically (same input → same output)
/// - Is used for hash validation and network synchronization
/// - Continues running even when individual players are eliminated
///
/// What belongs here:
/// - Territory ownership, player stats, attacks, resources
/// - Turn number, RNG seed (for determinism)
/// - Any state that affects gameplay or must be validated
///
/// What does NOT belong here:
/// - Client-specific UI state (use LocalPlayerContext)
/// - Individual player outcomes like Victory/Defeat (use LocalPlayerContext)
/// - Rendering preferences, camera position, etc. (use local resources)
///
/// The game never "stops" based on a single player's outcome - it continues
/// until a global end condition is met (e.g., all players eliminated, turn limit).
#[derive(Resource)]
pub struct GameInstance {
pub player_manager: PlayerManager,
pub territory_manager: TerritoryManager,
pub active_attacks: ActiveAttacks,
pub border_manager: BorderManager,
pub bot_manager: BotManager,
pub ship_manager: ShipManager,
pub terrain: Arc<TerrainData>,
pub rng: DeterministicRng,
pub turn_number: u64,
/// Cached set of all coastal tile indices (precomputed once, never changes)
/// A coastal tile is a land tile adjacent to water
pub coastal_tiles: HashSet<usize>,
}
impl GameInstance {
#[allow(clippy::too_many_arguments)]
pub fn new(player_manager: PlayerManager, territory_manager: TerritoryManager, active_attacks: ActiveAttacks, border_manager: BorderManager, bot_manager: BotManager, ship_manager: ShipManager, terrain: Arc<TerrainData>, rng_seed: u64) -> Self {
// Precompute coastal tiles (land tiles adjacent to water)
let size = territory_manager.size();
let coastal_tiles = Self::compute_coastal_tiles(&terrain, size);
Self { player_manager, territory_manager, active_attacks, border_manager, bot_manager, ship_manager, terrain, rng: DeterministicRng::new(rng_seed), turn_number: 0, coastal_tiles }
}
/// Compute all coastal tile indices (land tiles adjacent to water)
/// This is called once at initialization and cached
fn compute_coastal_tiles(terrain: &TerrainData, size: glam::U16Vec2) -> HashSet<usize> {
let mut coastal_tiles = HashSet::new();
let directions = [(0, -1), (1, 0), (0, 1), (-1, 0)];
let width = size.x as usize;
let height = size.y as usize;
for y in 0..height {
for x in 0..width {
let tile_idx = x + y * width;
// Skip water tiles
if terrain.is_navigable(glam::U16Vec2::new(x as u16, y as u16)) {
continue;
}
// Check if any neighbor is water (4-directional)
for (dx, dy) in directions {
let nx = x as i32 + dx;
let ny = y as i32 + dy;
if nx >= 0 && ny >= 0 && nx < width as i32 && ny < height as i32 && terrain.is_navigable(glam::U16Vec2::new(nx as u16, ny as u16)) {
coastal_tiles.insert(tile_idx);
break;
}
}
}
}
coastal_tiles
}
pub fn execute_turn(&mut self, turn: &Turn) {
let _guard = tracing::trace_span!("execute_turn", turn_number = self.turn_number, intent_count = turn.intents.len()).entered();
// Update RNG for this turn
self.rng.update_turn(self.turn_number);
// PHASE 1: Process bot actions (deterministic, based on turn N-1 state)
let bot_player_ids = self.bot_manager.bot_player_ids().to_vec();
{
// Count alive bots for telemetry
let alive_bot_count = bot_player_ids.iter().filter(|&&id| self.player_manager.get_player(id).map(|p| p.is_alive()).unwrap_or(false)).count();
let _guard = tracing::trace_span!("bot_processing", bot_count = bot_player_ids.len(), alive_bot_count).entered();
for (bot_index, &player_id) in bot_player_ids.iter().enumerate() {
if let Some(player) = self.player_manager.get_player(player_id) {
if !player.is_alive() {
continue;
}
if let Some(action) = self.bot_manager.calculate_action(bot_index, self.turn_number, player, &self.territory_manager, &self.border_manager, self.rng.turn_number()) {
self.apply_action(action);
}
}
}
}
// PHASE 2: Process player intents (from network)
for intent in &turn.intents {
match intent {
Intent::Action(action) => {
self.apply_action(action.clone());
}
Intent::SetSpawn { .. } => {
// SetSpawn intents should not appear in Turn messages
// They are only valid during spawn phase and handled separately
// If we see one here, it's likely a bug or late arrival - ignore it
}
}
}
// PHASE 3: Update ships and process arrivals
let ship_arrivals = self.ship_manager.update_ships();
{
let _guard = tracing::trace_span!("ship_arrivals", arrival_count = ship_arrivals.len()).entered();
for (owner_id, target_tile, troops) in ship_arrivals {
tracing::debug!(owner_id, target_tile, troops, "Ship arrived at destination, establishing beachhead");
let player_id = owner_id as usize;
let target_tile_usize = target_tile;
// Step 1: Force-claim the landing tile as beachhead
let previous_owner = self.territory_manager.conquer(target_tile_usize, player_id);
// Border updates now handled by update_player_borders_system (batched at end of turn)
// Step 2: Update player stats
let previous_ownership = crate::game::TileOwnership::from_u16(previous_owner);
if let Some(nation_id) = previous_ownership.nation_id()
&& let Some(prev_owner) = self.player_manager.get_player_mut(nation_id as usize)
{
prev_owner.remove_tile(target_tile_usize);
}
if let Some(player) = self.player_manager.get_player_mut(player_id) {
player.add_tile(target_tile_usize);
}
// Step 4: Notify active attacks of territory change
self.active_attacks.handle_territory_add(target_tile_usize, player_id, &self.territory_manager, &self.rng);
// Step 5: Create attack from beachhead to expand
// Note: Do NOT add troops back - they were deducted at ship launch
if let Some(player) = self.player_manager.get_player_mut(player_id) {
let _ = player;
// Find tiles adjacent to the beachhead
let size = self.territory_manager.size();
let width = size.x as usize;
let height = size.y as usize;
let beachhead_x = target_tile_usize % width;
let beachhead_y = target_tile_usize / width;
let directions = [(0, -1), (1, 0), (0, 1), (-1, 0)];
let mut adjacent_tiles = Vec::new();
for (dx, dy) in directions {
let nx = beachhead_x as i32 + dx;
let ny = beachhead_y as i32 + dy;
if nx >= 0 && ny >= 0 && nx < width as i32 && ny < height as i32 {
let neighbor_idx = nx as usize + ny as usize * width;
adjacent_tiles.push(neighbor_idx);
}
}
// Find valid attack targets (not water, not our own tiles)
let valid_targets: Vec<usize> = adjacent_tiles.iter().filter(|&&tile| !self.territory_manager.is_water(tile) && self.territory_manager.get_owner(tile) as usize != player_id).copied().collect();
// Pick a deterministic random target from valid targets
if !valid_targets.is_empty() {
// Deterministic random selection using turn number and beachhead position
let seed = self.turn_number.wrapping_mul(31).wrapping_add(target_tile_usize as u64);
let index = (seed % valid_targets.len() as u64) as usize;
let attack_target = valid_targets[index];
// Create a beachhead border set containing just this tile for the attack
let beachhead_borders: HashSet<usize> = std::iter::once(target_tile_usize).collect();
// Use the ship's troops for the attack (already deducted at launch)
self.handle_attack_internal(
owner_id,
attack_target as u32,
TroopCount::Absolute(troops),
false, // Don't deduct - troops were already deducted at ship launch
Some(&beachhead_borders),
);
} else {
tracing::debug!(player_id, target_tile_usize, "Ship landed but no valid attack targets found (all adjacent tiles are water or owned)");
}
}
}
}
// PHASE 4: Tick game systems (attacks, etc.)
// Note: Income is processed by process_player_income_system at 10 TPS (before this executes)
self.active_attacks.tick(self.player_manager.get_players_mut(), &mut self.territory_manager, &mut self.border_manager, &self.rng);
self.turn_number += 1;
}
/// Apply a game action (used by both bots and player intents)
pub fn apply_action(&mut self, action: GameAction) {
match action {
GameAction::Attack { player_id, target_tile, troops_ratio } => {
self.handle_attack(player_id, target_tile, troops_ratio);
}
GameAction::LaunchShip { player_id, target_tile, troops_percent } => {
self.handle_launch_ship(player_id, target_tile, troops_percent);
}
}
}
pub fn handle_spawn(&mut self, player_id: usize, tile_index: usize) {
let size = self.territory_manager.size();
let tile = tile_index;
if self.territory_manager.has_owner(tile) || self.territory_manager.is_water(tile) {
tracing::debug!(player_id, tile, "Spawn on occupied/water tile ignored");
return;
}
let width = size.x as usize;
let height = size.y as usize;
let spawn_x = tile % width;
let spawn_y = tile / width;
let mut changed: HashSet<usize> = HashSet::new();
for dy in -2..=2 {
for dx in -2..=2 {
let x = ((spawn_x as i32 + dx).clamp(0, width as i32 - 1)) as usize;
let y = ((spawn_y as i32 + dy).clamp(0, height as i32 - 1)) as usize;
let idx = x + y * width;
if !self.territory_manager.has_owner(idx) && !self.territory_manager.is_water(idx) {
self.territory_manager.conquer(idx, player_id);
changed.insert(idx);
}
}
}
if !changed.is_empty() {
// Border updates now handled by update_player_borders_system (batched at end of turn)
// Update player stats
if let Some(player) = self.player_manager.get_player_mut(player_id) {
for &t in &changed {
player.add_tile(t);
}
}
// Notify active attacks that territory changed
for &t in &changed {
self.active_attacks.handle_territory_add(t, player_id, &self.territory_manager, &self.rng);
}
}
}
pub fn handle_attack(&mut self, player_id: u16, target_tile: u32, troops_ratio: f32) {
self.handle_attack_internal(player_id, target_tile, TroopCount::Ratio(troops_ratio), true, None);
}
/// Handle attack with specific border tiles and troop allocation
fn handle_attack_internal(&mut self, player_id: u16, target_tile: u32, troop_count: TroopCount, deduct_from_player: bool, border_tiles: Option<&HashSet<usize>>) {
let player_id = player_id as usize;
let target_tile = target_tile as usize;
let target_owner = self.territory_manager.get_owner(target_tile);
if target_owner as usize == player_id {
tracing::debug!(player_id, target_tile, "Attack on own tile ignored");
return; // Can't attack self
}
let troops = match troop_count {
TroopCount::Ratio(ratio) => {
if let Some(player) = self.player_manager.get_player(player_id) {
player.get_troops() * ratio
} else {
return;
}
}
TroopCount::Absolute(count) => count as f32,
};
// Deduct troops from the player's pool when creating the attack (if requested)
if deduct_from_player {
if let Some(player) = self.player_manager.get_player_mut(player_id) {
player.remove_troops(troops);
} else {
return;
}
}
let border_tiles_to_use = border_tiles.or_else(|| Some(self.border_manager.get_border_tiles(player_id)));
use crate::game::TileOwnership;
if TileOwnership::from_u16(target_owner).is_unclaimed() {
if self.player_manager.get_player(player_id).is_some() {
self.active_attacks.schedule_unclaimed(player_id, troops, target_tile, border_tiles_to_use, &self.territory_manager, &self.border_manager, self.turn_number, &self.rng);
}
} else if self.player_manager.get_player(target_owner as usize).is_some() && self.player_manager.get_player(player_id).is_some() {
self.active_attacks.schedule_attack(player_id, target_owner as usize, target_tile, troops, border_tiles_to_use, &self.territory_manager, &self.border_manager, self.turn_number, &self.rng);
}
}
pub fn handle_launch_ship(&mut self, player_id: u16, target_tile: u32, troops_percent: u32) {
use crate::game::ships::{SHIP_TROOP_PERCENT, ship_pathfinding};
let player_id_usize = player_id as usize;
let target_tile_usize = target_tile as usize;
// Check if player exists and has troops
let player_troops = if let Some(player) = self.player_manager.get_player(player_id_usize) {
if !player.is_alive() {
tracing::debug!(player_id, "Dead player cannot launch ships");
return;
}
player.get_troops()
} else {
tracing::debug!(player_id, "Player not found");
return;
};
if player_troops <= 0.0 {
tracing::debug!(player_id, "Player has no troops to launch ship");
return;
}
// Calculate troop count: use provided percentage, or default to 20% if 0
let troops_to_send = if troops_percent > 0 {
// Clamp to reasonable range (1-100%)
let clamped = troops_percent.clamp(1, 100);
let calculated = (player_troops * (clamped as f32 / 100.0)).floor() as u32;
tracing::debug!(player_id, player_troops, troops_percent = clamped, troops_to_send = calculated, "Ship launch troop calculation");
calculated
} else {
// Default: 20% of troops
let calculated = (player_troops * SHIP_TROOP_PERCENT).floor() as u32;
tracing::debug!(player_id, player_troops, default_percent = SHIP_TROOP_PERCENT, troops_to_send = calculated, "Ship launch troop calculation (default)");
calculated
};
if troops_to_send == 0 {
tracing::debug!(player_id, "Not enough troops to launch ship");
return;
}
let size = self.territory_manager.size();
// Find target's nearest coastal tile
let target_coastal_tile = crate::game::connectivity::find_coastal_tile_in_region(self.territory_manager.as_u16_slice(), &self.terrain, target_tile_usize, size);
let target_coastal_tile = match target_coastal_tile {
Some(tile) => tile,
None => {
tracing::debug!(player_id, target_tile, "No coastal tile found in target region");
return;
}
};
// Find player's nearest coastal tile
let player_border_tiles = self.border_manager.get_border_tiles(player_id_usize);
let launch_tile = ShipManager::find_nearest_player_coastal_tile(&self.coastal_tiles, player_border_tiles, target_coastal_tile, size);
let launch_tile = match launch_tile {
Some(tile) => tile,
None => {
tracing::debug!(player_id, target_tile, "Player has no coastal tiles to launch from");
return;
}
};
// Calculate water path from launch tile to target coastal tile
let path = {
let _guard = tracing::trace_span!("ship_pathfinding", launch_tile = launch_tile, target_tile = target_coastal_tile).entered();
ship_pathfinding::find_water_path(&self.terrain, launch_tile, target_coastal_tile, crate::game::ships::SHIP_MAX_PATH_LENGTH)
};
let path = match path {
Some(p) => p, // Use full A* path for accurate frontend interpolation
None => {
tracing::debug!(player_id, target_tile, launch_tile, "No water path found");
return;
}
};
// Launch the ship
if self.ship_manager.launch_ship(player_id, troops_to_send, path, self.turn_number).is_some() {
// Deduct troops from player
if let Some(player) = self.player_manager.get_player_mut(player_id_usize) {
player.remove_troops(troops_to_send as f32);
}
tracing::debug!(player_id, target_tile, troops_to_send, launch_tile, "Ship launched successfully");
} else {
tracing::debug!(player_id, "Failed to launch ship (likely at ship limit)");
}
}
}

View File

@@ -0,0 +1,29 @@
use bevy_ecs::prelude::*;
use tracing::trace;
use crate::game::{BotPlayer, CurrentTurn, GameInstance};
/// Process player income at 10 TPS (once per turn)
/// Only runs when turn_is_ready() condition is true
///
/// Uses Option<&BotPlayer> to distinguish bot vs human players:
/// - Some(&BotPlayer) = bot player (60% income, 33% max troops)
/// - None = human player (100% income, 100% max troops)
pub fn process_player_income_system(current_turn: Res<CurrentTurn>, mut game_instance: ResMut<GameInstance>, query: Query<(Entity, Option<&BotPlayer>)>) {
// Process income for all players
let player_manager = &mut game_instance.player_manager;
for player in player_manager.get_players_mut() {
if !player.is_alive() {
continue;
}
// Determine if this player is a bot by checking ECS entity
let is_bot = player.entity.and_then(|entity| query.get(entity).ok()).and_then(|(_, bot_marker)| bot_marker).is_some();
// Process income with bot modifier if applicable
player.income(is_bot);
}
trace!("Income processed for turn {}", current_turn.turn.turn_number);
}

View File

@@ -0,0 +1,268 @@
//! Platform-agnostic input handling systems
//!
//! These systems use InputState instead of Bevy's input queries,
//! making them work across both WASM and desktop platforms with
//! Pixi.js rendering.
use bevy_ecs::prelude::*;
use tracing::{debug, info, trace};
use crate::game::{GameAction, LocalPlayerContext, SpawnManager, TileOwnership};
use crate::networking::{GameView, Intent, IntentEvent};
use crate::ui::input::{InputState, KeyCode, MouseButton};
use crate::ui::protocol::CameraCommand;
/// Resource tracking whether spawn phase is active
#[derive(Resource, Default)]
pub struct SpawnPhase {
pub active: bool,
}
/// Resource for attack control settings
#[derive(Resource)]
pub struct AttackControls {
pub attack_ratio: f32,
}
impl Default for AttackControls {
fn default() -> Self {
Self { attack_ratio: 0.5 }
}
}
/// Handle placing the human spawn by clicking on valid land
#[allow(clippy::too_many_arguments)]
pub fn handle_spawn_click_system(input_state: NonSend<std::sync::Arc<std::sync::Mutex<InputState>>>, spawn_phase: Res<SpawnPhase>, game_view: Option<ResMut<GameView>>, local_context: Option<Res<LocalPlayerContext>>, mut spawn_manager: Option<ResMut<SpawnManager>>, mut spawn_timeout: Option<ResMut<crate::game::SpawnTimeout>>, mut intent_writer: MessageWriter<IntentEvent>, game_instance: Option<Res<crate::game::GameInstance>>) {
if !spawn_phase.active {
return;
}
let Ok(input) = input_state.lock() else {
return;
};
if !input.mouse_just_released(MouseButton::Left) {
return;
}
let _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 = crate::ui::tile_to_index(tile_coord, game_view.width());
let tile_ownership = TileOwnership::from_u16(game_view.get_owner(tile));
if tile_ownership.is_owned() || tile_ownership.is_water() {
debug!("Spawn click on tile {} ignored - occupied or water", tile);
return;
}
// Player has chosen a spawn location - send to server
info!("Player {} setting spawn at tile {}", local_context.my_player_id, tile);
// Check if this is the first spawn (timer not started yet)
let is_first_spawn = if let Some(ref spawn_mgr) = spawn_manager { spawn_mgr.get_player_spawns().is_empty() } else { true };
// Send SetSpawn intent to server (not Action - this won't be in game history)
// Server will validate, track, and eventually send Turn(0) when timeout expires
intent_writer.write(IntentEvent(Intent::SetSpawn { player_id: local_context.my_player_id as u16, tile_index: tile as u32 }));
// Start spawn timeout on first spawn (spawn_phase plugin will emit countdown updates)
if is_first_spawn && let Some(ref mut timeout) = spawn_timeout {
timeout.start();
info!("Spawn timeout started ({:.1}s)", timeout.duration_secs);
}
// Update local spawn manager for preview/bot recalculation
// Note: This only updates the spawn manager, not the game instance
// The actual game state is updated when Turn(0) is processed
if let Some(ref mut spawn_mgr) = spawn_manager
&& let Some(ref game_inst) = game_instance
{
// Update spawn manager (triggers bot spawn recalculation)
spawn_mgr.update_player_spawn(local_context.my_player_id, tile, &game_inst.bot_manager, &game_inst.territory_manager);
info!("Spawn manager updated with player {} spawn at tile {}", local_context.my_player_id, tile);
info!("Total spawns in manager: {}", spawn_mgr.get_all_spawns().len());
}
}
/// Center the camera on the client's spawn (hotkey C)
pub fn handle_center_camera_system(input_state: NonSend<std::sync::Arc<std::sync::Mutex<InputState>>>, game_view: Option<Res<GameView>>, local_context: Option<Res<LocalPlayerContext>>, mut camera_commands: MessageWriter<CameraCommand>) {
let Ok(input) = input_state.lock() else {
return;
};
if !input.key_just_pressed(KeyCode::KeyC) {
return;
}
let Some(game_view) = game_view else {
return; // GameView not ready yet
};
let Some(local_context) = local_context else {
return; // LocalPlayerContext not ready yet
};
// Find any owned tile to center on
if let Some(tile) = game_view.find_tile_owned_by(local_context.my_player_id as u16) {
camera_commands.write(CameraCommand::CenterOnTile { tile_index: tile as u32, animate: true });
}
}
/// After spawn, clicking tiles triggers expansion/attack based on ownership
/// Automatically detects if a ship is needed for water attacks
pub fn handle_attack_click_system(input_state: NonSend<std::sync::Arc<std::sync::Mutex<InputState>>>, spawn_phase: Res<SpawnPhase>, game_view: Option<Res<GameView>>, game_instance: Option<Res<crate::game::GameInstance>>, local_context: Option<Res<LocalPlayerContext>>, attack_controls: Res<AttackControls>, mut intent_writer: MessageWriter<IntentEvent>) {
if spawn_phase.active {
return;
}
let Ok(input) = input_state.lock() else {
return;
};
if !input.mouse_just_released(MouseButton::Left) {
return;
}
// Frontend handles camera interaction filtering
if input.had_camera_interaction() {
return;
}
let _guard = tracing::trace_span!("attack_click").entered();
let Some(game_view) = game_view else {
return; // GameView not ready yet
};
let Some(game_instance) = game_instance else {
return; // GameInstance not ready yet
};
let Some(local_context) = local_context else {
return; // LocalPlayerContext not ready yet
};
// Can't attack if not allowed to send intents (defeated/spectating)
if !local_context.can_send_intents {
return;
}
// Get tile from InputState (set by frontend)
let Some(tile_coord) = input.cursor_tile() else {
return;
};
let tile = crate::ui::tile_to_index(tile_coord, game_view.width());
let owner = game_view.get_owner(tile);
let player_id = local_context.my_player_id as u16;
// Can't attack own tiles
if owner == player_id {
return;
}
// Check if target is water - ignore water clicks
let size = game_view.size();
let width_usize = size.x as usize;
let tile_x = (tile % width_usize) as u16;
let tile_y = (tile / width_usize) as u16;
if game_instance.terrain.is_navigable(glam::U16Vec2::new(tile_x, tile_y)) {
return;
}
// Check if target is connected to player's territory
let is_connected = crate::game::connectivity::is_connected_to_player(game_view.territories.as_ref(), tile, player_id, size);
if is_connected {
// Target is connected to player's territory - use normal attack
intent_writer.write(IntentEvent(Intent::Action(GameAction::Attack { player_id, target_tile: tile as u32, troops_ratio: attack_controls.attack_ratio })));
return;
}
// Target is NOT connected - need to use ship
debug!("Target {} not connected to player territory, attempting ship launch", tile);
// Find target's nearest coastal tile
let target_coastal_tile = crate::game::connectivity::find_coastal_tile_in_region(game_view.territories.as_ref(), &game_instance.terrain, tile, size);
let Some(target_coastal_tile) = target_coastal_tile else {
debug!("No coastal tile found in target's region for tile {}", tile);
return;
};
// Find player's nearest coastal tile
let player_border_tiles = game_instance.border_manager.get_border_tiles(player_id as usize);
let launch_tile = crate::game::ships::ShipManager::find_nearest_player_coastal_tile(&game_instance.coastal_tiles, player_border_tiles, target_coastal_tile, size);
let Some(launch_tile) = launch_tile else {
debug!("Player has no coastal tiles to launch ship from");
return;
};
debug!("Found launch tile {} and target coastal tile {} for target {}", launch_tile, target_coastal_tile, tile);
// Try to find a water path from launch tile to target coastal tile
let path = crate::game::ships::ship_pathfinding::find_water_path(&game_instance.terrain, launch_tile, target_coastal_tile, crate::game::ships::SHIP_MAX_PATH_LENGTH);
if let Some(_path) = path {
// We can reach the target by ship!
// Convert attack_ratio (0.0-1.0) to troops_percent (0-100)
let troops_percent = (attack_controls.attack_ratio * 100.0) as u32;
debug!("Launching ship to target {} with {}% troops", tile, troops_percent);
intent_writer.write(IntentEvent(Intent::Action(GameAction::LaunchShip { player_id, target_tile: tile as u32, troops_percent })));
} else {
debug!("No water path found from {} to {}", launch_tile, target_coastal_tile);
}
}
/// Adjust attack ratio with keys 1/2
pub fn handle_attack_ratio_keys_system(input_state: NonSend<std::sync::Arc<std::sync::Mutex<InputState>>>, mut controls: ResMut<AttackControls>) {
let Ok(input) = input_state.lock() else {
return;
};
let mut changed = false;
if input.key_just_pressed(KeyCode::Digit1) {
controls.attack_ratio = (controls.attack_ratio - 0.1).max(0.1);
changed = true;
}
if input.key_just_pressed(KeyCode::Digit2) {
controls.attack_ratio = (controls.attack_ratio + 0.1).min(1.0);
changed = true;
}
if changed {
debug!("Attack ratio changed to {:.1}", controls.attack_ratio);
}
}

View File

@@ -0,0 +1,187 @@
use bevy_ecs::prelude::*;
use rand::rngs::StdRng;
use rand::{Rng, SeedableRng};
use tracing::{debug, info};
use crate::constants::{BOT_COUNT, SPAWN_TIMEOUT_SECS};
use crate::game::{ActiveAttacks, BorderManager, BotManager, GameInstance, HSLColor, LocalPlayerContext, Player, PlayerManager, SpawnManager, SpawnPhase, SpawnTimeout, TerritoryManager};
use crate::networking::{GameView, LocalTurnServerHandle, PlayerView, TurnReceiver};
use flume::Receiver;
/// Parameters needed to initialize a new game
pub struct GameInitParams {
pub map_width: u16,
pub map_height: u16,
pub conquerable_tiles: Vec<bool>,
pub client_player_id: usize,
pub intent_rx: Receiver<crate::networking::Intent>,
pub terrain_data: std::sync::Arc<crate::game::terrain::TerrainData>,
}
/// Initialize all game resources when starting a new game
/// This should be called by the StartGame command handler
pub fn initialize_game_resources(commands: &mut Commands, params: GameInitParams) {
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);
// 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 border manager
let mut border_manager = BorderManager::new();
border_manager.reset(params.map_width, params.map_height, 1 + BOT_COUNT);
// Initialize active attacks
let mut active_attacks = ActiveAttacks::new();
active_attacks.init(1 + BOT_COUNT);
// Initialize bot manager (1 human player + BOT_COUNT bots)
let bot_manager = BotManager::new(BOT_COUNT, 1);
debug!("BotManager initialized with {} bots", BOT_COUNT);
// Use a fixed seed for deterministic bot behavior and color generation
// In multiplayer, this should come from the server
let rng_seed = 0xDEADBEEF;
// Create RNG for deterministic color generation
let mut rng = StdRng::seed_from_u64(rng_seed);
// Create players: 1 human + BOT_COUNT bots
// Player IDs start at 0 (human), then 1, 2, 3... for bots
let mut players = Vec::new();
// Generate random hue offset for color spread
let hue_offset = rng.random_range(0.0..360.0);
// All players (including human) get deterministically generated colors
for i in 0..=BOT_COUNT {
let is_human = i == 0;
let id = i as f32;
// Use golden angle distribution with random offset for visually distinct colors
let hue = (id * 137.5 + hue_offset) % 360.0;
let saturation = rng.random_range(0.75..=0.95);
let lightness = rng.random_range(0.35..=0.65);
let color = HSLColor::new(hue, saturation, lightness);
if is_human {
players.push(Player::new(i, "Player".to_string(), color));
} else {
players.push(Player::new(i, format!("Bot {}", i), color));
}
}
// Initialize player manager
// Human player is always ID 0
let mut player_manager = PlayerManager::new();
let human_player_id = 0;
player_manager.init(players, human_player_id);
debug!("Player manager initialized with {} players (human: {}, bots: {})", 1 + BOT_COUNT, human_player_id, BOT_COUNT);
// Create ship manager
let ship_manager = crate::game::ships::ShipManager::new();
// Create game instance (bots won't be spawned until player chooses spawn)
let mut game_instance = GameInstance::new(player_manager, territory_manager, active_attacks, border_manager, bot_manager, ship_manager, params.terrain_data.clone(), rng_seed);
// Calculate initial bot spawn positions (first pass)
// These will be shown to the player, but not applied to game state yet
let initial_bot_spawns = game_instance.bot_manager.calculate_initial_spawns(&game_instance.territory_manager, rng_seed);
debug!("Calculated {} initial bot spawn positions (requested: {})", initial_bot_spawns.len(), BOT_COUNT);
if initial_bot_spawns.len() < BOT_COUNT {
tracing::warn!("Only {} of {} bots were able to spawn - map may be too small or bot count too high", initial_bot_spawns.len(), BOT_COUNT);
}
// Create SpawnManager to track spawn positions during spawn phase
let spawn_manager = SpawnManager::new(initial_bot_spawns.clone(), rng_seed);
commands.insert_resource(spawn_manager);
// Initialize GameView with initial game state
// Calculate total land tiles once for caching (performance optimization)
use std::sync::Arc;
let total_land_tiles = game_instance.territory_manager.as_slice().iter().filter(|ownership| !ownership.is_water()).count() as u32;
let game_view = GameView {
size: glam::U16Vec2::new(params.map_width, params.map_height),
territories: Arc::from(game_instance.territory_manager.to_u16_vec().as_slice()),
turn_number: 0,
total_land_tiles,
changed_tiles: Vec::new(), // Empty on initialization
players: game_instance.player_manager.get_players().iter().map(|p| PlayerView { id: p.id as u16, color: p.color.to_rgba(), name: p.name.clone(), tile_count: p.get_territory_size() as u32, troops: p.get_troops() as u32, is_alive: p.is_alive() }).collect(),
ships: Vec::new(), // No ships at initialization
};
// Spawn player entities with ECS components BEFORE inserting GameInstance
// This ensures entities exist from the start for update_player_borders_system
let bot_player_ids: Vec<usize> = game_instance.bot_manager.bot_player_ids().to_vec();
for player in game_instance.player_manager.get_players_mut() {
let is_bot = bot_player_ids.contains(&player.id);
let entity = if is_bot { commands.spawn((crate::game::BotPlayer, crate::game::PlayerId(player.id as u16), crate::game::BorderTiles::default(), crate::game::Troops(player.get_troops()), crate::game::TerritorySize(player.get_territory_size()))).id() } else { commands.spawn((crate::game::PlayerId(player.id as u16), crate::game::BorderTiles::default(), crate::game::Troops(player.get_troops()), crate::game::TerritorySize(player.get_territory_size()))).id() };
player.entity = Some(entity);
}
debug!("Player entities spawned with ECS components ({} total)", 1 + BOT_COUNT);
commands.insert_resource(game_instance);
commands.insert_resource(game_view);
debug!("GameInstance and GameView resources created");
// Initialize local player context
commands.insert_resource(LocalPlayerContext::new(0)); // Human player is ID 0
debug!("LocalPlayerContext created for player 0 (human)");
// Initialize spawn timeout
commands.insert_resource(SpawnTimeout::new(SPAWN_TIMEOUT_SECS));
debug!("SpawnTimeout initialized ({} seconds)", SPAWN_TIMEOUT_SECS);
// Initialize turn generation resources
let (turn_tx, turn_rx) = flume::unbounded();
let server_handle = LocalTurnServerHandle { paused: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)), running: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)) };
commands.insert_resource(server_handle);
commands.insert_resource(TurnReceiver { turn_rx });
commands.insert_resource(crate::networking::TurnGenerator { turn_number: 0, accumulated_time: 0.0, turn_tx, spawn_config: std::collections::HashMap::new(), spawn_timeout_accumulated: None, game_started: false });
debug!("Turn generator initialized (paused until player spawn)");
// Activate spawn phase (SpawnPhasePlugin will emit initial SpawnPhaseUpdate)
commands.insert_resource(SpawnPhase { active: true });
debug!("Spawn phase activated");
info!("Game resources initialized successfully - ready to start");
}
/// Clean up all game resources when quitting a game
/// This should be called by the QuitGame command handler
pub fn cleanup_game_resources(world: &mut World) {
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::<GameInstance>();
world.remove_resource::<LocalPlayerContext>();
world.remove_resource::<TurnReceiver>();
world.remove_resource::<SpawnManager>();
world.remove_resource::<SpawnTimeout>();
world.remove_resource::<crate::networking::GameView>();
world.remove_resource::<crate::TerrainData>();
world.remove_resource::<crate::networking::TurnGenerator>();
// Note: SpawnPhase is a permanent resource (init_resource), not removed on quit
info!("Game resources cleaned up successfully");
}

View File

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

View File

@@ -0,0 +1,51 @@
pub mod action;
pub mod borders;
pub mod bot;
pub mod combat;
pub mod connectivity;
pub mod constants;
pub mod game_instance;
pub mod income;
pub mod input_handlers;
pub mod lifecycle;
pub mod local_context;
pub mod outcome;
pub mod player;
pub mod player_manager;
pub mod rng;
pub mod ships;
pub mod spawn_manager;
pub mod spawn_timeout;
pub mod terrain;
pub mod territory;
pub mod territory_manager;
pub mod tile_ownership;
pub mod tilemap;
pub mod tilemap_changes;
pub mod turn;
pub mod utils;
pub use action::*;
pub use borders::{BorderManager, BorderTransitionResult, update_player_borders_system};
pub use bot::*;
pub use combat::{ActiveAttacks, AttackConfig, AttackExecutor};
pub use connectivity::*;
pub use game_instance::*;
pub use income::process_player_income_system;
pub use input_handlers::*;
pub use lifecycle::*;
pub use local_context::*;
pub use outcome::*;
pub use player::{BorderTiles, BotPlayer, HSLColor, Player, PlayerId, TerritorySize, Troops};
pub use player_manager::*;
pub use rng::DeterministicRng;
pub use ships::*;
pub use spawn_manager::*;
pub use spawn_timeout::*;
pub use terrain::*;
pub use territory::*;
pub use territory_manager::*;
pub use tile_ownership::*;
pub use tilemap::*;
pub use tilemap_changes::*;
pub use turn::{CurrentTurn, turn_is_ready};

View File

@@ -0,0 +1,82 @@
use crate::game::local_context::LocalPlayerContext;
use crate::game::territory_manager::OWNER_WATER;
use crate::networking::GameView;
use crate::ui::protocol::{BackendMessage, GameOutcome};
use bevy_ecs::prelude::*;
use tracing::info;
/// System that checks if the local player has won or lost
/// This is a NON-BLOCKING check - the game continues running regardless
pub fn check_local_player_outcome(mut local_context: ResMut<LocalPlayerContext>, game_view: Res<GameView>, mut backend_messages: MessageWriter<BackendMessage>) {
// Don't check if outcome already determined
if local_context.my_outcome.is_some() {
return;
}
// Don't check outcome until player has spawned
// Skip only if player has 0 tiles AND is_alive (hasn't spawned yet)
// If player has 0 tiles AND !is_alive, that's a real defeat
let my_player_id = local_context.my_player_id as u16;
let Some(my_player) = game_view.get_player(my_player_id) else {
return;
};
if my_player.tile_count == 0 && my_player.is_alive {
// Player hasn't spawned yet - skip outcome check
return;
}
// Check defeat condition: I've been eliminated (0 tiles)
if !my_player.is_alive {
info!("Local player defeated - eliminated (0 tiles)");
local_context.mark_defeated();
backend_messages.write(BackendMessage::GameEnded { outcome: GameOutcome::Defeat });
return;
}
// Calculate total claimable tiles for victory condition checks
let total_claimable_tiles = game_view.territories.iter().filter(|&&owner| owner != OWNER_WATER).count();
const WIN_THRESHOLD: f32 = 0.80;
if total_claimable_tiles > 0 {
let my_tiles = my_player.tile_count as usize;
let my_occupation = my_tiles as f32 / total_claimable_tiles as f32;
// Check if I've won by occupation
if my_occupation >= WIN_THRESHOLD {
info!("Local player victorious - reached {:.1}% occupation ({}/{} claimable tiles, threshold: {:.0}%)", my_occupation * 100.0, my_tiles, total_claimable_tiles, WIN_THRESHOLD * 100.0);
local_context.mark_victorious();
backend_messages.write(BackendMessage::GameEnded { outcome: GameOutcome::Victory });
return;
}
// Check if any opponent has won by occupation (which means I lost)
for player in &game_view.players {
if player.id != my_player_id && player.is_alive {
let opponent_tiles = player.tile_count as usize;
let opponent_occupation = opponent_tiles as f32 / total_claimable_tiles as f32;
if opponent_occupation >= WIN_THRESHOLD {
info!("Local player defeated - {} reached {:.1}% occupation ({}/{} claimable tiles, threshold: {:.0}%)", player.name, opponent_occupation * 100.0, opponent_tiles, total_claimable_tiles, WIN_THRESHOLD * 100.0);
local_context.mark_defeated();
backend_messages.write(BackendMessage::GameEnded { outcome: GameOutcome::Defeat });
return;
}
}
}
}
// Check victory by eliminating all opponents
let all_opponents_dead = game_view
.players
.iter()
.filter(|p| p.id != my_player_id) // Exclude me
.all(|p| !p.is_alive);
if all_opponents_dead && my_player.is_alive {
info!("Local player victorious - all opponents eliminated");
local_context.mark_victorious();
backend_messages.write(BackendMessage::GameEnded { outcome: GameOutcome::Victory });
}
}

View File

@@ -0,0 +1,199 @@
use bevy_ecs::prelude::*;
use std::collections::HashSet;
/// Marker component to identify bot players
/// Human players are identified by the ABSENCE of this component
/// Use Option<&BotPlayer> in queries to handle both types
#[derive(Component, Debug, Clone, Copy, Default)]
pub struct BotPlayer;
/// Player ID component for ECS queries
#[derive(Component, Debug, Clone, Copy)]
pub struct PlayerId(pub u16);
/// Border tiles component - tiles at the edge of a player's territory
#[derive(Component, Debug, Clone, Default)]
pub struct BorderTiles(pub HashSet<usize>);
/// Troops component - current troop count
#[derive(Component, Debug, Clone, Copy)]
pub struct Troops(pub f32);
/// Territory size component - number of tiles owned
#[derive(Component, Debug, Clone, Copy)]
pub struct TerritorySize(pub usize);
/// HSL Color representation
#[derive(Debug, Clone, Copy)]
pub struct HSLColor {
pub h: f32, // Hue: 0-360
pub s: f32, // Saturation: 0-1
pub l: f32, // Lightness: 0-1
}
impl HSLColor {
pub fn new(h: f32, s: f32, l: f32) -> Self {
Self { h, s, l }
}
pub fn to_rgba(&self) -> [f32; 4] {
let c = (1.0 - (2.0 * self.l - 1.0).abs()) * self.s;
let h_prime = self.h / 60.0;
let x = c * (1.0 - ((h_prime % 2.0) - 1.0).abs());
let (r1, g1, b1) = if h_prime < 1.0 {
(c, x, 0.0)
} else if h_prime < 2.0 {
(x, c, 0.0)
} else if h_prime < 3.0 {
(0.0, c, x)
} else if h_prime < 4.0 {
(0.0, x, c)
} else if h_prime < 5.0 {
(x, 0.0, c)
} else {
(c, 0.0, x)
};
let m = self.l - c / 2.0;
[r1 + m, g1 + m, b1 + m, 1.0]
}
pub fn from_rgb(r: u8, g: u8, b: u8) -> Self {
let r = r as f32 / 255.0;
let g = g as f32 / 255.0;
let b = b as f32 / 255.0;
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let delta = max - min;
let l = (max + min) / 2.0;
if delta == 0.0 {
return Self { h: 0.0, s: 0.0, l };
}
let s = if l < 0.5 { delta / (max + min) } else { delta / (2.0 - max - min) };
let h = if max == r {
60.0 * (((g - b) / delta) % 6.0)
} else if max == g {
60.0 * (((b - r) / delta) + 2.0)
} else {
60.0 * (((r - g) / delta) + 4.0)
};
let h = if h < 0.0 { h + 360.0 } else { h };
Self { h, s, l }
}
}
/// Nation in the game (controlled by either human player or bot AI)
#[derive(Debug, Clone)]
pub struct Nation {
pub id: usize,
pub name: String,
pub color: HSLColor,
troops: f32,
territory_size: usize,
alive: bool,
pub entity: Option<Entity>, // Link to ECS entity
}
impl Nation {
pub fn new(id: usize, name: String, color: HSLColor) -> Self {
Self { id, name, color, troops: 2500.0, territory_size: 0, alive: true, entity: None }
}
/// Add a tile to the nation's territory
/// WARNING: Call this AFTER updating the territory manager
pub fn add_tile(&mut self, _tile: usize) {
self.territory_size += 1;
}
/// Remove a tile from the nation's territory
/// WARNING: Call this AFTER updating the territory manager
pub fn remove_tile(&mut self, _tile: usize) {
self.territory_size = self.territory_size.saturating_sub(1);
if self.territory_size == 0 {
self.alive = false;
}
}
/// Calculate maximum troop capacity based on territory size
/// 2 * (tiles^0.6 * 1000 + 50000)
pub fn calculate_max_troops(&self, is_bot: bool) -> f32 {
let base_max = 2.0 * ((self.territory_size as f32).powf(0.6) * 1000.0 + 50_000.0);
if is_bot {
base_max * 0.33 // Bots get 33% max troops
} else {
base_max
}
}
/// Calculate income for this tick
pub fn calculate_income(&self, is_bot: bool) -> f32 {
let max_troops = self.calculate_max_troops(is_bot);
// Base income calculation
let mut income = 10.0 + (self.troops.powf(0.73) / 4.0);
// Soft cap as approaching max troops
let ratio = 1.0 - (self.troops / max_troops);
income *= ratio;
// Apply bot modifier
if is_bot {
income * 0.6 // Bots get 60% income
} else {
income
}
}
/// Process one tick worth of income
pub fn income(&mut self, is_bot: bool) {
let income = self.calculate_income(is_bot);
self.add_troops_internal(income, is_bot);
}
/// Internal method to add troops with max cap
fn add_troops_internal(&mut self, amount: f32, is_bot: bool) {
let max_troops = self.calculate_max_troops(is_bot);
self.troops = (self.troops + amount).min(max_troops);
}
/// Get the amount of troops the nation has
pub fn get_troops(&self) -> f32 {
self.troops
}
/// Add troops to the nation
/// Troops will be capped based on territory size using the max troops formula
/// For internal use; external callers should use the income system
pub fn add_troops(&mut self, amount: f32) {
// For external calls (attacks, donations), always use human formula
// This ensures consistent behavior for troop transfers
self.add_troops_internal(amount, false);
}
/// Remove troops from the nation
pub fn remove_troops(&mut self, amount: f32) {
self.troops = (self.troops - amount).max(0.0);
}
/// Get the size of the nation's territory (in tiles)
pub fn get_territory_size(&self) -> usize {
self.territory_size
}
/// Check if the nation is alive
pub fn is_alive(&self) -> bool {
self.alive
}
}
/// Type alias for backward compatibility during ECS migration
pub type Player = Nation;

View File

@@ -0,0 +1,72 @@
use crate::game::player::Player;
/// Manages all players in the game
pub struct PlayerManager {
players: Vec<Player>,
pub client_player_id: usize,
human_count: usize,
}
impl Default for PlayerManager {
fn default() -> Self {
Self::new()
}
}
impl PlayerManager {
pub fn new() -> Self {
Self { players: Vec::new(), client_player_id: 0, human_count: 0 }
}
/// Initialize the player manager
pub fn init(&mut self, players: Vec<Player>, client_player_id: usize) {
self.players = players;
self.client_player_id = client_player_id;
self.human_count = 1; // For now, only one human player
}
/// Register a new player
pub fn register_player(&mut self, player: Player) {
self.players.push(player);
}
/// Get a player by ID
pub fn get_player(&self, id: usize) -> Option<&Player> {
self.players.get(id)
}
/// Get a mutable player by ID
pub fn get_player_mut(&mut self, id: usize) -> Option<&mut Player> {
self.players.get_mut(id)
}
/// Get all players
pub fn get_players(&self) -> &[Player] {
&self.players
}
/// Get all players mutably
pub fn get_players_mut(&mut self) -> &mut [Player] {
&mut self.players
}
/// Check if a player is a bot
pub fn is_bot(&self, player_id: usize) -> bool {
player_id >= self.human_count
}
/// Validate a player ID
pub fn validate_player(&self, player_id: usize) -> bool {
self.players.get(player_id).is_some_and(|p| p.is_alive())
}
/// Get the number of players
pub fn player_count(&self) -> usize {
self.players.len()
}
/// Get the client player
pub fn get_client_player(&self) -> Option<&Player> {
self.get_player(self.client_player_id)
}
}

View File

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

View File

@@ -0,0 +1,12 @@
pub mod ship_manager;
pub mod ship_pathfinding;
pub mod transport_ship;
pub use ship_manager::ShipManager;
pub use transport_ship::TransportShip;
// Ship-related constants
pub const MAX_SHIPS_PER_PLAYER: usize = 5;
pub const SHIP_TICKS_PER_TILE: u32 = 1; // How many ticks to move one tile (1 = fast speed)
pub const SHIP_MAX_PATH_LENGTH: usize = 1_000_000;
pub const SHIP_TROOP_PERCENT: f32 = 0.20;

View File

@@ -0,0 +1,167 @@
use crate::game::ships::transport_ship::TransportShip;
use crate::game::ships::{MAX_SHIPS_PER_PLAYER, SHIP_MAX_PATH_LENGTH, SHIP_TICKS_PER_TILE};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use tracing::debug;
/// Manages all active ships in the game
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ShipManager {
/// All active ships, indexed by ship ID
ships: HashMap<u32, TransportShip>,
/// Track number of ships per player
ships_per_player: HashMap<u16, usize>,
/// Next available ship ID
next_ship_id: u32,
}
impl ShipManager {
pub fn new() -> Self {
Self { ships: HashMap::new(), ships_per_player: HashMap::new(), next_ship_id: 1 }
}
/// Launch a new transport ship if possible
/// Returns Some(ship_id) if successful, None if launch failed
pub fn launch_ship(&mut self, owner_id: u16, troops: u32, path: Vec<usize>, launch_tick: u64) -> Option<u32> {
// Check if player has reached ship limit
let current_ships = *self.ships_per_player.get(&owner_id).unwrap_or(&0);
if current_ships >= MAX_SHIPS_PER_PLAYER {
debug!("Player {} cannot launch ship: already has {}/{} ships", owner_id, current_ships, MAX_SHIPS_PER_PLAYER);
return None;
}
// Check path length
if path.is_empty() {
debug!("Cannot launch ship with empty path");
return None;
}
if path.len() > SHIP_MAX_PATH_LENGTH {
debug!("Cannot launch ship: path too long ({} > {})", path.len(), SHIP_MAX_PATH_LENGTH);
return None;
}
// Create the ship
let ship_id = self.next_ship_id;
self.next_ship_id += 1;
let ship = TransportShip::new(ship_id, owner_id, troops, path, SHIP_TICKS_PER_TILE, launch_tick);
self.ships.insert(ship_id, ship);
*self.ships_per_player.entry(owner_id).or_insert(0) += 1;
debug!("Launched ship {} for player {} with {} troops", ship_id, owner_id, troops);
Some(ship_id)
}
/// Update all ships and return list of ships that arrived at destination
/// Returns: Vec<(owner_id, target_tile, troops)>
pub fn update_ships(&mut self) -> Vec<(u16, usize, u32)> {
let _guard = tracing::trace_span!("update_ships", ship_count = self.ships.len()).entered();
let mut arrivals = Vec::new();
let mut ships_to_remove = Vec::new();
for (ship_id, ship) in self.ships.iter_mut() {
if ship.update() {
// Ship has arrived at destination
arrivals.push((ship.owner_id, ship.target_tile, ship.troops));
ships_to_remove.push(*ship_id);
}
}
// Remove arrived ships
for ship_id in ships_to_remove {
if let Some(ship) = self.ships.remove(&ship_id) {
if let Some(count) = self.ships_per_player.get_mut(&ship.owner_id) {
*count = count.saturating_sub(1);
if *count == 0 {
self.ships_per_player.remove(&ship.owner_id);
}
}
debug!("Ship {} arrived at destination with {} troops", ship_id, ship.troops);
}
}
arrivals
}
/// Get all active ships
pub fn get_ships(&self) -> impl Iterator<Item = &TransportShip> {
self.ships.values()
}
/// Get ships for a specific player
pub fn get_player_ships(&self, player_id: u16) -> impl Iterator<Item = &TransportShip> {
self.ships.values().filter(move |ship| ship.owner_id == player_id)
}
/// Get number of ships for a player
pub fn get_ship_count(&self, player_id: u16) -> usize {
*self.ships_per_player.get(&player_id).unwrap_or(&0)
}
/// Remove all ships for a player (e.g., when player is eliminated)
pub fn remove_player_ships(&mut self, player_id: u16) {
let ships_to_remove: Vec<u32> = self.ships.iter().filter(|(_, ship)| ship.owner_id == player_id).map(|(id, _)| *id).collect();
for ship_id in ships_to_remove {
self.ships.remove(&ship_id);
}
self.ships_per_player.remove(&player_id);
}
/// Clear all ships
pub fn clear(&mut self) {
self.ships.clear();
self.ships_per_player.clear();
self.next_ship_id = 1;
}
/// Find the nearest coastal tile owned by a player to a target tile
/// Uses precomputed coastal tiles and player's border tiles for efficiency
/// Returns None if no valid coastal tile found
pub fn find_nearest_player_coastal_tile(coastal_tiles: &std::collections::HashSet<usize>, player_border_tiles: &std::collections::HashSet<usize>, target_tile: usize, size: glam::U16Vec2) -> Option<usize> {
let width = size.x as usize;
let height = size.y as usize;
let target_x = target_tile % width;
let target_y = target_tile / width;
debug!("Finding coastal tile: coastal_tiles.len={}, player_border_tiles.len={}, target_tile={}, size={}x{}", coastal_tiles.len(), player_border_tiles.len(), target_tile, width, height);
let mut best_tile = None;
let mut best_distance = usize::MAX;
let mut player_coastal_count = 0;
// Filter player's border tiles for coastal ones
for &tile in player_border_tiles {
// Check if this tile is in the coastal set
if coastal_tiles.contains(&tile) {
player_coastal_count += 1;
let tile_x = tile % width;
let tile_y = tile / width;
// Calculate Manhattan distance to target
let dist = ((tile_x as i32 - target_x as i32).abs() + (tile_y as i32 - target_y as i32).abs()) as usize;
if dist < best_distance {
best_distance = dist;
best_tile = Some(tile);
}
}
}
debug!("Found {} coastal tiles in player borders, best_tile={:?}", player_coastal_count, best_tile);
best_tile
}
}
impl Default for ShipManager {
fn default() -> Self {
Self::new()
}
}

View File

@@ -0,0 +1,319 @@
use crate::game::terrain::TerrainData;
use std::cmp::Ordering;
use std::collections::{BinaryHeap, HashMap, HashSet};
use tracing::debug;
/// A node in the pathfinding search
#[derive(Clone, Eq, PartialEq)]
struct PathNode {
tile_idx: usize,
g_cost: u32, // Cost from start
h_cost: u32, // Heuristic cost to goal
f_cost: u32, // Total cost (g + h)
parent: Option<usize>,
}
impl Ord for PathNode {
fn cmp(&self, other: &Self) -> Ordering {
// Reverse ordering for min-heap
other.f_cost.cmp(&self.f_cost)
}
}
impl PartialOrd for PathNode {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
/// Find a water path from start_tile to target_tile using A* algorithm
/// Returns None if no path exists
pub fn find_water_path(terrain: &TerrainData, start_tile: usize, target_tile: usize, max_path_length: usize) -> Option<Vec<usize>> {
let size = terrain.size();
let width = size.x as usize;
let _ = size.y as usize;
// 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 target_x = target_tile % width;
let target_y = target_tile / width;
let water_targets = if terrain.is_navigable(glam::U16Vec2::new(target_x as u16, target_y as u16)) { 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<usize, usize> = HashMap::new();
let mut g_scores: HashMap<usize, u32> = HashMap::new();
// Initialize with start node
let start_h = heuristic_distance(water_start, water_targets[0], size);
open_set.push(PathNode { tile_idx: water_start, g_cost: 0, h_cost: start_h, f_cost: start_h, parent: None });
g_scores.insert(water_start, 0);
while let Some(current_node) = open_set.pop() {
let current = current_node.tile_idx;
// Check if we've reached any of the target tiles
if water_targets.contains(&current) {
// Reconstruct path
let mut path = vec![current];
let mut current_tile = current;
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(glam::U16Vec2::new(target_x as u16, target_y as u16)) {
path.push(target_tile);
}
return Some(path);
}
// Skip if already processed
if closed_set.contains(&current) {
continue;
}
closed_set.insert(current);
// Check if we've exceeded max path length
if current_node.g_cost as usize > max_path_length {
continue;
}
// Explore neighbors
let neighbors = get_water_neighbors(terrain, current, size);
for neighbor in neighbors {
if closed_set.contains(&neighbor) {
continue;
}
let tentative_g = current_node.g_cost + 1;
if tentative_g < *g_scores.get(&neighbor).unwrap_or(&u32::MAX) {
came_from.insert(neighbor, current);
g_scores.insert(neighbor, tentative_g);
// Find best heuristic to any target
let h_cost = water_targets.iter().map(|&t| heuristic_distance(neighbor, t, size)).min().unwrap_or(0);
let f_cost = tentative_g + h_cost;
open_set.push(PathNode { tile_idx: neighbor, g_cost: tentative_g, h_cost, f_cost, parent: Some(current) });
}
}
}
debug!("Pathfinding failed: no path found from {} to {}", start_tile, target_tile);
None // No path found
}
/// Calculate heuristic distance between two tiles (Manhattan distance)
fn heuristic_distance(from: usize, to: usize, size: glam::U16Vec2) -> u32 {
let width = size.x as usize;
let from_x = from % width;
let from_y = from / width;
let to_x = to % width;
let to_y = to / width;
((from_x as i32 - to_x as i32).abs() + (from_y as i32 - to_y as i32).abs()) as u32
}
/// Get water neighbors of a tile (4-directional movement)
fn get_water_neighbors(terrain: &TerrainData, tile: usize, size: glam::U16Vec2) -> Vec<usize> {
let width = size.x as usize;
let height = size.y as usize;
let x = tile % width;
let y = tile / width;
let mut neighbors = Vec::with_capacity(4);
// Check 4 directions
let directions = [(0, -1), (1, 0), (0, 1), (-1, 0)];
for (dx, dy) in directions {
let nx = x as i32 + dx;
let ny = y as i32 + dy;
if nx >= 0 && ny >= 0 && nx < width as i32 && ny < height as i32 {
let neighbor_idx = nx as usize + ny as usize * width;
if terrain.is_navigable(glam::U16Vec2::new(nx as u16, ny as u16)) {
neighbors.push(neighbor_idx);
}
}
}
neighbors
}
/// Find a water tile adjacent to a coastal land tile for ship launch
fn find_water_launch_tile(terrain: &TerrainData, coast_tile: usize, size: glam::U16Vec2) -> Option<usize> {
let width = size.x as usize;
let height = size.y as usize;
let x = coast_tile % width;
let y = coast_tile / width;
debug!("find_water_launch_tile: checking coastal tile {} at ({},{})", coast_tile, x, y);
// Check 4 directions for water
let directions = [(0, -1), (1, 0), (0, 1), (-1, 0)];
for (dx, dy) in directions {
let nx = x as i32 + dx;
let ny = y as i32 + dy;
if nx >= 0 && ny >= 0 && nx < width as i32 && ny < height as i32 {
let neighbor_idx = nx as usize + ny as usize * width;
let is_water = terrain.is_navigable(glam::U16Vec2::new(nx as u16, ny as u16));
debug!(" Checking neighbor ({},{}) -> tile {}: is_water={}", nx, ny, neighbor_idx, is_water);
if is_water {
debug!(" Found water launch tile {} at ({},{})", neighbor_idx, nx, ny);
return Some(neighbor_idx);
}
}
}
debug!(" No water launch tile found for coastal tile {}", coast_tile);
None
}
/// Find all water tiles adjacent to a land tile
fn find_adjacent_water_tiles(terrain: &TerrainData, tile: usize, size: glam::U16Vec2) -> Vec<usize> {
let size_usize = size.as_uvec2();
let x = tile % size_usize.x as usize;
let y = tile / size_usize.x as usize;
let mut water_tiles = Vec::new();
// Check 4 directions for water
let directions = [(0, -1), (1, 0), (0, 1), (-1, 0)];
for (dx, dy) in directions {
let nx = x as i32 + dx;
let ny = y as i32 + dy;
if nx >= 0 && ny >= 0 && nx < size_usize.x as i32 && ny < size_usize.y as i32 {
let neighbor_idx = nx as usize + ny as usize * size_usize.x as usize;
if terrain.is_navigable(glam::U16Vec2::new(nx as u16, ny as u16)) {
water_tiles.push(neighbor_idx);
}
}
}
water_tiles
}
/// Check if a tile is a valid ship destination (water or coastal land)
fn is_valid_ship_destination(terrain: &TerrainData, tile: usize, size: glam::U16Vec2) -> bool {
let size_usize = size.as_uvec2();
let x = tile % size_usize.x as usize;
let y = tile / size_usize.x as usize;
// If it's water, it's valid
if terrain.is_navigable(glam::U16Vec2::new(x as u16, y as u16)) {
return true;
}
// If it's land, check if it's coastal
let directions = [(0, -1), (1, 0), (0, 1), (-1, 0)];
for (dx, dy) in directions {
let nx = x as i32 + dx;
let ny = y as i32 + dy;
if nx >= 0 && ny >= 0 && nx < size_usize.x as i32 && ny < size_usize.y as i32 && terrain.is_navigable(glam::U16Vec2::new(nx as u16, ny as u16)) {
return true; // It's coastal
}
}
false
}
/// Simplify a path by removing unnecessary waypoints (path smoothing)
/// This maintains determinism as it's purely geometric
pub fn smooth_path(path: Vec<usize>, terrain: &TerrainData, size: glam::U16Vec2) -> Vec<usize> {
if path.len() <= 2 {
return path;
}
let mut smoothed = vec![path[0]];
let mut current = 0;
while current < path.len() - 1 {
let mut farthest = current + 1;
// Find the farthest point we can see directly
for i in (current + 2)..path.len() {
if has_clear_water_line(terrain, path[current], path[i], size) {
farthest = i;
} else {
break;
}
}
smoothed.push(path[farthest]);
current = farthest;
}
smoothed
}
/// Check if there's a clear water line between two tiles
/// Uses Bresenham-like algorithm for deterministic line checking
fn has_clear_water_line(terrain: &TerrainData, from: usize, to: usize, size: glam::U16Vec2) -> bool {
let size_usize = size.as_uvec2();
let x0 = (from % size_usize.x as usize) as i32;
let y0 = (from / size_usize.x as usize) as i32;
let x1 = (to % size_usize.x as usize) as i32;
let y1 = (to / size_usize.x as usize) 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(glam::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;
}
}
}

View File

@@ -0,0 +1,88 @@
use serde::{Deserialize, Serialize};
/// A transport ship carrying troops across water
/// Uses fixed-point arithmetic for deterministic calculations
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TransportShip {
/// Unique identifier for this ship
pub id: u32,
/// Player who owns this ship
pub owner_id: u16,
/// Number of troops being transported (stored as integer)
pub troops: u32,
/// Pre-calculated path of tile indices
pub path: Vec<usize>,
/// Index of the current tile in the path
pub current_path_index: usize,
/// Movement speed in ticks per tile (e.g., 2 = move one tile every 2 ticks)
pub ticks_per_tile: u32,
/// Ticks since last tile transition
pub ticks_since_move: u32,
/// The tick when the ship was launched
pub launch_tick: u64,
/// The target tile (final destination)
pub target_tile: usize,
}
impl TransportShip {
/// Create a new transport ship
pub fn new(id: u32, owner_id: u16, troops: u32, path: Vec<usize>, ticks_per_tile: u32, launch_tick: u64) -> Self {
let target_tile = *path.last().unwrap_or(&path[0]);
Self { id, owner_id, troops, path, current_path_index: 0, ticks_per_tile, ticks_since_move: 0, launch_tick, target_tile }
}
/// Update the ship's position based on the current tick
/// Returns true if the ship has reached its destination
pub fn update(&mut self) -> bool {
if self.has_arrived() {
return true;
}
self.ticks_since_move += 1;
// Check if it's time to move to the next tile
if self.ticks_since_move >= self.ticks_per_tile {
self.ticks_since_move = 0;
self.current_path_index += 1;
if self.has_arrived() {
return true;
}
}
false
}
/// Get the current tile the ship is on
pub fn get_current_tile(&self) -> usize {
if self.current_path_index < self.path.len() { self.path[self.current_path_index] } else { self.target_tile }
}
/// Check if the ship has reached its destination
pub fn has_arrived(&self) -> bool {
self.current_path_index >= self.path.len() - 1
}
/// Get interpolation factor for smooth rendering (0.0 to 1.0)
/// This is only for visual interpolation and doesn't affect game logic
pub fn get_visual_interpolation(&self) -> f32 {
if self.ticks_per_tile == 0 {
return 1.0;
}
self.ticks_since_move as f32 / self.ticks_per_tile as f32
}
/// Get the next tile in the path (for interpolation)
pub fn get_next_tile(&self) -> Option<usize> {
if self.current_path_index + 1 < self.path.len() { Some(self.path[self.current_path_index + 1]) } else { None }
}
}

View File

@@ -0,0 +1,78 @@
use bevy_ecs::prelude::*;
/// Represents a spawn point for a player or bot
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SpawnPoint {
pub player_id: usize,
pub tile_index: usize,
}
impl SpawnPoint {
pub fn new(player_id: usize, tile_index: usize) -> Self {
Self { player_id, tile_index }
}
}
/// 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: usize, tile_index: usize, bot_manager: &crate::game::BotManager, territory_manager: &crate::game::TerritoryManager) {
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.player_id == player_id) {
*entry = spawn_point;
} else {
self.player_spawns.push(spawn_point);
}
// Recalculate bot spawns with updated player positions
self.current_bot_spawns = bot_manager.recalculate_spawns_with_players(self.initial_bot_spawns.clone(), &self.player_spawns, territory_manager, self.rng_seed);
}
/// Get all current spawn positions (players + bots)
pub fn get_all_spawns(&self) -> Vec<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,74 @@
use bevy_ecs::prelude::*;
/// Tracks spawn phase timeout state on the client side
///
/// This resource is used to:
/// - Show countdown timer in UI
/// - Know when spawn phase is active
/// - Calculate remaining time for display
#[derive(Resource)]
pub struct SpawnTimeout {
/// Whether spawn phase is currently active
pub active: bool,
/// Accumulated time since start (seconds)
pub elapsed_secs: f32,
/// Total timeout duration in seconds
pub duration_secs: f32,
/// Remaining time in seconds (updated each frame)
pub remaining_secs: f32,
}
impl Default for SpawnTimeout {
fn default() -> Self {
Self {
active: false,
elapsed_secs: 0.0,
duration_secs: 5.0, // Local mode: 5 seconds
remaining_secs: 5.0,
}
}
}
impl SpawnTimeout {
/// Create a new spawn timeout with specified duration
pub fn new(duration_secs: f32) -> Self {
Self { active: false, elapsed_secs: 0.0, duration_secs, remaining_secs: duration_secs }
}
/// Start the timeout countdown
pub fn start(&mut self) {
if self.elapsed_secs == 0.0 {
self.active = true;
self.elapsed_secs = 0.0;
self.remaining_secs = self.duration_secs;
}
}
/// Update remaining time (call each frame with delta time)
pub fn update(&mut self, delta_secs: f32) {
if !self.active {
return;
}
self.elapsed_secs += delta_secs;
self.remaining_secs = (self.duration_secs - self.elapsed_secs).max(0.0);
if self.remaining_secs <= 0.0 {
self.active = false;
}
}
/// Stop the timeout
pub fn stop(&mut self) {
self.active = false;
self.elapsed_secs = 0.0;
}
/// Check if timeout has expired
pub fn has_expired(&self) -> bool {
!self.active && self.remaining_secs <= 0.0
}
}

View File

@@ -0,0 +1,303 @@
use bevy_ecs::prelude::Resource;
use glam::UVec2;
use image::GenericImageView;
use serde::{Deserialize, Serialize};
use std::fs;
use tracing::{debug, info};
use crate::game::territory::get_idx;
use crate::game::tilemap::TileMap;
/// Calculate terrain color using pastel theme formulas
fn calculate_theme_color(color_base: &str, color_variant: u8) -> [u8; 3] {
let i = color_variant as i32;
match color_base {
"grass" => {
// rgb(238 - 2 * i, 238 - 2 * i, 190 - i)
[(238 - 2 * i).clamp(0, 255) as u8, (238 - 2 * i).clamp(0, 255) as u8, (190 - i).clamp(0, 255) as u8]
}
"mountain" => {
// rgb(250 - 2 * i, 250 - 2 * i, 220 - i)
[(250 - 2 * i).clamp(0, 255) as u8, (250 - 2 * i).clamp(0, 255) as u8, (220 - i).clamp(0, 255) as u8]
}
"water" => {
// rgb(172 - 2 * i, 225 - 2 * i, 249 - 3 * i)
[(172 - 2 * i).clamp(0, 255) as u8, (225 - 2 * i).clamp(0, 255) as u8, (249 - 3 * i).clamp(0, 255) as u8]
}
_ => {
// Default fallback color (gray)
[128, 128, 128]
}
}
}
/// Helper structs for loading World.json format
#[derive(Deserialize)]
struct WorldMapJson {
tiles: Vec<WorldTileDef>,
}
#[derive(Deserialize)]
struct WorldTileDef {
color: String,
name: String,
#[serde(default, rename = "colorBase")]
color_base: Option<String>,
#[serde(default, rename = "colorVariant")]
color_variant: Option<u32>,
conquerable: bool,
navigable: bool,
#[serde(default, rename = "expansionCost")]
expansion_cost: Option<u32>,
#[serde(default, rename = "expansionTime")]
expansion_time: Option<u32>,
}
/// Parse hex color string (#RRGGBB) to RGB bytes
fn parse_hex_rgb(s: &str) -> Option<[u8; 3]> {
let s = s.trim_start_matches('#');
if s.len() != 6 {
return None;
}
let r = u8::from_str_radix(&s[0..2], 16).ok()?;
let g = u8::from_str_radix(&s[2..4], 16).ok()?;
let b = u8::from_str_radix(&s[4..6], 16).ok()?;
Some([r, g, b])
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TileType {
pub name: String,
pub color_base: String,
pub color_variant: u8,
pub conquerable: bool,
pub navigable: bool,
pub expansion_time: u8,
pub expansion_cost: u8,
}
/// Map manifest structure
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct MapManifest {
pub map: MapMetadata,
pub name: String,
pub nations: Vec<NationSpawn>,
}
/// Map size metadata
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct MapMetadata {
pub size: glam::U16Vec2,
pub num_land_tiles: usize,
}
impl MapMetadata {
/// Get the width of the map
pub fn width(&self) -> u16 {
self.size.x
}
/// Get the height of the map
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 })
}
/// Load a map from the resources directory
pub fn load(map_name: &str) -> Result<Self, Box<dyn std::error::Error>> {
let base_path = format!("resources/maps/{}", map_name);
// Load manifest
let manifest_path = format!("{}/manifest.json", base_path);
let manifest_json = fs::read_to_string(&manifest_path)?;
let manifest: MapManifest = serde_json::from_str(&manifest_json)?;
// Load binary map data
let map_path = format!("{}/map.bin", base_path);
let terrain_data_raw = fs::read(&map_path)?;
let width = manifest.map.width();
let height = manifest.map.height();
// Verify data size
if terrain_data_raw.len() != (width as usize) * (height as usize) {
return Err(format!("Map data size mismatch: expected {} bytes, got {}", (width as usize) * (height as usize), terrain_data_raw.len()).into());
}
info!("Loaded map '{}' ({}x{})", manifest.name, width, height);
debug!("Land tiles: {}/{}", manifest.map.num_land_tiles, (width as usize) * (height as usize));
// Create default tile types for legacy format
let tile_types = vec![TileType { name: "water".to_string(), color_base: "water".to_string(), color_variant: 0, conquerable: false, navigable: true, expansion_time: 255, expansion_cost: 255 }, TileType { name: "land".to_string(), color_base: "grass".to_string(), color_variant: 0, conquerable: true, navigable: false, expansion_time: 50, expansion_cost: 50 }];
// Convert legacy format to tile indices
let tiles: Vec<u8> = terrain_data_raw.iter().map(|&byte| if byte & 0x80 != 0 { 1 } else { 0 }).collect();
// Create TileMap from terrain data
let terrain_data = TileMap::from_vec(width, height, terrain_data_raw);
Ok(Self { _manifest: manifest, terrain_data, tiles, tile_types })
}
/// Get the size of the map
pub fn size(&self) -> glam::U16Vec2 {
self.terrain_data.size()
}
pub fn get_value<T: Into<UVec2>>(&self, pos: T) -> u8 {
let pos = pos.into();
let pos16 = glam::U16Vec2::new(pos.x as u16, pos.y as u16);
self.terrain_data[get_idx(pos16, self.terrain_data.width())]
}
/// Check if a tile is land (bit 7 set)
pub fn is_land<T: Into<UVec2>>(&self, pos: T) -> bool {
self.get_value(pos) & 0x80 != 0
}
/// Get terrain magnitude (bits 0-4)
pub fn terrain_magnitude<T: Into<UVec2>>(&self, pos: T) -> u8 {
self.get_value(pos) & 0b00011111
}
/// Get tile type at position
pub fn get_tile_type<T: Into<UVec2>>(&self, pos: T) -> &TileType {
let pos = pos.into();
let pos16 = glam::U16Vec2::new(pos.x as u16, pos.y as u16);
let idx = get_idx(pos16, self.terrain_data.width());
&self.tile_types[self.tiles[idx] as usize]
}
/// Check if a tile is conquerable
pub fn is_conquerable<T: Into<UVec2>>(&self, pos: T) -> bool {
self.get_tile_type(pos).conquerable
}
/// Check if a tile is navigable (water)
pub fn is_navigable(&self, pos: glam::U16Vec2) -> bool {
let pos_u32 = UVec2::new(pos.x as u32, pos.y as u32);
self.get_tile_type(pos_u32).navigable
}
/// Get expansion time for a tile
pub fn get_expansion_time<T: Into<UVec2>>(&self, pos: T) -> u8 {
self.get_tile_type(pos).expansion_time
}
/// Get expansion cost for a tile
pub fn get_expansion_cost<T: Into<UVec2>>(&self, pos: T) -> u8 {
self.get_tile_type(pos).expansion_cost
}
/// Get tile type IDs for rendering (each position maps to a tile type)
pub fn get_tile_ids(&self) -> &[u8] {
&self.tiles
}
/// Get terrain palette colors from tile types (for rendering)
/// Returns a vec where index = tile type ID, value = RGB color
/// Colors are calculated using theme formulas based on colorBase and colorVariant
pub fn get_terrain_palette_colors(&self) -> Vec<[u8; 3]> {
self.tile_types.iter().map(|tile_type| calculate_theme_color(&tile_type.color_base, tile_type.color_variant)).collect()
}
}

View File

@@ -0,0 +1,24 @@
use glam::{I16Vec2, U16Vec2};
/// Convert 2D coordinates to a flat array index
///
/// Note: Prefer using `TileMap::pos_to_index()` for better type safety.
pub fn get_idx<T: Into<U16Vec2>>(pos: T, width: u16) -> usize {
let pos = pos.into();
pos.x as usize + pos.y as usize * width as usize
}
const CARDINAL_DIRECTIONS: [I16Vec2; 4] = [I16Vec2::new(0, 1), I16Vec2::new(1, 0), I16Vec2::new(0, -1), I16Vec2::new(-1, 0)];
/// Returns an iterator over the valid cardinal neighbors of a tile.
///
/// Requires the width and height of the map to be passed in to ensure the neighbor is within bounds.
pub fn get_neighbors<P: Into<U16Vec2>>(pos: P, width: u16, height: u16) -> impl Iterator<Item = U16Vec2> {
let pos = pos.into();
let in_bounds = move |neighbor: I16Vec2| neighbor.x >= 0 && neighbor.y >= 0 && neighbor.x < width as i16 && neighbor.y < height as i16;
CARDINAL_DIRECTIONS.into_iter().filter_map(move |dir| {
let neighbor = pos.as_i16vec2().saturating_add(dir);
in_bounds(neighbor).then_some(neighbor.as_u16vec2())
})
}

View File

@@ -0,0 +1,213 @@
use crate::game::tile_ownership::{ENCODED_WATER, TileOwnership};
use crate::game::tilemap::TileMap;
use crate::game::tilemap_changes::ChangeBuffer;
/// Deprecated: Use TileOwnership::Water instead
/// Kept for backward compatibility during migration
pub const OWNER_WATER: u16 = ENCODED_WATER;
/// Manages territory ownership for all tiles
pub struct TerritoryManager {
tile_owners: TileMap<TileOwnership>,
changes: ChangeBuffer,
/// Cached u16 representation for efficient serialization to frontend
u16_cache: Vec<u16>,
cache_dirty: bool,
}
impl TerritoryManager {
/// Creates a new territory manager
pub fn new(width: 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
/// Should only be called when a new game is started
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();
for (i, &conquerable) in conquerable_tiles.iter().enumerate() {
if !conquerable {
self.tile_owners[i] = TileOwnership::Water;
}
}
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: usize) -> bool {
let pos = self.tile_owners.index_to_pos(tile);
let owner = self.tile_owners[tile];
// Border if on map edge
if pos.x == 0 || pos.x == self.tile_owners.width() - 1 || pos.y == 0 || pos.y == self.tile_owners.height() - 1 {
return true;
}
// Border if any neighbor has different owner
for neighbor_pos in self.tile_owners.neighbors(pos) {
if self.tile_owners[neighbor_pos] != owner {
return true;
}
}
false
}
/// Checks if a tile has an owner
pub fn has_owner(&self, tile: usize) -> bool {
self.tile_owners[tile].is_owned()
}
/// Checks if a tile is owned by a specific player
pub fn is_owner(&self, tile: usize, owner: usize) -> bool {
self.tile_owners[tile].is_owned_by(owner as u16)
}
/// Gets the owner of a tile as u16 (for compatibility)
/// Returns the encoded u16 value (nation_id, or ENCODED_UNCLAIMED/ENCODED_WATER)
pub fn get_owner(&self, tile: usize) -> u16 {
self.tile_owners[tile].to_u16()
}
/// Gets the ownership enum for a tile
pub fn get_ownership(&self, tile: usize) -> TileOwnership {
self.tile_owners[tile]
}
/// Checks if a tile is water
pub fn is_water(&self, tile: usize) -> bool {
self.tile_owners[tile].is_water()
}
/// Conquers a tile for a player
/// If the tile is already owned by a player, that player will lose the tile
/// Only records a change if the owner actually changed
pub fn conquer(&mut self, tile: usize, owner: usize) -> u16 {
let previous_owner = self.tile_owners[tile];
let new_ownership = TileOwnership::Owned(owner as u16);
// Only update and track change if the owner actually changed
if previous_owner != new_ownership {
self.tile_owners[tile] = new_ownership;
self.changes.push(tile);
self.cache_dirty = true;
}
previous_owner.to_u16()
}
/// Clears a tile (removes ownership)
pub fn clear(&mut self, tile: usize) -> Option<u16> {
let ownership = self.tile_owners[tile];
if ownership.is_owned() {
self.tile_owners[tile] = TileOwnership::Unclaimed;
self.changes.push(tile);
self.cache_dirty = true;
ownership.nation_id()
} else {
None
}
}
/// Get the size of the map as U16Vec2
pub fn size(&self) -> glam::U16Vec2 {
self.tile_owners.size()
}
/// Get width of the map
pub fn width(&self) -> u16 {
self.tile_owners.width()
}
/// Get height of the map
pub fn height(&self) -> u16 {
self.tile_owners.height()
}
/// Returns a reference to the underlying tile ownership data as a slice of enums
pub fn as_slice(&self) -> &[TileOwnership] {
self.tile_owners.as_slice()
}
/// Returns the tile ownership data as u16 values for frontend serialization
/// This is cached and only recomputed when ownership changes
pub fn as_u16_slice(&mut self) -> &[u16] {
if self.cache_dirty {
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.to_u16();
}
self.cache_dirty = false;
}
&self.u16_cache
}
/// Converts tile ownership to a Vec<u16> for serialization (immutable)
/// Use this when you need a Vec for creating Arc<[u16]>
pub fn to_u16_vec(&self) -> Vec<u16> {
let tile_count = self.tile_owners.len();
let _guard = tracing::trace_span!("to_u16_vec", tile_count).entered();
self.tile_owners.as_slice().iter().map(|ownership| ownership.to_u16()).collect()
}
/// Returns the number of tiles in the map
pub fn len(&self) -> usize {
self.tile_owners.len()
}
/// Returns true if the map has no tiles
pub fn is_empty(&self) -> bool {
self.tile_owners.len() == 0
}
/// Returns an iterator over changed tile indices without consuming them
/// Use this to read changes without clearing the buffer
pub fn iter_changes(&self) -> impl Iterator<Item = usize> + '_ {
self.changes.iter()
}
/// Drains all changed tile indices, returning an iterator and clearing the change buffer
pub fn drain_changes(&mut self) -> impl Iterator<Item = usize> + '_ {
self.changes.drain()
}
/// Returns true if any territory changes have been recorded since last drain
pub fn has_changes(&self) -> bool {
self.changes.has_changes()
}
/// Clears all tracked changes without returning them
pub fn clear_changes(&mut self) {
self.changes.clear()
}
/// Calls a closure for each neighbor using tile indices
pub fn on_neighbor_indices<F>(&self, index: usize, closure: F)
where
F: FnMut(usize),
{
self.tile_owners.on_neighbor_indices(index, closure)
}
/// Checks if any neighbor has a different owner than the specified owner
/// This is optimized for border detection with early exit
pub fn any_neighbor_has_different_owner(&self, tile: usize, owner: u16) -> bool {
let mut has_different = false;
self.on_neighbor_indices(tile, |neighbor| {
if !has_different && self.tile_owners[neighbor].to_u16() != owner {
has_different = true;
}
});
has_different
}
}

View File

@@ -0,0 +1,159 @@
//! Tile ownership representation
//!
//! This module defines how tiles are owned and what their terrain type is.
//! It separates the concept of "who owns this tile" from "what type of terrain is this".
//!
//! ## Encoding
//!
//! For frontend serialization, TileOwnership is encoded as a u16:
//! - 0-65533: Nation IDs (supports 400+ nations)
//! - 65534: Unclaimed land
//! - 65535: Water (unconquerable terrain)
use serde::{Deserialize, Serialize};
/// Encoded value for unclaimed land in u16 representation
pub const ENCODED_UNCLAIMED: u16 = 65534;
/// Encoded value for water in u16 representation
/// Kept at 65535 for backward compatibility with OWNER_WATER
pub const ENCODED_WATER: u16 = 65535;
/// Represents the ownership/state of a single tile
///
/// This enum clearly separates nation ownership from terrain type,
/// allowing nation ID 0 to be valid without confusion with wilderness.
#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize, Deserialize, Default)]
pub enum TileOwnership {
/// Owned by a nation (0-65533, supporting 65,534 possible nations)
Owned(u16),
/// Unclaimed but conquerable land
#[default]
Unclaimed,
/// Water (unconquerable terrain)
Water,
}
impl TileOwnership {
/// Convert to u16 for frontend serialization
pub fn to_u16(self) -> u16 {
match self {
TileOwnership::Owned(nation_id) => nation_id,
TileOwnership::Unclaimed => ENCODED_UNCLAIMED,
TileOwnership::Water => ENCODED_WATER,
}
}
/// Convert from u16 (frontend serialization format)
pub fn from_u16(value: u16) -> Self {
match value {
ENCODED_WATER => TileOwnership::Water,
ENCODED_UNCLAIMED => TileOwnership::Unclaimed,
nation_id => TileOwnership::Owned(nation_id),
}
}
/// Check if this tile is owned by any nation
pub fn is_owned(self) -> bool {
matches!(self, TileOwnership::Owned(_))
}
/// Check if this tile is unclaimed land
pub fn is_unclaimed(self) -> bool {
matches!(self, TileOwnership::Unclaimed)
}
/// Check if this tile is water
pub fn is_water(self) -> bool {
matches!(self, TileOwnership::Water)
}
/// Get the nation ID if this tile is owned, otherwise None
pub fn nation_id(self) -> Option<u16> {
match self {
TileOwnership::Owned(id) => Some(id),
_ => None,
}
}
/// Check if this tile is owned by a specific nation
pub fn is_owned_by(self, nation_id: u16) -> bool {
matches!(self, TileOwnership::Owned(id) if id == nation_id)
}
}
impl From<u16> for TileOwnership {
fn from(value: u16) -> Self {
Self::from_u16(value)
}
}
impl From<TileOwnership> for u16 {
fn from(ownership: TileOwnership) -> Self {
ownership.to_u16()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_encoding_nation_ids() {
for nation_id in [0, 1, 100, 1000, 65533] {
let ownership = TileOwnership::Owned(nation_id);
let encoded = ownership.to_u16();
assert_eq!(encoded, nation_id);
assert_eq!(TileOwnership::from_u16(encoded), ownership);
}
}
#[test]
fn test_encoding_unclaimed() {
let ownership = TileOwnership::Unclaimed;
let encoded = ownership.to_u16();
assert_eq!(encoded, ENCODED_UNCLAIMED);
assert_eq!(TileOwnership::from_u16(encoded), ownership);
}
#[test]
fn test_encoding_water() {
let ownership = TileOwnership::Water;
let encoded = ownership.to_u16();
assert_eq!(encoded, ENCODED_WATER);
assert_eq!(TileOwnership::from_u16(encoded), ownership);
}
#[test]
fn test_is_owned() {
assert!(TileOwnership::Owned(0).is_owned());
assert!(TileOwnership::Owned(100).is_owned());
assert!(!TileOwnership::Unclaimed.is_owned());
assert!(!TileOwnership::Water.is_owned());
}
#[test]
fn test_is_owned_by() {
assert!(TileOwnership::Owned(5).is_owned_by(5));
assert!(!TileOwnership::Owned(5).is_owned_by(6));
assert!(!TileOwnership::Unclaimed.is_owned_by(5));
assert!(!TileOwnership::Water.is_owned_by(5));
}
#[test]
fn test_nation_id() {
assert_eq!(TileOwnership::Owned(42).nation_id(), Some(42));
assert_eq!(TileOwnership::Unclaimed.nation_id(), None);
assert_eq!(TileOwnership::Water.nation_id(), None);
}
#[test]
fn test_nation_zero_is_valid() {
let ownership = TileOwnership::Owned(0);
assert!(ownership.is_owned());
assert!(ownership.is_owned_by(0));
assert_eq!(ownership.nation_id(), Some(0));
assert_ne!(ownership, TileOwnership::Unclaimed);
assert_ne!(ownership, TileOwnership::Water);
}
}

View File

@@ -0,0 +1,488 @@
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) -> usize {
let pos = pos.into();
debug_assert!(pos.x < self.size.x && pos.y < self.size.y);
(pos.y as usize) * (self.size.x as usize) + (pos.x as usize)
}
/// Converts a flat array index to a 2D position.
#[inline]
pub fn index_to_pos(&self, index: usize) -> U16Vec2 {
debug_assert!(index < self.tiles.len());
let width = self.size.x as usize;
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)]) } 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);
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> + '_ {
const CARDINAL_DIRECTIONS: [(i32, i32); 4] = [(0, 1), (1, 0), (0, -1), (-1, 0)];
let pos = pos.into();
let pos_i32 = (pos.x as i32, pos.y as i32);
let width = self.size.x;
let height = self.size.y;
CARDINAL_DIRECTIONS.iter().filter_map(move |(dx, dy)| {
let nx = pos_i32.0 + dx;
let ny = pos_i32.1 + dy;
if nx >= 0 && ny >= 0 && nx < width as i32 && ny < height as i32 { Some(U16Vec2::new(nx as u16, ny as u16)) } else { None }
})
}
/// Calls a closure for each valid cardinal neighbor of a position.
///
/// This is more efficient than using the `neighbors()` iterator when you don't
/// need to collect the neighbors.
pub fn on_neighbors<P: Into<U16Vec2>, F>(&self, pos: P, mut closure: F)
where
F: FnMut(U16Vec2),
{
let pos = pos.into();
if pos.x > 0 {
closure(U16Vec2::new(pos.x - 1, pos.y));
}
if pos.x < self.size.x - 1 {
closure(U16Vec2::new(pos.x + 1, pos.y));
}
if pos.y > 0 {
closure(U16Vec2::new(pos.x, pos.y - 1));
}
if pos.y < self.size.y - 1 {
closure(U16Vec2::new(pos.x, pos.y + 1));
}
}
/// Calls a closure for each neighbor using tile indices instead of positions.
///
/// This is useful when working with systems that still use raw indices.
pub fn on_neighbor_indices<F>(&self, index: usize, mut closure: F)
where
F: FnMut(usize),
{
let width = self.size.x as usize;
let height = self.size.y as usize;
let x = index % width;
let y = index / width;
if x > 0 {
closure(index - 1);
}
if x < width - 1 {
closure(index + 1);
}
if y > 0 {
closure(index - width);
}
if y < height - 1 {
closure(index + width);
}
}
/// Returns an iterator over all positions and their tile values.
pub fn iter(&self) -> impl Iterator<Item = (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);
(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)]
}
}
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);
&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)]
}
}
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);
&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_neighbors() {
let map = TileMap::<u8>::with_default(10, 10, 0);
let mut count = 0;
map.on_neighbors(U16Vec2::new(5, 5), |_| count += 1);
assert_eq!(count, 4);
}
#[test]
fn test_on_neighbor_indices() {
let map = TileMap::<u8>::with_default(10, 10, 0);
let center_idx = map.pos_to_index(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,186 @@
/// 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: std::collections::HashSet<usize>,
}
impl ChangeBuffer {
/// Creates a new empty ChangeBuffer.
pub fn new() -> Self {
Self { changed_indices: std::collections::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: std::collections::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, index: usize) {
self.changed_indices.insert(index);
}
/// Returns an iterator over changed indices without consuming them.
///
/// Use this when you need to read changes without clearing the buffer.
/// The buffer will still contain all changes after iteration.
pub fn iter(&self) -> impl Iterator<Item = usize> + '_ {
self.changed_indices.iter().copied()
}
/// Drains all changed indices, returning an iterator and clearing the buffer.
///
/// The buffer retains its capacity for reuse.
pub fn drain(&mut self) -> impl Iterator<Item = usize> + '_ {
self.changed_indices.drain()
}
/// Clears all tracked changes without returning them.
///
/// The buffer retains its capacity for reuse.
pub fn clear(&mut self) {
self.changed_indices.clear();
}
/// Returns true if any changes have been recorded.
#[inline]
pub fn has_changes(&self) -> bool {
!self.changed_indices.is_empty()
}
/// Returns the number of changes recorded.
///
/// Note: This may include duplicate indices if the same tile was changed multiple times.
#[inline]
pub fn len(&self) -> usize {
self.changed_indices.len()
}
/// Returns true if no changes have been recorded.
#[inline]
pub fn is_empty(&self) -> bool {
self.changed_indices.is_empty()
}
/// Returns the current capacity of the internal buffer.
#[inline]
pub fn capacity(&self) -> usize {
self.changed_indices.capacity()
}
/// Returns the number of unique changes (alias for len for clarity)
#[inline]
pub fn unique_count(&self) -> usize {
self.changed_indices.len()
}
}
impl Default for ChangeBuffer {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_new() {
let buffer = ChangeBuffer::new();
assert!(buffer.is_empty());
assert_eq!(buffer.len(), 0);
}
#[test]
fn test_with_capacity() {
let buffer = ChangeBuffer::with_capacity(100);
assert_eq!(buffer.capacity(), 100);
assert!(buffer.is_empty());
}
#[test]
fn test_push_and_drain() {
let mut buffer = ChangeBuffer::new();
buffer.push(10);
buffer.push(25);
buffer.push(42);
assert_eq!(buffer.len(), 3);
assert!(buffer.has_changes());
let changes: Vec<_> = buffer.drain().collect();
assert_eq!(changes, vec![10, 25, 42]);
assert!(buffer.is_empty());
}
#[test]
fn test_clear() {
let mut buffer = ChangeBuffer::new();
buffer.push(1);
buffer.push(2);
buffer.push(3);
assert_eq!(buffer.len(), 3);
buffer.clear();
assert_eq!(buffer.len(), 0);
assert!(buffer.is_empty());
}
#[test]
fn test_duplicate_indices() {
let mut buffer = ChangeBuffer::new();
buffer.push(10);
buffer.push(10);
buffer.push(10);
assert_eq!(buffer.len(), 1); // Automatically deduplicates
let changes: Vec<_> = buffer.drain().collect();
assert_eq!(changes.len(), 1); // Only one unique entry
assert!(changes.contains(&10));
}
#[test]
fn test_capacity_retained_after_drain() {
let mut buffer = ChangeBuffer::with_capacity(100);
buffer.push(1);
buffer.push(2);
let initial_capacity = buffer.capacity();
let _: Vec<_> = buffer.drain().collect();
// Capacity should be retained after drain
assert!(buffer.capacity() >= initial_capacity);
assert!(buffer.is_empty());
}
}

View File

@@ -0,0 +1,34 @@
use bevy_ecs::prelude::*;
use crate::networking::Turn;
/// Resource containing the current turn data
/// Updated once per turn (10 TPS), provides turn context to all gameplay systems
#[derive(Resource)]
pub struct CurrentTurn {
pub turn: Turn,
/// Flag indicating if this turn has been processed by gameplay systems
/// Set to false when turn arrives, set to true after all systems run
pub processed: bool,
}
impl CurrentTurn {
pub fn new(turn: Turn) -> Self {
Self { turn, processed: false }
}
/// Mark turn as processed
pub fn mark_processed(&mut self) {
self.processed = true;
}
/// Check if turn is ready to process (not yet processed)
pub fn is_ready(&self) -> bool {
!self.processed
}
}
/// Run condition: only run when a turn is ready to process
pub fn turn_is_ready(current_turn: Option<Res<CurrentTurn>>) -> bool {
current_turn.is_some_and(|ct| ct.is_ready())
}

View File

@@ -0,0 +1,67 @@
/// Utility functions for tile operations
///
/// Call a closure for each neighbor of a tile
///
/// Handles boundary checks for the 4-connected grid (up, down, left, right)
pub fn for_each_neighbor<F>(tile: usize, size: glam::U16Vec2, mut closure: F)
where
F: FnMut(usize),
{
let size_usize = size.as_uvec2();
let x = tile % size_usize.x as usize;
let y = tile / size_usize.x as usize;
// Left neighbor
if x > 0 {
closure(tile - 1);
}
// Right neighbor
if x < size_usize.x as usize - 1 {
closure(tile + 1);
}
// Top neighbor
if y > 0 {
closure(tile - size_usize.x as usize);
}
// Bottom neighbor
if y < size_usize.y as usize - 1 {
closure(tile + size_usize.x as usize);
}
}
/// Check if a tile has at least one neighbor matching the given predicate
pub fn has_neighbor_owned_by(tile: usize, size: glam::U16Vec2, is_owner: impl Fn(usize) -> bool) -> bool {
let mut has_neighbor = false;
for_each_neighbor(tile, size, |neighbor| {
if is_owner(neighbor) {
has_neighbor = true;
}
});
has_neighbor
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_corner_tile_neighbors() {
let mut neighbors = Vec::new();
for_each_neighbor(0, glam::U16Vec2::new(10, 10), |n| neighbors.push(n));
assert_eq!(neighbors, vec![1, 10]); // Right and down only
}
#[test]
fn test_edge_tile_neighbors() {
let mut neighbors = Vec::new();
for_each_neighbor(5, glam::U16Vec2::new(10, 10), |n| neighbors.push(n));
assert_eq!(neighbors, vec![4, 6, 15]); // Left, right, and down
}
#[test]
fn test_center_tile_neighbors() {
let mut neighbors = Vec::new();
for_each_neighbor(55, glam::U16Vec2::new(10, 10), |n| neighbors.push(n));
assert_eq!(neighbors, vec![54, 56, 45, 65]); // All 4 directions
}
}

View File

@@ -0,0 +1,18 @@
pub mod app;
pub mod build_info;
pub mod constants;
pub mod game;
pub mod networking;
pub mod platform;
pub mod plugin;
pub mod telemetry;
pub mod time;
#[cfg(feature = "ui")]
pub mod ui;
pub use constants::*;
pub use game::*;
pub use networking::*;
pub use plugin::*;
#[cfg(feature = "ui")]
pub use ui::{LastAttacksDigest, LastLeaderboardDigest, NationHighlightState, input};

View File

@@ -0,0 +1,103 @@
use crate::networking::{GameView, Intent, IntentEvent, ProcessTurnEvent, protocol::NetMessage};
use bevy_ecs::prelude::*;
use flume::{Receiver, Sender};
use tracing::{debug, error, info, warn};
#[derive(Resource)]
pub struct ClientConnection {
pub intent_tx: Sender<Intent>,
pub game_view_rx: Receiver<GameView>,
}
#[derive(Resource)]
pub struct RemoteClientConnection {
pub intent_tx: Sender<NetMessage>,
pub net_message_rx: Receiver<NetMessage>,
pub player_id: Option<u16>,
}
pub fn receive_game_view_system(client: Res<ClientConnection>, mut game_view: ResMut<GameView>) {
while let Ok(new_view) = client.game_view_rx.try_recv() {
*game_view = new_view;
}
}
pub fn send_intent_system(mut intent_events: MessageReader<IntentEvent>, client: Res<ClientConnection>) {
for event in intent_events.read() {
debug!("Sending intent to local server: {:?}", event.0);
if let Err(e) = client.intent_tx.try_send(event.0.clone()) {
error!("Failed to send intent: {:?}", e);
}
}
}
/// System for remote clients to handle NetMessage protocol
pub fn receive_net_message_system(remote_client: Res<RemoteClientConnection>, mut process_turn_writer: MessageWriter<ProcessTurnEvent>, mut spawn_config_writer: MessageWriter<crate::networking::SpawnConfigEvent>) {
let mut message_count = 0;
while let Ok(message) = remote_client.net_message_rx.try_recv() {
message_count += 1;
match message {
NetMessage::ServerConfig { player_id } => {
info!("Received server config: player_id={}", player_id);
// Store player_id in the resource (would need to make it mutable)
}
NetMessage::Turn { turn, intents } => {
info!("Received turn {} with {} intents", turn, intents.len());
// Convert to ProcessTurnEvent
let turn_event = ProcessTurnEvent(crate::networking::Turn { turn_number: turn, intents });
process_turn_writer.write(turn_event);
}
NetMessage::Intent(_) => {
warn!("Received Intent message on client side");
}
NetMessage::SpawnConfiguration { spawns } => {
info!("Received spawn configuration with {} spawns", spawns.len());
spawn_config_writer.write(crate::networking::SpawnConfigEvent(spawns));
}
}
}
if message_count > 0 {
let _guard = tracing::debug_span!("receive_net_messages", message_count).entered();
}
}
/// System for remote clients to send intents as NetMessage
pub fn send_net_intent_system(mut intent_events: MessageReader<IntentEvent>, remote_client: Res<RemoteClientConnection>) {
let mut intent_count = 0;
for event in intent_events.read() {
intent_count += 1;
let net_message = NetMessage::Intent(event.0.clone());
if let Err(e) = remote_client.intent_tx.try_send(net_message) {
error!("Failed to send net intent: {:?}", e);
}
}
if intent_count > 0 {
let _guard = tracing::debug_span!("send_net_intents", intent_count).entered();
}
}
/// System to handle spawn configuration updates from server
/// Updates local SpawnManager with remote player spawn positions
pub fn handle_spawn_config_system(mut spawn_config_events: MessageReader<crate::networking::SpawnConfigEvent>, mut spawn_manager: Option<ResMut<crate::game::SpawnManager>>, game_instance: Option<Res<crate::game::GameInstance>>) {
for event in spawn_config_events.read() {
let Some(ref mut spawn_mgr) = spawn_manager else {
continue;
};
let Some(ref game_inst) = game_instance else {
continue;
};
// Update player spawns from server
spawn_mgr.player_spawns.clear();
for (&player_id, &tile_index) in &event.0 {
spawn_mgr.player_spawns.push(crate::game::SpawnPoint::new(player_id as usize, tile_index as usize));
}
// Recalculate bot spawns based on updated player positions
spawn_mgr.current_bot_spawns = game_inst.bot_manager.recalculate_spawns_with_players(spawn_mgr.initial_bot_spawns.clone(), &spawn_mgr.player_spawns, &game_inst.territory_manager, spawn_mgr.rng_seed);
info!("Updated spawn manager with {} player spawns from server", spawn_mgr.player_spawns.len());
}
}

View File

@@ -0,0 +1,189 @@
use crate::time::Time;
use bevy_ecs::prelude::*;
use tracing::{debug, info, trace, warn};
use crate::constants::{SPAWN_TIMEOUT_SECS, TICK_INTERVAL};
use crate::networking::{Intent, ProcessTurnEvent, Turn};
use flume::{Receiver, Sender};
use std::collections::HashMap;
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
#[derive(Resource)]
pub struct IntentReceiver {
pub intent_rx: Receiver<Intent>,
}
#[derive(Resource)]
pub struct TurnReceiver {
pub turn_rx: Receiver<Turn>,
}
/// Local turn server control handle
#[derive(Resource, Clone)]
pub struct LocalTurnServerHandle {
pub paused: Arc<AtomicBool>,
pub running: Arc<AtomicBool>,
}
impl LocalTurnServerHandle {
pub fn pause(&self) {
self.paused.store(true, Ordering::SeqCst);
}
pub fn resume(&self) {
self.paused.store(false, Ordering::SeqCst);
}
pub fn stop(&self) {
self.running.store(false, Ordering::SeqCst);
}
pub fn is_paused(&self) -> bool {
self.paused.load(Ordering::SeqCst)
}
pub fn is_running(&self) -> bool {
self.running.load(Ordering::SeqCst)
}
}
/// Spawn timeout duration for local games (milliseconds)
const SPAWN_TIMEOUT_MS: u64 = (SPAWN_TIMEOUT_SECS * 1000.0) as u64;
/// Resource to track turn generation state
#[derive(Resource)]
pub struct TurnGenerator {
pub turn_number: u64,
pub accumulated_time: f64, // milliseconds
pub turn_tx: Sender<Turn>,
// Spawn phase tracking
pub spawn_config: HashMap<u16, u32>,
pub spawn_timeout_accumulated: Option<f64>, // milliseconds since first spawn
pub game_started: bool,
}
/// System to generate turns using Bevy's Update loop
pub fn generate_turns_system(mut generator: ResMut<TurnGenerator>, server_handle: Res<LocalTurnServerHandle>, intent_receiver: Res<IntentReceiver>, mut spawn_manager: Option<ResMut<crate::game::SpawnManager>>, game_instance: Option<Res<crate::game::GameInstance>>, time: Res<Time>) {
let _guard = tracing::trace_span!("generate_turns").entered();
if !server_handle.is_running() {
return;
}
let is_paused = server_handle.paused.load(Ordering::SeqCst);
// During spawn phase (paused), handle SetSpawn intents and track timeout
if is_paused {
// Collect SetSpawn intents during spawn phase
while let Ok(intent) = intent_receiver.intent_rx.try_recv() {
match intent {
Intent::SetSpawn { player_id, tile_index } => {
debug!("Player {} set spawn at tile {}", player_id, tile_index);
generator.spawn_config.insert(player_id, tile_index);
// Update SpawnManager to recalculate bot positions (two-pass spawn system)
if let (Some(ref mut spawn_mgr), Some(game_inst)) = (spawn_manager.as_mut(), game_instance.as_ref()) {
spawn_mgr.update_player_spawn(player_id as usize, tile_index as usize, &game_inst.bot_manager, &game_inst.territory_manager);
}
// Start timeout on first spawn
if generator.spawn_timeout_accumulated.is_none() {
generator.spawn_timeout_accumulated = Some(0.0);
debug!("Spawn timeout started ({}ms)", SPAWN_TIMEOUT_MS);
}
}
Intent::Action(_) => {
// Action intents during spawn phase are ignored
warn!("Received Action intent during spawn phase - ignoring");
}
}
}
// Update spawn timeout if started
if let Some(ref mut accumulated) = generator.spawn_timeout_accumulated {
*accumulated += time.delta().as_secs_f64() * 1000.0;
// Check if timeout expired
if *accumulated >= SPAWN_TIMEOUT_MS as f64 {
let _guard = tracing::trace_span!("spawn_timeout", spawn_count = generator.spawn_config.len()).entered();
debug!("Spawn timeout expired - starting game");
// Create Turn(0) - no intents needed, spawns already applied via spawn_manager
// The spawn_manager was updated as spawns came in, and will be used
// to initialize territory during game start
let start_turn = Turn { turn_number: 0, intents: Vec::new() };
info!("Sending Turn(0) to start game (spawns already configured via SpawnManager)");
if let Err(e) = generator.turn_tx.send(start_turn) {
warn!("Failed to send Turn(0): {}", e);
}
// Mark game as started and clear spawn phase
generator.game_started = true;
generator.spawn_config.clear();
generator.spawn_timeout_accumulated = None;
generator.turn_number = 1; // Next turn will be turn 1
generator.accumulated_time = 0.0; // Reset accumulated time for clean turn timing
server_handle.resume();
info!("Spawn phase complete - game started, server resumed, accumulated_time reset, next turn will be Turn 1");
}
}
return;
}
// Normal turn generation (after game has started)
if !generator.game_started {
return; // Wait for spawn phase to complete
}
// Accumulate time (delta is in seconds, convert to milliseconds)
let delta_ms = time.delta().as_secs_f64() * 1000.0;
generator.accumulated_time += delta_ms;
// Only generate turn if enough time has passed
if generator.accumulated_time < TICK_INTERVAL as f64 {
return;
}
// Reset accumulated time
generator.accumulated_time -= TICK_INTERVAL as f64;
// Collect all pending Action intents (ignore SetSpawn after game starts)
let mut action_intents = Vec::new();
while let Ok(intent) = intent_receiver.intent_rx.try_recv() {
match intent {
Intent::Action(action) => {
action_intents.push(Intent::Action(action));
}
Intent::SetSpawn { .. } => {
// SetSpawn intents after game start are ignored
warn!("Received SetSpawn intent after game started - ignoring");
}
}
}
// Create turn
let turn = Turn { turn_number: generator.turn_number, intents: action_intents.clone() };
// Send turn
if let Err(e) = generator.turn_tx.send(turn) {
warn!("Failed to send turn {}: {}", generator.turn_number, e);
}
generator.turn_number += 1;
}
/// System to poll for turns from the local server and emit ProcessTurnEvent
/// This replaces the old FixedUpdate create_turns system
pub fn poll_turns_system(turn_receiver: Res<TurnReceiver>, mut process_turn_writer: MessageWriter<ProcessTurnEvent>) {
let _guard = tracing::trace_span!("poll_turns").entered();
while let Ok(turn) = turn_receiver.turn_rx.try_recv() {
trace!("Received Turn {} from channel", turn.turn_number);
process_turn_writer.write(ProcessTurnEvent(turn));
}
}

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,137 @@
pub mod client;
pub mod coordinator;
#[cfg(not(target_arch = "wasm32"))]
pub mod dns;
pub mod network;
pub mod protocol;
pub mod server;
// Re-export coordinator types for easier access
pub use coordinator::{IntentReceiver, LocalTurnServerHandle, TurnGenerator, TurnReceiver, generate_turns_system, poll_turns_system};
use bevy_ecs::prelude::{Message, Resource};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use crate::game::action::GameAction;
#[derive(Message, Debug, Clone, bincode::Encode, bincode::Decode)]
pub struct IntentEvent(pub Intent);
#[derive(Message, Debug, Clone, bincode::Encode, bincode::Decode)]
pub struct ProcessTurnEvent(pub Turn);
/// Event containing spawn configuration update from server (multiplayer)
#[derive(Message, Debug, Clone)]
pub struct SpawnConfigEvent(pub std::collections::HashMap<u16, u32>);
/// Network wrapper for player intents
///
/// Intent is the network-layer representation of player intents.
/// It has two variants:
/// - Action: State-recorded game actions that appear in game history (replays)
/// - SetSpawn: Ephemeral spawn selection that doesn't pollute game history
///
/// Note: Bot actions are NOT sent as intents - they are calculated
/// deterministically on each client during turn execution.
#[derive(Debug, Clone, Serialize, Deserialize, bincode::Encode, bincode::Decode)]
pub enum Intent {
/// State-recorded game action (appears in game history for replays)
Action(GameAction),
/// Ephemeral spawn selection (not recorded in history)
/// Only valid during spawn phase, ignored after game starts
SetSpawn { player_id: u16, tile_index: u32 },
}
#[derive(Debug, Clone, Serialize, Deserialize, bincode::Encode, bincode::Decode)]
pub struct Turn {
pub turn_number: u64,
pub intents: Vec<Intent>,
}
/// Read-only snapshot of game state for rendering - DETERMINISTIC, SHARED
///
/// **Important: This is GLOBAL/SHARED state identical across all clients!**
///
/// This is a read-only snapshot of GameInstance state, updated after each turn.
/// It provides:
/// - Safe, immutable access to game state for rendering and input systems
/// - Serializable format for network transmission
/// - Same view for all clients (server, players, spectators)
///
/// Systems should prefer using GameView over direct GameInstance access
/// to maintain clean separation between game logic and rendering/input.
#[derive(Resource, Default, Debug, Clone, Serialize, Deserialize, bincode::Encode, bincode::Decode)]
pub struct GameView {
#[bincode(with_serde)]
pub size: glam::U16Vec2,
/// Owner of each tile. Uses Arc for zero-copy sharing with rendering.
pub territories: Arc<[u16]>,
pub players: Vec<PlayerView>,
pub turn_number: u64,
/// Total number of conquerable (non-water) tiles on the map.
/// Cached for performance - calculated once at initialization.
pub total_land_tiles: u32,
/// Indices of tiles that changed ownership this turn (from TerritoryManager's ChangeBuffer).
/// Used for efficient delta rendering without full map scans.
pub changed_tiles: Vec<usize>,
/// Active ships on the map
pub ships: Vec<ShipView>,
}
impl GameView {
/// Get the 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 owner of a specific tile
pub fn get_owner(&self, tile_index: usize) -> u16 {
self.territories.get(tile_index).copied().unwrap_or(0)
}
/// Get a player by ID
pub fn get_player(&self, player_id: u16) -> Option<&PlayerView> {
self.players.iter().find(|p| p.id == player_id)
}
/// Find any tile owned by a specific player (useful for camera centering)
pub fn find_tile_owned_by(&self, player_id: u16) -> Option<usize> {
self.territories.iter().position(|&owner| owner == player_id)
}
}
#[derive(Clone, Default, Debug, Serialize, Deserialize, bincode::Encode, bincode::Decode)]
pub struct PlayerView {
pub id: u16,
pub color: [f32; 4],
pub name: String,
pub tile_count: u32,
pub troops: u32,
pub is_alive: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize, bincode::Encode, bincode::Decode)]
pub struct ShipView {
pub id: u32,
pub owner_id: u16,
pub current_tile: u32,
pub target_tile: u32,
pub troops: u32,
pub path_progress: u32,
pub ticks_until_move: u32,
pub path: Vec<u32>,
}

View File

@@ -0,0 +1,244 @@
use crate::networking::{GameView, Intent};
use anyhow::Result;
use async_trait::async_trait;
use flume::{Receiver, Sender};
#[cfg(not(target_arch = "wasm32"))]
use crate::networking::protocol::NetMessage;
#[cfg(not(target_arch = "wasm32"))]
use url::Url;
#[cfg(not(target_arch = "wasm32"))]
use web_transport::{ClientBuilder, RecvStream, SendStream};
#[async_trait]
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
pub trait Transceiver: Send + Sync {
async fn send(&mut self, intent: Intent) -> Result<()>;
async fn receive(&mut self) -> Result<GameView>;
}
#[async_trait]
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
pub trait ServerTransceiver: Send + Sync {
async fn send(&mut self, game_view: GameView) -> Result<()>;
async fn receive(&mut self) -> Result<Intent>;
}
#[cfg(not(target_arch = "wasm32"))]
pub struct RemoteSender {
send_stream: SendStream,
}
#[cfg(not(target_arch = "wasm32"))]
pub struct RemoteReceiver {
recv_stream: RecvStream,
}
pub struct RemoteConnection;
#[cfg(not(target_arch = "wasm32"))]
impl RemoteConnection {
/// Establishes a connection to a remote server and returns separate sender/receiver
pub async fn connect(server_address: &str) -> Result<(RemoteSender, RemoteReceiver)> {
let url = Url::parse(server_address)?;
let mut session = {
let client = ClientBuilder::new().with_system_roots()?;
client.connect(url).await?
};
let (send_stream, recv_stream) = session.open_bi().await?;
let sender = RemoteSender { send_stream };
let receiver = RemoteReceiver { recv_stream };
Ok((sender, receiver))
}
}
#[cfg(not(target_arch = "wasm32"))]
impl RemoteSender {
pub async fn send(&mut self, intent: Intent) -> Result<()> {
let config = bincode::config::standard();
let bytes = bincode::encode_to_vec(intent, config)?;
self.send_bytes(&bytes).await
}
pub async fn send_net_message(&mut self, message: NetMessage) -> Result<()> {
let config = bincode::config::standard();
let bytes = bincode::encode_to_vec(message, config)?;
self.send_bytes(&bytes).await
}
async fn send_bytes(&mut self, data: &[u8]) -> Result<()> {
let len = data.len() as u64;
self.send_stream.write(&len.to_be_bytes()).await?;
self.send_stream.write(data).await?;
Ok(())
}
}
#[cfg(not(target_arch = "wasm32"))]
impl RemoteReceiver {
pub async fn receive(&mut self) -> Result<GameView> {
let bytes = self.receive_bytes().await?;
let config = bincode::config::standard();
let (game_view, _) = bincode::decode_from_slice(&bytes, config)?;
Ok(game_view)
}
pub async fn receive_net_message(&mut self) -> Result<NetMessage> {
let bytes = self.receive_bytes().await?;
let config = bincode::config::standard();
let (net_message, _) = bincode::decode_from_slice(&bytes, config)?;
Ok(net_message)
}
async fn receive_bytes(&mut self) -> Result<Vec<u8>> {
// Read length prefix (8 bytes)
let mut len_bytes = Vec::new();
while len_bytes.len() < 8 {
let remaining = 8 - len_bytes.len();
if let Some(chunk) = self.recv_stream.read(remaining).await? {
len_bytes.extend_from_slice(&chunk);
} else {
anyhow::bail!("Stream closed before reading length prefix");
}
}
let len = u64::from_be_bytes(len_bytes[0..8].try_into()?) as usize;
// Read message data
let mut buffer = Vec::new();
while buffer.len() < len {
let remaining = len - buffer.len();
if let Some(chunk) = self.recv_stream.read(remaining).await? {
buffer.extend_from_slice(&chunk);
} else {
anyhow::bail!("Stream closed before reading full message");
}
}
Ok(buffer[0..len].to_vec())
}
}
#[cfg(not(target_arch = "wasm32"))]
pub struct RemoteServerSender {
send_stream: SendStream,
}
#[cfg(not(target_arch = "wasm32"))]
pub struct RemoteServerReceiver {
recv_stream: RecvStream,
}
pub struct RemoteConnectionMirror;
#[cfg(not(target_arch = "wasm32"))]
impl RemoteConnectionMirror {
/// Creates server-side sender/receiver from an accepted connection stream
pub fn from_streams(send_stream: SendStream, recv_stream: RecvStream) -> (RemoteServerSender, RemoteServerReceiver) {
let sender = RemoteServerSender { send_stream };
let receiver = RemoteServerReceiver { recv_stream };
(sender, receiver)
}
}
#[cfg(not(target_arch = "wasm32"))]
impl RemoteServerSender {
pub async fn send(&mut self, game_view: GameView) -> Result<()> {
let config = bincode::config::standard();
let bytes = bincode::encode_to_vec(game_view, config)?;
self.send_bytes(&bytes).await
}
async fn send_bytes(&mut self, data: &[u8]) -> Result<()> {
let len = data.len() as u64;
self.send_stream.write(&len.to_be_bytes()).await?;
self.send_stream.write(data).await?;
Ok(())
}
}
#[cfg(not(target_arch = "wasm32"))]
impl RemoteServerReceiver {
pub async fn receive(&mut self) -> Result<Intent> {
let bytes = self.receive_bytes().await?;
let config = bincode::config::standard();
let (intent, _) = bincode::decode_from_slice(&bytes, config)?;
Ok(intent)
}
async fn receive_bytes(&mut self) -> Result<Vec<u8>> {
// Read length prefix (8 bytes)
let mut len_bytes = Vec::new();
while len_bytes.len() < 8 {
let remaining = 8 - len_bytes.len();
if let Some(chunk) = self.recv_stream.read(remaining).await? {
len_bytes.extend_from_slice(&chunk);
} else {
anyhow::bail!("Stream closed before reading length prefix");
}
}
let len = u64::from_be_bytes(len_bytes[0..8].try_into()?) as usize;
// Read message data
let mut buffer = Vec::new();
while buffer.len() < len {
let remaining = len - buffer.len();
if let Some(chunk) = self.recv_stream.read(remaining).await? {
buffer.extend_from_slice(&chunk);
} else {
anyhow::bail!("Stream closed before reading full message");
}
}
Ok(buffer[0..len].to_vec())
}
}
pub struct LocalConnection {
pub intent_tx: Sender<Intent>,
pub game_view_rx: Receiver<GameView>,
}
impl LocalConnection {
pub fn new() -> (Self, LocalConnectionMirror) {
let (intent_tx, intent_rx) = flume::unbounded();
let (game_view_tx, game_view_rx) = flume::unbounded();
let client_end = Self { intent_tx, game_view_rx };
let server_end = LocalConnectionMirror { intent_rx, game_view_tx };
(client_end, server_end)
}
}
#[async_trait]
impl Transceiver for LocalConnection {
async fn send(&mut self, intent: Intent) -> Result<()> {
self.intent_tx.send_async(intent).await?;
Ok(())
}
async fn receive(&mut self) -> Result<GameView> {
let game_view = self.game_view_rx.recv_async().await?;
Ok(game_view)
}
}
#[async_trait]
impl ServerTransceiver for LocalConnectionMirror {
async fn send(&mut self, game_view: GameView) -> Result<()> {
self.game_view_tx.send_async(game_view).await?;
Ok(())
}
async fn receive(&mut self) -> Result<Intent> {
let intent = self.intent_rx.recv_async().await?;
Ok(intent)
}
}
pub struct LocalConnectionMirror {
pub intent_rx: Receiver<Intent>,
pub game_view_tx: Sender<GameView>,
}

View File

@@ -0,0 +1,23 @@
//! Network protocol for multiplayer client-server communication
use crate::networking::Intent;
use bincode::{Decode, Encode};
use std::collections::HashMap;
/// Network message protocol for client-server communication
#[derive(Encode, Decode, Debug, Clone)]
pub enum NetMessage {
/// Server assigns player ID to client
ServerConfig { player_id: u16 },
/// Client sends intent to server
Intent(Intent),
/// Server broadcasts turn to all clients
Turn { turn: u64, intents: Vec<Intent> },
/// Server broadcasts current spawn configuration during spawn phase
/// Maps player_id -> tile_index for all players who have chosen spawns
SpawnConfiguration { spawns: HashMap<u16, u32> },
}
/// Shared constants across all binaries for deterministic behavior
pub const NETWORK_SEED: u64 = 0xC0FFEE;
pub const TICK_MS: u64 = 100;

View File

@@ -0,0 +1,60 @@
use crate::game::GameInstance;
use crate::networking::{GameView, Intent, IntentEvent, PlayerView, protocol::NetMessage};
use bevy_ecs::prelude::*;
use flume::Sender;
use std::collections::HashMap;
#[derive(Resource)]
pub struct ServerChannels {
pub broadcast_tx: Sender<GameView>,
pub net_message_tx: Sender<NetMessage>,
}
pub fn broadcast_game_state_system(server_channels: Res<ServerChannels>, mut game: ResMut<GameInstance>) {
let _guard = tracing::trace_span!("broadcast_game_state").entered();
let game_view = create_game_view(&mut game);
let _ = server_channels.broadcast_tx.try_send(game_view);
}
/// System to broadcast turns to all connected clients
pub fn broadcast_turn_system(server_channels: Res<ServerChannels>, mut turn_events: MessageReader<crate::networking::ProcessTurnEvent>) {
for event in turn_events.read() {
let turn_message = NetMessage::Turn { turn: event.0.turn_number, intents: event.0.intents.clone() };
let _ = server_channels.net_message_tx.try_send(turn_message);
}
}
/// Resource to track spawn configurations during spawn phase
#[derive(Resource, Default)]
pub struct SpawnConfigTracker {
pub spawns: HashMap<u16, u32>,
}
/// System to broadcast spawn configuration when players set their spawn
/// Only runs during multiplayer server mode
pub fn broadcast_spawn_config_system(mut intent_events: MessageReader<IntentEvent>, mut spawn_tracker: ResMut<SpawnConfigTracker>, server_channels: Res<ServerChannels>) {
for event in intent_events.read() {
if let Intent::SetSpawn { player_id, tile_index } = event.0 {
// Track this spawn
spawn_tracker.spawns.insert(player_id, tile_index);
// Broadcast updated spawn configuration to all clients
let spawn_message = NetMessage::SpawnConfiguration { spawns: spawn_tracker.spawns.clone() };
let _ = server_channels.net_message_tx.try_send(spawn_message);
}
}
}
fn create_game_view(game: &mut GameInstance) -> GameView {
let _guard = tracing::trace_span!("create_game_view").entered();
use std::sync::Arc;
let total_land_tiles = game.territory_manager.as_slice().iter().filter(|ownership| !ownership.is_water()).count() as u32;
let changed_tiles: Vec<usize> = game.territory_manager.drain_changes().collect();
// Convert ships to ShipView format
let ships = game.ship_manager.get_ships().map(|ship| crate::networking::ShipView { id: ship.id, owner_id: ship.owner_id, current_tile: ship.get_current_tile() as u32, target_tile: ship.target_tile as u32, troops: ship.troops, path_progress: ship.current_path_index as u32, ticks_until_move: ship.ticks_per_tile.saturating_sub(ship.ticks_since_move), path: ship.path.iter().map(|&tile| tile as u32).collect() }).collect();
GameView { size: glam::U16Vec2::new(game.territory_manager.width(), game.territory_manager.height()), territories: Arc::from(game.territory_manager.to_u16_vec().as_slice()), turn_number: game.turn_number, total_land_tiles, changed_tiles, players: game.player_manager.get_players().iter().map(|p| PlayerView { id: p.id as u16, color: p.color.to_rgba(), name: p.name.clone(), tile_count: p.get_territory_size() as u32, troops: p.get_troops() as u32, is_alive: p.is_alive() }).collect(), ships }
}

View File

@@ -0,0 +1,214 @@
#[cfg(not(target_arch = "wasm32"))]
use crate::networking::{Intent, protocol::NetMessage, server::registry::ServerRegistry};
#[cfg(not(target_arch = "wasm32"))]
use anyhow::Result;
#[cfg(not(target_arch = "wasm32"))]
use flume::Sender;
#[cfg(not(target_arch = "wasm32"))]
use std::sync::Arc;
#[cfg(not(target_arch = "wasm32"))]
use tokio::sync::RwLock;
#[cfg(not(target_arch = "wasm32"))]
use tracing::{Instrument, error, info, instrument, warn};
#[cfg(not(target_arch = "wasm32"))]
use web_transport::quinn::{RecvStream, SendStream, ServerBuilder};
/// Handle a single client connection over WebTransport
#[cfg(not(target_arch = "wasm32"))]
#[instrument(skip_all)]
pub async fn handle_client_connection(mut send_stream: SendStream, mut recv_stream: RecvStream, intent_tx: Sender<Intent>, registry: Arc<RwLock<ServerRegistry>>) -> Result<()> {
info!("New client connected, starting message handling");
// Create a per-client channel for receiving broadcast messages
let (client_tx, client_rx) = flume::unbounded::<NetMessage>();
// Register this client with the server registry and get assigned player ID
let player_id = { registry.write().await.add_client(client_tx) };
info!(player_id = player_id, "Client registered");
// Send initial server config
let server_config = NetMessage::ServerConfig { player_id };
let config_bytes = bincode::encode_to_vec(server_config, bincode::config::standard())?;
let len_bytes = (config_bytes.len() as u64).to_be_bytes();
// Send length prefix
let mut written = 0;
while written < len_bytes.len() {
let bytes_written = send_stream.write(&len_bytes[written..]).await?;
written += bytes_written;
}
// Send config bytes
let mut written = 0;
while written < config_bytes.len() {
let bytes_written = send_stream.write(&config_bytes[written..]).await?;
written += bytes_written;
}
// Spawn task to handle incoming intents from this client
let intent_tx_clone = intent_tx.clone();
tokio::spawn(
async move {
loop {
// Read length prefix (8 bytes)
let mut len_bytes = Vec::new();
while len_bytes.len() < 8 {
let remaining = 8 - len_bytes.len();
if let Ok(maybe_chunk) = recv_stream.read_chunk(remaining, true).await {
if let Some(chunk) = maybe_chunk {
len_bytes.extend_from_slice(&chunk.bytes);
} else {
break;
}
} else {
error!("Stream closed before reading length prefix");
break;
}
}
let len = u64::from_be_bytes(len_bytes[0..8].try_into().unwrap()) as usize;
// Read message data
let mut message_bytes = Vec::new();
while message_bytes.len() < len {
let remaining = len - message_bytes.len();
if let Ok(maybe_chunk) = recv_stream.read_chunk(remaining, true).await {
if let Some(chunk) = maybe_chunk {
message_bytes.extend_from_slice(&chunk.bytes);
} else {
break;
}
} else {
error!("Stream closed before reading full message");
break;
}
}
// Decode message
match bincode::decode_from_slice(&message_bytes, bincode::config::standard()) {
Ok((net_message, _)) => match net_message {
NetMessage::Intent(intent) => {
if let Err(e) = intent_tx_clone.send(intent) {
error!(error = %e, "Failed to forward intent");
break;
}
}
_ => warn!("Received unexpected message type from client"),
},
Err(e) => {
error!(error = %e, "Failed to decode message");
break;
}
}
}
info!("Client intent receiver task ended");
}
.instrument(tracing::trace_span!("client_recv_loop", player_id = player_id)),
);
// Handle outgoing messages to this client
let registry_clone = registry.clone();
tokio::spawn(
async move {
while let Ok(message) = client_rx.recv_async().await {
match bincode::encode_to_vec(message, bincode::config::standard()) {
Ok(message_bytes) => {
let len_bytes = (message_bytes.len() as u64).to_be_bytes();
// Send length prefix
let mut written = 0;
while written < len_bytes.len() {
match send_stream.write(&len_bytes[written..]).await {
Ok(bytes_written) => written += bytes_written,
Err(e) => {
error!(
player_id = player_id,
error = %e,
"Failed to send length prefix"
);
break;
}
}
}
// Send message bytes
let mut written = 0;
while written < message_bytes.len() {
match send_stream.write(&message_bytes[written..]).await {
Ok(bytes_written) => written += bytes_written,
Err(e) => {
error!(player_id = player_id, error = %e, "Failed to send message");
break;
}
}
}
}
Err(e) => {
error!(player_id = player_id, error = %e, "Failed to encode message");
break;
}
}
}
// Remove client from registry when sender task ends
info!(player_id = player_id, "Client message sender task ended, removing from registry");
registry_clone.write().await.remove_client(player_id);
}
.instrument(tracing::trace_span!("client_send_loop", player_id = player_id)),
);
info!(player_id = player_id, "Client connection handler setup complete");
Ok(())
}
/// Start the WebTransport server and accept connections
#[cfg(not(target_arch = "wasm32"))]
#[instrument(skip_all, fields(bind_address = %bind_address))]
pub async fn start_server(bind_address: &str, intent_tx: Sender<Intent>, registry: Arc<RwLock<ServerRegistry>>) -> Result<()> {
info!("Starting WebTransport server");
// Load development certificate and key
let cert_path = "dev-cert.pem";
let key_path = "dev-key.pem";
let cert_data = std::fs::read(cert_path).map_err(|e| anyhow::anyhow!("Failed to read certificate file {}: {}", cert_path, e))?;
let key_data = std::fs::read(key_path).map_err(|e| anyhow::anyhow!("Failed to read key file {}: {}", key_path, e))?;
// Parse certificate and key
let certs = rustls_pemfile::certs(&mut &cert_data[..]).collect::<Result<Vec<_>, _>>().map_err(|e| anyhow::anyhow!("Failed to parse certificate: {}", e))?;
let key = rustls_pemfile::private_key(&mut &key_data[..]).map_err(|e| anyhow::anyhow!("Failed to parse private key: {}", e))?.ok_or_else(|| anyhow::anyhow!("No private key found"))?;
let mut server = ServerBuilder::new().with_addr(bind_address.parse()?).with_certificate(certs, key)?;
info!("WebTransport server listening for connections");
loop {
match server.accept().await {
Some(connection) => {
info!("New client connected");
let intent_tx_clone = intent_tx.clone();
let registry_clone = registry.clone();
let session = connection.ok().await?;
tokio::spawn(async move {
// Accept bidirectional stream from client
match session.accept_bi().await {
Ok((send_stream, recv_stream)) => {
if let Err(e) = handle_client_connection(send_stream, recv_stream, intent_tx_clone, registry_clone).await {
error!(error = %e, "Error handling client connection");
}
}
Err(e) => {
error!(error = %e, "Failed to accept bidirectional stream from client");
}
}
});
}
None => {
error!("Failed to accept connection");
}
}
}
}

View File

@@ -0,0 +1,12 @@
//! Server components for multiplayer networking
pub mod broadcast;
pub mod connection_handler;
pub mod registry;
// Re-export commonly used types
pub use broadcast::*;
pub use registry::ServerRegistry;
#[cfg(not(target_arch = "wasm32"))]
pub use connection_handler::{handle_client_connection, start_server};

View File

@@ -0,0 +1,72 @@
use crate::networking::protocol::NetMessage;
use bevy_ecs::prelude::Resource;
use flume::Sender;
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use tracing::error;
/// Connection information for a client
#[derive(Debug, Clone)]
pub struct ClientConnection {
pub id: u16,
pub tx: Sender<NetMessage>,
}
/// Registry for managing client connections and broadcasting messages
#[derive(Resource)]
pub struct ServerRegistry {
connections: Arc<RwLock<HashMap<u16, ClientConnection>>>,
next_player_id: Arc<RwLock<u16>>,
}
impl Default for ServerRegistry {
fn default() -> Self {
Self::new()
}
}
impl ServerRegistry {
pub fn new() -> Self {
Self {
connections: Arc::new(RwLock::new(HashMap::new())),
next_player_id: Arc::new(RwLock::new(1)), // Start from 1, 0 reserved
}
}
/// Add a new client connection and return assigned player ID
pub fn add_client(&self, tx: Sender<NetMessage>) -> u16 {
let mut next_id = self.next_player_id.write().unwrap();
let player_id = *next_id;
*next_id += 1;
let connection = ClientConnection { id: player_id, tx };
self.connections.write().unwrap().insert(player_id, connection);
player_id
}
/// Remove a client connection
pub fn remove_client(&self, player_id: u16) {
self.connections.write().unwrap().remove(&player_id);
}
/// Broadcast a message to all connected clients
pub fn broadcast(&self, message: NetMessage) {
let connections = self.connections.read().unwrap();
for connection in connections.values() {
if let Err(e) = connection.tx.send(message.clone()) {
error!("Failed to send message to client {}: {}", connection.id, e);
}
}
}
/// Get the number of connected clients
pub fn client_count(&self) -> usize {
self.connections.read().unwrap().len()
}
/// Get all client IDs
pub fn client_ids(&self) -> Vec<u16> {
self.connections.read().unwrap().keys().cloned().collect()
}
}

View File

@@ -0,0 +1,59 @@
use std::future::Future;
/// Spawn an async task on the appropriate runtime for the platform.
///
/// On native targets, uses tokio::spawn for multi-threaded execution.
/// On WASM targets, uses wasm_bindgen_futures::spawn_local for browser integration.
#[cfg(not(target_arch = "wasm32"))]
pub fn spawn_task<F>(future: F)
where
F: Future<Output = ()> + Send + 'static,
{
tokio::spawn(future);
}
#[cfg(target_arch = "wasm32")]
pub fn spawn_task<F>(future: F)
where
F: Future<Output = ()> + 'static,
{
wasm_bindgen_futures::spawn_local(future);
}
/// Spawn an async task that returns a value on the appropriate runtime.
///
/// On native targets, uses tokio::spawn and returns a JoinHandle.
/// On WASM targets, uses wasm_bindgen_futures::spawn_local and immediately returns None
/// since WASM doesn't support waiting on spawned tasks.
#[cfg(not(target_arch = "wasm32"))]
pub fn spawn_task_with_handle<F, T>(future: F) -> tokio::task::JoinHandle<T>
where
F: Future<Output = T> + Send + 'static,
T: Send + 'static,
{
tokio::spawn(future)
}
#[cfg(target_arch = "wasm32")]
pub fn spawn_task_with_handle<F, T>(future: F)
where
F: Future<Output = T> + 'static,
T: 'static,
{
wasm_bindgen_futures::spawn_local(async move {
let _ = future.await;
});
}
/// Extension trait to convert any Result into anyhow::Result with string error conversion.
///
/// This is useful for WASM where some error types don't implement std::error::Error.
pub trait IntoAnyhow<T> {
fn into_anyhow(self) -> anyhow::Result<T>;
}
impl<T, E: std::fmt::Display> IntoAnyhow<T> for Result<T, E> {
fn into_anyhow(self) -> anyhow::Result<T> {
self.map_err(|e| anyhow::anyhow!(e.to_string()))
}
}

View File

@@ -0,0 +1,769 @@
//! Consolidated game plugin integrating all core systems
//!
//! This module provides the main `GamePlugin` which sets up all game logic including:
//! - Networking (local or remote)
//! - Spawn phase management
//! - Core game systems and event handling
//! - Turn execution and processing
use bevy_ecs::prelude::*;
use bevy_ecs::schedule::common_conditions::resource_exists;
#[cfg(not(target_arch = "wasm32"))]
use tracing::Instrument;
use tracing::{debug, info, trace};
use crate::app::{App, Last, Plugin, Update};
use crate::time::{FixedTime, Time};
use crate::constants::TICK_INTERVAL;
use crate::game::{AttackControls, CurrentTurn, GameInstance, SpawnPhase, SpawnTimeout, turn_is_ready};
use crate::networking::{
GameView, IntentEvent, IntentReceiver, ProcessTurnEvent, TurnReceiver,
coordinator::{generate_turns_system, poll_turns_system},
};
#[cfg(not(target_arch = "wasm32"))]
use std::time::{SystemTime, UNIX_EPOCH};
#[cfg(target_arch = "wasm32")]
use web_time::{SystemTime, UNIX_EPOCH};
// Re-export protocol types for convenience
#[cfg(feature = "ui")]
use crate::ui::protocol::{BackendMessage, CameraCommand, SpawnCountdown};
/// Network mode configuration for the game
pub enum NetworkMode {
/// Local single-player or hotseat mode
Local,
/// Remote multiplayer mode (non-WASM only)
#[cfg(not(target_arch = "wasm32"))]
Remote { server_address: String },
}
/// Main game plugin that consolidates all core game logic
///
/// This plugin sets up:
/// - Network channels (local or remote)
/// - Spawn phase management
/// - Core game systems
/// - Turn processing
/// - Input handling
pub struct GamePlugin {
pub network_mode: NetworkMode,
}
impl GamePlugin {
pub fn new(network_mode: NetworkMode) -> Self {
Self { network_mode }
}
}
impl Plugin for GamePlugin {
fn build(&self, app: &mut App) {
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 direct channels
let (intent_tx, intent_rx) = flume::unbounded();
let (game_view_tx, game_view_rx) = flume::unbounded();
app.insert_resource(crate::networking::client::ClientConnection { intent_tx, game_view_rx })
.insert_resource(crate::networking::server::ServerChannels {
broadcast_tx: game_view_tx,
net_message_tx: flume::unbounded().0, // Dummy channel for local mode
})
.insert_resource(IntentReceiver { intent_rx })
.add_systems(Update, (crate::networking::client::send_intent_system, crate::networking::client::receive_game_view_system.run_if(resource_exists::<GameView>)))
.add_systems(Update, (poll_turns_system.run_if(resource_exists::<TurnReceiver>), crate::networking::server::broadcast_game_state_system.run_if(resource_exists::<GameInstance>).run_if(|spawn_phase: Option<Res<SpawnPhase>>| spawn_phase.is_none_or(|sp| !sp.active)).after(crate::game::update_player_borders_system)));
}
#[cfg(not(target_arch = "wasm32"))]
NetworkMode::Remote { server_address } => {
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::send_net_intent_system, crate::networking::client::receive_net_message_system, crate::networking::client::handle_spawn_config_system));
// Spawn networking thread
let server_addr = server_address.clone();
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::<crate::networking::SpawnConfigEvent>().init_resource::<GameView>();
// UI-related events and resources (feature-gated)
#[cfg(feature = "ui")]
{
app.add_message::<CameraCommand>().init_resource::<crate::ui::LastLeaderboardDigest>().init_resource::<crate::ui::LastAttacksDigest>().init_resource::<crate::ui::LeaderboardThrottle>().init_resource::<crate::ui::NationHighlightState>().init_resource::<crate::ui::ShipStateTracker>();
}
// Input-related resources
app.init_resource::<SpawnPhase>().init_resource::<AttackControls>();
// Spawn phase management
app.init_resource::<SpawnPhaseInitialized>().init_resource::<PreviousSpawnState>().add_systems(Update, (emit_initial_spawn_phase_system.run_if(resource_exists::<SpawnPhase>), manage_spawn_phase_system, update_spawn_preview_system.run_if(resource_exists::<SpawnPhase>)));
// Core game logic systems (run in Update, event-driven)
app.add_systems(
Update,
(
// Step 1: Receive turn events and update CurrentTurn resource
update_current_turn_system,
// Step 2: Execute gameplay systems only when turn is ready (10 TPS)
(crate::game::process_player_income_system, execute_turn_gameplay_system, crate::game::update_player_borders_system, crate::game::check_local_player_outcome)
.chain() // Ensure income runs before turn execution
.run_if(turn_is_ready)
.run_if(resource_exists::<GameInstance>)
.run_if(resource_exists::<GameView>)
.run_if(resource_exists::<SpawnPhase>),
)
.chain(), // Ensure update_current_turn_system completes before execution systems
);
// UI update systems (feature-gated)
#[cfg(feature = "ui")]
app.add_systems(Update, (crate::ui::emit_leaderboard_snapshot_system, crate::ui::emit_attacks_update_system, crate::ui::emit_ships_update_system, crate::ui::emit_nation_highlight_system).run_if(resource_exists::<GameInstance>).run_if(resource_exists::<GameView>));
// Command handlers
#[cfg(feature = "ui")]
app.add_systems(Update, handle_frontend_messages_system);
// Platform-agnostic input systems
app.add_systems(Update, (crate::game::handle_spawn_click_system, crate::game::handle_attack_click_system, crate::game::handle_center_camera_system, crate::game::handle_attack_ratio_keys_system).run_if(resource_exists::<GameInstance>));
// Input state frame update
app.add_systems(Last, clear_input_state_system);
// Turn generation system
app.add_systems(Update, generate_turns_system.run_if(resource_exists::<crate::networking::TurnGenerator>));
}
}
/// Resource to track if we've emitted the initial spawn phase event
#[derive(Resource, Default)]
struct SpawnPhaseInitialized {
emitted_initial: bool,
}
/// Resource to track previous spawn state for incremental updates
#[derive(Resource, Default)]
struct PreviousSpawnState {
spawns: Vec<crate::game::SpawnPoint>,
}
/// System to emit initial SpawnPhaseUpdate when game starts
#[cfg(feature = "ui")]
fn emit_initial_spawn_phase_system(mut initialized: ResMut<SpawnPhaseInitialized>, spawn_phase: Res<SpawnPhase>, game_instance: Option<Res<GameInstance>>, mut backend_messages: MessageWriter<BackendMessage>) {
if initialized.emitted_initial || !spawn_phase.active || game_instance.is_none() {
return;
}
backend_messages.write(BackendMessage::SpawnPhaseUpdate { countdown: None });
initialized.emitted_initial = true;
debug!("Emitted initial SpawnPhaseUpdate (no countdown)");
}
#[cfg(not(feature = "ui"))]
fn emit_initial_spawn_phase_system() {}
/// System to manage spawn timeout and emit countdown updates
#[cfg(feature = "ui")]
fn manage_spawn_phase_system(mut spawn_timeout: Option<ResMut<SpawnTimeout>>, spawn_phase: Option<Res<SpawnPhase>>, time: Res<Time>, mut backend_messages: MessageWriter<BackendMessage>) {
let Some(spawn_phase) = spawn_phase else {
return;
};
let Some(ref mut spawn_timeout) = spawn_timeout else {
return;
};
if !spawn_phase.active || !spawn_timeout.active {
return;
}
spawn_timeout.update(time.delta_secs());
let started_at_ms = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_millis() as u64 - (spawn_timeout.elapsed_secs * 1000.0) as u64;
backend_messages.write(BackendMessage::SpawnPhaseUpdate { countdown: Some(SpawnCountdown { started_at_ms, duration_secs: spawn_timeout.duration_secs }) });
trace!("SpawnPhaseUpdate: remaining {:.1}s", spawn_timeout.remaining_secs);
}
#[cfg(not(feature = "ui"))]
fn manage_spawn_phase_system() {}
/// System to update GameView with spawn preview during spawn phase
/// This shows territory data to the frontend BEFORE Turn(0) is executed
/// Only processes changed spawns incrementally for better performance
fn update_spawn_preview_system(spawn_phase: Res<SpawnPhase>, spawn_manager: Option<Res<crate::game::SpawnManager>>, mut game_view: Option<ResMut<GameView>>, game_instance: Option<Res<GameInstance>>, mut previous_state: ResMut<PreviousSpawnState>) {
if !spawn_phase.active {
return;
}
let Some(ref spawn_mgr) = spawn_manager else {
return;
};
// Only update if SpawnManager has changed
if !spawn_mgr.is_changed() {
return;
}
let Some(ref mut game_view) = game_view else {
return;
};
let Some(ref game_inst) = game_instance else {
return;
};
let size = game_view.size();
let width = size.x as usize;
// Get current spawns
let current_spawns = spawn_mgr.get_all_spawns();
// Find spawns that were removed and added
let previous_spawns = &previous_state.spawns;
let removed_spawns: Vec<_> = previous_spawns.iter().filter(|prev| !current_spawns.contains(prev)).copied().collect();
let added_spawns: Vec<_> = current_spawns.iter().filter(|curr| !previous_spawns.contains(curr)).copied().collect();
// If nothing changed, return early
if removed_spawns.is_empty() && added_spawns.is_empty() {
return;
}
// Clone territories only if we have changes to process
use std::sync::Arc;
let old_territories: Vec<crate::game::TileOwnership> = game_view.territories.iter().map(|&u| crate::game::TileOwnership::from_u16(u)).collect();
let mut territories = old_territories.clone();
let base_territories = game_inst.territory_manager.as_slice();
let mut changed_tiles = std::collections::HashSet::new();
// Process removed spawns: revert their 5x5 areas to base state
for spawn in &removed_spawns {
let spawn_x = spawn.tile_index % width;
let spawn_y = spawn.tile_index / width;
for dy in -2i32..=2 {
for dx in -2i32..=2 {
let x = (spawn_x as i32 + dx).clamp(0, width as i32 - 1) as usize;
let y = (spawn_y as i32 + dy).clamp(0, size.y as i32 - 1) as usize;
let idx = x + y * width;
// Check if this tile belongs to the removed spawn
if territories[idx].is_owned_by(spawn.player_id as u16) {
// Recalculate from scratch for this tile
let mut new_owner = base_territories[idx];
// Check if any other spawn claims this tile
for other_spawn in &current_spawns {
let other_x = other_spawn.tile_index % width;
let other_y = other_spawn.tile_index / width;
let dx_other = x as i32 - other_x as i32;
let dy_other = y as i32 - other_y as i32;
if dx_other.abs() <= 2 && dy_other.abs() <= 2 && base_territories[idx].is_unclaimed() {
new_owner = crate::game::TileOwnership::Owned(other_spawn.player_id as u16);
break;
}
}
if territories[idx] != new_owner {
territories[idx] = new_owner;
changed_tiles.insert(idx);
}
}
}
}
}
// Process added spawns: mark their 5x5 areas
for spawn in &added_spawns {
let spawn_x = spawn.tile_index % width;
let spawn_y = spawn.tile_index / width;
for dy in -2i32..=2 {
for dx in -2i32..=2 {
let x = (spawn_x as i32 + dx).clamp(0, width as i32 - 1) as usize;
let y = (spawn_y as i32 + dy).clamp(0, size.y as i32 - 1) as usize;
let idx = x + y * width;
// Only claim if base territory is unclaimed
if base_territories[idx].is_unclaimed() {
let old_value = territories[idx];
let new_owner = crate::game::TileOwnership::Owned(spawn.player_id as u16);
if old_value != new_owner {
territories[idx] = new_owner;
changed_tiles.insert(idx);
}
}
}
}
}
// Convert back to u16 and update game view
let territories_u16: Vec<u16> = territories.iter().map(|o| o.to_u16()).collect();
game_view.territories = Arc::from(territories_u16.as_slice());
game_view.changed_tiles = changed_tiles.into_iter().collect();
// Update player tile counts incrementally by tracking deltas from changed tiles
// This is O(changed_tiles) instead of O(total_tiles) = ~100 ops instead of 6.5M ops
let mut players_map = std::collections::HashMap::new();
for player in &game_view.players {
players_map.insert(player.id, player.clone());
}
// Track tile count changes per player
let mut tile_deltas: std::collections::HashMap<u16, i32> = std::collections::HashMap::new();
// For each changed tile, update the delta counters
for &idx in &game_view.changed_tiles {
let old_owner = old_territories[idx];
let new_owner = territories[idx];
// Decrement count for previous owner (if any)
if let crate::game::TileOwnership::Owned(old_owner_id) = old_owner
&& old_owner_id != 0
{
*tile_deltas.entry(old_owner_id).or_insert(0) -= 1;
}
// Increment count for new owner (if any)
if let crate::game::TileOwnership::Owned(new_owner_id) = new_owner
&& new_owner_id != 0
{
*tile_deltas.entry(new_owner_id).or_insert(0) += 1;
}
}
// Apply deltas to player tile counts
for (player_id, delta) in tile_deltas {
if let Some(player) = players_map.get_mut(&player_id) {
player.tile_count = (player.tile_count as i32 + delta).max(0) as u32;
}
}
game_view.players = players_map.into_values().collect();
// Update previous state
previous_state.spawns = current_spawns.clone();
trace!("Spawn preview updated: {} removed, {} added, {} changed tiles", removed_spawns.len(), added_spawns.len(), game_view.changed_tiles.len());
}
/// System to clear per-frame input state data
fn clear_input_state_system(input: Option<NonSend<std::sync::Arc<std::sync::Mutex<crate::ui::input::InputState>>>>) {
if let Some(input) = input
&& let Ok(mut state) = input.lock()
{
state.clear_frame_data();
}
}
/// System to receive turn events and update CurrentTurn resource
pub fn update_current_turn_system(mut turn_events: MessageReader<ProcessTurnEvent>, mut current_turn: Option<ResMut<CurrentTurn>>, mut commands: Commands) {
// Read all turn events (should only be one per frame at 10 TPS)
let turns: Vec<_> = turn_events.read().map(|e| e.0.clone()).collect();
if turns.is_empty() {
return;
}
// Take the latest turn (in case multiple arrived, though this shouldn't happen)
let turn = turns.into_iter().last().unwrap();
trace!("Received turn {} with {} intents", turn.turn_number, turn.intents.len());
if let Some(ref mut current_turn_res) = current_turn {
// Update existing resource
current_turn_res.turn = turn;
current_turn_res.processed = false; // Mark as ready for processing
} else {
// Initialize resource on first turn
commands.insert_resource(CurrentTurn::new(turn));
}
}
/// Execute turn gameplay logic
/// Only runs when turn_is_ready() returns true (once per turn at 10 TPS)
pub fn execute_turn_gameplay_system(mut current_turn: ResMut<CurrentTurn>, mut game_instance: ResMut<GameInstance>, mut game_view: Option<ResMut<GameView>>, spawn_manager: Option<Res<crate::game::SpawnManager>>, mut spawn_phase: ResMut<SpawnPhase>, #[cfg(feature = "ui")] mut backend_messages: MessageWriter<BackendMessage>, server_handle: Option<Res<crate::networking::LocalTurnServerHandle>>) {
use std::sync::Arc;
let Some(ref mut game_view) = game_view else {
return;
};
let turn = &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());
game_instance.execute_turn(turn);
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 {
game_instance.handle_spawn(spawn.player_id, spawn.tile_index);
}
}
let total_land_tiles = game_instance.territory_manager.as_slice().iter().filter(|ownership| !ownership.is_water()).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<usize> = game_instance.territory_manager.iter_changes().collect();
// Convert ships to ShipView format
let ships = game_instance.ship_manager.get_ships().map(|ship| crate::networking::ShipView { id: ship.id, owner_id: ship.owner_id, current_tile: ship.get_current_tile() as u32, target_tile: ship.target_tile as u32, troops: ship.troops, path_progress: ship.current_path_index as u32, ticks_until_move: ship.ticks_per_tile.saturating_sub(ship.ticks_since_move), path: ship.path.iter().map(|&tile| tile as u32).collect() }).collect();
{
let _guard = tracing::trace_span!("create_game_view_in_execute_turn").entered();
**game_view = GameView { size: glam::U16Vec2::new(game_instance.territory_manager.width(), game_instance.territory_manager.height()), territories: Arc::from(game_instance.territory_manager.to_u16_vec().as_slice()), turn_number: game_instance.turn_number, total_land_tiles, changed_tiles, players: game_instance.player_manager.get_players().iter().map(|p| crate::networking::PlayerView { id: p.id as u16, color: p.color.to_rgba(), name: p.name.clone(), tile_count: p.get_territory_size() as u32, troops: p.get_troops() as u32, is_alive: p.is_alive() }).collect(), ships };
}
trace!("GameView updated: turn {}, {} players", game_view.turn_number, game_view.players.len());
if turn.turn_number == 0 && spawn_phase.active {
spawn_phase.active = false;
#[cfg(feature = "ui")]
backend_messages.write(BackendMessage::SpawnPhaseEnded);
info!("Spawn phase ended after Turn(0) execution");
if let Some(ref handle) = server_handle {
handle.resume();
info!("Local turn server resumed - game started");
}
}
// Mark turn as processed to prevent re-execution
current_turn.mark_processed();
}
/// System to handle FrontendMessage events
#[cfg(feature = "ui")]
#[allow(clippy::too_many_arguments)]
fn handle_frontend_messages_system(mut commands: Commands, mut frontend_messages: MessageReader<crate::ui::protocol::FrontendMessage>, game_instance: Option<Res<GameInstance>>, intent_receiver: Option<Res<IntentReceiver>>, mut attack_controls: Option<ResMut<AttackControls>>, mut spawn_phase: ResMut<SpawnPhase>, mut spawn_phase_init: ResMut<SpawnPhaseInitialized>, mut previous_spawn_state: ResMut<PreviousSpawnState>) {
use crate::ui::protocol::FrontendMessage;
use tracing::{debug, error, info};
for message in frontend_messages.read() {
match message {
FrontendMessage::StartGame => {
let _guard = tracing::debug_span!("handle_start_game").entered();
info!("Processing StartGame command");
if game_instance.is_some() {
error!("Game already running - ignoring StartGame");
continue;
}
let Some(ref intent_receiver) = intent_receiver else {
error!("IntentReceiver not available - cannot start game");
continue;
};
let terrain_data = {
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 = std::sync::Arc::new(terrain_data.clone());
commands.insert_resource(terrain_data);
let size = terrain_arc.size();
let width = size.x;
let height = size.y;
let 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((x as u32, y as u32)));
}
}
}
let params = crate::game::GameInitParams {
map_width: width,
map_height: height,
conquerable_tiles,
client_player_id: 0, // Human player is ID 0
intent_rx: intent_receiver.intent_rx.clone(),
terrain_data: terrain_arc,
};
crate::game::initialize_game_resources(&mut commands, params);
info!("Game initialized successfully");
}
FrontendMessage::QuitGame => {
info!("Processing QuitGame command");
if game_instance.is_some() {
// Remove all game-specific resources
commands.remove_resource::<GameInstance>();
commands.remove_resource::<crate::game::LocalPlayerContext>();
commands.remove_resource::<TurnReceiver>();
commands.remove_resource::<crate::game::SpawnManager>();
commands.remove_resource::<crate::game::SpawnTimeout>();
commands.remove_resource::<GameView>();
commands.remove_resource::<crate::TerrainData>();
commands.remove_resource::<crate::networking::TurnGenerator>();
// Reset permanent resources to default state
spawn_phase.active = false;
spawn_phase_init.emitted_initial = false;
previous_spawn_state.spawns.clear();
// Note: LocalTurnServerHandle cleanup requires World access
// It will be cleaned up automatically when the resource is dropped
info!("Game stopped and resources cleaned up");
}
}
FrontendMessage::PauseGame | FrontendMessage::ResumeGame => {
// TODO: Implement pause/resume functionality
}
FrontendMessage::SetAttackRatio { ratio } => {
if let Some(ref mut controls) = attack_controls {
controls.attack_ratio = ratio.clamp(0.01, 1.0);
debug!("Attack ratio set to {:.1}%", controls.attack_ratio * 100.0);
}
}
_ => {}
}
}
}

View File

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

View File

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

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,160 @@
//! Shared leaderboard data structures and utilities
//!
//! This module contains types and systems for managing leaderboard data
//! that are shared between desktop and WASM builds.
use bevy_ecs::prelude::*;
use crate::game::GameInstance;
use crate::networking::GameView;
#[cfg(not(target_arch = "wasm32"))]
use std::time::Instant;
#[cfg(target_arch = "wasm32")]
use web_time::Instant;
// Re-export UI types from protocol for convenience
pub use crate::ui::protocol::{AttackEntry, AttacksUpdatePayload, BackendMessage, LeaderboardEntry, LeaderboardSnapshot};
/// Convert RGBA color to hex string (without alpha)
pub fn rgba_to_hex(color: [f32; 4]) -> String {
let r = (color[0] * 255.0) as u8;
let g = (color[1] * 255.0) as u8;
let b = (color[2] * 255.0) as u8;
format!("{:02X}{:02X}{:02X}", r, g, b)
}
/// Resource to track last emitted leaderboard state for deduplication
#[derive(Resource, Default, Debug)]
pub struct LastLeaderboardDigest {
pub entries: Vec<(u16, String, u32, u32)>, // (id, name, tile_count, troops)
pub turn: u64,
}
/// Resource to track last emitted attacks state for deduplication
#[derive(Resource, Default, Debug)]
pub struct LastAttacksDigest {
pub entries: Vec<(usize, Option<usize>, u32, u64, bool)>, // (attacker_id, target_id, troops, start_turn, is_outgoing)
pub turn: u64,
}
/// Resource to throttle leaderboard snapshot emissions
#[derive(Resource, Debug)]
pub struct LeaderboardThrottle {
last_emission: Option<Instant>,
throttle_duration: core::time::Duration,
}
impl Default for LeaderboardThrottle {
fn default() -> Self {
Self {
last_emission: None,
throttle_duration: core::time::Duration::from_millis(1250), // 1.25 seconds
}
}
}
/// Build a complete leaderboard snapshot from current game state
/// Returns None if nothing has changed since last_digest
pub fn build_leaderboard_snapshot(game_view: &GameView, game: &GameInstance, last_digest: &mut LastLeaderboardDigest) -> Option<LeaderboardSnapshot> {
// Use cached total_land_tiles from GameView (performance optimization)
let total_land_tiles = game_view.total_land_tiles;
// Get player manager to look up names/colors
let players_by_id: std::collections::HashMap<usize, &crate::game::Player> = game.player_manager.get_players().iter().map(|p| (p.id, p)).collect();
// Build current digest for comparison (includes names now)
let current_entries: Vec<(u16, String, u32, u32)> = game_view
.players
.iter()
.map(|p| {
let player = players_by_id.get(&(p.id as usize));
let name = player.map(|pl| if pl.name.is_empty() { if pl.id == game.player_manager.client_player_id { "Player".to_string() } else { format!("Nation {}", pl.id) } } else { pl.name.clone() }).unwrap_or_else(|| format!("Nation {}", p.id));
(p.id, name, p.tile_count, p.troops)
})
.collect();
// Check if anything has changed (stats OR names)
if current_entries == last_digest.entries && game.turn_number == last_digest.turn {
return None; // No changes
}
// Update digest
last_digest.entries = current_entries;
last_digest.turn = game.turn_number;
// Build complete leaderboard entries (names + colors + stats)
let mut entries: Vec<LeaderboardEntry> = game_view
.players
.iter()
.map(|player| {
let player_data = players_by_id.get(&(player.id as usize));
let name = player_data.map(|p| if p.name.is_empty() { if p.id == game.player_manager.client_player_id { "Player".to_string() } else { format!("Nation {}", p.id) } } else { p.name.clone() }).unwrap_or_else(|| format!("Nation {}", player.id));
let color = player_data.map(|p| rgba_to_hex(p.color.to_rgba())).unwrap_or_else(|| "808080".to_string()); // Gray fallback
let territory_percent = if total_land_tiles > 0 { player.tile_count as f32 / total_land_tiles as f32 } else { 0.0 };
LeaderboardEntry { id: player.id, name, color, tile_count: player.tile_count, troops: player.troops, territory_percent }
})
.collect();
// Sort by tile count descending
entries.sort_by(|a, b| b.tile_count.cmp(&a.tile_count));
Some(LeaderboardSnapshot { turn: game.turn_number, total_land_tiles, entries, client_player_id: game.player_manager.client_player_id as u16 })
}
/// Bevy system that emits leaderboard snapshot events
pub fn emit_leaderboard_snapshot_system(game_view: Res<GameView>, game: Res<GameInstance>, mut last_digest: ResMut<LastLeaderboardDigest>, mut throttle: ResMut<LeaderboardThrottle>, mut backend_messages: MessageWriter<BackendMessage>) {
let _guard = tracing::debug_span!("emit_leaderboard_snapshot").entered();
// Check if enough time has passed since last emission
let now = Instant::now();
let should_emit = throttle.last_emission.map(|last| now.duration_since(last) >= throttle.throttle_duration).unwrap_or(true); // Emit on first call
if !should_emit {
return;
}
if let Some(snapshot) = build_leaderboard_snapshot(&game_view, &game, &mut last_digest) {
backend_messages.write(BackendMessage::LeaderboardSnapshot(snapshot));
throttle.last_emission = Some(now);
}
}
/// Build an attacks update payload from current game state
/// Always returns the current state (digest is used to prevent duplicate emissions)
pub fn build_attacks_update(game: &GameInstance, client_player_id: usize, last_digest: &mut LastAttacksDigest) -> Option<AttacksUpdatePayload> {
// Get attacks for the client player
let raw_attacks = game.active_attacks.get_attacks_for_player(client_player_id);
// Build current digest for comparison
let current_entries: Vec<(usize, Option<usize>, u32, u64, bool)> = raw_attacks.iter().map(|&(attacker_id, target_id, troops, start_turn, is_outgoing)| (attacker_id, target_id, troops as u32, start_turn, is_outgoing)).collect();
// Check if anything has changed
if current_entries == last_digest.entries {
return None; // No changes
}
// Update digest
last_digest.entries = current_entries;
last_digest.turn = game.turn_number;
// Build attack entries
let entries: Vec<AttackEntry> = raw_attacks.into_iter().map(|(attacker_id, target_id, troops, start_turn, is_outgoing)| AttackEntry { attacker_id: attacker_id as u16, target_id: target_id.map(|id| id as u16), troops: troops as u32, start_turn, is_outgoing }).collect();
Some(AttacksUpdatePayload { turn: game.turn_number, entries })
}
/// Bevy system that emits attacks update events
pub fn emit_attacks_update_system(game: Res<GameInstance>, mut last_digest: ResMut<LastAttacksDigest>, mut backend_messages: MessageWriter<BackendMessage>) {
let _guard = tracing::debug_span!("emit_attacks_update").entered();
let client_player_id = game.player_manager.client_player_id;
if let Some(payload) = build_attacks_update(&game, client_player_id, &mut last_digest) {
backend_messages.write(BackendMessage::AttacksUpdate(payload));
}
}

View File

@@ -0,0 +1,104 @@
//! UI/Frontend module for rendering and user interaction
//!
//! This module contains all frontend-related concerns including:
//! - Protocol definitions for frontend-backend communication
//! - Input handling
//! - Leaderboard management
//! - Platform transport abstraction
pub mod input;
pub mod leaderboard;
pub mod plugin;
pub mod protocol;
pub mod transport;
// Re-export commonly used types
pub use input::{InputEvent, InputState, KeyCode, MouseButton, TileCoord, WorldPos, tile_from_index, tile_to_index};
pub use leaderboard::{LastAttacksDigest, LastLeaderboardDigest, LeaderboardThrottle, build_attacks_update, build_leaderboard_snapshot, emit_attacks_update_system, emit_leaderboard_snapshot_system};
pub use plugin::FrontendPlugin;
pub use protocol::{AttackEntry, AttacksUpdatePayload, BackendMessage, CameraCommand, CameraStateUpdate, FrontendMessage, GameOutcome, LeaderboardEntry, LeaderboardSnapshot, MapQuery, MapQueryResponse, PaletteInit, RenderInit, RenderInputEvent, RgbColor, ShipUpdateVariant, ShipsUpdatePayload, SpawnCountdown, TerrainInit, TerrainPalette, TerrainType, TerritoryDelta, TerritorySnapshot, TileChange};
pub use transport::{FrontendTransport, RenderBridge, handle_camera_update, handle_render_input};
use crate::networking::GameView;
use bevy_ecs::prelude::*;
use std::collections::HashMap;
/// Resource to track currently highlighted nation for visual feedback
#[derive(Resource, Default, Debug)]
pub struct NationHighlightState {
pub highlighted_nation: Option<u16>,
}
/// System that tracks hovered nation and emits highlight events
pub fn emit_nation_highlight_system(input_state: NonSend<std::sync::Arc<std::sync::Mutex<InputState>>>, game_view: Res<GameView>, mut highlight_state: ResMut<NationHighlightState>, mut backend_messages: MessageWriter<BackendMessage>) {
let Ok(input) = input_state.lock() else {
return;
};
let new_highlighted = if let Some(tile_coord) = input.cursor_tile() {
let tile_index = tile_to_index(tile_coord, game_view.width());
let owner_id = game_view.get_owner(tile_index);
// Water (65535) and unclaimed (65534) should clear highlight
if owner_id >= 65534 { None } else { Some(owner_id) }
} else {
None
};
// Only emit if highlight changed
if new_highlighted != highlight_state.highlighted_nation {
highlight_state.highlighted_nation = new_highlighted;
backend_messages.write(BackendMessage::HighlightNation { nation_id: new_highlighted });
}
}
/// Resource to track previous ship states for delta updates
#[derive(Resource, Default)]
pub struct ShipStateTracker {
/// Map of ship ID to current_path_index
ship_indices: HashMap<u32, u32>,
}
/// System that emits ship update variants to the frontend (delta-based)
/// - Create: sent when ship first appears
/// - Move: sent only when current_path_index changes
/// - Destroy: sent when ship disappears
pub fn emit_ships_update_system(game_view: Res<GameView>, mut ship_tracker: ResMut<ShipStateTracker>, mut backend_messages: MessageWriter<BackendMessage>) {
let current_ship_ids: std::collections::HashSet<u32> = game_view.ships.iter().map(|s| s.id).collect();
let mut updates = Vec::new();
// Detect destroyed ships
for &ship_id in ship_tracker.ship_indices.keys() {
if !current_ship_ids.contains(&ship_id) {
updates.push(ShipUpdateVariant::Destroy { id: ship_id });
}
}
// Detect new ships and moved ships
for ship in &game_view.ships {
match ship_tracker.ship_indices.get(&ship.id) {
None => {
// New ship - send Create
updates.push(ShipUpdateVariant::Create { id: ship.id, owner_id: ship.owner_id, path: ship.path.clone(), troops: ship.troops });
ship_tracker.ship_indices.insert(ship.id, ship.path_progress);
}
Some(&prev_index) if prev_index != ship.path_progress => {
// Ship moved to next tile - send Move
updates.push(ShipUpdateVariant::Move { id: ship.id, current_path_index: ship.path_progress });
ship_tracker.ship_indices.insert(ship.id, ship.path_progress);
}
_ => {
// No change, do nothing
}
}
}
// Clean up destroyed ships from tracker
ship_tracker.ship_indices.retain(|id, _| current_ship_ids.contains(id));
// Only send if there are updates
if !updates.is_empty() {
backend_messages.write(BackendMessage::ShipsUpdate(ShipsUpdatePayload { turn: game_view.turn_number, updates }));
}
}

View File

@@ -0,0 +1,53 @@
//! Frontend plugin for UI/rendering integration
//!
//! This module provides the FrontendPlugin which handles all frontend communication
//! including rendering, input, and UI updates.
use bevy_ecs::prelude::*;
use bevy_ecs::schedule::common_conditions::resource_exists;
use crate::TerrainData;
use crate::app::{App, Plugin, Update};
use crate::networking::GameView;
use crate::ui::protocol::{BackendMessage, FrontendMessage};
use crate::ui::transport::{FrontendTransport, RenderBridge, emit_backend_messages_system, ingest_frontend_messages_system, send_initial_render_data, stream_territory_deltas};
/// Plugin to add frontend communication and UI systems to Bevy
pub struct FrontendPlugin<T: FrontendTransport> {
transport: T,
}
impl<T: FrontendTransport> FrontendPlugin<T> {
pub fn new(transport: T) -> Self {
Self { transport }
}
}
impl<T: FrontendTransport> Plugin for FrontendPlugin<T> {
fn build(&self, app: &mut App) {
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>.run_if(resource_exists::<GameView>).run_if(resource_exists::<TerrainData>).run_if(resource_exists::<RenderBridge<T>>), stream_territory_deltas::<T>.run_if(resource_exists::<GameView>).run_if(resource_exists::<RenderBridge<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,615 @@
//! Protocol for frontend-backend communication
//!
//! This module defines the bidirectional message protocol used for communication
//! between the game core (Bevy/Rust) and the frontend (PixiJS/TypeScript).
use bevy_ecs::message::Message;
use serde::{Deserialize, Serialize};
/// All messages sent from backend to frontend
#[derive(Debug, Clone, Serialize, Deserialize, Message)]
#[serde(tag = "msg_type")]
pub enum BackendMessage {
/// Atomic initialization message containing terrain, palette, and initial territories
RenderInit(RenderInit),
/// Full territory snapshot (typically only sent at initialization)
TerritorySnapshot(TerritorySnapshot),
/// Incremental territory changes (sent each turn)
TerritoryDelta(TerritoryDelta),
/// Initial terrain data (typically sent once, now part of RenderInit)
TerrainInit(TerrainInit),
/// Terrain color palette (typically sent once, now part of RenderInit)
TerrainPalette(TerrainPalette),
/// Player color palette (typically sent once, now part of RenderInit)
PaletteInit(PaletteInit),
/// Camera control commands from backend to frontend
CameraCommand(CameraCommand),
/// Response to map queries
MapQueryResponse(MapQueryResponse),
/// Complete leaderboard snapshot (includes names, colors, and stats)
LeaderboardSnapshot(LeaderboardSnapshot),
/// Dynamic attacks updates
AttacksUpdate(AttacksUpdatePayload),
/// Active ships on the map
ShipsUpdate(ShipsUpdatePayload),
/// Game has ended with the specified outcome
GameEnded { outcome: GameOutcome },
/// Spawn phase update
/// - countdown: None = phase active, waiting for first spawn
/// - countdown: Some = countdown in progress with epoch timestamp
SpawnPhaseUpdate { countdown: Option<SpawnCountdown> },
/// Spawn phase has ended, game is now active
SpawnPhaseEnded,
/// Highlight a specific nation (None to clear)
HighlightNation { nation_id: Option<u16> },
}
/// All messages sent from frontend to backend
#[derive(Debug, Clone, Serialize, Deserialize, Message)]
#[serde(tag = "msg_type")]
pub enum FrontendMessage {
/// Input event (mouse clicks, keyboard, hover)
InputEvent(RenderInputEvent),
/// Camera state update from frontend
CameraStateUpdate(CameraStateUpdate),
/// Query about the map state
MapQuery(MapQuery),
/// Start a new game
StartGame,
/// Quit the current game and return to menu
QuitGame,
/// Pause the game (local/singleplayer only)
PauseGame,
/// Resume the game (local/singleplayer only)
ResumeGame,
/// Set attack ratio (percentage of troops to use when attacking)
SetAttackRatio { ratio: f32 },
}
/// Terrain types for map tiles
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[repr(u8)]
pub enum TerrainType {
Water = 0,
Land = 1,
Mountain = 2,
}
/// Initial terrain data for the entire map
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TerrainInit {
/// Map size (width x height in tiles, each max 65535)
pub size: glam::U16Vec2,
/// Tile type IDs (one u8 per tile, referencing TerrainPalette)
/// Each value is an index into the terrain_palette colors array
///
/// Uses base64 for Tauri (native) to avoid slow JSON array serialization
/// Uses serde_bytes for WASM (efficient MessagePack/bincode serialization)
#[cfg_attr(not(target_arch = "wasm32"), serde(with = "base64_serde"))]
#[cfg_attr(target_arch = "wasm32", serde(with = "serde_bytes"))]
pub terrain_data: Vec<u8>,
}
impl TerrainInit {
/// Create terrain data from tile type IDs
pub fn from_tile_ids(size: glam::U16Vec2, tile_ids: Vec<u8>) -> Self {
assert_eq!(tile_ids.len(), (size.x as usize) * (size.y as usize), "Terrain data size mismatch");
Self { size, terrain_data: tile_ids }
}
/// Create terrain data from a legacy terrain enum array (for backward compatibility)
pub fn from_terrain(size: glam::U16Vec2, terrain: &[TerrainType]) -> Self {
let terrain_data: Vec<u8> = terrain.iter().map(|&t| t as u8).collect();
Self { size, terrain_data }
}
/// Extract tile type ID for a specific tile
pub fn get_tile_id(&self, index: usize) -> u8 {
self.terrain_data.get(index).copied().unwrap_or(0)
}
}
/// Terrain palette defining colors for each terrain shade/type
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TerrainPalette {
/// Color definitions for each terrain shade
/// Index in this array corresponds to the shade value in terrain_data
pub colors: Vec<RgbColor>,
}
/// Special tile ownership values
pub const WATER_TILE: u16 = 65534;
pub const UNCLAIMED_TILE: u16 = 65535;
/// Encode complete initialization data into binary format for channel streaming
///
/// This combines terrain and territory data into a single atomic payload to avoid
/// synchronization issues with multiple channels.
///
/// Format: [terrain_len:4][terrain_data][territory_len:4][territory_data]
///
/// 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: &[u16]) -> 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)
let claimed_tiles: Vec<(u32, u16)> = territories.iter().enumerate().filter(|&(_, &owner)| owner < WATER_TILE).map(|(index, &owner)| (index as u32, owner)).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());
}
// Combine into single payload with length prefixes
let total_size = 4 + terrain_data.len() + 4 + territory_data.len();
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);
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 player ID (0 = unclaimed)
pub owner_id: u16,
}
/// Delta update containing changed tiles for efficient streaming
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TerritoryDelta {
/// Turn number this delta applies to
pub turn: u64,
/// List of changed tiles since last update
pub changes: Vec<TileChange>,
}
/// Full territory snapshot for initial state (sparse binary format)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TerritorySnapshot {
/// Turn number for this snapshot
pub turn: u64,
/// Binary-encoded sparse territory data
/// Format: [count:4][changes...]
/// where changes = [index:4][owner:2] repeated count times
/// All tiles not in this list default to owner_id=0 (unclaimed)
///
/// Uses base64 for Tauri (native) to avoid slow JSON array serialization
/// Uses serde_bytes for WASM (efficient MessagePack/bincode serialization)
#[cfg_attr(not(target_arch = "wasm32"), serde(with = "base64_serde"))]
#[cfg_attr(target_arch = "wasm32", serde(with = "serde_bytes"))]
pub data: Vec<u8>,
}
/// Base64 serialization for Vec<u8> (Tauri IPC optimization)
/// Tauri uses JSON for IPC, so Vec<u8> becomes [0,1,2,...] which is very slow
/// Base64 string is ~10x faster to serialize/deserialize than JSON array
#[cfg(not(target_arch = "wasm32"))]
mod base64_serde {
use base64::{Engine as _, engine::general_purpose::STANDARD};
use serde::{Deserialize, Deserializer, Serializer};
#[tracing::instrument(skip_all, fields(data_len = data.len()))]
pub fn serialize<S>(data: &Vec<u8>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let encoded = STANDARD.encode(data);
serializer.serialize_str(&encoded)
}
#[tracing::instrument(skip_all)]
pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
where
D: Deserializer<'de>,
{
let encoded = String::deserialize(deserializer)?;
let decoded = STANDARD.decode(&encoded).map_err(serde::de::Error::custom)?;
tracing::trace!(decoded_len = decoded.len(), "base64 decoded");
Ok(decoded)
}
}
impl TerritorySnapshot {
/// Create sparse binary snapshot from full territory array
/// Only includes player-owned tiles (< WATER_TILE)
/// Excludes special values: WATER_TILE (65534), UNCLAIMED_TILE (65535)
pub fn encode(turn: u64, territories: &[u16]) -> Self {
let claimed_tiles: Vec<(u32, u16)> = territories.iter().enumerate().filter(|&(_, &owner)| owner < WATER_TILE).map(|(index, &owner)| (index as u32, owner)).collect();
let count = claimed_tiles.len() as u32;
let mut data = Vec::with_capacity(4 + claimed_tiles.len() * 6);
data.extend_from_slice(&count.to_le_bytes());
for (index, owner) in claimed_tiles {
data.extend_from_slice(&index.to_le_bytes());
data.extend_from_slice(&owner.to_le_bytes());
}
Self { turn, data }
}
/// Decode binary snapshot back to list of claimed tiles
/// Returns list of (tile_index, owner_id) pairs
pub fn decode(&self) -> Option<Vec<(u32, u16)>> {
if self.data.len() < 4 {
return None; // Not enough data for count
}
let count = u32::from_le_bytes([self.data[0], self.data[1], self.data[2], self.data[3]]) as usize;
let expected_size = 4 + count * 6;
if self.data.len() != expected_size {
return None; // Invalid size
}
let mut tiles = Vec::with_capacity(count);
for i in 0..count {
let offset = 4 + i * 6;
let index = u32::from_le_bytes([self.data[offset], self.data[offset + 1], self.data[offset + 2], self.data[offset + 3]]);
let owner = u16::from_le_bytes([self.data[offset + 4], self.data[offset + 5]]);
tiles.push((index, owner));
}
Some(tiles)
}
}
/// Binary format for efficient territory delta streaming (Tauri)
/// This is for the pixel streaming channel, separate from JSON messages
#[derive(Debug)]
pub struct BinaryTerritoryDelta {
/// Raw bytes: [turn:8][count:4][changes...]
/// Each change: [index:4][owner:2] = 6 bytes
pub data: Vec<u8>,
}
impl BinaryTerritoryDelta {
/// Create binary delta from territory changes
pub fn encode(turn: u64, changes: &[TileChange]) -> Vec<u8> {
let count = changes.len() as u32;
let mut data = Vec::with_capacity(12 + changes.len() * 6);
// Header: turn (8 bytes) + count (4 bytes)
data.extend_from_slice(&turn.to_le_bytes());
data.extend_from_slice(&count.to_le_bytes());
// Changes: each is index (4 bytes) + owner (2 bytes)
for change in changes {
data.extend_from_slice(&change.index.to_le_bytes());
data.extend_from_slice(&change.owner_id.to_le_bytes());
}
data
}
/// Decode binary delta back to structured format
pub fn decode(data: &[u8]) -> Option<(u64, Vec<TileChange>)> {
if data.len() < 12 {
return None; // Not enough data for header
}
let turn = u64::from_le_bytes([data[0], data[1], data[2], data[3], data[4], data[5], data[6], data[7]]);
let count = u32::from_le_bytes([data[8], data[9], data[10], data[11]]) as usize;
let expected_size = 12 + count * 6;
if data.len() != expected_size {
return None; // Invalid size
}
let mut changes = Vec::with_capacity(count);
for i in 0..count {
let offset = 12 + i * 6;
let index = u32::from_le_bytes([data[offset], data[offset + 1], data[offset + 2], data[offset + 3]]);
let owner_id = u16::from_le_bytes([data[offset + 4], data[offset + 5]]);
changes.push(TileChange { index, owner_id });
}
Some((turn, changes))
}
}
/// RGB color for player palette
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct RgbColor {
pub r: u8,
pub g: u8,
pub b: u8,
}
/// Initial palette data mapping player IDs to colors
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PaletteInit {
/// Player ID to color mapping
/// Index in the vec corresponds to player_id
pub colors: Vec<RgbColor>,
}
/// Commands sent from backend to control the camera
#[derive(Debug, Clone, Serialize, Deserialize, bevy_ecs::message::Message)]
#[serde(tag = "type")]
pub enum CameraCommand {
/// Center camera on a specific tile
CenterOnTile {
tile_index: u32,
#[serde(default)]
animate: bool,
},
/// Highlight a rectangular region
HighlightRegion {
position: glam::U16Vec2,
size: glam::U16Vec2,
#[serde(default = "default_highlight_duration")]
duration_ms: u32,
},
/// Set camera zoom level
SetZoom {
zoom: f32,
#[serde(default)]
animate: bool,
},
/// Pan camera by offset
PanBy {
dx: f32,
dy: f32,
#[serde(default)]
animate: bool,
},
}
fn default_highlight_duration() -> u32 {
3000 // 3 seconds
}
/// Camera state update sent from frontend to backend
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CameraStateUpdate {
/// Current camera X position (world coordinates)
pub x: f32,
/// Current camera Y position (world coordinates)
pub y: f32,
/// Current zoom level (1.0 = normal)
pub zoom: f32,
}
/// Queries sent from frontend to backend about the map
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum MapQuery {
/// Get owner of tile at world coordinates
GetOwnerAt { x: f32, y: f32 },
/// Get detailed tile info by index
GetTileInfo { tile_index: u32 },
/// Find any tile owned by player (for camera centering)
FindPlayerTerritory { player_id: u16 },
/// Convert screen coordinates to tile index
ScreenToTile { screen_x: f32, screen_y: f32 },
}
/// Response to map queries
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum MapQueryResponse {
/// Response to GetOwnerAt
Owner { owner_id: u16, tile_index: Option<u32> },
/// Response to GetTileInfo
TileInfo { tile_index: u32, owner_id: u16, terrain: TerrainType, troops: u32 },
/// Response to FindPlayerTerritory
PlayerTerritory { tile_index: Option<u32> },
/// Response to ScreenToTile
TileIndex { index: Option<u32> },
}
/// Input event sent from frontend to backend
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum RenderInputEvent {
/// Mouse click on the map
MapClick {
/// Tile index clicked (if over a valid tile)
tile_index: Option<u32>,
/// World coordinates of click
world_x: f32,
world_y: f32,
/// Mouse button (0=left, 1=middle, 2=right)
button: u8,
},
/// Key press event
KeyPress {
/// Key code as string (e.g., "KeyC", "Digit1")
key: String,
/// Whether key is pressed (true) or released (false)
pressed: bool,
},
/// Mouse moved over map
MapHover {
/// Tile index under cursor (if any)
tile_index: Option<u32>,
/// World coordinates
world_x: f32,
world_y: f32,
},
}
/// Initialization message containing palette and initial territory state
/// For Tauri (desktop), terrain and territory sent via binary channels for performance
/// For WASM (browser), all data included in the message for compatibility
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RenderInit {
/// Terrain data (only present for WASM builds)
#[serde(skip_serializing_if = "Option::is_none")]
pub terrain: Option<TerrainInit>,
/// Terrain palette (only present for WASM builds)
#[serde(skip_serializing_if = "Option::is_none")]
pub terrain_palette: Option<TerrainPalette>,
/// Player color palette
pub palette: PaletteInit,
/// Initial territory ownership (only present for WASM builds)
#[serde(skip_serializing_if = "Option::is_none")]
pub initial_territories: Option<TerritorySnapshot>,
}
/// Unified leaderboard entry containing both static and dynamic data
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LeaderboardEntry {
pub id: u16,
pub name: String,
pub color: String, // Hex color without alpha, e.g. "0A44FF"
pub tile_count: u32,
pub troops: u32,
pub territory_percent: f32,
}
/// Complete leaderboard snapshot (replaces separate Init/Update)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LeaderboardSnapshot {
pub turn: u64,
pub total_land_tiles: u32,
pub entries: Vec<LeaderboardEntry>,
pub client_player_id: u16,
}
/// Outcome of the game
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum GameOutcome {
/// Player won the game
Victory,
/// Player lost the game
Defeat,
}
/// Single attack entry for attacks UI
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AttackEntry {
pub attacker_id: u16,
pub target_id: Option<u16>, // None for unclaimed territory
pub troops: u32,
pub start_turn: u64,
pub is_outgoing: bool,
}
/// Attacks update payload
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AttacksUpdatePayload {
pub turn: u64,
pub entries: Vec<AttackEntry>,
}
/// Ships update payload with lifecycle variants
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ShipsUpdatePayload {
pub turn: u64,
pub updates: Vec<ShipUpdateVariant>,
}
/// Ship update variants for efficient delta updates
/// NOTE: SHIP_TICKS_PER_TILE (1) must be synchronized between backend and frontend
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum ShipUpdateVariant {
/// Ship created - full initial state
Create {
id: u32,
owner_id: u16,
path: Vec<u32>,
troops: u32, // Static value, currently unused for rendering
},
/// Ship moved to next tile in path
Move { id: u32, current_path_index: u32 },
/// Ship destroyed (arrived or cancelled)
Destroy { id: u32 },
}
// TODO: On client reconnection/late-join, send Create variants for all active ships
/// Legacy ship data (deprecated - remove after migration)
#[derive(Debug, Clone, Serialize, Deserialize)]
#[deprecated(note = "Use ShipUpdateVariant instead")]
pub struct ShipData {
pub id: u32,
pub owner_id: u16,
pub current_tile: u32,
pub target_tile: u32,
pub troops: u32,
pub path_progress: u32,
pub ticks_until_move: u32,
}
/// Countdown state for spawn phase
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpawnCountdown {
pub started_at_ms: u64,
pub duration_secs: f32,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_terrain_tile_ids() {
let terrain = vec![TerrainType::Water, TerrainType::Land, TerrainType::Mountain, TerrainType::Land, TerrainType::Water];
let init = TerrainInit::from_terrain(glam::U16Vec2::new(5, 1), &terrain);
assert_eq!(init.terrain_data.len(), 5);
assert_eq!(init.get_tile_id(0), TerrainType::Water as u8);
assert_eq!(init.get_tile_id(1), TerrainType::Land as u8);
assert_eq!(init.get_tile_id(2), TerrainType::Mountain as u8);
assert_eq!(init.get_tile_id(3), TerrainType::Land as u8);
assert_eq!(init.get_tile_id(4), TerrainType::Water as u8);
}
#[test]
fn test_binary_delta_encoding() {
let changes = vec![TileChange { index: 100, owner_id: 1 }, TileChange { index: 200, owner_id: 2 }, TileChange { index: 300, owner_id: 0 }];
let encoded = BinaryTerritoryDelta::encode(42, &changes);
assert_eq!(encoded.len(), 12 + 3 * 6);
let decoded = BinaryTerritoryDelta::decode(&encoded).unwrap();
assert_eq!(decoded.0, 42);
assert_eq!(decoded.1.len(), 3);
assert_eq!(decoded.1[0].index, 100);
assert_eq!(decoded.1[0].owner_id, 1);
}
}

View File

@@ -0,0 +1,263 @@
//! Shared render bridge infrastructure for platform-agnostic rendering
//!
//! This module provides the common logic for rendering bridges across platforms
//! (WASM, Tauri, etc.), with platform-specific transport mechanisms abstracted
//! behind the RenderBridgeTransport trait.
use crate::TerrainData;
use crate::networking::GameView;
use crate::ui::protocol::{BackendMessage, BinaryTerritoryDelta, CameraStateUpdate, FrontendMessage, PaletteInit, RenderInit, RenderInputEvent, RgbColor, TerrainInit, TerrainPalette, TerritoryDelta, TerritorySnapshot, TileChange};
use bevy_ecs::prelude::*;
use tracing::{error, info, trace, warn};
/// Trait for platform-specific frontend communication
///
/// This abstracts the actual mechanism for bidirectional frontend communication,
/// allowing WASM (JS callbacks), Tauri (events), and other platforms to implement
/// their own transport while sharing the core logic.
pub trait FrontendTransport: Send + Sync + Clone + 'static {
/// Send a message from backend to frontend
fn send_backend_message(&self, message: &BackendMessage) -> Result<(), String>;
/// Check if this transport supports binary init channel streaming
///
/// Returns true if send_init_binary is implemented (Tauri)
/// Returns false if init data should be sent via JSON messages (WASM)
fn supports_init_binary(&self) -> bool {
false // Default: use JSON messages
}
/// Send binary initialization data (terrain + territory) for initial load
///
/// Default implementation does nothing - platforms that support
/// channel streaming can override this.
fn send_init_binary(&self, _data: Vec<u8>) -> Result<(), String> {
Ok(()) // No-op by default
}
/// Send binary territory delta data (optional, mainly for Tauri)
///
/// Default implementation does nothing - platforms that support
/// binary streaming can override this.
fn send_binary_delta(&self, _data: Vec<u8>) -> Result<(), String> {
Ok(()) // No-op by default
}
/// Try to receive a message from the frontend
///
/// Returns `Some(message)` if a message is available, `None` if not.
/// This should be non-blocking and called frequently (e.g., every frame).
fn try_recv_frontend_message(&self) -> Option<FrontendMessage>;
}
/// Resource for managing frontend communication state
#[derive(Resource)]
pub struct RenderBridge<T: FrontendTransport> {
pub transport: T,
/// Track if we've sent initial data
pub(crate) initialized: bool,
}
impl<T: FrontendTransport> RenderBridge<T> {
pub fn new(transport: T) -> Self {
Self { transport, initialized: false }
}
/// Reset the bridge to allow re-initialization
/// This should be called when a game is quit to ensure fresh data is sent on next game start
pub fn reset(&mut self) {
self.initialized = false;
}
}
/// System to send initial render data (terrain, palette, initial territories)
pub fn send_initial_render_data<T: FrontendTransport>(game_view: Res<GameView>, terrain_data: Res<TerrainData>, mut bridge: 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", width = game_view.width(), height = game_view.height(), player_count = game_view.players.len()).entered();
info!("Building RenderInit message (map: {}x{}, {} players)", game_view.width(), game_view.height(), game_view.players.len());
// 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());
// For transports that support binary channels (Tauri), send terrain and territory via single channel
// For other transports (WASM), include all data in RenderInit message
let (terrain_opt, terrain_palette_opt, initial_territories_opt) = if bridge.transport.supports_init_binary() {
let _guard = tracing::trace_span!("send_init_binary", terrain_size = tile_ids.len(), territory_size = game_view.territories.len()).entered();
let binary_init = crate::ui::protocol::encode_init_binary(size, tile_ids, &palette_colors, &game_view.territories);
if let Err(e) = bridge.transport.send_init_binary(binary_init) {
error!("Failed to send init binary data: {}", e);
bridge.initialized = false;
return;
}
(None, None, None) // Don't include in RenderInit
} else {
// Include all data in RenderInit for WASM
let terrain = TerrainInit::from_tile_ids(size, tile_ids.to_vec());
let terrain_palette = TerrainPalette { colors: palette_colors.clone() };
let initial_territories = TerritorySnapshot::encode(game_view.turn_number, &game_view.territories);
(Some(terrain), Some(terrain_palette), Some(initial_territories))
};
// Build palette component
let palette = {
let _guard = tracing::trace_span!("build_player_palette").entered();
// Allocate only enough space for active players + a small buffer
// This avoids the wasteful 65K allocation (1MB+ of memory)
let max_player_id = game_view.players.iter().map(|p| p.id).max().unwrap_or(0) as usize;
// Allocate palette size as: max(256, max_player_id + 1) to handle typical player counts
// Most games have <100 players, so this is ~256 bytes instead of 1MB+
let palette_size = (max_player_id + 1).max(256);
let mut colors = vec![RgbColor { r: 0, g: 0, b: 0 }; palette_size];
for player in &game_view.players {
// Player color is already in [r, g, b, a] format as f32
colors[player.id as usize] = RgbColor { r: (player.color[0] * 255.0) as u8, g: (player.color[1] * 255.0) as u8, b: (player.color[2] * 255.0) as u8 };
}
PaletteInit { colors }
};
// Create initialization message
// For Tauri: terrain and territory sent via binary channels (None values)
// For WASM: all data included in message (Some values)
let render_init = RenderInit { terrain: terrain_opt, terrain_palette: terrain_palette_opt, palette, initial_territories: initial_territories_opt };
// Send metadata message
// Note: initialized flag is already set above to prevent retries
{
let _guard = tracing::trace_span!("send_render_init").entered();
if let Err(e) = bridge.transport.send_backend_message(&BackendMessage::RenderInit(render_init)) {
error!("Failed to send RenderInit message (will not retry): {}", e);
return;
}
}
if bridge.transport.supports_init_binary() {
info!("RenderInit sent successfully (terrain+territory sent via binary channel)");
} else {
info!("RenderInit sent successfully (all data included in message)");
}
}
/// System to detect and stream territory changes
pub fn stream_territory_deltas<T: FrontendTransport>(game_view: Res<GameView>, bridge: 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();
// Use efficient changed_tiles from TerritoryManager's ChangeBuffer instead of scanning
if !game_view.changed_tiles.is_empty() {
let turn = game_view.turn_number;
// Build delta from the pre-tracked changes
let changes: Vec<TileChange> = game_view.changed_tiles.iter().map(|&index| TileChange { index: index as u32, owner_id: game_view.territories[index] }).collect();
// Send binary format for platforms that support it (e.g., Tauri)
let binary_data = BinaryTerritoryDelta::encode(turn, &changes);
if let Err(e) = bridge.transport.send_binary_delta(binary_data) {
error!("Failed to send binary territory delta: {}", e);
}
// Send structured format
let delta = TerritoryDelta { turn, changes };
if let Err(e) = bridge.transport.send_backend_message(&BackendMessage::TerritoryDelta(delta)) {
error!("Failed to send territory delta: {}", e);
}
}
}
/// Handle render input events from the frontend
///
/// This function processes input events and updates the shared InputState.
/// It should be called from platform-specific command handlers.
pub fn handle_render_input(event: &RenderInputEvent, input_state: &mut crate::ui::input::InputState, map_width: 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(())
}
/// Handle camera state updates from the frontend
///
/// This is a simple wrapper for consistency. Camera state is typically stored
/// in a shared Arc<Mutex<Option<CameraStateUpdate>>> resource.
pub fn handle_camera_update(update: CameraStateUpdate, camera_state: &mut Option<CameraStateUpdate>) -> Result<(), String> {
*camera_state = Some(update);
Ok(())
}
/// System that reads BackendMessage events and sends them through the transport
pub(crate) fn emit_backend_messages_system<T: FrontendTransport>(mut events: MessageReader<BackendMessage>, bridge: Res<RenderBridge<T>>) {
for event in events.read() {
if let Err(e) = bridge.transport.send_backend_message(event) {
warn!("Failed to send backend message through transport: {}", e);
}
}
}
/// System that polls the transport for incoming frontend messages and emits them as events
pub(crate) fn ingest_frontend_messages_system<T: FrontendTransport>(mut messages: MessageWriter<FrontendMessage>, bridge: Res<RenderBridge<T>>) {
while let Some(message) = bridge.transport.try_recv_frontend_message() {
messages.write(message);
}
}

View File

@@ -0,0 +1,50 @@
use borders_core::game::terrain::{MapManifest, MapMetadata, TerrainData, TileType};
use borders_core::game::tilemap::TileMap;
fn create_test_terrain(width: usize, height: usize) -> TerrainData {
let tile_types = vec![TileType { name: "water".to_string(), color_base: "blue".to_string(), color_variant: 0, conquerable: false, navigable: true, expansion_time: 255, expansion_cost: 255 }, TileType { name: "land".to_string(), color_base: "green".to_string(), color_variant: 0, conquerable: true, navigable: false, expansion_time: 50, expansion_cost: 50 }];
let mut terrain_data_raw = vec![0; width * height];
terrain_data_raw[5] = 0x80; // Make position 5 land
terrain_data_raw[10] = 0x85; // Make position 10 land with magnitude 5
let tiles: Vec<u8> = terrain_data_raw.iter().map(|&byte| if byte & 0x80 != 0 { 1 } else { 0 }).collect();
let terrain_data = TileMap::from_vec(width as 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((0, 0)));
assert!(terrain.is_land((5, 0)));
}
#[test]
fn test_is_conquerable() {
let terrain = create_test_terrain(10, 10);
assert!(!terrain.is_conquerable((0, 0)));
assert!(terrain.is_conquerable((5, 0)));
}
#[test]
fn test_is_navigable() {
let terrain = create_test_terrain(10, 10);
assert!(terrain.is_navigable(glam::U16Vec2::new(0, 0)));
assert!(!terrain.is_navigable(glam::U16Vec2::new(5, 0)));
}
#[test]
fn test_terrain_magnitude() {
let terrain = create_test_terrain(10, 10);
assert_eq!(terrain.terrain_magnitude((0, 1)), 5);
}
#[test]
fn test_expansion_properties() {
let terrain = create_test_terrain(10, 10);
assert_eq!(terrain.get_expansion_time((5, 0)), 50);
assert_eq!(terrain.get_expansion_cost((5, 0)), 50);
}

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

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

Binary file not shown.

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,51 @@
use borders_core::telemetry::{self, TelemetryEvent};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use tauri::Manager;
/// Analytics event from the frontend
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnalyticsEventPayload {
pub event: String,
#[serde(default)]
pub properties: HashMap<String, serde_json::Value>,
}
/// Tauri command to track analytics events from the frontend
#[tauri::command]
pub async fn track_analytics_event(payload: AnalyticsEventPayload) -> Result<(), String> {
tracing::debug!("Tracking analytics event: {}", payload.event);
let event = TelemetryEvent { event: payload.event, properties: payload.properties };
// Track the event asynchronously (Tauri handles the async context)
telemetry::track(event).await;
Ok(())
}
/// Tauri command to flush pending analytics events
#[tauri::command]
pub async fn flush_analytics() -> Result<(), String> {
if let Some(client) = telemetry::client() {
client.flush().await;
Ok(())
} else {
Err("Telemetry client not initialized".to_string())
}
}
/// Tauri command to request app exit
///
/// Simply closes the window - analytics flush happens in ExitRequested event handler
#[tauri::command]
pub async fn request_exit(app_handle: tauri::AppHandle) -> Result<(), String> {
tracing::debug!("Exit requested via command");
// Close the window (will trigger ExitRequested event → analytics flush)
if let Some(window) = app_handle.get_webview_window("main") {
window.close().map_err(|e| e.to_string())?;
}
Ok(())
}

View File

@@ -0,0 +1,105 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use crate::plugin::{TauriPlugin, generate_tauri_context};
use borders_core::app::App;
use borders_core::time::Time;
mod analytics;
mod plugin;
mod render_bridge;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let mut app = App::new();
// Initialize time tracking
app.insert_resource(Time::new());
TauriPlugin::new(|| {
let _guard = tracing::trace_span!("tauri_build").entered();
tauri::Builder::default().plugin(tauri_plugin_opener::init()).plugin(tauri_plugin_process::init()).invoke_handler(tauri::generate_handler![render_bridge::register_init_channel, render_bridge::send_frontend_message, render_bridge::handle_render_input, render_bridge::handle_camera_update, render_bridge::handle_map_query, render_bridge::get_game_state, analytics::track_analytics_event, analytics::flush_analytics, analytics::request_exit,]).build(generate_tauri_context()).expect("error while building tauri application")
})
.build_and_run(app);
}
fn main() {
// Initialize tracing before Bevy
#[cfg(feature = "tracy")]
{
use tracing_subscriber::fmt::format::DefaultFields;
// Initialize Tracy profiler client
let _ = tracy_client::Client::start();
use tracing_subscriber::layer::SubscriberExt;
struct BareTracyConfig {
fmt: DefaultFields,
}
impl tracing_tracy::Config for BareTracyConfig {
type Formatter = DefaultFields;
fn formatter(&self) -> &Self::Formatter {
&self.fmt
}
fn format_fields_in_zone_name(&self) -> bool {
false
}
}
let tracy_layer = tracing_tracy::TracyLayer::new(BareTracyConfig { fmt: DefaultFields::default() });
tracing::subscriber::set_global_default(tracing_subscriber::registry().with(tracy_layer)).expect("setup tracy layer");
}
#[cfg(not(feature = "tracy"))]
{
use tracing_subscriber::fmt::time::FormatTime;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
#[cfg(debug_assertions)]
let log_filter = "borders_core=debug,borders_protocol=debug,borders_desktop=debug,iron_borders=debug,info";
#[cfg(not(debug_assertions))]
let log_filter = "borders_core=warn,borders_protocol=warn,iron_borders=warn,error";
struct CustomTimeFormat;
impl FormatTime for CustomTimeFormat {
fn format_time(&self, w: &mut tracing_subscriber::fmt::format::Writer<'_>) -> std::fmt::Result {
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap();
let total_secs = now.as_secs();
let nanos = now.subsec_nanos();
let secs_in_day = total_secs % 86400;
let hours = secs_in_day / 3600;
let minutes = (secs_in_day % 3600) / 60;
let seconds = secs_in_day % 60;
let millis = nanos / 1_000_000;
let micros = (nanos / 1_000) % 1_000;
write!(w, "{:02}:{:02}:{:02}.{:03}{:03}", hours, minutes, seconds, millis, micros)
}
}
tracing_subscriber::registry().with(tracing_subscriber::EnvFilter::new(log_filter)).with(tracing_subscriber::fmt::layer().with_timer(CustomTimeFormat)).init();
}
// Log build information
tracing::info!(git_commit = borders_core::build_info::git_commit_short(), build_time = borders_core::build_info::BUILD_TIME, "Iron Borders v{} © 2025 Ryan Walters. All Rights Reserved.", borders_core::build_info::VERSION);
// Initialize telemetry
{
let _guard = tracing::trace_span!("telemetry_init").entered();
tokio::runtime::Runtime::new().unwrap().block_on(async {
borders_core::telemetry::init(borders_core::telemetry::TelemetryConfig::default()).await;
borders_core::telemetry::track_session_start().await;
});
tracing::info!("Observability ready");
}
run();
}

View File

@@ -0,0 +1,199 @@
//! Tauri-Bevy integration plugin
//!
//! This module provides the main integration between Tauri and Bevy, handling
//! the main application loop and event bridging.
use borders_core::app::{App, Plugin, Update};
use borders_core::time::Time;
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tauri::{Manager, RunEvent};
#[cfg(not(target_arch = "wasm32"))]
use std::time::Instant;
#[cfg(target_arch = "wasm32")]
use web_time::Instant;
use crate::render_bridge::{TauriRenderBridgeTransport, cache_leaderboard_snapshot_system};
const TARGET_FPS: f64 = 60.0;
pub fn generate_tauri_context() -> tauri::Context {
tauri::generate_context!()
}
fn setup_tauri_integration(app: &mut App, tauri_app: &tauri::AppHandle, shared_render_state: Arc<Mutex<Option<borders_core::ui::protocol::RenderInit>>>, shared_leaderboard_state: Arc<Mutex<Option<borders_core::ui::protocol::LeaderboardSnapshot>>>) {
let _guard = tracing::trace_span!("setup_tauri_integration").entered();
tracing::debug!("Setup tauri integration");
// Register state for render bridge commands
tauri_app.manage(Arc::new(Mutex::new(None::<borders_core::ui::protocol::CameraStateUpdate>)));
tauri_app.manage(Arc::new(Mutex::new(None::<borders_core::networking::GameView>)));
// InputState - shared between Tauri commands and ECS systems
let input_state_shared = Arc::new(Mutex::new(borders_core::ui::input::InputState::new()));
tauri_app.manage(input_state_shared.clone());
app.insert_non_send_resource(input_state_shared);
// Register shared state with Tauri (for get_game_state command)
tauri_app.manage(shared_render_state.clone());
tauri_app.manage(shared_leaderboard_state.clone());
// Get the message queue and init channel from the transport (already added as plugin)
let transport = app.world().get_resource::<borders_core::ui::RenderBridge<TauriRenderBridgeTransport>>().expect("RenderBridge should be added by plugin");
let message_queue = transport.transport.inbound_messages();
let init_channel_storage = transport.transport.init_channel();
tauri_app.manage(message_queue);
tauri_app.manage(init_channel_storage);
// Store shared states in world
app.insert_non_send_resource(shared_leaderboard_state);
}
pub struct TauriPlugin {
setup: Box<dyn Fn() -> tauri::App + Send + Sync>,
}
impl TauriPlugin {
pub fn new<F>(setup: F) -> Self
where
F: Fn() -> tauri::App + Send + Sync + 'static,
{
Self { setup: Box::new(setup) }
}
}
impl TauriPlugin {
pub fn build_and_run(self, mut app: App) -> ! {
let tauri_app = {
let _guard = tracing::debug_span!("tauri_plugin_build").entered();
let tauri_app = (self.setup)();
// Create shared state for game state recovery
let shared_render_state = Arc::new(Mutex::new(None::<borders_core::ui::protocol::RenderInit>));
let shared_leaderboard_state = Arc::new(Mutex::new(None::<borders_core::ui::protocol::LeaderboardSnapshot>));
// Create transport for Tauri frontend (handles both render and UI communication)
let transport = TauriRenderBridgeTransport::new(tauri_app.handle().clone(), shared_render_state.clone());
// Add the render bridge plugin to handle all frontend communication
borders_core::ui::FrontendPlugin::new(transport).build(&mut app);
// Set up Tauri integration directly (no startup system needed)
setup_tauri_integration(&mut app, tauri_app.handle(), shared_render_state.clone(), shared_leaderboard_state.clone());
// Add the leaderboard caching system
app.add_systems(Update, cache_leaderboard_snapshot_system);
// Pre-initialize game plugin BEFORE entering the event loop
// This prevents a 300ms delay waiting for the first loop iteration
{
let _guard = tracing::debug_span!("pre_initialize_game").entered();
tracing::info!("Pre-initializing game systems...");
borders_core::GamePlugin::new(borders_core::plugin::NetworkMode::Local).build(&mut app);
app.run_startup();
app.finish();
app.cleanup();
tracing::info!("Game systems pre-initialized");
}
tauri_app
};
// Run the app (already initialized)
run_tauri_app(app, tauri_app, true); // Pass true to skip re-init
std::process::exit(0)
}
}
pub fn run_tauri_app(app: App, tauri_app: tauri::App, already_initialized: bool) {
let app_rc = Rc::new(RefCell::new(app));
let mut tauri_app = tauri_app;
let mut is_initialized = already_initialized; // Skip init if already done
let mut last_frame_time = Instant::now();
let target_frame_duration = Duration::from_secs_f64(1.0 / TARGET_FPS);
loop {
let _guard = tracing::trace_span!("main_frame").entered();
let frame_start = Instant::now();
#[allow(deprecated)]
tauri_app.run_iteration(move |_app_handle, event: RunEvent| {
match event {
tauri::RunEvent::Ready => {
// Event acknowledged, actual setup happens below
}
tauri::RunEvent::ExitRequested { .. } => {
// Track session end and flush analytics before exit
if borders_core::telemetry::client().is_some() {
tracing::debug!("ExitRequested: tracking session end and flushing analytics");
// Create a minimal runtime for blocking operations
let runtime = tokio::runtime::Builder::new_current_thread().enable_time().enable_io().build().expect("Failed to create tokio runtime for flush");
runtime.block_on(async {
// Track session end event
borders_core::telemetry::track_session_end().await;
// Flush all pending events (the batch-triggered send is now synchronous)
if let Some(client) = borders_core::telemetry::client() {
let timeout = std::time::Duration::from_millis(500);
match tokio::time::timeout(timeout, client.flush()).await {
Ok(_) => {
tracing::debug!("Analytics flushed successfully before exit")
}
Err(_) => {
tracing::warn!("Analytics flush timed out after 500ms")
}
}
}
});
}
}
_ => (),
}
});
if tauri_app.webview_windows().is_empty() {
tauri_app.cleanup_before_exit();
break;
}
// Initialize game plugin on first iteration after Tauri is ready
if !is_initialized {
let _guard = tracing::debug_span!("app_initialization").entered();
let mut app = app_rc.borrow_mut();
// Add core game plugin
borders_core::GamePlugin::new(borders_core::plugin::NetworkMode::Local).build(&mut app);
app.run_startup();
app.finish();
app.cleanup();
is_initialized = true;
last_frame_time = Instant::now(); // Reset timer after initialization
tracing::info!("Game initialized");
}
// Update time resource with delta from PREVIOUS frame
let mut app = app_rc.borrow_mut();
let delta = frame_start.duration_since(last_frame_time);
if let Some(mut time) = app.world_mut().get_resource_mut::<Time>() {
time.update(delta);
}
app.update();
let frame_duration = frame_start.elapsed();
if frame_duration < target_frame_duration {
std::thread::sleep(target_frame_duration - frame_duration);
}
last_frame_time = frame_start;
}
}

View File

@@ -0,0 +1,223 @@
//! Tauri-specific frontend transport and command handlers
//!
//! This module provides the Tauri implementation of FrontendTransport,
//! along with Tauri command handlers for input events, camera updates, and
//! state recovery.
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
use bevy_ecs::message::MessageReader;
use bevy_ecs::system::NonSend;
use borders_core::networking::GameView;
use borders_core::ui::FrontendTransport;
use borders_core::ui::protocol::{BackendMessage, CameraStateUpdate, FrontendMessage, LeaderboardSnapshot, MapQuery, MapQueryResponse, RenderInit, RenderInputEvent, TerrainType};
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter, ipc::Channel};
use tracing::trace;
/// Storage for the init channel used for binary streaming of initialization data
pub struct InitChannelStorage(pub Arc<Mutex<Option<Channel<Vec<u8>>>>>);
/// Tauri-specific frontend transport using Tauri events
#[derive(Clone)]
pub struct TauriRenderBridgeTransport {
app_handle: AppHandle,
/// Shared state for RenderInit (accessible from Tauri commands)
shared_render_state: Arc<Mutex<Option<RenderInit>>>,
/// Inbound messages from the frontend
inbound_messages: Arc<Mutex<VecDeque<FrontendMessage>>>,
/// Init channel for binary data streaming (terrain + territory)
init_channel: Arc<Mutex<Option<Channel<Vec<u8>>>>>,
}
impl TauriRenderBridgeTransport {
pub fn new(app_handle: AppHandle, shared_render_state: Arc<Mutex<Option<RenderInit>>>) -> Self {
Self { app_handle, shared_render_state, inbound_messages: Arc::new(Mutex::new(VecDeque::new())), init_channel: Arc::new(Mutex::new(None)) }
}
/// Get a reference to the inbound messages queue (for Tauri command handler)
pub fn inbound_messages(&self) -> Arc<Mutex<VecDeque<FrontendMessage>>> {
self.inbound_messages.clone()
}
/// Get the init channel for registration
pub fn init_channel(&self) -> InitChannelStorage {
InitChannelStorage(self.init_channel.clone())
}
}
impl FrontendTransport for TauriRenderBridgeTransport {
fn supports_init_binary(&self) -> bool {
true // Tauri supports binary channel streaming
}
fn send_backend_message(&self, message: &BackendMessage) -> Result<(), String> {
let _guard = tracing::trace_span!(
"tauri_send_backend_message",
message_type = match message {
BackendMessage::RenderInit(_) => "RenderInit",
BackendMessage::TerritorySnapshot(_) => "TerritorySnapshot",
BackendMessage::TerritoryDelta(_) => "TerritoryDelta",
BackendMessage::TerrainInit(_) => "TerrainInit",
BackendMessage::TerrainPalette(_) => "TerrainPalette",
BackendMessage::PaletteInit(_) => "PaletteInit",
BackendMessage::CameraCommand(_) => "CameraCommand",
BackendMessage::MapQueryResponse(_) => "MapQueryResponse",
BackendMessage::LeaderboardSnapshot(_) => "LeaderboardSnapshot",
BackendMessage::AttacksUpdate(_) => "AttacksUpdate",
BackendMessage::ShipsUpdate(_) => "ShipsUpdate",
BackendMessage::GameEnded { .. } => "GameEnded",
BackendMessage::SpawnPhaseUpdate { .. } => "SpawnPhaseUpdate",
BackendMessage::SpawnPhaseEnded => "SpawnPhaseEnded",
BackendMessage::HighlightNation { .. } => "HighlightNation",
}
)
.entered();
// Cache RenderInit for state recovery on reload
if let BackendMessage::RenderInit(render_init) = message
&& let Ok(mut state) = self.shared_render_state.lock()
{
*state = Some(render_init.clone());
}
self.app_handle.emit("backend:message", message).map_err(|e| format!("Failed to emit backend message: {}", e))
}
fn send_init_binary(&self, data: Vec<u8>) -> Result<(), String> {
let _guard = tracing::trace_span!("tauri_send_init_binary", size = data.len()).entered();
let ch_lock = self.init_channel.lock().map_err(|_| "Failed to lock init channel")?;
let channel = ch_lock.as_ref().ok_or("Init channel not registered")?;
channel.send(data).map_err(|e| format!("Failed to send init data via channel: {}", e))
}
fn send_binary_delta(&self, data: Vec<u8>) -> Result<(), String> {
let _guard = tracing::trace_span!("tauri_send_binary_delta", size = data.len()).entered();
self.app_handle.emit("render:pixel_stream", &data).map_err(|e| format!("Failed to emit pixel stream: {}", e))
}
fn try_recv_frontend_message(&self) -> Option<FrontendMessage> {
if let Ok(mut messages) = self.inbound_messages.lock() { messages.pop_front() } else { None }
}
}
/// Tauri command to register the init channel for binary streaming
#[tauri::command]
pub fn register_init_channel(init_channel: Channel<Vec<u8>>, channel_storage: tauri::State<InitChannelStorage>) -> Result<(), String> {
tracing::info!("Init channel registered");
channel_storage
.0
.lock()
.map_err(|_| {
tracing::error!("Failed to acquire lock on init channel storage");
"Failed to acquire lock on init channel storage".to_string()
})?
.replace(init_channel);
Ok(())
}
/// Tauri command handler for receiving frontend messages
#[tauri::command]
pub fn send_frontend_message(message: FrontendMessage, bridge: tauri::State<Arc<Mutex<VecDeque<FrontendMessage>>>>) -> Result<(), String> {
tracing::info!("Frontend sent message: {:?}", message);
if let Ok(mut messages) = bridge.lock() {
messages.push_back(message);
tracing::debug!("Message queued, queue size: {}", messages.len());
Ok(())
} else {
tracing::error!("Failed to acquire lock on message queue");
Err("Failed to acquire lock on message queue".to_string())
}
}
/// Handle input events from the frontend
#[tauri::command]
pub fn handle_render_input(event: RenderInputEvent, input_state: tauri::State<Arc<Mutex<borders_core::ui::input::InputState>>>) -> Result<(), String> {
let mut state = input_state.lock().map_err(|e| format!("Failed to lock input state: {}", e))?;
// TODO: Get actual map width from GameView or TerrainData
let map_width = 2560; // Placeholder
borders_core::ui::handle_render_input(&event, &mut state, map_width)
}
/// Handle camera state updates from the frontend
#[tauri::command]
pub fn handle_camera_update(update: CameraStateUpdate, bridge: tauri::State<Arc<Mutex<Option<CameraStateUpdate>>>>) -> Result<(), String> {
let mut state = bridge.lock().map_err(|e| format!("Failed to lock camera state: {}", e))?;
borders_core::ui::handle_camera_update(update, &mut state)
}
/// Handle map queries from the frontend
#[tauri::command]
pub fn handle_map_query(query: MapQuery, game_view: tauri::State<Arc<Mutex<Option<GameView>>>>) -> Result<MapQueryResponse, String> {
let view = game_view.lock().map_err(|e| format!("Failed to lock game view: {}", e))?;
let Some(ref view) = *view else {
return Err("Game view not available".to_string());
};
match query {
MapQuery::GetOwnerAt { x: _, y: _ } => {
// This query is not used with Pixi.js frontend - frontend sends tile indices directly
Ok(MapQueryResponse::Owner { owner_id: 0, tile_index: None })
}
MapQuery::GetTileInfo { tile_index } => {
let index = tile_index as usize;
let owner_id = view.get_owner(index);
// TODO: Get actual terrain and troop data
Ok(MapQueryResponse::TileInfo {
tile_index,
owner_id,
terrain: TerrainType::Land, // Placeholder
troops: 0, // Placeholder
})
}
MapQuery::FindPlayerTerritory { player_id } => {
let tile_index = view.find_tile_owned_by(player_id).map(|i| i as u32);
Ok(MapQueryResponse::PlayerTerritory { tile_index })
}
MapQuery::ScreenToTile { screen_x: _, screen_y: _ } => {
// This would need camera state to work properly
Ok(MapQueryResponse::TileIndex { index: None })
}
}
}
/// Combined state for recovery after reload
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GameStateRecovery {
pub render_init: Option<RenderInit>,
pub leaderboard_snapshot: Option<LeaderboardSnapshot>,
}
/// Get current game state for frontend recovery after reload
#[tauri::command]
pub fn get_game_state(render_state: tauri::State<Arc<Mutex<Option<RenderInit>>>>, leaderboard_state: tauri::State<Arc<Mutex<Option<LeaderboardSnapshot>>>>) -> Result<GameStateRecovery, String> {
let render_init = render_state.lock().map_err(|e| format!("Failed to lock render state: {}", e))?.clone();
let leaderboard_snapshot = leaderboard_state.lock().map_err(|e| format!("Failed to lock leaderboard state: {}", e))?.clone();
Ok(GameStateRecovery { render_init, leaderboard_snapshot })
}
/// System to cache leaderboard snapshots for state recovery
pub fn cache_leaderboard_snapshot_system(mut events: MessageReader<BackendMessage>, shared_leaderboard_state: Option<NonSend<Arc<Mutex<Option<LeaderboardSnapshot>>>>>) {
let Some(shared_state) = shared_leaderboard_state else {
return;
};
for event in events.read() {
if let BackendMessage::LeaderboardSnapshot(snapshot) = event
&& let Ok(mut state) = shared_state.lock()
{
*state = Some(snapshot.clone());
trace!("Cached leaderboard snapshot for state recovery");
}
}
}

View File

@@ -0,0 +1,40 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "iron-borders",
"version": "0.1.0",
"identifier": "com.xevion.iron-borders",
"build": {
"beforeDevCommand": "pnpm dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "pnpm build:desktop",
"frontendDist": "../../frontend/dist/client"
},
"app": {
"windows": [
{
"title": "Iron Borders",
"width": 1280,
"height": 720
}
],
"security": {
"csp": null
}
},
"plugins": {
"process": {
"all": true
}
},
"bundle": {
"active": true,
"targets": ["appimage", "deb", "rpm", "dmg"],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}

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