From 319801d6e51b8a248546ec1b2ed49f8089f93401 Mon Sep 17 00:00:00 2001 From: Matteo Benedetto Date: Wed, 17 Jun 2026 10:29:26 +0200 Subject: [PATCH] Fix rat reproduction collision check Replace the brittle 'self.position == other.position_before' check with a symmetric cell-intersection test: two rats are considered colliding if any of their current/previous cells overlap. This handles both 'both rats in same cell' and 'one rat enters the cell the other just left'. Also remove the erroneous hasattr(other_unit, 'fuck') guard that prevented Male.fuck() from being called on Female (Female has no fuck method, but only Male should initiate reproduction). Add tests/test_rat_reproduction.py with 8 scenarios covering overlapping rats, baby rats, already-pregnant females, far-apart rats, female self-initiation, sound playback, and procreate interval spawning. --- tests/test_rat_reproduction.py | 300 +++++++++++++++++++++++++++++++++ units/rat.py | 10 +- 2 files changed, 307 insertions(+), 3 deletions(-) create mode 100644 tests/test_rat_reproduction.py diff --git a/tests/test_rat_reproduction.py b/tests/test_rat_reproduction.py new file mode 100644 index 0000000..26a454a --- /dev/null +++ b/tests/test_rat_reproduction.py @@ -0,0 +1,300 @@ +"""Test for rat reproduction collision logic. + +Simulates Male.fuck() being called on collision between adult male and female rats +in various positions (overlapping, entering same cell, etc.) to verify that +reproduction triggers correctly in all expected cases. +""" + +import sys +import os +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import random +from unittest.mock import MagicMock + +from engine.collision_system import CollisionSystem +from engine import maze +from units import rat as rat_module +from units.unit import UnitType + + +def make_game(cell_size=40, width=10, height=10, with_sound=True): + game = MagicMock() + game.cell_size = cell_size + + # Minimal maze: everything empty + m = maze.Map.__new__(maze.Map) + m.width = width + m.height = height + m.tiles = [[maze.MAP_EMPTY for _ in range(width)] for _ in range(height)] + m.in_bounds = lambda x, y: 0 <= x < width and 0 <= y < height + m.is_wall = lambda x, y: False + m.is_empty = lambda x, y: True + m.is_traversable = lambda x, y: True + m.is_tunnel = lambda x, y: False + game.map = m + + game.collision_system = CollisionSystem(cell_size, width, height) + game.units = {} + game.unit_positions = {} + game.unit_positions_before = {} + game.scoring = MagicMock() + # Mock graphics with realistic image sizes for rat bbox + graphics = MagicMock() + image_size = (36, 36) + graphics.rat_image_sizes = { + "MALE": {d: image_size for d in ["UP", "DOWN", "LEFT", "RIGHT"]}, + "FEMALE": {d: image_size for d in ["UP", "DOWN", "LEFT", "RIGHT"]}, + "BABY": {d: (24, 24) for d in ["UP", "DOWN", "LEFT", "RIGHT"]}, + } + graphics.rat_assets_textures = {} + graphics.bomb_assets = {} + graphics.assets = {} + graphics.blood_layer_sprites = [] + graphics.cave_foreground_tiles = [] + graphics.draw_maze = MagicMock() + graphics.regenerate_background = MagicMock() + graphics.draw_blood_layer = MagicMock() + game.graphics = graphics + game.profile_integration = MagicMock() + game.profile_integration.get_device_leaderboard = MagicMock(return_value=[]) + game.current_level = 0 + game.points = 0 + game.sound_volume = 50 + game.music_volume = 50 + game.ammo = {"bomb": {"count": 0, "max": 0}} + game.game_status = "playing" + game.combined_scores = None + game.game_end = (False, None) + game.audio = False + game.ammo_sound = None + + # Mock render engine + render = MagicMock() + render.play_sound = MagicMock() + if not with_sound: + render.play_sound = MagicMock(side_effect=AssertionError("sound should not play")) + game.render_engine = render + + # Unit manager + manager = MagicMock() + manager.get_unit_by_id = lambda uid: game.units.get(uid) + manager.spawn_rat = MagicMock(side_effect=lambda pos=None: None) + game.unit_manager = manager + + return game + + +def make_rat(game, rat_class, position, age=None): + rat = rat_class(game, position=position) + if age is not None: + rat.age = age + else: + rat.age = rat_module.AGE_THRESHOLD + 10 # adult + # Make sure stop and partial_move are valid + rat.stop = 0 + rat.partial_move = 0.0 + # Compute bbox for collision system + rat._update_render_position() + # Add to game's units so collisions can find them + game.units[rat.id] = rat + # Register in collision system + cs = game.collision_system + cs.register_unit(rat.id, rat.bbox, rat.position, rat.position_before, rat.collision_layer) + return rat + + +def test_reproduction_when_both_rats_share_current_cell(): + """Both rats end up in the same cell at the same time.""" + game = make_game() + male = make_rat(game, rat_module.Male, (3, 3)) + female = make_rat(game, rat_module.Female, (3, 3)) + + # Both just arrived in (3,3) - position_before == position for both. + # bbox uses position (not position_before) when partial_move==0 after arrival. + male.position = (3, 3) + male.position_before = (3, 3) + female.position = (3, 3) + female.position_before = (3, 3) + female.pregnant = False + female.babies = 0 + + # Trigger collision check + male.collisions() + + assert female.pregnant == rat_module.PREGNANCY_DURATION, \ + f"Female should be pregnant, got pregnant={female.pregnant}" + assert female.babies >= 1 and female.babies <= 3, \ + f"Babies count should be 1-3, got {female.babies}" + assert male.stop == 100 + assert female.stop == 200 + print("PASS: reproduction when both rats share current cell") + + +def test_reproduction_when_male_enters_female_previous_cell(): + """Male just stepped into the cell the female was previously in.""" + game = make_game() + male = make_rat(game, rat_module.Male, (2, 3)) + female = make_rat(game, rat_module.Female, (3, 3)) + + # Male is in (3,3) having moved from (2,3); female is transitioning from (3,3) to (4,3) + # at partial_move ~0.3, so her bbox straddles cell 3 and cell 4. + male.position = (3, 3) + male.position_before = (3, 3) + male.partial_move = 0.0 + female.position = (4, 3) + female.position_before = (3, 3) + female.partial_move = 0.3 + female.pregnant = False + female.babies = 0 + male.direction = "RIGHT" + female.direction = "RIGHT" + male._update_render_position() + female._update_render_position() + game.collision_system.clear() + game.collision_system.register_unit(male.id, male.bbox, male.position, male.position_before, male.collision_layer) + game.collision_system.register_unit(female.id, female.bbox, female.position, female.position_before, female.collision_layer) + + male.collisions() + + assert female.pregnant == rat_module.PREGNANCY_DURATION, \ + f"Female should be pregnant, got pregnant={female.pregnant}" + print("PASS: reproduction when male enters female's previous cell") + + +def test_no_reproduction_when_already_pregnant(): + """Female already pregnant: fuck should be a no-op (idempotent).""" + game = make_game() + male = make_rat(game, rat_module.Male, (3, 3)) + female = make_rat(game, rat_module.Female, (3, 3)) + + male.position = (3, 3) + male.position_before = (2, 3) + female.position = (3, 3) + female.position_before = (4, 3) + female.pregnant = True + female.pregnant_counter = 100 # track if re-set + female.babies = 2 + + male.collisions() + + # pregnancy counter should not be reset + assert female.pregnant is True + assert female.babies == 2, f"Babies should remain 2, got {female.babies}" + print("PASS: no reproduction when already pregnant") + + +def test_no_reproduction_when_rats_far_apart(): + """Rats in different cells: no reproduction.""" + game = make_game() + male = make_rat(game, rat_module.Male, (2, 3)) + female = make_rat(game, rat_module.Female, (5, 5)) + + male.position = (2, 3) + male.position_before = (2, 3) + female.position = (5, 5) + female.position_before = (5, 5) + female.pregnant = False + female.babies = 0 + + male.collisions() + + assert female.pregnant is False, \ + f"Female should NOT be pregnant, got pregnant={female.pregnant}" + print("PASS: no reproduction when rats far apart") + + +def test_no_reproduction_when_baby_rats(): + """Baby rats (age < threshold) should not reproduce.""" + game = make_game() + male = make_rat(game, rat_module.Male, (3, 3), age=10) + female = make_rat(game, rat_module.Female, (3, 3), age=10) + + male.position = (3, 3) + male.position_before = (2, 3) + female.position = (3, 3) + female.position_before = (4, 3) + female.pregnant = False + female.babies = 0 + + male.collisions() + + assert female.pregnant is False, \ + "Baby rats should not reproduce" + print("PASS: no reproduction for baby rats") + + +def test_female_does_not_initiate_reproduction(): + """Female calling collisions() should not initiate reproduction on its own. + + This is intentional: only Male has the fuck() method. Female is the receiver. + """ + game = make_game() + male = make_rat(game, rat_module.Male, (3, 3)) + female = make_rat(game, rat_module.Female, (3, 3)) + + male.position = (3, 3) + male.position_before = (2, 3) + female.position = (3, 3) + female.position_before = (4, 3) + female.pregnant = False + female.babies = 0 + + # Female calling collisions() should NOT change pregnant state + female.collisions() + + assert female.pregnant is False, \ + "Female should not initiate her own pregnancy" + print("PASS: female does not initiate reproduction") + + +def test_reproduction_sound_played(): + """Verify SEX.WAV sound is played on reproduction.""" + game = make_game() + male = make_rat(game, rat_module.Male, (3, 3)) + female = make_rat(game, rat_module.Female, (3, 3)) + + male.position = (3, 3) + male.position_before = (2, 3) + female.position = (3, 3) + female.position_before = (4, 3) + female.pregnant = False + female.babies = 0 + + male.collisions() + + game.render_engine.play_sound.assert_called_with("SEX.WAV") + print("PASS: SEX.WAV sound played") + + +def test_procreate_spawns_baby_at_interval(): + """Female.procreate() spawns a baby after each BABY_INTERVAL tick of pregnancy.""" + game = make_game() + female = make_rat(game, rat_module.Female, (3, 3)) + female.babies = 3 + # procreate() first decrements pregnant, then checks if pregnant == babies * BABY_INTERVAL. + # So pregnant must start at babies * BABY_INTERVAL + 1 to trigger after decrement. + female.pregnant = female.babies * rat_module.BABY_INTERVAL + 1 + female.position = (3, 3) + female.position_before = (3, 3) + + initial_babies = female.babies + female.procreate() + + assert game.unit_manager.spawn_rat.called, "spawn_rat should be called" + assert female.babies == initial_babies - 1, \ + f"Babies should decrease by 1, got {female.babies}" + assert female.stop == 20 + print("PASS: procreate spawns baby at correct interval") + + +if __name__ == "__main__": + test_reproduction_when_both_rats_share_current_cell() + test_reproduction_when_male_enters_female_previous_cell() + test_no_reproduction_when_already_pregnant() + test_no_reproduction_when_rats_far_apart() + test_no_reproduction_when_baby_rats() + test_female_does_not_initiate_reproduction() + test_reproduction_sound_played() + test_procreate_spawns_baby_at_interval() + print("\nAll tests passed!") diff --git a/units/rat.py b/units/rat.py index 5d7bc43..21c79fb 100644 --- a/units/rat.py +++ b/units/rat.py @@ -138,8 +138,12 @@ class Rat(Unit): if other_unit.age < AGE_THRESHOLD: continue - # Check if units are actually moving towards each other - if self.position != other_unit.position_before: + # Check if units are actually overlapping (same cell now or in transition). + # Accept any of: same current cell, same previous cell, or each entered + # the other's previous cell. + self_here = (self.position, self.position_before) + other_here = (other_unit.position, other_unit.position_before) + if not (set(self_here) & set(other_here)): continue # Both units still exist in game @@ -148,7 +152,7 @@ class Rat(Unit): # Same sex + fight mode = combat self.die(other_unit) elif self.sex != other_unit.sex: - # Different sex = reproduction + # Different sex = reproduction (only Male has fuck method) if hasattr(self, 'fuck'): self.fuck(other_unit)