From b1d0d9a4390ac1ac11f0af2506ea9e96e67cc3f4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Sep 2025 04:13:36 +0000 Subject: [PATCH 1/9] Initial plan From bb1b90fb4a446a97ee1f88eb8cc3fa1a2578fb1b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Sep 2025 04:23:15 +0000 Subject: [PATCH 2/9] Implement comprehensive lag prevention mechanisms Co-authored-by: dmccoystephenson <21204351+dmccoystephenson@users.noreply.github.com> --- src/config/config.py | 5 + src/entity/livingEntity.py | 33 +++--- src/kreatures.py | 45 ++++++-- src/world/world.py | 30 ++++- tests/test_lag_prevention.py | 214 +++++++++++++++++++++++++++++++++++ 5 files changed, 306 insertions(+), 21 deletions(-) create mode 100644 tests/test_lag_prevention.py diff --git a/src/config/config.py b/src/config/config.py index c8b4090..dcabbd7 100644 --- a/src/config/config.py +++ b/src/config/config.py @@ -14,3 +14,8 @@ 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 = 100 # Maximum number of entities allowed in simulation + self.entityCullThreshold = 0.9 # Cull entities when population reaches 90% of max + self.entityLogMaxSize = 50 # Maximum number of log entries per entity to prevent memory bloat diff --git a/src/entity/livingEntity.py b/src/entity/livingEntity.py index 7e6233d..927483f 100644 --- a/src/entity/livingEntity.py +++ b/src/entity/livingEntity.py @@ -44,8 +44,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 +64,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 +92,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 +159,14 @@ 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""" + self.log.append(message) + # Keep only the most recent entries to prevent memory bloat + if len(self.log) > maxLogSize: + self.log = self.log[-maxLogSize:] diff --git a/src/kreatures.py b/src/kreatures.py index 8c5ed43..17ef9f8 100644 --- a/src/kreatures.py +++ b/src/kreatures.py @@ -40,7 +40,7 @@ def __init__(self): # 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 initiateEntityActions(self): entities_to_remove = [] # Track entities that die this turn @@ -48,13 +48,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": @@ -70,7 +70,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 @@ -91,6 +91,9 @@ def initiateEntityActions(self): # Remove all entities that died this turn for entity in entities_to_remove: self.environment.removeEntity(entity) + + # Manage population to prevent lag + self.managePopulation() def updatePlayerProtection(self): """Update player protection based on current tick""" @@ -98,7 +101,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 * 0.7) # Reduce to 70% of max + 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""" @@ -107,11 +128,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) @@ -130,8 +160,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) diff --git a/src/world/world.py b/src/world/world.py index 7cb7924..6bec90b 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, @@ -52,4 +51,33 @@ 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 and hasattr(e, 'health')] + + 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(getattr(x, 'children', [])))) + + # Calculate how many to remove + entities_to_remove = len(self.entities) - targetCount + entities_to_remove = min(entities_to_remove, len(cullable_entities)) + + # Remove the weakest entities + removed_entities = [] + for i in range(entities_to_remove): + entity = cullable_entities[i] + self.removeEntity(entity) + removed_entities.append(entity) + + return removed_entities diff --git a/tests/test_lag_prevention.py b/tests/test_lag_prevention.py new file mode 100644 index 0000000..10e14af --- /dev/null +++ b/tests/test_lag_prevention.py @@ -0,0 +1,214 @@ +# 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') \ No newline at end of file From 21490478c6c16ffc17f2ca684ecedb86b69e61e1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Sep 2025 04:24:55 +0000 Subject: [PATCH 3/9] Implement comprehensive lag prevention mechanisms to prevent simulation performance degradation Co-authored-by: dmccoystephenson <21204351+dmccoystephenson@users.noreply.github.com> --- .coverage | Bin 53248 -> 53248 bytes cov.xml | 450 +++++++++++++++++++++++++++++++++--------------------- 2 files changed, 277 insertions(+), 173 deletions(-) diff --git a/.coverage b/.coverage index a378a20b7f21a98c9314e8fb56f256b8ce681f08..abd75334093a6a71d05c55f281ae713529de2866 100644 GIT binary patch delta 418 zcmZozz}&Ead4s3}w>|G)-o3m{ydJzvn*{|P@l0m&ZD6wGntagLij|Xvp^!l7^(JTe2{W=!uJhC7F3&H@Nzn(Bn=Ab$GK%s3UGgE+#(~+f-6~fK(6Jq7CVXg)9Pz{gkRTRr) z;Qzt@j{iCTDgHzJJNbL~TllN_Gl7Qt^4AM+u`qHfu}hr$|L^>^x1z4kxY;>@A_8m; z@@yPH1|KVW?p|9&y{J^a7;Kl5J%I&=ZQG{{^=b}lf*#tEiaIlvUxYlIXIa>7IJbd HT3`SGqv2?g delta 381 zcmYk0KS)AB9LMk8y?^)e-tS4M{Tb3J2$Y6~(n#Hk@YNWEN`q4kQ8v6Gc-h!sa9fLy zR+qpfMAD_Pe^YxcO*OQLf_CTB5AHs9@cn-H`2q0*au>->rP+XWS&eNnr4@EQLR=~M zO2*=OH>0glf;@iVF4>1Cm4oWpN#!(k>}{C5TgYOA_jXb|=}u!^gp;kNTgFj4!-miW z4_56V>oH=t?Q?c1#GfHfiayVk)-@|lJS?s3zm@WbViucxRLl!?u9RvuOZ$mv6+s_< z;0vDM5jw!(3TjXl!aXR5EEUr^Yvy@8Y9zM|Dt?acFX*Ot*cx6m1b1ow9-;4}?eI+u zUAN;3LXLyvnBw?xc^SHFsy@***(7*Y6KeF@Rrq ggN|4xfO+Y_mNkJ+C(z6ZD1UDxOjD6^YTO3Pe_Rb+4*&oF diff --git a/cov.xml b/cov.xml index 6801238..2e043c7 100644 --- a/cov.xml +++ b/cov.xml @@ -1,220 +1,260 @@ - + /home/runner/work/Kreatures/Kreatures/src - + - + - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + - - - + - - - + + + - - - - - - + + + - + + + - + - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + - + + - - - - - - + + + + + + + + - - + - - - + - - - - + - - + - + + + + + + + - + + - + + - - + - - + - - - + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - + - - - - - + + + + + + + + + + - + - + - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + - + @@ -229,90 +269,100 @@ - + - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + - - - - - - - - - - + + + + + + + + + + - - + - + - + - + + - + + + - + - - - - - + + + + + + + + + + + + + + - + - + - - - + + + - + @@ -322,15 +372,69 @@ - + - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 88692a2e806a91b6a0dbc7329185424f7207551a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 30 Sep 2025 05:07:58 +0000 Subject: [PATCH 4/9] Implement dynamic max entities based on lag recognition Co-authored-by: dmccoystephenson <21204351+dmccoystephenson@users.noreply.github.com> --- src/config/config.py | 8 +- src/kreatures.py | 50 ++++++++ tests/test_dynamic_entities.py | 217 +++++++++++++++++++++++++++++++++ 3 files changed, 274 insertions(+), 1 deletion(-) create mode 100644 tests/test_dynamic_entities.py diff --git a/src/config/config.py b/src/config/config.py index dcabbd7..bb2be50 100644 --- a/src/config/config.py +++ b/src/config/config.py @@ -16,6 +16,12 @@ def __init__(self): # During grace period, other creatures have 85% chance to avoid attacking player # Population control settings to prevent lag - self.maxEntities = 100 # Maximum number of entities allowed in simulation + 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.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/kreatures.py b/src/kreatures.py index 17ef9f8..609fa8f 100644 --- a/src/kreatures.py +++ b/src/kreatures.py @@ -38,6 +38,9 @@ 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.addLogEntry("%s has early-game protection!" % self.playerCreature.name) @@ -223,6 +226,37 @@ 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: + avg_tick_time = sum(self.tickTimes) / len(self.tickTimes) + self.adjustMaxEntitiesBasedOnLag(avg_tick_time) + + 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: @@ -258,6 +292,12 @@ 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: + avg_tick_time = sum(self.tickTimes) / len(self.tickTimes) + print("Average tick time: %.4f seconds" % avg_tick_time) + print("Final max entities limit: %d (started at 50)" % self.config.maxEntities) def printStats(self): print("=== Stats ===") @@ -283,9 +323,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/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 From 6a4f32b3b0547346ec7f236169823384898949f7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Oct 2025 05:36:55 +0000 Subject: [PATCH 5/9] Merge master branch and resolve conflicts in cov.xml Co-authored-by: dmccoystephenson <21204351+dmccoystephenson@users.noreply.github.com> --- .gitignore | 7 +- src/config/names.json | 433 ++++++++++++++++++++++++++++++++++++++ src/kreatures.py | 53 +++-- tests/test_integration.py | 98 +++++++++ tests/test_names.py | 360 +++++++++++++++++++++++++++++++ 5 files changed, 932 insertions(+), 19 deletions(-) create mode 100644 src/config/names.json create mode 100644 tests/test_integration.py create mode 100644 tests/test_names.py 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/src/config/names.json b/src/config/names.json new file mode 100644 index 0000000..6380d20 --- /dev/null +++ b/src/config/names.json @@ -0,0 +1,433 @@ +{ + "names": [ + "Jesse", + "Juan", + "Jose", + "Ralph", + "Jeremy", + "Bobby", + "Johnny", + "Douglas", + "Peter", + "Scott", + "Kyle", + "Billy", + "Terry", + "Randy", + "Adam", + "Alexander", + "Andrew", + "Anthony", + "Austin", + "Benjamin", + "Brandon", + "Brian", + "Christopher", + "Daniel", + "David", + "Edward", + "Ethan", + "Gabriel", + "George", + "Henry", + "Isaac", + "Jacob", + "James", + "Jason", + "John", + "Joseph", + "Joshua", + "Kevin", + "Lucas", + "Matthew", + "Michael", + "Nathan", + "Nicholas", + "Oliver", + "Patrick", + "Richard", + "Robert", + "Samuel", + "Stephen", + "Thomas", + "Timothy", + "Tyler", + "William", + "Zachary", + "Abigail", + "Alice", + "Amanda", + "Amy", + "Angela", + "Anna", + "Ashley", + "Barbara", + "Betty", + "Brenda", + "Brittany", + "Carol", + "Catherine", + "Charlotte", + "Christina", + "Christine", + "Deborah", + "Diana", + "Donna", + "Dorothy", + "Elizabeth", + "Emily", + "Emma", + "Grace", + "Hannah", + "Helen", + "Isabella", + "Jessica", + "Jennifer", + "Julie", + "Karen", + "Katherine", + "Kimberly", + "Laura", + "Linda", + "Lisa", + "Margaret", + "Maria", + "Marie", + "Mary", + "Michelle", + "Nancy", + "Nicole", + "Olivia", + "Patricia", + "Rachel", + "Rebecca", + "Ruth", + "Sandra", + "Sarah", + "Sharon", + "Sophia", + "Susan", + "Victoria", + "Aiden", + "Aria", + "Avery", + "Blake", + "Caleb", + "Chloe", + "Dakota", + "Elijah", + "Felix", + "Harper", + "Hunter", + "Ian", + "Jaxon", + "Liam", + "Luna", + "Madison", + "Mason", + "Maya", + "Mia", + "Noah", + "Owen", + "Parker", + "Quinn", + "Riley", + "Sage", + "Taylor", + "Zoe", + "Ahmed", + "Aiko", + "Carlos", + "Chen", + "Diego", + "Elena", + "Giovanni", + "Hassan", + "Ivan", + "Jin", + "Kai", + "Lucia", + "Mohammed", + "Natasha", + "Oscar", + "Pierre", + "Raj", + "Sofia", + "Takeshi", + "Valentina", + "Wei", + "Yuki", + "Zara", + "Aaron", + "Adrian", + "Adriana", + "Albert", + "Alicia", + "Allen", + "Allison", + "Andre", + "Andrea", + "Antonio", + "Ariana", + "Arthur", + "Ashton", + "Aubrey", + "Audrey", + "Autumn", + "Bella", + "Bernard", + "Bethany", + "Beverly", + "Brandi", + "Brooke", + "Bruce", + "Cameron", + "Camila", + "Carl", + "Carmen", + "Caroline", + "Cassandra", + "Cedric", + "Celeste", + "Charles", + "Chelsea", + "Chester", + "Christian", + "Claire", + "Clara", + "Clarence", + "Claudia", + "Clayton", + "Clifford", + "Colin", + "Colleen", + "Cooper", + "Courtney", + "Crystal", + "Curtis", + "Cynthia", + "Damian", + "Dana", + "Danielle", + "Darius", + "Darryl", + "Dawn", + "Dean", + "Debbie", + "Dennis", + "Derek", + "Desiree", + "Devin", + "Diane", + "Dominic", + "Donovan", + "Edgar", + "Edith", + "Edmund", + "Eduardo", + "Edwin", + "Eleanor", + "Elias", + "Ellen", + "Elsie", + "Emanuel", + "Emilia", + "Eric", + "Erica", + "Ernest", + "Eugene", + "Eva", + "Evan", + "Evelyn", + "Faith", + "Fernando", + "Florence", + "Floyd", + "Frances", + "Francisco", + "Frank", + "Frederick", + "Gabriela", + "Gareth", + "Garrett", + "Geoffrey", + "Gerald", + "Gilbert", + "Gina", + "Glenn", + "Gloria", + "Gordon", + "Graham", + "Grant", + "Gregory", + "Gwendolyn", + "Harold", + "Hazel", + "Heather", + "Hector", + "Herbert", + "Holly", + "Howard", + "Hugo", + "Iris", + "Jack", + "Jackie", + "Jackson", + "Jacqueline", + "Jake", + "Janet", + "Javier", + "Jeffery", + "Jenna", + "Jerome", + "Jillian", + "Joanna", + "Joel", + "Johnathan", + "Jonah", + "Jonathan", + "Jordan", + "Josiah", + "Joyce", + "Judith", + "Julian", + "Juliana", + "Julius", + "June", + "Justin", + "Kaitlyn", + "Keith", + "Kelvin", + "Kenneth", + "Kent", + "Kirk", + "Kristen", + "Lance", + "Larry", + "Lauren", + "Lawrence", + "Leah", + "Leonard", + "Lillian", + "Logan", + "Lorraine", + "Louis", + "Louise", + "Lucy", + "Luis", + "Luke", + "Lydia", + "Marcus", + "Marina", + "Mark", + "Marshall", + "Martin", + "Marvin", + "Mateo", + "Maureen", + "Maurice", + "Max", + "Melanie", + "Melissa", + "Miguel", + "Miranda", + "Mitchell", + "Monica", + "Morgan", + "Natalie", + "Neil", + "Nelson", + "Nora", + "Norman", + "Omar", + "Paige", + "Paul", + "Penny", + "Philip", + "Phyllis", + "Preston", + "Priscilla", + "Quentin", + "Raymond", + "Regina", + "Reginald", + "Renee", + "Ricardo", + "Rita", + "Roberto", + "Robin", + "Roger", + "Ronald", + "Rosa", + "Rose", + "Roy", + "Ruby", + "Russell", + "Ryan", + "Sabrina", + "Sally", + "Samantha", + "Sean", + "Sebastian", + "Sergio", + "Shane", + "Sheila", + "Shelby", + "Shirley", + "Simon", + "Stacy", + "Stanley", + "Stella", + "Stephanie", + "Steve", + "Steven", + "Stuart", + "Tamara", + "Tanya", + "Tara", + "Teresa", + "Theresa", + "Todd", + "Tony", + "Tracy", + "Trevor", + "Troy", + "Vanessa", + "Vernon", + "Victor", + "Vincent", + "Virginia", + "Walter", + "Warren", + "Wayne", + "Wendy", + "Wesley", + "Whitney" + ], + "metadata": { + "total_count": 403, + "description": "Names available for Kreatures entities", + "categories": { + "original": [ + "Jesse", + "Juan", + "Jose", + "Ralph", + "Jeremy", + "Bobby", + "Johnny", + "Douglas", + "Peter", + "Scott", + "Kyle", + "Billy", + "Terry", + "Randy", + "Adam" + ], + "traditional": "Names 16-150 approximately", + "modern": "Names 151-200 approximately", + "international": "Names 201+ approximately" + } + } +} \ No newline at end of file diff --git a/src/kreatures.py b/src/kreatures.py index 609fa8f..447b3c3 100644 --- a/src/kreatures.py +++ b/src/kreatures.py @@ -1,5 +1,7 @@ # Copyright (c) 2022 Daniel McCoy Stephenson # Apache License 2.0 +import json +import os import random import time from world.world import World @@ -11,24 +13,7 @@ class Kreatures: def __init__(self): self.environment = World() - - self.names = [ - "Jesse", - "Juan", - "Jose", - "Ralph", - "Jeremy", - "Bobby", - "Johnny", - "Douglas", - "Peter", - "Scott", - "Kyle", - "Billy", - "Terry", - "Randy", - "Adam", - ] + self.names = self._load_names() print("What would you like to name your kreature?") self.creatureName = input("> ") @@ -45,6 +30,38 @@ def __init__(self): self.playerCreature.damageReduction = self.config.playerDamageReduction self.playerCreature.addLogEntry("%s has early-game protection!" % self.playerCreature.name) + def _load_names(self): + """Load names from configuration file""" + try: + # Get the directory of this file to build the path to names.json + current_dir = os.path.dirname(os.path.abspath(__file__)) + names_file = os.path.join(current_dir, "config", "names.json") + + with open(names_file, "r") as f: + config = json.load(f) + return config["names"] + except (FileNotFoundError, KeyError, json.JSONDecodeError) as e: + # Fallback to a minimal set of names if config file is missing/corrupted + print(f"Warning: Could not load names from config file: {e}") + print("Using fallback names list.") + return [ + "Jesse", + "Juan", + "Jose", + "Ralph", + "Jeremy", + "Bobby", + "Johnny", + "Douglas", + "Peter", + "Scott", + "Kyle", + "Billy", + "Terry", + "Randy", + "Adam", + ] + def initiateEntityActions(self): entities_to_remove = [] # Track entities that die this turn diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..a7a6a01 --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,98 @@ +# Copyright (c) 2022 Daniel McCoy Stephenson +# Apache License 2.0 + +import sys +import os +import unittest +from unittest.mock import patch + +# Add src to path to import modules +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) + +from entity.livingEntity import LivingEntity + + +class TestKreaturesIntegration(unittest.TestCase): + """Integration test to verify the game works with expanded names""" + + @patch("builtins.input", return_value="TestPlayer") + @patch("builtins.print") + def setUp(self, mock_print, mock_input): + """Set up test environment""" + from kreatures import Kreatures + + self.kreatures = Kreatures() + + def test_create_multiple_entities_have_different_names(self): + """Test that creating multiple entities can result in different names""" + # Create several entities and collect their names + entity_names = set() + for _ in range(20): + self.kreatures.createEntity() + + # Get names of all entities (excluding player creature and starter entities) + # Filter out non-LivingEntity objects (like the "placeholder" string) + for entity in self.kreatures.environment.getEntities(): + if hasattr(entity, "name") and entity != self.kreatures.playerCreature: + entity_names.add(entity.name) + + # With 159 names available, we should see some variety + # Even with random selection, getting 20+ different names should be likely + self.assertGreater( + len(entity_names), 1, "Should have at least some name variety" + ) + + def test_child_entities_get_names_from_expanded_list(self): + """Test that child entities use names from the expanded list""" + parent1 = LivingEntity("Parent1") + parent2 = LivingEntity("Parent2") + + # Create multiple children to test name variety + child_names = set() + for _ in range(10): + child = self.kreatures.createChildEntity(parent1, parent2) + child_names.add(child.name) + # Remove child to avoid cluttering the environment + self.kreatures.environment.removeEntity(child) + + # All child names should be from our expanded list + for name in child_names: + self.assertIn(name, self.kreatures.names) + + # With 159 names, we should get some variety + self.assertGreaterEqual(len(child_names), 1) + + def test_world_starter_entities_still_work(self): + """Test that the world still initializes with starter entities correctly""" + entities = self.kreatures.environment.getEntities() + + # Should have player creature plus starter entities from world + self.assertGreater(len(entities), 1) + + # Verify starter entities have valid names + starter_names = [ + "Alison", + "Barry", + "Conrad", + "Derrick", + "Eric", + "Francis", + "Gary", + "Harry", + "Isabelle", + "Jasper", + ] + + # Filter out non-LivingEntity objects (like the "placeholder" string) + found_starter_names = [ + entity.name + for entity in entities + if hasattr(entity, "name") and entity.name in starter_names + ] + self.assertGreater( + len(found_starter_names), 0, "Should find some starter entities" + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_names.py b/tests/test_names.py new file mode 100644 index 0000000..db04986 --- /dev/null +++ b/tests/test_names.py @@ -0,0 +1,360 @@ +# Copyright (c) 2022 Daniel McCoy Stephenson +# Apache License 2.0 + +import sys +import os +import unittest +from unittest.mock import patch + +# Add src to path to import modules +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) + +from entity.livingEntity import LivingEntity + + +class TestNames(unittest.TestCase): + """Test that the names functionality works correctly""" + + @patch("builtins.input", return_value="TestPlayer") + @patch("builtins.print") + def setUp(self, mock_print, mock_input): + """Set up test environment""" + # Import Kreatures class only (not the module level instance) + from kreatures import Kreatures + + self.kreatures = Kreatures() + + def test_names_list_exists(self): + """Test that names list exists and has content""" + self.assertIsInstance(self.kreatures.names, list) + self.assertGreater(len(self.kreatures.names), 0) + + def test_names_are_strings(self): + """Test that all names are strings""" + for name in self.kreatures.names: + self.assertIsInstance(name, str) + self.assertGreater(len(name), 0) # Non-empty strings + + def test_create_entity_uses_name_from_list(self): + """Test that createEntity uses a name from the names list""" + entity = LivingEntity(self.kreatures.names[0]) + self.assertIn(entity.name, self.kreatures.names) + + def test_create_child_entity_uses_name_from_list(self): + """Test that createChildEntity uses a name from the names list""" + parent1 = LivingEntity("Parent1") + parent2 = LivingEntity("Parent2") + + child = self.kreatures.createChildEntity(parent1, parent2) + self.assertIn(child.name, self.kreatures.names) + + def test_names_contain_original_names(self): + """Test that original names are still present""" + original_names = [ + "Jesse", + "Juan", + "Jose", + "Ralph", + "Jeremy", + "Bobby", + "Johnny", + "Douglas", + "Peter", + "Scott", + "Kyle", + "Billy", + "Terry", + "Randy", + "Adam", + ] + for name in original_names: + self.assertIn(name, self.kreatures.names) + + def test_names_list_expanded(self): + """Test that names list has been significantly expanded""" + # Should be much larger than the original 15 names + self.assertGreater(len(self.kreatures.names), 400) + + def test_names_include_diverse_names(self): + """Test that expanded names include diverse/modern names""" + expected_new_names = [ + "Sophia", + "Liam", + "Emma", + "Noah", + "Olivia", + "Alexander", + "Isabella", + "Mason", + "Aria", + "Diego", + "Sofia", + "Ahmed", + "Chen", + "Elena", + "Hassan", + "Yuki", + "Zara", + ] + for name in expected_new_names: + self.assertIn(name, self.kreatures.names) + + def test_names_no_duplicates(self): + """Test that there are no duplicate names in the list""" + names_set = set(self.kreatures.names) + self.assertEqual( + len(names_set), + len(self.kreatures.names), + "Names list should not contain duplicates", + ) + + def test_names_proper_capitalization(self): + """Test that all names are properly capitalized""" + for name in self.kreatures.names: + self.assertTrue( + name[0].isupper(), f"Name '{name}' should start with uppercase letter" + ) + # Check that rest of name is lowercase (basic name format) + if len(name) > 1: + self.assertTrue( + name[1:].islower(), + f"Name '{name}' should have only first letter capitalized", + ) + + def test_names_length_validation(self): + """Test that all names have reasonable lengths""" + for name in self.kreatures.names: + self.assertGreaterEqual( + len(name), 2, f"Name '{name}' should be at least 2 characters long" + ) + self.assertLessEqual( + len(name), 15, f"Name '{name}' should be at most 15 characters long" + ) + + def test_names_alphabetic_characters(self): + """Test that all names contain only alphabetic characters""" + for name in self.kreatures.names: + self.assertTrue( + name.isalpha(), + f"Name '{name}' should contain only alphabetic characters", + ) + + def test_name_categories_present(self): + """Test that names from different categories are present""" + # Traditional male names + traditional_male = [ + "Alexander", + "Andrew", + "Benjamin", + "Christopher", + "Daniel", + "David", + "James", + "John", + "Michael", + "William", + ] + male_count = sum(1 for name in traditional_male if name in self.kreatures.names) + self.assertGreater(male_count, 5, "Should have multiple traditional male names") + + # Traditional female names + traditional_female = [ + "Elizabeth", + "Emily", + "Emma", + "Jessica", + "Jennifer", + "Katherine", + "Margaret", + "Mary", + "Patricia", + "Sarah", + ] + female_count = sum( + 1 for name in traditional_female if name in self.kreatures.names + ) + self.assertGreater( + female_count, 5, "Should have multiple traditional female names" + ) + + # International names + international = ["Ahmed", "Chen", "Diego", "Hassan", "Yuki", "Zara", "Sofia"] + intl_count = sum(1 for name in international if name in self.kreatures.names) + self.assertGreater(intl_count, 3, "Should have multiple international names") + + # Modern names + modern = ["Aiden", "Aria", "Blake", "Hunter", "Luna", "Mason", "Quinn", "Riley"] + modern_count = sum(1 for name in modern if name in self.kreatures.names) + self.assertGreater(modern_count, 3, "Should have multiple modern names") + + def test_random_name_selection_variety(self): + """Test that random name selection provides variety over multiple calls""" + import random + + # Seed random for reproducible results + random.seed(42) + + selected_names = set() + # Create many entities to test variety + for _ in range(50): + self.kreatures.createEntity() + + # Collect names of created entities (excluding starter entities and player) + starter_names = [ + "Alison", + "Barry", + "Conrad", + "Derrick", + "Eric", + "Francis", + "Gary", + "Harry", + "Isabelle", + "Jasper", + ] + + for entity in self.kreatures.environment.getEntities(): + if ( + hasattr(entity, "name") + and entity != self.kreatures.playerCreature + and entity.name not in starter_names + ): + selected_names.add(entity.name) + + # With 403 names and 50 entities, we should get reasonable variety + # Expect at least 10 different names (conservative estimate accounting for randomness) + self.assertGreaterEqual( + len(selected_names), + 10, + f"Should have reasonable variety in selected names. Got: {selected_names}", + ) + + def test_create_entity_always_uses_valid_name(self): + """Test that createEntity always selects a valid name from the list""" + # Test multiple entity creations + for _ in range(20): + initial_count = len(self.kreatures.environment.getEntities()) + self.kreatures.createEntity() + new_count = len(self.kreatures.environment.getEntities()) + + # Should have added exactly one entity + self.assertEqual(new_count, initial_count + 1) + + # Get the newly created entity + new_entity = self.kreatures.environment.getEntities()[-1] + if hasattr(new_entity, "name"): # Filter out placeholder strings + self.assertIn(new_entity.name, self.kreatures.names) + + def test_create_child_entity_always_uses_valid_name(self): + """Test that createChildEntity always selects a valid name from the list""" + parent1 = LivingEntity("TestParent1") + parent2 = LivingEntity("TestParent2") + + # Test multiple child creations + for _ in range(20): + child = self.kreatures.createChildEntity(parent1, parent2) + self.assertIn(child.name, self.kreatures.names) + # Clean up to avoid environment clutter + if child in self.kreatures.environment.getEntities(): + self.kreatures.environment.removeEntity(child) + + def test_name_selection_robustness(self): + """Test that name selection handles edge cases properly""" + # Verify names list is not empty (should never happen but good to test) + self.assertGreater(len(self.kreatures.names), 0) + + # Verify all indices in range are valid + import random + + for _ in range(100): # Test many random selections + random_index = random.randint(0, len(self.kreatures.names) - 1) + selected_name = self.kreatures.names[random_index] + self.assertIsInstance(selected_name, str) + self.assertGreater(len(selected_name), 0) + + def test_exact_name_count(self): + """Test that we have exactly the expected number of names""" + # Should be 403 names total (15 original + 388 new) + self.assertEqual(len(self.kreatures.names), 403) + + def test_name_distribution_across_alphabet(self): + """Test that names are distributed across different starting letters""" + first_letters = set() + for name in self.kreatures.names: + first_letters.add(name[0].upper()) + + # Should have names starting with various letters of the alphabet + # With 403 diverse names, we should have at least 20 different starting letters + self.assertGreaterEqual( + len(first_letters), + 20, + f"Names should start with diverse letters. Found: {sorted(first_letters)}", + ) + + def test_names_loaded_from_config_file(self): + """Test that names are successfully loaded from configuration file""" + # This test verifies that the _load_names method works correctly + self.assertIsInstance(self.kreatures.names, list) + self.assertEqual(len(self.kreatures.names), 403) + + # Verify that the names loaded match what we expect from the config file + expected_first_names = ["Jesse", "Juan", "Jose", "Ralph", "Jeremy"] + actual_first_names = self.kreatures.names[:5] + self.assertEqual(actual_first_names, expected_first_names) + + def test_config_file_fallback_behavior(self): + """Test fallback behavior when config file is missing or corrupted""" + from unittest.mock import patch + from kreatures import Kreatures + + # Test with missing config file + with patch("os.path.join") as mock_join: + mock_join.return_value = "/nonexistent/path/names.json" + with patch("builtins.input", return_value="TestPlayer"), patch( + "builtins.print" + ): + k = Kreatures() + # Should fallback to 15 original names + self.assertEqual(len(k.names), 15) + self.assertIn("Jesse", k.names) + self.assertIn("Adam", k.names) + + def test_config_file_json_structure(self): + """Test that the config file has the expected JSON structure""" + import json + import os + + # Get path to names.json + current_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + names_file = os.path.join(current_dir, "src", "config", "names.json") + + # Verify file exists + self.assertTrue(os.path.exists(names_file)) + + # Verify JSON structure + with open(names_file, "r") as f: + config = json.load(f) + + self.assertIn("names", config) + self.assertIn("metadata", config) + self.assertIsInstance(config["names"], list) + self.assertEqual(len(config["names"]), 403) + + # Verify metadata structure + metadata = config["metadata"] + self.assertIn("total_count", metadata) + self.assertIn("description", metadata) + self.assertEqual(metadata["total_count"], 403) + + def test_load_names_method_directly(self): + """Test the _load_names method directly""" + names = self.kreatures._load_names() + + self.assertIsInstance(names, list) + self.assertEqual(len(names), 403) + self.assertIn("Jesse", names) # First original name + self.assertIn("Whitney", names) # Last name in current list + + +if __name__ == "__main__": + unittest.main() From 3a9a2d8867b8aec77cc3063401750d1db47da1dd Mon Sep 17 00:00:00 2001 From: Daniel McCoy Stephenson Date: Sun, 19 Jul 2026 17:21:57 -0600 Subject: [PATCH 6/9] refactor: fix O(n^2) entity removal and dedupe perf-tracking code This PR's whole point is avoiding per-tick lag at high entity counts, so these fixes target the mechanism itself: - World.removeEntity() was called once per dead/culled entity via list.remove() (O(n) each), both in the per-tick death cleanup (kreatures.py initiateEntityActions) and in cullWeakestEntities - O(n*k) overall on the exact hot path this feature exists to keep cheap. Added World.removeEntities() to filter the entity list once in O(n) and used it in both call sites. - LivingEntity.addLogEntry() copied up to maxLogSize elements via list slicing on every call once the log hit its cap; log entries are appended on nearly every action, every tick, for every entity. Now backed by a deque bounded to maxLogSize for O(1) amortized appends. - Removed dead hasattr(e, 'health')/getattr(x, 'children', []) checks in cullWeakestEntities - every entity is a LivingEntity, which always sets both attributes. - Deduped the average-tick-time computation (was independently recomputed in monitorPerformance and printSummary) into a single getAverageTickTime() helper. - Named the previously-magic 0.7 cull-target fraction as config.entityCullTarget, matching the existing entityCullThreshold pattern. Left game.tickTimes as a plain list (not a deque) despite the same per-tick slicing pattern - tests assert isinstance(game.tickTimes, list) and list equality, and the window is only 10 entries so the cost is negligible. All 64 existing tests pass unchanged. Security review of this PR's diff found no concrete vulnerabilities to fix (no new I/O, network, subprocess, eval, or user-input-to-sensitive-sink flows). Co-Authored-By: Claude Sonnet 5 --- src/config/config.py | 1 + src/entity/livingEntity.py | 15 +++++++++++---- src/kreatures.py | 22 +++++++++++++--------- src/world/world.py | 36 +++++++++++++++++++++--------------- 4 files changed, 46 insertions(+), 28 deletions(-) diff --git a/src/config/config.py b/src/config/config.py index bb2be50..53e6581 100644 --- a/src/config/config.py +++ b/src/config/config.py @@ -20,6 +20,7 @@ def __init__(self): 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 diff --git a/src/entity/livingEntity.py b/src/entity/livingEntity.py index 927483f..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 @@ -165,8 +166,14 @@ def regenerateHealth(self): ) def addLogEntry(self, message, maxLogSize=50): - """Add a log entry and maintain log size limit""" + """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) - # Keep only the most recent entries to prevent memory bloat - if len(self.log) > maxLogSize: - self.log = self.log[-maxLogSize:] diff --git a/src/kreatures.py b/src/kreatures.py index 447b3c3..dbd9133 100644 --- a/src/kreatures.py +++ b/src/kreatures.py @@ -108,10 +108,10 @@ 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() @@ -131,7 +131,7 @@ def managePopulation(self): cull_threshold = int(self.config.maxEntities * self.config.entityCullThreshold) if current_count > cull_threshold: - target_count = int(self.config.maxEntities * 0.7) # Reduce to 70% of max + target_count = int(self.config.maxEntities * self.config.entityCullTarget) removed_entities = self.environment.cullWeakestEntities(target_count, self.playerCreature) if removed_entities: @@ -254,8 +254,13 @@ def monitorPerformance(self, tick_duration): # Only adjust after we have some data if len(self.tickTimes) >= 5: - avg_tick_time = sum(self.tickTimes) / len(self.tickTimes) - self.adjustMaxEntitiesBasedOnLag(avg_tick_time) + 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""" @@ -312,8 +317,7 @@ def printSummary(self): # Show performance and dynamic entity limit info if self.tickTimes: - avg_tick_time = sum(self.tickTimes) / len(self.tickTimes) - print("Average tick time: %.4f seconds" % avg_tick_time) + print("Average tick time: %.4f seconds" % self.getAverageTickTime()) print("Final max entities limit: %d (started at 50)" % self.config.maxEntities) def printStats(self): diff --git a/src/world/world.py b/src/world/world.py index 6bec90b..0f84dc9 100644 --- a/src/world/world.py +++ b/src/world/world.py @@ -44,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) @@ -61,23 +71,19 @@ def cullWeakestEntities(self, targetCount, protectedEntity=None): return [] # Create list of entities that can be culled (excluding protected entity) - cullable_entities = [e for e in self.entities if e != protectedEntity and hasattr(e, 'health')] - + 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(getattr(x, 'children', [])))) - + cullable_entities.sort(key=lambda x: (x.health, len(x.children))) + # Calculate how many to remove - entities_to_remove = len(self.entities) - targetCount - entities_to_remove = min(entities_to_remove, len(cullable_entities)) - - # Remove the weakest entities - removed_entities = [] - for i in range(entities_to_remove): - entity = cullable_entities[i] - self.removeEntity(entity) - removed_entities.append(entity) - + 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 From 9d0b4d521f61884ea9bc15f2a51962013c7ca143 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 20 Jul 2026 02:38:50 +0000 Subject: [PATCH 7/9] fix: resolve merge conflicts - remove duplicate _load_names and untrack cov.xml --- cov.xml | 447 ----------------------------------------------- src/kreatures.py | 32 ---- 2 files changed, 479 deletions(-) delete mode 100644 cov.xml diff --git a/cov.xml b/cov.xml deleted file mode 100644 index a4756db..0000000 --- a/cov.xml +++ /dev/null @@ -1,447 +0,0 @@ - -<<<<<<< HEAD - -======= - ->>>>>>> origin/master - - - - /home/runner/work/Kreatures/Kreatures/src - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/kreatures.py b/src/kreatures.py index 7082d19..dbd9133 100644 --- a/src/kreatures.py +++ b/src/kreatures.py @@ -62,38 +62,6 @@ def _load_names(self): "Adam", ] - def _load_names(self): - """Load names from configuration file""" - try: - # Get the directory of this file to build the path to names.json - current_dir = os.path.dirname(os.path.abspath(__file__)) - names_file = os.path.join(current_dir, "config", "names.json") - - with open(names_file, "r") as f: - config = json.load(f) - return config["names"] - except (FileNotFoundError, KeyError, json.JSONDecodeError) as e: - # Fallback to a minimal set of names if config file is missing/corrupted - print(f"Warning: Could not load names from config file: {e}") - print("Using fallback names list.") - return [ - "Jesse", - "Juan", - "Jose", - "Ralph", - "Jeremy", - "Bobby", - "Johnny", - "Douglas", - "Peter", - "Scott", - "Kyle", - "Billy", - "Terry", - "Randy", - "Adam", - ] - def initiateEntityActions(self): entities_to_remove = [] # Track entities that die this turn From 1059a2f4064402e22473f138a4931c76f0051c1e Mon Sep 17 00:00:00 2001 From: Daniel McCoy Stephenson Date: Thu, 6 Aug 2026 20:08:32 -0600 Subject: [PATCH 8/9] fix: keep every starter creature when the player joins the world Removing the "placeholder" string from World's starter entities left a real creature at index 0, so run()'s `entities[0] = playerCreature` silently deleted Alison from the world. Insert the player instead, via a new placePlayerCreature() method so the behavior is directly testable. Co-Authored-By: Claude Opus 5 (1M context) --- src/kreatures.py | 12 +++++++++++- tests/test_lag_prevention.py | 24 +++++++++++++++++++++++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/kreatures.py b/src/kreatures.py index dbd9133..d0bfcc1 100644 --- a/src/kreatures.py +++ b/src/kreatures.py @@ -326,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 diff --git a/tests/test_lag_prevention.py b/tests/test_lag_prevention.py index 10e14af..357b439 100644 --- a/tests/test_lag_prevention.py +++ b/tests/test_lag_prevention.py @@ -211,4 +211,26 @@ def test_world_starts_without_placeholder(self): for entity in world.entities: assert isinstance(entity, LivingEntity) assert hasattr(entity, 'name') - assert hasattr(entity, 'health') \ No newline at end of file + 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 From c88e85468a0a837806b841cef8f0a93fcf7526f2 Mon Sep 17 00:00:00 2001 From: Daniel McCoy Stephenson Date: Thu, 6 Aug 2026 20:09:19 -0600 Subject: [PATCH 9/9] chore: stop tracking the generated .coverage database .gitignore already lists .coverage alongside cov.xml, but the file stayed tracked, so every local test run dirtied the working tree and re-created the merge conflicts this PR's .gitignore change was meant to end. Co-Authored-By: Claude Opus 5 (1M context) --- .coverage | Bin 53248 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 .coverage diff --git a/.coverage b/.coverage deleted file mode 100644 index abd75334093a6a71d05c55f281ae713529de2866..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 53248 zcmeI5?{6bV8OL|MYp>VwdQ(o32~m{+33AaTaU)(_u7|j!TqI7dR0=1AR0(%&kCWx> z-Q4avi3O*g?<$=@@RBzOc)=fmC@MuNFOYaa)fY&-K_Fg~3hDX=AysWDJkS1!?MqT+ zU!5AhBYSpscAlB%`OGsjp555_#tYB6krZ7&=sS`4qB5(fse^wfa-*3o~z3`{oa3er`Bsn=T-L00@8p2>gE& z7`;$6>htsJy*rW9>d7c@+A>ID>nmU1-nzLhZf-sE+_p&a#KnrBu(~R?MBpEYJsF6u z+mpieI&RyETyIB2yD}?12xW&CI?AJ^jtj2E@|RV4pp%ixP2!Wid%BH5*J8Y zi{xRHWKbp2-SN1DxR{DBi$Hc|AicH>6D=;fo#o2%{g*37{lW$HfsAc(f;PQormUfi zu_-sYR8iXxI;?#waJ=@e3|EA6WMSJUJrASA+Q9EOB46}eFE-N)-N^MlArEDH5XsJo z2Cgu)ED*d&YpAZ5*r1mZ$+WErI%DAzB-7%laF>+x{0!?@keepqSZ8n0ZxQnE4ElTA zIV6!Z9Qmh7o+calsj+A7n-ltSZ0}TeX2`im6RCmc-X6%K&R-E@$(7g)&tDO21bM*c zXG--e7gUA3QXYo4d-Qp$=?o%2K5tUzH`ghuzVbxbs4p(64{pbf!g!i3Hwupu=9JSM zWuM|YeEoF4;ZZW}Bs_|GJ>hrbA;Z!l=^XOr$uZN&U^oE}G~uTONz=#bZLBQmAX+`Y zHSKG$$Appw2+ajqvF^ZY^H2%dhPJ;i17}CBrB{<_;c+_>xz4&-s&6lz<~m0%&|E)C zRz~wBqrR}9-ZNu2N_#6k7*j5k_2dcDXm+VDop2Cj6UoO-l9|MKmdwUTdWFncHA?lZ zh11MQ%Qe^2M0sTBMtyEh9VH`z=Oum@r;Om7@ep`qAvnAGdrq_~j_c0Zr1(;?RKGfR zn&PZNbDiVT{TBQWB25>XRL2h!T3C5Po3YS>a8fJH z{X96ion#UrL9s-GGNd8;NS#@w=~bSwvijHdhzZx)cRI;%=T4cZ@nBaTt1elMt~-&8 z+`bfCBvvMNtOF+$?Lg8+T1%Igw2yNG?ycrJ$Gl4}^GCVSqH5GHT~bGbbTB6KOf#fK zz3*gdr>BJ}Cwb)J*k7Kf370vJnhCPX>(xKe*L+ zmcRB*zkLu+q{5)x2qPy78_lNcxlyx8-#y z(Jz~%x_Wk5`eB8xyTiZWK?joGER%dOxBP_iR}D?MbM^`dY_G$3dmU>2YAKg+0zQsO z_*#*4*UsMbbhmNqdal;HJ!dCu#4)$7U{00@8p2!H?xfB*=900@8p2%KvIno5uT`T4(Uf2hz40R%t* z1V8`;KmY_l00ck)1V8`;K;Rq_&@{{B=l>PkRqXfZKLGs0{)7F7{i^*#`xX1Ul!*WW zAOHd&00JNY0w4eaAOHd&00JP8kHBn2SIy5EpZ?>2KYH_*59hYuo-s_ynJwL2ER`uy zE2@_aI)1D2-metp-QJ5&{G_hT{^`fwoc>R0sa%em(<$DZ<;W7B6