Implement atomic food claiming mechanism to prevent race conditions during consumption

This commit is contained in:
Sam 2025-11-09 17:09:14 -06:00
parent 78438ae768
commit 19b946949d
2 changed files with 32 additions and 4 deletions

View File

@ -96,11 +96,27 @@ class FoodObject(BaseEntity):
self.max_decay = 400 self.max_decay = 400
self.interaction_radius: int = 50 self.interaction_radius: int = 50
self.neighbors: int = 0 self.neighbors: int = 0
self.claimed_this_tick = False # Track if food was claimed this tick
self.flags: dict[str, bool] = { self.flags: dict[str, bool] = {
"death": False, "death": False,
"can_interact": True, "can_interact": True,
} }
def try_claim(self) -> bool:
"""
Atomically claims this food for consumption within the current tick.
:return: True if successfully claimed, False if already claimed this tick
"""
if not self.claimed_this_tick:
self.claimed_this_tick = True
return True
return False
def reset_claim(self) -> None:
"""Reset the claim for next tick"""
self.claimed_this_tick = False
def tick(self, interactable: Optional[List[BaseEntity]] = None) -> Union["FoodObject", List["FoodObject"]]: def tick(self, interactable: Optional[List[BaseEntity]] = None) -> Union["FoodObject", List["FoodObject"]]:
""" """
Updates the food object, increasing decay and flagging for death if decayed. Updates the food object, increasing decay and flagging for death if decayed.
@ -108,6 +124,9 @@ class FoodObject(BaseEntity):
:param interactable: List of nearby entities (unused). :param interactable: List of nearby entities (unused).
:return: Self :return: Self
""" """
# Reset claim at the beginning of each tick
self.reset_claim()
if interactable is None: if interactable is None:
interactable = [] interactable = []
@ -300,6 +319,8 @@ class DefaultCell(BaseEntity):
distance_to_food = get_distance_between_objects(self, food_object) distance_to_food = get_distance_between_objects(self, food_object)
if distance_to_food < self.max_visual_width and food_objects: if distance_to_food < self.max_visual_width and food_objects:
# Use atomic consumption to prevent race conditions
if food_object.try_claim():
self.energy += 140 self.energy += 140
food_object.flag_for_death() food_object.flag_for_death()
return self return self

View File

@ -170,6 +170,8 @@ class World:
next_buffer: int = 1 - self.current_buffer next_buffer: int = 1 - self.current_buffer
self.buffers[next_buffer].clear() self.buffers[next_buffer].clear()
# Food claims auto-reset in FoodObject.tick()
for obj_list in self.buffers[self.current_buffer].values(): for obj_list in self.buffers[self.current_buffer].values():
for obj in obj_list: for obj in obj_list:
if obj.flags["death"]: if obj.flags["death"]:
@ -178,6 +180,9 @@ class World:
interactable = self.query_objects_within_radius( interactable = self.query_objects_within_radius(
obj.position.x, obj.position.y, obj.interaction_radius obj.position.x, obj.position.y, obj.interaction_radius
) )
# Create defensive copy to prevent shared state corruption
interactable = interactable.copy()
if obj in interactable:
interactable.remove(obj) interactable.remove(obj)
new_obj = obj.tick(interactable) new_obj = obj.tick(interactable)
else: else:
@ -185,7 +190,7 @@ class World:
if new_obj is None: if new_obj is None:
continue continue
# reproduction code # reproduction code - buffer new entities for atomic state transition
if isinstance(new_obj, list): if isinstance(new_obj, list):
for item in new_obj: for item in new_obj:
if isinstance(item, BaseEntity): if isinstance(item, BaseEntity):
@ -194,6 +199,8 @@ class World:
else: else:
cell = self._hash_position(new_obj.position) cell = self._hash_position(new_obj.position)
self.buffers[next_buffer][cell].append(new_obj) self.buffers[next_buffer][cell].append(new_obj)
# Atomic buffer switch - all state changes become visible simultaneously
self.current_buffer = next_buffer self.current_buffer = next_buffer
def add_object(self, new_object: BaseEntity) -> None: def add_object(self, new_object: BaseEntity) -> None: