diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..fcf410f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,54 @@ +name: Bug report +description: Report a bug in LP +body: + +- type: markdown + attributes: + value: | + When reporting bugs, please follow the guidelines in this template. This helps identify the problem precisely and thus enables contributors to fix it faster. + - Write a descriptive issue title above. + - The golden rule is to **always open *one* issue for *one* bug**. If you notice several bugs and want to report them, make sure to create one new issue for each of them. + - Search [open issues](https://github.com/erematorg/LP/issues) and [closed issues](https://github.com/erematorg/LP/issues?q=is%3Aissue+is%3Aclosed) to ensure it has not already been reported. If you don't find a relevant match or if you're unsure, don't hesitate to **open a new issue**. The bugsquad will handle it from there if it's a duplicate. + - Please always check if your issue is reproducible in the latest version – it may already have been fixed! + +- type: textarea + attributes: + label: Tested versions + description: | + To properly fix a bug, we need to identify if the bug was recently introduced in the LP project or if it has been present from the beginning. + - Specify the LP version you found the issue in. If you are using a specific build, include relevant details. + - If you can, **please test earlier LP versions** and, if applicable, newer versions. Mention whether the bug is reproducible or not in the versions you tested. + placeholder: | + + - Reproducible in: v1.0, v1.1 + - Not reproducible in: v0.9 + validations: + required: true + +- type: input + attributes: + label: System information + description: | + - Specify the OS version, and when relevant, hardware information. + - For issues that are likely OS-specific and/or graphics-related, please specify the CPU model and architecture. + placeholder: Windows 10 - LP v1.0 - dedicated GPU NVIDIA GeForce GTX 970 - Intel Core i7-10700KF CPU @ 3.80GHz (16 Threads) + validations: + required: true + +- type: textarea + attributes: + label: Issue description + description: | + Describe your issue briefly. What doesn't work, and how do you expect it to work instead? + You can include images or videos with drag and drop, and format code blocks or logs with ``` tags. + validations: + required: true + +- type: textarea + attributes: + label: Steps to reproduce + description: | + List the steps or sample code that reproduces the issue. Having reproducible issues is a prerequisite for contributors to be able to solve them. + If you include a minimal reproduction project below, detail how to use it here. + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..5b1e3da --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,36 @@ +name: Feature request +description: Suggest a new feature for LP +body: + +- type: markdown + attributes: + value: | + When suggesting new features, please follow the guidelines in this template. This helps provide clear and actionable suggestions. + - Write a descriptive title above. + - Clearly describe the problem or need that this feature aims to address. + - If you have multiple feature suggestions, create a new request for each to maintain clarity. + - Search [open issues](https://github.com/erematorg/LP/issues) to ensure a similar feature request has not already been submitted. If you don't find a relevant match or if you're unsure, feel free to **open a new request**. + +- type: textarea + attributes: + label: Problem Description + description: | + A clear and concise description of the problem or need that this feature aims to address. + +- type: textarea + attributes: + label: Proposed Solution + description: | + A clear and concise description of the solution or functionality you would like to see implemented. + +- type: textarea + attributes: + label: Alternatives Considered + description: | + Describe any alternative solutions or features you have considered or think might be viable. + +- type: textarea + attributes: + label: Additional Context + description: | + Add any other context, examples, or use cases that provide additional information about the feature request. diff --git a/.github/code_of_conduct.md b/.github/code_of_conduct.md new file mode 100644 index 0000000..e660ddf --- /dev/null +++ b/.github/code_of_conduct.md @@ -0,0 +1,129 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +contact@eremat.org. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. + diff --git a/.github/funding.yml b/.github/funding.yml new file mode 100644 index 0000000..0f775a8 --- /dev/null +++ b/.github/funding.yml @@ -0,0 +1,14 @@ +# These are supported funding model platforms + +github: [erematorg] +patreon: # Replace with a single Patreon username +open_collective: # Replace with a single Open Collective username +ko_fi: eremat +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +otechie: # Replace with a single Otechie username +lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry +custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] + diff --git a/crates/energy/src/electromagnetism/interactions.rs b/crates/energy/src/electromagnetism/interactions.rs index 82bd4be..ff3e8b1 100644 --- a/crates/energy/src/electromagnetism/interactions.rs +++ b/crates/energy/src/electromagnetism/interactions.rs @@ -1,6 +1,7 @@ -use super::fields::{ElectricField, MagneticField}; use bevy::prelude::*; +use super::fields::{ElectricField, MagneticField}; + // Speed of light (in m/s) constant physical value //TODO: Making this cleaner later on to make units of measure dynamic rather than admiting 1 meter = 1 meter, same for seconds and much more const C: f32 = 299_792_458.0; diff --git a/crates/energy/src/waves/mod.rs b/crates/energy/src/waves/mod.rs index 8b1e76c..c178b8d 100644 --- a/crates/energy/src/waves/mod.rs +++ b/crates/energy/src/waves/mod.rs @@ -1,5 +1,17 @@ pub mod oscillation; pub mod propagation; + +use bevy::prelude::Vec2; + +/// Helper function to normalize a vector or return a fallback if the vector is too small +#[inline] +pub(crate) fn normalize_or(vec: Vec2, fallback: Vec2) -> Vec2 { + if vec.length_squared() > f32::EPSILON { + vec.normalize() + } else { + fallback + } +} pub mod superposition; pub mod wave_equation; diff --git a/crates/energy/src/waves/oscillation.rs b/crates/energy/src/waves/oscillation.rs index 89f63bf..153cc8b 100644 --- a/crates/energy/src/waves/oscillation.rs +++ b/crates/energy/src/waves/oscillation.rs @@ -1,13 +1,6 @@ use bevy::prelude::*; -#[inline] -fn normalize_or(vec: Vec2, fallback: Vec2) -> Vec2 { - if vec.length_squared() > f32::EPSILON { - vec.normalize() - } else { - fallback - } -} +use super::normalize_or; /// Wave parameters for configuring wave behavior #[derive(Component, Debug, Clone, Copy, Reflect)] @@ -47,11 +40,6 @@ impl Default for WaveParameters { } impl WaveParameters { - /// Builder-style method for creating custom wave parameters - pub fn new() -> Self { - Self::default() - } - /// Fluent interface for setting speed pub fn with_speed(mut self, speed: f32) -> Self { self.speed = speed; diff --git a/crates/energy/src/waves/propagation.rs b/crates/energy/src/waves/propagation.rs index 7dee9ec..578e9b8 100644 --- a/crates/energy/src/waves/propagation.rs +++ b/crates/energy/src/waves/propagation.rs @@ -1,14 +1,7 @@ -use super::oscillation::{WaveParameters, angular_frequency, wave_number}; use bevy::prelude::*; -#[inline] -fn normalize_or(vec: Vec2, fallback: Vec2) -> Vec2 { - if vec.length_squared() > f32::EPSILON { - vec.normalize() - } else { - fallback - } -} +use super::normalize_or; +use super::oscillation::{WaveParameters, angular_frequency, wave_number}; // Calculate modified angular frequency with dispersion #[inline] diff --git a/crates/forces/src/core/gravity.rs b/crates/forces/src/core/gravity.rs index 378b9ac..0b3abe6 100644 --- a/crates/forces/src/core/gravity.rs +++ b/crates/forces/src/core/gravity.rs @@ -2,18 +2,47 @@ use super::newton_laws::{AppliedForce, Mass}; use bevy::prelude::*; // Simulation constants -pub const GRAVITATIONAL_CONSTANT: f32 = 0.1; +pub const DEFAULT_GRAVITATIONAL_CONSTANT: f32 = 0.1; /// Resource for gravity simulation parameters #[derive(Resource, Clone, Debug)] pub struct GravityParams { /// Softening parameter to prevent singularities pub softening: f32, + /// Gravitational constant controlling attraction strength + pub gravitational_constant: f32, + /// Maximum depth for Barnes-Hut octree spatial partitioning + pub barnes_hut_max_depth: usize, + /// Maximum bodies per node before subdivision in Barnes-Hut algorithm + pub barnes_hut_max_bodies_per_node: usize, } impl Default for GravityParams { fn default() -> Self { - Self { softening: 5.0 } + Self { + softening: 5.0, + gravitational_constant: DEFAULT_GRAVITATIONAL_CONSTANT, + barnes_hut_max_depth: 8, + barnes_hut_max_bodies_per_node: 8, + } + } +} + +impl GravityParams { + pub fn with_softening(mut self, softening: f32) -> Self { + self.softening = softening; + self + } + + pub fn with_gravitational_constant(mut self, gravitational_constant: f32) -> Self { + self.gravitational_constant = gravitational_constant; + self + } + + pub fn with_barnes_hut_params(mut self, max_depth: usize, max_bodies_per_node: usize) -> Self { + self.barnes_hut_max_depth = max_depth.max(1); + self.barnes_hut_max_bodies_per_node = max_bodies_per_node.max(1); + self } } @@ -55,10 +84,6 @@ pub struct MassiveBody; mod spatial { use bevy::prelude::*; - // Algorithm parameters - const MAX_DEPTH: usize = 8; - const MAX_BODIES_PER_NODE: usize = 8; - #[derive(Clone, Debug)] pub struct AABB { pub center: Vec2, @@ -131,16 +156,20 @@ mod spatial { pub mass_properties: MassProperties, pub bodies: Vec<(Entity, Vec3, f32)>, pub children: [Option>; 4], + pub max_depth: usize, + pub max_bodies_per_node: usize, } impl QuadtreeNode { - pub fn new(aabb: AABB, depth: usize) -> Self { + pub fn new(aabb: AABB, depth: usize, max_depth: usize, max_bodies_per_node: usize) -> Self { Self { aabb, depth, mass_properties: MassProperties::new(), bodies: Vec::new(), children: [None, None, None, None], + max_depth, + max_bodies_per_node, } } @@ -159,8 +188,8 @@ mod spatial { pub fn insert(&mut self, entity: Entity, position: Vec3, mass: f32) { self.mass_properties.add_body(position, mass); - if self.depth >= MAX_DEPTH - || (self.bodies.len() < MAX_BODIES_PER_NODE && self.children[0].is_none()) + if self.depth >= self.max_depth + || (self.bodies.len() < self.max_bodies_per_node && self.children[0].is_none()) { self.bodies.push((entity, position, mass)); return; @@ -171,6 +200,8 @@ mod spatial { self.children[i] = Some(Box::new(QuadtreeNode::new( self.aabb.get_quadrant_aabb(i), self.depth + 1, + self.max_depth, + self.max_bodies_per_node, ))); } @@ -196,15 +227,23 @@ mod spatial { } impl Quadtree { - pub fn new(bounds: AABB) -> Self { + pub fn new(bounds: AABB, max_depth: usize, max_bodies_per_node: usize) -> Self { Self { - root: QuadtreeNode::new(bounds, 0), + root: QuadtreeNode::new(bounds, 0, max_depth, max_bodies_per_node), } } - pub fn from_bodies(bodies: &[(Entity, Vec3, f32)]) -> Self { + pub fn from_bodies( + bodies: &[(Entity, Vec3, f32)], + max_depth: usize, + max_bodies_per_node: usize, + ) -> Self { if bodies.is_empty() { - return Self::new(AABB::new(Vec2::ZERO, Vec2::new(1000.0, 1000.0))); + return Self::new( + AABB::new(Vec2::ZERO, Vec2::new(1000.0, 1000.0)), + max_depth, + max_bodies_per_node, + ); } let mut min_x = f32::MAX; @@ -229,7 +268,11 @@ mod spatial { let half_size = Vec2::new((max_x - min_x) * 0.5, (max_y - min_y) * 0.5); let max_half_size = half_size.x.max(half_size.y); - let mut tree = Self::new(AABB::new(center, Vec2::splat(max_half_size))); + let mut tree = Self::new( + AABB::new(center, Vec2::splat(max_half_size)), + max_depth, + max_bodies_per_node, + ); for &(entity, position, mass) in bodies { tree.insert(entity, position, mass); @@ -268,6 +311,7 @@ pub fn calculate_gravitational_attraction( >, ) { let softening_squared = gravity_params.softening * gravity_params.softening; + let gravitational_constant = gravity_params.gravitational_constant; let sources: Vec<(Entity, Vec3, f32)> = query .iter() @@ -286,7 +330,7 @@ pub fn calculate_gravitational_attraction( let direction = source_pos - affected_pos; let distance_squared = direction.length_squared(); let softened_distance_squared = distance_squared + softening_squared; - let force_magnitude = GRAVITATIONAL_CONSTANT * source_mass * affected_mass.value + let force_magnitude = gravitational_constant * source_mass * affected_mass.value / softened_distance_squared; force.force += direction.normalize() * force_magnitude; } @@ -314,7 +358,13 @@ pub fn calculate_barnes_hut_attraction( .map(|(e, t, m)| (e, t.translation, m.value)) .collect(); - let quadtree = spatial::Quadtree::from_bodies(&bodies); + let quadtree = spatial::Quadtree::from_bodies( + &bodies, + gravity_params.barnes_hut_max_depth, + gravity_params.barnes_hut_max_bodies_per_node, + ); + let softening = gravity_params.softening; + let gravitational_constant = gravity_params.gravitational_constant; affected_query .par_iter_mut() @@ -329,7 +379,8 @@ pub fn calculate_barnes_hut_attraction( position, &quadtree.root, theta, - gravity_params.softening, + softening, + gravitational_constant, ); force.force += force_vector; @@ -341,6 +392,7 @@ pub fn calculate_barnes_hut_force( node: &spatial::QuadtreeNode, theta: f32, softening: f32, + gravitational_constant: f32, ) -> Vec3 { let softening_squared = softening * softening; @@ -349,7 +401,7 @@ pub fn calculate_barnes_hut_force( let distance_squared = direction.length_squared(); let softened_distance_squared = distance_squared + softening_squared; let force_magnitude = - GRAVITATIONAL_CONSTANT * node.mass_properties.total_mass / softened_distance_squared; + gravitational_constant * node.mass_properties.total_mass / softened_distance_squared; return direction.normalize() * force_magnitude; } @@ -364,12 +416,9 @@ pub fn calculate_barnes_hut_force( continue; } - total_force += { - let distance_squared = direction.length_squared(); - let softened_distance_squared = distance_squared + softening_squared; - let force_magnitude = GRAVITATIONAL_CONSTANT * mass / softened_distance_squared; - direction.normalize() * force_magnitude - }; + let softened_distance_squared = distance_squared + softening_squared; + let force_magnitude = gravitational_constant * mass / softened_distance_squared; + total_force += direction.normalize() * force_magnitude; } return total_force; @@ -377,14 +426,20 @@ pub fn calculate_barnes_hut_force( let mut total_force = Vec3::ZERO; for child_node in node.children.iter().flatten() { - total_force += calculate_barnes_hut_force(affected_position, child_node, theta, softening); + total_force += calculate_barnes_hut_force( + affected_position, + child_node, + theta, + softening, + gravitational_constant, + ); } total_force } pub fn calculate_orbital_velocity(central_mass: f32, orbit_radius: f32) -> f32 { - (GRAVITATIONAL_CONSTANT * central_mass / orbit_radius).sqrt() + (DEFAULT_GRAVITATIONAL_CONSTANT * central_mass / orbit_radius).sqrt() } pub fn calculate_elliptical_orbit_velocity( @@ -393,13 +448,13 @@ pub fn calculate_elliptical_orbit_velocity( eccentricity: f32, is_periapsis: bool, ) -> f32 { - let mu = GRAVITATIONAL_CONSTANT * central_mass; + let mu = DEFAULT_GRAVITATIONAL_CONSTANT * central_mass; let semimajor_axis = distance / (1.0 - eccentricity * if is_periapsis { 1.0 } else { -1.0 }); (mu * (2.0 / distance - 1.0 / semimajor_axis)).sqrt() } pub fn calculate_escape_velocity(central_mass: f32, distance: f32) -> f32 { - (2.0 * GRAVITATIONAL_CONSTANT * central_mass / distance).sqrt() + (2.0 * DEFAULT_GRAVITATIONAL_CONSTANT * central_mass / distance).sqrt() } #[derive(Default)] diff --git a/crates/forces/src/core/mod.rs b/crates/forces/src/core/mod.rs index 3c9daa9..2cc15a3 100644 --- a/crates/forces/src/core/mod.rs +++ b/crates/forces/src/core/mod.rs @@ -7,7 +7,7 @@ pub mod newton_laws; pub mod prelude { // Re-export from gravity module pub use crate::core::gravity::{ - GRAVITATIONAL_CONSTANT, GravityAffected, GravityParams, GravitySource, MassiveBody, + DEFAULT_GRAVITATIONAL_CONSTANT, GravityAffected, GravityParams, GravitySource, MassiveBody, UniformGravity, calculate_elliptical_orbit_velocity, calculate_escape_velocity, calculate_gravitational_attraction, calculate_orbital_velocity, }; diff --git a/crates/information/src/fractals/core.rs b/crates/information/src/fractals/core.rs index 422f084..b20dfb2 100644 --- a/crates/information/src/fractals/core.rs +++ b/crates/information/src/fractals/core.rs @@ -1,6 +1,7 @@ -use super::generator; use std::collections::HashMap; +use super::generator; + /// Handles rule management for L-Systems. pub struct RuleManager<'a> { rules: HashMap, diff --git a/crates/information/src/fractals/data_loader.rs b/crates/information/src/fractals/data_loader.rs index 7d5c89d..3fd4009 100644 --- a/crates/information/src/fractals/data_loader.rs +++ b/crates/information/src/fractals/data_loader.rs @@ -1,5 +1,4 @@ use serde::Deserialize; -use std::fs; #[derive(Deserialize, Debug)] pub struct Parameters { @@ -25,10 +24,7 @@ pub struct Template { /// Load a template from the fractals.json file pub fn load_template(template_name: &str) -> Result { - let file_content = fs::read_to_string("crates/information/src/fractals/fractals.json") - .map_err(|_| "Error: Could not read fractals.json".to_string())?; - - let json: serde_json::Value = serde_json::from_str(&file_content) + let json: serde_json::Value = serde_json::from_str(include_str!("fractals.json")) .map_err(|_| "Error: Invalid JSON format in fractals.json".to_string())?; json["templates"] diff --git a/crates/information/src/fractals/renderer.rs b/crates/information/src/fractals/renderer.rs index 5d13346..258cc2c 100644 --- a/crates/information/src/fractals/renderer.rs +++ b/crates/information/src/fractals/renderer.rs @@ -4,7 +4,6 @@ use bevy::prelude::*; // Components /// Component for an L-System branch #[derive(Component)] -#[allow(dead_code)] struct Branch { /// Type of this branch segment symbol_type: SymbolType, diff --git a/crates/systems/Cargo.toml b/crates/systems/Cargo.toml index a335f94..33cf35d 100644 --- a/crates/systems/Cargo.toml +++ b/crates/systems/Cargo.toml @@ -7,6 +7,6 @@ description = "Core simulation infrastructure components that manage and support [dependencies] bevy = "0.17" save_system = { path = "./save_system" } -pbmpm = { path = "./pbmpm" } +mpm = { path = "./mpm" } acoustics = { path = "./acoustics" } ai = { path = "./ai" } diff --git a/crates/systems/README.md b/crates/systems/README.md index 0a111f9..ca6bfa7 100644 --- a/crates/systems/README.md +++ b/crates/systems/README.md @@ -1,3 +1,47 @@ # Systems -High-level systems including AI, physics solvers, acoustics, and save systems. \ No newline at end of file +The `systems` workspace crate bundles LP's high-level simulation layers +(decision-making, acoustics, future MPM integration, and persistence) so they +can be added to a Bevy app with a single plugin. It sits on top of the domain +crates (`energy`, `forces`, `information`, `matter`) and takes care of wiring and +scheduling. + +## Modules + +- `ai` - utility-driven agency used by LP's creatures and actors. +- `acoustics` - scaffold for physics-based sound that will hook into matter and + wave simulation. +- `mpm` - placeholder for the upcoming Material Point Method solver. +- `save_system` - shared save / load infrastructure. + +Each module exposes a `prelude` for selective use, while +`systems::SystemsPlugin` pulls everything together. + +## Quick start + +```rust +use bevy::prelude::*; +use systems::prelude::*; + +fn main() { + App::new() + .add_plugins(DefaultPlugins) + .add_plugins(SystemsPlugin::default()) + .run(); +} +``` + +### Configuration + +`SystemsPlugin` can be customised before registration: + +```rust +use systems::save_system::prelude::SaveSettings; + +let systems = SystemsPlugin::default() + .with_ai(true) + .with_acoustics(false) + .with_save_settings(SaveSettings::default().with_default_file("saves/slot_a.json")); + +app.add_plugins(systems); +``` diff --git a/crates/systems/ai/README.md b/crates/systems/ai/README.md new file mode 100644 index 0000000..4eae3aa --- /dev/null +++ b/crates/systems/ai/README.md @@ -0,0 +1,65 @@ +# LP AI Architecture + +This crate provides the agent decision loop used across Life's Progress. Every organism—plant, fungus, animal, machine—shares the same ECS-driven pipeline: + +``` +Perception → Trackers & Drives → State Layers (memory/emotion/social) → Utility Arbiter → Behavior Executors +``` + +## Key Concepts + +### AIModule +Any component implementing `AIModule` exposes `update()` and `utility()` (0-1). Existing modules: +- **Perception** - sensory cache (vision/chemical/etc.) +- **Threat / Prey trackers** - context-specific evaluation +- **Needs tracker** - energy/homeostasis drives +- **Memory events** - short-term context +- **Personality / Social graphs** - trait-based biasing + +Modules can publish `IntentContribution` events to compete for control (see Arbiter below). + +### Utility Arbiter +`arbiter::UtilityArbiterPlugin` resets each agent’s `IntentSelection` every frame, reads `IntentContribution` events, applies continuation bias/hysteresis, and records the winning module & score. Only one intent wins per frame, providing Rain-World-style “strongest drive wins” behavior. + +### Behavior Executors +Systems downstream of the arbiter read `IntentSelection` and perform actions (movement, growth, interaction). Executors will be built per intent (e.g., flee, forage, rest) and consume data from trackers/drives to apply forces or state changes. + +## Adding a New Decision Module +1. Create a component implementing `AIModule` (e.g., `#[derive(Component)] struct HungerTracker`). +2. In your system, update the module’s internal state and call `IntentContribution` to report a utility: +```rust +fn hunger_intent_system( + query: Query<(Entity, &HungerTracker)>, + mut writer: MessageWriter, +) { + for (entity, tracker) in &query { + writer.write(IntentContribution { + entity, + module: "hunger", + utility: tracker.utility(), + }); + } +} +``` +3. The arbiter will compare utilities (with continuation bias if the same module won last frame) and set `IntentSelection`. + +## Behavior Executors (WIP) +Executors subscribe to `IntentSelection`: +```rust +fn flee_executor( + query: Query<(Entity, &IntentSelection, &mut Velocity), With>, +) { + // if `selection.winner == Some("flee")`, apply movement logic +} +``` + +### End-to-end example +See `examples/basic_ai.rs` for a minimal wiring of the full loop: +1. Each creature owns `Perception`, `EntityTracker`, `NeedsTracker`, `ThreatTracker`, `PreyTracker`, and `IntentSelection`. +2. Sensors fill the `EntityTracker`; tracker systems evaluate utilities and emit `IntentContribution` events via the arbiter. +3. A behavior executor reads `IntentResolved` events, maps winners (`"threat"`, `"prey"`, `"needs"`) to locomotion commands, and world systems react to the actions. + +## Philosophy +- **No DSL / behavior trees** - everything is ECS data. +- **Species-agnostic** - modules work for plants/animals alike; species differences are just component presets. +- **Emergence-first** - utilities are derived from physics-aware trackers/drives, not hand-authored scripts. diff --git a/crates/systems/ai/src/arbiter/mod.rs b/crates/systems/ai/src/arbiter/mod.rs new file mode 100644 index 0000000..b9770dc --- /dev/null +++ b/crates/systems/ai/src/arbiter/mod.rs @@ -0,0 +1,210 @@ +use std::collections::HashMap; + +use bevy::prelude::*; + +use crate::AIModule; +use crate::trackers::needs_tracker::NeedsTracker; +use crate::trackers::prey_tracker::PreyTracker; +use crate::trackers::threat_tracker::ThreatTracker; + +/// Schedule sets that ensure the arbiter runs in a predictable order each frame. +#[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone)] +pub enum ArbiterSet { + Reset, + Gather, + Evaluate, + Broadcast, +} + +/// Component describing the intent chosen for an agent this frame. +#[derive(Component, Debug, Default, Reflect)] +#[reflect(Component)] +pub struct IntentSelection { + /// Winner chosen this frame. + pub winner: Option<&'static str>, + /// Utility returned by the winning module (biased). + pub utility: f32, + /// Winner from the previous frame (used for continuation bias). + pub last_winner: Option<&'static str>, +} + +impl IntentSelection { + fn reset_for_frame(&mut self) { + self.last_winner = self.winner; + self.winner = None; + self.utility = 0.0; + } +} + +/// Resource storing arbiter tuning parameters. +#[derive(Resource, Debug, Clone, Copy, Reflect)] +#[reflect(Resource)] +pub struct ArbiterConfig { + /// How much advantage we give to the previous winner when scores are similar. + pub continuation_bias: f32, +} + +impl Default for ArbiterConfig { + fn default() -> Self { + Self { + continuation_bias: 0.05, + } + } +} + +/// Event emitted by modules competing for control. +#[derive(Message, Clone, Debug)] +pub struct IntentContribution { + pub entity: Entity, + pub module: &'static str, + pub utility: f32, +} + +/// Event emitted after the arbiter resolves the winning intent. +#[derive(Message, Clone, Debug)] +pub struct IntentResolved { + pub entity: Entity, + pub winner: Option<&'static str>, + pub utility: f32, +} + +fn reset_intentions(mut query: Query<&mut IntentSelection>) { + for mut selection in &mut query { + selection.reset_for_frame(); + } +} + +fn gather_need_intents( + query: Query<(Entity, &NeedsTracker)>, + mut writer: MessageWriter, +) { + for (entity, tracker) in &query { + let utility = tracker.utility(); + if utility > 0.0 { + writer.write(IntentContribution { + entity, + module: "needs", + utility, + }); + } + } +} + +fn gather_threat_intents( + query: Query<(Entity, &ThreatTracker)>, + mut writer: MessageWriter, +) { + for (entity, tracker) in &query { + let utility = tracker.utility(); + if utility > 0.0 { + writer.write(IntentContribution { + entity, + module: "threat", + utility, + }); + } + } +} + +fn gather_prey_intents( + query: Query<(Entity, &PreyTracker)>, + mut writer: MessageWriter, +) { + for (entity, tracker) in &query { + let utility = tracker.utility(); + if utility > 0.0 { + writer.write(IntentContribution { + entity, + module: "prey", + utility, + }); + } + } +} + +fn evaluate_intentions( + config: Res, + mut contributions: MessageReader, + mut selections: Query<&mut IntentSelection>, +) { + // Track the best score per entity within this frame. + let mut best_scores: HashMap = HashMap::default(); + + for contribution in contributions.read() { + if let Ok(mut selection) = selections.get_mut(contribution.entity) { + let bias = if selection.last_winner == Some(contribution.module) { + config.continuation_bias + } else { + 0.0 + }; + + let adjusted = (contribution.utility + bias).clamp(0.0, 1.0); + let current_best = best_scores.entry(contribution.entity).or_insert(0.0); + + if adjusted > *current_best { + *current_best = adjusted; + selection.utility = adjusted; + selection.winner = Some(contribution.module); + } + } + } +} + +fn broadcast_intent_selections( + query: Query<(Entity, &IntentSelection)>, + mut writer: MessageWriter, +) { + for (entity, selection) in &query { + writer.write(IntentResolved { + entity, + winner: selection.winner, + utility: selection.utility, + }); + } +} + +/// Plugin wiring the utility arbiter into the Bevy schedule. +#[derive(Default)] +pub struct UtilityArbiterPlugin; + +impl Plugin for UtilityArbiterPlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + .register_type::() + .register_type::() + .add_message::() + .add_message::() + .configure_sets( + Update, + ( + ArbiterSet::Reset, + ArbiterSet::Gather, + ArbiterSet::Evaluate, + ArbiterSet::Broadcast, + ) + .chain(), + ) + .add_systems(Update, reset_intentions.in_set(ArbiterSet::Reset)) + .add_systems( + Update, + ( + gather_need_intents, + gather_threat_intents, + gather_prey_intents, + ) + .in_set(ArbiterSet::Gather), + ) + .add_systems(Update, evaluate_intentions.in_set(ArbiterSet::Evaluate)) + .add_systems( + Update, + broadcast_intent_selections.in_set(ArbiterSet::Broadcast), + ); + } +} + +pub mod prelude { + pub use super::{ + ArbiterConfig, ArbiterSet, IntentContribution, IntentResolved, IntentSelection, + UtilityArbiterPlugin, + }; +} diff --git a/crates/systems/ai/src/core/actions.rs b/crates/systems/ai/src/core/actions.rs deleted file mode 100644 index 18395b3..0000000 --- a/crates/systems/ai/src/core/actions.rs +++ /dev/null @@ -1,484 +0,0 @@ -//! Defines Action-related functionality. This module includes the -//! ActionBuilder trait and some Composite Actions for utility. -use crate::core::thinkers::{Action, ActionSpan, Actor}; -use bevy::prelude::*; -use std::sync::Arc; - -// Error message constants to reduce repetition -const ACTION_STATE_ERROR: &str = - "Failed to find action state component for concurrent action entity"; - -/// The current state for an Action. These states are changed by a combination -/// of the Thinker that spawned it, and the actual Action system executing the -/// Action itself. -#[derive(Debug, Clone, Component, Eq, PartialEq, Reflect)] -#[component(storage = "Table")] -pub enum ActionState { - /// Initial state. No action should be performed. - Init, - /// Action requested. The Action-handling system should start executing this Action ASAP. - Requested, - /// The action has ongoing execution. - Executing, - /// An ongoing Action has been cancelled. **You must check whether the Cancelled state was set** - /// and change to either Success or Failure. Thinkers will wait on Cancelled actions to do - /// cleanup work, so this can hang your AI if you don't look for it. - Cancelled, - /// The Action was a success. - Success, - /// The Action failed. - Failure, -} - -impl Default for ActionState { - fn default() -> Self { - Self::Init - } -} - -impl ActionState { - pub fn new() -> Self { - Self::default() - } -} - -#[derive(Debug, Clone, Eq, PartialEq)] -pub(crate) struct ActionBuilderId; - -#[derive(Debug, Clone)] -pub(crate) struct ActionBuilderWrapper(pub Arc, pub Arc); - -impl ActionBuilderWrapper { - pub fn new(builder: Arc) -> Self { - ActionBuilderWrapper(Arc::new(ActionBuilderId), builder) - } -} - -/// Trait that must be defined by types in order to be `ActionBuilder`s. -/// The `build()` method MUST be implemented for any `ActionBuilder`s you want to define. -#[reflect_trait] -pub trait ActionBuilder: std::fmt::Debug + Send + Sync { - /// MUST insert your concrete Action component into the Scorer [`Entity`], - /// using `cmd`. You _may_ use `actor`, but it's perfectly normal to just ignore it. - fn build(&self, cmd: &mut Commands, action: Entity, actor: Entity); - - fn label(&self) -> Option<&str> { - None - } -} - -/// Spawns a new Action Component, using the given ActionBuilder. -pub fn spawn_action( - builder: &T, - cmd: &mut Commands, - actor: Entity, -) -> Entity { - let action_ent = Action(cmd.spawn_empty().id()); - let span = ActionSpan::new(action_ent.entity(), ActionBuilder::label(builder)); - let _guard = span.span().enter(); - debug!("New Action spawned."); - cmd.entity(action_ent.entity()) - .insert(Name::new("Action")) - .insert(ActionState::new()) - .insert(Actor(actor)); - builder.build(cmd, action_ent.entity(), actor); - std::mem::drop(_guard); - cmd.entity(action_ent.entity()).insert(span); - action_ent.entity() -} - -/// [`ActionBuilder`] for the [`Steps`] component. -#[derive(Debug, Reflect)] -#[reflect(ActionBuilder)] -pub struct StepsBuilder { - label: Option, - steps_labels: Vec, - #[reflect(ignore)] - steps: Vec>, -} - -impl StepsBuilder { - pub fn label>(mut self, label: S) -> Self { - self.label = Some(label.into()); - self - } - - pub fn step(mut self, action_builder: impl ActionBuilder + 'static) -> Self { - if let Some(label) = action_builder.label() { - self.steps_labels.push(label.into()); - } else { - self.steps_labels.push("Unlabeled Action".into()); - } - self.steps.push(Arc::new(action_builder)); - self - } -} - -impl ActionBuilder for StepsBuilder { - fn label(&self) -> Option<&str> { - self.label.as_deref() - } - - fn build(&self, cmd: &mut Commands, action: Entity, actor: Entity) { - if let Some(step) = self.steps.first() { - let child_action = spawn_action(step.as_ref(), cmd, actor); - cmd.entity(action) - .insert(Name::new("Steps Action")) - .insert(Steps { - active_step: 0, - active_ent: Action(child_action), - steps: self.steps.clone(), - steps_labels: self.steps_labels.clone(), - }) - .add_children(&[child_action]); - } - } -} - -/// Composite Action that executes a series of steps in sequential order. -#[derive(Component, Debug, Reflect)] -pub struct Steps { - #[reflect(ignore)] - steps: Vec>, - steps_labels: Vec, - active_step: usize, - active_ent: Action, -} - -impl Steps { - pub fn build() -> StepsBuilder { - StepsBuilder { - steps: Vec::new(), - steps_labels: Vec::new(), - label: None, - } - } -} - -/// System that executes [`Steps`] Actions. -pub fn steps_system( - mut cmd: Commands, - mut steps_q: Query<(Entity, &Actor, &mut Steps, &ActionSpan)>, - mut states: Query<&mut ActionState>, -) { - use ActionState::*; - for (seq_ent, Actor(actor), mut steps_action, _span) in steps_q.iter_mut() { - let active_ent = steps_action.active_ent.entity(); - let current_state = states.get_mut(seq_ent).unwrap().clone(); - #[cfg(feature = "trace")] - let _guard = _span.span().enter(); - - match current_state { - Requested => { - #[cfg(feature = "trace")] - trace!( - "Initializing StepsAction and requesting first step: {:?}", - active_ent - ); - *states.get_mut(active_ent).unwrap() = Requested; - *states.get_mut(seq_ent).unwrap() = Executing; - } - Executing => { - let mut step_state = states.get_mut(active_ent).unwrap(); - match *step_state { - Init => *step_state = Requested, - Executing | Requested => {} - Cancelled => {} - Failure => { - #[cfg(feature = "trace")] - trace!("Step {:?} failed. Failing entire StepsAction.", active_ent); - let step_state = step_state.clone(); - let mut seq_state = states - .get_mut(seq_ent) - .expect("Failed to find action state for sequence entity"); - *seq_state = step_state; - if let Ok(mut ent) = cmd.get_entity(steps_action.active_ent.entity()) { - ent.despawn(); - } - } - Success if steps_action.active_step == steps_action.steps.len() - 1 => { - #[cfg(feature = "trace")] - trace!("StepsAction completed all steps successfully."); - let step_state = step_state.clone(); - let mut seq_state = states - .get_mut(seq_ent) - .expect("Failed to find action state for sequence entity"); - *seq_state = step_state; - if let Ok(mut ent) = cmd.get_entity(steps_action.active_ent.entity()) { - ent.despawn(); - } - } - Success => { - #[cfg(feature = "trace")] - trace!("Step succeeded, but there's more steps. Spawning next action."); - if let Ok(mut ent) = cmd.get_entity(steps_action.active_ent.entity()) { - ent.despawn(); - } - steps_action.active_step += 1; - let step_builder = steps_action.steps[steps_action.active_step].clone(); - let step_ent = spawn_action(step_builder.as_ref(), &mut cmd, *actor); - #[cfg(feature = "trace")] - trace!("Spawned next step: {:?}", step_ent); - cmd.entity(seq_ent).add_children(&[step_ent]); - steps_action.active_ent = Action(step_ent); - } - } - } - Cancelled => { - #[cfg(feature = "trace")] - trace!( - "StepsAction has been cancelled. Cancelling current step {:?} before finalizing.", - active_ent - ); - let mut step_state = states - .get_mut(active_ent) - .expect("Failed to find action state for active step entity"); - if matches!(*step_state, Requested | Executing | Init) { - *step_state = Cancelled; - } else if matches!(*step_state, Failure | Success) { - *states.get_mut(seq_ent).unwrap() = step_state.clone(); - } - } - Init | Success | Failure => {} - } - } -} - -/// Configures what mode the [`Concurrently`] action will run in. -#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Reflect)] -pub enum ConcurrentMode { - Race, - Join, -} - -/// [`ActionBuilder`] for the [`Concurrently`] component. -#[derive(Debug, Reflect)] -pub struct ConcurrentlyBuilder { - mode: ConcurrentMode, - #[reflect(ignore)] - actions: Vec>, - action_labels: Vec, - label: Option, -} - -impl ConcurrentlyBuilder { - pub fn label>(mut self, label: S) -> Self { - self.label = Some(label.into()); - self - } - - pub fn push(mut self, action_builder: impl ActionBuilder + 'static) -> Self { - if let Some(label) = action_builder.label() { - self.action_labels.push(label.into()); - } else { - self.action_labels.push("Unnamed Action".into()); - } - self.actions.push(Arc::new(action_builder)); - self - } - - pub fn mode(mut self, mode: ConcurrentMode) -> Self { - self.mode = mode; - self - } -} - -impl ActionBuilder for ConcurrentlyBuilder { - fn label(&self) -> Option<&str> { - self.label.as_deref() - } - - fn build(&self, cmd: &mut Commands, action: Entity, actor: Entity) { - let children: Vec = self - .actions - .iter() - .map(|action| spawn_action(action.as_ref(), cmd, actor)) - .collect(); - cmd.entity(action) - .insert(Name::new("Concurrent Action")) - .add_children(&children[..]) - .insert(Concurrently { - actions: children.into_iter().map(Action).collect(), - action_labels: self.action_labels.clone(), - mode: self.mode, - }); - } -} - -/// Composite Action that executes a number of Actions concurrently. -#[derive(Component, Debug, Reflect)] -pub struct Concurrently { - mode: ConcurrentMode, - actions: Vec, - action_labels: Vec, -} - -impl Concurrently { - pub fn build() -> ConcurrentlyBuilder { - ConcurrentlyBuilder { - actions: Vec::new(), - action_labels: Vec::new(), - mode: ConcurrentMode::Join, - label: None, - } - } -} - -/// System that executes [`Concurrently`] Actions. -pub fn concurrent_system( - concurrent_q: Query<(Entity, &Concurrently, &ActionSpan)>, - mut states_q: Query<&mut ActionState>, -) { - use ActionState::*; - for (seq_ent, concurrent_action, _span) in concurrent_q.iter() { - let current_state = states_q - .get_mut(seq_ent) - .expect("Failed to find action state for concurrent sequence entity") - .clone(); - #[cfg(feature = "trace")] - let _guard = _span.span.enter(); - - match current_state { - Requested => { - #[cfg(feature = "trace")] - trace!( - "Initializing Concurrently action with {} children.", - concurrent_action.actions.len() - ); - let mut current_state = states_q.get_mut(seq_ent).expect(ACTION_STATE_ERROR); - *current_state = Executing; - for action in concurrent_action.actions.iter() { - let child_ent = action.entity(); - let mut child_state = states_q.get_mut(child_ent).expect(ACTION_STATE_ERROR); - *child_state = Requested; - } - } - Executing => match concurrent_action.mode { - ConcurrentMode::Join => { - let mut all_success = true; - let mut failed_idx = None; - for (idx, action) in concurrent_action.actions.iter().enumerate() { - let child_ent = action.entity(); - let mut child_state = - states_q.get_mut(child_ent).expect(ACTION_STATE_ERROR); - match *child_state { - Failure => { - failed_idx = Some(idx); - all_success = false; - #[cfg(feature = "trace")] - trace!( - "Join action has failed. Cancelling all other actions that haven't completed yet." - ); - } - Success => {} - _ => { - all_success = false; - if failed_idx.is_some() { - *child_state = Cancelled; - } - } - } - } - if all_success { - *states_q.get_mut(seq_ent).expect(ACTION_STATE_ERROR) = Success; - } else if let Some(idx) = failed_idx { - for action in concurrent_action.actions.iter().take(idx) { - let child_ent = action.entity(); - let mut child_state = - states_q.get_mut(child_ent).expect(ACTION_STATE_ERROR); - if !matches!(*child_state, Failure | Success) { - *child_state = Cancelled; - } - } - *states_q.get_mut(seq_ent).expect(ACTION_STATE_ERROR) = Failure; - } - } - ConcurrentMode::Race => { - let mut all_failure = true; - let mut succeed_idx = None; - for (idx, action) in concurrent_action.actions.iter().enumerate() { - let child_ent = action.entity(); - let mut child_state = - states_q.get_mut(child_ent).expect(ACTION_STATE_ERROR); - match *child_state { - Failure => {} - Success => { - succeed_idx = Some(idx); - all_failure = false; - #[cfg(feature = "trace")] - trace!( - "Race action has succeeded. Cancelling all other actions that haven't completed yet." - ); - } - _ => { - all_failure = false; - if succeed_idx.is_some() { - *child_state = Cancelled; - } - } - } - } - if all_failure { - *states_q.get_mut(seq_ent).expect(ACTION_STATE_ERROR) = Failure; - } else if let Some(idx) = succeed_idx { - for action in concurrent_action.actions.iter().take(idx) { - let child_ent = action.entity(); - let mut child_state = - states_q.get_mut(child_ent).expect(ACTION_STATE_ERROR); - if !matches!(*child_state, Failure | Success) { - *child_state = Cancelled; - } - } - *states_q.get_mut(seq_ent).expect(ACTION_STATE_ERROR) = Success; - } - } - }, - Cancelled => { - let mut all_done = true; - let mut any_failed = false; - let mut any_success = false; - for action in concurrent_action.actions.iter() { - let child_ent = action.entity(); - let mut child_state = states_q.get_mut(child_ent).expect(ACTION_STATE_ERROR); - match *child_state { - Init => {} - Success => any_success = true, - Failure => any_failed = true, - _ => { - all_done = false; - *child_state = Cancelled; - } - } - } - if all_done { - let mut state_var = states_q.get_mut(seq_ent).expect(ACTION_STATE_ERROR); - match concurrent_action.mode { - ConcurrentMode::Race => { - if any_success { - #[cfg(feature = "trace")] - trace!("Race action has succeeded due to succeeded children."); - *state_var = Success; - } else { - #[cfg(feature = "trace")] - trace!("No race children has completed Successfully."); - *state_var = Failure; - } - } - ConcurrentMode::Join => { - if any_failed { - #[cfg(feature = "trace")] - trace!("Join action has failed due to failed children."); - *state_var = Failure; - } else { - #[cfg(feature = "trace")] - trace!("All Join children have completed Successfully."); - *state_var = Success; - } - } - } - } - } - Init | Success | Failure => {} - } - } -} diff --git a/crates/systems/ai/src/core/choices.rs b/crates/systems/ai/src/core/choices.rs deleted file mode 100644 index 0909c09..0000000 --- a/crates/systems/ai/src/core/choices.rs +++ /dev/null @@ -1,60 +0,0 @@ -use std::sync::Arc; - -use bevy::prelude::*; - -use crate::core::{ - actions::{ActionBuilder, ActionBuilderWrapper}, - scorers::{self, Score, ScorerBuilder}, - thinkers::Scorer, -}; - -/// Contains different types of Considerations and Actions -#[derive(Debug, Clone, Reflect)] -#[reflect(from_reflect = false)] -pub struct Choice { - pub(crate) scorer: Scorer, - #[reflect(ignore)] - pub(crate) action: ActionBuilderWrapper, - pub(crate) action_label: Option, -} - -impl Choice { - pub fn calculate(&self, scores: &Query<&Score>) -> f32 { - scores - .get(self.scorer.0) - .expect("Failed to find score component for choice scorer") - .0 - } -} - -/// Builds a new [`Choice`]. -#[derive(Clone, Debug, Reflect)] -#[reflect(from_reflect = false)] -pub struct ChoiceBuilder { - when_label: Option, - #[reflect(ignore)] - pub when: Arc, - then_label: Option, - #[reflect(ignore)] - pub then: Arc, -} -impl ChoiceBuilder { - pub fn new(scorer: Arc, action: Arc) -> Self { - Self { - when_label: scorer.label().map(|s| s.into()), - when: scorer, - then_label: action.label().map(|s| s.into()), - then: action, - } - } - - pub fn build(&self, cmd: &mut Commands, actor: Entity, parent: Entity) -> Choice { - let scorer_ent = scorers::spawn_scorer(&*self.when, cmd, actor); - cmd.entity(parent).add_child(scorer_ent); - Choice { - scorer: Scorer(scorer_ent), - action_label: self.then.label().map(|s| s.into()), - action: ActionBuilderWrapper::new(self.then.clone()), - } - } -} diff --git a/crates/systems/ai/src/core/evaluators.rs b/crates/systems/ai/src/core/evaluators.rs deleted file mode 100644 index 06e9eac..0000000 --- a/crates/systems/ai/src/core/evaluators.rs +++ /dev/null @@ -1,166 +0,0 @@ -use bevy::prelude::*; - -/** -Trait that any evaluators must implement. Must return an `f32` value between `0.0..=100.0`. - */ -#[reflect_trait] -pub trait Evaluator: std::fmt::Debug + Sync + Send { - fn evaluate(&self, value: f32) -> f32; -} - -/** -[`Evaluator`] for linear values. That is, there's no curve to the value mapping. - */ -#[derive(Debug, Clone, Reflect)] -pub struct LinearEvaluator { - xa: f32, - ya: f32, - yb: f32, - dy_over_dx: f32, -} - -impl LinearEvaluator { - pub fn new() -> Self { - Self::new_full(0.0, 0.0, 1.0, 1.0) - } - pub fn new_inversed() -> Self { - Self::new_ranged(1.0, 0.0) - } - pub fn new_ranged(min: f32, max: f32) -> Self { - Self::new_full(min, 0.0, max, 1.0) - } - fn new_full(xa: f32, ya: f32, xb: f32, yb: f32) -> Self { - Self { - xa, - ya, - yb, - dy_over_dx: (yb - ya) / (xb - xa), - } - } -} - -impl Default for LinearEvaluator { - fn default() -> Self { - Self::new() - } -} - -impl Evaluator for LinearEvaluator { - fn evaluate(&self, value: f32) -> f32 { - clamp( - self.ya + self.dy_over_dx * (value - self.xa), - self.ya, - self.yb, - ) - } -} - -/** -[`Evaluator`] with an exponent curve. The value will grow according to its `power` parameter. - */ -#[derive(Debug, Clone, Reflect)] -pub struct PowerEvaluator { - xa: f32, - ya: f32, - xb: f32, - power: f32, - dy: f32, -} - -impl PowerEvaluator { - pub fn new(power: f32) -> Self { - Self::new_full(power, 0.0, 0.0, 1.0, 1.0) - } - pub fn new_ranged(power: f32, min: f32, max: f32) -> Self { - Self::new_full(power, min, 0.0, max, 1.0) - } - fn new_full(power: f32, xa: f32, ya: f32, xb: f32, yb: f32) -> Self { - Self { - power: clamp(power, 0.0, 10000.0), - dy: yb - ya, - xa, - ya, - xb, - } - } -} - -impl Default for PowerEvaluator { - fn default() -> Self { - Self::new(2.0) - } -} - -impl Evaluator for PowerEvaluator { - fn evaluate(&self, value: f32) -> f32 { - let cx = clamp(value, self.xa, self.xb); - self.dy * ((cx - self.xa) / (self.xb - self.xa)).powf(self.power) + self.ya - } -} - -/** -[`Evaluator`] with a "Sigmoid", or "S-like" curve. - */ -#[derive(Debug, Clone, Reflect)] -pub struct SigmoidEvaluator { - xa: f32, - xb: f32, - ya: f32, - yb: f32, - k: f32, - two_over_dx: f32, - x_mean: f32, - y_mean: f32, - dy_over_two: f32, - one_minus_k: f32, -} - -impl SigmoidEvaluator { - pub fn new(k: f32) -> Self { - Self::new_full(k, 0.0, 0.0, 1.0, 1.0) - } - - pub fn new_ranged(k: f32, min: f32, max: f32) -> Self { - Self::new_full(k, min, 0.0, max, 1.0) - } - - fn new_full(k: f32, xa: f32, ya: f32, xb: f32, yb: f32) -> Self { - let k = clamp(k, -0.99999, 0.99999); - Self { - xa, - xb, - ya, - yb, - two_over_dx: (2.0 / (xb - ya)).abs(), - x_mean: (xa + xb) / 2.0, - y_mean: (ya + yb) / 2.0, - dy_over_two: (yb - ya) / 2.0, - one_minus_k: 1.0 - k, - k, - } - } -} - -impl Evaluator for SigmoidEvaluator { - fn evaluate(&self, x: f32) -> f32 { - let cx_minus_x_mean = clamp(x, self.xa, self.xb) - self.x_mean; - let numerator = self.two_over_dx * cx_minus_x_mean * self.one_minus_k; - let denominator = self.k * (1.0 - 2.0 * (self.two_over_dx * cx_minus_x_mean)).abs() + 1.0; - clamp( - self.dy_over_two * (numerator / denominator) + self.y_mean, - self.ya, - self.yb, - ) - } -} - -impl Default for SigmoidEvaluator { - fn default() -> Self { - Self::new(-0.5) - } -} - -pub(crate) fn clamp(val: T, min: T, max: T) -> T { - let val = if val > max { max } else { val }; - if val < min { min } else { val } -} diff --git a/crates/systems/ai/src/core/measures.rs b/crates/systems/ai/src/core/measures.rs deleted file mode 100644 index 009366f..0000000 --- a/crates/systems/ai/src/core/measures.rs +++ /dev/null @@ -1,77 +0,0 @@ -//! * A series of -//! [Measures](https://en.wikipedia.org/wiki/Measure_(mathematics)) used to -//! * weight score. - -use bevy::prelude::*; - -use crate::prelude::Score; - -/// A Measure trait describes a way to combine scores together. -#[reflect_trait] -pub trait Measure: std::fmt::Debug + Sync + Send { - /// Calculates a score from the child scores - fn calculate(&self, inputs: Vec<(&Score, f32)>) -> f32; -} - -/// A measure that adds all the elements together and multiplies them by the -/// weight. -#[derive(Debug, Clone, Reflect)] -pub struct WeightedSum; - -impl Measure for WeightedSum { - fn calculate(&self, scores: Vec<(&Score, f32)>) -> f32 { - scores - .iter() - .fold(0f32, |acc, (score, weight)| acc + score.0 * weight) - } -} - -/// A measure that multiplies all the elements together. -#[derive(Debug, Clone, Reflect)] -pub struct WeightedProduct; - -impl Measure for WeightedProduct { - fn calculate(&self, scores: Vec<(&Score, f32)>) -> f32 { - if scores.is_empty() { - return 0.0; - } - - scores - .iter() - .fold(1.0f32, |acc, (score, weight)| acc * score.0 * weight) - } -} - -/// A measure that returns the max of the weighted child scares based on the -/// one-dimensional (Chebychev -/// Distance)[https://en.wikipedia.org/wiki/Chebyshev_distance]. -#[derive(Debug, Clone, Reflect)] -pub struct ChebyshevDistance; - -impl Measure for ChebyshevDistance { - fn calculate(&self, scores: Vec<(&Score, f32)>) -> f32 { - scores - .iter() - .fold(0f32, |best, (score, weight)| (score.0 * weight).max(best)) - } -} - -/// The default measure which uses a weight to provide an intuitive curve. -#[derive(Debug, Clone, Default, Reflect)] -pub struct WeightedMeasure; - -impl Measure for WeightedMeasure { - fn calculate(&self, scores: Vec<(&Score, f32)>) -> f32 { - let wsum: f32 = scores.iter().map(|(_score, weight)| weight).sum(); - - if wsum == 0.0 { - 0.0 - } else { - scores - .iter() - .map(|(score, weight)| weight / wsum * score.get().powf(2.0)) - .sum::() - .powf(1.0 / 2.0) - } - } -} diff --git a/crates/systems/ai/src/core/mod.rs b/crates/systems/ai/src/core/mod.rs deleted file mode 100644 index 488d7e8..0000000 --- a/crates/systems/ai/src/core/mod.rs +++ /dev/null @@ -1,154 +0,0 @@ -//! Core AI module for LP -//! -//! Provides a utility AI system with composable scorers, actions, and thinkers -//! for sophisticated agent behavior in dynamic ecosystems. - -pub mod actions; -pub mod choices; -pub mod evaluators; -pub mod measures; -pub mod pickers; -pub mod scorers; -pub mod thinkers; - -/// Prelude for the core AI module. -/// -/// This includes the most common types you'll need when working with LP's AI system. -/// Use with `use crate::systems::ai::core::prelude::*;` -pub mod prelude { - // Actions (ActionBuilder and ActionState are in actions, but Action is in thinkers) - pub use crate::core::actions::{ - ActionBuilder, ActionState, ConcurrentMode, Concurrently, Steps, - }; - - // Scorers - pub use crate::core::scorers::{ - AllOrNothing, EvaluatingScorer, FixedScore, MeasuredScorer, ProductOfScorers, Score, - ScorerBuilder, SumOfScorers, WinningScorer, - }; - - // Thinkers (includes Action, Actor, etc.) - pub use crate::core::thinkers::{ - Action, ActionSpan, Actor, HasThinker, Scorer, ScorerSpan, Thinker, ThinkerBuilder, - }; - - // Evaluators - pub use crate::core::evaluators::{ - Evaluator, LinearEvaluator, PowerEvaluator, SigmoidEvaluator, - }; - - // Measures - pub use crate::core::measures::{ChebyshevDistance, Measure, WeightedProduct, WeightedSum}; - - // Pickers - pub use crate::core::pickers::{FirstToScore, Highest, HighestToScore, Picker}; - - // Choices - pub use crate::core::choices::{Choice, ChoiceBuilder}; -} - -use bevy::{ - ecs::{intern::Interned, schedule::ScheduleLabel}, - prelude::*, -}; - -/// Core plugin for LP's AI system. Provides utility AI functionality. -/// -/// Add this plugin to enable AI behaviors in your app. -/// -/// # Example -/// ```rust -/// # use bevy::prelude::*; -/// # use ai::prelude::*; -/// App::new() -/// .add_plugins(DefaultPlugins) -/// .add_plugins(LPAIPlugin::new(PreUpdate)) -/// .run(); -/// ``` -#[derive(Debug, Clone, Reflect)] -#[reflect(from_reflect = false)] -pub struct LPAIPlugin { - #[reflect(ignore)] - schedule: Interned, - #[reflect(ignore)] - cleanup_schedule: Interned, -} - -impl Default for LPAIPlugin { - fn default() -> Self { - Self::new(Update) - } -} - -impl LPAIPlugin { - /// Create the AI plugin which runs in the specified schedule - pub fn new(schedule: impl ScheduleLabel) -> Self { - Self { - schedule: schedule.intern(), - cleanup_schedule: Last.intern(), - } - } - - /// Set the schedule for cleanup tasks (default: Last) - pub fn with_cleanup_schedule(mut self, cleanup_schedule: impl ScheduleLabel) -> Self { - self.cleanup_schedule = cleanup_schedule.intern(); - self - } -} - -impl Plugin for LPAIPlugin { - fn build(&self, app: &mut App) { - app.configure_sets( - self.schedule.intern(), - (AISet::Scorers, AISet::Thinkers, AISet::Actions).chain(), - ) - .configure_sets(self.cleanup_schedule.intern(), AISet::Cleanup) - // Add scorer systems - .add_systems( - self.schedule.intern(), - ( - scorers::fixed_score_system, - scorers::measured_scorers_system, - scorers::all_or_nothing_system, - scorers::sum_of_scorers_system, - scorers::product_of_scorers_system, - scorers::winning_scorer_system, - scorers::evaluating_scorer_system, - ) - .in_set(AISet::Scorers), - ) - // Add thinker systems - .add_systems( - self.schedule.intern(), - thinkers::thinker_system.in_set(AISet::Thinkers), - ) - // Add action systems - .add_systems( - self.schedule.intern(), - (actions::steps_system, actions::concurrent_system).in_set(AISet::Actions), - ) - // Add cleanup systems - .add_systems( - self.cleanup_schedule.intern(), - ( - thinkers::thinker_component_attach_system, - thinkers::thinker_component_detach_system, - thinkers::actor_gone_cleanup, - ) - .in_set(AISet::Cleanup), - ); - } -} - -/// System sets for organizing AI-related systems -#[derive(Clone, Debug, Hash, Eq, PartialEq, SystemSet, Reflect)] -pub enum AISet { - /// Scorers evaluate world state - Scorers, - /// Thinkers make decisions based on scores - Thinkers, - /// Actions execute the chosen behaviors - Actions, - /// Cleanup tasks run last - Cleanup, -} diff --git a/crates/systems/ai/src/core/pickers.rs b/crates/systems/ai/src/core/pickers.rs deleted file mode 100644 index 383f1f6..0000000 --- a/crates/systems/ai/src/core/pickers.rs +++ /dev/null @@ -1,127 +0,0 @@ -//! Pickers are used by Thinkers to determine which of its Scorers will "win". - -use bevy::prelude::*; - -use crate::core::{choices::Choice, scorers::Score}; - -/// Required trait for Pickers. A Picker is given a slice of choices and a -/// query that can be passed into `Choice::calculate`. -/// -/// Implementations of `pick` must return `Some(Choice)` for the `Choice` that -/// was picked, or `None`. -#[reflect_trait] -pub trait Picker: std::fmt::Debug + Sync + Send { - fn pick<'a>(&self, choices: &'a [Choice], scores: &Query<&Score>) -> Option<&'a Choice>; -} - -/// Picker that chooses the first `Choice` with a [`Score`] higher than its -/// configured `threshold`. -/// -/// ### Example -/// -/// ``` -/// # use ai::prelude::*; -/// # fn main() { -/// Thinker::build() -/// .picker(FirstToScore::new(0.8)) -/// // .when(...) -/// # ; -/// # } -/// ``` -#[derive(Debug, Clone, Default)] -pub struct FirstToScore { - pub threshold: f32, -} - -impl FirstToScore { - pub fn new(threshold: f32) -> Self { - Self { threshold } - } -} - -impl Picker for FirstToScore { - fn pick<'a>(&self, choices: &'a [Choice], scores: &Query<&Score>) -> Option<&'a Choice> { - for choice in choices { - let value = choice.calculate(scores); - if value >= self.threshold { - return Some(choice); - } - } - None - } -} - -/// Picker that chooses the `Choice` with the highest non-zero [`Score`], and the first highest in case of a tie. -/// -/// ### Example -/// -/// ``` -/// # use ai::prelude::*; -/// # fn main() { -/// Thinker::build() -/// .picker(Highest) -/// // .when(...) -/// # ; -/// # } -/// ``` -#[derive(Debug, Clone, Default)] -pub struct Highest; - -impl Picker for Highest { - fn pick<'a>(&self, choices: &'a [Choice], scores: &Query<&Score>) -> Option<&'a Choice> { - let mut max_score = 0f32; - - choices.iter().fold(None, |acc, choice| { - let score = choice.calculate(scores); - - if score <= max_score || score <= 0.0 { - return acc; - } - - max_score = score; - Some(choice) - }) - } -} - -/// Picker that chooses the highest `Choice` with a [`Score`] higher than its -/// configured `threshold`. -/// -/// ### Example -/// -/// ``` -/// # use ai::prelude::*; -/// # fn main() { -/// Thinker::build() -/// .picker(HighestToScore::new(0.8)) -/// // .when(...) -/// # ; -/// # } -/// ``` -#[derive(Debug, Clone, Default)] -pub struct HighestToScore { - pub threshold: f32, -} - -impl HighestToScore { - pub fn new(threshold: f32) -> Self { - Self { threshold } - } -} - -impl Picker for HighestToScore { - fn pick<'a>(&self, choices: &'a [Choice], scores: &Query<&Score>) -> Option<&'a Choice> { - let mut highest_score = 0f32; - - choices.iter().fold(None, |acc, choice| { - let score = choice.calculate(scores); - - if score <= self.threshold || score <= highest_score { - return acc; - } - - highest_score = score; - Some(choice) - }) - } -} diff --git a/crates/systems/ai/src/core/scorers.rs b/crates/systems/ai/src/core/scorers.rs deleted file mode 100644 index f055cd8..0000000 --- a/crates/systems/ai/src/core/scorers.rs +++ /dev/null @@ -1,915 +0,0 @@ -//! Scorers look at the world and boil down arbitrary characteristics into a -//! range of 0.0..=1.0. This module includes the ScorerBuilder trait and some -//! built-in Composite Scorers. - -use std::{cmp::Ordering, sync::Arc}; - -use bevy::prelude::*; - -use crate::core::{ - evaluators::Evaluator, - measures::{Measure, WeightedMeasure}, - thinkers::{Actor, Scorer, ScorerSpan}, -}; - -/// Score value between `0.0..=1.0` associated with a Scorer. -/// -/// This type represents normalized utility scores used throughout the AI system. -/// It provides math operations for combining and transforming scores. -#[derive(Clone, Copy, Component, Debug, Default, Reflect, PartialEq, PartialOrd)] -pub struct Score(pub(crate) f32); - -impl Score { - /// Common score constants - pub const ZERO: Self = Self(0.0); - pub const HALF: Self = Self(0.5); - pub const MAX: Self = Self(1.0); - - /// Create a new score, clamping to valid range `0.0..=1.0` - pub fn new(value: f32) -> Self { - Self(value.clamp(0.0, 1.0)) - } - - /// Returns the `Score`'s current value. - pub fn get(&self) -> f32 { - self.0 - } - - /// Get the raw score value (alias for `get()`) - pub fn value(&self) -> f32 { - self.0 - } - - /// Set the `Score`'s value. - /// - /// Values outside `0.0..=1.0` will be automatically clamped to the valid range. - pub fn set(&mut self, value: f32) { - self.0 = value.clamp(0.0, 1.0); - } - - /// Set the `Score`'s value. Allows values outside the range `0.0..=1.0` - /// WARNING: `Scorer`s are significantly harder to compose when there - /// isn't a set scale. Avoid using unless it's not feasible to rescale - /// and use `set` instead. - pub fn set_unchecked(&mut self, value: f32) { - self.0 = value; - } - - /// Helper function for clamping trait values to valid range - pub fn clamp_trait_value(value: f32) -> f32 { - value.clamp(0.0, 1.0) - } - - /// Normalize a collection of scores to sum to 1.0 - pub fn normalize_scores(scores: &mut [Score]) { - let total: f32 = scores.iter().map(|score| score.value()).sum(); - if total > 0.0 { - for score in scores.iter_mut() { - *score = Score::new(score.value() / total); - } - } else { - // If all scores are 0, distribute evenly - let equal_value = if scores.is_empty() { - 0.0 - } else { - 1.0 / scores.len() as f32 - }; - for score in scores.iter_mut() { - *score = Score::new(equal_value); - } - } - } - - /// Multiply the score by a factor (adjusts importance) - pub fn multiply_by_factor(&self, factor: f32) -> Self { - Self::new(self.0 * factor) - } - - /// Combine two scores with AND logic (both must be true) - /// Returns lower values as both scores must be high - pub fn and_with(&self, other: &Self) -> Self { - Self::new(self.0 * other.0) - } - - /// Blend two scores with custom importance weights - /// weight_a and weight_b should ideally sum to 1.0 - pub fn blend_weighted(&self, other: &Self, weight_a: f32, weight_b: f32) -> Self { - Self::new(self.0 * weight_a + other.0 * weight_b) - } - - /// Combine scores with OR logic (either can be true) - /// P(A or B) = P(A) + P(B) - P(A and B) - pub fn or_with(&self, other: &Self) -> Self { - Self::new(self.0 + other.0 - (self.0 * other.0)) - } - - /// Get the opposite score (1 - score) - /// Useful for negating or inverting priorities - pub fn opposite(&self) -> Self { - Self::new(1.0 - self.0) - } - - /// Perform weighted random selection between multiple options - pub fn weighted_select( - options: &[(T, Score)], - rng: &mut R, - ) -> Option { - let total_weight: f32 = options.iter().map(|(_, score)| score.value()).sum(); - if total_weight <= 0.0 || options.is_empty() { - return None; - } - let random_point = rng.random_range(0.0..total_weight); - let mut cumulative_weight = 0.0; - for (option, score) in options { - cumulative_weight += score.value(); - if random_point <= cumulative_weight { - return Some(option.clone()); - } - } - // Fallback to last option (shouldn't happen with proper weights) - options.last().map(|(opt, _)| opt.clone()) - } -} - -/// Trait for building scorer components. Must implement `build()`. -#[reflect_trait] -pub trait ScorerBuilder: std::fmt::Debug + Sync + Send { - /// MUST insert your concrete Scorer component into the Scorer [`Entity`], - /// using `cmd`. You _may_ use `actor`, but it's perfectly normal to just - /// ignore it. - /// - /// In most cases, your `ScorerBuilder` and `Scorer` can be the same type. - /// The only requirement is that your struct implements `Debug`, - /// `Component, `Clone`. You can then use the derive macro `ScorerBuilder` - /// to turn your struct into a `ScorerBuilder` - /// - /// ### Example - /// - /// Using the derive macro (the easy way): - /// - /// ``` - /// # use bevy::prelude::*; - /// # use ai::prelude::*; - /// #[derive(Debug, Clone, Component, ScorerBuilder)] - /// #[scorer_label = "MyScorerLabel"] // Optional. Defaults to type name. - /// struct MyScorer; - /// ``` - /// - /// Implementing it manually: - /// - /// ``` - /// # use bevy::prelude::*; - /// # use ai::prelude::*; - /// #[derive(Debug)] - /// struct MyBuilder; - /// #[derive(Debug, Component)] - /// struct MyScorer; - /// - /// impl ScorerBuilder for MyBuilder { - /// fn build(&self, cmd: &mut Commands, scorer: Entity, _actor: Entity) { - /// cmd.entity(scorer).insert(MyScorer); - /// } - /// } - /// ``` - fn build(&self, cmd: &mut Commands, scorer: Entity, actor: Entity); - - /// Optional label for logging. - fn label(&self) -> Option<&str> { - None - } -} - -pub fn spawn_scorer( - builder: &T, - cmd: &mut Commands, - actor: Entity, -) -> Entity { - let scorer_ent = cmd.spawn_empty().id(); - let span = ScorerSpan::new(scorer_ent, ScorerBuilder::label(builder)); - let _guard = span.span().enter(); - debug!("New Scorer spawned."); - cmd.entity(scorer_ent) - .insert(Name::new("Scorer")) - .insert(Score::default()) - .insert(Actor(actor)); - builder.build(cmd, scorer_ent, actor); - std::mem::drop(_guard); - cmd.entity(scorer_ent).insert(span); - scorer_ent -} - -/// Scorer that always returns the same, fixed score. - -#[derive(Clone, Component, Debug, Reflect)] -pub struct FixedScore(pub f32); - -impl FixedScore { - pub fn build(score: f32) -> FixedScorerBuilder { - FixedScorerBuilder { score, label: None } - } -} - -pub fn fixed_score_system(mut query: Query<(&FixedScore, &mut Score, &ScorerSpan)>) { - for (FixedScore(fixed), mut score, _span) in query.iter_mut() { - #[cfg(feature = "trace")] - { - let _guard = _span.span().enter(); - trace!("FixedScore: {}", fixed); - } - score.set(*fixed); - } -} - -#[derive(Debug, Reflect)] -pub struct FixedScorerBuilder { - score: f32, - label: Option, -} - -impl FixedScorerBuilder { - pub fn label(mut self, label: impl Into) -> Self { - self.label = Some(label.into()); - self - } -} - -impl ScorerBuilder for FixedScorerBuilder { - fn build(&self, cmd: &mut Commands, scorer: Entity, _actor: Entity) { - cmd.entity(scorer).insert(FixedScore(self.score)); - } - - fn label(&self) -> Option<&str> { - self.label.as_deref().or(Some("FixedScore")) - } -} - -/// Composite Scorer: sums scores if all individual scores >= threshold. -#[derive(Component, Debug, Reflect)] -pub struct AllOrNothing { - threshold: f32, - scorers: Vec, -} - -impl AllOrNothing { - pub fn build(threshold: f32) -> AllOrNothingBuilder { - AllOrNothingBuilder { - threshold, - scorers: Vec::new(), - scorer_labels: Vec::new(), - label: None, - } - } -} - -pub fn all_or_nothing_system( - query: Query<(Entity, &AllOrNothing, &ScorerSpan)>, - mut scores: Query<&mut Score>, -) { - for ( - aon_ent, - AllOrNothing { - threshold, - scorers: children, - }, - _span, - ) in query.iter() - { - let mut sum = 0.0; - for Scorer(child) in children.iter() { - let score = scores.get_mut(*child).expect("score missing"); - if score.0 < *threshold { - sum = 0.0; - break; - } else { - sum += score.0; - } - } - let mut score = scores.get_mut(aon_ent).expect("score missing"); - score.set(crate::core::evaluators::clamp(sum, 0.0, 1.0)); - #[cfg(feature = "trace")] - { - let _guard = _span.span().enter(); - trace!("AllOrNothing score: {}", score.get()); - } - } -} - -#[derive(Debug, Clone, Reflect)] -pub struct AllOrNothingBuilder { - threshold: f32, - #[reflect(ignore)] - scorers: Vec>, - scorer_labels: Vec, - label: Option, -} - -impl AllOrNothingBuilder { - /// Add another Scorer to this [`ScorerBuilder`]. - pub fn push(mut self, scorer: impl ScorerBuilder + 'static) -> Self { - if let Some(label) = scorer.label() { - self.scorer_labels.push(label.into()); - } else { - self.scorer_labels.push("Unnamed Scorer".into()); - } - self.scorers.push(Arc::new(scorer)); - self - } - - pub fn label(mut self, label: impl AsRef) -> Self { - self.label = Some(label.as_ref().into()); - self - } -} - -impl ScorerBuilder for AllOrNothingBuilder { - fn label(&self) -> Option<&str> { - self.label.as_deref().or(Some("AllOrNothing")) - } - - fn build(&self, cmd: &mut Commands, scorer: Entity, actor: Entity) { - let scorers: Vec<_> = self - .scorers - .iter() - .map(|scorer| spawn_scorer(&**scorer, cmd, actor)) - .collect(); - cmd.entity(scorer) - .insert(Score::default()) - .add_children(&scorers[..]) - .insert(Name::new("Scorer")) - .insert(AllOrNothing { - threshold: self.threshold, - scorers: scorers.into_iter().map(Scorer).collect(), - }); - } -} - -/// Composite Scorer: sums all scores, returns 0 if total < threshold. -#[derive(Component, Debug, Reflect)] -pub struct SumOfScorers { - threshold: f32, - scorers: Vec, - scorer_labels: Vec, -} - -impl SumOfScorers { - pub fn build(threshold: f32) -> SumOfScorersBuilder { - SumOfScorersBuilder { - threshold, - scorers: Vec::new(), - scorer_labels: Vec::new(), - label: None, - } - } -} - -pub fn sum_of_scorers_system( - query: Query<(Entity, &SumOfScorers, &ScorerSpan)>, - mut scores: Query<&mut Score>, -) { - for ( - sos_ent, - SumOfScorers { - threshold, - scorers: children, - .. - }, - _span, - ) in query.iter() - { - let mut sum = 0.0; - for Scorer(child) in children.iter() { - let score = scores - .get_mut(*child) - .expect("Failed to find score component for SumOfScorers child"); - sum += score.0; - } - if sum < *threshold { - sum = 0.0; - } - let mut score = scores - .get_mut(sos_ent) - .expect("Failed to find score component for SumOfScorers entity"); - score.set(crate::core::evaluators::clamp(sum, 0.0, 1.0)); - #[cfg(feature = "trace")] - { - let _guard = _span.span().enter(); - trace!( - "SumOfScorers score: {}, from {} scores", - score.get(), - children.len() - ); - } - } -} - -#[derive(Debug, Clone, Reflect)] -pub struct SumOfScorersBuilder { - threshold: f32, - #[reflect(ignore)] - scorers: Vec>, - scorer_labels: Vec, - label: Option, -} - -impl SumOfScorersBuilder { - /// Add a new Scorer to this [`SumOfScorersBuilder`]. - pub fn push(mut self, scorer: impl ScorerBuilder + 'static) -> Self { - if let Some(label) = scorer.label() { - self.scorer_labels.push(label.into()); - } else { - self.scorer_labels.push("Unnamed Scorer".into()); - } - self.scorers.push(Arc::new(scorer)); - self - } - - /// Set a label for this Action. - pub fn label(mut self, label: impl AsRef) -> Self { - self.label = Some(label.as_ref().into()); - self - } -} - -impl ScorerBuilder for SumOfScorersBuilder { - fn label(&self) -> Option<&str> { - self.label.as_deref().or(Some("SumOfScorers")) - } - - #[allow(clippy::needless_collect)] - fn build(&self, cmd: &mut Commands, scorer: Entity, actor: Entity) { - let scorers: Vec<_> = self - .scorers - .iter() - .map(|scorer| spawn_scorer(&**scorer, cmd, actor)) - .collect(); - cmd.entity(scorer) - .add_children(&scorers[..]) - .insert(SumOfScorers { - threshold: self.threshold, - scorers: scorers.into_iter().map(Scorer).collect(), - scorer_labels: self.scorer_labels.clone(), - }); - } -} - -/// Composite Scorer: multiplies all scores with optional compensation factor. -#[derive(Component, Debug, Reflect)] -pub struct ProductOfScorers { - threshold: f32, - use_compensation: bool, - scorers: Vec, - scorer_labels: Vec, -} - -impl ProductOfScorers { - pub fn build(threshold: f32) -> ProductOfScorersBuilder { - ProductOfScorersBuilder { - threshold, - use_compensation: false, - scorers: Vec::new(), - scorer_labels: Vec::new(), - label: None, - } - } -} - -pub fn product_of_scorers_system( - query: Query<(Entity, &ProductOfScorers, &ScorerSpan)>, - mut scores: Query<&mut Score>, -) { - for ( - sos_ent, - ProductOfScorers { - threshold, - use_compensation, - scorers: children, - .. - }, - _span, - ) in query.iter() - { - let mut product = 1.0; - let mut num_scorers = 0; - - for Scorer(child) in children.iter() { - let score = scores - .get_mut(*child) - .expect("Failed to find score component for ProductOfScorers child"); - product *= score.0; - num_scorers += 1; - } - - // See for example - // http://www.gdcvault.com/play/1021848/Building-a-Better-Centaur-AI - if *use_compensation && product < 1.0 { - let mod_factor = 1.0 - 1.0 / (num_scorers as f32); - let makeup = (1.0 - product) * mod_factor; - product += makeup * product; - } - - if product < *threshold { - product = 0.0; - } - - let mut score = scores - .get_mut(sos_ent) - .expect("Failed to find score component for ProductOfScorers entity"); - score.set(product.clamp(0.0, 1.0)); - #[cfg(feature = "trace")] - { - let _guard = _span.span().enter(); - trace!( - "ProductOfScorers score: {}, from {} scores", - score.get(), - children.len() - ); - } - } -} - -#[derive(Debug, Clone)] -pub struct ProductOfScorersBuilder { - threshold: f32, - use_compensation: bool, - scorers: Vec>, - scorer_labels: Vec, - label: Option, -} - -impl ProductOfScorersBuilder { - /// To account for the fact that the total score will be reduced for - /// scores with more inputs, we can optionally apply a compensation factor - /// by calling this and passing `true` - pub fn use_compensation(mut self, use_compensation: bool) -> Self { - self.use_compensation = use_compensation; - self - } - - /// Add a new scorer to this [`ProductOfScorersBuilder`]. - pub fn push(mut self, scorer: impl ScorerBuilder + 'static) -> Self { - if let Some(label) = scorer.label() { - self.scorer_labels.push(label.into()); - } else { - self.scorer_labels.push("Unnamed Scorer".into()); - } - self.scorers.push(Arc::new(scorer)); - self - } - - /// Set a label for this Action. - pub fn label(mut self, label: impl AsRef) -> Self { - self.label = Some(label.as_ref().into()); - self - } -} - -impl ScorerBuilder for ProductOfScorersBuilder { - fn label(&self) -> Option<&str> { - self.label.as_deref().or(Some("ProductOfScorers")) - } - - #[allow(clippy::needless_collect)] - fn build(&self, cmd: &mut Commands, scorer: Entity, actor: Entity) { - let scorers: Vec<_> = self - .scorers - .iter() - .map(|scorer| spawn_scorer(&**scorer, cmd, actor)) - .collect(); - cmd.entity(scorer) - .add_children(&scorers[..]) - .insert(ProductOfScorers { - threshold: self.threshold, - use_compensation: self.use_compensation, - scorers: scorers.into_iter().map(Scorer).collect(), - scorer_labels: self.scorer_labels.clone(), - }); - } -} - -/// Composite Scorer: returns highest score if any score >= threshold. -#[derive(Component, Debug, Reflect)] -pub struct WinningScorer { - threshold: f32, - scorers: Vec, - scorer_labels: Vec, -} - -impl WinningScorer { - pub fn build(threshold: f32) -> WinningScorerBuilder { - WinningScorerBuilder { - threshold, - scorers: Vec::new(), - scorer_labels: Vec::new(), - label: None, - } - } -} - -pub fn winning_scorer_system( - mut query: Query<(Entity, &mut WinningScorer, &ScorerSpan)>, - mut scores: Query<&mut Score>, -) { - for (sos_ent, mut winning_scorer, _span) in query.iter_mut() { - let (threshold, children) = (winning_scorer.threshold, &mut winning_scorer.scorers); - let winning_score_or_zero = children - .iter() - .map(|Scorer(e)| { - scores - .get(*e) - .expect("Failed to find score component for WinningScorer child") - }) - .map(|score| score.get()) - .max_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal)) - .filter(|&score| score >= threshold) - .unwrap_or(0.0); - let mut score = scores - .get_mut(sos_ent) - .expect("Failed to find score component for WinningScorer entity"); - score.set(crate::core::evaluators::clamp( - winning_score_or_zero, - 0.0, - 1.0, - )); - #[cfg(feature = "trace")] - { - let _guard = _span.span().enter(); - trace!( - "WinningScorer score: {}, from {} scores", - score.get(), - children.len() - ); - } - } -} - -#[derive(Debug, Clone, Reflect)] -pub struct WinningScorerBuilder { - threshold: f32, - #[reflect(ignore)] - scorers: Vec>, - scorer_labels: Vec, - label: Option, -} - -impl WinningScorerBuilder { - /// Add another Scorer to this [`WinningScorerBuilder`]. - pub fn push(mut self, scorer: impl ScorerBuilder + 'static) -> Self { - if let Some(label) = scorer.label() { - self.scorer_labels.push(label.into()); - } else { - self.scorer_labels.push("Unnamed Scorer".into()) - } - self.scorers.push(Arc::new(scorer)); - self - } - - /// Set a label for this Action. - pub fn label(mut self, label: impl AsRef) -> Self { - self.label = Some(label.as_ref().into()); - self - } -} - -impl ScorerBuilder for WinningScorerBuilder { - fn label(&self) -> Option<&str> { - self.label.as_deref().or(Some("WinningScorer")) - } - - #[allow(clippy::needless_collect)] - fn build(&self, cmd: &mut Commands, scorer: Entity, actor: Entity) { - let scorers: Vec<_> = self - .scorers - .iter() - .map(|scorer| spawn_scorer(&**scorer, cmd, actor)) - .collect(); - cmd.entity(scorer) - .add_children(&scorers[..]) - .insert(WinningScorer { - threshold: self.threshold, - scorers: scorers.into_iter().map(Scorer).collect(), - scorer_labels: self.scorer_labels.clone(), - }); - } -} - -/// Applies an Evaluator to transform a single scorer's output. -#[derive(Component, Debug, Reflect)] -#[reflect(from_reflect = false)] -pub struct EvaluatingScorer { - scorer: Scorer, - evaluator_string: String, - #[reflect(ignore)] - evaluator: Arc, -} - -impl EvaluatingScorer { - pub fn build( - scorer: impl ScorerBuilder + 'static, - evaluator: impl Evaluator + 'static, - ) -> EvaluatingScorerBuilder { - EvaluatingScorerBuilder { - scorer_label: scorer.label().map(|s| s.into()), - evaluator: Arc::new(evaluator), - scorer: Arc::new(scorer), - label: None, - } - } -} - -pub fn evaluating_scorer_system( - query: Query<(Entity, &EvaluatingScorer, &ScorerSpan)>, - mut scores: Query<&mut Score>, -) { - for (sos_ent, eval_scorer, _span) in query.iter() { - // Get the inner score - let inner_score = scores - .get(eval_scorer.scorer.0) - .expect("Failed to find score component for TrustScorer inner scorer") - .get(); - // Get composite score - let mut score = scores - .get_mut(sos_ent) - .expect("Failed to find score component for TrustScorer entity"); - score.set(crate::core::evaluators::clamp( - eval_scorer.evaluator.evaluate(inner_score), - 0.0, - 1.0, - )); - #[cfg(feature = "trace")] - { - let _guard = _span.span().enter(); - trace!( - "EvaluatingScorer score: {}, from score: {}", - score.get(), - inner_score - ); - } - } -} - -#[derive(Debug, Reflect)] -#[reflect(from_reflect = false)] -pub struct EvaluatingScorerBuilder { - #[reflect(ignore)] - scorer: Arc, - scorer_label: Option, - #[reflect(ignore)] - evaluator: Arc, - label: Option, -} - -impl ScorerBuilder for EvaluatingScorerBuilder { - fn label(&self) -> Option<&str> { - self.label.as_deref().or(Some("EvaluatingScorer")) - } - - fn build(&self, cmd: &mut Commands, scorer: Entity, actor: Entity) { - let inner_scorer = spawn_scorer(&*self.scorer, cmd, actor); - let scorers = [inner_scorer]; - cmd.entity(scorer) - .add_children(&scorers[..]) - .insert(EvaluatingScorer { - evaluator: self.evaluator.clone(), - scorer: Scorer(inner_scorer), - evaluator_string: format!("{:#?}", self.evaluator), - }); - } -} - -/// Composite Scorer: combines scores using customizable Measure (default: weighted). -#[derive(Component, Debug, Reflect)] -#[reflect(from_reflect = false)] -pub struct MeasuredScorer { - threshold: f32, - #[reflect(ignore)] - measure: Arc, - measure_string: String, - scorers: Vec<(Scorer, f32)>, -} - -impl MeasuredScorer { - pub fn build(threshold: f32) -> MeasuredScorerBuilder { - MeasuredScorerBuilder { - threshold, - measure: Arc::new(WeightedMeasure), - measure_string: format!("{WeightedMeasure:#?}"), - scorers: Vec::new(), - scorer_labels: Vec::new(), - label: None, - } - } -} - -pub fn measured_scorers_system( - query: Query<(Entity, &MeasuredScorer, &ScorerSpan)>, - mut scores: Query<&mut Score>, -) { - for ( - sos_ent, - MeasuredScorer { - threshold, - measure, - scorers: children, - .. - }, - _span, - ) in query.iter() - { - let measured_score = measure.calculate( - children - .iter() - .map(|(scorer, weight)| { - ( - scores - .get(scorer.0) - .expect("Failed to find score component for WeightedScorer child"), - *weight, - ) - }) - .collect::>(), - ); - let mut score = scores - .get_mut(sos_ent) - .expect("Failed to find score component for WeightedScorer entity"); - - if measured_score < *threshold { - score.set(0.0); - } else { - score.set(measured_score.clamp(0.0, 1.0)); - } - #[cfg(feature = "trace")] - { - let _guard = _span.span().enter(); - trace!( - "MeasuredScorer score: {}, from {} scores", - score.get(), - children.len() - ); - } - } -} - -#[derive(Debug, Reflect)] -#[reflect(from_reflect = false)] -pub struct MeasuredScorerBuilder { - threshold: f32, - #[reflect(ignore)] - measure: Arc, - measure_string: String, - #[reflect(ignore)] - scorers: Vec<(Arc, f32)>, - scorer_labels: Vec, - label: Option, -} - -impl MeasuredScorerBuilder { - /// Sets the measure to be used to combine the child scorers - pub fn measure(mut self, measure: impl Measure + 'static) -> Self { - self.measure_string = format!("{measure:#?}"); - self.measure = Arc::new(measure); - self - } - - pub fn push(mut self, scorer: impl ScorerBuilder + 'static, weight: f32) -> Self { - if let Some(label) = scorer.label() { - self.scorer_labels.push(label.into()); - } else { - self.scorer_labels.push("Unnamed Scorer".into()); - } - self.scorers.push((Arc::new(scorer), weight)); - self - } - - /// Set a label for this ScorerBuilder. - pub fn label(mut self, label: impl AsRef) -> Self { - self.label = Some(label.as_ref().into()); - self - } -} - -impl ScorerBuilder for MeasuredScorerBuilder { - fn label(&self) -> Option<&str> { - self.label.as_deref().or(Some("MeasuredScorer")) - } - - #[allow(clippy::needless_collect)] - fn build(&self, cmd: &mut Commands, scorer: Entity, actor: Entity) { - let scorers: Vec<_> = self - .scorers - .iter() - .map(|(scorer, _)| spawn_scorer(&**scorer, cmd, actor)) - .collect(); - cmd.entity(scorer) - .add_children(&scorers[..]) - .insert(MeasuredScorer { - threshold: self.threshold, - measure: self.measure.clone(), - scorers: scorers - .into_iter() - .map(Scorer) - .zip(self.scorers.iter().map(|(_, weight)| *weight)) - .collect(), - measure_string: self.measure_string.clone(), - }); - } -} diff --git a/crates/systems/ai/src/core/thinkers.rs b/crates/systems/ai/src/core/thinkers.rs deleted file mode 100644 index 0c7d694..0000000 --- a/crates/systems/ai/src/core/thinkers.rs +++ /dev/null @@ -1,548 +0,0 @@ -//! Thinkers are the "brain" of an entity. You attach Scorers to it, and the -//! Thinker picks the right Action to run based on the resulting Scores. - -use std::{ - collections::VecDeque, - sync::Arc, - time::{Duration, Instant}, -}; - -use bevy::{ - log::{ - Level, - tracing::{Span, field, span}, - }, - prelude::*, -}; - -use crate::core::{ - actions::{self, ActionBuilder, ActionBuilderWrapper, ActionState}, - choices::{Choice, ChoiceBuilder}, - pickers::Picker, - scorers::{Score, ScorerBuilder}, -}; - -/// Wrapper for Actor entities. In terms of Scorers, Thinkers, and Actions, -/// this is the Entity actually performing the action. -#[derive(Debug, Clone, Component, Copy, Reflect)] -pub struct Actor(pub Entity); - -#[derive(Debug, Clone, Copy, Reflect)] -pub struct Action(pub Entity); - -impl Action { - pub fn entity(&self) -> Entity { - self.0 - } -} - -#[derive(Debug, Clone, Component)] -pub struct ActionSpan { - pub(crate) span: Span, -} - -impl ActionSpan { - pub(crate) fn new(action: Entity, label: Option<&str>) -> Self { - let span = span!( - Level::DEBUG, - "action", - ent = ?action, - label = field::Empty, - ); - if let Some(label) = label { - span.record("label", label); - } - Self { span } - } - - pub fn span(&self) -> &Span { - &self.span - } -} - -#[derive(Debug, Clone, Copy, Reflect)] -pub struct Scorer(pub Entity); - -#[derive(Debug, Clone, Component)] -pub struct ScorerSpan { - pub(crate) span: Span, -} - -impl ScorerSpan { - pub(crate) fn new(scorer: Entity, label: Option<&str>) -> Self { - let span = span!( - Level::DEBUG, - "scorer", - ent = ?scorer, - label = field::Empty, - ); - - if let Some(label) = label { - span.record("label", label); - } - Self { span } - } - - pub fn span(&self) -> &Span { - &self.span - } -} - -/// The "brains" behind this whole operation. A `Thinker` is what glues -/// together `Actions` and `Scorers` and shapes larger, intelligent-seeming -/// systems. -#[derive(Component, Debug, Reflect)] -#[reflect(from_reflect = false)] -pub struct Thinker { - #[reflect(ignore)] - picker: Arc, - #[reflect(ignore)] - otherwise: Option, - #[reflect(ignore)] - choices: Vec, - #[reflect(ignore)] - current_action: Option<(Action, ActionBuilderWrapper)>, - current_action_label: Option>, - #[reflect(ignore)] - span: Span, - #[reflect(ignore)] - scheduled_actions: VecDeque, -} - -impl Thinker { - /// Make a new [`ThinkerBuilder`]. - pub fn build() -> ThinkerBuilder { - ThinkerBuilder::new() - } - - pub fn schedule_action(&mut self, action: impl ActionBuilder + 'static) { - self.scheduled_actions - .push_back(ActionBuilderWrapper::new(Arc::new(action))); - } -} - -/// This is what you actually use to configure Thinker behavior. -#[derive(Component, Clone, Debug, Default)] -pub struct ThinkerBuilder { - picker: Option>, - otherwise: Option, - choices: Vec, - label: Option, -} - -impl ThinkerBuilder { - pub(crate) fn new() -> Self { - Self { - picker: None, - otherwise: None, - choices: Vec::new(), - label: None, - } - } - - /// Define a [`Picker`] for this Thinker. - pub fn picker(mut self, picker: impl Picker + 'static) -> Self { - self.picker = Some(Arc::new(picker)); - self - } - - /// Define an [`ActionBuilder`] and [`ScorerBuilder`] pair. - pub fn when( - mut self, - scorer: impl ScorerBuilder + 'static, - action: impl ActionBuilder + 'static, - ) -> Self { - self.choices - .push(ChoiceBuilder::new(Arc::new(scorer), Arc::new(action))); - self - } - - /// Default `Action` to execute if the `Picker` did not pick any choices. - pub fn otherwise(mut self, otherwise: impl ActionBuilder + 'static) -> Self { - self.otherwise = Some(ActionBuilderWrapper::new(Arc::new(otherwise))); - self - } - - /// Configures a label to use for the thinker when logging. - pub fn label(mut self, label: impl AsRef) -> Self { - self.label = Some(label.as_ref().to_string()); - self - } -} - -impl ActionBuilder for ThinkerBuilder { - fn build(&self, cmd: &mut Commands, action_ent: Entity, actor: Entity) { - let span = span!( - Level::DEBUG, - "thinker", - actor = ?actor, - ); - let _guard = span.enter(); - debug!("Spawning Thinker."); - let choices = self - .choices - .iter() - .map(|choice| choice.build(cmd, actor, action_ent)) - .collect(); - std::mem::drop(_guard); - cmd.entity(action_ent) - .insert(Thinker { - picker: self - .picker - .clone() - .expect("ThinkerBuilder must have a Picker"), - otherwise: self.otherwise.clone(), - choices, - current_action: None, - current_action_label: None, - span, - scheduled_actions: VecDeque::new(), - }) - .insert(Name::new("Thinker")) - .insert(ActionState::Requested); - } - - fn label(&self) -> Option<&str> { - self.label.as_deref() - } -} - -pub fn thinker_component_attach_system( - mut cmd: Commands, - q: Query<(Entity, &ThinkerBuilder), Without>, -) { - for (entity, thinker_builder) in q.iter() { - let thinker = actions::spawn_action(thinker_builder, &mut cmd, entity); - cmd.entity(entity).insert(HasThinker(thinker)); - } -} - -pub fn thinker_component_detach_system( - mut cmd: Commands, - q: Query<(Entity, &HasThinker), Without>, -) { - for (actor, HasThinker(thinker)) in q.iter() { - if let Ok(mut ent) = cmd.get_entity(*thinker) { - ent.despawn(); - } - cmd.entity(actor).remove::(); - } -} - -pub fn actor_gone_cleanup( - mut cmd: Commands, - actors: Query<&ThinkerBuilder>, - q: Query<(Entity, &Actor)>, -) { - for (child, Actor(actor)) in q.iter() { - if actors.get(*actor).is_err() { - if let Ok(mut ent) = cmd.get_entity(child) { - ent.despawn(); - } - } - } -} - -#[derive(Component, Debug, Reflect)] -pub struct HasThinker(Entity); - -impl HasThinker { - pub fn entity(&self) -> Entity { - self.0 - } -} - -pub struct ThinkerIterations { - index: usize, - max_duration: Duration, -} -impl ThinkerIterations { - pub fn new(max_duration: Duration) -> Self { - Self { - index: 0, - max_duration, - } - } -} -impl Default for ThinkerIterations { - fn default() -> Self { - Self::new(Duration::from_millis(10)) - } -} - -pub fn thinker_system( - mut cmd: Commands, - mut iterations: Local, - mut thinker_q: Query<(Entity, &Actor, &mut Thinker)>, - scores: Query<&Score>, - mut action_states: Query<&mut actions::ActionState>, - action_spans: Query<&ActionSpan>, - scorer_spans: Query<&ScorerSpan>, -) { - let start = Instant::now(); - for (thinker_ent, Actor(actor), mut thinker) in thinker_q.iter_mut().skip(iterations.index) { - iterations.index += 1; - - let thinker_state = action_states - .get_mut(thinker_ent) - .expect("Failed to find action state component for thinker") - .clone(); - - let thinker_span = thinker.span.clone(); - let _thinker_span_guard = thinker_span.enter(); - - match thinker_state { - ActionState::Init => { - let mut act_state = action_states - .get_mut(thinker_ent) - .expect("Failed to find action state component for thinker entity"); - debug!("Initializing thinker."); - *act_state = ActionState::Requested; - } - ActionState::Requested => { - let mut act_state = action_states - .get_mut(thinker_ent) - .expect("Failed to find action state component for thinker entity"); - debug!("Starting execution."); - *act_state = ActionState::Executing; - } - ActionState::Success | ActionState::Failure => {} - ActionState::Cancelled => { - debug!("Cleaning up."); - if let Some(current) = &mut thinker.current_action { - let action_span = action_spans - .get(current.0.0) - .expect("Failed to find action span for current action"); - debug!("Cancelling current action."); - let state = action_states - .get_mut(current.0.0) - .expect("Missing current action") - .clone(); - match state { - ActionState::Success | ActionState::Failure => { - debug!("Action already wrapped up."); - if let Ok(mut ent) = cmd.get_entity(current.0.0) { - ent.despawn(); - } - thinker.current_action = None; - } - ActionState::Cancelled => { - debug!("Already cancelled."); - } - _ => { - let mut state = - action_states.get_mut(current.0.0).expect("Missing action"); - debug!("Action still executing. Cancelling it."); - action_span.span.in_scope(|| { - debug!("Cancelling action."); - }); - *state = ActionState::Cancelled; - } - } - } else { - let mut act_state = action_states - .get_mut(thinker_ent) - .expect("Failed to find action state component for thinker entity"); - debug!("No current action. Completing as Success."); - *act_state = ActionState::Success; - } - } - ActionState::Executing => { - #[cfg(feature = "trace")] - trace!("Thinker is executing. Thinking..."); - if let Some(choice) = thinker.picker.pick(&thinker.choices, &scores) { - #[cfg(feature = "trace")] - trace!("Action picked. Executing picked action."); - let action = choice.action.clone(); - let scorer = choice.scorer; - let score = scores - .get(choice.scorer.0) - .expect("Failed to find score component for chosen scorer"); - exec_picked_action( - &mut cmd, - *actor, - &mut thinker, - &action, - &mut action_states, - &action_spans, - Some((&scorer, score)), - &scorer_spans, - true, - ); - } else if should_schedule_action(&mut thinker, &mut action_states) { - debug!("Spawning scheduled action."); - let action = thinker - .scheduled_actions - .pop_front() - .expect("we literally just checked if it was there."); - let new_action = actions::spawn_action(action.1.as_ref(), &mut cmd, *actor); - thinker.current_action = Some((Action(new_action), action.clone())); - thinker.current_action_label = Some(action.1.label().map(|s| s.into())); - } else if let Some(default_action_ent) = &thinker.otherwise { - let default_action_ent = default_action_ent.clone(); - exec_picked_action( - &mut cmd, - *actor, - &mut thinker, - &default_action_ent, - &mut action_states, - &action_spans, - None, - &scorer_spans, - false, - ); - } else if let Some((action_ent, _)) = &thinker.current_action { - let action_span = action_spans - .get(action_ent.0) - .expect("Failed to find action span for action entity"); - let _guard = action_span.span.enter(); - let mut curr_action_state = action_states - .get_mut(action_ent.0) - .expect("Missing current action"); - let previous_done = matches!( - *curr_action_state, - ActionState::Success | ActionState::Failure - ); - if previous_done { - debug!("Action completed. Despawning."); - if let Ok(mut ent) = cmd.get_entity(action_ent.0) { - ent.despawn(); - } - thinker.current_action = None; - } else if *curr_action_state == ActionState::Init { - *curr_action_state = ActionState::Requested; - } - } - } - } - if iterations.index % 500 == 0 && start.elapsed() > iterations.max_duration { - return; - } - } - iterations.index = 0; -} - -fn should_schedule_action( - thinker: &mut Mut, - states: &mut Query<&mut ActionState>, -) -> bool { - #[cfg(feature = "trace")] - let thinker_span = thinker.span.clone(); - #[cfg(feature = "trace")] - let _thinker_span_guard = thinker_span.enter(); - if thinker.scheduled_actions.is_empty() { - #[cfg(feature = "trace")] - trace!("No scheduled actions. Not scheduling anything."); - false - } else if let Some((action_ent, _)) = &mut thinker.current_action { - let curr_action_state = states - .get_mut(action_ent.0) - .expect("Missing current action"); - - let action_done = matches!( - *curr_action_state, - ActionState::Success | ActionState::Failure - ); - - #[cfg(feature = "trace")] - if action_done { - trace!("Current action is already done. Can schedule."); - } else { - trace!("Current action is still executing. Not scheduling anything."); - } - - action_done - } else { - #[cfg(feature = "trace")] - trace!("No current action actions. Can schedule."); - true - } -} - -#[allow(clippy::too_many_arguments)] -fn exec_picked_action( - cmd: &mut Commands, - actor: Entity, - thinker: &mut Mut, - picked_action: &ActionBuilderWrapper, - states: &mut Query<&mut ActionState>, - action_spans: &Query<&ActionSpan>, - scorer_info: Option<(&Scorer, &Score)>, - scorer_spans: &Query<&ScorerSpan>, - override_current: bool, -) { - let thinker_span = thinker.span.clone(); - let _thinker_span_guard = thinker_span.enter(); - if let Some((action_ent, ActionBuilderWrapper(current_id, _))) = &mut thinker.current_action { - let mut curr_action_state = states - .get_mut(action_ent.0) - .expect("Missing current action"); - let previous_done = matches!( - *curr_action_state, - ActionState::Success | ActionState::Failure - ); - let action_span = action_spans - .get(action_ent.0) - .expect("Failed to find action span for action entity"); - let _guard = action_span.span.enter(); - if (!Arc::ptr_eq(current_id, &picked_action.0) && override_current) || previous_done { - if !previous_done { - if override_current { - #[cfg(feature = "trace")] - trace!("Falling back to `otherwise` clause.",); - } else { - #[cfg(feature = "trace")] - trace!("Picked a different action than the current one.",); - } - } - match *curr_action_state { - ActionState::Executing | ActionState::Requested => { - debug!("Requesting cancellation."); - *curr_action_state = ActionState::Cancelled; - } - ActionState::Init | ActionState::Success | ActionState::Failure => { - debug!("Previous action completed. Despawning."); - if let Ok(mut ent) = cmd.get_entity(action_ent.0) { - ent.despawn(); - } - if let Some((Scorer(ent), score)) = scorer_info { - let scorer_span = scorer_spans - .get(*ent) - .expect("Failed to find scorer span for scorer entity"); - let _guard = scorer_span.span.enter(); - debug!("Winning score: {}", score.get()); - } - std::mem::drop(_guard); - debug!("Spawning next action"); - let new_action = - Action(actions::spawn_action(picked_action.1.as_ref(), cmd, actor)); - thinker.current_action = Some((new_action, picked_action.clone())); - thinker.current_action_label = Some(picked_action.1.label().map(|s| s.into())); - } - ActionState::Cancelled => { - #[cfg(feature = "trace")] - trace!("Cancellation already requested. Waiting."); - } - }; - } else if *curr_action_state == ActionState::Init { - *curr_action_state = ActionState::Requested; - } - #[cfg(feature = "trace")] - trace!("Continuing execution of current action.",) - } else { - #[cfg(feature = "trace")] - trace!("Falling back to `otherwise` clause.",); - - if let Some((Scorer(ent), score)) = scorer_info { - let scorer_span = scorer_spans - .get(*ent) - .expect("Failed to find scorer span for scorer entity"); - let _guard = scorer_span.span.enter(); - debug!("Winning score: {}", score.get()); - } - debug!("No current action. Spawning new."); - let new_action = actions::spawn_action(picked_action.1.as_ref(), cmd, actor); - thinker.current_action = Some((Action(new_action), picked_action.clone())); - thinker.current_action_label = Some(picked_action.1.label().map(|s| s.into())); - } -} diff --git a/crates/systems/ai/src/drives/needs.rs b/crates/systems/ai/src/drives/needs.rs index d3ba9a1..b38cb97 100644 --- a/crates/systems/ai/src/drives/needs.rs +++ b/crates/systems/ai/src/drives/needs.rs @@ -1,7 +1,8 @@ -use crate::core::scorers::Score; -use crate::prelude::*; use bevy::prelude::*; +use crate::Score; +use crate::prelude::*; + /// Universal need types that apply to all life forms /// These represent fundamental biological drives that emerge from physics and chemistry #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Reflect)] @@ -107,8 +108,7 @@ impl AIModule for Need { self.satisfaction = self.satisfaction.clamp(0.0, 1.0); } - fn utility(&self) -> Score { - // Return urgency as utility - self.urgency() + fn utility(&self) -> f32 { + self.urgency().value() } } diff --git a/crates/systems/ai/src/lib.rs b/crates/systems/ai/src/lib.rs index 6252717..4bddbd9 100644 --- a/crates/systems/ai/src/lib.rs +++ b/crates/systems/ai/src/lib.rs @@ -1,27 +1,36 @@ -pub mod core; +pub mod arbiter; pub mod drives; pub mod memory; pub mod personality; pub mod relationships; pub mod trackers; -/// Core AI plugin with utility-based decision making -pub use crate::core::LPAIPlugin; +use bevy::prelude::*; +use bevy::reflect::Reflect; + +/// Main plugin exposed by the AI crate. Currently it installs the utility arbiter. +#[derive(Default, Debug, Clone)] +pub struct LPAIPlugin; + +impl Plugin for LPAIPlugin { + fn build(&self, app: &mut App) { + app.add_plugins(arbiter::UtilityArbiterPlugin); + } +} /// Common AI types and plugins pub mod prelude { // Main plugins for easy access pub use crate::LPAIPlugin; + pub use crate::arbiter::prelude::*; pub use crate::drives::DrivesPlugin; pub use crate::personality::PersonalityPlugin; pub use crate::relationships::SocialPlugin; pub use crate::trackers::TrackerPlugin; - // Core interfaces - now directly from crate root - pub use crate::{AIModule, ActionExecutor}; + // Core interfaces + pub use crate::{AIModule, ActionExecutor, Score}; - // Re-export module preludes - pub use crate::core::prelude::*; pub use crate::drives::prelude::*; pub use crate::memory::prelude::*; pub use crate::personality::prelude::*; @@ -30,34 +39,55 @@ pub mod prelude { // Context-aware personality system pub use crate::personality::traits::{ - ContextAwareUtilities, update_collective_influence, update_context_aware_utilities, + ContextAwareUtilities, PersonalityContextInputs, update_collective_influence, + update_context_aware_utilities, }; } -use crate::core::scorers::Score; -use bevy::prelude::*; - -/// Base trait for all AI modules +/// Base trait for all AI modules. pub trait AIModule: Send + Sync { - /// Update the module's internal state - fn update(&mut self); + /// Update the module's internal state. + /// Default implementation does nothing - override if needed. + fn update(&mut self) {} + + /// Calculate the utility value of this module (0.0 - 1.0 range). + fn utility(&self) -> f32; +} + +/// Lightweight score helper (0.0 - 1.0) replacing the legacy Big-Brain type. +#[derive(Clone, Copy, Debug, Default, PartialEq, Reflect)] +#[reflect(PartialEq)] +pub struct Score(f32); + +impl Score { + pub const ZERO: Self = Self(0.0); + pub const HALF: Self = Self(0.5); + + pub fn new(value: f32) -> Self { + Self(value.clamp(0.0, 1.0)) + } + + pub fn value(&self) -> f32 { + self.0 + } - /// Calculate the utility value of this module - fn utility(&self) -> Score; + pub fn clamp_trait_value(value: f32) -> f32 { + value.clamp(0.0, 1.0) + } } -/// Trait for executing actions based on behavior decisions +/// Trait for executing actions based on behavior decisions. pub trait ActionExecutor { - /// Move toward a target position + /// Move toward a target position. fn move_toward(&mut self, target: Vec2, speed: f32) -> bool; - /// Perform an attack action + /// Perform an attack action. fn attack(&mut self, target: Option) -> bool; - /// Move away from a threat + /// Move away from a threat. fn flee_from(&mut self, threat: Vec2) -> bool; - /// Idle/rest at current position + /// Idle/rest at current position. fn idle(&mut self, duration: f32) -> bool; fn cleanup(&mut self); diff --git a/crates/systems/ai/src/memory/types.rs b/crates/systems/ai/src/memory/types.rs index 3f7c0e2..d1f363b 100644 --- a/crates/systems/ai/src/memory/types.rs +++ b/crates/systems/ai/src/memory/types.rs @@ -1,7 +1,8 @@ -use crate::core::scorers::Score; -use crate::prelude::*; use bevy::prelude::*; +use crate::Score; +use crate::prelude::*; + /// Memory timestamp (game ticks) pub type MemoryTimestamp = u64; @@ -42,14 +43,8 @@ impl MemoryEvent { } impl AIModule for MemoryEvent { - fn update(&mut self) { - // Memories don't need regular updates - // Could implement decay of importance over time if needed - } - - fn utility(&self) -> Score { - // Return importance as utility - self.importance + fn utility(&self) -> f32 { + self.importance.value() } } diff --git a/crates/systems/ai/src/personality/mod.rs b/crates/systems/ai/src/personality/mod.rs index ecb32a3..d4e3104 100644 --- a/crates/systems/ai/src/personality/mod.rs +++ b/crates/systems/ai/src/personality/mod.rs @@ -8,8 +8,12 @@ pub struct PersonalityPlugin; impl Plugin for PersonalityPlugin { fn build(&self, app: &mut App) { - app.register_type::() - .register_type::(); + app.init_resource::() + .register_type::() + .register_type::() + .register_type::() + .register_type::() + .register_type::(); } } @@ -18,5 +22,7 @@ impl Plugin for PersonalityPlugin { /// This includes personality traits and related components. pub mod prelude { pub use crate::personality::PersonalityPlugin; - pub use crate::personality::traits::{Altruistic, Personality}; + pub use crate::personality::traits::{ + Altruistic, ContextAwareUtilities, Personality, PersonalityConfig, PersonalityContextInputs, + }; } diff --git a/crates/systems/ai/src/personality/traits.rs b/crates/systems/ai/src/personality/traits.rs index 5f53961..c33260b 100644 --- a/crates/systems/ai/src/personality/traits.rs +++ b/crates/systems/ai/src/personality/traits.rs @@ -1,10 +1,33 @@ -use crate::core::scorers::Score; -use crate::prelude::*; use bevy::prelude::*; + +use crate::Score; +use crate::prelude::*; + // Removed direct energy dependency - use trait-based interface instead -// Constants for social influence calculations -const MAX_INFLUENCE_DISTANCE: f32 = 100.0; // Universal influence range +/// Configuration resource for personality-related parameters +#[derive(Resource, Debug, Clone, Reflect)] +#[reflect(Resource)] +pub struct PersonalityConfig { + /// Maximum distance for social influence calculations (universal influence range) + pub max_influence_distance: f32, +} + +impl Default for PersonalityConfig { + fn default() -> Self { + Self { + max_influence_distance: 100.0, + } + } +} + +impl PersonalityConfig { + pub fn new(max_influence_distance: f32) -> Self { + Self { + max_influence_distance: max_influence_distance.max(0.0), + } + } +} /// Universal life adaptation traits for all organisms (plants through animals) #[derive(Component, Debug, Clone, Reflect)] @@ -79,14 +102,8 @@ impl Personality { } impl AIModule for Personality { - fn update(&mut self) { - // Personality traits are generally stable - // but could evolve slowly based on experiences - } - - fn utility(&self) -> Score { - // Return a base utility score for personality-driven behaviors - Score::HALF + fn utility(&self) -> f32 { + Score::HALF.value() } } @@ -155,30 +172,57 @@ impl Default for ContextAwareUtilities { } } +/// Optional context inputs that gameplay can supply to influence personality-driven utilities. +#[derive(Component, Debug, Clone, Reflect)] +pub struct PersonalityContextInputs { + /// Current energy or stamina level (0.0-1.0) + pub energy_level: f32, + /// Recent success metric (-1.0..=1.0) influencing confidence + pub recent_success: f32, + /// Environmental stress measure (0.0-1.0) + pub environmental_stress: f32, +} + +impl Default for PersonalityContextInputs { + fn default() -> Self { + Self { + energy_level: 0.5, + recent_success: 0.0, + environmental_stress: 0.0, + } + } +} + /// System that updates personality utilities based on generic resource and environmental state pub fn update_context_aware_utilities( mut query: Query<( &Personality, &mut ContextAwareUtilities, - // TODO: Replace with trait-based resource system when available - // For now, use simple f32 values that can be populated by game-level integration + Option<&PersonalityContextInputs>, )>, ) { - for (personality, mut utilities) in &mut query { - // Default values - will be replaced by proper resource tracking - let energy_level = 0.5; // Default moderate energy - let recent_success = 0.0; // Default neutral success - let environmental_stress = 0.0; // Default no stress + for (personality, mut utilities, context_inputs) in &mut query { + let default_context = PersonalityContextInputs::default(); + let context = context_inputs.unwrap_or(&default_context); // Update utilities with default context (to be enhanced later) - utilities.resource_competition = - calculate_contextual_resource_competition(personality, energy_level, recent_success); - - utilities.competitive_behavior = - calculate_contextual_competitive_strength(personality, energy_level, recent_success); - - utilities.stress_retreat = - calculate_contextual_stress_retreat(personality, energy_level, environmental_stress); + utilities.resource_competition = calculate_contextual_resource_competition( + personality, + context.energy_level, + context.recent_success, + ); + + utilities.competitive_behavior = calculate_contextual_competitive_strength( + personality, + context.energy_level, + context.recent_success, + ); + + utilities.stress_retreat = calculate_contextual_stress_retreat( + personality, + context.energy_level, + context.environmental_stress, + ); utilities.cooperation = Score::new(personality.social_utility()); @@ -190,10 +234,13 @@ pub fn update_context_aware_utilities( /// System that calculates collective influence from nearby social relations /// Universal swarm intelligence - works for plant root networks, animal herds, bacterial colonies pub fn update_collective_influence( + config: Res, mut utilities_query: Query<(Entity, &Transform, &mut ContextAwareUtilities)>, relations_query: Query<&SocialRelation>, positions_query: Query<&Transform, Without>, ) { + let max_influence_distance = config.max_influence_distance; + for (entity, transform, mut utilities) in &mut utilities_query { let mut total_collective_influence = 0.0; let position = transform.translation.truncate(); @@ -209,9 +256,9 @@ pub fn update_collective_influence( let target_pos = target_transform.translation.truncate(); let distance = position.distance(target_pos); - if distance <= MAX_INFLUENCE_DISTANCE { + if distance <= max_influence_distance { let proximity_influence = - relation.proximity_utility_modifier(MAX_INFLUENCE_DISTANCE); + relation.proximity_utility_modifier(max_influence_distance); total_collective_influence += proximity_influence; } } diff --git a/crates/systems/ai/src/relationships/mod.rs b/crates/systems/ai/src/relationships/mod.rs index cd14cc3..2331dc0 100644 --- a/crates/systems/ai/src/relationships/mod.rs +++ b/crates/systems/ai/src/relationships/mod.rs @@ -8,7 +8,9 @@ pub struct SocialPlugin; impl Plugin for SocialPlugin { fn build(&self, app: &mut App) { - app.register_type::() + app.init_resource::() + .register_type::() + .register_type::() .register_type::() .register_type::() .register_type::() @@ -22,7 +24,7 @@ impl Plugin for SocialPlugin { pub mod prelude { pub use crate::relationships::SocialPlugin; pub use crate::relationships::social::{ - EntityRelationship, RelationshipStrength, RelationshipType, SocialNetwork, SocialRelation, - get_relationship_strength, + EntityRelationship, RelationshipStrength, RelationshipType, SocialConfig, SocialNetwork, + SocialRelation, get_relationship_strength, }; } diff --git a/crates/systems/ai/src/relationships/social.rs b/crates/systems/ai/src/relationships/social.rs index 69ea3f6..d74cbe8 100644 --- a/crates/systems/ai/src/relationships/social.rs +++ b/crates/systems/ai/src/relationships/social.rs @@ -1,8 +1,64 @@ -use crate::core::scorers::Score; -use crate::prelude::*; -use bevy::prelude::*; use std::collections::HashMap; +use bevy::prelude::*; + +use crate::Score; +use crate::prelude::*; + +/// Configuration resource for social relationship parameters +#[derive(Resource, Debug, Clone, Reflect)] +#[reflect(Resource)] +pub struct SocialConfig { + /// Time scale for relationship decay (lower = faster decay) + pub decay_time_scale: f32, + /// Maximum decay per interaction + pub max_decay_per_interaction: f32, + /// Weight for historical relationship strength when blending + pub history_weight: f32, + /// Weight for new observation when blending + pub new_observation_weight: f32, +} + +impl Default for SocialConfig { + fn default() -> Self { + Self { + decay_time_scale: 1000.0, + max_decay_per_interaction: 0.25, + history_weight: 0.7, + new_observation_weight: 0.3, + } + } +} + +impl SocialConfig { + pub fn new( + decay_time_scale: f32, + max_decay: f32, + history_weight: f32, + new_weight: f32, + ) -> Self { + // Normalize weights to sum to 1.0 + let total_weight = history_weight + new_weight; + let normalized_history = if total_weight > 0.0 { + history_weight / total_weight + } else { + 0.7 + }; + let normalized_new = if total_weight > 0.0 { + new_weight / total_weight + } else { + 0.3 + }; + + Self { + decay_time_scale: decay_time_scale.max(1.0), + max_decay_per_interaction: max_decay.clamp(0.0, 1.0), + history_weight: normalized_history, + new_observation_weight: normalized_new, + } + } +} + /// Entity identifier type (compatible with Bevy ECS) pub type EntityId = Entity; @@ -69,30 +125,42 @@ impl SocialNetwork { relationship_type: RelationshipType, strength: f32, current_tick: u64, + config: &SocialConfig, ) { - let mut relationship = EntityRelationship { - strength: RelationshipStrength::new(strength), - relationship_type, - last_interaction_tick: current_tick, - }; - - // If relationship already exists, update based on existing interaction history - if let Some(existing_relationship) = self + let clamped_strength = Score::clamp_trait_value(strength); + let relationships = self .relationships .entry(target) - .or_default() - .get_mut(&relationship_type) - { - // Modify strength based on interaction frequency - relationship.strength.adjust( - (current_tick - existing_relationship.last_interaction_tick) as f32 / 1000.0, - ); - } + .or_insert_with(|| HashMap::with_capacity(5)); // 5 relationship types max - self.relationships - .entry(target) - .or_default() - .insert(relationship_type, relationship); + match relationships.entry(relationship_type) { + std::collections::hash_map::Entry::Occupied(mut existing_entry) => { + let relationship = existing_entry.get_mut(); + let previous_strength = relationship.strength.value(); + let time_since_last = + current_tick.saturating_sub(relationship.last_interaction_tick) as f32; + + // Lightly decay stale relationships before applying the new observation. + if time_since_last > 0.0 { + let decay = (time_since_last / config.decay_time_scale) + .min(config.max_decay_per_interaction); + relationship.strength.adjust(-decay); + } + + // Blend prior strength with the latest observation to preserve history. + let blended_strength = previous_strength * config.history_weight + + clamped_strength * config.new_observation_weight; + relationship.strength = RelationshipStrength::new(blended_strength); + relationship.last_interaction_tick = current_tick; + } + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(EntityRelationship { + strength: RelationshipStrength::new(clamped_strength), + relationship_type, + last_interaction_tick: current_tick, + }); + } + } } /// Query relationships with flexible filtering @@ -125,6 +193,7 @@ impl SocialNetwork { relationship_type: RelationshipType, personality: &Personality, current_tick: u64, + config: &SocialConfig, ) { // Base strength from existing relationship or default let base_strength = self @@ -161,7 +230,13 @@ impl SocialNetwork { }; // Add or update the relationship with the modified strength - self.add_or_update_relationship(target, relationship_type, modified_strength, current_tick); + self.add_or_update_relationship( + target, + relationship_type, + modified_strength, + current_tick, + config, + ); } } @@ -198,14 +273,8 @@ pub fn get_relationship_strength( } impl AIModule for SocialNetwork { - fn update(&mut self) { - // In a real implementation, this would decay old relationships - // or update based on recent interactions - } - - fn utility(&self) -> Score { - // Calculate overall social interaction utility - social_behavior_utility(self) + fn utility(&self) -> f32 { + social_behavior_utility(self).value() } } diff --git a/crates/systems/ai/src/trackers/base_tracker.rs b/crates/systems/ai/src/trackers/base_tracker.rs deleted file mode 100644 index d345563..0000000 --- a/crates/systems/ai/src/trackers/base_tracker.rs +++ /dev/null @@ -1,114 +0,0 @@ -use crate::core::scorers::Score; -use crate::prelude::*; -use bevy::prelude::*; -use std::collections::HashMap; - -/// Core tracker for monitoring entities -#[derive(Component)] -pub struct EntityTracker { - /// Entities being tracked - tracked_entities: HashMap, - /// Maximum number of entities to track - max_tracked_entities: usize, - /// Priority score for decision making - priority: f32, -} - -/// Information about a tracked entity -pub struct TrackedEntity { - /// Last known position - pub last_seen_position: Vec2, - /// Whether the entity is currently visible - pub visual_contact: bool, - /// Ticks since last seen - pub ticks_since_seen: u32, - /// Importance of this entity (0.0-1.0) - pub importance: f32, -} - -impl AIModule for EntityTracker { - fn update(&mut self) { - // Update tracking information - for tracked in self.tracked_entities.values_mut() { - if !tracked.visual_contact { - tracked.ticks_since_seen += 1; - - // Reduce importance over time when not seen - if tracked.ticks_since_seen > 10 { - tracked.importance *= 0.99; - } - } - } - - // Calculate priority based on most important tracked entity - self.priority = self - .tracked_entities - .values() - .map(|entity| entity.importance) - .fold(0.0, f32::max); - - // Remove low importance entities when over capacity - if self.tracked_entities.len() > self.max_tracked_entities { - let mut entries: Vec<_> = self.tracked_entities.iter().collect(); - entries.sort_by(|a, b| b.1.importance.partial_cmp(&a.1.importance).unwrap()); - - let cutoff = entries[self.max_tracked_entities].1.importance; - self.tracked_entities.retain(|_, e| e.importance > cutoff); - } - } - - fn utility(&self) -> Score { - Score::new(self.priority) - } -} - -impl EntityTracker { - pub fn new(max_tracked_entities: usize) -> Self { - Self { - tracked_entities: HashMap::new(), - max_tracked_entities, - priority: 0.0, - } - } - - pub fn add_entity(&mut self, entity: Entity, position: Vec2, importance: f32) { - self.tracked_entities.insert( - entity, - TrackedEntity { - last_seen_position: position, - visual_contact: true, - ticks_since_seen: 0, - importance: importance.clamp(0.0, 1.0), - }, - ); - } - - pub fn update_entity(&mut self, entity: Entity, position: Vec2) { - if let Some(tracked) = self.tracked_entities.get_mut(&entity) { - tracked.last_seen_position = position; - tracked.visual_contact = true; - tracked.ticks_since_seen = 0; - } - } - - pub fn get_tracked_entity(&self, entity: Entity) -> Option<&TrackedEntity> { - self.tracked_entities.get(&entity) - } - - pub fn get_most_important_entity(&self) -> Option<(Entity, &TrackedEntity)> { - self.tracked_entities - .iter() - .max_by(|a, b| a.1.importance.partial_cmp(&b.1.importance).unwrap()) - .map(|(e, data)| (*e, data)) - } -} - -// Optional Relations-based tracking component (alternative to HashMap approach) -#[derive(Component, Debug, Clone)] -pub struct TrackingRelation { - pub target: Entity, - pub last_seen_position: Vec2, - pub visual_contact: bool, - pub ticks_since_seen: u32, - pub importance: f32, -} diff --git a/crates/systems/ai/src/trackers/entity_tracker.rs b/crates/systems/ai/src/trackers/entity_tracker.rs new file mode 100644 index 0000000..a3b5e0c --- /dev/null +++ b/crates/systems/ai/src/trackers/entity_tracker.rs @@ -0,0 +1,175 @@ +//! Entity tracking - data storage only +//! +//! Stores raw data about tracked entities. No evaluation logic here. +//! Specialized trackers (threat, prey, etc.) read this data and evaluate. + +use bevy::prelude::*; +use std::collections::HashMap; + +/// Metadata types for tracked entities +#[derive(Debug, Clone)] +pub enum EntityMetadata { + /// Potential threat (predator, hazard) + Threat { severity: f32 }, + + /// Potential food source + Prey { attractiveness: f32 }, + + /// Social entity (pack member, competitor) + Social { relationship_strength: f32 }, + + /// Neutral/unknown entity + Neutral, +} + +/// Raw data about a tracked entity +#[derive(Debug, Clone)] +pub struct TrackedEntity { + /// Entity being tracked + pub entity: Entity, + + /// Last known position + pub position: Vec2, + + /// Last time this entity was observed (seconds) + pub last_seen_time: f32, + + /// Distance when last seen + pub last_distance: f32, + + /// Whether currently in visual contact + pub in_visual_contact: bool, + + /// Metadata about this entity + pub metadata: EntityMetadata, +} + +impl TrackedEntity { + pub fn new( + entity: Entity, + position: Vec2, + distance: f32, + time: f32, + metadata: EntityMetadata, + ) -> Self { + Self { + entity, + position, + last_seen_time: time, + last_distance: distance, + in_visual_contact: true, + metadata, + } + } + + /// Time since last observation + pub fn time_since_seen(&self, current_time: f32) -> f32 { + current_time - self.last_seen_time + } +} + +/// Component that stores tracked entities (data only, no evaluation) +#[derive(Component, Debug)] +pub struct EntityTracker { + /// All tracked entities + tracked: HashMap, + + /// Maximum entities to track + max_tracked: usize, +} + +impl EntityTracker { + pub fn new(max_tracked: usize) -> Self { + Self { + tracked: HashMap::with_capacity(max_tracked), + max_tracked, + } + } + + /// Add or update tracked entity + pub fn track_entity( + &mut self, + entity: Entity, + position: Vec2, + distance: f32, + current_time: f32, + metadata: EntityMetadata, + ) { + if let Some(tracked) = self.tracked.get_mut(&entity) { + // Update existing + tracked.position = position; + tracked.last_seen_time = current_time; + tracked.in_visual_contact = true; + tracked.last_distance = distance; + tracked.metadata = metadata; + } else { + // Add new + self.tracked.insert( + entity, + TrackedEntity::new(entity, position, distance, current_time, metadata), + ); + } + } + + /// Mark entity as no longer in visual contact + pub fn lost_visual_contact(&mut self, entity: Entity) { + if let Some(tracked) = self.tracked.get_mut(&entity) { + tracked.in_visual_contact = false; + } + } + + /// Get tracked entity data + pub fn get(&self, entity: Entity) -> Option<&TrackedEntity> { + self.tracked.get(&entity) + } + + /// Get all tracked entities + pub fn all(&self) -> impl Iterator { + self.tracked.values() + } + + /// Get entities matching metadata filter + pub fn filter_by_metadata(&self, predicate: F) -> impl Iterator + where + F: Fn(&EntityMetadata) -> bool, + { + self.tracked + .values() + .filter(move |t| predicate(&t.metadata)) + } + + /// Remove entities not seen for too long + pub fn forget_old_entities(&mut self, current_time: f32, forget_after: f32) { + self.tracked + .retain(|_, tracked| tracked.time_since_seen(current_time) < forget_after); + } + + /// Enforce capacity limit (remove least recently seen) + pub fn enforce_capacity(&mut self) { + if self.tracked.len() <= self.max_tracked { + return; + } + + let mut entries: Vec<_> = self + .tracked + .iter() + .map(|(k, v)| (*k, v.last_seen_time)) + .collect(); + entries.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)); + + // Remove oldest + let to_remove = self.tracked.len() - self.max_tracked; + let entities_to_remove: Vec = + entries.iter().take(to_remove).map(|(e, _)| *e).collect(); + + for entity in entities_to_remove { + self.tracked.remove(&entity); + } + } +} + +impl Default for EntityTracker { + fn default() -> Self { + Self::new(20) + } +} diff --git a/crates/systems/ai/src/trackers/mod.rs b/crates/systems/ai/src/trackers/mod.rs index 1782e58..c11f8cb 100644 --- a/crates/systems/ai/src/trackers/mod.rs +++ b/crates/systems/ai/src/trackers/mod.rs @@ -1,28 +1,115 @@ -pub mod base_tracker; +// Entity tracking with clean separation: +// - entity_tracker: Stores raw data (position, last_seen, metadata) +// - Specialized trackers: Read entity_tracker and evaluate (threat, prey, etc.) + +pub mod entity_tracker; pub mod needs_tracker; pub mod perception_tracker; - -//TODO soon: pub mod relationship_tracker; +pub mod prey_tracker; +pub mod threat_tracker; use bevy::prelude::*; +use entity_tracker::{EntityMetadata, EntityTracker}; + +/// Helper function for common tracker evaluation pattern with time decay and distance factors. +/// +/// This consolidates the shared logic between ThreatTracker, PreyTracker, and future trackers. +/// Returns an iterator of (Entity, evaluated_score) pairs. +/// +/// # Parameters +/// - `entity_tracker`: The entity tracker to read from +/// - `current_time`: Current simulation time for decay calculation +/// - `decay_rate`: How quickly the value decays per second (exponential) +/// - `max_distance`: Distance beyond which the value becomes zero (linear) +/// - `extract_value`: Closure that extracts the base value from EntityMetadata (returns None if wrong type) +pub(crate) fn evaluate_tracked_entities_with_decay<'a, F>( + entity_tracker: &'a EntityTracker, + current_time: f32, + decay_rate: f32, + max_distance: f32, + extract_value: F, +) -> impl Iterator + 'a +where + F: Fn(&EntityMetadata) -> Option + 'a, +{ + entity_tracker.all().filter_map(move |tracked| { + let base_value = extract_value(&tracked.metadata)?; + + // Time-based exponential decay + let time_since = tracked.time_since_seen(current_time); + let decay = (-decay_rate * time_since).exp(); + let decayed_value = base_value * decay; + + // Distance-based linear factor (closer = higher score) + let distance_factor = if tracked.last_distance > 0.0 { + 1.0 - (tracked.last_distance / max_distance).clamp(0.0, 1.0) + } else { + 1.0 + }; + + let final_score = decayed_value * distance_factor; + Some((tracked.entity, final_score)) + }) +} /// Plugin for entity tracking systems #[derive(Default)] pub struct TrackerPlugin; impl Plugin for TrackerPlugin { - fn build(&self, _app: &mut App) { - // Simple plugin - just makes trackers available - // Systems will be added later when we have proper integration + fn build(&self, app: &mut App) { + app.init_resource::() + .init_resource::() + .register_type::() + .register_type::() + .add_systems( + Update, + ( + threat_tracker::threat_tracker_system, + prey_tracker::prey_tracker_system, + ), + ); } } -/// Prelude for the trackers module. -/// -/// This includes the most common tracking components and systems. +/// Prelude for the trackers module pub mod prelude { pub use crate::trackers::TrackerPlugin; - pub use crate::trackers::base_tracker::{EntityTracker, TrackedEntity, TrackingRelation}; + + // Data storage + pub use crate::trackers::entity_tracker::{EntityMetadata, EntityTracker, TrackedEntity}; + + // Evaluation trackers + pub use crate::trackers::prey_tracker::{PreyConfig, PreyTracker}; + pub use crate::trackers::threat_tracker::{ThreatConfig, ThreatTracker}; + + // Other trackers pub use crate::trackers::needs_tracker::NeedsTracker; pub use crate::trackers::perception_tracker::Perception; } + +// Future trackers planned (add as LP grows): +// +// aggression_tracker.rs +// Evaluates anger/hostility from entity_tracker +// Used for: Territorial defense, competitive behavior +// +// social_tracker.rs +// Evaluates pack bonds, herd affiliation from entity_tracker +// Used for: Pack hunting, colony cooperation, herd movement +// +// territory_tracker.rs +// Evaluates familiarity with locations (dens, nesting sites) +// Used for: Migration routes, home territory, safe zones +// +// noise_tracker.rs +// Evaluates sound sources and their significance +// Used for: Predator detection, communication +// +// obstacle_tracker.rs +// Evaluates navigation obstacles and path quality +// Used for: Pathfinding assistance, stuck detection +// +// injury_tracker.rs +// Evaluates damage state and healing needs +// Used for: Retreat behavior, vulnerability assessment diff --git a/crates/systems/ai/src/trackers/needs_tracker.rs b/crates/systems/ai/src/trackers/needs_tracker.rs index aac3508..b824806 100644 --- a/crates/systems/ai/src/trackers/needs_tracker.rs +++ b/crates/systems/ai/src/trackers/needs_tracker.rs @@ -1,7 +1,8 @@ -use crate::core::scorers::Score; -use crate::prelude::*; use bevy::prelude::*; +use crate::Score; +use crate::prelude::*; + /// Tracks and manages needs for an entity #[derive(Component, Default)] pub struct NeedsTracker { @@ -10,10 +11,6 @@ pub struct NeedsTracker { } impl NeedsTracker { - pub fn new() -> Self { - Self::default() - } - pub fn add_need(&mut self, need: Need) { self.needs.push(need); } @@ -55,10 +52,9 @@ impl AIModule for NeedsTracker { self.most_urgent_need = most_urgent.map(|need_type| (need_type, highest_urgency)); } - fn utility(&self) -> Score { - // Return the urgency of the most urgent need, or zero if no needs + fn utility(&self) -> f32 { self.most_urgent_need - .map(|(_, urgency)| urgency) - .unwrap_or(Score::ZERO) + .map(|(_, urgency)| urgency.value()) + .unwrap_or(Score::ZERO.value()) } } diff --git a/crates/systems/ai/src/trackers/perception_tracker.rs b/crates/systems/ai/src/trackers/perception_tracker.rs index bc0b376..fe52b35 100644 --- a/crates/systems/ai/src/trackers/perception_tracker.rs +++ b/crates/systems/ai/src/trackers/perception_tracker.rs @@ -1,4 +1,3 @@ -use crate::core::scorers::Score; use crate::prelude::*; use bevy::prelude::*; @@ -57,8 +56,7 @@ impl AIModule for Perception { self.highest_threat_level *= 0.95; } - fn utility(&self) -> Score { - // Return threat level as utility - higher threat means more important - Score::new(self.highest_threat_level) + fn utility(&self) -> f32 { + self.highest_threat_level.clamp(0.0, 1.0) } } diff --git a/crates/systems/ai/src/trackers/prey_tracker.rs b/crates/systems/ai/src/trackers/prey_tracker.rs new file mode 100644 index 0000000..f964326 --- /dev/null +++ b/crates/systems/ai/src/trackers/prey_tracker.rs @@ -0,0 +1,104 @@ +//! Prey evaluation - reads entity tracker, outputs food assessment +//! +//! Data storage separate from evaluation. +//! This reads EntityTracker and calculates food attractiveness. + +use super::entity_tracker::{EntityMetadata, EntityTracker}; +use crate::prelude::*; +use bevy::prelude::*; + +/// Configuration for prey evaluation +#[derive(Resource, Debug, Clone, Reflect)] +#[reflect(Resource)] +pub struct PreyConfig { + /// How quickly food memory fades per second + pub memory_decay_per_second: f32, + + /// Time before completely forgetting food + pub forget_after: f32, + + /// Max distance to consider food attractive + pub max_attractive_distance: f32, +} + +impl Default for PreyConfig { + fn default() -> Self { + Self { + memory_decay_per_second: 0.1, + forget_after: 10.0, + max_attractive_distance: 200.0, + } + } +} + +/// Evaluates food sources from entity tracker (no data storage) +#[derive(Component, Debug, Default)] +pub struct PreyTracker { + /// Most attractive prey entity + best_prey: Option, + + /// Attractiveness of best prey (0.0-1.0) + best_attractiveness: f32, +} + +impl PreyTracker { + /// Get most attractive prey + pub fn best_prey(&self) -> Option { + self.best_prey + } + + /// Get attractiveness of best prey + pub fn best_attractiveness(&self) -> f32 { + self.best_attractiveness + } + + /// Update prey evaluation from entity tracker + pub fn update( + &mut self, + entity_tracker: &EntityTracker, + current_time: f32, + config: &PreyConfig, + ) { + let mut best_entity = None; + let mut best_score = 0.0; + + // Evaluate all prey using shared decay/distance logic + for (entity, score) in super::evaluate_tracked_entities_with_decay( + entity_tracker, + current_time, + config.memory_decay_per_second, + config.max_attractive_distance, + |m| match m { + EntityMetadata::Prey { attractiveness } => Some(*attractiveness), + _ => None, + }, + ) { + if score > best_score { + best_score = score; + best_entity = Some(entity); + } + } + + self.best_prey = best_entity; + self.best_attractiveness = best_score; + } +} + +impl AIModule for PreyTracker { + fn utility(&self) -> f32 { + self.best_attractiveness.clamp(0.0, 1.0) + } +} + +/// System that updates all prey trackers +pub fn prey_tracker_system( + time: Res