Update source files

This commit is contained in:
2025-10-10 20:00:55 -05:00
commit 605be6bb17
152 changed files with 25175 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"

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

@@ -0,0 +1,185 @@
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/macos/*.app
target/${{ matrix.target.rust_target }}/release/bundle/dmg/*.dmg
target/${{ matrix.target.rust_target }}/release/bundle/msi/*.msi
target/${{ matrix.target.rust_target }}/release/bundle/nsis/*.exe
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/**/*
if-no-files-found: error
- name: Build frontend (GitHub Pages)
run: pnpm run build:browser
working-directory: frontend
env:
GITHUB_PAGES: true
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

32
.gitignore vendored Normal file
View File

@@ -0,0 +1,32 @@
target/
pkg/
*.pem
/*.io/
# 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 @@
6c7548121b7e04910107da963a8fb920f47717f9

7533
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.5.6"
# 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

74
Justfile Normal file
View File

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

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,76 @@
[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-lite = "2.6.1"
glam = "0.30"
hex = "0.4"
hmac = "0.12"
image = "0.25"
once_cell = "1.20"
rand = "0.9"
serde = { version = "1.0", features = ["derive", "rc"] }
serde_bytes = "0.11"
serde_json = "1.0"
sha2 = "0.10"
tracing = "0.1"
url = "2.5.0"
web-transport = "0.9"
# Target-specific dependencies to keep WASM builds compatible
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
tokio = { version = "1", features = [
"rt-multi-thread",
"macros",
"time",
"io-util",
"sync",
] }
reqwest = { version = "0.12", default-features = false, features = [
"json",
"rustls-tls",
] }
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,28 @@
use std::env;
use std::fs;
use std::path::PathBuf;
fn main() {
// Get the workspace root (two levels up from borders-core)
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap());
let workspace_root = manifest_dir.parent().unwrap().parent().unwrap();
// Read git commit from .source-commit file
let source_commit_path = workspace_root.join(".source-commit");
let git_commit = if source_commit_path.exists() {
fs::read_to_string(&source_commit_path).unwrap_or_else(|_| "unknown".to_string()).trim().to_string()
} else {
// Fallback to git command if file doesn't exist (local development)
std::process::Command::new("git").args(["rev-parse", "HEAD"]).current_dir(workspace_root).output().ok().and_then(|output| if output.status.success() { String::from_utf8(output.stdout).ok() } else { None }).map(|s| s.trim().to_string()).unwrap_or_else(|| "unknown".to_string())
};
// Get current build time in UTC
let build_time = chrono::Utc::now().to_rfc3339();
// Set environment variables for compile-time access
println!("cargo:rustc-env=BUILD_GIT_COMMIT={}", git_commit);
println!("cargo:rustc-env=BUILD_TIME={}", build_time);
// Re-run if .source-commit changes
println!("cargo:rerun-if-changed={}", source_commit_path.display());
}

View File

@@ -0,0 +1,140 @@
//! Minimal ECS app wrapper to replace Bevy's App
use bevy_ecs::message::{Message, Messages};
use bevy_ecs::prelude::*;
use bevy_ecs::schedule::{IntoScheduleConfigs, ScheduleLabel, Schedules};
use bevy_ecs::system::ScheduleSystem;
use std::fmt::Debug;
#[derive(Debug, Hash, PartialEq, Eq, Clone, ScheduleLabel)]
pub struct Startup;
#[derive(Debug, Hash, PartialEq, Eq, Clone, ScheduleLabel)]
pub struct Update;
#[derive(Debug, Hash, PartialEq, Eq, Clone, ScheduleLabel)]
pub struct Last;
#[derive(Debug, Hash, PartialEq, Eq, Clone, ScheduleLabel)]
pub struct Fixed;
pub struct App {
world: World,
}
impl App {
pub fn new() -> Self {
let mut world = World::new();
// Initialize schedules
let mut schedules = Schedules::new();
schedules.insert(Schedule::new(Startup));
schedules.insert(Schedule::new(Update));
schedules.insert(Schedule::new(Last));
schedules.insert(Schedule::new(Fixed));
world.insert_resource(schedules);
Self { world }
}
pub fn world(&self) -> &World {
&self.world
}
pub fn world_mut(&mut self) -> &mut World {
&mut self.world
}
pub fn insert_resource<R: Resource>(&mut self, resource: R) -> &mut Self {
self.world.insert_resource(resource);
self
}
pub fn init_resource<R: Resource + FromWorld>(&mut self) -> &mut Self {
self.world.init_resource::<R>();
self
}
pub fn insert_non_send_resource<R: 'static>(&mut self, resource: R) -> &mut Self {
self.world.insert_non_send_resource(resource);
self
}
pub fn add_message<M: Message>(&mut self) -> &mut Self {
if !self.world.contains_resource::<Messages<M>>() {
self.world.init_resource::<Messages<M>>();
// Add system to update this message type each frame
self.add_systems(Last, |mut messages: ResMut<Messages<M>>| {
messages.update();
});
}
self
}
pub fn add_systems<M>(&mut self, schedule: impl ScheduleLabel, systems: impl IntoScheduleConfigs<ScheduleSystem, M>) -> &mut Self {
let mut schedules = self.world.resource_mut::<Schedules>();
if let Some(schedule_inst) = schedules.get_mut(schedule) {
schedule_inst.add_systems(systems);
}
self
}
pub fn update(&mut self) {
// Remove schedules temporarily to avoid resource_scope conflicts
let mut schedules = self.world.remove_resource::<Schedules>().unwrap();
// Run Update schedule
if let Some(schedule) = schedules.get_mut(Update) {
schedule.run(&mut self.world);
}
// Run Last schedule (includes event updates)
if let Some(schedule) = schedules.get_mut(Last) {
schedule.run(&mut self.world);
}
// Re-insert schedules
self.world.insert_resource(schedules);
}
pub fn run_startup(&mut self) {
// Remove schedules temporarily to avoid resource_scope conflicts
let mut schedules = self.world.remove_resource::<Schedules>().unwrap();
// Run Startup schedule
if let Some(schedule) = schedules.get_mut(Startup) {
schedule.run(&mut self.world);
}
// Re-insert schedules
self.world.insert_resource(schedules);
}
pub fn finish(&mut self) {
// Finalize schedules
let mut schedules = self.world.remove_resource::<Schedules>().unwrap();
for (_, schedule) in schedules.iter_mut() {
schedule.graph_mut().initialize(&mut self.world);
}
self.world.insert_resource(schedules);
}
pub fn cleanup(&mut self) {
// Any cleanup needed before running
}
}
impl Default for App {
fn default() -> Self {
Self::new()
}
}
/// Plugin trait for modular setup
pub trait Plugin {
fn build(&self, app: &mut App);
}

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;
/// Income is processed every 10 ticks (1 second at 10 TPS)
pub const INCOME_TICK_INTERVAL: u64 = 10;
/// Number of bot players
pub const BOT_COUNT: usize = 100;

View File

@@ -0,0 +1,33 @@
//! Game action system
//!
//! This module defines the core action types that can be performed in the game.
//! Actions represent discrete game events that can be initiated by both human players
//! and AI bots. They are processed deterministically during turn execution.
use serde::{Deserialize, Serialize};
/// Core game action type
///
/// This enum represents all possible actions that can be performed in the game.
/// Unlike `Intent`, which is a network-layer wrapper, `GameAction` is the actual
/// game-level operation.
///
/// Actions can originate from:
/// - Human players (via input systems → intents → network)
/// - AI bots (calculated deterministically during turn execution)
///
/// All actions are processed in a deterministic order during `GameInstance::execute_turn()`.
#[derive(Debug, Clone, Serialize, Deserialize, bincode::Encode, bincode::Decode)]
pub enum GameAction {
/// Spawn a player's initial territory at a specific tile
///
/// TODO: Remove this, it should be handled by a Turn0 special configuration AND the Intent::SetSpawn
Spawn { player_id: u16, tile_index: u32 },
/// Attack a target tile with a percentage of the player's total troops
Attack { player_id: u16, target_tile: u32, troops_ratio: f32 },
// 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,347 @@
use rand::Rng;
use std::collections::HashSet;
use crate::game::{TileOwnership, border_manager::BorderManager, player::Player, territory_manager::TerritoryManager};
/// Maximum attack schedule slots (circular buffer size)
pub const MAX_ATTACK_SCHEDULE: usize = 135;
/// Configuration for creating an AttackExecutor
pub struct AttackConfig<'a> {
pub player: &'a Player,
pub target: Option<&'a Player>,
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 tile_expansion_times: &'a [u8],
pub width: u32,
pub turn_number: u64,
}
/// Attack executor that manages the conquest of tiles
pub struct AttackExecutor {
pub player_id: usize,
pub target_id: Option<usize>,
troops: f32,
tile_queue: Vec<Vec<(usize, f32)>>, // Vec of (tile, delay) pairs
queue_slot: f32,
scheduled_tiles: usize,
speed_factor: f32,
start_turn: u64,
target_region_center: usize, // The initially clicked tile (region center)
}
impl AttackExecutor {
/// Create a new attack executor
pub fn new(config: AttackConfig) -> Self {
let player_id = config.player.id;
let target_id = config.target.map(|t| t.id);
let mut executor = Self { player_id, target_id, troops: config.troops, tile_queue: vec![Vec::new(); MAX_ATTACK_SCHEDULE], queue_slot: 0.0, scheduled_tiles: 0, speed_factor: 1.0, start_turn: config.turn_number, target_region_center: config.target_tile };
executor.speed_factor = executor.calculate_speed_factor(config.player, config.target);
executor.order_tiles(config.border_tiles, config.territory_manager, config.border_manager, config.tile_expansion_times, config.width);
executor
}
/// Modify the amount of troops in the attack
pub fn modify_troops(&mut self, amount: f32) {
self.troops += amount;
}
/// 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, tile_expansion_times: &[u8], tile_expansion_costs: &[u8], width: u32) -> bool {
let _ = tracing::debug_span!("attack_tick");
let _span = tracing::trace_span!("attack_tick", player_id = self.player_id).entered();
let attack_cost = self.calculate_attack_cost(players);
let defense_cost = ((1.0 + attack_cost) / 1.7).ceil();
self.speed_factor = self.calculate_speed_factor(&players[self.player_id], self.target_id.map(|id| &players[id]));
let slot_index = (self.queue_slot as usize) % MAX_ATTACK_SCHEDULE;
let mut conquered = 0;
// Collect tiles to process first to avoid borrow conflicts
let mut tiles_to_process = Vec::new();
while self.troops >= attack_cost && !self.tile_queue[slot_index].is_empty() {
let _ = tracing::trace_span!("dequeue_tile");
if let Some((tile, delay)) = self.tile_queue[slot_index].pop() {
tiles_to_process.push((tile, delay));
} else {
break;
}
}
// Process the collected tiles
for (tile, delay) in tiles_to_process {
let _ = tracing::trace_span!("process_tile");
self.queue_slot = delay;
self.scheduled_tiles = self.scheduled_tiles.saturating_sub(1);
// Check if tile is still valid target
let tile_valid = if let Some(target_id) = self.target_id { territory_manager.is_owner(tile, target_id) } else { !territory_manager.has_owner(tile) && !territory_manager.is_water(tile) };
if !tile_valid {
continue;
}
// Check if tile borders player territory
if !AttackExecutor::check_borders_tile(tile, self.player_id, territory_manager, width) {
continue;
}
// Check if we still have enough troops
if self.troops < attack_cost {
break;
}
// Conquer the tile
let _ = tracing::trace_span!("conquer");
let previous_owner = territory_manager.conquer(tile, self.player_id);
// Update borders
let _ = tracing::trace_span!("apply_transition");
let tiles_changed = std::iter::once(tile).collect::<std::collections::HashSet<_>>();
border_manager.transition_tiles(&tiles_changed, self.player_id, self.target_id, territory_manager);
// Update player territory sizes
// Convert u16 back to TileOwnership to use type-safe methods
let previous_ownership = TileOwnership::from_u16(previous_owner);
if let Some(nation_id) = previous_ownership.nation_id() {
players[nation_id as usize].remove_tile(tile);
}
players[self.player_id].add_tile(tile);
// Schedule neighbors akin to handle_player_tile_add for immediate follow-up
let queue_slot = self.queue_slot;
let speed_factor = self.speed_factor;
let mut rng = rand::rng();
let mut neighbors_to_schedule: Vec<(usize, f32)> = Vec::new();
self.on_neighbors(tile, width, territory_manager, |neighbor| {
let neighbor_is_target = if let Some(target_id) = self.target_id { territory_manager.is_owner(neighbor, target_id) } else { !territory_manager.has_owner(neighbor) && !territory_manager.is_water(neighbor) };
if neighbor_is_target {
let random_factor: f32 = rng.random_range(0.0..1.0);
let delay = queue_slot + tile_expansion_times[tile] as f32 * (0.025 + random_factor * 0.06) * speed_factor;
neighbors_to_schedule.push((neighbor, delay));
}
});
for (neighbor, delay) in neighbors_to_schedule {
let slot_index = (delay.floor() as usize) % MAX_ATTACK_SCHEDULE;
self.tile_queue[slot_index].push((neighbor, delay));
self.scheduled_tiles += 1;
}
self.troops -= attack_cost + tile_expansion_costs[tile] as f32 / 50.0;
conquered += 1;
}
// Apply defense cost to target
if let Some(target_id) = self.target_id {
players[target_id].remove_troops(conquered as f32 * defense_cost);
}
// Check if attack should continue
if self.scheduled_tiles == 0 || self.troops < attack_cost {
return false;
}
self.queue_slot = (self.queue_slot + 1.0).floor();
true
}
/// Handle the addition of a tile to the player's territory
pub fn handle_player_tile_add(&mut self, tile: usize, territory_manager: &TerritoryManager, tile_expansion_times: &[u8], width: u32) {
let queue_slot = self.queue_slot;
let speed_factor = self.speed_factor;
let mut neighbors_to_schedule = Vec::new();
self.on_neighbors(tile, width, territory_manager, |neighbor| {
let neighbor_is_target = if let Some(target_id) = self.target_id { territory_manager.is_owner(neighbor, target_id) } else { !territory_manager.has_owner(neighbor) && !territory_manager.is_water(neighbor) };
if neighbor_is_target {
let mut rng = rand::rng();
let random_factor: f32 = rng.random_range(0.0..1.0);
let delay = queue_slot + tile_expansion_times[tile] as f32 * (0.025 + random_factor * 0.06) * speed_factor;
neighbors_to_schedule.push((neighbor, delay));
}
});
for (neighbor, delay) in neighbors_to_schedule {
let slot_index = (delay.floor() as usize) % MAX_ATTACK_SCHEDULE;
self.tile_queue[slot_index].push((neighbor, delay));
self.scheduled_tiles += 1;
}
}
/// Handle the addition of a tile to the target's territory
pub fn handle_target_tile_add(&mut self, tile: usize, territory_manager: &TerritoryManager, tile_expansion_times: &[u8], width: u32) {
if AttackExecutor::check_borders_tile(tile, self.player_id, territory_manager, width) {
let mut rng = rand::rng();
let random_factor: f32 = rng.random_range(0.0..1.0);
let delay = self.queue_slot + tile_expansion_times[tile] as f32 * (0.025 + random_factor * 0.06) * self.speed_factor;
let slot_index = (delay.floor() as usize) % MAX_ATTACK_SCHEDULE;
self.tile_queue[slot_index].push((tile, delay));
self.scheduled_tiles += 1;
}
}
/// Check if this attack is targeting a specific tile or its region
/// Uses distance from the initial target_region_center to determine if it's the same region
pub fn is_targeting_region(&self, tile: usize, width: u32, _height: u32) -> bool {
// Calculate distance from the attack's target region center
let center_x = (self.target_region_center % width as usize) as i32;
let center_y = (self.target_region_center / width as usize) as i32;
let tile_x = (tile % width as usize) as i32;
let tile_y = (tile / width as usize) as i32;
let dx = (center_x - tile_x).abs();
let dy = (center_y - tile_y).abs();
// Consider it the same region if within 8 tiles Manhattan distance
// This gives a reasonable region size while preventing overlap
dx + dy <= 8
}
/// Build the initial tile queue from border tiles
fn order_tiles(&mut self, border_tiles: Option<&HashSet<usize>>, territory_manager: &TerritoryManager, border_manager: &BorderManager, tile_expansion_times: &[u8], width: u32) {
let mut result = Vec::new();
let mut amount_cache = vec![0u8; territory_manager.len()];
let borders = border_tiles.unwrap_or_else(|| border_manager.get_border_tiles(self.player_id));
for &tile in borders {
let x = tile as u32 % width;
let y = tile as u32 / width;
// Helper to check if a tile matches our target
let is_target_tile = |t: usize| {
if let Some(target_id) = self.target_id { territory_manager.is_owner(t, target_id) } else { !territory_manager.has_owner(t) && !territory_manager.is_water(t) }
};
// Check all neighbors
if x > 0 && is_target_tile(tile - 1) {
if amount_cache[tile - 1] == 0 {
result.push(tile - 1);
}
amount_cache[tile - 1] += 1;
}
if x < width - 1 && is_target_tile(tile + 1) {
if amount_cache[tile + 1] == 0 {
result.push(tile + 1);
}
amount_cache[tile + 1] += 1;
}
if y > 0 && is_target_tile(tile - width as usize) {
if amount_cache[tile - width as usize] == 0 {
result.push(tile - width as usize);
}
amount_cache[tile - width as usize] += 1;
}
if y < (territory_manager.len() as u32 / width) - 1 && is_target_tile(tile + width as usize) {
if amount_cache[tile + width as usize] == 0 {
result.push(tile + width as usize);
}
amount_cache[tile + width as usize] += 1;
}
}
let mut rng = rand::rng();
for tile in result {
let random_factor: f32 = rng.random_range(0.0..1.0);
let delay = tile_expansion_times[tile] as f32 * (0.08 - 0.02 * amount_cache[tile] as f32 + random_factor * 0.06) * self.speed_factor;
let slot_index = (delay.floor() as usize) % MAX_ATTACK_SCHEDULE;
self.tile_queue[slot_index].push((tile, delay));
self.scheduled_tiles += 1;
}
}
/// Calculate the speed factor of the attack
fn calculate_speed_factor(&self, player: &Player, target: Option<&Player>) -> f32 {
if let Some(target) = target {
let ratio = (player.get_territory_size() as f32 * player.get_troops()) / (target.get_territory_size().max(1) as f32) / (target.get_troops().max(1.0));
2.0 / (0.325 + (1.0 + ratio.min(50.0)).ln())
} else {
1.0
}
}
/// Calculate the cost of attacking
fn calculate_attack_cost(&self, players: &[Player]) -> f32 {
if let Some(target_id) = self.target_id {
let target = &players[target_id];
((target.get_troops() / target.get_territory_size().max(1) as f32) * 2.0).floor()
} else {
0.0
}
}
/// Check if a tile borders the player's territory
fn check_borders_tile(tile: usize, player_id: usize, territory_manager: &TerritoryManager, width: u32) -> bool {
let x = tile as u32 % width;
let y = tile as u32 / width;
let height = territory_manager.len() as u32 / width;
if x > 0 && territory_manager.is_owner(tile - 1, player_id) {
return true;
}
if x < width - 1 && territory_manager.is_owner(tile + 1, player_id) {
return true;
}
if y > 0 && territory_manager.is_owner(tile - width as usize, player_id) {
return true;
}
if y < height - 1 && territory_manager.is_owner(tile + width as usize, player_id) {
return true;
}
false
}
/// Call closure on all neighbors
fn on_neighbors<F>(&self, tile: usize, width: u32, territory_manager: &TerritoryManager, mut closure: F)
where
F: FnMut(usize),
{
let x = tile as u32 % width;
let y = tile as u32 / width;
let height = territory_manager.len() as u32 / width;
if x > 0 {
closure(tile - 1);
}
if x < width - 1 {
closure(tile + 1);
}
if y > 0 {
closure(tile - width as usize);
}
if y < height - 1 {
closure(tile + width as usize);
}
}
}

View File

@@ -0,0 +1,284 @@
use std::collections::HashSet;
use crate::game::{
attack::{AttackConfig, AttackExecutor},
border_manager::BorderManager,
player::Player,
territory_manager::TerritoryManager,
tilemap::TileMap,
};
/// Manages all active attacks
pub struct AttackActionHandler {
attacks: Vec<AttackExecutor>,
player_index: Vec<Vec<Vec<usize>>>, // [attacker][target] -> list of attack indices
unclaimed_index: Vec<Option<usize>>, // [attacker] -> attack index for unclaimed
player_attack_list: Vec<Vec<usize>>, // [player] -> list of attack indices where player is attacker
target_attack_list: Vec<Vec<usize>>, // [player] -> list of attack indices where player is target
unclaimed_attack_list: Vec<usize>, // List of attack indices for unclaimed territory
tile_expansion_times: TileMap<u8>,
tile_expansion_costs: TileMap<u8>,
}
impl Default for AttackActionHandler {
fn default() -> Self {
Self::new()
}
}
impl AttackActionHandler {
pub fn new() -> Self {
Self { attacks: Vec::new(), player_index: Vec::new(), unclaimed_index: Vec::new(), player_attack_list: Vec::new(), target_attack_list: Vec::new(), unclaimed_attack_list: Vec::new(), tile_expansion_times: TileMap::with_default(0, 0, 0), tile_expansion_costs: TileMap::with_default(0, 0, 0) }
}
/// Initialize the attack handler
pub fn init(&mut self, max_players: usize, tile_expansion_times: Vec<u8>, tile_expansion_costs: Vec<u8>, width: u32, height: u32) {
self.attacks.clear();
self.player_index = vec![vec![Vec::new(); max_players]; max_players];
self.unclaimed_index = vec![None; max_players];
self.player_attack_list = vec![Vec::new(); max_players];
self.target_attack_list = vec![Vec::new(); max_players];
self.unclaimed_attack_list.clear();
self.tile_expansion_times = TileMap::from_vec(width, height, tile_expansion_times);
self.tile_expansion_costs = TileMap::from_vec(width, height, tile_expansion_costs);
}
/// Find an attack from a player to a target that is targeting a specific tile region
fn find_attack_for_tile(&self, player_id: usize, target_id: usize, target_tile: usize) -> Option<usize> {
self.player_index[player_id][target_id].iter().find(|&&attack_idx| self.attacks[attack_idx].is_targeting_region(target_tile, self.tile_expansion_times.width(), self.tile_expansion_times.height())).copied()
}
/// Filter border tiles to only include those adjacent to the target region
fn filter_border_tiles_near(&self, border_tiles: &HashSet<usize>, _target_tile: usize, target_owner: u16, territory_manager: &TerritoryManager) -> HashSet<usize> {
let mut filtered = HashSet::new();
for &tile in border_tiles {
// Check if any neighbor of this border tile is owned by the target
let mut is_adjacent = false;
territory_manager.on_neighbor_indices(tile, |neighbor| {
if territory_manager.get_owner(neighbor) == target_owner {
is_adjacent = true;
}
});
if is_adjacent {
filtered.insert(tile);
}
}
// If we didn't find any adjacent border tiles, return all border tiles as fallback
if filtered.is_empty() { border_tiles.clone() } else { filtered }
}
/// Schedule an attack on unclaimed territory
#[allow(clippy::too_many_arguments)]
pub fn attack_unclaimed(&mut self, player: &Player, troops: f32, target_tile: usize, border_tiles: Option<&HashSet<usize>>, territory_manager: &TerritoryManager, border_manager: &BorderManager, turn_number: u64) {
// Check if there's already an attack on unclaimed territory
if let Some(attack_idx) = self.unclaimed_index[player.id] {
self.attacks[attack_idx].modify_troops(troops);
return;
}
// Create new attack
self.add_unclaimed(player, troops, target_tile, border_tiles, territory_manager, border_manager, turn_number);
}
/// Schedule an attack on a player
#[allow(clippy::too_many_arguments)]
pub fn attack_player(&mut self, player: &Player, target: &Player, target_tile: usize, mut troops: f32, border_tiles: Option<&HashSet<usize>>, territory_manager: &TerritoryManager, border_manager: &BorderManager, turn_number: u64) {
// Check if there's already an attack on this target region
if let Some(attack_idx) = self.find_attack_for_tile(player.id, target.id, target_tile) {
self.attacks[attack_idx].modify_troops(troops);
return;
}
// Check for counter-attacks (opposite direction) - prevent mutual attacks between players
// Oppose against ALL active attacks from target->player
while !self.player_index[target.id][player.id].is_empty() {
let opposite_idx = self.player_index[target.id][player.id][0];
if self.attacks[opposite_idx].oppose(troops) {
// Counter-attack absorbed the new attack
return;
}
// Counter-attack was defeated, deduct its troops from the new attack
troops -= self.attacks[opposite_idx].get_troops();
// Remove the defeated counter-attack
self.remove_attack(opposite_idx);
// Continue to the next counter-attack if any remain
}
// Filter border tiles to only those near the target region
let border_tiles_set = border_tiles.cloned().unwrap_or_else(|| border_manager.get_border_tiles(player.id).clone());
let filtered_borders = self.filter_border_tiles_near(&border_tiles_set, target_tile, target.id as u16, territory_manager);
// Create new attack
self.add_attack(player, target, troops, target_tile, Some(&filtered_borders), territory_manager, border_manager, turn_number);
}
/// Tick all active attacks
pub fn tick(&mut self, players: &mut [Player], territory_manager: &mut TerritoryManager, border_manager: &mut BorderManager) {
let _ = tracing::debug_span!("attacks_tick");
let _span = tracing::trace_span!("attacks_tick", attack_count = self.attacks.len()).entered();
let mut i = 0;
while i < self.attacks.len() {
let _ = tracing::trace_span!("tick_attack");
let should_continue = self.attacks[i].tick(players, territory_manager, border_manager, self.tile_expansion_times.as_slice(), self.tile_expansion_costs.as_slice(), self.tile_expansion_times.width());
if !should_continue {
// Return remaining troops to player
let player_id = self.attacks[i].player_id;
let remaining_troops = self.attacks[i].get_troops();
tracing::trace!(player_id, remaining_troops, "Attack completed");
players[player_id].add_troops(remaining_troops);
// Remove attack
self.remove_attack(i);
} else {
i += 1;
}
}
}
/// Handle a tile being added to a player
pub fn handle_territory_add(&mut self, tile: usize, player_id: usize, territory_manager: &TerritoryManager) {
// Notify all attacks where this player is the attacker
for &attack_idx in &self.player_attack_list[player_id] {
self.attacks[attack_idx].handle_player_tile_add(tile, territory_manager, self.tile_expansion_times.as_slice(), self.tile_expansion_times.width());
}
// Notify all attacks where this player is the target
for &attack_idx in &self.target_attack_list[player_id] {
self.attacks[attack_idx].handle_target_tile_add(tile, territory_manager, self.tile_expansion_times.as_slice(), self.tile_expansion_times.width());
}
}
/// Add an attack on unclaimed territory
#[allow(clippy::too_many_arguments)]
fn add_unclaimed(&mut self, player: &Player, troops: f32, target_tile: usize, border_tiles: Option<&HashSet<usize>>, territory_manager: &TerritoryManager, border_manager: &BorderManager, turn_number: u64) {
let attack = AttackExecutor::new(AttackConfig { player, target: None, troops, target_tile, border_tiles, territory_manager, border_manager, tile_expansion_times: self.tile_expansion_times.as_slice(), width: self.tile_expansion_times.width(), turn_number });
let attack_idx = self.attacks.len();
self.attacks.push(attack);
self.unclaimed_index[player.id] = Some(attack_idx);
self.player_attack_list[player.id].push(attack_idx);
self.unclaimed_attack_list.push(attack_idx);
}
/// Add an attack on a player
#[allow(clippy::too_many_arguments)]
fn add_attack(&mut self, player: &Player, target: &Player, troops: f32, target_tile: usize, border_tiles: Option<&HashSet<usize>>, territory_manager: &TerritoryManager, border_manager: &BorderManager, turn_number: u64) {
let attack = AttackExecutor::new(AttackConfig { player, target: Some(target), troops, target_tile, border_tiles, territory_manager, border_manager, tile_expansion_times: self.tile_expansion_times.as_slice(), width: self.tile_expansion_times.width(), turn_number });
let attack_idx = self.attacks.len();
self.attacks.push(attack);
self.player_index[player.id][target.id].push(attack_idx);
self.player_attack_list[player.id].push(attack_idx);
self.target_attack_list[target.id].push(attack_idx);
}
/// Get all attacks involving a specific player (as attacker or target)
/// Returns a list of (attacker_id, target_id, troops, start_turn, is_outgoing)
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_idx in &self.player_attack_list[player_id] {
let attack = &self.attacks[attack_idx];
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_idx in &self.target_attack_list[player_id] {
let attack = &self.attacks[attack_idx];
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
fn remove_attack(&mut self, attack_idx: usize) {
let attack = &self.attacks[attack_idx];
let player_id = attack.player_id;
let target_id = attack.target_id;
// Remove from player attack list
if let Some(pos) = self.player_attack_list[player_id].iter().position(|&x| x == attack_idx) {
self.player_attack_list[player_id].remove(pos);
}
if let Some(target_id) = target_id {
// Remove from target attack list
if let Some(pos) = self.target_attack_list[target_id].iter().position(|&x| x == attack_idx) {
self.target_attack_list[target_id].remove(pos);
}
// Remove from player index
if let Some(pos) = self.player_index[player_id][target_id].iter().position(|&x| x == attack_idx) {
self.player_index[player_id][target_id].remove(pos);
}
} else {
// Remove from unclaimed attack list
if let Some(pos) = self.unclaimed_attack_list.iter().position(|&x| x == attack_idx) {
self.unclaimed_attack_list.remove(pos);
}
// Remove from unclaimed index
self.unclaimed_index[player_id] = None;
}
// Remove attack from attacks list
self.attacks.remove(attack_idx);
// Update all indices greater than attack_idx
for player_attacks in &mut self.player_attack_list {
for attack in player_attacks {
if *attack > attack_idx {
*attack -= 1;
}
}
}
for target_attacks in &mut self.target_attack_list {
for attack in target_attacks {
if *attack > attack_idx {
*attack -= 1;
}
}
}
for attack in &mut self.unclaimed_attack_list {
if *attack > attack_idx {
*attack -= 1;
}
}
for player_row in &mut self.player_index {
for target_attacks in player_row {
for i in target_attacks {
if *i > attack_idx {
*i -= 1;
}
}
}
}
for i in self.unclaimed_index.iter_mut().flatten() {
if *i > attack_idx {
*i -= 1;
}
}
}
}

View File

@@ -0,0 +1,127 @@
use std::collections::HashSet;
use crate::game::territory_manager::TerritoryManager;
use crate::game::tilemap::TileMap;
/// Result of a border transition
#[derive(Debug)]
pub struct BorderTransitionResult {
/// Tiles that changed to attacker territory (interior, grade 4)
pub territory: Vec<usize>,
/// Tiles that changed to attacker border (grade < 4)
pub attacker: Vec<usize>,
/// Tiles that changed to defender border
pub defender: Vec<usize>,
}
/// Manages border tiles for all players
pub struct BorderManager {
tile_grades: TileMap<u8>,
border_tiles: Vec<HashSet<usize>>,
}
impl Default for BorderManager {
fn default() -> Self {
Self::new()
}
}
impl BorderManager {
pub fn new() -> Self {
Self { tile_grades: TileMap::with_default(0, 0, 0), border_tiles: Vec::new() }
}
/// Resets the border manager
/// Should only be called when a new game is started
pub fn reset(&mut self, width: u32, height: u32, player_count: usize) {
self.tile_grades = TileMap::with_default(width, height, 0);
self.border_tiles = vec![HashSet::new(); player_count];
}
/// Checks for updated borders when claiming tiles
pub fn transition_tiles(&mut self, tiles: &HashSet<usize>, attacker: usize, defender: Option<usize>, territory_manager: &TerritoryManager) -> BorderTransitionResult {
let _ = tracing::debug_span!("border_transition");
let _span = tracing::trace_span!("border_transition", tile_count = tiles.len()).entered();
let mut result = BorderTransitionResult { territory: Vec::new(), attacker: Vec::new(), defender: Vec::new() };
// Collect all neighbor updates first
let mut neighbor_updates = Vec::new();
for &tile in tiles {
let _ = tracing::trace_span!("border_neighbors");
let mut grade = 0;
let mut neighbors_to_check = Vec::new();
// Get all neighbors using TileMap
let pos = self.tile_grades.index_to_pos(tile);
for neighbor_pos in self.tile_grades.neighbors(pos) {
neighbors_to_check.push(self.tile_grades.pos_to_index(neighbor_pos));
}
// Process neighbors
for neighbor in neighbors_to_check {
let owner = territory_manager.get_owner(neighbor);
if let Some(defender_id) = defender
&& owner == defender_id as u16
{
neighbor_updates.push((neighbor, defender_id, true)); // Mark for defender border
}
if owner == attacker as u16 {
grade += 1;
if !tiles.contains(&neighbor) {
neighbor_updates.push((neighbor, attacker, false)); // Mark for attacker interior check
}
}
}
self.tile_grades[tile] = grade;
}
// Apply updates
for (neighbor, player_id, is_defender) in neighbor_updates {
let _ = tracing::trace_span!("apply_neighbor");
if is_defender {
self.tile_grades[neighbor] = self.tile_grades[neighbor].saturating_sub(1);
if self.tile_grades[neighbor] == 3 {
self.border_tiles[player_id].insert(neighbor);
result.defender.push(neighbor);
}
} else {
self.tile_grades[neighbor] += 1;
if self.tile_grades[neighbor] == 4 {
self.border_tiles[player_id].remove(&neighbor);
result.territory.push(neighbor);
}
}
}
// Update borders for the tiles themselves
for &tile in tiles {
let _ = tracing::trace_span!("apply_self");
if let Some(defender_id) = defender {
self.border_tiles[defender_id].remove(&tile);
}
let grade = self.tile_grades[tile];
if grade < 4 {
self.border_tiles[attacker].insert(tile);
result.attacker.push(tile);
} else {
result.territory.push(tile);
}
}
if tiles.len() > 50 {
tracing::trace!(tile_count = tiles.len(), "Large border transition");
}
result
}
/// Gets the border tiles of a player
pub fn get_border_tiles(&self, player: usize) -> &HashSet<usize> {
&self.border_tiles[player]
}
}

View File

@@ -0,0 +1,346 @@
use rand::rngs::StdRng;
use rand::{Rng, SeedableRng};
use crate::game::action::GameAction;
use crate::game::border_manager::BorderManager;
use crate::game::player::Player;
use crate::game::territory_manager::TerritoryManager;
/// Simple bot AI
pub struct BotPlayer {
last_action_tick: u64,
action_cooldown: u64,
}
impl Default for BotPlayer {
fn default() -> Self {
Self::new()
}
}
impl BotPlayer {
pub fn new() -> Self {
let mut rng = rand::rng();
Self {
last_action_tick: 0,
action_cooldown: rng.random_range(0..10), // 0-1 seconds
}
}
/// Tick the bot AI - now deterministic based on turn number and RNG seed
pub fn tick(&mut self, turn_number: u64, player: &Player, territory_manager: &TerritoryManager, border_manager: &BorderManager, rng_seed: u64) -> Option<GameAction> {
// Only act every few ticks
if turn_number < self.last_action_tick + self.action_cooldown {
return None;
}
self.last_action_tick = turn_number;
// Deterministic RNG based on turn number, player ID, and global seed
let seed = rng_seed.wrapping_add(turn_number).wrapping_add(player.id as u64);
let mut rng = StdRng::seed_from_u64(seed);
self.action_cooldown = rng.random_range(3..15);
// Decide action: expand into wilderness or attack a neighbor
let action_type: f32 = rng.random();
let action = if action_type < 0.6 {
// Expand into wilderness (60% chance)
self.expand_wilderness(player, territory_manager, border_manager, &mut rng)
} else {
// Attack a neighbor (40% chance)
self.attack_neighbor(player, territory_manager, border_manager, &mut rng)
};
let _span = tracing::debug_span!("bot_tick", player_id = player.id, has_action = action.is_some()).entered();
action
}
/// Expand into unclaimed territory
fn expand_wilderness(&self, player: &Player, territory_manager: &TerritoryManager, border_manager: &BorderManager, rng: &mut StdRng) -> Option<GameAction> {
let border_tiles = border_manager.get_border_tiles(player.id);
// Find a valid, unclaimed neighbor tile to attack
for &tile in border_tiles {
let x = tile as u32 % territory_manager.width();
let y = tile as u32 / territory_manager.width();
let neighbors = [(x > 0).then_some(tile - 1), (x < territory_manager.width() - 1).then_some(tile + 1), (y > 0).then_some(tile - territory_manager.width() as usize), (y < territory_manager.height() - 1).then_some(tile + territory_manager.width() as usize)];
for neighbor in neighbors.iter().flatten() {
if !territory_manager.has_owner(*neighbor) && !territory_manager.is_water(*neighbor) {
// Found unclaimed land
let troop_percentage: f32 = rng.random_range(0.1..0.3);
return Some(GameAction::Attack { player_id: player.id as u16, target_tile: *neighbor as u32, troops_ratio: troop_percentage });
}
}
}
tracing::trace!(player_id = player.id, "No wilderness target found");
None
}
/// Attack a neighboring player
fn attack_neighbor(&self, player: &Player, territory_manager: &TerritoryManager, border_manager: &BorderManager, rng: &mut StdRng) -> Option<GameAction> {
// Find neighboring players
let mut neighbors = std::collections::HashSet::new();
let border_tiles = border_manager.get_border_tiles(player.id);
for &tile in border_tiles {
let x = tile as u32 % territory_manager.width();
let y = tile as u32 / territory_manager.width();
// Check all neighbors
if x > 0 {
let neighbor = tile - 1;
let ownership = territory_manager.get_ownership(neighbor);
if let Some(nation_id) = ownership.nation_id()
&& nation_id != player.id as u16
{
neighbors.insert(nation_id as usize);
}
}
if x < territory_manager.width() - 1 {
let neighbor = tile + 1;
let ownership = territory_manager.get_ownership(neighbor);
if let Some(nation_id) = ownership.nation_id()
&& nation_id != player.id as u16
{
neighbors.insert(nation_id as usize);
}
}
if y > 0 {
let neighbor = tile - territory_manager.width() as usize;
let ownership = territory_manager.get_ownership(neighbor);
if let Some(nation_id) = ownership.nation_id()
&& nation_id != player.id as u16
{
neighbors.insert(nation_id as usize);
}
}
if y < territory_manager.height() - 1 {
let neighbor = tile + territory_manager.width() as usize;
let ownership = territory_manager.get_ownership(neighbor);
if let Some(nation_id) = ownership.nation_id()
&& nation_id != player.id as u16
{
neighbors.insert(nation_id as usize);
}
}
}
if neighbors.is_empty() {
return None;
}
// Pick a random neighbor to attack
let neighbor_vec: Vec<_> = neighbors.into_iter().collect();
let target_id = neighbor_vec[rng.random_range(0..neighbor_vec.len())];
// To attack a player, we need to pick a specific tile.
// Let's find a border tile of the target player that is adjacent to us.
let target_border = border_manager.get_border_tiles(target_id);
for &target_tile in target_border {
let x = target_tile as u32 % territory_manager.width();
let y = target_tile as u32 / territory_manager.width();
let neighbor_indices = [(x > 0).then_some(target_tile - 1), (x < territory_manager.width() - 1).then_some(target_tile + 1), (y > 0).then_some(target_tile - territory_manager.width() as usize), (y < territory_manager.height() - 1).then_some(target_tile + territory_manager.width() as usize)];
for &neighbor_idx in neighbor_indices.iter().flatten() {
if territory_manager.get_owner(neighbor_idx) == player.id as u16 {
// This is a valid attack target
let troop_percentage: f32 = rng.random_range(0.2..0.5);
return Some(GameAction::Attack { player_id: player.id as u16, target_tile: target_tile as u32, troops_ratio: troop_percentage });
}
}
}
None
}
}
/// Minimum distance (in tiles) between any two spawn points
/// This ensures players and bots don't spawn too close together
const MIN_SPAWN_DISTANCE: f32 = 70.0;
/// Calculate Euclidean distance between two tiles
fn calculate_tile_distance(tile1: usize, tile2: usize, map_width: u32) -> f32 {
let x1 = (tile1 as u32 % map_width) as f32;
let y1 = (tile1 as u32 / map_width) as f32;
let x2 = (tile2 as u32 % map_width) as f32;
let y2 = (tile2 as u32 / map_width) as f32;
let dx = x1 - x2;
let dy = y1 - y2;
(dx * dx + dy * dy).sqrt()
}
/// Manager for bot AI state and decision-making
///
/// BotManager is part of GameInstance and handles all bot decision-making
/// in a deterministic way. Unlike the old Bevy-based bot system, this is
/// part of the core game state and executes during turn processing.
pub struct BotManager {
bots: Vec<BotPlayer>,
bot_player_ids: Vec<usize>,
}
impl BotManager {
/// Create a new BotManager with the specified number of bots
pub fn new(bot_count: usize, human_player_count: usize) -> Self {
let bots = (0..bot_count).map(|_| BotPlayer::new()).collect();
// Bot player IDs start after human players
// Human player is ID 0, so first bot is ID 1
let first_bot_id = human_player_count;
let bot_player_ids = (first_bot_id..(first_bot_id + bot_count)).collect();
Self { bots, bot_player_ids }
}
/// Get the number of bots
pub fn bot_count(&self) -> usize {
self.bots.len()
}
/// Get bot player IDs
pub fn bot_player_ids(&self) -> &[usize] {
&self.bot_player_ids
}
/// Calculate initial bot spawn positions (first pass)
///
/// Places bots at random valid locations, ensuring they are sufficiently
/// spaced from each other. This is deterministic based on rng_seed.
///
/// Returns Vec<(player_id, tile_index)> for each bot
pub fn calculate_initial_spawns(&self, territory_manager: &TerritoryManager, rng_seed: u64) -> Vec<(usize, usize)> {
let mut spawn_positions = Vec::new();
let width = territory_manager.width();
let height = territory_manager.height();
let map_size = (width * height) as usize;
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);
// Try to find a valid spawn location
for _ in 0..1000 {
let tile = rng.random_range(0..map_size);
// Check if tile is unclaimed land
if territory_manager.has_owner(tile) || territory_manager.is_water(tile) {
continue;
}
// Check distance from all previous spawns
let mut too_close = false;
for &(_, existing_tile) in &spawn_positions {
if calculate_tile_distance(tile, existing_tile, width) < MIN_SPAWN_DISTANCE {
too_close = true;
break;
}
}
if !too_close {
spawn_positions.push((player_id, tile));
break;
}
}
}
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.
/// This maintains determinism while ensuring proper spawn spacing.
///
/// Arguments:
/// - `initial_bot_spawns`: Bot positions from first pass
/// - `player_spawns`: Human player spawn positions
/// - `territory_manager`: For checking valid tiles
/// - `rng_seed`: For deterministic relocation
///
/// Returns updated Vec<(player_id, tile_index)> with relocated bots
pub fn recalculate_spawns_with_players(&self, initial_bot_spawns: Vec<(usize, usize)>, player_spawns: &[(usize, usize)], territory_manager: &TerritoryManager, rng_seed: u64) -> Vec<(usize, usize)> {
let width = territory_manager.width();
let map_size = (width * territory_manager.height()) as usize;
// Identify bots that need relocation
let mut bots_to_relocate = Vec::new();
let mut final_spawns = Vec::new();
for (player_id, tile) in initial_bot_spawns {
let mut needs_relocation = false;
// Check distance from all player spawns
for &(_, player_tile) in player_spawns {
if calculate_tile_distance(tile, player_tile, width) < MIN_SPAWN_DISTANCE {
needs_relocation = true;
break;
}
}
if needs_relocation {
bots_to_relocate.push(player_id);
} else {
final_spawns.push((player_id, tile));
}
}
// Relocate bots that are too close to players
for &player_id in &bots_to_relocate {
let seed = rng_seed.wrapping_add(player_id as u64).wrapping_add(0xDEADBEEF);
let mut rng = StdRng::seed_from_u64(seed);
// Find new location away from all existing spawns (players + already-placed bots)
for _ in 0..1000 {
let tile = rng.random_range(0..map_size);
// Check if tile is unclaimed land
if territory_manager.has_owner(tile) || territory_manager.is_water(tile) {
continue;
}
// Check distance from all player spawns
let mut too_close = false;
for &(_, existing_tile) in player_spawns {
if calculate_tile_distance(tile, existing_tile, width) < MIN_SPAWN_DISTANCE {
too_close = true;
break;
}
}
// Check distance from all finalized bot spawns
if !too_close {
for &(_, existing_tile) in &final_spawns {
if calculate_tile_distance(tile, existing_tile, width) < MIN_SPAWN_DISTANCE {
too_close = true;
break;
}
}
}
if !too_close {
final_spawns.push((player_id, tile));
break;
}
}
}
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,186 @@
use crate::game::action::GameAction;
use crate::game::attack_handler::AttackActionHandler;
use crate::game::border_manager::BorderManager;
use crate::game::bot::BotManager;
use crate::game::player_manager::PlayerManager;
use crate::game::territory_manager::TerritoryManager;
use crate::networking::{Intent, Turn};
use bevy_ecs::prelude::*;
use std::collections::HashSet;
/// 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 attack_handler: AttackActionHandler,
pub border_manager: BorderManager,
pub bot_manager: BotManager,
pub turn_number: u64,
/// RNG seed for deterministic random number generation
/// All deterministic RNG should use: hash(turn_number + rng_seed + context)
pub rng_seed: u64,
}
impl GameInstance {
pub fn new(player_manager: PlayerManager, territory_manager: TerritoryManager, attack_handler: AttackActionHandler, border_manager: BorderManager, bot_manager: BotManager, rng_seed: u64) -> Self {
Self { player_manager, territory_manager, attack_handler, border_manager, bot_manager, turn_number: 0, rng_seed }
}
pub fn execute_turn(&mut self, turn: &Turn) {
let _span = tracing::trace_span!("execute_turn", turn_number = self.turn_number, intent_count = turn.intents.len()).entered();
// PHASE 1: Process bot actions (deterministic, based on turn N-1 state)
let bot_player_ids = self.bot_manager.bot_player_ids().to_vec();
for (bot_index, &player_id) in bot_player_ids.iter().enumerate() {
if let Some(player) = self.player_manager.get_player(player_id) {
if !player.is_alive() {
continue;
}
if let Some(action) = self.bot_manager.calculate_action(bot_index, self.turn_number, player, &self.territory_manager, &self.border_manager, self.rng_seed) {
tracing::trace!(bot_index, player_id, "Bot action executed");
self.apply_action(action);
}
}
}
// PHASE 2: Process player intents (from network)
for intent in &turn.intents {
match intent {
Intent::Action(action) => {
self.apply_action(action.clone());
}
Intent::SetSpawn { .. } => {
// SetSpawn intents should not appear in Turn messages
// They are only valid during spawn phase and handled separately
// If we see one here, it's likely a bug or late arrival - ignore it
}
}
}
// PHASE 3: Tick game systems (income, attacks, etc.)
if self.turn_number.is_multiple_of(10) {
// income every 10 turns (1 second)
self.player_manager.process_income();
}
self.attack_handler.tick(self.player_manager.get_players_mut(), &mut self.territory_manager, &mut self.border_manager);
self.turn_number += 1;
}
/// Apply a game action (used by both bots and player intents)
pub fn apply_action(&mut self, action: GameAction) {
match action {
GameAction::Spawn { player_id, tile_index } => {
self.handle_spawn(player_id, tile_index);
}
GameAction::Attack { player_id, target_tile, troops_ratio } => {
self.handle_attack(player_id, target_tile, troops_ratio);
}
}
}
pub fn handle_spawn(&mut self, player_id: u16, tile_index: u32) {
let player_id = player_id as usize;
let width = self.territory_manager.width();
let height = self.territory_manager.height();
let tile = tile_index as usize;
if self.territory_manager.has_owner(tile) || self.territory_manager.is_water(tile) {
tracing::debug!(player_id, tile, "Spawn on occupied/water tile ignored");
return;
}
let spawn_x = tile as u32 % width;
let spawn_y = tile as u32 / width;
let mut changed: HashSet<usize> = HashSet::new();
for dy in -2..=2 {
for dx in -2..=2 {
let x = (spawn_x as i32 + dx).clamp(0, width as i32 - 1) as u32;
let y = (spawn_y as i32 + dy).clamp(0, height as i32 - 1) as u32;
let idx = (x + y * width) as usize;
if !self.territory_manager.has_owner(idx) && !self.territory_manager.is_water(idx) {
self.territory_manager.conquer(idx, player_id);
changed.insert(idx);
}
}
}
if !changed.is_empty() {
self.border_manager.transition_tiles(&changed, player_id, None, &self.territory_manager);
// Update player stats
if let Some(player) = self.player_manager.get_player_mut(player_id) {
for &t in &changed {
player.add_tile(t);
}
}
// Notify attack scheduling that territory changed
for &t in &changed {
self.attack_handler.handle_territory_add(t, player_id, &self.territory_manager);
}
}
}
pub fn handle_attack(&mut self, player_id: u16, target_tile: u32, troops_ratio: f32) {
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 = if let Some(player) = self.player_manager.get_player(player_id) {
player.get_troops() * troops_ratio
} else {
return;
};
// Deduct troops from the player's pool when creating the attack
if let Some(player) = self.player_manager.get_player_mut(player_id) {
player.remove_troops(troops);
} else {
return;
}
use crate::game::TileOwnership;
if TileOwnership::from_u16(target_owner).is_unclaimed() {
if let Some(player) = self.player_manager.get_player(player_id) {
let p = player.clone();
self.attack_handler.attack_unclaimed(&p, troops, target_tile, Some(self.border_manager.get_border_tiles(player_id)), &self.territory_manager, &self.border_manager, self.turn_number);
}
} else if let Some(target) = self.player_manager.get_player(target_owner as usize)
&& let Some(player) = self.player_manager.get_player(player_id)
{
let p = player.clone();
let t = target.clone();
self.attack_handler.attack_player(&p, &t, target_tile, troops, Some(self.border_manager.get_border_tiles(player_id)), &self.territory_manager, &self.border_manager, self.turn_number);
}
}
}

View File

@@ -0,0 +1,205 @@
//! Platform-agnostic input handling systems
//!
//! These systems use InputState instead of Bevy's input queries,
//! making them work across both WASM and desktop platforms with
//! Pixi.js rendering.
use bevy_ecs::prelude::*;
use tracing::{debug, info, trace};
use crate::game::{GameAction, LocalPlayerContext, SpawnManager, TileOwnership};
use crate::networking::{GameView, Intent, IntentEvent};
use crate::ui::input::{InputState, KeyCode, MouseButton};
use crate::ui::protocol::CameraCommand;
/// Resource tracking whether spawn phase is active
#[derive(Resource, Default)]
pub struct SpawnPhase {
pub active: bool,
}
/// Resource for attack control settings
#[derive(Resource)]
pub struct AttackControls {
pub attack_ratio: f32,
}
impl Default for AttackControls {
fn default() -> Self {
Self { attack_ratio: 0.5 }
}
}
/// Handle placing the human spawn by clicking on valid land
#[allow(clippy::too_many_arguments)]
pub fn handle_spawn_click_system(input_state: NonSend<std::sync::Arc<std::sync::Mutex<InputState>>>, spawn_phase: Res<SpawnPhase>, game_view: Option<ResMut<GameView>>, local_context: Option<Res<LocalPlayerContext>>, mut spawn_manager: Option<ResMut<SpawnManager>>, mut spawn_timeout: Option<ResMut<crate::game::SpawnTimeout>>, mut intent_writer: MessageWriter<IntentEvent>, game_instance: Option<Res<crate::game::GameInstance>>) {
if !spawn_phase.active {
return;
}
let Ok(input) = input_state.lock() else {
return;
};
if !input.mouse_just_released(MouseButton::Left) {
return;
}
let _span = tracing::trace_span!("spawn_click").entered();
// Frontend handles camera interaction filtering, but double-check here
if input.had_camera_interaction() {
trace!("Spawn click ignored - camera interaction detected");
return;
}
let Some(game_view) = game_view else {
debug!("Spawn click ignored - GameView not ready");
return;
};
let Some(local_context) = local_context else {
debug!("Spawn click ignored - LocalPlayerContext not ready");
return;
};
// Can't spawn if not allowed to send intents
if !local_context.can_send_intents {
debug!("Spawn click ignored - cannot send intents");
return;
}
// Get tile from InputState (set by frontend)
let Some(tile_coord) = input.cursor_tile() else {
debug!("Spawn click ignored - cursor not over valid tile");
return;
};
let tile = tile_coord.to_index(game_view.width);
let tile_ownership = TileOwnership::from_u16(game_view.get_owner(tile));
if tile_ownership.is_owned() || tile_ownership.is_water() {
debug!("Spawn click on tile {} ignored - occupied or water", tile);
return;
}
// Player has chosen a spawn location - send to server
info!("Player {} setting spawn at tile {}", local_context.my_player_id, tile);
// Check if this is the first spawn (timer not started yet)
let is_first_spawn = if let Some(ref spawn_mgr) = spawn_manager { spawn_mgr.get_player_spawns().is_empty() } else { true };
// Send SetSpawn intent to server (not Action - this won't be in game history)
// Server will validate, track, and eventually send Turn(0) when timeout expires
intent_writer.write(IntentEvent(Intent::SetSpawn { player_id: local_context.my_player_id as u16, tile_index: tile as u32 }));
// Start spawn timeout on first spawn (spawn_phase plugin will emit countdown updates)
if is_first_spawn && let Some(ref mut timeout) = spawn_timeout {
timeout.start();
info!("Spawn timeout started ({:.1}s)", timeout.duration_secs);
}
// Update local spawn manager for preview/bot recalculation
// Note: This only updates the spawn manager, not the game instance
// The actual game state is updated when Turn(0) is processed
if let Some(ref mut spawn_mgr) = spawn_manager
&& let Some(ref game_inst) = game_instance
{
// Update spawn manager (triggers bot spawn recalculation)
spawn_mgr.update_player_spawn(local_context.my_player_id, tile, &game_inst.bot_manager, &game_inst.territory_manager);
info!("Spawn manager updated with player {} spawn at tile {}", local_context.my_player_id, tile);
info!("Total spawns in manager: {}", spawn_mgr.get_all_spawns().len());
}
}
/// Center the camera on the client's spawn (hotkey C)
pub fn handle_center_camera_system(input_state: NonSend<std::sync::Arc<std::sync::Mutex<InputState>>>, game_view: Option<Res<GameView>>, local_context: Option<Res<LocalPlayerContext>>, mut camera_commands: MessageWriter<CameraCommand>) {
let Ok(input) = input_state.lock() else {
return;
};
if !input.key_just_pressed(KeyCode::KeyC) {
return;
}
let Some(game_view) = game_view else {
return; // GameView not ready yet
};
let Some(local_context) = local_context else {
return; // LocalPlayerContext not ready yet
};
// Find any owned tile to center on
if let Some(tile) = game_view.find_tile_owned_by(local_context.my_player_id as u16) {
camera_commands.write(CameraCommand::CenterOnTile { tile_index: tile as u32, animate: true });
}
}
/// After spawn, clicking tiles triggers expansion/attack based on ownership
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>>, local_context: Option<Res<LocalPlayerContext>>, attack_controls: Res<AttackControls>, mut intent_writer: MessageWriter<IntentEvent>) {
if spawn_phase.active {
return;
}
let Ok(input) = input_state.lock() else {
return;
};
if !input.mouse_just_released(MouseButton::Left) {
return;
}
// Frontend handles camera interaction filtering
if input.had_camera_interaction() {
return;
}
let _span = tracing::trace_span!("attack_click").entered();
let Some(game_view) = game_view else {
return; // GameView not ready yet
};
let Some(local_context) = local_context else {
return; // LocalPlayerContext not ready yet
};
// Can't attack if not allowed to send intents (defeated/spectating)
if !local_context.can_send_intents {
return;
}
// Get tile from InputState (set by frontend)
let Some(tile_coord) = input.cursor_tile() else {
return;
};
let tile = tile_coord.to_index(game_view.width);
let owner = game_view.get_owner(tile);
if owner != local_context.my_player_id as u16 {
intent_writer.write(IntentEvent(Intent::Action(GameAction::Attack { player_id: local_context.my_player_id as u16, target_tile: tile as u32, troops_ratio: attack_controls.attack_ratio })));
}
}
/// 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,164 @@
use bevy_ecs::prelude::*;
use rand::rngs::StdRng;
use rand::{Rng, SeedableRng};
use tracing::{debug, info};
use crate::constants::BOT_COUNT;
use crate::game::{AttackActionHandler, BorderManager, BotManager, GameInstance, HSLColor, LocalPlayerContext, Player, PlayerManager, SpawnManager, SpawnPhase, SpawnTimeout, TerritoryManager};
use crate::networking::{GameView, LocalTurnServerHandle, PlayerView, TurnReceiver};
use flume::Receiver;
/// Parameters needed to initialize a new game
pub struct GameInitParams {
pub map_width: u32,
pub map_height: u32,
pub conquerable_tiles: Vec<bool>,
pub tile_expansion_times: Vec<u8>,
pub tile_expansion_costs: Vec<u8>,
pub client_player_id: usize,
pub intent_rx: Receiver<crate::networking::Intent>,
}
/// Initialize all game resources when starting a new game
/// This should be called by the StartGame command handler
pub fn initialize_game_resources(commands: &mut Commands, params: GameInitParams) {
info!("Initializing game resources (map: {}x{}, player: {})", params.map_width, params.map_height, params.client_player_id);
// Initialize territory manager
let mut territory_manager = TerritoryManager::new(params.map_width, params.map_height);
territory_manager.reset(params.map_width, params.map_height, &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 attack handler
let mut attack_handler = AttackActionHandler::new();
attack_handler.init(1 + BOT_COUNT, params.tile_expansion_times, params.tile_expansion_costs, params.map_width, params.map_height);
// 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 game instance (bots won't be spawned until player chooses spawn)
let game_instance = GameInstance::new(player_manager, territory_manager, attack_handler, border_manager, bot_manager, 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", initial_bot_spawns.len());
// Create SpawnManager to track spawn positions during spawn phase
let spawn_manager = SpawnManager::new(initial_bot_spawns.clone(), rng_seed, params.map_width, params.map_height);
commands.insert_resource(spawn_manager);
// Initialize GameView with initial game state
// Calculate total land tiles once for caching (performance optimization)
use std::sync::Arc;
let total_land_tiles = game_instance.territory_manager.as_slice().iter().filter(|ownership| !ownership.is_water()).count() as u32;
let game_view = GameView {
width: params.map_width,
height: params.map_height,
territories: Arc::from(game_instance.territory_manager.to_u16_vec().as_slice()),
turn_number: 0,
total_land_tiles,
changed_tiles: Vec::new(), // Empty on initialization
players: game_instance.player_manager.get_players().iter().map(|p| PlayerView { id: p.id as u16, color: p.color.to_rgba(), name: p.name.clone(), tile_count: p.get_territory_size() as u32, troops: p.get_troops() as u32, is_alive: p.is_alive() }).collect(),
};
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 (5 seconds for local mode)
commands.insert_resource(SpawnTimeout::new(5.0));
debug!("SpawnTimeout initialized (5.0 seconds)");
// Initialize turn generation resources
let (turn_tx, turn_rx) = flume::unbounded();
let server_handle = LocalTurnServerHandle { paused: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)), running: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)) };
commands.insert_resource(server_handle);
commands.insert_resource(TurnReceiver { turn_rx });
commands.insert_resource(crate::networking::TurnGenerator { turn_number: 0, accumulated_time: 0.0, turn_tx, spawn_config: std::collections::HashMap::new(), spawn_timeout_accumulated: None, game_started: false });
debug!("Turn generator initialized (paused until player spawn)");
// Activate spawn phase (SpawnPhasePlugin will emit initial SpawnPhaseUpdate)
commands.insert_resource(SpawnPhase { active: true });
debug!("Spawn phase activated");
info!("Game resources initialized successfully - ready to start");
}
/// Clean up all game resources when quitting a game
/// This should be called by the QuitGame command handler
pub fn cleanup_game_resources(world: &mut World) {
info!("Cleaning up game resources...");
// Stop local turn server if running
if let Some(server_handle) = world.get_resource::<LocalTurnServerHandle>() {
server_handle.stop();
world.remove_resource::<LocalTurnServerHandle>();
}
// Remove all game-specific resources
world.remove_resource::<GameInstance>();
world.remove_resource::<LocalPlayerContext>();
world.remove_resource::<TurnReceiver>();
world.remove_resource::<SpawnManager>();
world.remove_resource::<SpawnTimeout>();
world.remove_resource::<crate::networking::GameView>();
world.remove_resource::<crate::TerrainData>();
world.remove_resource::<crate::networking::TurnGenerator>();
// Note: SpawnPhase is a permanent resource (init_resource), not removed on quit
info!("Game resources cleaned up successfully");
}

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,41 @@
pub mod action;
pub mod attack;
pub mod attack_handler;
pub mod border_manager;
pub mod bot;
pub mod game_instance;
pub mod input_handlers;
pub mod lifecycle;
pub mod local_context;
pub mod outcome;
pub mod player;
pub mod player_manager;
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 use action::*;
pub use attack::*;
pub use attack_handler::*;
pub use border_manager::*;
pub use bot::*;
pub use game_instance::*;
pub use input_handlers::*;
pub use lifecycle::*;
pub use local_context::*;
pub use outcome::*;
pub use player::*;
pub use player_manager::*;
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::*;

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,132 @@
/// 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 }
}
}
/// Player in the game
#[derive(Debug, Clone)]
pub struct Player {
pub id: usize,
pub name: String,
pub color: HSLColor,
troops: f32,
territory_size: usize,
alive: bool,
}
impl Player {
pub fn new(id: usize, name: String, color: HSLColor) -> Self {
Self { id, name, color, troops: 1000.0, territory_size: 0, alive: true }
}
/// Add a tile to the player'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 player'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;
}
}
/// Process one tick worth of income
pub fn income(&mut self) {
let base_income = (self.territory_size / 10) as f32;
let troop_factor = (3.0 / 5.0_f32).powf(1.0 - (self.troops + 1.0).ln() / std::f32::consts::LN_2);
let income = 1.0_f32.max(base_income + troop_factor.floor());
self.add_troops(income);
}
/// Get the amount of troops the player has
pub fn get_troops(&self) -> f32 {
self.troops
}
/// Add troops to the player
/// Troops will be capped at 100 times the territory size
pub fn add_troops(&mut self, amount: f32) {
self.troops = (self.troops + amount).min((self.territory_size * 100) as f32);
}
/// Remove troops from the player
pub fn remove_troops(&mut self, amount: f32) {
self.troops = (self.troops - amount).max(0.0);
}
/// Get the size of the player's territory (in tiles)
pub fn get_territory_size(&self) -> usize {
self.territory_size
}
/// Check if the player is alive
pub fn is_alive(&self) -> bool {
self.alive
}
}

View File

@@ -0,0 +1,82 @@
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())
}
/// Process income for all players
pub fn process_income(&mut self) {
let _ = tracing::debug_span!("process_income");
for player in &mut self.players {
if player.is_alive() {
player.income();
}
}
}
/// 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,67 @@
use bevy_ecs::prelude::*;
/// Manages spawn positions during the pre-game spawn phase
///
/// This resource tracks bot and player spawn positions before the game starts ticking.
/// It allows for dynamic recalculation of bot positions when players change their spawn
/// location, implementing the two-pass spawn system described in the README.
#[derive(Resource)]
pub struct SpawnManager {
/// Initial bot spawn positions from first pass (player_id, tile_index)
pub initial_bot_spawns: Vec<(usize, usize)>,
/// Current bot spawn positions after recalculation (player_id, tile_index)
/// These are updated whenever a player chooses/changes their spawn
pub current_bot_spawns: Vec<(usize, usize)>,
/// Player spawn positions (player_id, tile_index)
/// Tracks human player spawn selections
pub player_spawns: Vec<(usize, usize)>,
/// RNG seed for deterministic spawn calculations
pub rng_seed: u64,
/// Map dimensions for distance calculations
pub map_width: u32,
pub map_height: u32,
}
impl SpawnManager {
/// Create a new SpawnManager with initial bot spawns
pub fn new(initial_bot_spawns: Vec<(usize, usize)>, rng_seed: u64, map_width: u32, map_height: u32) -> Self {
Self { current_bot_spawns: initial_bot_spawns.clone(), initial_bot_spawns, player_spawns: Vec::new(), rng_seed, map_width, map_height }
}
/// Update a player's spawn position and recalculate bot spawns if necessary
///
/// This triggers the second pass of the two-pass spawn system, relocating
/// any bots that are too close to the new player position.
pub fn update_player_spawn(&mut self, player_id: usize, tile_index: usize, bot_manager: &crate::game::BotManager, territory_manager: &crate::game::TerritoryManager) {
// Update or add player spawn
if let Some(entry) = self.player_spawns.iter_mut().find(|(pid, _)| *pid == player_id) {
entry.1 = tile_index;
} else {
self.player_spawns.push((player_id, tile_index));
}
// Recalculate bot spawns with updated player positions
self.current_bot_spawns = bot_manager.recalculate_spawns_with_players(self.initial_bot_spawns.clone(), &self.player_spawns, territory_manager, self.rng_seed);
}
/// Get all current spawn positions (players + bots)
pub fn get_all_spawns(&self) -> Vec<(usize, usize)> {
let mut all_spawns = self.player_spawns.clone();
all_spawns.extend(self.current_bot_spawns.iter().copied());
all_spawns
}
/// Get only bot spawn positions
pub fn get_bot_spawns(&self) -> &[(usize, usize)] {
&self.current_bot_spawns
}
/// Get only player spawn positions
pub fn get_player_spawns(&self) -> &[(usize, usize)] {
&self.player_spawns
}
}

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

View File

@@ -0,0 +1,21 @@
use glam::{IVec2, UVec2};
/// Convert 2D coordinates to a flat array index
pub fn get_idx<T: Into<UVec2>>(pos: T, width: u32) -> usize {
let pos = pos.into();
pos.x as usize + pos.y as usize * width as usize
}
const CARDINAL_DIRECTIONS: [IVec2; 4] = [IVec2::new(0, 1), IVec2::new(1, 0), IVec2::new(0, -1), IVec2::new(-1, 0)];
/// Returns an iterator over the valid cardinal neighbors of a tile.
///
/// Requires the width and height of the map to be passed in to ensure the neighbor is within bounds.
pub fn get_neighbors(pos: UVec2, width: u32, height: u32) -> impl Iterator<Item = UVec2> {
let in_bounds = move |neighbor: IVec2| (0..width).contains(&(neighbor.x as u32)) && (0..height).contains(&(neighbor.y as u32));
CARDINAL_DIRECTIONS.into_iter().filter_map(move |dir| {
let neighbor = pos.as_ivec2().saturating_add(dir);
in_bounds(neighbor).then_some(neighbor.as_uvec2())
})
}

View File

@@ -0,0 +1,184 @@
use crate::game::tile_ownership::{ENCODED_WATER, TileOwnership};
use crate::game::tilemap::TileMap;
use crate::game::tilemap_changes::ChangeBuffer;
/// Deprecated: Use TileOwnership::Water instead
/// Kept for backward compatibility during migration
pub const OWNER_WATER: u16 = ENCODED_WATER;
/// Manages territory ownership for all tiles
pub struct TerritoryManager {
tile_owners: TileMap<TileOwnership>,
changes: ChangeBuffer,
/// Cached u16 representation for efficient serialization to frontend
u16_cache: Vec<u16>,
cache_dirty: bool,
}
impl TerritoryManager {
/// Creates a new territory manager
pub fn new(width: u32, height: u32) -> Self {
let size = (width * height) as usize;
Self { tile_owners: TileMap::with_default(width, height, TileOwnership::Unclaimed), changes: ChangeBuffer::with_capacity((width * height / 100) as usize), u16_cache: vec![0; size], cache_dirty: true }
}
/// Resets the territory manager
/// Should only be called when a new game is started
pub fn reset(&mut self, width: u32, height: u32, conquerable_tiles: &[bool]) {
self.tile_owners = TileMap::with_default(width, height, TileOwnership::Unclaimed);
self.changes.clear();
for (i, &conquerable) in conquerable_tiles.iter().enumerate() {
if !conquerable {
self.tile_owners[i] = TileOwnership::Water;
}
}
let size = (width * height) as usize;
self.u16_cache.resize(size, 0);
self.cache_dirty = true;
}
/// Checks if a tile is a border tile of the territory of its owner
/// A tile is a border tile if it is adjacent to a tile that is not owned by the same player
pub fn is_border(&self, tile: usize) -> bool {
let pos = self.tile_owners.index_to_pos(tile);
let owner = self.tile_owners[tile];
// Border if on map edge
if pos.x == 0 || pos.x == self.tile_owners.width() - 1 || pos.y == 0 || pos.y == self.tile_owners.height() - 1 {
return true;
}
// Border if any neighbor has different owner
for neighbor_pos in self.tile_owners.neighbors(pos) {
if self.tile_owners[neighbor_pos] != owner {
return true;
}
}
false
}
/// Checks if a tile has an owner
pub fn has_owner(&self, tile: usize) -> bool {
self.tile_owners[tile].is_owned()
}
/// Checks if a tile is owned by a specific player
pub fn is_owner(&self, tile: usize, owner: usize) -> bool {
self.tile_owners[tile].is_owned_by(owner as u16)
}
/// Gets the owner of a tile as u16 (for compatibility)
/// Returns the encoded u16 value (nation_id, or ENCODED_UNCLAIMED/ENCODED_WATER)
pub fn get_owner(&self, tile: usize) -> u16 {
self.tile_owners[tile].to_u16()
}
/// Gets the ownership enum for a tile
pub fn get_ownership(&self, tile: usize) -> TileOwnership {
self.tile_owners[tile]
}
/// Checks if a tile is water
pub fn is_water(&self, tile: usize) -> bool {
self.tile_owners[tile].is_water()
}
/// Conquers a tile for a player
/// If the tile is already owned by a player, that player will lose the tile
/// Only records a change if the owner actually changed
pub fn conquer(&mut self, tile: usize, owner: usize) -> u16 {
let previous_owner = self.tile_owners[tile];
let new_ownership = TileOwnership::Owned(owner as u16);
// Only update and track change if the owner actually changed
if previous_owner != new_ownership {
self.tile_owners[tile] = new_ownership;
self.changes.push(tile);
self.cache_dirty = true;
}
previous_owner.to_u16()
}
/// Clears a tile (removes ownership)
pub fn clear(&mut self, tile: usize) -> Option<u16> {
let ownership = self.tile_owners[tile];
if ownership.is_owned() {
self.tile_owners[tile] = TileOwnership::Unclaimed;
self.changes.push(tile);
self.cache_dirty = true;
ownership.nation_id()
} else {
None
}
}
/// Get width of the map
pub fn width(&self) -> u32 {
self.tile_owners.width()
}
/// Get height of the map
pub fn height(&self) -> u32 {
self.tile_owners.height()
}
/// Returns a reference to the underlying tile ownership data as a slice of enums
pub fn as_slice(&self) -> &[TileOwnership] {
self.tile_owners.as_slice()
}
/// Returns the tile ownership data as u16 values for frontend serialization
/// This is cached and only recomputed when ownership changes
pub fn as_u16_slice(&mut self) -> &[u16] {
if self.cache_dirty {
for (i, ownership) in self.tile_owners.as_slice().iter().enumerate() {
self.u16_cache[i] = ownership.to_u16();
}
self.cache_dirty = false;
}
&self.u16_cache
}
/// Converts tile ownership to a Vec<u16> for serialization (immutable)
/// Use this when you need a Vec for creating Arc<[u16]>
pub fn to_u16_vec(&self) -> Vec<u16> {
self.tile_owners.as_slice().iter().map(|ownership| ownership.to_u16()).collect()
}
/// Returns the number of tiles in the map
pub fn len(&self) -> usize {
self.tile_owners.len()
}
/// Returns true if the map has no tiles
pub fn is_empty(&self) -> bool {
self.tile_owners.len() == 0
}
/// 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)
}
}

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,441 @@
use glam::UVec2;
use std::ops::{Index, IndexMut};
/// A 2D grid-based map structure optimized for tile-based games.
///
/// Provides efficient access to tiles using 2D coordinates (UVec2) while maintaining
/// cache-friendly contiguous memory layout. Supports generic tile types that implement Copy.
///
/// # Type Parameters
/// * `T` - The tile value type. Must implement `Copy` for efficient access.
///
/// # Examples
/// ```
/// use glam::UVec2;
/// use borders_core::game::TileMap;
///
/// let mut map = TileMap::<u8>::new(10, 10);
/// map[UVec2::new(5, 5)] = 42;
/// assert_eq!(map[UVec2::new(5, 5)], 42);
/// ```
#[derive(Clone, Debug)]
pub struct TileMap<T: Copy> {
tiles: Box<[T]>,
width: u32,
height: u32,
}
impl<T: Copy> TileMap<T> {
/// Creates a new TileMap with the specified dimensions and default value.
///
/// # Arguments
/// * `width` - The width of the map in tiles
/// * `height` - The height of the map in tiles
/// * `default` - The default value to initialize all tiles with
pub fn with_default(width: u32, height: u32, default: T) -> Self {
let capacity = (width * height) as usize;
let tiles = vec![default; capacity].into_boxed_slice();
Self { tiles, width, height }
}
/// Creates a TileMap from an existing vector of tile data.
///
/// # Arguments
/// * `width` - The width of the map in tiles
/// * `height` - The height of the map in tiles
/// * `data` - Vector containing tile data in row-major order
///
/// # Panics
/// Panics if `data.len() != width * height`
pub fn from_vec(width: u32, height: u32, data: Vec<T>) -> Self {
assert_eq!(data.len(), (width * height) as usize, "Data length must match width * height");
Self { tiles: data.into_boxed_slice(), width, height }
}
/// Converts the position to a flat array index.
///
/// # Safety
/// Debug builds will assert that the position is in bounds.
/// Release builds skip the check for performance.
#[inline]
pub fn pos_to_index(&self, pos: UVec2) -> usize {
debug_assert!(pos.x < self.width && pos.y < self.height);
(pos.y * self.width + pos.x) as usize
}
/// Converts a flat array index to a 2D position.
#[inline]
pub fn index_to_pos(&self, index: usize) -> UVec2 {
debug_assert!(index < self.tiles.len());
UVec2::new((index as u32) % self.width, (index as u32) / self.width)
}
/// Checks if a position is within the map bounds.
#[inline]
pub fn in_bounds(&self, pos: UVec2) -> bool {
pos.x < self.width && pos.y < self.height
}
/// Gets the tile value at the specified position.
///
/// Returns `None` if the position is out of bounds.
pub fn get(&self, pos: UVec2) -> Option<T> {
if self.in_bounds(pos) { Some(self.tiles[self.pos_to_index(pos)]) } else { None }
}
/// Sets the tile value at the specified position.
///
/// Returns `true` if the position was in bounds and the value was set,
/// `false` otherwise.
pub fn set(&mut self, pos: UVec2, tile: T) -> bool {
if self.in_bounds(pos) {
let idx = self.pos_to_index(pos);
self.tiles[idx] = tile;
true
} else {
false
}
}
/// Returns the width of the map.
#[inline]
pub fn width(&self) -> u32 {
self.width
}
/// Returns the height of the map.
#[inline]
pub fn height(&self) -> u32 {
self.height
}
/// Returns the total number of tiles in the map.
#[inline]
pub fn len(&self) -> usize {
self.tiles.len()
}
/// Returns `true` if the map contains no tiles.
#[inline]
pub fn is_empty(&self) -> bool {
self.tiles.is_empty()
}
/// Returns an iterator over all valid cardinal neighbors of a position.
///
/// Yields positions for up, down, left, and right neighbors that are within bounds.
pub fn neighbors(&self, pos: UVec2) -> impl Iterator<Item = UVec2> + '_ {
const CARDINAL_DIRECTIONS: [(i32, i32); 4] = [(0, 1), (1, 0), (0, -1), (-1, 0)];
let pos_i32 = (pos.x as i32, pos.y as i32);
let width = self.width;
let height = self.height;
CARDINAL_DIRECTIONS.iter().filter_map(move |(dx, dy)| {
let nx = pos_i32.0 + dx;
let ny = pos_i32.1 + dy;
if nx >= 0 && ny >= 0 && nx < width as i32 && ny < height as i32 { Some(UVec2::new(nx as u32, ny as u32)) } else { None }
})
}
/// Calls a closure for each valid cardinal neighbor of a position.
///
/// This is more efficient than using the `neighbors()` iterator when you don't
/// need to collect the neighbors.
pub fn on_neighbors<F>(&self, pos: UVec2, mut closure: F)
where
F: FnMut(UVec2),
{
if pos.x > 0 {
closure(UVec2::new(pos.x - 1, pos.y));
}
if pos.x < self.width - 1 {
closure(UVec2::new(pos.x + 1, pos.y));
}
if pos.y > 0 {
closure(UVec2::new(pos.x, pos.y - 1));
}
if pos.y < self.height - 1 {
closure(UVec2::new(pos.x, pos.y + 1));
}
}
/// Calls a closure for each neighbor using tile indices instead of positions.
///
/// This is useful when working with systems that still use raw indices.
pub fn on_neighbor_indices<F>(&self, index: usize, mut closure: F)
where
F: FnMut(usize),
{
let width = self.width as usize;
let height = self.height as usize;
let x = index % width;
let y = index / width;
if x > 0 {
closure(index - 1);
}
if x < width - 1 {
closure(index + 1);
}
if y > 0 {
closure(index - width);
}
if y < height - 1 {
closure(index + width);
}
}
/// Returns an iterator over all positions and their tile values.
pub fn iter(&self) -> impl Iterator<Item = (UVec2, T)> + '_ {
(0..self.height).flat_map(move |y| {
(0..self.width).map(move |x| {
let pos = UVec2::new(x, y);
(pos, self[pos])
})
})
}
/// Returns an iterator over just the tile values.
pub fn iter_values(&self) -> impl Iterator<Item = T> + '_ {
self.tiles.iter().copied()
}
/// Returns an iterator over all positions in the map.
pub fn positions(&self) -> impl Iterator<Item = UVec2> + '_ {
(0..self.height).flat_map(move |y| (0..self.width).map(move |x| UVec2::new(x, y)))
}
/// Returns an iterator over tile indices, positions, and values.
pub fn enumerate(&self) -> impl Iterator<Item = (usize, UVec2, T)> + '_ {
self.tiles.iter().enumerate().map(move |(idx, &value)| {
let pos = self.index_to_pos(idx);
(idx, pos, value)
})
}
/// Returns a reference to the underlying tile data as a slice.
pub fn as_slice(&self) -> &[T] {
&self.tiles
}
/// Returns a mutable reference to the underlying tile data as a slice.
pub fn as_mut_slice(&mut self) -> &mut [T] {
&mut self.tiles
}
}
impl<T: Copy + Default> TileMap<T> {
/// Creates a new TileMap with the specified dimensions, using T::default() for initialization.
pub fn new(width: u32, height: u32) -> Self {
Self::with_default(width, height, T::default())
}
}
impl<T: Copy> Index<UVec2> for TileMap<T> {
type Output = T;
#[inline]
fn index(&self, pos: UVec2) -> &Self::Output {
&self.tiles[self.pos_to_index(pos)]
}
}
impl<T: Copy> IndexMut<UVec2> for TileMap<T> {
#[inline]
fn index_mut(&mut self, pos: UVec2) -> &mut Self::Output {
let idx = self.pos_to_index(pos);
&mut self.tiles[idx]
}
}
impl<T: Copy> Index<usize> for TileMap<T> {
type Output = T;
#[inline]
fn index(&self, index: usize) -> &Self::Output {
&self.tiles[index]
}
}
impl<T: Copy> IndexMut<usize> for TileMap<T> {
#[inline]
fn index_mut(&mut self, index: usize) -> &mut Self::Output {
&mut self.tiles[index]
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_new_with_default() {
let map = TileMap::<u8>::with_default(10, 10, 42);
assert_eq!(map.width(), 10);
assert_eq!(map.height(), 10);
assert_eq!(map[UVec2::new(0, 0)], 42);
assert_eq!(map[UVec2::new(9, 9)], 42);
}
#[test]
fn test_from_vec() {
let data = vec![1u8, 2, 3, 4];
let map = TileMap::from_vec(2, 2, data);
assert_eq!(map[UVec2::new(0, 0)], 1);
assert_eq!(map[UVec2::new(1, 0)], 2);
assert_eq!(map[UVec2::new(0, 1)], 3);
assert_eq!(map[UVec2::new(1, 1)], 4);
}
#[test]
fn test_pos_to_index() {
let map = TileMap::<u8>::with_default(10, 10, 0);
assert_eq!(map.pos_to_index(UVec2::new(0, 0)), 0);
assert_eq!(map.pos_to_index(UVec2::new(5, 0)), 5);
assert_eq!(map.pos_to_index(UVec2::new(0, 1)), 10);
assert_eq!(map.pos_to_index(UVec2::new(3, 2)), 23);
}
#[test]
fn test_index_to_pos() {
let map = TileMap::<u8>::with_default(10, 10, 0);
assert_eq!(map.index_to_pos(0), UVec2::new(0, 0));
assert_eq!(map.index_to_pos(5), UVec2::new(5, 0));
assert_eq!(map.index_to_pos(10), UVec2::new(0, 1));
assert_eq!(map.index_to_pos(23), UVec2::new(3, 2));
}
#[test]
fn test_in_bounds() {
let map = TileMap::<u8>::with_default(10, 10, 0);
assert!(map.in_bounds(UVec2::new(0, 0)));
assert!(map.in_bounds(UVec2::new(9, 9)));
assert!(!map.in_bounds(UVec2::new(10, 0)));
assert!(!map.in_bounds(UVec2::new(0, 10)));
}
#[test]
fn test_get_set() {
let mut map = TileMap::<u8>::with_default(10, 10, 0);
assert_eq!(map.get(UVec2::new(5, 5)), Some(0));
assert!(map.set(UVec2::new(5, 5), 42));
assert_eq!(map.get(UVec2::new(5, 5)), Some(42));
assert!(!map.set(UVec2::new(10, 10), 99));
assert_eq!(map.get(UVec2::new(10, 10)), None);
}
#[test]
fn test_index_operators() {
let mut map = TileMap::<u8>::with_default(10, 10, 0);
map[UVec2::new(5, 5)] = 42;
assert_eq!(map[UVec2::new(5, 5)], 42);
}
#[test]
fn test_index_by_usize() {
let mut map = TileMap::<u8>::with_default(10, 10, 0);
map[23] = 42;
assert_eq!(map[23], 42);
assert_eq!(map[UVec2::new(3, 2)], 42);
}
#[test]
fn test_neighbors_center() {
let map = TileMap::<u8>::with_default(10, 10, 0);
let neighbors: Vec<_> = map.neighbors(UVec2::new(5, 5)).collect();
assert_eq!(neighbors.len(), 4);
assert!(neighbors.contains(&UVec2::new(5, 6)));
assert!(neighbors.contains(&UVec2::new(6, 5)));
assert!(neighbors.contains(&UVec2::new(5, 4)));
assert!(neighbors.contains(&UVec2::new(4, 5)));
}
#[test]
fn test_neighbors_corner() {
let map = TileMap::<u8>::with_default(10, 10, 0);
let neighbors: Vec<_> = map.neighbors(UVec2::new(0, 0)).collect();
assert_eq!(neighbors.len(), 2);
assert!(neighbors.contains(&UVec2::new(1, 0)));
assert!(neighbors.contains(&UVec2::new(0, 1)));
}
#[test]
fn test_neighbors_edge() {
let map = TileMap::<u8>::with_default(10, 10, 0);
let neighbors: Vec<_> = map.neighbors(UVec2::new(0, 5)).collect();
assert_eq!(neighbors.len(), 3);
assert!(neighbors.contains(&UVec2::new(0, 6)));
assert!(neighbors.contains(&UVec2::new(1, 5)));
assert!(neighbors.contains(&UVec2::new(0, 4)));
}
#[test]
fn test_on_neighbors() {
let map = TileMap::<u8>::with_default(10, 10, 0);
let mut count = 0;
map.on_neighbors(UVec2::new(5, 5), |_| count += 1);
assert_eq!(count, 4);
}
#[test]
fn test_on_neighbor_indices() {
let map = TileMap::<u8>::with_default(10, 10, 0);
let center_idx = map.pos_to_index(UVec2::new(5, 5));
let mut count = 0;
map.on_neighbor_indices(center_idx, |_| count += 1);
assert_eq!(count, 4);
}
#[test]
fn test_iter() {
let map = TileMap::<u8>::with_default(2, 2, 0);
let positions: Vec<_> = map.iter().map(|(pos, _)| pos).collect();
assert_eq!(positions.len(), 4);
assert!(positions.contains(&UVec2::new(0, 0)));
assert!(positions.contains(&UVec2::new(1, 1)));
}
#[test]
fn test_iter_values() {
let map = TileMap::<u8>::with_default(2, 2, 42);
let values: Vec<_> = map.iter_values().collect();
assert_eq!(values, vec![42, 42, 42, 42]);
}
#[test]
fn test_positions() {
let map = TileMap::<u8>::with_default(2, 2, 0);
let positions: Vec<_> = map.positions().collect();
assert_eq!(positions.len(), 4);
assert_eq!(positions[0], UVec2::new(0, 0));
assert_eq!(positions[3], UVec2::new(1, 1));
}
#[test]
fn test_enumerate() {
let mut map = TileMap::<u8>::with_default(2, 2, 0);
map[UVec2::new(1, 1)] = 42;
let entries: Vec<_> = map.enumerate().collect();
assert_eq!(entries.len(), 4);
assert_eq!(entries[3], (3, UVec2::new(1, 1), 42));
}
#[test]
fn test_generic_u16() {
let mut map = TileMap::<u16>::with_default(5, 5, 0);
assert_eq!(map[UVec2::new(0, 0)], 0);
map[UVec2::new(2, 2)] = 65535;
assert_eq!(map[UVec2::new(2, 2)], 65535);
}
#[test]
fn test_generic_f32() {
let mut map = TileMap::<f32>::with_default(5, 5, 1.5);
assert_eq!(map[UVec2::new(0, 0)], 1.5);
map[UVec2::new(2, 2)] = 2.7;
assert_eq!(map[UVec2::new(2, 2)], 2.7);
}
}

View File

@@ -0,0 +1,171 @@
/// Lightweight change tracking buffer for tile mutations.
///
/// Stores only the indices of changed tiles, avoiding allocations in the hot path
/// by reusing Vec capacity across frames. This enables efficient delta updates
/// for GPU rendering and network synchronization.
///
/// # Design
/// - Records tile index changes as they occur
/// - Reuses Vec capacity to avoid allocations
/// - O(1) push, O(changes) iteration
/// - Optional: can be cleared/ignored when tracking not needed
///
/// # Example
/// ```
/// use borders_core::game::ChangeBuffer;
///
/// let mut changes = ChangeBuffer::new();
/// changes.push(10);
/// changes.push(25);
/// assert_eq!(changes.len(), 2);
///
/// let indices: Vec<_> = changes.drain().collect();
/// assert_eq!(indices, vec![10, 25]);
/// assert_eq!(changes.len(), 0);
/// ```
#[derive(Debug, Clone)]
pub struct ChangeBuffer {
changed_indices: Vec<usize>,
}
impl ChangeBuffer {
/// Creates a new empty ChangeBuffer.
pub fn new() -> Self {
Self { changed_indices: Vec::new() }
}
/// Creates a new ChangeBuffer with pre-allocated capacity.
///
/// Use this when you know the approximate number of changes to avoid reallocations.
pub fn with_capacity(capacity: usize) -> Self {
Self { changed_indices: Vec::with_capacity(capacity) }
}
/// Records a tile index as changed.
///
/// Does not check for duplicates - the same index can be pushed multiple times.
/// Consumers should handle deduplication if needed.
#[inline]
pub fn push(&mut self, index: usize) {
self.changed_indices.push(index);
}
/// Drains all changed indices, returning an iterator and clearing the buffer.
///
/// The buffer retains its capacity for reuse.
pub fn drain(&mut self) -> impl Iterator<Item = usize> + '_ {
self.changed_indices.drain(..)
}
/// Clears all tracked changes without returning them.
///
/// The buffer retains its capacity for reuse.
pub fn clear(&mut self) {
self.changed_indices.clear();
}
/// Returns true if any changes have been recorded.
#[inline]
pub fn has_changes(&self) -> bool {
!self.changed_indices.is_empty()
}
/// Returns the number of changes recorded.
///
/// Note: This may include duplicate indices if the same tile was changed multiple times.
#[inline]
pub fn len(&self) -> usize {
self.changed_indices.len()
}
/// Returns true if no changes have been recorded.
#[inline]
pub fn is_empty(&self) -> bool {
self.changed_indices.is_empty()
}
/// Returns the current capacity of the internal buffer.
#[inline]
pub fn capacity(&self) -> usize {
self.changed_indices.capacity()
}
}
impl Default for ChangeBuffer {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_new() {
let buffer = ChangeBuffer::new();
assert!(buffer.is_empty());
assert_eq!(buffer.len(), 0);
}
#[test]
fn test_with_capacity() {
let buffer = ChangeBuffer::with_capacity(100);
assert_eq!(buffer.capacity(), 100);
assert!(buffer.is_empty());
}
#[test]
fn test_push_and_drain() {
let mut buffer = ChangeBuffer::new();
buffer.push(10);
buffer.push(25);
buffer.push(42);
assert_eq!(buffer.len(), 3);
assert!(buffer.has_changes());
let changes: Vec<_> = buffer.drain().collect();
assert_eq!(changes, vec![10, 25, 42]);
assert!(buffer.is_empty());
}
#[test]
fn test_clear() {
let mut buffer = ChangeBuffer::new();
buffer.push(1);
buffer.push(2);
buffer.push(3);
assert_eq!(buffer.len(), 3);
buffer.clear();
assert_eq!(buffer.len(), 0);
assert!(buffer.is_empty());
}
#[test]
fn test_duplicate_indices() {
let mut buffer = ChangeBuffer::new();
buffer.push(10);
buffer.push(10);
buffer.push(10);
assert_eq!(buffer.len(), 3); // Does not deduplicate
let changes: Vec<_> = buffer.drain().collect();
assert_eq!(changes, vec![10, 10, 10]);
}
#[test]
fn test_capacity_retained_after_drain() {
let mut buffer = ChangeBuffer::with_capacity(100);
buffer.push(1);
buffer.push(2);
let initial_capacity = buffer.capacity();
let _: Vec<_> = buffer.drain().collect();
// Capacity should be retained after drain
assert!(buffer.capacity() >= initial_capacity);
assert!(buffer.is_empty());
}
}

View File

@@ -0,0 +1,17 @@
pub mod app;
pub mod build_info;
pub mod constants;
pub mod game;
pub mod networking;
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 _span = tracing::debug_span!("receive_net_messages", message_count).entered();
}
}
/// System for remote clients to send intents as NetMessage
pub fn send_net_intent_system(mut intent_events: MessageReader<IntentEvent>, remote_client: Res<RemoteClientConnection>) {
let mut intent_count = 0;
for event in intent_events.read() {
intent_count += 1;
let net_message = NetMessage::Intent(event.0.clone());
if let Err(e) = remote_client.intent_tx.try_send(net_message) {
error!("Failed to send net intent: {:?}", e);
}
}
if intent_count > 0 {
let _span = tracing::debug_span!("send_net_intents", intent_count).entered();
}
}
/// System to handle spawn configuration updates from server
/// Updates local SpawnManager with remote player spawn positions
pub fn handle_spawn_config_system(mut spawn_config_events: MessageReader<crate::networking::SpawnConfigEvent>, mut spawn_manager: Option<ResMut<crate::game::SpawnManager>>, game_instance: Option<Res<crate::game::GameInstance>>) {
for event in spawn_config_events.read() {
let Some(ref mut spawn_mgr) = spawn_manager else {
continue;
};
let Some(ref game_inst) = game_instance else {
continue;
};
// Update player spawns from server
spawn_mgr.player_spawns.clear();
for (&player_id, &tile_index) in &event.0 {
spawn_mgr.player_spawns.push((player_id as usize, tile_index as usize));
}
// Recalculate bot spawns based on updated player positions
spawn_mgr.current_bot_spawns = game_inst.bot_manager.recalculate_spawns_with_players(spawn_mgr.initial_bot_spawns.clone(), &spawn_mgr.player_spawns, &game_inst.territory_manager, spawn_mgr.rng_seed);
info!("Updated spawn manager with {} player spawns from server", spawn_mgr.player_spawns.len());
}
}

View File

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

View File

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

View File

@@ -0,0 +1,305 @@
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;
// Write length prefix
#[cfg(target_arch = "wasm32")]
{
self.send_stream.write(&len.to_be_bytes()).await.map_err(|e| anyhow::anyhow!(e.to_string()))?;
self.send_stream.write(data).await.map_err(|e| anyhow::anyhow!(e.to_string()))?;
}
#[cfg(not(target_arch = "wasm32"))]
{
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();
#[cfg(target_arch = "wasm32")]
{
if let Some(chunk) = self.recv_stream.read(remaining).await.map_err(|e| anyhow::anyhow!(e.to_string()))? {
len_bytes.extend_from_slice(&chunk);
} else {
anyhow::bail!("Stream closed before reading length prefix");
}
}
#[cfg(not(target_arch = "wasm32"))]
{
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();
#[cfg(target_arch = "wasm32")]
{
if let Some(chunk) = self.recv_stream.read(remaining).await.map_err(|e| anyhow::anyhow!(e.to_string()))? {
buffer.extend_from_slice(&chunk);
} else {
anyhow::bail!("Stream closed before reading full message");
}
}
#[cfg(not(target_arch = "wasm32"))]
{
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;
#[cfg(target_arch = "wasm32")]
{
self.send_stream.write(&len.to_be_bytes()).await.map_err(|e| anyhow::anyhow!(e.to_string()))?;
self.send_stream.write(data).await.map_err(|e| anyhow::anyhow!(e.to_string()))?;
}
#[cfg(not(target_arch = "wasm32"))]
{
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();
#[cfg(target_arch = "wasm32")]
{
if let Some(chunk) = self.recv_stream.read(remaining).await.map_err(|e| anyhow::anyhow!(e.to_string()))? {
len_bytes.extend_from_slice(&chunk);
} else {
anyhow::bail!("Stream closed before reading length prefix");
}
}
#[cfg(not(target_arch = "wasm32"))]
{
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();
#[cfg(target_arch = "wasm32")]
{
if let Some(chunk) = self.recv_stream.read(remaining).await.map_err(|e| anyhow::anyhow!(e.to_string()))? {
buffer.extend_from_slice(&chunk);
} else {
anyhow::bail!("Stream closed before reading full message");
}
}
#[cfg(not(target_arch = "wasm32"))]
{
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,55 @@
use crate::game::GameInstance;
use crate::networking::{GameView, Intent, IntentEvent, PlayerView, protocol::NetMessage};
use bevy_ecs::prelude::*;
use flume::Sender;
use std::collections::HashMap;
#[derive(Resource)]
pub struct ServerChannels {
pub broadcast_tx: Sender<GameView>,
pub net_message_tx: Sender<NetMessage>,
}
pub fn broadcast_game_state_system(server_channels: Res<ServerChannels>, mut game: ResMut<GameInstance>) {
let game_view = create_game_view(&mut game);
let _ = server_channels.broadcast_tx.try_send(game_view);
}
/// System to broadcast turns to all connected clients
pub fn broadcast_turn_system(server_channels: Res<ServerChannels>, mut turn_events: MessageReader<crate::networking::ProcessTurnEvent>) {
for event in turn_events.read() {
let turn_message = NetMessage::Turn { turn: event.0.turn_number, intents: event.0.intents.clone() };
let _ = server_channels.net_message_tx.try_send(turn_message);
}
}
/// Resource to track spawn configurations during spawn phase
#[derive(Resource, Default)]
pub struct SpawnConfigTracker {
pub spawns: HashMap<u16, u32>,
}
/// System to broadcast spawn configuration when players set their spawn
/// Only runs during multiplayer server mode
pub fn broadcast_spawn_config_system(mut intent_events: MessageReader<IntentEvent>, mut spawn_tracker: ResMut<SpawnConfigTracker>, server_channels: Res<ServerChannels>) {
for event in intent_events.read() {
if let Intent::SetSpawn { player_id, tile_index } = event.0 {
// Track this spawn
spawn_tracker.spawns.insert(player_id, tile_index);
// Broadcast updated spawn configuration to all clients
let spawn_message = NetMessage::SpawnConfiguration { spawns: spawn_tracker.spawns.clone() };
let _ = server_channels.net_message_tx.try_send(spawn_message);
}
}
}
fn create_game_view(game: &mut GameInstance) -> GameView {
use std::sync::Arc;
let total_land_tiles = game.territory_manager.as_slice().iter().filter(|ownership| !ownership.is_water()).count() as u32;
let changed_tiles: Vec<usize> = game.territory_manager.drain_changes().collect();
GameView { width: game.territory_manager.width(), height: game.territory_manager.height(), territories: Arc::from(game.territory_manager.to_u16_vec().as_slice()), turn_number: game.turn_number, total_land_tiles, changed_tiles, players: game.player_manager.get_players().iter().map(|p| PlayerView { id: p.id as u16, color: p.color.to_rgba(), name: p.name.clone(), tile_count: p.get_territory_size() as u32, troops: p.get_troops() as u32, is_alive: p.is_alive() }).collect() }
}

View File

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

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,686 @@
//! Consolidated game plugin integrating all core systems
//!
//! This module provides the main `GamePlugin` which sets up all game logic including:
//! - Networking (local or remote)
//! - Spawn phase management
//! - Core game systems and event handling
//! - Turn execution and processing
use bevy_ecs::prelude::*;
use bevy_ecs::schedule::common_conditions::resource_exists;
use tracing::{debug, info, trace};
use crate::app::{App, Last, Plugin, Update};
use crate::time::{FixedTime, Time};
use crate::constants::TICK_INTERVAL;
use crate::game::{AttackControls, GameInstance, SpawnPhase, SpawnTimeout};
use crate::networking::{
GameView, IntentEvent, IntentReceiver, ProcessTurnEvent, TurnReceiver,
coordinator::{generate_turns_system, poll_turns_system},
};
#[cfg(not(target_arch = "wasm32"))]
use std::time::{SystemTime, UNIX_EPOCH};
#[cfg(target_arch = "wasm32")]
use web_time::{SystemTime, UNIX_EPOCH};
// Re-export protocol types for convenience
#[cfg(feature = "ui")]
use crate::ui::protocol::{BackendMessage, CameraCommand, SpawnCountdown};
/// Network mode configuration for the game
pub enum NetworkMode {
/// Local single-player or hotseat mode
Local,
/// Remote multiplayer mode (non-WASM only)
#[cfg(not(target_arch = "wasm32"))]
Remote { server_address: String },
}
/// Main game plugin that consolidates all core game logic
///
/// This plugin sets up:
/// - Network channels (local or remote)
/// - Spawn phase management
/// - Core game systems
/// - Turn processing
/// - Input handling
pub struct GamePlugin {
pub network_mode: NetworkMode,
}
impl GamePlugin {
pub fn new(network_mode: NetworkMode) -> Self {
Self { network_mode }
}
}
impl Plugin for GamePlugin {
fn build(&self, app: &mut App) {
// Setup networking based on mode
match &self.network_mode {
NetworkMode::Local => {
info!("Initializing GamePlugin in Local mode");
// Local mode: use direct channels
let (intent_tx, intent_rx) = flume::unbounded();
let (game_view_tx, game_view_rx) = flume::unbounded();
app.insert_resource(crate::networking::client::ClientConnection { intent_tx, game_view_rx })
.insert_resource(crate::networking::server::ServerChannels {
broadcast_tx: game_view_tx,
net_message_tx: flume::unbounded().0, // Dummy channel for local mode
})
.insert_resource(IntentReceiver { intent_rx })
.add_systems(Update, (crate::networking::client::send_intent_system, crate::networking::client::receive_game_view_system.run_if(resource_exists::<GameView>)))
.add_systems(Update, (poll_turns_system.run_if(resource_exists::<TurnReceiver>), crate::networking::server::broadcast_game_state_system.run_if(resource_exists::<GameInstance>).run_if(|spawn_phase: Option<Res<SpawnPhase>>| spawn_phase.is_none_or(|sp| !sp.active))));
}
#[cfg(not(target_arch = "wasm32"))]
NetworkMode::Remote { server_address } => {
info!("Initializing GamePlugin in Remote mode (server: {})", server_address);
// Remote mode: use NetMessage protocol
let (net_intent_tx, net_intent_rx) = flume::unbounded();
let (net_message_tx, net_message_rx) = flume::unbounded();
app.insert_resource(crate::networking::client::RemoteClientConnection { intent_tx: net_intent_tx, net_message_rx, player_id: None }).add_systems(Update, (crate::networking::client::send_net_intent_system, crate::networking::client::receive_net_message_system, crate::networking::client::handle_spawn_config_system));
// Spawn networking thread
let server_addr = server_address.clone();
std::thread::spawn(move || {
let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
runtime.block_on(async move {
use crate::networking::protocol::NetMessage;
use tracing::error;
info!("Connecting to remote server at {}", server_addr);
// Load server certificate for validation
let cert_path = "dev-cert.pem";
let cert_data = match std::fs::read(cert_path) {
Ok(data) => data,
Err(e) => {
error!("Failed to read certificate file {}: {}", cert_path, e);
error!("Please run the `generate-dev-cert.ps1` script first.");
return;
}
};
let pem = pem::parse(&cert_data).expect("Failed to parse PEM certificate");
let cert_hash = ring::digest::digest(&ring::digest::SHA256, pem.contents()).as_ref().to_vec();
let client = web_transport::ClientBuilder::new().with_server_certificate_hashes(vec![cert_hash]).expect("Failed to create client with certificate hash");
let mut connection = match client.connect(server_addr.parse().unwrap()).await {
Ok(conn) => {
info!("Connected to server successfully");
conn
}
Err(e) => {
error!("Failed to connect to server: {}", e);
return;
}
};
let (mut send_stream, mut recv_stream) = match connection.open_bi().await {
Ok(streams) => {
info!("Opened bidirectional stream");
streams
}
Err(e) => {
error!("Failed to open bidirectional stream: {}", e);
return;
}
};
// Read initial ServerConfig
info!("Reading initial server config...");
let mut len_bytes = Vec::new();
while len_bytes.len() < 8 {
let remaining = 8 - len_bytes.len();
match recv_stream.read(remaining).await {
Ok(Some(chunk)) => {
len_bytes.extend_from_slice(&chunk);
}
Ok(None) => {
error!("Stream closed before reading server config length");
return;
}
Err(e) => {
error!("Failed to read server config length: {}", e);
return;
}
}
}
let len = u64::from_be_bytes(len_bytes[0..8].try_into().unwrap()) as usize;
let mut message_bytes = Vec::new();
while message_bytes.len() < len {
let remaining = len - message_bytes.len();
match recv_stream.read(remaining).await {
Ok(Some(chunk)) => {
message_bytes.extend_from_slice(&chunk);
}
Ok(None) => {
error!("Stream closed before reading server config data");
return;
}
Err(e) => {
error!("Failed to read server config data: {}", e);
return;
}
}
}
match bincode::decode_from_slice(&message_bytes, bincode::config::standard()) {
Ok((net_message, _)) => {
info!("Received server config: {:?}", net_message);
match net_message {
NetMessage::ServerConfig { player_id } => {
info!("Assigned player ID: {}", player_id);
}
_ => {
error!("Expected ServerConfig, got: {:?}", net_message);
return;
}
}
}
Err(e) => {
error!("Failed to decode server config: {}", e);
return;
}
}
// Send intents to server
let send_task = async {
while let Ok(net_message) = net_intent_rx.recv_async().await {
match bincode::encode_to_vec(net_message, bincode::config::standard()) {
Ok(message_bytes) => {
let len_bytes = (message_bytes.len() as u64).to_be_bytes();
let mut written = 0;
while written < len_bytes.len() {
match send_stream.write(&len_bytes[written..]).await {
Ok(bytes_written) => written += bytes_written,
Err(e) => {
error!("Failed to send length prefix: {}", e);
return;
}
}
}
let mut written = 0;
while written < message_bytes.len() {
match send_stream.write(&message_bytes[written..]).await {
Ok(bytes_written) => written += bytes_written,
Err(e) => {
error!("Failed to send message: {}", e);
return;
}
}
}
}
Err(e) => {
error!("Failed to encode message: {}", e);
break;
}
}
}
};
// Receive messages from server
let recv_task = async {
loop {
let mut len_bytes = Vec::new();
while len_bytes.len() < 8 {
let remaining = 8 - len_bytes.len();
if let Ok(maybe_chunk) = recv_stream.read(remaining).await {
if let Some(chunk) = maybe_chunk {
len_bytes.extend_from_slice(&chunk);
} else {
break;
}
} else {
error!("Stream closed before reading length prefix");
break;
}
}
let len = u64::from_be_bytes(len_bytes[0..8].try_into().unwrap()) as usize;
let mut message_bytes = Vec::new();
while message_bytes.len() < len {
let remaining = len - message_bytes.len();
if let Ok(maybe_chunk) = recv_stream.read(remaining).await {
if let Some(chunk) = maybe_chunk {
message_bytes.extend_from_slice(&chunk);
} else {
break;
}
} else {
error!("Stream closed before reading full message");
break;
}
}
match bincode::decode_from_slice(&message_bytes, bincode::config::standard()) {
Ok((net_message, _)) => {
if net_message_tx.send_async(net_message).await.is_err() {
error!("Failed to forward message to client");
break;
}
}
Err(e) => {
error!("Failed to decode message: {}", e);
break;
}
}
}
};
futures_lite::future::zip(send_task, recv_task).await;
error!("Connection to server closed");
});
});
}
}
// Configure fixed timestep for game logic (10 TPS = 100ms)
app.insert_resource(FixedTime::from_seconds(TICK_INTERVAL as f64 / 1000.0));
// Core multiplayer events and resources
app.add_message::<IntentEvent>().add_message::<ProcessTurnEvent>().add_message::<crate::networking::SpawnConfigEvent>().init_resource::<GameView>();
// UI-related events and resources (feature-gated)
#[cfg(feature = "ui")]
{
app.add_message::<CameraCommand>().init_resource::<crate::ui::LastLeaderboardDigest>().init_resource::<crate::ui::LastAttacksDigest>().init_resource::<crate::ui::LeaderboardThrottle>().init_resource::<crate::ui::NationHighlightState>();
}
// 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, (execute_turn_system, crate::game::check_local_player_outcome).run_if(resource_exists::<GameInstance>).run_if(resource_exists::<GameView>).run_if(resource_exists::<SpawnPhase>));
// 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_nation_highlight_system).run_if(resource_exists::<GameInstance>).run_if(resource_exists::<GameView>));
// Command handlers
#[cfg(feature = "ui")]
app.add_systems(Update, handle_frontend_messages_system);
// Platform-agnostic input systems
app.add_systems(Update, (crate::game::handle_spawn_click_system, crate::game::handle_attack_click_system, crate::game::handle_center_camera_system, crate::game::handle_attack_ratio_keys_system).run_if(resource_exists::<GameInstance>));
// Input state frame update
app.add_systems(Last, clear_input_state_system);
// Turn generation system
app.add_systems(Update, generate_turns_system.run_if(resource_exists::<crate::networking::TurnGenerator>));
}
}
/// Resource to track if we've emitted the initial spawn phase event
#[derive(Resource, Default)]
struct SpawnPhaseInitialized {
emitted_initial: bool,
}
/// Resource to track previous spawn state for incremental updates
#[derive(Resource, Default)]
struct PreviousSpawnState {
spawns: Vec<(usize, usize)>, // (player_id, tile_index)
}
/// System to emit initial SpawnPhaseUpdate when game starts
#[cfg(feature = "ui")]
fn emit_initial_spawn_phase_system(mut initialized: ResMut<SpawnPhaseInitialized>, spawn_phase: Res<SpawnPhase>, game_instance: Option<Res<GameInstance>>, mut backend_messages: MessageWriter<BackendMessage>) {
if initialized.emitted_initial || !spawn_phase.active || game_instance.is_none() {
return;
}
backend_messages.write(BackendMessage::SpawnPhaseUpdate { countdown: None });
initialized.emitted_initial = true;
debug!("Emitted initial SpawnPhaseUpdate (no countdown)");
}
#[cfg(not(feature = "ui"))]
fn emit_initial_spawn_phase_system() {}
/// System to manage spawn timeout and emit countdown updates
#[cfg(feature = "ui")]
fn manage_spawn_phase_system(mut spawn_timeout: Option<ResMut<SpawnTimeout>>, spawn_phase: Option<Res<SpawnPhase>>, time: Res<Time>, mut backend_messages: MessageWriter<BackendMessage>) {
let Some(spawn_phase) = spawn_phase else {
return;
};
let Some(ref mut spawn_timeout) = spawn_timeout else {
return;
};
if !spawn_phase.active || !spawn_timeout.active {
return;
}
spawn_timeout.update(time.delta_secs());
let started_at_ms = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_millis() as u64 - (spawn_timeout.elapsed_secs * 1000.0) as u64;
backend_messages.write(BackendMessage::SpawnPhaseUpdate { countdown: Some(SpawnCountdown { started_at_ms, duration_secs: spawn_timeout.duration_secs }) });
trace!("SpawnPhaseUpdate: remaining {:.1}s", spawn_timeout.remaining_secs);
}
#[cfg(not(feature = "ui"))]
fn manage_spawn_phase_system() {}
/// System to update GameView with spawn preview during spawn phase
/// This shows territory data to the frontend BEFORE Turn(0) is executed
/// Only processes changed spawns incrementally for better performance
fn update_spawn_preview_system(spawn_phase: Res<SpawnPhase>, spawn_manager: Option<Res<crate::game::SpawnManager>>, mut game_view: Option<ResMut<GameView>>, game_instance: Option<Res<GameInstance>>, mut previous_state: ResMut<PreviousSpawnState>) {
if !spawn_phase.active {
return;
}
let Some(ref spawn_mgr) = spawn_manager else {
return;
};
// Only update if SpawnManager has changed
if !spawn_mgr.is_changed() {
return;
}
let Some(ref mut game_view) = game_view else {
return;
};
let Some(ref game_inst) = game_instance else {
return;
};
let width = game_view.width;
let height = game_view.height;
// Get current spawns
let current_spawns = spawn_mgr.get_all_spawns();
// Find spawns that were removed and added
let previous_spawns = &previous_state.spawns;
let removed_spawns: Vec<_> = previous_spawns.iter().filter(|prev| !current_spawns.contains(prev)).copied().collect();
let added_spawns: Vec<_> = current_spawns.iter().filter(|curr| !previous_spawns.contains(curr)).copied().collect();
// If nothing changed, return early
if removed_spawns.is_empty() && added_spawns.is_empty() {
return;
}
// Clone territories only if we have changes to process
use std::sync::Arc;
let mut territories: Vec<crate::game::TileOwnership> = game_view.territories.iter().map(|&u| crate::game::TileOwnership::from_u16(u)).collect();
let base_territories = game_inst.territory_manager.as_slice();
let mut changed_tiles = std::collections::HashSet::new();
// Process removed spawns: revert their 5x5 areas to base state
for &(player_id, tile_index) in &removed_spawns {
let spawn_x = tile_index as u32 % width;
let spawn_y = tile_index as u32 / width;
for dy in -2..=2 {
for dx in -2..=2 {
let x = (spawn_x as i32 + dx).clamp(0, width as i32 - 1) as u32;
let y = (spawn_y as i32 + dy).clamp(0, height as i32 - 1) as u32;
let idx = (x + y * width) as usize;
// Check if this tile belongs to the removed spawn
if territories[idx].is_owned_by(player_id as u16) {
// Recalculate from scratch for this tile
let mut new_owner = base_territories[idx];
// Check if any other spawn claims this tile
for &(other_player_id, other_tile_index) in &current_spawns {
let other_x = other_tile_index as u32 % width;
let other_y = other_tile_index as u32 / width;
let dx_other = x as i32 - other_x as i32;
let dy_other = y as i32 - other_y as i32;
if dx_other.abs() <= 2 && dy_other.abs() <= 2 && base_territories[idx].is_unclaimed() {
new_owner = crate::game::TileOwnership::Owned(other_player_id as u16);
break;
}
}
if territories[idx] != new_owner {
territories[idx] = new_owner;
changed_tiles.insert(idx);
}
}
}
}
}
// Process added spawns: mark their 5x5 areas
for &(player_id, tile_index) in &added_spawns {
let spawn_x = tile_index as u32 % width;
let spawn_y = tile_index as u32 / width;
for dy in -2..=2 {
for dx in -2..=2 {
let x = (spawn_x as i32 + dx).clamp(0, width as i32 - 1) as u32;
let y = (spawn_y as i32 + dy).clamp(0, height as i32 - 1) as u32;
let idx = (x + y * width) as usize;
// Only claim if base territory is unclaimed
if base_territories[idx].is_unclaimed() {
let old_value = territories[idx];
let new_owner = crate::game::TileOwnership::Owned(player_id as u16);
if old_value != new_owner {
territories[idx] = new_owner;
changed_tiles.insert(idx);
}
}
}
}
}
// Convert back to u16 and update game view
let territories_u16: Vec<u16> = territories.iter().map(|o| o.to_u16()).collect();
game_view.territories = Arc::from(territories_u16.as_slice());
game_view.changed_tiles = changed_tiles.into_iter().collect();
// Update player tile counts incrementally
let mut players_map = std::collections::HashMap::new();
for player in &game_view.players {
players_map.insert(player.id, player.clone());
}
// Recalculate tile counts for affected players only
let mut affected_players = std::collections::HashSet::new();
for &(player_id, _) in removed_spawns.iter().chain(added_spawns.iter()) {
affected_players.insert(player_id as u16);
}
for player_id in affected_players {
if let Some(player) = players_map.get_mut(&player_id) {
let tile_count = territories.iter().filter(|ownership| ownership.is_owned_by(player_id)).count() as u32;
player.tile_count = tile_count;
}
}
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 execute turns from ProcessTurnEvent
pub fn execute_turn_system(mut turn_events: MessageReader<ProcessTurnEvent>, 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 events: Vec<_> = turn_events.read().map(|e| e.0.clone()).collect();
if events.is_empty() {
return;
}
let Some(ref mut game_view) = game_view else {
return;
};
for turn in events {
trace!("Executing turn {} with {} intents", turn.turn_number, turn.intents.len());
game_instance.execute_turn(&turn);
if turn.turn_number == 0
&& let Some(ref spawn_mgr) = spawn_manager
{
let bot_spawns = spawn_mgr.get_bot_spawns();
for &(player_id, tile_index) in bot_spawns {
game_instance.handle_spawn(player_id as u16, tile_index as u32);
}
}
let total_land_tiles = game_instance.territory_manager.as_slice().iter().filter(|ownership| !ownership.is_water()).count() as u32;
**game_view = GameView { width: game_instance.territory_manager.width(), height: game_instance.territory_manager.height(), territories: Arc::from(game_instance.territory_manager.to_u16_vec().as_slice()), turn_number: game_instance.turn_number, total_land_tiles, changed_tiles: Vec::new(), players: game_instance.player_manager.get_players().iter().map(|p| crate::networking::PlayerView { id: p.id as u16, color: p.color.to_rgba(), name: p.name.clone(), tile_count: p.get_territory_size() as u32, troops: p.get_troops() as u32, is_alive: p.is_alive() }).collect() };
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");
}
}
}
}
/// System to handle FrontendMessage events
#[cfg(feature = "ui")]
#[allow(clippy::too_many_arguments)]
fn handle_frontend_messages_system(mut commands: Commands, mut frontend_messages: MessageReader<crate::ui::protocol::FrontendMessage>, game_instance: Option<Res<GameInstance>>, intent_receiver: Option<Res<IntentReceiver>>, mut attack_controls: Option<ResMut<AttackControls>>, mut spawn_phase: ResMut<SpawnPhase>, mut spawn_phase_init: ResMut<SpawnPhaseInitialized>, mut previous_spawn_state: ResMut<PreviousSpawnState>) {
use crate::ui::protocol::FrontendMessage;
use tracing::{debug, error, info};
for message in frontend_messages.read() {
match message {
FrontendMessage::StartGame => {
info!("Processing StartGame command");
if game_instance.is_some() {
error!("Game already running - ignoring StartGame");
continue;
}
let Some(ref intent_receiver) = intent_receiver else {
error!("IntentReceiver not available - cannot start game");
continue;
};
let terrain_data = match crate::game::TerrainData::load_world_map() {
Ok(data) => data,
Err(e) => {
error!("Failed to load World map: {}", e);
continue;
}
};
commands.insert_resource(terrain_data.clone());
let size = terrain_data.size();
let width = size.x;
let height = size.y;
let mut conquerable_tiles = Vec::with_capacity((width * height) as usize);
let mut tile_expansion_times = Vec::with_capacity((width * height) as usize);
let mut tile_expansion_costs = Vec::with_capacity((width * height) as usize);
for y in 0..height {
for x in 0..width {
conquerable_tiles.push(terrain_data.is_conquerable((x, y)));
tile_expansion_times.push(terrain_data.get_expansion_time((x, y)));
tile_expansion_costs.push(terrain_data.get_expansion_cost((x, y)));
}
}
let params = crate::game::GameInitParams {
map_width: width,
map_height: height,
conquerable_tiles,
tile_expansion_times,
tile_expansion_costs,
client_player_id: 0, // Human player is ID 0
intent_rx: intent_receiver.intent_rx.clone(),
};
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,317 @@
use super::types::{BatchCaptureRequest, BatchEvent, TelemetryConfig, TelemetryEvent};
use super::user_id::UserIdType;
use hmac::{Hmac, Mac};
use sha2::Sha256;
use std::collections::HashMap;
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>;
#[cfg(not(target_arch = "wasm32"))]
use tokio::sync::Mutex;
#[cfg(target_arch = "wasm32")]
use std::sync::Mutex;
/// 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<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 {
// Get or create a persistent user ID
let (distinct_id, id_type) = get_or_create_user_id();
debug!("Telemetry client initialized with user ID: {} (type: {})", &distinct_id[..8], id_type.as_str());
let default_properties = build_default_properties(id_type);
Self {
config,
client: reqwest::Client::new(),
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(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 with user ID: {} (type: {})", &distinct_id[..8], id_type.as_str());
let default_properties = build_default_properties(id_type);
Self {
config,
client: reqwest::Client::new(),
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(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;
#[cfg(not(target_arch = "wasm32"))]
{
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(interval_secs));
loop {
interval.tick().await;
client.flush().await;
}
});
debug!("Started periodic flush task (interval: {}s)", interval_secs);
}
#[cfg(target_arch = "wasm32")]
{
use gloo_timers::future::TimeoutFuture;
wasm_bindgen_futures::spawn_local(async move {
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);
#[cfg(not(target_arch = "wasm32"))]
let mut buffer = self.buffer.lock().await;
#[cfg(target_arch = "wasm32")]
let mut buffer = self.buffer.lock().unwrap();
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")]
wasm_bindgen_futures::spawn_local(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 = {
#[cfg(not(target_arch = "wasm32"))]
let mut buffer = self.buffer.lock().await;
#[cfg(target_arch = "wasm32")]
let mut buffer = self.buffer.lock().unwrap();
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;
}
debug!("Sending {} telemetry events to PostHog", events.len());
let batch_events: Vec<BatchEvent> = events
.into_iter()
.map(|mut event| {
debug!(" - Event: {}", event.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);
debug!("Request signature: {}", &signature[..16]);
let url = format!("https://{}/batch", self.config.api_host);
debug!("POST {}", url);
// 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!("Successfully sent telemetry batch (status: {})", status);
} else {
let body = response.text().await.unwrap_or_default();
warn!("PostHog returned non-success status: {} - Body: {}", status, body);
}
}
Err(e) => {
error!("Failed to send telemetry batch: {}", e);
}
}
}
/// 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,127 @@
//! 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
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TileCoord {
pub x: u32,
pub y: u32,
}
impl TileCoord {
/// Convert to linear tile index
pub fn to_index(&self, map_width: u32) -> usize {
(self.y * map_width + self.x) as usize
}
/// Create from linear tile index
pub fn from_index(index: usize, map_width: u32) -> Self {
Self { x: (index as u32) % map_width, y: (index as u32) / map_width }
}
}
/// Camera state for coordinate conversions
#[derive(Debug, Clone, Copy)]
pub struct CameraState {
/// Camera position in world coordinates
pub x: f32,
pub y: f32,
/// Camera zoom level (1.0 = normal)
pub zoom: f32,
/// Viewport width in pixels
pub viewport_width: f32,
/// Viewport height in pixels
pub viewport_height: f32,
}
/// Input event from the frontend
#[derive(Debug, Clone)]
pub enum InputEvent {
MouseButton { button: MouseButton, state: ButtonState, world_pos: Option<WorldPos>, tile: Option<TileCoord> },
MouseMove { world_pos: WorldPos, screen_pos: ScreenPos, tile: Option<TileCoord> },
MouseWheel { delta_x: f32, delta_y: f32 },
KeyPress { key: KeyCode, state: ButtonState },
}
#[derive(Debug, Default, Resource)]
pub struct InputState {
// Mouse state
mouse_buttons: Vec<(MouseButton, ButtonState)>,
cursor_world_pos: Option<WorldPos>,
cursor_tile: Option<TileCoord>,
mouse_wheel_delta: (f32, f32),
// Keyboard state
keys_pressed: Vec<KeyCode>,
keys_just_pressed: Vec<KeyCode>,
keys_just_released: Vec<KeyCode>,
// Track if camera was interacted with (for click filtering)
camera_interaction: bool,
}
impl InputState {
pub fn new() -> Self {
Self::default()
}
/// Clear per-frame data (call at start of frame)
pub fn clear_frame_data(&mut self) {
self.mouse_buttons.clear();
self.keys_just_pressed.clear();
self.keys_just_released.clear();
self.mouse_wheel_delta = (0.0, 0.0);
self.camera_interaction = false;
}
/// Process an input event
pub fn handle_event(&mut self, event: InputEvent) {
match event {
InputEvent::MouseButton { button, state, world_pos, tile } => {
self.mouse_buttons.push((button, state));
if world_pos.is_some() {
self.cursor_world_pos = world_pos;
}
if tile.is_some() {
self.cursor_tile = tile;
}
}
InputEvent::MouseMove { world_pos, tile, .. } => {
self.cursor_world_pos = Some(world_pos);
self.cursor_tile = tile;
}
InputEvent::MouseWheel { delta_x, delta_y } => {
self.mouse_wheel_delta.0 += delta_x;
self.mouse_wheel_delta.1 += delta_y;
// Mouse wheel = camera interaction
if delta_x.abs() > 0.0 || delta_y.abs() > 0.0 {
self.camera_interaction = true;
}
}
InputEvent::KeyPress { key, state } => match state {
ButtonState::Pressed => {
if !self.keys_pressed.contains(&key) {
self.keys_pressed.push(key);
self.keys_just_pressed.push(key);
}
}
ButtonState::Released => {
self.keys_pressed.retain(|&k| k != key);
self.keys_just_released.push(key);
}
},
}
}
/// Check if a mouse button was just pressed this frame
pub fn mouse_just_pressed(&self, button: MouseButton) -> bool {
self.mouse_buttons.iter().any(|&(b, s)| b == button && s == ButtonState::Pressed)
}
/// Check if a mouse button was just released this frame
pub fn mouse_just_released(&self, button: MouseButton) -> bool {
self.mouse_buttons.iter().any(|&(b, s)| b == button && s == ButtonState::Released)
}
/// Check if a key is currently pressed
pub fn key_pressed(&self, key: KeyCode) -> bool {
self.keys_pressed.contains(&key)
}
/// Check if a key was just pressed this frame
pub fn key_just_pressed(&self, key: KeyCode) -> bool {
self.keys_just_pressed.contains(&key)
}
/// Check if a key was just released this frame
pub fn key_just_released(&self, key: KeyCode) -> bool {
self.keys_just_released.contains(&key)
}
/// Get current cursor position in world coordinates
pub fn cursor_world_pos(&self) -> Option<WorldPos> {
self.cursor_world_pos
}
/// Get current tile under cursor
pub fn cursor_tile(&self) -> Option<TileCoord> {
self.cursor_tile
}
/// Get mouse wheel delta for this frame
pub fn mouse_wheel_delta(&self) -> (f32, f32) {
self.mouse_wheel_delta
}
/// Check if camera was interacted with (for filtering clicks)
pub fn had_camera_interaction(&self) -> bool {
self.camera_interaction
}
/// Mark that camera was interacted with
pub fn set_camera_interaction(&mut self) {
self.camera_interaction = true;
}
}
/// Coordinate conversion utilities
pub mod coords {
use super::*;
/// Convert screen position to world position
pub fn screen_to_world(screen: ScreenPos, camera: &CameraState) -> WorldPos {
// Adjust for camera position and zoom
let world_x = (screen.x - camera.viewport_width / 2.0) / camera.zoom + camera.x;
let world_y = (screen.y - camera.viewport_height / 2.0) / camera.zoom + camera.y;
WorldPos { x: world_x, y: world_y }
}
/// Convert world position to screen position
pub fn world_to_screen(world: WorldPos, camera: &CameraState) -> ScreenPos {
let screen_x = (world.x - camera.x) * camera.zoom + camera.viewport_width / 2.0;
let screen_y = (world.y - camera.y) * camera.zoom + camera.viewport_height / 2.0;
ScreenPos { x: screen_x, y: screen_y }
}
/// Convert world position to tile coordinates
pub fn world_to_tile(world: WorldPos, map_width: u32, map_height: u32, pixel_scale: f32) -> Option<TileCoord> {
// Adjust for centered map
let half_width = (map_width as f32 * pixel_scale) / 2.0;
let half_height = (map_height as f32 * pixel_scale) / 2.0;
let adjusted_x = world.x + half_width;
let adjusted_y = world.y + half_height;
let tile_x = (adjusted_x / pixel_scale) as i32;
let tile_y = (adjusted_y / pixel_scale) as i32;
if tile_x >= 0 && tile_x < map_width as i32 && tile_y >= 0 && tile_y < map_height as i32 { Some(TileCoord { x: tile_x as u32, y: tile_y as u32 }) } else { None }
}
/// Convert tile coordinates to world position (center of tile)
pub fn tile_to_world(tile: TileCoord, map_width: u32, map_height: u32, pixel_scale: f32) -> WorldPos {
let half_width = (map_width as f32 * pixel_scale) / 2.0;
let half_height = (map_height as f32 * pixel_scale) / 2.0;
WorldPos { x: (tile.x as f32 + 0.5) * pixel_scale - half_width, y: (tile.y as f32 + 0.5) * pixel_scale - half_height }
}
/// Convert tile index to world position
pub fn tile_index_to_world(index: usize, map_width: u32, map_height: u32, pixel_scale: f32) -> WorldPos {
let tile = TileCoord::from_index(index, map_width);
tile_to_world(tile, map_width, map_height, pixel_scale)
}
/// Convert screen position directly to tile (combines screen_to_world and world_to_tile)
pub fn screen_to_tile(screen: ScreenPos, camera: &CameraState, map_width: u32, map_height: u32, pixel_scale: f32) -> Option<TileCoord> {
let world = screen_to_world(screen, camera);
world_to_tile(world, map_width, map_height, pixel_scale)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tile_coord_conversion() {
let tile = TileCoord { x: 5, y: 3 };
let index = tile.to_index(10);
assert_eq!(index, 35); // 3 * 10 + 5
let tile2 = TileCoord::from_index(35, 10);
assert_eq!(tile2.x, 5);
assert_eq!(tile2.y, 3);
}
#[test]
fn test_input_state() {
let mut state = InputState::new();
// Test key press
state.handle_event(InputEvent::KeyPress { key: KeyCode::KeyC, state: ButtonState::Pressed });
assert!(state.key_just_pressed(KeyCode::KeyC));
assert!(state.key_pressed(KeyCode::KeyC));
// Clear frame data
state.clear_frame_data();
assert!(!state.key_just_pressed(KeyCode::KeyC));
assert!(state.key_pressed(KeyCode::KeyC)); // Still pressed
// Release key
state.handle_event(InputEvent::KeyPress { key: KeyCode::KeyC, state: ButtonState::Released });
assert!(state.key_just_released(KeyCode::KeyC));
assert!(!state.key_pressed(KeyCode::KeyC));
}
#[test]
fn test_coordinate_conversion() {
let camera = CameraState { x: 100.0, y: 100.0, zoom: 2.0, viewport_width: 800.0, viewport_height: 600.0 };
let screen = ScreenPos { x: 400.0, y: 300.0 };
let world = coords::screen_to_world(screen, &camera);
assert_eq!(world.x, 100.0); // Center of screen = camera position
assert_eq!(world.y, 100.0);
// Test round trip
let screen2 = coords::world_to_screen(world, &camera);
assert!((screen2.x - screen.x).abs() < 0.001);
assert!((screen2.y - screen.y).abs() < 0.001);
}
}

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 _span = tracing::debug_span!("emit_leaderboard_snapshot").entered();
// Check if enough time has passed since last emission
let now = Instant::now();
let should_emit = throttle.last_emission.map(|last| now.duration_since(last) >= throttle.throttle_duration).unwrap_or(true); // Emit on first call
if !should_emit {
return;
}
if let Some(snapshot) = build_leaderboard_snapshot(&game_view, &game, &mut last_digest) {
backend_messages.write(BackendMessage::LeaderboardSnapshot(snapshot));
throttle.last_emission = Some(now);
}
}
/// Build an attacks update payload from current game state
/// Always returns the current state (digest is used to prevent duplicate emissions)
pub fn build_attacks_update(game: &GameInstance, client_player_id: usize, last_digest: &mut LastAttacksDigest) -> Option<AttacksUpdatePayload> {
// Get attacks for the client player
let raw_attacks = game.attack_handler.get_attacks_for_player(client_player_id);
// Build current digest for comparison
let current_entries: Vec<(usize, Option<usize>, u32, u64, bool)> = raw_attacks.iter().map(|&(attacker_id, target_id, troops, start_turn, is_outgoing)| (attacker_id, target_id, troops as u32, start_turn, is_outgoing)).collect();
// Check if anything has changed
if current_entries == last_digest.entries {
return None; // No changes
}
// Update digest
last_digest.entries = current_entries;
last_digest.turn = game.turn_number;
// Build attack entries
let entries: Vec<AttackEntry> = raw_attacks.into_iter().map(|(attacker_id, target_id, troops, start_turn, is_outgoing)| AttackEntry { attacker_id: attacker_id as u16, target_id: target_id.map(|id| id as u16), troops: troops as u32, start_turn, is_outgoing }).collect();
Some(AttacksUpdatePayload { turn: game.turn_number, entries })
}
/// Bevy system that emits attacks update events
pub fn emit_attacks_update_system(game: Res<GameInstance>, mut last_digest: ResMut<LastAttacksDigest>, mut backend_messages: MessageWriter<BackendMessage>) {
let _span = tracing::debug_span!("emit_attacks_update").entered();
let client_player_id = game.player_manager.client_player_id;
if let Some(payload) = build_attacks_update(&game, client_player_id, &mut last_digest) {
backend_messages.write(BackendMessage::AttacksUpdate(payload));
}
}

View File

@@ -0,0 +1,52 @@
//! UI/Frontend module for rendering and user interaction
//!
//! This module contains all frontend-related concerns including:
//! - Protocol definitions for frontend-backend communication
//! - Input handling
//! - Leaderboard management
//! - Platform transport abstraction
pub mod input;
pub mod leaderboard;
pub mod plugin;
pub mod protocol;
pub mod transport;
// Re-export commonly used types
pub use input::{InputEvent, InputState, KeyCode, MouseButton, TileCoord, WorldPos};
pub use leaderboard::{LastAttacksDigest, LastLeaderboardDigest, LeaderboardThrottle, build_attacks_update, build_leaderboard_snapshot, emit_attacks_update_system, emit_leaderboard_snapshot_system};
pub use plugin::FrontendPlugin;
pub use protocol::{AttackEntry, AttacksUpdatePayload, BackendMessage, CameraCommand, CameraStateUpdate, FrontendMessage, GameOutcome, LeaderboardEntry, LeaderboardSnapshot, MapQuery, MapQueryResponse, PaletteInit, RenderInit, RenderInputEvent, RgbColor, 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::*;
/// Resource to track currently highlighted nation for visual feedback
#[derive(Resource, Default, Debug)]
pub struct NationHighlightState {
pub highlighted_nation: Option<u16>,
}
/// System that tracks hovered nation and emits highlight events
pub fn emit_nation_highlight_system(input_state: NonSend<std::sync::Arc<std::sync::Mutex<InputState>>>, game_view: Res<GameView>, mut highlight_state: ResMut<NationHighlightState>, mut backend_messages: MessageWriter<BackendMessage>) {
let Ok(input) = input_state.lock() else {
return;
};
let new_highlighted = if let Some(tile_coord) = input.cursor_tile() {
let tile_index = tile_coord.to_index(game_view.width);
let owner_id = game_view.get_owner(tile_index);
// Water (65535) and unclaimed (65534) should clear highlight
if owner_id >= 65534 { None } else { Some(owner_id) }
} else {
None
};
// Only emit if highlight changed
if new_highlighted != highlight_state.highlighted_nation {
highlight_state.highlighted_nation = new_highlighted;
backend_messages.write(BackendMessage::HighlightNation { nation_id: new_highlighted });
}
}

View File

@@ -0,0 +1,50 @@
//! Frontend plugin for UI/rendering integration
//!
//! This module provides the FrontendPlugin which handles all frontend communication
//! including rendering, input, and UI updates.
use bevy_ecs::prelude::*;
use bevy_ecs::schedule::common_conditions::resource_exists;
use crate::app::{App, Plugin, Update};
use crate::networking::GameView;
use crate::ui::protocol::{BackendMessage, FrontendMessage};
use crate::ui::transport::{FrontendTransport, RenderBridge, emit_backend_messages_system, ingest_frontend_messages_system, send_initial_render_data, stream_territory_deltas};
/// Plugin to add frontend communication and UI systems to Bevy
pub struct FrontendPlugin<T: FrontendTransport> {
transport: T,
}
impl<T: FrontendTransport> FrontendPlugin<T> {
pub fn new(transport: T) -> Self {
Self { transport }
}
}
impl<T: FrontendTransport> Plugin for FrontendPlugin<T> {
fn build(&self, app: &mut App) {
// Register message event types
app.add_message::<BackendMessage>();
app.add_message::<FrontendMessage>();
// Insert the bridge resource
app.insert_resource(RenderBridge::new(self.transport.clone()));
// Add render systems
app.add_systems(Update, (send_initial_render_data::<T>.run_if(resource_exists::<GameView>), stream_territory_deltas::<T>.run_if(resource_exists::<GameView>)).chain());
// Add communication systems
app.add_systems(Update, (emit_backend_messages_system::<T>, ingest_frontend_messages_system::<T>, reset_bridge_on_quit_system::<T>));
}
}
/// System to reset the render bridge when a game is quit
/// This ensures fresh initialization data is sent when starting a new game
fn reset_bridge_on_quit_system<T: FrontendTransport>(game_view: Option<Res<GameView>>, mut bridge: ResMut<RenderBridge<T>>) {
// If GameView doesn't exist but bridge is initialized, reset it
if game_view.is_none() && bridge.initialized {
bridge.reset();
tracing::debug!("RenderBridge reset - ready for next game initialization");
}
}

View File

@@ -0,0 +1,475 @@
//! 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),
/// Game has ended with the specified outcome
GameEnded { outcome: GameOutcome },
/// Spawn phase update
/// - countdown: None = phase active, waiting for first spawn
/// - countdown: Some = countdown in progress with epoch timestamp
SpawnPhaseUpdate { countdown: Option<SpawnCountdown> },
/// Spawn phase has ended, game is now active
SpawnPhaseEnded,
/// Highlight a specific nation (None to clear)
HighlightNation { nation_id: Option<u16> },
}
/// All messages sent from frontend to backend
#[derive(Debug, Clone, Serialize, Deserialize, Message)]
#[serde(tag = "msg_type")]
pub enum FrontendMessage {
/// Input event (mouse clicks, keyboard, hover)
InputEvent(RenderInputEvent),
/// Camera state update from frontend
CameraStateUpdate(CameraStateUpdate),
/// Query about the map state
MapQuery(MapQuery),
/// Start a new game
StartGame,
/// Quit the current game and return to menu
QuitGame,
/// Pause the game (local/singleplayer only)
PauseGame,
/// Resume the game (local/singleplayer only)
ResumeGame,
/// Set attack ratio (percentage of troops to use when attacking)
SetAttackRatio { ratio: f32 },
}
/// Terrain types for map tiles
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[repr(u8)]
pub enum TerrainType {
Water = 0,
Land = 1,
Mountain = 2,
}
/// Initial terrain data for the entire map
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TerrainInit {
/// Map width in tiles
pub width: u32,
/// Map height in tiles
pub height: u32,
/// Tile type IDs (one u8 per tile, referencing TerrainPalette)
/// Each value is an index into the terrain_palette colors array
#[serde(with = "serde_bytes")]
pub terrain_data: Vec<u8>,
}
impl TerrainInit {
/// Create terrain data from tile type IDs
pub fn from_tile_ids(width: u32, height: u32, tile_ids: Vec<u8>) -> Self {
assert_eq!(tile_ids.len(), (width * height) as usize, "Terrain data size mismatch");
Self { width, height, terrain_data: tile_ids }
}
/// Create terrain data from a legacy terrain enum array (for backward compatibility)
pub fn from_terrain(width: u32, height: u32, terrain: &[TerrainType]) -> Self {
let terrain_data: Vec<u8> = terrain.iter().map(|&t| t as u8).collect();
Self { width, height, terrain_data }
}
/// Extract tile type ID for a specific tile
pub fn get_tile_id(&self, index: usize) -> u8 {
self.terrain_data.get(index).copied().unwrap_or(0)
}
}
/// Terrain palette defining colors for each terrain shade/type
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TerrainPalette {
/// Color definitions for each terrain shade
/// Index in this array corresponds to the shade value in terrain_data
pub colors: Vec<RgbColor>,
}
/// A single tile change in the territory map
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct TileChange {
/// Tile index (row * width + col)
pub index: u32,
/// New owner player ID (0 = unclaimed)
pub owner_id: u16,
}
/// Delta update containing changed tiles for efficient streaming
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TerritoryDelta {
/// Turn number this delta applies to
pub turn: u64,
/// List of changed tiles since last update
pub changes: Vec<TileChange>,
}
/// Full territory snapshot for initial state (sparse binary format)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TerritorySnapshot {
/// Turn number for this snapshot
pub turn: u64,
/// Binary-encoded sparse territory data
/// Format: [count:4][changes...]
/// where changes = [index:4][owner:2] repeated count times
/// All tiles not in this list default to owner_id=0 (unclaimed)
pub data: Vec<u8>,
}
impl TerritorySnapshot {
/// Create sparse binary snapshot from full territory array
/// Only includes claimed tiles (owner_id != 0)
pub fn encode(turn: u64, territories: &[u16]) -> Self {
// Collect claimed tiles (non-zero owner IDs)
let claimed_tiles: Vec<(u32, u16)> = territories.iter().enumerate().filter(|&(_, &owner)| owner != 0).map(|(index, &owner)| (index as u32, owner)).collect();
let count = claimed_tiles.len() as u32;
let mut data = Vec::with_capacity(4 + claimed_tiles.len() * 6);
// Encode count (4 bytes)
data.extend_from_slice(&count.to_le_bytes());
// Encode each claimed tile (6 bytes each)
for (index, owner) in claimed_tiles {
data.extend_from_slice(&index.to_le_bytes());
data.extend_from_slice(&owner.to_le_bytes());
}
Self { turn, data }
}
/// Decode binary snapshot back to list of claimed tiles
/// Returns list of (tile_index, owner_id) pairs
pub fn decode(&self) -> Option<Vec<(u32, u16)>> {
if self.data.len() < 4 {
return None; // Not enough data for count
}
let count = u32::from_le_bytes([self.data[0], self.data[1], self.data[2], self.data[3]]) as usize;
let expected_size = 4 + count * 6;
if self.data.len() != expected_size {
return None; // Invalid size
}
let mut tiles = Vec::with_capacity(count);
for i in 0..count {
let offset = 4 + i * 6;
let index = u32::from_le_bytes([self.data[offset], self.data[offset + 1], self.data[offset + 2], self.data[offset + 3]]);
let owner = u16::from_le_bytes([self.data[offset + 4], self.data[offset + 5]]);
tiles.push((index, owner));
}
Some(tiles)
}
}
/// Binary format for efficient territory delta streaming (Tauri)
/// This is for the pixel streaming channel, separate from JSON messages
#[derive(Debug)]
pub struct BinaryTerritoryDelta {
/// Raw bytes: [turn:8][count:4][changes...]
/// Each change: [index:4][owner:2] = 6 bytes
pub data: Vec<u8>,
}
impl BinaryTerritoryDelta {
/// Create binary delta from territory changes
pub fn encode(turn: u64, changes: &[TileChange]) -> Vec<u8> {
let count = changes.len() as u32;
let mut data = Vec::with_capacity(12 + changes.len() * 6);
// Header: turn (8 bytes) + count (4 bytes)
data.extend_from_slice(&turn.to_le_bytes());
data.extend_from_slice(&count.to_le_bytes());
// Changes: each is index (4 bytes) + owner (2 bytes)
for change in changes {
data.extend_from_slice(&change.index.to_le_bytes());
data.extend_from_slice(&change.owner_id.to_le_bytes());
}
data
}
/// Decode binary delta back to structured format
pub fn decode(data: &[u8]) -> Option<(u64, Vec<TileChange>)> {
if data.len() < 12 {
return None; // Not enough data for header
}
let turn = u64::from_le_bytes([data[0], data[1], data[2], data[3], data[4], data[5], data[6], data[7]]);
let count = u32::from_le_bytes([data[8], data[9], data[10], data[11]]) as usize;
let expected_size = 12 + count * 6;
if data.len() != expected_size {
return None; // Invalid size
}
let mut changes = Vec::with_capacity(count);
for i in 0..count {
let offset = 12 + i * 6;
let index = u32::from_le_bytes([data[offset], data[offset + 1], data[offset + 2], data[offset + 3]]);
let owner_id = u16::from_le_bytes([data[offset + 4], data[offset + 5]]);
changes.push(TileChange { index, owner_id });
}
Some((turn, changes))
}
}
/// RGB color for player palette
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct RgbColor {
pub r: u8,
pub g: u8,
pub b: u8,
}
/// Initial palette data mapping player IDs to colors
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PaletteInit {
/// Player ID to color mapping
/// Index in the vec corresponds to player_id
pub colors: Vec<RgbColor>,
}
/// Commands sent from backend to control the camera
#[derive(Debug, Clone, Serialize, Deserialize, bevy_ecs::message::Message)]
#[serde(tag = "type")]
pub enum CameraCommand {
/// Center camera on a specific tile
CenterOnTile {
tile_index: u32,
#[serde(default)]
animate: bool,
},
/// Highlight a rectangular region
HighlightRegion {
x: u32,
y: u32,
width: u32,
height: u32,
#[serde(default = "default_highlight_duration")]
duration_ms: u32,
},
/// Set camera zoom level
SetZoom {
zoom: f32,
#[serde(default)]
animate: bool,
},
/// Pan camera by offset
PanBy {
dx: f32,
dy: f32,
#[serde(default)]
animate: bool,
},
}
fn default_highlight_duration() -> u32 {
3000 // 3 seconds
}
/// Camera state update sent from frontend to backend
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CameraStateUpdate {
/// Current camera X position (world coordinates)
pub x: f32,
/// Current camera Y position (world coordinates)
pub y: f32,
/// Current zoom level (1.0 = normal)
pub zoom: f32,
}
/// Queries sent from frontend to backend about the map
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum MapQuery {
/// Get owner of tile at world coordinates
GetOwnerAt { x: f32, y: f32 },
/// Get detailed tile info by index
GetTileInfo { tile_index: u32 },
/// Find any tile owned by player (for camera centering)
FindPlayerTerritory { player_id: u16 },
/// Convert screen coordinates to tile index
ScreenToTile { screen_x: f32, screen_y: f32 },
}
/// Response to map queries
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum MapQueryResponse {
/// Response to GetOwnerAt
Owner { owner_id: u16, tile_index: Option<u32> },
/// Response to GetTileInfo
TileInfo { tile_index: u32, owner_id: u16, terrain: TerrainType, troops: u32 },
/// Response to FindPlayerTerritory
PlayerTerritory { tile_index: Option<u32> },
/// Response to ScreenToTile
TileIndex { index: Option<u32> },
}
/// Input event sent from frontend to backend
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum RenderInputEvent {
/// Mouse click on the map
MapClick {
/// Tile index clicked (if over a valid tile)
tile_index: Option<u32>,
/// World coordinates of click
world_x: f32,
world_y: f32,
/// Mouse button (0=left, 1=middle, 2=right)
button: u8,
},
/// Key press event
KeyPress {
/// Key code as string (e.g., "KeyC", "Digit1")
key: String,
/// Whether key is pressed (true) or released (false)
pressed: bool,
},
/// Mouse moved over map
MapHover {
/// Tile index under cursor (if any)
tile_index: Option<u32>,
/// World coordinates
world_x: f32,
world_y: f32,
},
}
/// Atomic initialization message containing all data needed to start rendering
/// This ensures terrain, palette, and initial territory state arrive together
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RenderInit {
pub terrain: TerrainInit,
pub terrain_palette: TerrainPalette,
pub palette: PaletteInit,
pub initial_territories: TerritorySnapshot,
}
/// Unified leaderboard entry containing both static and dynamic data
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LeaderboardEntry {
pub id: u16,
pub name: String,
pub color: String, // Hex color without alpha, e.g. "0A44FF"
pub tile_count: u32,
pub troops: u32,
pub territory_percent: f32,
}
/// Complete leaderboard snapshot (replaces separate Init/Update)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LeaderboardSnapshot {
pub turn: u64,
pub total_land_tiles: u32,
pub entries: Vec<LeaderboardEntry>,
pub client_player_id: u16,
}
/// Outcome of the game
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum GameOutcome {
/// Player won the game
Victory,
/// Player lost the game
Defeat,
}
/// Single attack entry for attacks UI
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AttackEntry {
pub attacker_id: u16,
pub target_id: Option<u16>, // None for unclaimed territory
pub troops: u32,
pub start_turn: u64,
pub is_outgoing: bool,
}
/// Attacks update payload
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AttacksUpdatePayload {
pub turn: u64,
pub entries: Vec<AttackEntry>,
}
/// Countdown state for spawn phase
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpawnCountdown {
pub started_at_ms: u64,
pub duration_secs: f32,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_terrain_tile_ids() {
let terrain = vec![TerrainType::Water, TerrainType::Land, TerrainType::Mountain, TerrainType::Land, TerrainType::Water];
let init = TerrainInit::from_terrain(5, 1, &terrain);
assert_eq!(init.terrain_data.len(), 5);
assert_eq!(init.get_tile_id(0), TerrainType::Water as u8);
assert_eq!(init.get_tile_id(1), TerrainType::Land as u8);
assert_eq!(init.get_tile_id(2), TerrainType::Mountain as u8);
assert_eq!(init.get_tile_id(3), TerrainType::Land as u8);
assert_eq!(init.get_tile_id(4), TerrainType::Water as u8);
}
#[test]
fn test_binary_delta_encoding() {
let changes = vec![TileChange { index: 100, owner_id: 1 }, TileChange { index: 200, owner_id: 2 }, TileChange { index: 300, owner_id: 0 }];
let encoded = BinaryTerritoryDelta::encode(42, &changes);
assert_eq!(encoded.len(), 12 + 3 * 6);
let decoded = BinaryTerritoryDelta::decode(&encoded).unwrap();
assert_eq!(decoded.0, 42);
assert_eq!(decoded.1.len(), 3);
assert_eq!(decoded.1[0].index, 100);
assert_eq!(decoded.1[0].owner_id, 1);
}
}

View File

@@ -0,0 +1,228 @@
//! Shared render bridge infrastructure for platform-agnostic rendering
//!
//! This module provides the common logic for rendering bridges across platforms
//! (WASM, Tauri, etc.), with platform-specific transport mechanisms abstracted
//! behind the RenderBridgeTransport trait.
use crate::TerrainData;
use crate::networking::GameView;
use crate::ui::protocol::{BackendMessage, BinaryTerritoryDelta, CameraStateUpdate, FrontendMessage, PaletteInit, RenderInit, RenderInputEvent, RgbColor, TerrainInit, TerrainPalette, TerritoryDelta, TerritorySnapshot, TileChange};
use bevy_ecs::prelude::*;
use tracing::{error, info, trace, warn};
/// Trait for platform-specific frontend communication
///
/// This abstracts the actual mechanism for bidirectional frontend communication,
/// allowing WASM (JS callbacks), Tauri (events), and other platforms to implement
/// their own transport while sharing the core logic.
pub trait FrontendTransport: Send + Sync + Clone + 'static {
/// Send a message from backend to frontend
fn send_backend_message(&self, message: &BackendMessage) -> Result<(), String>;
/// Send binary territory delta data (optional, mainly for Tauri)
///
/// Default implementation does nothing - platforms that support
/// binary streaming can override this.
fn send_binary_delta(&self, _data: Vec<u8>) -> Result<(), String> {
Ok(()) // No-op by default
}
/// Try to receive a message from the frontend
///
/// Returns `Some(message)` if a message is available, `None` if not.
/// This should be non-blocking and called frequently (e.g., every frame).
fn try_recv_frontend_message(&self) -> Option<FrontendMessage>;
}
/// Resource for managing frontend communication state
#[derive(Resource)]
pub struct RenderBridge<T: FrontendTransport> {
pub transport: T,
/// Track if we've sent initial data
pub(crate) initialized: bool,
}
impl<T: FrontendTransport> RenderBridge<T> {
pub fn new(transport: T) -> Self {
Self { transport, initialized: false }
}
/// Reset the bridge to allow re-initialization
/// This should be called when a game is quit to ensure fresh data is sent on next game start
pub fn reset(&mut self) {
self.initialized = false;
}
}
/// System to send initial render data (terrain, palette, initial territories)
pub fn send_initial_render_data<T: FrontendTransport>(game_view: Option<Res<GameView>>, terrain_data: Option<Res<TerrainData>>, mut bridge: Option<ResMut<RenderBridge<T>>>) {
let Some(game_view) = game_view else {
trace!("send_initial_render_data: GameView not available yet");
return;
};
let Some(terrain_data) = terrain_data else {
trace!("send_initial_render_data: TerrainData not available yet");
return;
};
let Some(ref mut bridge) = bridge else {
error!("send_initial_render_data: RenderBridge not available");
return;
};
// Early return if already initialized - prevents duplicate sends
if bridge.initialized {
return;
}
// Don't send initial data for empty game view
if game_view.width == 0 || game_view.height == 0 || game_view.players.is_empty() {
trace!("send_initial_render_data: GameView not yet populated, waiting...");
return;
}
info!("Building atomic RenderInit message (map: {}x{}, {} players)", game_view.width, game_view.height, game_view.players.len());
// Build terrain component from TerrainData
let width = game_view.width;
let height = game_view.height;
let tile_ids = terrain_data.get_tile_ids().to_vec();
let terrain = TerrainInit::from_tile_ids(width, height, tile_ids);
// Build terrain palette from TerrainData
let palette_colors = terrain_data.get_terrain_palette_colors();
let terrain_palette = TerrainPalette { colors: palette_colors.into_iter().map(|[r, g, b]| RgbColor { r, g, b }).collect() };
info!("Terrain palette: {} colors", terrain_palette.colors.len());
// Build palette component
let mut colors = vec![RgbColor { r: 0, g: 0, b: 0 }; 256]; // Pre-allocate for all possible players
for player in &game_view.players {
// Player color is already in [r, g, b, a] format as f32
colors[player.id as usize] = RgbColor { r: (player.color[0] * 255.0) as u8, g: (player.color[1] * 255.0) as u8, b: (player.color[2] * 255.0) as u8 };
}
let palette = PaletteInit { colors };
// Build initial territory snapshot (sparse binary format)
let initial_territories = TerritorySnapshot::encode(game_view.turn_number, &game_view.territories);
// Create atomic initialization message
let render_init = RenderInit { terrain, terrain_palette, palette, initial_territories };
// Send single atomic message - all or nothing
if let Err(e) = bridge.transport.send_backend_message(&BackendMessage::RenderInit(render_init)) {
error!("Failed to send atomic RenderInit message: {}", e);
return;
}
// Only mark as initialized if send succeeded
bridge.initialized = true;
info!("Atomic RenderInit sent successfully");
}
/// System to detect and stream territory changes
pub fn stream_territory_deltas<T: FrontendTransport>(game_view: Option<Res<GameView>>, bridge: Option<Res<RenderBridge<T>>>) {
let Some(game_view) = game_view else {
return; // GameView not ready yet
};
let Some(bridge) = bridge else {
return; // Bridge not initialized
};
// Gate: Don't send deltas until initial render data has been sent
if !bridge.initialized {
return;
}
// Skip if GameView hasn't changed
if !game_view.is_changed() {
return;
}
let _span = tracing::debug_span!("stream_territory_deltas").entered();
// Use efficient changed_tiles from TerritoryManager's ChangeBuffer instead of scanning
if !game_view.changed_tiles.is_empty() {
let turn = game_view.turn_number;
// Build delta from the pre-tracked changes
let changes: Vec<TileChange> = game_view.changed_tiles.iter().map(|&index| TileChange { index: index as u32, owner_id: game_view.territories[index] }).collect();
// Send binary format for platforms that support it (e.g., Tauri)
let binary_data = BinaryTerritoryDelta::encode(turn, &changes);
if let Err(e) = bridge.transport.send_binary_delta(binary_data) {
error!("Failed to send binary territory delta: {}", e);
}
// Send structured format
let delta = TerritoryDelta { turn, changes };
if let Err(e) = bridge.transport.send_backend_message(&BackendMessage::TerritoryDelta(delta)) {
error!("Failed to send territory delta: {}", e);
}
}
}
/// Handle render input events from the frontend
///
/// This function processes input events and updates the shared InputState.
/// It should be called from platform-specific command handlers.
pub fn handle_render_input(event: &RenderInputEvent, input_state: &mut crate::ui::input::InputState, map_width: u32) -> Result<(), String> {
match event {
RenderInputEvent::MapClick { tile_index, world_x, world_y, button } => {
if let Some(button) = crate::ui::input::MouseButton::from_u8(*button) {
let world_pos = crate::ui::input::WorldPos { x: *world_x, y: *world_y };
let tile_coord = tile_index.map(|idx| crate::ui::input::TileCoord::from_index(idx as usize, map_width));
input_state.handle_event(crate::ui::input::InputEvent::MouseButton { button, state: crate::ui::input::ButtonState::Released, world_pos: Some(world_pos), tile: tile_coord });
}
}
RenderInputEvent::KeyPress { key, pressed } => {
if let Some(key_code) = crate::ui::input::KeyCode::from_string(key) {
let button_state = if *pressed { crate::ui::input::ButtonState::Pressed } else { crate::ui::input::ButtonState::Released };
input_state.handle_event(crate::ui::input::InputEvent::KeyPress { key: key_code, state: button_state });
}
}
RenderInputEvent::MapHover { tile_index, world_x, world_y } => {
let world_pos = crate::ui::input::WorldPos { x: *world_x, y: *world_y };
let tile_coord = tile_index.map(|idx| crate::ui::input::TileCoord::from_index(idx as usize, map_width));
input_state.handle_event(crate::ui::input::InputEvent::MouseMove {
world_pos,
screen_pos: crate::ui::input::ScreenPos { x: 0.0, y: 0.0 }, // Not used
tile: tile_coord,
});
}
}
Ok(())
}
/// Handle camera state updates from the frontend
///
/// This is a simple wrapper for consistency. Camera state is typically stored
/// in a shared Arc<Mutex<Option<CameraStateUpdate>>> resource.
pub fn handle_camera_update(update: CameraStateUpdate, camera_state: &mut Option<CameraStateUpdate>) -> Result<(), String> {
*camera_state = Some(update);
Ok(())
}
/// System that reads BackendMessage events and sends them through the transport
pub(crate) fn emit_backend_messages_system<T: FrontendTransport>(mut events: MessageReader<BackendMessage>, bridge: Res<RenderBridge<T>>) {
for event in events.read() {
if let Err(e) = bridge.transport.send_backend_message(event) {
warn!("Failed to send backend message through transport: {}", e);
}
}
}
/// System that polls the transport for incoming frontend messages and emits them as events
pub(crate) fn ingest_frontend_messages_system<T: FrontendTransport>(mut messages: MessageWriter<FrontendMessage>, bridge: Res<RenderBridge<T>>) {
while let Some(message) = bridge.transport.try_recv_frontend_message() {
messages.write(message);
}
}

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 u32, height as u32, terrain_data_raw);
TerrainData { _manifest: MapManifest { map: MapMetadata { width, height, num_land_tiles: 2 }, name: "Test".to_string(), nations: Vec::new() }, terrain_data, tiles, tile_types }
}
#[test]
fn test_is_land() {
let terrain = create_test_terrain(10, 10);
assert!(!terrain.is_land((0, 0)));
assert!(terrain.is_land((5, 0)));
}
#[test]
fn test_is_conquerable() {
let terrain = create_test_terrain(10, 10);
assert!(!terrain.is_conquerable((0, 0)));
assert!(terrain.is_conquerable((5, 0)));
}
#[test]
fn test_is_navigable() {
let terrain = create_test_terrain(10, 10);
assert!(terrain.is_navigable((0, 0)));
assert!(!terrain.is_navigable((5, 0)));
}
#[test]
fn test_terrain_magnitude() {
let terrain = create_test_terrain(10, 10);
assert_eq!(terrain.terrain_magnitude((0, 1)), 5);
}
#[test]
fn test_expansion_properties() {
let terrain = create_test_terrain(10, 10);
assert_eq!(terrain.get_expansion_time((5, 0)), 50);
assert_eq!(terrain.get_expansion_cost((5, 0)), 50);
}

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

View File

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

View File

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

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"
},
"app": {
"windows": [
{
"title": "Iron Borders",
"width": 1280,
"height": 720
}
],
"security": {
"csp": null
}
},
"plugins": {
"process": {
"all": true
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}

View File

@@ -0,0 +1,14 @@
[package]
name = "borders-server"
version.workspace = true
edition.workspace = true
authors.workspace = true
[dependencies]
anyhow = "1.0"
borders-core = { path = "../borders-core" }
flume = "0.11"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] }
tracing = "0.1"
tracing-log = "0.2"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

View File

@@ -0,0 +1,145 @@
use borders_core::networking::{
Intent,
protocol::NetMessage,
server::{ServerRegistry, start_server},
};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use tokio::time::{Duration, Instant, interval};
use tracing::{error, info};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Initialize tracing
// Debug builds: debug level for our crates, info for dependencies
// Release builds: warn level for our crates, error for dependencies
#[cfg(debug_assertions)]
let default_filter = "borders_core=debug,borders_server=debug,borders_protocol=debug,info";
#[cfg(not(debug_assertions))]
let default_filter = "borders_core=warn,borders_server=warn,borders_protocol=warn,error";
tracing_subscriber::fmt().with_env_filter(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(default_filter))).init();
// Initialize log-to-tracing bridge for dependencies using log crate
tracing_log::LogTracer::init().expect("Failed to set logger");
// Parse command line arguments
let args: Vec<String> = std::env::args().collect();
let bind_address = if args.len() > 1 { args[1].clone() } else { "127.0.0.1:4433".to_string() };
info!(bind_address = %bind_address, "Starting borders relay server");
// Create channels for communication
let (intent_tx, intent_rx) = flume::unbounded::<Intent>();
// Create server registry
let registry = Arc::new(RwLock::new(ServerRegistry::new()));
// Spawn network listener task
let intent_tx_clone = intent_tx.clone();
let registry_clone = registry.clone();
let bind_addr = bind_address.clone();
info!("Spawning server listener task...");
tokio::spawn(async move {
info!("Server listener task started");
if let Err(e) = start_server(&bind_addr, intent_tx_clone, registry_clone).await {
error!(error = %e, "Server listener failed");
}
});
// Main server loop: collect intents and broadcast turns
let mut turn_number = 0u64;
let mut tick_interval = interval(Duration::from_millis(100)); // 100ms tick rate
// Spawn phase tracking
let mut spawn_config: HashMap<u16, u32> = HashMap::new();
let mut spawn_timeout_started: Option<Instant> = None;
let mut game_started = false;
const SPAWN_TIMEOUT_MS: u64 = 5000;
info!("Server running - spawn phase active");
loop {
tick_interval.tick().await;
// During spawn phase, handle SetSpawn intents
if !game_started {
while let Ok(intent) = intent_rx.try_recv() {
match intent {
Intent::SetSpawn { player_id, tile_index } => {
info!("Player {} set spawn at tile {}", player_id, tile_index);
spawn_config.insert(player_id, tile_index);
// Start timeout on first spawn
if spawn_timeout_started.is_none() {
spawn_timeout_started = Some(Instant::now());
info!("Spawn timeout started ({}ms)", SPAWN_TIMEOUT_MS);
}
// Broadcast spawn configuration to all clients
let spawn_message = NetMessage::SpawnConfiguration { spawns: spawn_config.clone() };
registry.write().await.broadcast(spawn_message);
}
Intent::Action(_) => {
info!("Received Action intent during spawn phase - ignoring");
}
}
}
// Check if spawn timeout has expired
if let Some(timeout_start) = spawn_timeout_started {
let elapsed = timeout_start.elapsed();
if elapsed >= Duration::from_millis(SPAWN_TIMEOUT_MS) {
info!("Spawn timeout expired - starting game!");
// Create Turn(0) with all spawn actions
use borders_core::game::action::GameAction;
let spawn_intents: Vec<Intent> = spawn_config.iter().map(|(&player_id, &tile_index)| Intent::Action(GameAction::Spawn { player_id, tile_index })).collect();
let start_turn = NetMessage::Turn { turn: 0, intents: spawn_intents.clone() };
info!("Broadcasting Turn(0) with {} spawns", spawn_intents.len());
registry.write().await.broadcast(start_turn);
// Mark game as started and clear spawn phase
game_started = true;
spawn_config.clear();
spawn_timeout_started = None;
turn_number = 1;
info!("Spawn phase complete - game started");
}
}
continue;
}
// Normal turn generation (after game has started)
let mut intents = Vec::new();
while let Ok(intent) = intent_rx.try_recv() {
match intent {
Intent::Action(action) => {
intents.push(Intent::Action(action));
}
Intent::SetSpawn { .. } => {
info!("Received SetSpawn intent after game started - ignoring");
}
}
}
// Create and broadcast turn
let turn_message = NetMessage::Turn { turn: turn_number, intents: intents.clone() };
let client_count = { registry.read().await.client_count() };
if !intents.is_empty() || turn_number.is_multiple_of(100) {
info!(turn_number = turn_number, intent_count = intents.len(), client_count = client_count, "Broadcasting turn");
}
registry.write().await.broadcast(turn_message);
turn_number += 1;
}
}

View File

@@ -0,0 +1,33 @@
[package]
name = "borders-wasm"
version.workspace = true
edition.workspace = true
authors.workspace = true
[lib]
crate-type = ["cdylib", "rlib"]
path = "src/lib.rs"
[features]
default = []
[dependencies]
bevy_ecs = { version = "0.17", default-features = false, features = ["std"] }
borders-core = { path = "../borders-core", features = ["ui"] }
console_error_panic_hook = "0.1"
getrandom = { version = "0.3", features = ["wasm_js"] }
gloo-timers = { version = "0.3", features = ["futures"] }
js-sys = "0.3"
lazy_static = "1.5"
serde = { version = "1.0", features = ["derive"] }
serde-wasm-bindgen = "0.6"
serde_json = "1.0"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["registry", "env-filter"] }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
wasm-tracing = { version = "2.1", features = ["tracing-log"] }
web-time = "1.1"
[package.metadata.cargo-machete]
ignored = ["getrandom", "bevy_ecs"]

View File

@@ -0,0 +1,67 @@
//! WASM-JS bridge for game communication
//!
//! This module provides JavaScript bindings to expose game data and events
//! from the Bevy/WASM game to the React frontend.
use std::sync::{Arc, Mutex};
use wasm_bindgen::prelude::{JsValue, wasm_bindgen};
use borders_core::ui::protocol::{CameraStateUpdate, RenderInputEvent};
// Global state for input handling (needs to be accessible from Bevy systems)
lazy_static::lazy_static! {
static ref INPUT_STATE: Arc<Mutex<borders_core::ui::input::InputState>> =
Arc::new(Mutex::new(borders_core::ui::input::InputState::new()));
static ref CAMERA_STATE: Arc<Mutex<Option<CameraStateUpdate>>> =
Arc::new(Mutex::new(None));
}
/// Handle render input events from the frontend (clicks, keys, hover)
#[wasm_bindgen]
pub fn handle_render_input(event: JsValue) -> Result<(), JsValue> {
let event: RenderInputEvent = serde_wasm_bindgen::from_value(event).map_err(|e| JsValue::from_str(&format!("Failed to deserialize render input: {}", e)))?;
let mut state = INPUT_STATE.lock().map_err(|e| JsValue::from_str(&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).map_err(|e| JsValue::from_str(&e))
}
/// Handle camera state updates from the frontend
#[wasm_bindgen]
pub fn handle_camera_update(state: JsValue) -> Result<(), JsValue> {
let update: CameraStateUpdate = serde_wasm_bindgen::from_value(state).map_err(|e| JsValue::from_str(&format!("Failed to deserialize camera state: {}", e)))?;
let mut camera_state = CAMERA_STATE.lock().map_err(|e| JsValue::from_str(&format!("Failed to lock camera state: {}", e)))?;
borders_core::ui::handle_camera_update(update, &mut camera_state).map_err(|e| JsValue::from_str(&e))
}
/// Get the global input state (for Bevy systems to access)
pub fn get_input_state() -> Arc<Mutex<borders_core::ui::input::InputState>> {
INPUT_STATE.clone()
}
/// Track an analytics event
#[wasm_bindgen]
pub fn track_analytics_event(event: JsValue) -> Result<(), JsValue> {
#[derive(serde::Deserialize)]
struct AnalyticsEventPayload {
event: String,
#[serde(default)]
properties: std::collections::HashMap<String, serde_json::Value>,
}
let payload: AnalyticsEventPayload = serde_wasm_bindgen::from_value(event).map_err(|e| JsValue::from_str(&format!("Failed to deserialize analytics event: {}", e)))?;
let telemetry_event = borders_core::telemetry::TelemetryEvent { event: payload.event, properties: payload.properties };
// Spawn a task to track the event asynchronously
wasm_bindgen_futures::spawn_local(async move {
borders_core::telemetry::track(telemetry_event).await;
});
Ok(())
}

View File

@@ -0,0 +1,98 @@
pub mod bridge;
pub mod render_bridge;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use wasm_bindgen::prelude::*;
#[wasm_bindgen(start)]
pub fn main() {
// Set up panic hook for better error messages in the browser
console_error_panic_hook::set_once();
// Initialize tracing for WASM (outputs to browser console)
// Debug builds: debug level for our crates, info for dependencies
// Release builds: warn level for our crates, error for dependencies
#[cfg(debug_assertions)]
let level_filter = "borders_core=debug,borders_protocol=debug,borders_wasm=debug,info";
#[cfg(not(debug_assertions))]
let level_filter = "borders_core=warn,borders_protocol=warn,borders_wasm=warn,error";
if let Err(e) = tracing_subscriber::registry()
.with(tracing_subscriber::EnvFilter::new(level_filter))
.with(wasm_tracing::WasmLayer::new(
wasm_tracing::WasmLayerConfig::new()
.set_show_fields(true)
.set_report_logs_in_timings(true)
.set_console_config(wasm_tracing::ConsoleConfig::ReportWithConsoleColor)
// Only show origin (filename, line number) in debug builds
.set_show_origin(true)
.clone(),
))
.try_init()
{
eprintln!("Failed to initialize tracing: {}", e);
}
// Log build information
tracing::info!("Iron Borders v{}", borders_core::build_info::VERSION);
tracing::info!("Git: {} | Built: {}", borders_core::build_info::git_commit_short(), borders_core::build_info::BUILD_TIME);
tracing::info!("© 2025 Ryan Walters. All Rights Reserved.");
// Start the Bevy app
wasm_bindgen_futures::spawn_local(async {
// Initialize telemetry (async to load user ID from IndexedDB)
borders_core::telemetry::init(borders_core::telemetry::TelemetryConfig::default()).await;
borders_core::telemetry::track_session_start().await;
tracing::info!("Telemetry initialized");
run().await;
});
}
async fn run() {
use borders_core::app::{App, Plugin};
use borders_core::time::Time;
use std::time::Duration;
use web_time::Instant;
let mut app = App::new();
// Initialize time tracking
app.insert_resource(Time::new());
// Add core game logic and frontend transport
borders_core::GamePlugin::new(borders_core::plugin::NetworkMode::Local).build(&mut app);
borders_core::ui::FrontendPlugin::new(render_bridge::WasmRenderBridgeTransport).build(&mut app);
// Insert InputState as NonSend resource (shared with WASM bindings)
let input_state_shared = bridge::get_input_state();
app.insert_non_send_resource(input_state_shared);
// Run startup systems
app.run_startup();
// Finish app setup
app.finish();
app.cleanup();
// Manual update loop at 60 FPS (worker-compatible)
let frame_time = Duration::from_millis(16); // ~60 FPS
let mut last_frame_time = Instant::now();
loop {
let frame_start = Instant::now();
// Update time resource with delta from PREVIOUS frame
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();
last_frame_time = frame_start;
gloo_timers::future::sleep(frame_time).await;
}
}

View File

@@ -0,0 +1,68 @@
//! WASM-specific frontend transport using JavaScript callbacks
//!
//! This module provides the WASM implementation of FrontendTransport,
//! sending render messages and UI events through JavaScript callbacks to the browser frontend.
use std::cell::RefCell;
use std::collections::VecDeque;
use borders_core::ui::FrontendTransport;
use borders_core::ui::protocol::{BackendMessage, FrontendMessage};
use wasm_bindgen::prelude::*;
// Thread-local storage for callbacks and inbound messages
thread_local! {
static BACKEND_MESSAGE_CALLBACK: RefCell<Option<js_sys::Function>> = const { RefCell::new(None) };
static INBOUND_MESSAGES: RefCell<VecDeque<FrontendMessage>> = const { RefCell::new(VecDeque::new()) };
}
/// Register a callback for backend messages (all messages from backend to frontend)
#[wasm_bindgen]
pub fn register_backend_message_callback(callback: js_sys::Function) {
BACKEND_MESSAGE_CALLBACK.with(|cb| {
*cb.borrow_mut() = Some(callback);
});
}
/// Send a frontend message from JavaScript to the game core
#[wasm_bindgen]
pub fn send_frontend_message(msg: JsValue) -> Result<(), JsValue> {
let message: FrontendMessage = serde_wasm_bindgen::from_value(msg).map_err(|e| JsValue::from_str(&format!("Failed to deserialize frontend message: {}", e)))?;
INBOUND_MESSAGES.with(|messages_cell| {
messages_cell.borrow_mut().push_back(message);
});
Ok(())
}
/// WASM-specific frontend transport using JavaScript callbacks
#[derive(Clone)]
pub struct WasmRenderBridgeTransport;
impl FrontendTransport for WasmRenderBridgeTransport {
fn send_backend_message(&self, message: &BackendMessage) -> Result<(), String> {
BACKEND_MESSAGE_CALLBACK.with(|cb_cell| {
if let Some(cb) = cb_cell.borrow().as_ref() {
match serde_wasm_bindgen::to_value(message) {
Ok(js_payload) => {
let this = JsValue::null();
if let Err(e) = cb.call1(&this, &js_payload) {
return Err(format!("Backend message callback failed: {:?}", e));
}
Ok(())
}
Err(e) => Err(format!("Failed to serialize backend message: {}", e)),
}
} else {
Err("No backend message callback registered".to_string())
}
})
}
fn try_recv_frontend_message(&self) -> Option<FrontendMessage> {
INBOUND_MESSAGES.with(|messages_cell| messages_cell.borrow_mut().pop_front())
}
// WASM uses structured messages only, no binary streaming
}

5
frontend/.prettierrc Normal file
View File

@@ -0,0 +1,5 @@
{
"tabWidth": 2,
"printWidth": 135,
"useTabs": false
}

7
frontend/index.html Normal file
View File

@@ -0,0 +1,7 @@
<!doctype html>
<html lang="en">
<head></head>
<body>
<div id="root"></div>
</body>
</html>

48
frontend/package.json Normal file
View File

@@ -0,0 +1,48 @@
{
"name": "iron-borders",
"private": true,
"type": "module",
"scripts": {
"check": "tsc --noEmit --project tsconfig.browser.json",
"dev": "vike dev",
"dev:browser": "vike dev --mode browser",
"build": "tsc --project tsconfig.browser.json && vike build",
"build:desktop": "tsc --project tsconfig.desktop.json && vike build --mode desktop",
"build:browser": "tsc --project tsconfig.browser.json && vike build --mode browser",
"preview": "vike preview",
"preview:browser": "vike preview --mode browser",
"tauri": "tauri"
},
"dependencies": {
"@fontsource-variable/inter": "^5.2.8",
"@fontsource-variable/oswald": "^5.2.8",
"@radix-ui/react-dialog": "^1.1.15",
"@tauri-apps/api": "^2.8.0",
"@tauri-apps/plugin-opener": "^2.5.0",
"@tauri-apps/plugin-process": "^2.3.0",
"lucide-react": "^0.545.0",
"motion": "^12.23.22",
"overlayscrollbars": "^2.12.0",
"overlayscrollbars-react": "^0.5.6",
"pixi.js": "^8.14.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"vike": "^0.4.242",
"vike-react": "^0.6.9"
},
"devDependencies": {
"@tauri-apps/cli": "^2.8.4",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.1",
"@vitejs/plugin-react": "^5.0.4",
"lightningcss": "^1.30.2",
"prettier": "^3.6.2",
"typescript": "~5.9.3",
"vite": "^7.1.9",
"vite-imagetools": "^9.0.0"
},
"browserslist": [
"last 2 versions and >0.2% and not dead",
"Firefox ESR"
]
}

73
frontend/pages/+Head.tsx Normal file
View File

@@ -0,0 +1,73 @@
// Import fonts CSS as raw string to inline in HTML
import fontsCss from "./fonts.css?inline";
import oswaldWoff2 from "@fontsource-variable/oswald/files/oswald-latin-wght-normal.woff2?url";
export default function HeadDefault() {
return (
<>
<meta charSet="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
{/* Preload critical Oswald font for faster title rendering */}
<link rel="preload" href={oswaldWoff2} as="font" type="font/woff2" crossOrigin="anonymous" />
{/* Inlined font definitions - processed by Vite at build time */}
<style dangerouslySetInnerHTML={{ __html: fontsCss }} />
{/* Global styles for initial render */}
<style>{`
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
width: 100%;
height: 100%;
overflow: hidden;
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 50%, #cbd5e1 100%);
font-family: Arial, sans-serif;
position: relative;
}
body::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
filter: blur(4px) brightness(0.85);
transform: scale(1.05);
z-index: -2;
}
body::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(255, 255, 255, 0.3);
backdrop-filter: blur(1px);
z-index: -1;
}
#game-canvas {
position: absolute;
top: 0;
left: 0;
width: 100vw !important;
height: 100vh !important;
display: block;
background: #000;
z-index: 0;
}
`}</style>
</>
);
}

View File

@@ -0,0 +1,85 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import { GameAPIProvider } from '@/shared/api/GameAPIContext'
import { AnalyticsProvider } from '@/shared/analytics'
export default function Wrapper({ children }: { children: ReactNode }) {
// Determine platform based on build-time define
const isDesktop = typeof __DESKTOP__ !== 'undefined' ? __DESKTOP__ : false
const [gameAPI, setGameAPI] = useState<any>(null)
const [analytics, setAnalytics] = useState<any>(null)
// Dynamically import the appropriate platform implementation
// Use build-time constant for tree-shaking
useEffect(() => {
if (__DESKTOP__) {
Promise.all([
import('@/desktop/api/tauriAPI'),
import('@/desktop/analytics/tauriAnalytics'),
]).then(([{ tauriAPI }, { tauriAnalytics }]) => {
setGameAPI(tauriAPI)
setAnalytics(tauriAnalytics)
})
} else {
Promise.all([
import('@/browser/api/wasmBridge'),
import('@/browser/analytics'),
]).then(([{ wasmBridge }, { wasmAnalytics }]) => {
setGameAPI(wasmBridge)
setAnalytics(wasmAnalytics)
})
}
}, [])
// Browser-specific setup (must be before early return to satisfy Rules of Hooks)
useEffect(() => {
if (!__DESKTOP__) {
// Disable context menu to prevent interference with right-click controls
const handleContextMenu = (e: MouseEvent) => {
e.preventDefault()
return false
}
document.addEventListener('contextmenu', handleContextMenu)
// Handle user ID storage from worker
const userIdChannel = new BroadcastChannel('user_id_storage')
userIdChannel.onmessage = (event) => {
try {
const msg = JSON.parse(event.data as string)
if (msg.action === 'save') {
localStorage.setItem('app_session_id', msg.id)
} else if (msg.action === 'load') {
const id = localStorage.getItem('app_session_id')
if (id) {
userIdChannel.postMessage(JSON.stringify({ action: 'load_response', id }))
}
}
} catch {}
}
// Create canvas element for the game renderer
const canvas = document.createElement('canvas')
canvas.id = 'game-canvas'
document.body.appendChild(canvas)
return () => {
document.removeEventListener('contextmenu', handleContextMenu)
userIdChannel.close()
if (canvas.parentElement) {
canvas.remove()
}
}
}
}, [])
// Wait for platform-specific modules to load
if (!gameAPI || !analytics) {
return null
}
return (
<AnalyticsProvider api={analytics}>
<GameAPIProvider api={gameAPI}>{children}</GameAPIProvider>
</AnalyticsProvider>
)
}

View File

@@ -0,0 +1,17 @@
import { type ReactNode } from 'react'
import { GameAPIProvider } from '@/shared/api/GameAPIContext'
import { AnalyticsProvider } from '@/shared/analytics'
// Fonts are imported in +Head.tsx
// Server-side wrapper provides stub implementations for SSG/pre-rendering
// The real implementations are provided by +Wrapper.client.tsx on the client
export default function Wrapper({ children }: { children: ReactNode }) {
// During SSR/pre-rendering, provide null implementations
// These will be replaced by real implementations when the client-side wrapper takes over
return (
<AnalyticsProvider api={null as any}>
<GameAPIProvider api={null as any}>{children}</GameAPIProvider>
</AnalyticsProvider>
)
}

15
frontend/pages/+config.js Normal file
View File

@@ -0,0 +1,15 @@
import vikeReact from 'vike-react/config'
export default {
extends: [vikeReact],
// Enable pre-rendering for static site generation
prerender: true,
// Global head configuration
title: 'Iron Borders',
description: 'Strategic Territory Control',
// Disable React StrictMode to avoid double-mounting issues with PixiJS
reactStrictMode: false
}

19
frontend/pages/fonts.css Normal file
View File

@@ -0,0 +1,19 @@
/* oswald-latin-wght-normal */
@font-face {
font-family: 'Oswald Variable';
font-style: normal;
font-display: block;
font-weight: 200 700;
src: url(@fontsource-variable/oswald/files/oswald-latin-wght-normal.woff2) format('woff2-variations');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
/* inter-latin-wght-normal */
@font-face {
font-family: 'Inter Variable';
font-style: normal;
font-display: swap;
font-weight: 100 900;
src: url(@fontsource-variable/inter/files/inter-latin-wght-normal.woff2) format('woff2-variations');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}

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