Skip to content

Commit f99fd07

Browse files
authored
Merge pull request #22 from YJack0000/feat/python-behavior-system
feat: add injectable Python behavior strategies for Organism
2 parents b87991d + 25f518a commit f99fd07

6 files changed

Lines changed: 366 additions & 129 deletions

File tree

bindings/core/Organism_bindings.cpp

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
#include <pybind11/pybind11.h>
22
#include <pybind11/functional.h>
3+
#include <pybind11/stl.h>
34

45
#include <core/Organism.hpp>
56

@@ -17,9 +18,16 @@ void init_Organism(py::module &m) {
1718
.def("killed", &Organism::killed)
1819
.def("is_alive", &Organism::isAlive)
1920
.def("can_reproduce", &Organism::canReproduce)
20-
// .def("is_full", &Organism::isFull)
21+
.def("add_life_span", &Organism::addLifeSpan, py::arg("amount"))
2122
.def("reproduce", &Organism::reproduce)
2223
.def("get_reaction_radius", &Organism::getReactionRadius)
2324
.def("interact", &Organism::interact)
24-
.def("post_iteration", &Organism::postIteration);
25+
.def("react", &Organism::react)
26+
.def("post_iteration", &Organism::postIteration)
27+
.def("set_reaction_strategy", &Organism::setReactionStrategy, py::arg("strategy"),
28+
"Set a custom reaction strategy. The callable receives (organism, nearby_objects) "
29+
"and should return a (dx, dy) tuple for movement direction, or (0, 0) for no reaction.")
30+
.def("set_interaction_strategy", &Organism::setInteractionStrategy, py::arg("strategy"),
31+
"Set a custom interaction strategy. The callable receives (organism, nearby_objects) "
32+
"and should perform interactions (e.g., eat food, kill organisms).");
2533
}

examples/custom_behavior.py

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
"""
2+
Example: Custom Python Behavior Strategies
3+
4+
Demonstrates how to define organism behavior entirely in Python
5+
without modifying the C++ engine. Two species coexist:
6+
- Herbivores: only eat food, flee from all organisms
7+
- Predators: ignore food, chase smaller organisms
8+
"""
9+
10+
import random
11+
12+
from simevopy import Environment, Food, Genes, Organism
13+
14+
15+
# --- Custom Reaction Strategies ---
16+
17+
def herbivore_reaction(organism, nearby_objects):
18+
"""Herbivores prioritize food, flee from any organism."""
19+
my_pos = organism.get_position()
20+
nearest_food = None
21+
nearest_food_dist = float("inf")
22+
nearest_org = None
23+
nearest_org_dist = float("inf")
24+
25+
for obj in nearby_objects:
26+
dx = my_pos[0] - obj.get_position()[0]
27+
dy = my_pos[1] - obj.get_position()[1]
28+
dist = (dx * dx + dy * dy) ** 0.5
29+
30+
if hasattr(obj, "can_be_eaten") and obj.can_be_eaten():
31+
if dist < nearest_food_dist:
32+
nearest_food = obj
33+
nearest_food_dist = dist
34+
elif hasattr(obj, "is_alive") and obj.is_alive():
35+
if dist < nearest_org_dist:
36+
nearest_org = obj
37+
nearest_org_dist = dist
38+
39+
# Flee from organisms if one is very close
40+
if nearest_org and nearest_org_dist < 20:
41+
org_pos = nearest_org.get_position()
42+
return (my_pos[0] - org_pos[0], my_pos[1] - org_pos[1])
43+
44+
# Otherwise move toward food
45+
if nearest_food:
46+
food_pos = nearest_food.get_position()
47+
return (food_pos[0] - my_pos[0], food_pos[1] - my_pos[1])
48+
49+
return (0.0, 0.0)
50+
51+
52+
def predator_reaction(organism, nearby_objects):
53+
"""Predators chase smaller organisms, ignore food."""
54+
my_pos = organism.get_position()
55+
my_size = organism.get_size()
56+
57+
nearest_prey = None
58+
nearest_prey_dist = float("inf")
59+
60+
for obj in nearby_objects:
61+
if not hasattr(obj, "is_alive") or not obj.is_alive():
62+
continue
63+
if not hasattr(obj, "get_size"):
64+
continue
65+
if obj.get_size() >= my_size:
66+
continue
67+
68+
dx = my_pos[0] - obj.get_position()[0]
69+
dy = my_pos[1] - obj.get_position()[1]
70+
dist = (dx * dx + dy * dy) ** 0.5
71+
72+
if dist < nearest_prey_dist:
73+
nearest_prey = obj
74+
nearest_prey_dist = dist
75+
76+
if nearest_prey:
77+
prey_pos = nearest_prey.get_position()
78+
return (prey_pos[0] - my_pos[0], prey_pos[1] - my_pos[1])
79+
80+
return (0.0, 0.0)
81+
82+
83+
# --- Custom Interaction Strategies ---
84+
85+
def herbivore_interaction(organism, nearby_objects):
86+
"""Herbivores only eat food, never attack other organisms."""
87+
for obj in nearby_objects:
88+
if hasattr(obj, "can_be_eaten") and obj.can_be_eaten():
89+
organism.add_life_span(obj.get_energy())
90+
obj.eaten()
91+
92+
93+
def predator_interaction(organism, nearby_objects):
94+
"""Predators eat smaller organisms, ignore food."""
95+
for obj in nearby_objects:
96+
if not hasattr(obj, "is_alive") or not obj.is_alive():
97+
continue
98+
if not hasattr(obj, "get_size"):
99+
continue
100+
if organism.get_size() > 1.2 * obj.get_size():
101+
organism.add_life_span(obj.get_life_span())
102+
obj.killed()
103+
104+
105+
def no_life_cost(organism):
106+
"""Zero life consumption for demo purposes."""
107+
return 0
108+
109+
110+
def main():
111+
env = Environment(500, 500)
112+
113+
# Create herbivores (small, fast, high awareness)
114+
for _ in range(15):
115+
dna = chr(60) + chr(15) + chr(80) + chr(0) # fast, small, aware
116+
org = Organism(Genes(dna), no_life_cost)
117+
org.set_reaction_strategy(herbivore_reaction)
118+
org.set_interaction_strategy(herbivore_interaction)
119+
env.add_organism(org, random.uniform(10, 490), random.uniform(10, 490))
120+
121+
# Create predators (slower, bigger, less awareness)
122+
for _ in range(5):
123+
dna = chr(30) + chr(80) + chr(60) + chr(0) # slow, big, moderate awareness
124+
org = Organism(Genes(dna), no_life_cost)
125+
org.set_reaction_strategy(predator_reaction)
126+
org.set_interaction_strategy(predator_interaction)
127+
env.add_organism(org, random.uniform(10, 490), random.uniform(10, 490))
128+
129+
# Add food
130+
for _ in range(30):
131+
env.add_food(Food(), random.uniform(10, 490), random.uniform(10, 490))
132+
133+
# Run simulation
134+
env.simulate_iteration(200)
135+
136+
# Report results
137+
alive = env.get_all_organisms()
138+
print(f"\nAfter 200 iterations:")
139+
print(f" Surviving organisms: {len(alive)}")
140+
print(f" Remaining food: {len(env.get_all_foods())}")
141+
print(f" Food consumed: {env.get_food_consumption_in_iteration()}")
142+
print(f" Dead organisms: {len(env.get_dead_organisms())}")
143+
144+
145+
if __name__ == "__main__":
146+
main()

include/core/Environment.hpp

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,15 @@
1515
*
1616
* Environment owns all simulation objects, delegates spatial lookups to an
1717
* ISpatialIndex implementation, and drives the interact-react-move lifecycle
18-
* each iteration. The interaction phase runs single-threaded because it mutates
19-
* shared state (food eaten flags, organism lifespans). The reaction phase can
20-
* be parallelized since each organism only writes to its own movement fields.
18+
* each iteration. Both phases currently run single-threaded so that Python
19+
* strategy callbacks can safely acquire the GIL.
20+
*
21+
* TODO: Re-enable multi-threaded handleReactions(). The reaction phase only
22+
* writes to each organism's own movement fields and is inherently parallelizable.
23+
* When Python callbacks are involved, either release the GIL before spawning
24+
* worker threads (using py::gil_scoped_release) or detect at runtime whether
25+
* any organism has a custom strategy and only fall back to single-threaded in
26+
* that case.
2127
*/
2228
class Environment {
2329
public:
@@ -26,7 +32,7 @@ class Environment {
2632
* @param width The horizontal extent of the simulation area.
2733
* @param height The vertical extent of the simulation area.
2834
* @param type Spatial index implementation: "default" or "optimized".
29-
* @param numThreads Number of threads for the parallelizable reaction phase.
35+
* @param numThreads Reserved for future multi-threaded reaction phase.
3036
* @throws std::invalid_argument If type is not "default" or "optimized".
3137
*/
3238
Environment(int width, int height, std::string type = "default", int numThreads = 1);
@@ -80,9 +86,9 @@ class Environment {
8086
* @param iterations Number of iterations to simulate.
8187
* @param on_each_iteration Optional callback invoked after each iteration.
8288
*
83-
* Each iteration proceeds in order: interactions (single-threaded),
84-
* reactions (optionally multi-threaded), then post-iteration (life
85-
* consumption + movement). Stops early if no organisms or food remain.
89+
* Each iteration proceeds in order: interactions, reactions, then
90+
* post-iteration (life consumption + movement). Stops early if no
91+
* organisms or food remain.
8692
*/
8793
void simulateIteration(int iterations,
8894
std::function<void(const Environment&)> on_each_iteration = nullptr);
@@ -112,7 +118,8 @@ class Environment {
112118
std::vector<std::shared_ptr<Organism>> deadOrganisms; ///< Accumulated dead organisms
113119
unsigned long foodConsumption = 0; ///< Running food consumption counter
114120

115-
int numThreads = 1; ///< Thread count for the parallelizable reaction phase
121+
// TODO: use numThreads to re-enable multi-threaded handleReactions()
122+
int numThreads = 1; ///< Reserved for future multi-threaded reaction phase
116123
bool verbose = false;
117124

118125
/**
@@ -137,9 +144,9 @@ class Environment {
137144
/**
138145
* @brief Run the reaction phase: organisms decide movement direction.
139146
*
140-
* Safe to parallelize because each organism only writes to its own
141-
* movement vector and reactionCounter. Uses numThreads worker threads
142-
* when numThreads > 1.
147+
* Currently runs single-threaded for GIL safety with Python callbacks.
148+
* TODO: Re-enable multi-threading -- each organism only writes to its own
149+
* movement/reactionCounter fields, so this phase is inherently parallelizable.
143150
*/
144151
void handleReactions();
145152

include/core/Organism.hpp

Lines changed: 86 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,13 @@
1212
/**
1313
* @brief A living entity in the simulation that can move, eat, fight, and reproduce.
1414
*
15-
* Organisms have gene-derived attributes (speed, size, awareness) that determine
16-
* their behavior and survival. Each iteration, organisms react to nearby objects
17-
* (deciding movement direction) and interact with overlapping objects (eating food,
18-
* killing smaller organisms). Reproduction creates a mutated offspring and halves
19-
* the parent's lifespan.
15+
* Organism behaviour is driven by two pluggable strategy callbacks:
16+
* - **ReactionStrategy** -- decides movement direction based on nearby objects.
17+
* - **InteractionStrategy** -- performs close-range actions (eating, fighting).
18+
*
19+
* When no custom strategy is set, built-in defaults are used. Custom strategies
20+
* are inherited by offspring produced via reproduce(), enabling Python-side
21+
* behaviour injection that persists across generations.
2022
*/
2123
class Organism : public EnvironmentObject {
2224
public:
@@ -28,6 +30,26 @@ class Organism : public EnvironmentObject {
2830
*/
2931
using LifeConsumptionCalculator = std::function<uint32_t(const Organism &)>;
3032

33+
/**
34+
* @brief Strategy that decides how an organism reacts to nearby objects.
35+
*
36+
* Given a reference to the organism and a list of nearby objects (within
37+
* the reaction radius), the strategy returns a (dx, dy) movement direction.
38+
* Returning {0, 0} signals "no reaction" and the organism keeps wandering.
39+
*/
40+
using ReactionStrategy = std::function<std::pair<float, float>(
41+
Organism &, const std::vector<std::shared_ptr<EnvironmentObject>> &)>;
42+
43+
/**
44+
* @brief Strategy that defines close-range interactions with nearby objects.
45+
*
46+
* Given a reference to the organism and objects within its size radius,
47+
* the strategy mutates state directly (e.g. consuming food, killing smaller
48+
* organisms). The default eats food and preys on organisms less than 2/3 its size.
49+
*/
50+
using InteractionStrategy = std::function<void(
51+
Organism &, const std::vector<std::shared_ptr<EnvironmentObject>> &)>;
52+
3153
/** @brief Construct a default organism with preset genes and 500 lifespan. */
3254
Organism();
3355

@@ -84,32 +106,75 @@ class Organism : public EnvironmentObject {
84106
*/
85107
bool canReproduce() const;
86108

109+
/**
110+
* @brief Add (or subtract) life-span points.
111+
*
112+
* Exposed publicly so that custom InteractionStrategy callbacks (including
113+
* those written in Python) can reward or penalise organisms.
114+
*
115+
* @param amount Points to add; negative values reduce life-span.
116+
*/
117+
void addLifeSpan(float amount);
118+
87119
~Organism() {};
88120

121+
// ── Behaviour injection ─────────────────────────────────────────────
122+
123+
/**
124+
* @brief Replace the reaction strategy with a custom implementation.
125+
*
126+
* The strategy is propagated to offspring during reproduce(). Pass nullptr
127+
* or an empty std::function to revert to the built-in default.
128+
*
129+
* @param strategy Callable matching the ReactionStrategy signature.
130+
*/
131+
void setReactionStrategy(ReactionStrategy strategy);
132+
133+
/**
134+
* @brief Replace the interaction strategy with a custom implementation.
135+
*
136+
* The strategy is propagated to offspring during reproduce(). Pass nullptr
137+
* or an empty std::function to revert to the built-in default.
138+
*
139+
* @param strategy Callable matching the InteractionStrategy signature.
140+
*/
141+
void setInteractionStrategy(InteractionStrategy strategy);
142+
143+
/**
144+
* @brief Check whether this organism has any custom (non-default) strategy set.
145+
* @return true if either reactionStrategy or interactionStrategy is set.
146+
*
147+
* Used by Environment to decide whether multi-threaded execution is safe.
148+
* Custom strategies may involve Python callbacks that require the GIL.
149+
*/
150+
bool hasCustomStrategy() const;
151+
152+
// ── Actions ─────────────────────────────────────────────────────────
153+
89154
/**
90155
* @brief Decide movement direction based on nearby objects within reaction radius.
91156
* @param reactableObjects Objects detected within the organism's reaction radius.
92157
*
93-
* Finds the nearest valid object and sets movement direction accordingly:
94-
* flee from larger organisms, chase smaller organisms, move toward food.
95-
* Only triggers once per iteration (guarded by reactionCounter).
96-
* Safe to call in parallel -- only writes to this organism's own fields.
158+
* Delegates to the custom ReactionStrategy if one has been set, otherwise
159+
* falls back to defaultReaction(). Only triggers once per iteration
160+
* (guarded by reactionCounter).
97161
*/
98162
void react(const std::vector<std::shared_ptr<EnvironmentObject>> &reactableObjects);
99163

100164
/**
101165
* @brief Interact with objects within the organism's body size range.
102166
* @param interactableObjects Objects overlapping the organism's size radius.
103167
*
104-
* Eats available food (gaining its energy) and kills smaller organisms
105-
* (absorbing their remaining lifespan). Must run single-threaded because
106-
* it mutates shared state (food eaten flags, other organisms' lifespans).
168+
* Delegates to the custom InteractionStrategy if set, otherwise uses
169+
* defaultInteraction(). Must run single-threaded because it mutates
170+
* shared state (food eaten flags, other organisms' lifespans).
107171
*/
108172
void interact(const std::vector<std::shared_ptr<EnvironmentObject>> &interactableObjects);
109173

110174
/**
111175
* @brief Create a mutated offspring organism.
112-
* @return A new organism with mutated genes, inheriting the life consumption calculator.
176+
* @return A new organism with mutated genes, inheriting the life consumption
177+
* calculator and any custom behavior strategies.
113178
*
114179
* The parent's lifespan is halved. The child is placed at a small offset
115180
* from the parent's position.
@@ -124,6 +189,8 @@ class Organism : public EnvironmentObject {
124189
private:
125190
Genes genes; ///< Genetic data driving attributes
126191
LifeConsumptionCalculator lifeConsumptionCalculator; ///< Optional custom life drain formula
192+
ReactionStrategy reactionStrategy; ///< Optional custom reaction behaviour
193+
InteractionStrategy interactionStrategy; ///< Optional custom interaction behaviour
127194
float lifeSpan; ///< Remaining life points
128195

129196
/**
@@ -144,6 +211,12 @@ class Organism : public EnvironmentObject {
144211
* not exceed the organism's speed.
145212
*/
146213
void makeMove();
214+
215+
// Default built-in strategies
216+
static std::pair<float, float> defaultReaction(
217+
Organism &self, const std::vector<std::shared_ptr<EnvironmentObject>> &objects);
218+
static void defaultInteraction(
219+
Organism &self, const std::vector<std::shared_ptr<EnvironmentObject>> &objects);
147220
};
148221

149222
#endif

0 commit comments

Comments
 (0)