diff --git a/.coverage b/.coverage deleted file mode 100644 index a378a20..0000000 Binary files a/.coverage and /dev/null differ diff --git a/.gitignore b/.gitignore index 7e99e36..102b2e3 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,6 @@ -*.pyc \ No newline at end of file +*.pyc +.coverage +cov.xml +__pycache__/ +*.py[cod] +*$py.class \ No newline at end of file diff --git a/cov.xml b/cov.xml deleted file mode 100644 index 758c2f1..0000000 --- a/cov.xml +++ /dev/null @@ -1,339 +0,0 @@ - - - - - - /home/runner/work/Kreatures/Kreatures/src - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/config/config.py b/src/config/config.py index c8b4090..53e6581 100644 --- a/src/config/config.py +++ b/src/config/config.py @@ -14,3 +14,15 @@ def __init__(self): self.earlyGameGracePeriod = 50 # Number of ticks of protection for player self.playerDamageReduction = 0.4 # 40% damage reduction for player during grace period # During grace period, other creatures have 85% chance to avoid attacking player + + # Population control settings to prevent lag + self.maxEntities = 50 # Starting maximum number of entities (will adjust dynamically) + self.minEntities = 20 # Minimum entities to maintain for gameplay + self.maxEntitiesLimit = 200 # Hard upper limit for entities + self.entityCullThreshold = 0.9 # Cull entities when population reaches 90% of max + self.entityCullTarget = 0.7 # Reduce population to 70% of max when culling + self.entityLogMaxSize = 50 # Maximum number of log entries per entity to prevent memory bloat + + # Dynamic performance monitoring settings + self.lagThreshold = 0.05 # Tick time in seconds that indicates lag (50ms) + self.performanceWindow = 10 # Number of recent ticks to analyze for performance diff --git a/src/entity/livingEntity.py b/src/entity/livingEntity.py index 7e6233d..5ca9a69 100644 --- a/src/entity/livingEntity.py +++ b/src/entity/livingEntity.py @@ -1,6 +1,7 @@ # Copyright (c) 2022 Daniel McCoy Stephenson # Apache License 2.0 import random +from collections import deque from flags.flags import Flags from stats.stats import Stats @@ -44,8 +45,8 @@ def getNextAction(self, kreature): return "befriend" def reproduce(self, kreature): - self.log.append("%s made a baby with %s!" % (self.name, kreature.name)) - kreature.log.append("%s made a baby with %s!" % (kreature.name, self.name)) + self.addLogEntry("%s made a baby with %s!" % (self.name, kreature.name)) + kreature.addLogEntry("%s made a baby with %s!" % (kreature.name, self.name)) self.stats.numOffspring += 1 kreature.stats.numOffspring += 1 # Return the parent entities so the child can be created with proper references @@ -64,20 +65,20 @@ def fight(self, kreature): kreature.health -= damage if kreature.health <= 0: - self.log.append( + self.addLogEntry( "%s fought and ate %s!" % (self.name, kreature.name) ) - kreature.log.append( + kreature.addLogEntry( "%s was eaten by %s!" % (kreature.name, self.name) ) self.stats.numCreaturesEaten += 1 break else: - self.log.append( + self.addLogEntry( "%s fought %s and dealt %d damage!" % (self.name, kreature.name, damage) ) - kreature.log.append( + kreature.addLogEntry( "%s took %d damage from %s! Health: %d" % (kreature.name, damage, self.name, kreature.health) ) @@ -92,25 +93,25 @@ def fight(self, kreature): self.health -= damage if self.health <= 0: - kreature.log.append( + kreature.addLogEntry( "%s fought and ate %s!" % (kreature.name, self.name) ) - self.log.append("%s was eaten by %s!" % (self.name, kreature.name)) + self.addLogEntry("%s was eaten by %s!" % (self.name, kreature.name)) kreature.stats.numCreaturesEaten += 1 break else: - kreature.log.append( + kreature.addLogEntry( "%s fought %s and dealt %d damage!" % (kreature.name, self.name, damage) ) - self.log.append( + self.addLogEntry( "%s took %d damage from %s! Health: %d" % (self.name, damage, kreature.name, self.health) ) def befriend(self, kreature): - self.log.append("%s made friends with %s!" % (self.name, kreature.name)) - kreature.log.append("%s made friends with %s!" % (kreature.name, self.name)) + self.addLogEntry("%s made friends with %s!" % (self.name, kreature.name)) + kreature.addLogEntry("%s made friends with %s!" % (kreature.name, self.name)) self.friends.append(kreature) kreature.friends.append( self @@ -159,7 +160,20 @@ def regenerateHealth(self): self.health = min(self.health + regeneration, self.maxHealth) # Only log significant regeneration events to avoid spam if regeneration >= 2: - self.log.append( + self.addLogEntry( "%s regenerated %d health! Health: %d/%d" % (self.name, regeneration, self.health, self.maxHealth) ) + + def addLogEntry(self, message, maxLogSize=50): + """Add a log entry and maintain log size limit. + + Backed by a deque bounded to maxLogSize so repeated appends are O(1) + instead of copying up to maxLogSize elements on every call once the + cap is reached (this fires on nearly every action, every tick, for + every entity, so it's on the hot path this feature exists to keep + cheap). + """ + if not isinstance(self.log, deque) or self.log.maxlen != maxLogSize: + self.log = deque(self.log, maxlen=maxLogSize) + self.log.append(message) diff --git a/src/kreatures.py b/src/kreatures.py index 26a523d..d0bfcc1 100644 --- a/src/kreatures.py +++ b/src/kreatures.py @@ -23,9 +23,12 @@ def __init__(self): self.config = Config() self.tick = 0 + # Performance monitoring for dynamic entity limits + self.tickTimes = [] # Store recent tick times for lag detection + # Initialize player early-game protection self.playerCreature.damageReduction = self.config.playerDamageReduction - self.playerCreature.log.append("%s has early-game protection!" % self.playerCreature.name) + self.playerCreature.addLogEntry("%s has early-game protection!" % self.playerCreature.name) def _load_names(self): """Load names from configuration file""" @@ -65,13 +68,13 @@ def initiateEntityActions(self): for entity in self.environment.getEntities(): target = self.environment.getRandomEntity() - if target == entity: + if target == entity or target is None: continue decision = entity.getNextAction(target) if decision == "nothing": - entity.log.append( + entity.addLogEntry( "%s had an argument with %s!" % (entity.name, target.name) ) elif decision == "love": @@ -87,7 +90,7 @@ def initiateEntityActions(self): # During grace period, 85% chance to skip attacking the player if (self.tick < self.config.earlyGameGracePeriod and random.randint(1, 100) <= 85): - entity.log.append( + entity.addLogEntry( "%s decided not to attack %s." % (entity.name, target.name) ) continue @@ -105,9 +108,12 @@ def initiateEntityActions(self): entity.decreaseChanceToFight() entity.befriend(target) - # Remove all entities that died this turn - for entity in entities_to_remove: - self.environment.removeEntity(entity) + # Remove all entities that died this turn in a single O(n) pass + # (avoids O(n) list.remove() per death, which is O(n*k) overall) + self.environment.removeEntities(entities_to_remove) + + # Manage population to prevent lag + self.managePopulation() def updatePlayerProtection(self): """Update player protection based on current tick""" @@ -115,7 +121,25 @@ def updatePlayerProtection(self): # Grace period has ended if hasattr(self.playerCreature, 'damageReduction') and self.playerCreature.damageReduction > 0: self.playerCreature.damageReduction = 0 - self.playerCreature.log.append("%s's protection has worn off!" % self.playerCreature.name) + self.playerCreature.addLogEntry("%s's protection has worn off!" % self.playerCreature.name) + + def managePopulation(self): + """Manage entity population to prevent performance issues""" + current_count = self.environment.getNumEntities() + + # Check if we need to cull entities + cull_threshold = int(self.config.maxEntities * self.config.entityCullThreshold) + + if current_count > cull_threshold: + target_count = int(self.config.maxEntities * self.config.entityCullTarget) + removed_entities = self.environment.cullWeakestEntities(target_count, self.playerCreature) + + if removed_entities: + print(f"Population management: Removed {len(removed_entities)} weak entities (Population: {current_count} -> {self.environment.getNumEntities()})") + + def canCreateNewEntity(self): + """Check if we can create a new entity without exceeding limits""" + return self.environment.getNumEntities() < self.config.maxEntities def regenerateAllEntities(self): """Regenerate health for all living entities""" @@ -124,11 +148,20 @@ def regenerateAllEntities(self): entity.regenerateHealth() def createEntity(self): + if not self.canCreateNewEntity(): + return None newEntity = LivingEntity(self.names[random.randint(0, len(self.names) - 1)]) self.environment.addEntity(newEntity) + return newEntity def createChildEntity(self, parent1, parent2): """Create a child entity with proper parent-child relationships""" + if not self.canCreateNewEntity(): + # Population limit reached, no new child can be created + parent1.addLogEntry(f"{parent1.name} and {parent2.name} tried to have a child, but the world is too crowded!") + parent2.addLogEntry(f"{parent1.name} and {parent2.name} tried to have a child, but the world is too crowded!") + return None + childName = self.names[random.randint(0, len(self.names) - 1)] child = LivingEntity(childName) @@ -147,8 +180,9 @@ def createChildEntity(self, parent1, parent2): child.health = parentHealthAvg + random.randint(-10, 10) # Add some variation child.maxHealth = child.health - child.log.append( - "%s is the child of %s and %s." % (childName, parent1.name, parent2.name) + child.addLogEntry( + "%s is the child of %s and %s." % (childName, parent1.name, parent2.name), + self.config.entityLogMaxSize ) self.environment.addEntity(child) @@ -209,6 +243,42 @@ def continueAsChild(self): return False + def monitorPerformance(self, tick_duration): + """Monitor tick performance and adjust max entities dynamically""" + # Track recent tick times + self.tickTimes.append(tick_duration) + + # Keep only recent performance window + if len(self.tickTimes) > self.config.performanceWindow: + self.tickTimes = self.tickTimes[-self.config.performanceWindow:] + + # Only adjust after we have some data + if len(self.tickTimes) >= 5: + self.adjustMaxEntitiesBasedOnLag(self.getAverageTickTime()) + + def getAverageTickTime(self): + """Compute the average tick time over the tracked performance window""" + if not self.tickTimes: + return 0.0 + return sum(self.tickTimes) / len(self.tickTimes) + + def adjustMaxEntitiesBasedOnLag(self, avg_tick_time): + """Dynamically adjust max entities based on performance""" + current_max = self.config.maxEntities + + if avg_tick_time > self.config.lagThreshold: + # Performance is poor, reduce max entities + new_max = max(self.config.minEntities, int(current_max * 0.8)) + if new_max != current_max: + self.config.maxEntities = new_max + print(f"Performance lag detected (avg: {avg_tick_time:.3f}s). Reducing max entities to {new_max}") + elif avg_tick_time < self.config.lagThreshold * 0.5: + # Performance is good, cautiously increase max entities + new_max = min(self.config.maxEntitiesLimit, int(current_max * 1.1)) + if new_max != current_max and self.environment.getNumEntities() > current_max * 0.8: + self.config.maxEntities = new_max + print(f"Good performance (avg: {avg_tick_time:.3f}s). Increasing max entities to {new_max}") + def printSummary(self): print("=== Summary ===") if self.playerCreature.chanceToFight > self.playerCreature.chanceToBefriend: @@ -244,6 +314,11 @@ def printSummary(self): print("%s died during the simulation." % self.playerCreature.name) print("Kreatures still alive: %d" % self.environment.getNumEntities()) print("Simulation ran for %d ticks." % self.tick) + + # Show performance and dynamic entity limit info + if self.tickTimes: + print("Average tick time: %.4f seconds" % self.getAverageTickTime()) + print("Final max entities limit: %d (started at 50)" % self.config.maxEntities) def printStats(self): print("=== Stats ===") @@ -251,8 +326,18 @@ def printStats(self): print("Babies made: %d" % self.playerCreature.stats.numOffspring) print("Creatures Eaten: %d" % self.playerCreature.stats.numCreaturesEaten) + def placePlayerCreature(self): + """Put the player's creature at the front of the world's entity list. + + The player is inserted rather than assigned over index 0: the world's + starter entities are all real creatures now that the "placeholder" + string is gone, so overwriting index 0 would silently delete the first + starter creature (Alison) from the world. + """ + self.environment.entities.insert(0, self.playerCreature) + def run(self): - self.environment.entities[0] = self.playerCreature + self.placePlayerCreature() print("") # code to run a day, then show any new additions to log @@ -269,9 +354,19 @@ def run(self): except: # if list is empty, just keep going pass + # Monitor performance and run simulation tick + tick_start_time = time.time() + self.initiateEntityActions() self.updatePlayerProtection() # Update player protection status self.regenerateAllEntities() # Regenerate health for all entities + + tick_end_time = time.time() + tick_duration = tick_end_time - tick_start_time + + # Track performance and adjust entity limits dynamically + self.monitorPerformance(tick_duration) + time.sleep(self.config.tickLength) self.tick += 1 if self.tick >= self.config.maxTicks: diff --git a/src/world/world.py b/src/world/world.py index 7cb7924..0f84dc9 100644 --- a/src/world/world.py +++ b/src/world/world.py @@ -23,7 +23,6 @@ def __init__(self): self.Jasper = LivingEntity("Jasper") self.starterEntities = [ - "placeholder", self.Alison, self.Barry, self.Conrad, @@ -45,6 +44,16 @@ def addEntity(self, entity): def removeEntity(self, entity): self.entities.remove(entity) + def removeEntities(self, entities): + """Remove multiple entities in a single O(n) pass instead of one + O(n) list.remove() call per entity (which is O(n*k) overall and + directly undermines the population-limit feature's goal of + avoiding per-tick lag at high entity counts).""" + if not entities: + return + to_remove = set(entities) + self.entities = [e for e in self.entities if e not in to_remove] + def getNumEntities(self): return len(self.entities) @@ -52,4 +61,29 @@ def getEntities(self): return self.entities def getRandomEntity(self): + if len(self.entities) == 0: + return None return self.entities[random.randint(0, len(self.entities) - 1)] + + def cullWeakestEntities(self, targetCount, protectedEntity=None): + """Remove the weakest entities to reduce population to targetCount""" + if len(self.entities) <= targetCount: + return [] + + # Create list of entities that can be culled (excluding protected entity) + cullable_entities = [e for e in self.entities if e != protectedEntity] + + if len(cullable_entities) == 0: + return [] + + # Sort by health (weakest first), then by number of children (fewer children first) + cullable_entities.sort(key=lambda x: (x.health, len(x.children))) + + # Calculate how many to remove + num_to_remove = min(len(self.entities) - targetCount, len(cullable_entities)) + + # Remove the weakest entities in a single O(n) pass + removed_entities = cullable_entities[:num_to_remove] + self.removeEntities(removed_entities) + + return removed_entities diff --git a/tests/test_dynamic_entities.py b/tests/test_dynamic_entities.py new file mode 100644 index 0000000..433d53f --- /dev/null +++ b/tests/test_dynamic_entities.py @@ -0,0 +1,217 @@ +# Copyright (c) 2022 Daniel McCoy Stephenson +# Apache License 2.0 +import sys +import os +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src')) + +import pytest +from unittest.mock import patch +from config.config import Config + + +class TestDynamicEntityLimits: + """Test suite for dynamic entity limit adjustment based on performance""" + + def test_config_has_dynamic_settings(self): + """Test that config includes dynamic performance settings""" + config = Config() + assert hasattr(config, 'maxEntities') + assert hasattr(config, 'minEntities') + assert hasattr(config, 'maxEntitiesLimit') + assert hasattr(config, 'lagThreshold') + assert hasattr(config, 'performanceWindow') + + # Check reasonable defaults + assert config.minEntities < config.maxEntities < config.maxEntitiesLimit + assert config.lagThreshold > 0 + assert config.performanceWindow > 0 + + def test_performance_monitoring_initialization(self): + """Test that Kreatures initializes performance monitoring""" + from kreatures import Kreatures + + with patch('builtins.input', return_value='TestPlayer'): + with patch('builtins.print'): + game = Kreatures() + + assert hasattr(game, 'tickTimes') + assert isinstance(game.tickTimes, list) + assert len(game.tickTimes) == 0 + + def test_monitor_performance_tracks_times(self): + """Test that performance monitoring tracks tick times""" + from kreatures import Kreatures + + with patch('builtins.input', return_value='TestPlayer'): + with patch('builtins.print'): + game = Kreatures() + + # Simulate some tick times + game.monitorPerformance(0.01) # Fast tick + game.monitorPerformance(0.02) # Another fast tick + game.monitorPerformance(0.03) # Slower tick + + assert len(game.tickTimes) == 3 + assert 0.01 in game.tickTimes + assert 0.02 in game.tickTimes + assert 0.03 in game.tickTimes + + def test_performance_window_limit(self): + """Test that performance monitoring respects window size""" + from kreatures import Kreatures + + with patch('builtins.input', return_value='TestPlayer'): + with patch('builtins.print'): + game = Kreatures() + game.config.performanceWindow = 3 + + # Add more tick times than window size + for i in range(5): + game.monitorPerformance(0.01 * (i + 1)) + + # Should only keep the most recent window size + assert len(game.tickTimes) == 3 + assert game.tickTimes == [0.03, 0.04, 0.05] + + def test_adjust_max_entities_reduces_on_lag(self): + """Test that max entities is reduced when lag is detected""" + from kreatures import Kreatures + + with patch('builtins.input', return_value='TestPlayer'): + with patch('builtins.print') as mock_print: + game = Kreatures() + initial_max = game.config.maxEntities + + # Simulate high lag (above threshold) + lag_time = game.config.lagThreshold * 2 # Double the threshold + game.adjustMaxEntitiesBasedOnLag(lag_time) + + # Max entities should be reduced + assert game.config.maxEntities < initial_max + assert game.config.maxEntities >= game.config.minEntities + + # Should print a message about reducing entities + mock_print.assert_called() + printed_text = str(mock_print.call_args) + assert "lag detected" in printed_text.lower() + assert "reducing" in printed_text.lower() + + def test_adjust_max_entities_increases_on_good_performance(self): + """Test that max entities is increased when performance is good""" + from kreatures import Kreatures + + with patch('builtins.input', return_value='TestPlayer'): + with patch('builtins.print') as mock_print: + game = Kreatures() + initial_max = game.config.maxEntities + + # Add some entities to make the system consider increasing limit + from entity.livingEntity import LivingEntity + for i in range(int(initial_max * 0.9)): # Fill to 90% capacity + entity = LivingEntity(f"Entity{i}") + game.environment.addEntity(entity) + + # Simulate very good performance (well below threshold) + good_time = game.config.lagThreshold * 0.25 # Quarter of the threshold + game.adjustMaxEntitiesBasedOnLag(good_time) + + # Max entities should be increased (but not exceed limit) + assert game.config.maxEntities >= initial_max + assert game.config.maxEntities <= game.config.maxEntitiesLimit + + # Should print a message about increasing entities + mock_print.assert_called() + printed_text = str(mock_print.call_args) + assert "good performance" in printed_text.lower() + assert "increasing" in printed_text.lower() + + def test_max_entities_respects_hard_limits(self): + """Test that max entities never goes below min or above hard limit""" + from kreatures import Kreatures + + with patch('builtins.input', return_value='TestPlayer'): + with patch('builtins.print'): + game = Kreatures() + + # Test lower bound - extreme lag + extreme_lag_time = game.config.lagThreshold * 10 + for _ in range(10): # Multiple adjustments + game.adjustMaxEntitiesBasedOnLag(extreme_lag_time) + + assert game.config.maxEntities >= game.config.minEntities + + # Reset and test upper bound - extreme good performance + game.config.maxEntities = game.config.maxEntitiesLimit - 10 + from entity.livingEntity import LivingEntity + for i in range(game.config.maxEntities): + entity = LivingEntity(f"Entity{i}") + game.environment.addEntity(entity) + + excellent_time = game.config.lagThreshold * 0.1 + for _ in range(10): # Multiple adjustments + game.adjustMaxEntitiesBasedOnLag(excellent_time) + + assert game.config.maxEntities <= game.config.maxEntitiesLimit + + def test_no_adjustment_with_insufficient_data(self): + """Test that no adjustment occurs without sufficient performance data""" + from kreatures import Kreatures + + with patch('builtins.input', return_value='TestPlayer'): + with patch('builtins.print'): + game = Kreatures() + initial_max = game.config.maxEntities + + # Add only a few data points (less than 5) + game.monitorPerformance(0.1) # High lag time + game.monitorPerformance(0.1) # High lag time + + # Max entities should not change + assert game.config.maxEntities == initial_max + + +class TestDynamicEntityIntegration: + """Test suite for integration of dynamic entity limits with existing systems""" + + def test_dynamic_limits_work_with_population_management(self): + """Test that dynamic limits integrate with existing population management""" + from kreatures import Kreatures + + with patch('builtins.input', return_value='TestPlayer'): + with patch('builtins.print'): + game = Kreatures() + + # Reduce max entities dynamically + game.config.maxEntities = 30 + + # Fill beyond the new limit + from entity.livingEntity import LivingEntity + for i in range(35): + entity = LivingEntity(f"Entity{i}") + game.environment.addEntity(entity) + + # Population management should respect the new dynamic limit + game.managePopulation() + + # Should have culled based on the new limit + assert game.environment.getNumEntities() <= game.config.maxEntities + + def test_child_creation_respects_dynamic_limits(self): + """Test that child creation uses the current dynamic max entities""" + from kreatures import Kreatures + + with patch('builtins.input', return_value='TestPlayer'): + with patch('builtins.print'): + game = Kreatures() + + # Dynamically reduce the limit to current population + current_pop = game.environment.getNumEntities() + game.config.maxEntities = current_pop + + # Try to create a child - should fail + from entity.livingEntity import LivingEntity + parent1 = LivingEntity("Parent1") + parent2 = LivingEntity("Parent2") + + child = game.createChildEntity(parent1, parent2) + assert child is None # Should be blocked by dynamic limit \ No newline at end of file diff --git a/tests/test_lag_prevention.py b/tests/test_lag_prevention.py new file mode 100644 index 0000000..357b439 --- /dev/null +++ b/tests/test_lag_prevention.py @@ -0,0 +1,236 @@ +# Copyright (c) 2022 Daniel McCoy Stephenson +# Apache License 2.0 +import sys +import os +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src')) + +import pytest +import random +from unittest.mock import patch, MagicMock +from entity.livingEntity import LivingEntity +from world.world import World +from config.config import Config + + +class TestPopulationControl: + """Test suite for population management and lag prevention""" + + def test_config_has_population_settings(self): + """Test that config includes population control settings""" + config = Config() + assert hasattr(config, 'maxEntities') + assert hasattr(config, 'entityCullThreshold') + assert hasattr(config, 'entityLogMaxSize') + assert config.maxEntities > 0 + assert 0 < config.entityCullThreshold < 1 + + def test_entity_log_size_management(self): + """Test that entity logs are kept within size limits""" + entity = LivingEntity("TestEntity") + + # Add many log entries + for i in range(100): + entity.addLogEntry(f"Log entry {i}", maxLogSize=10) + + # Should only keep the most recent 10 entries + assert len(entity.log) == 10 + assert "Log entry 99" in entity.log[-1] + assert "Log entry 90" in entity.log[0] + + def test_world_cull_weakest_entities(self): + """Test that world can cull weakest entities""" + world = World() + + # Clear initial entities and add test entities with different health + world.entities = [] + strong_entity = LivingEntity("Strong") + strong_entity.health = 100 + weak_entity1 = LivingEntity("Weak1") + weak_entity1.health = 10 + weak_entity2 = LivingEntity("Weak2") + weak_entity2.health = 20 + + world.addEntity(strong_entity) + world.addEntity(weak_entity1) + world.addEntity(weak_entity2) + + # Cull to 1 entity + removed = world.cullWeakestEntities(1) + + assert len(removed) == 2 + assert len(world.entities) == 1 + assert world.entities[0] == strong_entity + + def test_world_cull_protects_player(self): + """Test that culling protects the player entity""" + world = World() + world.entities = [] + + player = LivingEntity("Player") + player.health = 10 # Very weak + other1 = LivingEntity("Other1") + other1.health = 50 + other2 = LivingEntity("Other2") + other2.health = 60 + + world.addEntity(player) + world.addEntity(other1) + world.addEntity(other2) + + # Cull to 1, but protect player + removed = world.cullWeakestEntities(1, protectedEntity=player) + + assert len(world.entities) == 1 + assert player in world.entities + assert len(removed) == 2 + + def test_world_get_random_entity_empty(self): + """Test that getRandomEntity handles empty entity list""" + world = World() + world.entities = [] + + result = world.getRandomEntity() + assert result is None + + +class TestLagPreventionIntegration: + """Test suite for integration scenarios with lag prevention""" + + def test_child_creation_respects_population_limit(self): + """Test that child creation is blocked when population limit is reached""" + from kreatures import Kreatures + + with patch('builtins.input', return_value='TestPlayer'): + with patch('builtins.print'): + game = Kreatures() + + # World starts with 10 entities + player = 11, so set limit to current count + initial_count = game.environment.getNumEntities() + game.config.maxEntities = initial_count # Set limit to current population + + # Try to create a child - should fail because we're at the limit + parent1 = LivingEntity("Parent1") + parent2 = LivingEntity("Parent2") + + child = game.createChildEntity(parent1, parent2) + assert child is None + + # Population should stay at limit + assert game.environment.getNumEntities() == game.config.maxEntities + + def test_population_management_triggers_culling(self): + """Test that population management triggers culling when needed""" + from kreatures import Kreatures + + with patch('builtins.input', return_value='TestPlayer'): + with patch('builtins.print'): + game = Kreatures() + + # Set low limits to trigger culling + game.config.maxEntities = 10 + game.config.entityCullThreshold = 0.8 # Cull at 8 entities + + # Add entities to trigger culling threshold + while game.environment.getNumEntities() < 9: # Above threshold + entity = LivingEntity("TestEntity") + entity.health = random.randint(10, 100) + game.environment.addEntity(entity) + + initial_count = game.environment.getNumEntities() + + # Trigger population management + game.managePopulation() + + # Should have fewer entities now + final_count = game.environment.getNumEntities() + assert final_count < initial_count + assert final_count <= int(game.config.maxEntities * 0.7) # Target is 70% of max + + def test_can_create_new_entity_logic(self): + """Test the logic for determining if new entities can be created""" + from kreatures import Kreatures + + with patch('builtins.input', return_value='TestPlayer'): + with patch('builtins.print'): + game = Kreatures() + + # Set limit higher than current count to allow creation + current_count = game.environment.getNumEntities() + game.config.maxEntities = current_count + 5 + + # Should be able to create when under limit + assert game.canCreateNewEntity() == True + + # Set limit to current count to block creation + game.config.maxEntities = current_count + + # Should not be able to create when at limit + assert game.canCreateNewEntity() == False + + +class TestPerformanceOptimizations: + """Test suite for performance-related optimizations""" + + def test_entity_addlogentry_uses_config_limit(self): + """Test that addLogEntry respects the configured log size limit""" + config = Config() + entity = LivingEntity("TestEntity") + + # Add more entries than the config limit + for i in range(config.entityLogMaxSize + 20): + entity.addLogEntry(f"Entry {i}", maxLogSize=config.entityLogMaxSize) + + # Should not exceed the configured limit + assert len(entity.log) <= config.entityLogMaxSize + + def test_empty_entity_list_handling(self): + """Test that empty entity lists are handled gracefully""" + from kreatures import Kreatures + + with patch('builtins.input', return_value='TestPlayer'): + with patch('builtins.print'): + game = Kreatures() + + # Clear all entities except player + game.environment.entities = [game.playerCreature] + + # Should handle empty interactions gracefully + game.initiateEntityActions() # Should not crash + + assert len(game.environment.entities) == 1 # Player should remain + + +class TestWorldInitializationFix: + """Test suite for the World initialization bug fix""" + + def test_world_starts_without_placeholder(self): + """Test that World no longer includes 'placeholder' string in entities""" + world = World() + + # All entities should be LivingEntity instances, not strings + for entity in world.entities: + assert isinstance(entity, LivingEntity) + assert hasattr(entity, 'name') + assert hasattr(entity, 'health') + + def test_player_creature_does_not_displace_a_starter_entity(self): + """Test that placing the player keeps every starter creature in the world + + With the "placeholder" string removed from the starter entities, index 0 + holds a real creature, so the player has to be inserted rather than + assigned over it. + """ + from kreatures import Kreatures + + with patch('builtins.input', return_value='TestPlayer'): + with patch('builtins.print'): + game = Kreatures() + + starters = list(game.environment.getEntities()) + + game.placePlayerCreature() + + # The player acts first, and no starter creature was dropped + assert game.environment.getEntities()[0] is game.playerCreature + assert game.environment.getEntities()[1:] == starters + assert game.environment.getNumEntities() == len(starters) + 1 \ No newline at end of file