diff --git a/src/agent.py b/src/agent.py index 80c6526..465339b 100644 --- a/src/agent.py +++ b/src/agent.py @@ -1,8 +1,8 @@ """Agent module containing the Agent class and related utilities for simulation.""" from __future__ import annotations -from food import FoodSource import math import random +from collections import deque import pygame import typing import logging @@ -45,10 +45,10 @@ def torus_diff(ax: float, ay: float, bx: float, by: float) -> typing.Tuple[float max_y = float(Agent.bound_y) tx = float(bx - ax) ty = float(by - ay) - dx = tx if abs(tx) < max_x / 2 else tx - _sign(tx) * max_x + dx = tx if abs(tx) < max_x / 2 else tx - _sign(tx) * max_x dy = ty if abs(ty) < max_y / 2 else ty - _sign(ty) * max_y return (dx, dy) - + class Agent: """Represents an agent in the simulation with neural network-based decision making.""" @@ -88,6 +88,13 @@ class Agent: logging.basicConfig(level=logging.INFO) logger = logging.getLogger() + + LOOP_WINDOW = 24 + LOOP_MAX_SPAN = 3.0 + LOOP_TURN_STD_EPS = 0.035 + LOOP_TURN_MEAN_MIN = 0.18 + ESCAPE_STEPS = 14 + def __init__(self, position: tuple, environment : Environment,/, decision_matrix : typing.List[typing.List[int]] = None, genome = None, species = None ): from mating import Mating @@ -98,7 +105,7 @@ def __init__(self, position: tuple, environment : Environment,/, decision_matrix self.uuid = uuid.uuid4() - self.group_id = species if species else random.randint(0,1) # team/species id (same -> friend, different -> enemy) + self.group_id = species if species else random.randint(0,1) # species id (same -> friend, different -> enemy) if genome: self.body_points = genome @@ -109,7 +116,7 @@ def __init__(self, position: tuple, environment : Environment,/, decision_matrix self.max_energy = (10.0 + _sqrt_scale(self.body_points["energy"], 2.0)) * 2 self.base_speed = 0.5 + _sqrt_scale(self.body_points["speed"], 0.2) self.attack_power = 0.5 + _sqrt_scale(self.body_points["attack"], 0.06) - self.max_age = int(200 + _sqrt_scale(self.body_points["lifespan"], 14.0)) * 2 + self.max_age = int((300 + _sqrt_scale(self.body_points["lifespan"], 18.0)) * 2.5) self.sight = 70.0 + _sqrt_scale(self.body_points["sight"], 6.0) self.agility = 30.0 + _sqrt_scale(self.body_points["agility"], 2.0) @@ -120,6 +127,12 @@ def __init__(self, position: tuple, environment : Environment,/, decision_matrix self.angle = random.random() * 360.0 self.last_action = self.actions["ACTION_MOVE"] + #tracks recent motion, does not change the NN input/output interface. + self._loop_positions = deque(maxlen=self.LOOP_WINDOW) + self._loop_turns = deque(maxlen=self.LOOP_WINDOW) + self._escape_steps_left = 0 + self._loop_position_anchor = (self.x, self.y) + self.input_size = len(self.sense()) self.action_count = len(self.actions) self.output_size = self.action_count + 2 @@ -155,6 +168,66 @@ def set_inputs(self, inputs): raise ValueError(f"expected input vector length {self.input_size}, got {len(inputs)}") self._inputs_override = [float(v) for v in inputs] + #keep random-weight agents from moving in perfect circles + def _record_motion_sample(self, applied_turn: float, did_move: bool) -> None: + if not did_move: + return + + if len(self._loop_positions) == 0: + self._loop_position_anchor = (self.x, self.y) + + self._loop_turns.append(float(_clamp(applied_turn, -1.0, 1.0))) + self._loop_positions.append((self.x, self.y)) + + def _unwrapped_positions(self) -> typing.List[typing.Tuple[float, float]]: + ax, ay = self._loop_position_anchor + out: typing.List[typing.Tuple[float, float]] = [] + for px, py in self._loop_positions: + dx, dy = torus_diff(ax, ay, px, py) + out.append((dx, dy)) + return out + + @staticmethod + def _mean_std(values: typing.Sequence[float]) -> typing.Tuple[float, float]: + n = len(values) + if n == 0: + return 0.0, 0.0 + mean = sum(values) / n + var = sum((v - mean) * (v - mean) for v in values) / n + return mean, math.sqrt(var) + + def _looks_like_circle(self) -> bool: + if len(self._loop_turns) < self.LOOP_WINDOW: + return False + + mean_turn, std_turn = self._mean_std(self._loop_turns) + if abs(mean_turn) < self.LOOP_TURN_MEAN_MIN or std_turn > self.LOOP_TURN_STD_EPS: + return False + + pts = self._unwrapped_positions() + xs = [p[0] for p in pts] + ys = [p[1] for p in pts] + span = max(max(xs) - min(xs), max(ys) - min(ys)) + return span <= self.LOOP_MAX_SPAN + + def _apply_anti_loop(self, action: int, turn: float, intensity: float) -> typing.Tuple[int, float, float]: + if self._escape_steps_left > 0: + self._escape_steps_left -= 1 + escape_turn = _clamp(turn, -1.0, 1.0) + (random.random() * 2.0 - 1.0) * 0.55 + return self.actions["ACTION_MOVE"], _clamp(escape_turn, -1.0, 1.0), max(0.35, intensity) + + if action != self.actions["ACTION_MOVE"]: + return action, turn, intensity + + if self._looks_like_circle(): + self._escape_steps_left = self.ESCAPE_STEPS + mean_turn, _ = self._mean_std(self._loop_turns) + flipped = -_clamp(mean_turn, -1.0, 1.0) + escape_turn = flipped + (random.random() * 2.0 - 1.0) * 0.35 + return self.actions["ACTION_MOVE"], _clamp(escape_turn, -1.0, 1.0), max(0.45, intensity) + + return action, turn, intensity + def sense(self, foods=None, agents=None): """Generate normalized sensory input vector.""" bx = float(self.bound_x) if self.bound_x > 0 else 1.0 @@ -189,7 +262,7 @@ def sense(self, foods=None, agents=None): food_dx_n = _clamp(dx / max(1e-9, self.sight), -1.0, 1.0) food_dy_n = _clamp(dy / max(1e-9, self.sight), -1.0, 1.0) self._last_food = best - if d <= self.sight: # Adding boolean to deal with semantic discontinuity ( 1 could mean food is far away and 0,95 mean is really close) + if d <= self.sight: # Adding boolean to deal with discontinuity ( 1 could mean food is far away and 0,95 mean is really close) food_in_sight = 1 friend_count_n = 0.0 @@ -202,7 +275,7 @@ def sense(self, foods=None, agents=None): friends = 0 enemies = 0 best_friend_d2 = 1e18 - best_friend = None + best_friend = None best_enemy_d2 = 1e18 best_enemy = None @@ -240,7 +313,7 @@ def sense(self, foods=None, agents=None): else: self._last_enemy = None - + if best_friend is not None: # Adding this part to enable to agent getting information where are friends dx, dy = torus_diff(self.x, self.y, best_friend[0], best_friend[1]) d = math.sqrt(best_friend_d2) @@ -333,7 +406,7 @@ def _move(self, turn: float, intensity: float, speed_modifier: float = 1.0): new_y = self.y - math.sin(rad) * sp self._apply_bounds(new_x, new_y, new_angle) - cost = 0.02 + 0.06 * inten + cost = 0.01 + 0.05 * inten self.energy = max(0.0, self.energy - cost) def _idle(self): @@ -372,14 +445,20 @@ def update(self, speed_modifier): return if self._inputs_override is None: - inputs = self.sense(self.environment.food_sources, self.environment.get_agents()) + nearby_agents = self.environment.get_nearby_agents(self, self.sight * 2) + inputs = self.sense(self.environment.food_sources, nearby_agents) else: inputs = self._inputs_override outputs = self.think(inputs) action, turn, intensity = self.decide(outputs) + + #breaks circles when turn stays nearly constant. + action, turn, intensity = self._apply_anti_loop(action, turn, intensity) self.last_action = action + did_move = action == self.actions["ACTION_MOVE"] + if action == self.actions["ACTION_MOVE"]: self.logger.debug(f"Agent - {self.uuid}; action - move") self._move(turn, intensity) @@ -396,6 +475,9 @@ def update(self, speed_modifier): self.logger.debug(f"Agent - {self.uuid}; action - attack") self._attack() + #to record motion samples after the action was applied. + self._record_motion_sample(applied_turn=turn, did_move=did_move) + self._tick_body() def render(self, window: pygame.window, cell_size: int, offset: tuple): @@ -418,7 +500,7 @@ def render(self, window: pygame.window, cell_size: int, offset: tuple): rad = math.radians(self.angle - 135.0) points.append((env_x + math.cos(rad) * r, env_y - math.sin(rad) * r)) - color = (100, 200, 255) if self.group_id == 0 else (255, 255, 255) + color = (100, 200, 255) if self.group_id == 0 else (255, 255, 255) pygame.draw.polygon(window, color, points) bar_w = int(cell_size * 0.8)