From 06fdc96e7ef6c0d32d2b36d562147e1b40195452 Mon Sep 17 00:00:00 2001 From: Shi Johnson-Bey Date: Tue, 15 Nov 2022 15:45:24 -0800 Subject: [PATCH] updates for new ai components --- CHANGELOG.md | 13 +- samples/demon_slayer.py | 25 ++-- src/neighborly/builtin/ai.py | 64 +++++++++ src/neighborly/builtin/helpers.py | 6 +- src/neighborly/builtin/systems.py | 21 ++- src/neighborly/core/action.py | 53 ++++++++ src/neighborly/core/ai.py | 140 ++++++++++++++++++++ src/neighborly/core/archetypes.py | 4 +- src/neighborly/core/event.py | 16 ++- src/neighborly/core/life_event.py | 8 +- src/neighborly/plugins/defaults/__init__.py | 8 +- src/neighborly/plugins/talktown/school.py | 1 + src/neighborly/simulation.py | 13 +- 13 files changed, 327 insertions(+), 45 deletions(-) create mode 100644 src/neighborly/builtin/ai.py create mode 100644 src/neighborly/core/action.py create mode 100644 src/neighborly/core/ai.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 097153b..15d9011 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,13 +9,16 @@ incrementing to a completely new version number. ## [0.9.4] +**0.9.4 is not compatible with 0.9.3** + ### Added - `Building` class to identify when a business currently exists within the town vs. -when it is archived within the ECS for story sifting. -- Systems to update business components when they are pending opening, open for business, and closed for business and awaiting demolition. + when it is archived within the ECS for story sifting. +- Systems to update business components when they are pending opening, open for business, and closed for business and + awaiting demolition. - New status components to identify Businesses at different phases in their lifecycle: -`ClosedForBusiness`, `OpenForBusiness`, `PendingOpening` + `ClosedForBusiness`, `OpenForBusiness`, `PendingOpening` - New PyGame UI elements for displaying information about a GameObject - Strings may be used as world seeds - `CHANGELOG.md` file @@ -32,9 +35,9 @@ when it is archived within the ECS for story sifting. - Jupyter notebook and pygame samples - samples category from dependencies within `setup.cfg` - `events`, `town`, `land grid`, and `relationships` fields from `NeighborlyJsonExporter`. -These are duplicated when serializing the resources. + These are duplicated when serializing the resources. - `SimulationBuilder.add_system()` and `SimulationBuilder.add_resource()`. To add -these, users need to encapsulate their content within a plugin + these, users need to encapsulate their content within a plugin - Flake8 configuration from `setup.cfg` ### Fixed diff --git a/samples/demon_slayer.py b/samples/demon_slayer.py index a314cf8..45f797b 100644 --- a/samples/demon_slayer.py +++ b/samples/demon_slayer.py @@ -638,23 +638,17 @@ def execute(world: World, event: Event): opponent = world.get_gameobject(event["Opponent"]).get_component(Demon) rng = world.get_resource(NeighborlyEngine).rng _death_event_type = LifeEvents.get("Death") - generated_events = [event] - slayer_success_chance = probability_of_winning( - opponent.power_level, challenger.power_level - ) - - demon_success_chance = probability_of_winning( - challenger.power_level, opponent.power_level - ) + opponent_success_chance = probability_of_winning(opponent.power_level, challenger.power_level) + challenger_success_chance = probability_of_winning(challenger.power_level, opponent.power_level) - if rng.random() < slayer_success_chance: + if rng.random() < opponent_success_chance: # Demon slayer wins new_slayer_pl, _ = update_power_level( opponent.power_level, challenger.power_level, - slayer_success_chance, - demon_success_chance, + opponent_success_chance, + challenger_success_chance, ) opponent.power_level = new_slayer_pl @@ -665,15 +659,14 @@ def execute(world: World, event: Event): if death_event: _death_event_type.execute(world, death_event) - generated_events.append(death_event) - + # Update Power Ranking else: # Demon wins _, new_demon_pl = update_power_level( challenger.power_level, opponent.power_level, - demon_success_chance, - slayer_success_chance, + challenger_success_chance, + opponent_success_chance, ) challenger.power_level = new_demon_pl @@ -684,7 +677,7 @@ def execute(world: World, event: Event): if death_event: _death_event_type.execute(world, death_event) - generated_events.append(death_event) + # Update Power Ranking return LifeEvent( "ChallengeForPower", diff --git a/src/neighborly/builtin/ai.py b/src/neighborly/builtin/ai.py new file mode 100644 index 0000000..17d8f8f --- /dev/null +++ b/src/neighborly/builtin/ai.py @@ -0,0 +1,64 @@ +""" +Default implementations of AI modules +""" +from typing import Optional, List + +from neighborly import World, GameObject, SimDateTime, NeighborlyEngine +from neighborly.builtin.components import LocationAliases, OpenToPublic, CurrentLocation +from neighborly.core.action import Action, AvailableActions +from neighborly.core.location import Location +from neighborly.core.routine import Routine + + +class DefaultMovementModule: + + def get_next_location(self, world: World, gameobject: GameObject) -> Optional[int]: + date = world.get_resource(SimDateTime) + routine = gameobject.try_component(Routine) + location_aliases = gameobject.try_component(LocationAliases) + + if routine: + routine_entry = routine.get_entry(date.weekday, date.hour) + + if ( + routine_entry + and isinstance(routine_entry.location, str) + and location_aliases + ): + return location_aliases.aliases[routine_entry.location] + + elif routine_entry: + return int(routine_entry.location) + + potential_locations: List[int] = list( + map( + lambda res: res[0], + world.get_components(Location, OpenToPublic), + ) + ) + + if potential_locations: + return world.get_resource(NeighborlyEngine).rng.choice(potential_locations) + + return None + + +class DefaultSocialAIModule: + + def get_next_action(self, world: World, gameobject: GameObject) -> Optional[Action]: + current_location_comp = gameobject.try_component(CurrentLocation) + + if current_location_comp is None: + return None + + current_location = world.get_gameobject(current_location_comp.location) + + available_actions = current_location.try_component(AvailableActions) + + if available_actions is None: + return None + + for action in available_actions.actions: + ... + + return None diff --git a/src/neighborly/builtin/helpers.py b/src/neighborly/builtin/helpers.py index 59cf2cb..4d3a41a 100644 --- a/src/neighborly/builtin/helpers.py +++ b/src/neighborly/builtin/helpers.py @@ -263,7 +263,7 @@ def close_for_business(business: Business) -> None: ], ) - world.get_resource(EventLog).record_event(close_for_business_event) + world.get_resource(EventLog).record_event(world, close_for_business_event) for employee in business.get_employees(): layoff_employee(business, world.get_gameobject(employee)) @@ -288,7 +288,7 @@ def leave_job(world: World, employee: GameObject) -> None: ], ) - world.get_resource(EventLog).record_event(fired_event) + world.get_resource(EventLog).record_event(world, fired_event) business.get_component(Business).remove_employee(employee.id) @@ -323,7 +323,7 @@ def layoff_employee(business: Business, employee: GameObject) -> None: ], ) - world.get_resource(EventLog).record_event(fired_event) + world.get_resource(EventLog).record_event(world, fired_event) if not employee.has_component(WorkHistory): employee.add_component(WorkHistory()) diff --git a/src/neighborly/builtin/systems.py b/src/neighborly/builtin/systems.py index 1e19158..9ca30c3 100644 --- a/src/neighborly/builtin/systems.py +++ b/src/neighborly/builtin/systems.py @@ -256,6 +256,7 @@ def process(self, *args, **kwargs) -> None: ) event_log.record_event( + self.world, Event( name="BecameBusinessOwner", timestamp=date.to_iso_str(), @@ -362,6 +363,7 @@ def process(self, *args, **kwargs) -> None: ) event_log.record_event( + self.world, Event( "HiredAtBusiness", date.to_iso_str(), @@ -489,10 +491,10 @@ def choose_random_eligible_business( archetype.get_instances() < archetype.get_max_instances() and town.population >= archetype.get_min_population() and ( - archetype.get_year_available() - <= date.year - < archetype.get_year_obsolete() - ) + archetype.get_year_available() + <= date.year + < archetype.get_year_obsolete() + ) ): archetype_choices.append(archetype) archetype_weights.append(archetype.get_spawn_frequency()) @@ -698,6 +700,7 @@ def run(self, *args, **kwargs) -> None: # Record a life event event_logger.record_event( + self.world, Event( name="MoveIntoTown", timestamp=date.to_iso_str(), @@ -764,6 +767,7 @@ def run(self, *args, **kwargs) -> None: character.gameobject.add_component(Teen()) character.gameobject.remove_component(Child) event_log.record_event( + self.world, Event( name="BecomeTeen", timestamp=current_date.to_iso_str(), @@ -786,6 +790,7 @@ def run(self, *args, **kwargs) -> None: character.gameobject.add_component(Unemployed()) event_log.record_event( + self.world, Event( name="BecomeYoungAdult", timestamp=current_date.to_iso_str(), @@ -801,6 +806,7 @@ def run(self, *args, **kwargs) -> None: ): character.gameobject.remove_component(YoungAdult) event_log.record_event( + self.world, Event( name="BecomeAdult", timestamp=current_date.to_iso_str(), @@ -817,6 +823,7 @@ def run(self, *args, **kwargs) -> None: ): character.gameobject.add_component(Elder()) event_log.record_event( + self.world, Event( name="BecomeElder", timestamp=current_date.to_iso_str(), @@ -863,15 +870,14 @@ def process(self, *args, **kwargs): town.increment_population() baby.get_component(Age).value = ( - current_date - pregnancy.due_date - ).hours / HOURS_PER_YEAR + current_date - pregnancy.due_date + ).hours / HOURS_PER_YEAR baby.get_component(CharacterName).surname = birthing_parent_name.surname move_to_location(self.world, birthing_parent, "home") if birthing_parent.has_component(CurrentLocation): - current_location = birthing_parent.get_component(CurrentLocation) move_to_location( @@ -943,6 +949,7 @@ def process(self, *args, **kwargs): # Pregnancy event dates are retconned to be the actual date that the # child was due. event_logger.record_event( + self.world, Event( name="ChildBirth", timestamp=pregnancy.due_date.to_iso_str(), diff --git a/src/neighborly/core/action.py b/src/neighborly/core/action.py new file mode 100644 index 0000000..9810447 --- /dev/null +++ b/src/neighborly/core/action.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from typing import List, Set, Union, Tuple, Callable + +from neighborly.core.ecs import Component, World +from neighborly.core.event import EventProbabilityFn, Event + + +class AvailableActions(Component): + """ + Tracks all the Actions that are available at a given location + + Attributes + ---------- + actions: Set[Action] + The actions that characters can take + """ + + __slots__ = "actions" + + def __init__(self, actions: List[Action]) -> None: + super().__init__() + self.actions: Set[Action] = set(actions) + + +class Action: + + def __init__(self, uid: int, rules: List[ActionRule], *roles: str) -> None: + self.uid: int = uid + self.rules: List[ActionRule] = rules + + def find_match(self, world: World) -> Tuple[Event, float]: + raise NotImplementedError() + + def __eq__(self, other: Action) -> bool: + return self.uid == other.uid + + def __hash__(self) -> int: + return self.uid + + +class ActionRule: + """ + ActionRules are combinations of patterns and probabilities for + when an action is allowed to occur. A single action is mapped + to one or more action rules. + """ + + def __init__(self, bind_fn: Callable[..., Event], probability: Union[EventProbabilityFn, float] = 1.0) -> None: + self.bind_fn: Callable[..., Event] = bind_fn + self.probability_fn: EventProbabilityFn = ( + probability if callable(probability) else (lambda world, event: probability) + ) diff --git a/src/neighborly/core/ai.py b/src/neighborly/core/ai.py new file mode 100644 index 0000000..b239a15 --- /dev/null +++ b/src/neighborly/core/ai.py @@ -0,0 +1,140 @@ +""" +This module contains interfaces, components, and systems +related to character decision-making. Users of this library +should use these classes to override character decision-making +processes +""" +from abc import abstractmethod +from typing import Optional, Protocol + +from neighborly.builtin import helpers +from neighborly.builtin.components import Active +from neighborly.core.action import Action +from neighborly.core.ecs import Component, World, GameObject +from neighborly.core.system import System + + +class IMovementAIModule(Protocol): + """ + Interface defines functions that a class needs to implement to be + injected into a MovementAI component. + """ + + @abstractmethod + def get_next_location(self, world: World, gameobject: GameObject) -> Optional[int]: + """ + Returns where the character will move to this simulation tick. + + Parameters + ---------- + world: World + The world that the character belongs to + gameobject: GameObject + The GameObject instance the module is associated with + + Returns + ------- + The ID of the location to move to next + """ + raise NotImplementedError + + +class MovementAI(Component): + """ + Component responsible for moving a character around the simulation. It + uses an IMovementAIModule instance to determine where the character + should go. + + Attributes + ---------- + module: IMovementAIModule + AI module responsible for movement decision-making + """ + + __slots__ = "module" + + def __init__(self, module: IMovementAIModule) -> None: + super().__init__() + self.module: IMovementAIModule = module + + def get_next_location(self, world: World) -> Optional[int]: + """ + Calls the internal module to determine where the character should move + """ + return self.module.get_next_location(world, self.gameobject) + + +class MovementAISystem(System): + """Updates the MovementAI components attached to characters""" + + def run(self, *args, **kwargs) -> None: + for gid, (movement_ai, _) in self.world.get_components(MovementAI, Active): + next_location = movement_ai.get_next_location(self.world) + if next_location is not None: + helpers.move_to_location( + self.world, self.world.get_gameobject(gid), next_location + ) + + +class ISocialAIModule(Protocol): + """ + Interface that defines how characters make decisions + regarding social actions that they take between each other + """ + + def get_next_action(self, world: World, gameobject: GameObject) -> Optional[Action]: + """ + Get the next action for this character + + Parameters + ---------- + world: World + The world instance the character belongs to + gameobject: GameObject + The GameObject instance this module is associated with + + Returns + ------- + An instance of an action to take + """ + raise NotImplementedError + + +class SocialAI(Component): + """ + Component responsible for helping characters decide who to + interact with and how. + + This class should not be subclassed as the subclasses will not + be automatically recognized by Neighborly's default systems. If + a subclass is necessary, then a new system will need to also be + created to handle AI updates. + + Attributes + ---------- + module: ISocialAIModule + AI module responsible for social action decision-making + """ + + __slots__ = "module" + + def __init__(self, module: ISocialAIModule) -> None: + super().__init__() + self.module: ISocialAIModule = module + + def get_next_action(self, world: World) -> Optional[Action]: + """ + Calls the internal module to determine what action the character should take + """ + return self.module.get_next_action(world, self.gameobject) + + +class SocialAISystem(System): + """Characters performs social actions""" + + def run(self, *args, **kwargs) -> None: + for gid, (social_ai, _) in self.world.get_components(SocialAI, Active): + next_action = social_ai.get_next_action(self.world) + if next_action is not None: + # TODO: Implement Action API + pass diff --git a/src/neighborly/core/archetypes.py b/src/neighborly/core/archetypes.py index 4bbcb1a..b8d7339 100644 --- a/src/neighborly/core/archetypes.py +++ b/src/neighborly/core/archetypes.py @@ -5,7 +5,9 @@ from enum import Enum from typing import Dict, List, Optional, Set, Type +from neighborly.builtin.ai import DefaultMovementModule from neighborly.builtin.components import Active, Age, Lifespan, LifeStages, Name +from neighborly.core.ai import MovementAI from neighborly.core.business import ( Business, IBusinessType, @@ -197,6 +199,7 @@ def create(self, world: World, **kwargs) -> GameObject: ), PersonalValues.create(world), Relationships(), + MovementAI(DefaultMovementModule()) ] ) @@ -379,7 +382,6 @@ def create(self, world: World, **kwargs) -> GameObject: class ArchetypeRef(Component): - __slots__ = "name" def __init__(self, name: str) -> None: diff --git a/src/neighborly/core/event.py b/src/neighborly/core/event.py index ba514b3..8b5d338 100644 --- a/src/neighborly/core/event.py +++ b/src/neighborly/core/event.py @@ -154,6 +154,7 @@ class EventLog: "_subscribers", "_per_gameobject", "_per_gameobject_subscribers", + "_listeners" ) def __init__(self) -> None: @@ -163,15 +164,20 @@ def __init__(self) -> None: self._per_gameobject_subscribers: DefaultDict[ int, List[Callable[[Event], None]] ] = defaultdict(list) + self._listeners: DefaultDict[ + str, List[Callable[[World, Event], None]] + ] = defaultdict(list) - def record_event(self, event: Event) -> None: + def record_event(self, world: World, event: Event) -> None: """ Adds a LifeEvent to the history and calls all registered callback functions Parameters ---------- + world: World + event: Event - Event that occurred + The event that occurred """ self.event_history.append(event) @@ -181,9 +187,15 @@ def record_event(self, event: Event) -> None: for cb in self._per_gameobject_subscribers[role.gid]: cb(event) + for listener in self._listeners[event.name]: + listener(world, event) + for cb in self._subscribers: cb(event) + def add_event_listener(self, event_name: str, listener: Callable[[World, Event], None]) -> None: + self._listeners[event_name].append(listener) + def subscribe(self, cb: Callable[[Event], None]) -> None: """ Add a function to be called whenever a LifeEvent occurs diff --git a/src/neighborly/core/life_event.py b/src/neighborly/core/life_event.py index 2fd5ec8..0ea6722 100644 --- a/src/neighborly/core/life_event.py +++ b/src/neighborly/core/life_event.py @@ -144,7 +144,7 @@ def instantiate(self, world: World, **bindings: GameObject) -> Optional[Event]: def execute(self, world: World, event: Event) -> None: """Run the effects function using the given event""" - world.get_resource(EventLog).record_event(event) + world.get_resource(EventLog).record_event(world, event) self.effect(world, event) def try_execute_event(self, world: World, **bindings: GameObject) -> bool: @@ -172,7 +172,6 @@ def try_execute_event(self, world: World, **bindings: GameObject) -> bool: class PatternLifeEvent: - __slots__ = "name", "probability", "pattern", "effect" def __init__( @@ -222,7 +221,7 @@ def instantiate(self, world: World, **bindings: GameObject) -> Optional[Event]: def execute(self, world: World, event: Event) -> None: """Run the effects function using the given event""" - world.get_resource(EventLog).record_event(event) + world.get_resource(EventLog).record_event(world, event) self.effect(world, event) def try_execute_event(self, world: World, **bindings: GameObject) -> bool: @@ -291,5 +290,6 @@ def run(self, *args, **kwargs) -> None: rng = self.world.get_resource(NeighborlyEngine).rng # Perform number of events equal to 10% of the population - for life_event in rng.choices(LifeEvents.get_all(), k=(town.population // 10)): + + for life_event in rng.choices(LifeEvents.get_all(), k=(int(town.population / 2))): life_event.try_execute_event(self.world) diff --git a/src/neighborly/plugins/defaults/__init__.py b/src/neighborly/plugins/defaults/__init__.py index 5e6174f..e2cdfda 100644 --- a/src/neighborly/plugins/defaults/__init__.py +++ b/src/neighborly/plugins/defaults/__init__.py @@ -26,7 +26,6 @@ OpenForBusinessSystem, PendingOpeningSystem, PregnancySystem, - RoutineSystem, SpawnResidentSystem, ) from neighborly.core.business import Occupation @@ -37,7 +36,7 @@ ) from neighborly.core.ecs import World from neighborly.core.engine import NeighborlyEngine -from neighborly.core.life_event import LifeEvents, LifeEventSystem +from neighborly.core.life_event import LifeEvents from neighborly.core.personal_values import PersonalValues from neighborly.core.time import TimeDelta from neighborly.simulation import Plugin, Simulation @@ -163,7 +162,7 @@ def setup(self, sim: Simulation, **kwargs) -> None: # SocializeSystem.add_compatibility_check(age_difference_debuff) sim.world.add_system(CharacterAgingSystem(), priority=CHARACTER_UPDATE_PHASE) - sim.world.add_system(RoutineSystem(), priority=CHARACTER_UPDATE_PHASE) + # sim.world.add_system(RoutineSystem(), priority=CHARACTER_UPDATE_PHASE) sim.world.add_system(BusinessUpdateSystem(), priority=BUSINESS_UPDATE_PHASE) sim.world.add_system(FindBusinessOwnerSystem(), priority=BUSINESS_UPDATE_PHASE) sim.world.add_system(FindEmployeesSystem(), priority=BUSINESS_UPDATE_PHASE) @@ -178,9 +177,6 @@ def setup(self, sim: Simulation, **kwargs) -> None: ) sim.world.add_system(OpenForBusinessSystem(), priority=BUSINESS_UPDATE_PHASE) sim.world.add_system(ClosedForBusinessSystem(), priority=BUSINESS_UPDATE_PHASE) - sim.world.add_system( - LifeEventSystem(interval=TimeDelta(months=2)), priority=TOWN_SYSTEMS_PHASE - ) sim.world.add_system( BuildHousingSystem(chance_of_build=1.0), priority=TOWN_SYSTEMS_PHASE diff --git a/src/neighborly/plugins/talktown/school.py b/src/neighborly/plugins/talktown/school.py index 59e5b03..84f3723 100644 --- a/src/neighborly/plugins/talktown/school.py +++ b/src/neighborly/plugins/talktown/school.py @@ -35,6 +35,7 @@ def process(self, *args, **kwargs) -> None: school.remove_student(gid) young_adult.gameobject.remove_component(Student) event_logger.record_event( + self.world, Event( "GraduatedSchool", date.to_iso_str(), diff --git a/src/neighborly/simulation.py b/src/neighborly/simulation.py index c885883..f2f8797 100644 --- a/src/neighborly/simulation.py +++ b/src/neighborly/simulation.py @@ -13,10 +13,12 @@ RemoveDepartedFromResidences, RemoveRetiredFromOccupation, ) -from neighborly.core.constants import TIME_UPDATE_PHASE +from neighborly.core.ai import MovementAISystem, SocialAISystem +from neighborly.core.constants import TIME_UPDATE_PHASE, TOWN_SYSTEMS_PHASE from neighborly.core.ecs import ISystem, World from neighborly.core.engine import NeighborlyEngine from neighborly.core.event import EventLog +from neighborly.core.life_event import LifeEventSystem from neighborly.core.time import SimDateTime, TimeDelta from neighborly.core.town import LandGrid, Town @@ -147,6 +149,7 @@ class SimulationBuilder: "resources", "plugins", "print_events", + "life_event_interval_hours" ) def __init__( @@ -157,6 +160,7 @@ def __init__( town_name: str = "#town_name#", town_size: TownSize = "medium", print_events: bool = True, + life_event_interval_hours: int = 336 ) -> None: self.seed: int = hash(seed if seed is not None else random.randint(0, 99999999)) self.time_increment_hours: int = time_increment_hours @@ -173,6 +177,7 @@ def __init__( self.resources: List[Any] = [] self.plugins: List[Tuple[Plugin, Dict[str, Any]]] = [] self.print_events: bool = print_events + self.life_event_interval_hours: int = life_event_interval_hours def add_plugin(self, plugin: Plugin, **kwargs) -> SimulationBuilder: """Add plugin to simulation""" @@ -217,6 +222,12 @@ def build( LinearTimeSystem(TimeDelta(hours=self.time_increment_hours)), TIME_UPDATE_PHASE, ) + sim.world.add_system(MovementAISystem()) + sim.world.add_system(SocialAISystem()) + sim.world.add_system( + LifeEventSystem(interval=TimeDelta(hours=self.life_event_interval_hours)), priority=TOWN_SYSTEMS_PHASE + ) + sim.world.add_system(RemoveDeceasedFromResidences()) sim.world.add_system(RemoveDepartedFromResidences()) sim.world.add_system(RemoveDepartedFromOccupation())