diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..a55e7a1 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml index f958556..fd7b292 100644 --- a/.idea/inspectionProfiles/Project_Default.xml +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -1,7 +1,20 @@ diff --git a/.vscode/launch.json b/.vscode/launch.json index c6e2461..b3b66c4 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -5,7 +5,15 @@ "version": "0.2.0", "configurations": [ { - "name": "Python: Module", + "name": "Sample: Talk of the Town", + "type": "python", + "request": "launch", + "program": "samples/talktown.py", + "console": "integratedTerminal", + "justMyCode": true + }, + { + "name": "Python: Module - Neighborly", "type": "python", "request": "launch", "module": "neighborly", diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..15d9011 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,46 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres mostly to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +There may be minor-version updates that contain breaking changes, but do not warrant +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. +- New status components to identify Businesses at different phases in their lifecycle: + `ClosedForBusiness`, `OpenForBusiness`, `PendingOpening` +- New PyGame UI elements for displaying information about a GameObject +- Strings may be used as world seeds +- `CHANGELOG.md` file + +### Updated + +- PyGame sample to use the new API +- Docstrings for `Simulation` and `SimulationBuilder` classes +- `SimulationBuilder` class +- Moved isort configuration to `pyproject.toml` + +### Removed + +- 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. +- `SimulationBuilder.add_system()` and `SimulationBuilder.add_resource()`. To add + these, users need to encapsulate their content within a plugin +- Flake8 configuration from `setup.cfg` + +### Fixed + +- Bug in Business operating hours regex that did not recognize AM/PM strings +- `setup.cfg` did not properly include data files in the wheel build. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 0d803e1..642f711 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -116,7 +116,7 @@ the community. This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, available at -https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. +. Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). @@ -124,5 +124,5 @@ enforcement ladder](https://github.com/mozilla/diversity). [homepage]: https://www.contributor-covenant.org For answers to common questions about this code of conduct, see the FAQ at -https://www.contributor-covenant.org/faq. Translations are available at -https://www.contributor-covenant.org/translations. +. Translations are available at +. diff --git a/MANIFEST.in b/MANIFEST.in index ad9e7c3..6c4e563 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,4 @@ include LICENSE +include CHANGELOG.md include CODE_OF_CONDUCT.md -recursive-include src/neighborly/plugins/default_plugin/data * +recursive-include src/neighborly/plugins/defaults/data * diff --git a/README.md b/README.md index aa4060f..bf6d3f5 100644 --- a/README.md +++ b/README.md @@ -1,77 +1,112 @@


Neighborly

-

Social simulation engine for procedurally generating towns of characters

-

- - + + + + +

# Overview -Neighborly is a social simulation engine for procedurally generating towns of characters. It simulates -the lives of each character, their jobs, routines, relationships, and life events. Neighborly utilizes -an entity-component system architecture, and enables users to specify custom character types, businesses, -occupations, and life events. +Neighborly is a Python framework for generating and forward simulating towns of +characters over large periods of time (decades to centuries). It uses a character-driven +social simulation that forward-simulates the lives of each character, their jobs, +routines, relationships, and life events. Users can specify custom characters, +residential/commercial buildings, occupations, life events, social actions, and more. + +Currently, _Neighborly_ works best as narrative data generator. When the simulation +ends, users can save the history of events, characters, relationships, and other stuff. -Neighborly takes lessons learned from working with +Neighborly was inspired by lessons learned from working with [_Talk of the Town_](https://github.com/james-owen-ryan/talktown) -and aims to give people better documentation, simpler interfaces, and more opportunities for extension and content authoring. +and aims to give people better documentation, simpler interfaces, and more opportunities +for extension and content authoring. -# Core Features +## Core Features -* Create custom Character Archetypes and have them all interact within the same simulation -* Create custom Business and Occupation definitions -* Configure simulation data using YAML or in code with Python +* Create custom character, buildings, life events, and social actions +* Commandline interface (CLI) tool +* Configure the CLI using YAML text files * Plugin architecture allows users to modularize and share their custom content -* Low fidelity simulation simulates the macro events in character's lives (relationship milestones, job changes, victories, tragic events) * Export simulation state to JSON for further data processing -# Tutorials and How-to Guides +# How to use -I plan to add these after I have finished implementing Neighborly's core -functionality. I will try to align them with the sample projects, but we -will see how the first pre-release looks. For now, loosely refer to the -samples. Although, they too lag behind breaking changes to the core codebase. +Below are instructions for installing Neighborly and the options one has for using it +in their projects. If you want examples of how to use Neighborly and how to extend it +with custom content, please refer to +[Neighborly's wiki](https://github.com/ShiJbey/neighborly/wiki) and the sample scripts +in the [_samples_ directory](https://github.com/ShiJbey/neighborly/tree/main/samples). -# Installing from PyPI +## Installation -Neighborly is available to install via pip. +Neighborly is available to install from PyPI. ```bash pip install neighborly ``` -# Running the commandline tool - -Neighborly can be run as a module from the commandline. By default, it runs a -builtin version of **Talk of the Town**. You can configure the simulation settings -by creating a `neighborlyconfig.yaml` file in the same directory where you're -running the application. When world generation concludes, Neighborly will write -the final simulation data to a JSON file with the name of the town and the -seed used for random number generation. +Or you can install it by cloning the main branch of this repo and installing that. ```bash -python -m neighborly +git clone https://github.com/ShiJbey/neighborly.git -# Please use the following command for additional help with running Neighborly's CLI -python -m neighborly --help +cd neighborly + +python -m pip install . ``` +## Using as a library + +Neighborly can be used as a library within a Python script or package. +The `samples` directory contains python scripts that use Neighborly this +way. Please refer to them when creating new Plugins and other content. + +## Running the CLI + +Neighborly can be run as a module `$ python -m neighborly` or commandline `$ neighborly` +script. If you require additional help while running, please use +`python -m neighborly --help` or `neighborly --help`. + +By default, Neighborly runs a builtin version of **Talk of the Town**. However, you can +configure the simulation settings by creating a `neighborlyconfig.yaml` file in +the same directory where you're running the CLI. Please refer to the +[wiki](https://github.com/ShiJbey/neighborly/wiki/Neighborly-CLI) for a list of +valid configuration settings. + +When world generation concludes, Neighborly can write the final simulation data +to a JSON file with the name of the town and the seed used for random number +generation. + +## Running the Samples -# Installing for local development +Neighborly provides sample simulations to show users how to customize +it to create new story world themes. -If you wish to download a Neighborly for local development, follow the these instructions. +```bash +# Make sure that you've activated your python virtual environment +# Replace .py with the name of the +# sample you want to run +python ./samples/.py +``` + +## Installing for local development + +If you wish to download a Neighborly for local development, you need to clone/fork this +repository and install using the _editable_ flag (-e). Please see the instructions +below. ```bash # Step One: Clone Repository @@ -92,12 +127,12 @@ python -m venv venv python -m pip install -e "." ``` -# Running the Tests +## Running the Tests The tests are currently out-of-date and may refer to systems and logic that no longer exists in Neighborly. The codebase -changes so frequently that it hasn't been worth the time. -As modules become more established, I will add proper tests for them. +changes so frequently that it hasn't been worth the time. +As modules become more established, I will add proper tests for them. Feel free to contribute tests by forking the repo, adding your test(s), and submitting a pull request with a description of your test cases. Your commits should only contain changes to files within the `tests` directory. If you @@ -112,25 +147,16 @@ python -m pip install -e ".[tests]" # Step 2: Run Pytest pytest -``` - -# Running the Samples - -Please follow the steps below to run the sample simulations. -We also have examples for using Neighborly in a IPython -notebook and with PyGame. -```bash -# Step 1: Install dependencies for samples -python -m pip install -e ".[samples]" - -# Step 2: Run desired sample -python ./samples/.py +# Step3 : (Optional) Generate a test coverage report +pytest --cov=neighborly tests/ ``` # Documentation -Neighborly uses [Numpy-style](https://numpydoc.readthedocs.io/en/latest/format.html) docstrings in code and full documentation can be found in the [Wiki](https://github.com/ShiJbey/neighborly/wiki). +Neighborly uses [Numpy-style](https://numpydoc.readthedocs.io/en/latest/format.html) +docstrings in code and full documentation can be found in the +[Wiki](https://github.com/ShiJbey/neighborly/wiki). When adding docstrings for existing or new bits of code please use the following references for how to format your contributions: @@ -138,13 +164,8 @@ references for how to format your contributions: * [Sphinx Napoleon Plugin for processing Numpy Docstrings](https://www.sphinx-doc.org/en/master/usage/extensions/napoleon.html) * [Example Numpy Style Docstrings](https://www.sphinx-doc.org/en/master/usage/extensions/example_numpy.html#example-numpy) - # Contributing -If you are interested in contributing to Neighborly, feel free to fork this repository, make your changes, and submit a pull-request. Please keep in mind that this project is a tool for creativity and learning. We have a [code of conduct](./CODE_OF_CONDUCT.md) to encourage healthy collaboration, and will enforce it if we need to. - -**WARNING::** This repository's structure in high flux. Parts of the code get shifted to make the APIs cleaner for use. - Here are some ways that people can contribute to Neighborly: 1. Proposing/Implementing new features @@ -154,34 +175,45 @@ Here are some ways that people can contribute to Neighborly: 5. Filing issues 6. Contributing tutorials/how-tos to the wiki 7. Fixing grammar and spelling in the wiki -8. Creating new samples +8. Creating new samples/plugins + +If you are interested in contributing to Neighborly, there are multiple ways to get +involved, and not all of them require you to be proficient with GitHub. Interested +parties can contribute to the core code base of Neighborly and/or create nre content +in the way of plugins. I love feedback, and if you have any questions, create a new +issue, and I will do my best to answer. If you want to contribute to the core code, +free to fork this repository, make your changes, and submit a pull-request with a +description of your contribution. Please keep in mind that this project is a +tool for creativity and learning. I have a [code of conduct](./CODE_OF_CONDUCT.md) to +encourage healthy collaboration, and will enforce it if I need to. ## Code Style -Neighborly does not have a set-in-stone code style yet, but I have started integrating -isort, black, and flake8 into the development workflow in VSCode. - -You can follow [these instructions](https://black.readthedocs.io/en/stable/integrations/editors.html) for setting up both black and isort. And I found this gist helpful for getting [flake8 working in PyCharm](https://gist.github.com/tossmilestone/23139d870841a3d5cba2aea28da1a895). +Neighborly uses [_Black_](https://black.readthedocs.io/en/stable/) to handle code style +and sorts imports using [_isort_](https://pycqa.github.io/isort/). You can follow +[these instructions](https://black.readthedocs.io/en/stable/integrations/editors.html) +for setting up both black and isort. # Notes ## Non-Deterministic Behavior -The goal of having a seeded pseudo random simulation is so that users experience deterministic behavior when using the -same starting seed. We try to remove all forms of non-determinism, but some slip through. The known areas are listed -below. If you find any, please make a new issue with details of the behavior. +The goal of having a seeded pseudo random simulation is so that users experience +deterministic behavior when using the same starting seed. I try to remove all forms of +non-determinism, but some slip through. The known areas are listed below. If you find +any, please make a new issue with details of the behavior. -* Names may not be consistent when using the same seed. Currently, names are generated - using [Tracery](https://github.com/aparrish/pytracery). We would need to create a custom version that uses an RNG - instance instead of the global random module to generate names. +* Neighborly uses [Tracery](https://github.com/aparrish/pytracery) to generate names for +characters and locations, and these names may not be consistent despite using the same +rng seed value. ## DMCA Statement -Upon receipt of a notice alleging copyright infringement, I will take whatever action it deems -appropriate within its sole discretion, including removal of the allegedly infringing materials. - -The repo image is something fun that I made. I love _The Simpsons_, and I couldn't think of anything more neighborly -than Ned Flanders. If the copyright owner for _The Simpsons_ would like me to take it down, -please contact me. +Upon receipt of a notice alleging copyright infringement, I will take whatever action it +deems appropriate within its sole discretion, including removal of the allegedly +infringing materials. -The same takedown policy applies to any code samples inspired by TV shows, movies, and games. +The repo image is something fun that I made. I love _The Simpsons_, and I couldn't think +of anyone more neighborly than Ned Flanders. If the copyright owner for _The Simpsons_ +would like me to take it down, please contact me. The same takedown policy applies to +any code samples inspired by TV shows, movies, and games. diff --git a/pyproject.toml b/pyproject.toml index 374b58c..7f2ef2c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,3 +4,12 @@ requires = [ "wheel" ] build-backend = "setuptools.build_meta" + +[tool.black] +line-length = 88 + +[tool.isort] +profile = "black" +default_section = "THIRDPARTY" +known_first_party = "neighborly" +src_paths = ["src/neighborly", "test", "samples"] diff --git a/samples/demon_slayer.py b/samples/demon_slayer.py index a1bcdcb..45f797b 100644 --- a/samples/demon_slayer.py +++ b/samples/demon_slayer.py @@ -40,25 +40,40 @@ import math import time from enum import IntEnum -from typing import Optional, Tuple, List, Any, Dict +from typing import Any, Dict, List, Optional, Tuple from ordered_set import OrderedSet -from neighborly.builtin.statuses import Deceased +from neighborly import Plugin, Simulation, SimulationBuilder +from neighborly.builtin.components import ( + Active, + Age, + CanAge, + CanGetPregnant, + CurrentLocation, + Deceased, + LifeStages, +) +from neighborly.builtin.helpers import move_to_location +from neighborly.core import query from neighborly.core.character import GameCharacter -from neighborly.core.ecs import Component, World, GameObject +from neighborly.core.ecs import Component, GameObject, World from neighborly.core.engine import NeighborlyEngine -from neighborly.core.life_event import LifeEventType, EventRoleType, LifeEvent, LifeEventLibrary, EventResult, \ - LifeEventLog +from neighborly.core.event import Event +from neighborly.core.life_event import ( + ILifeEvent, + LifeEvent, + LifeEventRoleType, + LifeEvents, +) from neighborly.core.location import Location -from neighborly.plugins.default_plugin import DefaultPlugin -from neighborly.plugins.talktown import TalkOfTheTownPlugin -from neighborly.plugins.weather_plugin import WeatherPlugin -from neighborly.simulation import Plugin, Simulation, SimulationBuilder +from neighborly.exporter import NeighborlyJsonExporter +from neighborly.plugins import defaults, talktown, weather class DemonSlayerRank(IntEnum): """Various ranks within the DemonSlayerCorp""" + Mizunoto = 0 Mizunoe = 1 Kanoto = 2 @@ -74,6 +89,7 @@ class DemonSlayerRank(IntEnum): class BreathingStyle(IntEnum): """Various breathing styles for demon slayers""" + Flower = 0 Love = 1 Flame = 2 @@ -104,7 +120,7 @@ class DemonSlayer(Component): This slayer's power level (used to calculate chance of winning a battle). breathing_style: BreathingStyle - What style of breathing does this character use + What style of breathing does this entity use """ __slots__ = "rank", "kills", "power_level", "breathing_style" @@ -127,14 +143,15 @@ def to_dict(self) -> Dict[str, Any]: **super().to_dict(), "rank": str(self.rank.name), "power_level": self.power_level, - "breathing_style": str(self.breathing_style) + "breathing_style": str(self.breathing_style), } def on_archive(self) -> None: if self.rank == DemonSlayerRank.Hashira: # Remove the hashira from the DemonSlayerCorp self.gameobject.world.get_resource(DemonSlayerCorps).retire_hashira( - self.gameobject.id) + self.gameobject.id + ) self.gameobject.remove_component(type(self)) @@ -217,12 +234,13 @@ def has_vacancy(self, breathing_style: BreathingStyle) -> bool: def to_dict(self) -> Dict[str, Any]: return { "hashira": list(self.hashira), - "former_hashira": list(self.former_hashira) + "former_hashira": list(self.former_hashira), } class DemonRank(IntEnum): """The various ranks held by demons""" + LowerDemon = 0 Demon = 1 BloodDemon = 2 @@ -267,8 +285,7 @@ def __init__( @classmethod def create(cls, world: World, **kwargs) -> Component: return cls( - power_level=kwargs.get("power_level", 0), - turned_by=kwargs.get("turned_by") + power_level=kwargs.get("power_level", 0), turned_by=kwargs.get("turned_by") ) def to_dict(self) -> Dict[str, Any]: @@ -277,7 +294,7 @@ def to_dict(self) -> Dict[str, Any]: "rank": str(self.rank.name), "power_level": self.power_level, "kills": self.kills, - "turned_by": self.turned_by if self.turned_by else -1 + "turned_by": self.turned_by if self.turned_by else -1, } def on_archive(self) -> None: @@ -316,7 +333,7 @@ class DemonKingdom: "_lower_moons", "_upper_moons", "_former_upper_moons", - "_former_lower_moons" + "_former_lower_moons", ) def __init__(self) -> None: @@ -374,7 +391,7 @@ def to_dict(self) -> Dict[str, Any]: "lower_moons": list(self.lower_moons), "former_lower_moons": list(self.lower_moons), "upper_moons": list(self.upper_moons), - "former_upper_moons": list(self.former_upper_moons) + "former_upper_moons": list(self.former_upper_moons), } @@ -416,6 +433,7 @@ def to_dict(self) -> Dict[str, Any]: # UTILITY FUNCTIONS ######################################## + def probability_of_winning(rating_a: int, rating_b: int) -> float: """ Return the probability of a defeating b @@ -423,9 +441,9 @@ def probability_of_winning(rating_a: int, rating_b: int) -> float: Parameters ---------- rating_a: int - Rating of character A + Rating of entity A rating_b: int - Rating of character B + Rating of entity B """ return 1.0 / (1 + math.pow(10, (rating_a - rating_b) / ELO_SCALE)) @@ -435,9 +453,10 @@ def update_power_level( loser_rating: int, winner_expectation: float, loser_expectation: float, - k: int = 16 + k: int = 16, ) -> Tuple[int, int]: """ + Perform ELO calculation for new ratings Parameters ---------- @@ -516,20 +535,13 @@ def power_level_to_demon_rank(power_level: int) -> DemonRank: return DemonRank.LowerDemon -def at_same_location(a: GameObject, b: GameObject) -> bool: - """Return True if these characters are at the same location""" - return ( - a.get_component(GameCharacter).location - == b.get_component(GameCharacter).location - ) - - ######################################## # CUSTOM LIFE EVENTS ######################################## -def become_demon_slayer(probability: float = 1) -> LifeEventType: - def bind_character(world: World, event: LifeEvent): + +def become_demon_slayer(probability: float = 1) -> ILifeEvent: + def bind_character(world: World, event: Event, candidate: Optional[GameObject]): candidates = [] @@ -538,68 +550,61 @@ def bind_character(world: World, event: LifeEvent): continue if character.gameobject.has_component(Demon): continue - if character.age >= character.character_def.life_stages["teen"]: + if ( + character.gameobject.get_component(Age).value + >= character.gameobject.get_component(LifeStages).stages["teen"] + ): candidates.append(character.gameobject) if candidates: - return world.get_resource(NeighborlyEngine).rng.choice( - candidates - ) + return world.get_resource(NeighborlyEngine).rng.choice(candidates) return None - def execute(world: World, event: LifeEvent): + def execute(world: World, event: Event): character = world.get_gameobject(event["Character"]) character.add_component(DemonSlayer.create(world)) - return EventResult(generated_events=[event]) - return LifeEventType( + return LifeEvent( "BecameDemonSlayer", probability=probability, - roles=[EventRoleType("Character", binder_fn=bind_character)], - execute_fn=execute + roles=[LifeEventRoleType("Character", binder_fn=bind_character)], + effect=execute, ) -def demon_slayer_promotion(probability: float = 1.0) -> LifeEventType: +def demon_slayer_promotion(probability: float = 1.0) -> ILifeEvent: """Demon slayer is promoted to the next rank""" - def bind_demon_slayer(world: World, event: LifeEvent): + def bind_demon_slayer(world: World, event: Event, candidate: Optional[GameObject]): candidates: List[GameObject] = [] for _, demon_slayer in world.get_component(DemonSlayer): - power_level_rank = power_level_to_slayer_rank( - demon_slayer.power_level - ) + power_level_rank = power_level_to_slayer_rank(demon_slayer.power_level) if power_level_rank < demon_slayer.rank: candidates.append(demon_slayer.gameobject) if candidates: - return world.get_resource(NeighborlyEngine).rng.choice( - candidates - ) + return world.get_resource(NeighborlyEngine).rng.choice(candidates) return None - def execute(world: World, event: LifeEvent): - slayer = world.get_gameobject(event["Slayer"]).get_component( - DemonSlayer - ) + def execute(world: World, event: Event): + slayer = world.get_gameobject(event["Slayer"]).get_component(DemonSlayer) power_level_rank = power_level_to_slayer_rank(slayer.power_level) slayer.rank = power_level_rank - return EventResult(generated_events=[event]) - return LifeEventType( + return LifeEvent( "DemonSlayerPromotion", probability=probability, - roles=[EventRoleType("Slayer", binder_fn=bind_demon_slayer)], - execute_fn=execute + roles=[LifeEventRoleType("Slayer", binder_fn=bind_demon_slayer)], + effect=execute, ) -def challenge_for_power(probability: float = 1.0) -> LifeEventType: - def bind_challenger(world: World, event: LifeEvent): +def challenge_for_power(probability: float = 1.0) -> ILifeEvent: + def bind_challenger(world: World, event: Event, candidate: Optional[GameObject]): """Get a challenger demon that has someone above them""" candidates: List[GameObject] = [] for _, demon in world.get_component(Demon): @@ -607,13 +612,11 @@ def bind_challenger(world: World, event: LifeEvent): candidates.append(demon.gameobject) if candidates: - return world.get_resource(NeighborlyEngine).rng.choice( - candidates - ) + return world.get_resource(NeighborlyEngine).rng.choice(candidates) return None - def bind_opponent(world: World, event: LifeEvent): + def bind_opponent(world: World, event: Event, candidate: Optional[GameObject]): """Find an opponent for the challenger""" challenger = world.get_gameobject(event["Challenger"]).get_component(Demon) candidates: List[GameObject] = [] @@ -625,103 +628,111 @@ def bind_opponent(world: World, event: LifeEvent): candidates.append(demon.gameobject) if candidates: - return world.get_resource(NeighborlyEngine).rng.choice( - candidates - ) + return world.get_resource(NeighborlyEngine).rng.choice(candidates) return None - def execute(world: World, event: LifeEvent): + def execute(world: World, event: Event): """Execute the battle""" challenger = world.get_gameobject(event["Challenger"]).get_component(Demon) opponent = world.get_gameobject(event["Opponent"]).get_component(Demon) rng = world.get_resource(NeighborlyEngine).rng - death_event_type = LifeEventLibrary.get("Death") - generated_events = [event] + _death_event_type = LifeEvents.get("Death") - 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 - death_event = death_event_type.instantiate(world, Deceased=challenger.gameobject) + death_event = _death_event_type.instantiate( + world, Deceased=challenger.gameobject + ) if death_event: - death_event_type.execute(world, death_event) - generated_events.append(death_event) - + _death_event_type.execute(world, 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 - death_event = death_event_type.instantiate(world, Deceased=opponent.gameobject) + death_event = _death_event_type.instantiate( + world, Deceased=opponent.gameobject + ) if death_event: - death_event_type.execute(world, death_event) - generated_events.append(death_event) - - return EventResult(generated_events=generated_events) + _death_event_type.execute(world, death_event) + # Update Power Ranking - return LifeEventType( + return LifeEvent( "ChallengeForPower", roles=[ - EventRoleType("Challenger", binder_fn=bind_challenger), - EventRoleType("Opponent", binder_fn=bind_opponent) + LifeEventRoleType("Challenger", binder_fn=bind_challenger), + LifeEventRoleType("Opponent", binder_fn=bind_opponent), ], probability=probability, - execute_fn=execute + effect=execute, ) -def devour_human(probability: float = 1.0) -> LifeEventType: - def execute(world: World, event: LifeEvent): +def devour_human(probability: float = 1.0) -> ILifeEvent: + def execute(world: World, event: Event): demon = world.get_gameobject(event["Demon"]) victim = world.get_gameobject(event["Victim"]) if victim.has_component(DemonSlayer): - battle_event_type = LifeEventLibrary.get("Battle") - battle_event = battle_event_type.instantiate(world, Demon=demon, Slayer=victim) + battle_event_type = LifeEvents.get("Battle") + battle_event = battle_event_type.instantiate( + world, Demon=demon, Slayer=victim + ) if battle_event: battle_event_type.execute(world, battle_event) - return EventResult(generated_events=[battle_event, event]) + else: demon.get_component(Demon).power_level += 1 demon.get_component(Demon).rank = power_level_to_demon_rank( demon.get_component(Demon).power_level ) - death_event = LifeEventLibrary.get("Death").instantiate(world, Deceased=victim) - return EventResult(generated_events=[death_event, event]) + _death_event_type = LifeEvents.get("Death") + _death_event_type.try_execute_event(world, Deceased=victim) + + def bind_demon(world: World, event: Event, candidate: Optional[GameObject] = None): + q = query.Query(("Demon",), [query.where(query.has_components(Demon))]) + candidate_id = candidate.id if candidate else None + results = q.execute(world, Demon=candidate_id) + if results: + return world.get_gameobject( + world.get_resource(NeighborlyEngine).rng.choice(results)[0] + ) - def bind_victim(world: World, event: LifeEvent): + def bind_victim(world: World, event: Event, candidate: Optional[GameObject] = None): """Get all people at the same location who are not demons""" demon = world.get_gameobject(event["Demon"]) + + if not demon.has_component(CurrentLocation): + return None + demon_location = world.get_gameobject( - demon.get_component(GameCharacter).location + demon.get_component(CurrentLocation).location ).get_component(Location) candidates: List[GameObject] = [] - for character_id in demon_location.characters_present: + for character_id in demon_location.entities: character = world.get_gameobject(character_id) if character_id == demon.id: continue @@ -733,32 +744,30 @@ def bind_victim(world: World, event: LifeEvent): candidates.append(character) if candidates: - return world.get_resource(NeighborlyEngine).rng.choice( - candidates - ) + return world.get_resource(NeighborlyEngine).rng.choice(candidates) return None - return LifeEventType( + return LifeEvent( "DevourHuman", probability=probability, roles=[ - EventRoleType("Demon", components=[Demon]), - EventRoleType("Victim", binder_fn=bind_victim) + LifeEventRoleType("Demon", binder_fn=bind_demon), + LifeEventRoleType("Victim", binder_fn=bind_victim), ], - execute_fn=execute + effect=execute, ) -def battle(probability: float = 1.0) -> LifeEventType: +def battle(probability: float = 1.0) -> ILifeEvent: """Have a demon fight a demon slayer""" - def execute(world: World, event: LifeEvent): + def execute(world: World, event: Event): """Choose a winner based on their expected success""" demon = world.get_gameobject(event["Demon"]).get_component(Demon) slayer = world.get_gameobject(event["Slayer"]).get_component(DemonSlayer) rng = world.get_resource(NeighborlyEngine).rng - death_event_type = LifeEventLibrary.get("Death") + _death_event_type = LifeEvents.get("Death") slayer_success_chance = probability_of_winning( slayer.power_level, demon.power_level @@ -774,18 +783,18 @@ def execute(world: World, event: LifeEvent): slayer.power_level, demon.power_level, slayer_success_chance, - demon_success_chance + demon_success_chance, ) slayer.power_level = new_slayer_pl slayer.rank = power_level_to_slayer_rank(slayer.power_level) - death_event = death_event_type.instantiate(world, Deceased=demon.gameobject) + death_event = _death_event_type.instantiate( + world, Deceased=demon.gameobject + ) if death_event: - death_event_type.execute(world, death_event) - - return EventResult(generated_events=[death_event, event]) + _death_event_type.execute(world, death_event) else: # Demon wins @@ -799,26 +808,46 @@ def execute(world: World, event: LifeEvent): demon.power_level = new_demon_pl demon.rank = power_level_to_demon_rank(demon.power_level) - death_event = death_event_type.instantiate(world, Deceased=slayer.gameobject) + death_event = _death_event_type.instantiate( + world, Deceased=slayer.gameobject + ) if death_event: - death_event_type.execute(world, death_event) + _death_event_type.execute(world, death_event) + + def bind_demon(world: World, event: Event, candidate: Optional[GameObject] = None): + q = query.Query(("Demon",), [query.where(query.has_components(Demon))]) + candidate_id = candidate.id if candidate else None + results = q.execute(world, Demon=candidate_id) + if results: + return world.get_gameobject( + world.get_resource(NeighborlyEngine).rng.choice(results)[0] + ) - return EventResult(generated_events=[death_event, event]) + def bind_demon_slayer( + world: World, event: Event, candidate: Optional[GameObject] = None + ): + q = query.Query(("DemonSlayer",), [query.where(query.has_components(Demon))]) + candidate_id = candidate.id if candidate else None + results = q.execute(world, DemonSlayer=candidate_id) + if results: + return world.get_gameobject( + world.get_resource(NeighborlyEngine).rng.choice(results)[0] + ) - return LifeEventType( + return LifeEvent( "Battle", probability=probability, roles=[ - EventRoleType("Demon", components=[Demon]), - EventRoleType("Slayer", components=[DemonSlayer]), + LifeEventRoleType("Demon", bind_demon), + LifeEventRoleType("Slayer", bind_demon_slayer), ], - execute_fn=execute + effect=execute, ) -def turn_into_demon(probability: float = 1.0) -> LifeEventType: - def bind_new_demon(world: World, event: LifeEvent): +def turn_into_demon(probability: float = 1.0) -> ILifeEvent: + def bind_new_demon(world: World, event: Event, candidate: Optional[GameObject]): candidates: List[GameObject] = [] for _, character in world.get_component(GameCharacter): if character.gameobject.has_component(Demon): @@ -826,46 +855,48 @@ def bind_new_demon(world: World, event: LifeEvent): if character.gameobject.has_component(DemonSlayer): continue - if character.age >= character.character_def.life_stages["teen"]: + if ( + character.gameobject.get_component(Age).value + >= character.gameobject.get_component(LifeStages).stages["teen"] + ): candidates.append(character.gameobject) if candidates: - return world.get_resource(NeighborlyEngine).rng.choice( - candidates - ) + return world.get_resource(NeighborlyEngine).rng.choice(candidates) return None - def execute(world: World, event: LifeEvent): + def execute(world: World, event: Event): character = world.get_gameobject(event["Character"]) character.add_component(Demon.create(world)) - return EventResult(generated_events=[event]) + character.remove_component(CanAge) + character.remove_component(CanGetPregnant) - return LifeEventType( + return LifeEvent( "TurnIntoDemon", probability=probability, - roles=[EventRoleType("Character", binder_fn=bind_new_demon)], - execute_fn=execute + roles=[LifeEventRoleType("Character", binder_fn=bind_new_demon)], + effect=execute, ) -def death_event_type() -> LifeEventType: - def execute(world: World, event: LifeEvent): +def death_event_type() -> ILifeEvent: + def execute(world: World, event: Event): deceased = world.get_gameobject(event["Deceased"]) deceased.add_component(Deceased()) - deceased.archive() - return EventResult(generated_events=[event]) + deceased.remove_component(Active) + move_to_location(world, deceased, None) - return LifeEventType( + return LifeEvent( "Death", - roles=[EventRoleType("Deceased")], - execute_fn=execute, - probability=0.0 + roles=[LifeEventRoleType("Deceased")], + effect=execute, + probability=0, ) -def promotion_to_lower_moon(probability: float = 1.0) -> LifeEventType: - def bind_demon(world: World, event: LifeEvent): +def promotion_to_lower_moon(probability: float = 1.0) -> ILifeEvent: + def bind_demon(world: World, event: Event, candidate: Optional[GameObject]): demon_kingdom = world.get_resource(DemonKingdom) @@ -878,29 +909,24 @@ def bind_demon(world: World, event: LifeEvent): candidates.append(demon.gameobject) if candidates: - return world.get_resource(NeighborlyEngine).rng.choice( - candidates - ) + return world.get_resource(NeighborlyEngine).rng.choice(candidates) return None - def execute(world: World, event: LifeEvent): - demon = world.get_gameobject(event["Demon"]).get_component( - Demon - ) + def execute(world: World, event: Event): + demon = world.get_gameobject(event["Demon"]).get_component(Demon) demon.rank = DemonRank.LowerMoon - return EventResult(generated_events=[event]) - return LifeEventType( + return LifeEvent( "PromotedToLowerMoon", probability=probability, - roles=[EventRoleType("Demon", binder_fn=bind_demon)], - execute_fn=execute + roles=[LifeEventRoleType("Demon", binder_fn=bind_demon)], + effect=execute, ) -def promotion_to_upper_moon(probability: float = 1.0) -> LifeEventType: - def bind_demon(world: World, event: LifeEvent): +def promotion_to_upper_moon(probability: float = 1.0) -> ILifeEvent: + def bind_demon(world: World, event: Event, candidate: Optional[None]): demon_kingdom = world.get_resource(DemonKingdom) @@ -913,24 +939,19 @@ def bind_demon(world: World, event: LifeEvent): candidates.append(demon.gameobject) if candidates: - return world.get_resource(NeighborlyEngine).rng.choice( - candidates - ) + return world.get_resource(NeighborlyEngine).rng.choice(candidates) return None - def execute(world: World, event: LifeEvent): - demon = world.get_gameobject(event["Demon"]).get_component( - Demon - ) + def execute(world: World, event: Event): + demon = world.get_gameobject(event["Demon"]).get_component(Demon) demon.rank = DemonRank.UpperMoon - return EventResult(generated_events=[event]) - return LifeEventType( + return LifeEvent( "PromotedToUpperMoon", probability=probability, - roles=[EventRoleType("Demon", binder_fn=bind_demon)], - execute_fn=execute + roles=[LifeEventRoleType("Demon", binder_fn=bind_demon)], + effect=execute, ) @@ -938,17 +959,18 @@ def execute(world: World, event: LifeEvent): # Plugin ######################################## + class DemonSlayerPlugin(Plugin): def setup(self, sim: Simulation, **kwargs) -> None: - LifeEventLibrary.add(promotion_to_upper_moon()) - LifeEventLibrary.add(promotion_to_lower_moon()) - LifeEventLibrary.add(turn_into_demon(0.3)) - LifeEventLibrary.add(battle(0.7)) - LifeEventLibrary.add(devour_human(0.3)) - LifeEventLibrary.add(challenge_for_power(0.4)) - LifeEventLibrary.add(demon_slayer_promotion(0.7)) - LifeEventLibrary.add(become_demon_slayer(0.3)) - LifeEventLibrary.add(death_event_type()) + LifeEvents.add(promotion_to_upper_moon()) + LifeEvents.add(promotion_to_lower_moon()) + LifeEvents.add(turn_into_demon(0.8)) + LifeEvents.add(battle(0.7)) + LifeEvents.add(devour_human(0.3)) + LifeEvents.add(challenge_for_power(0.4)) + LifeEvents.add(demon_slayer_promotion(0.7)) + LifeEvents.add(become_demon_slayer(0.3)) + LifeEvents.add(death_event_type()) sim.world.add_resource(DemonSlayerCorps()) sim.world.add_resource(DemonKingdom()) @@ -957,25 +979,34 @@ def setup(self, sim: Simulation, **kwargs) -> None: # MAIN FUNCTION ######################################## +EXPORT_WORLD = False + + def main(): sim = ( SimulationBuilder() - .add_plugin(DefaultPlugin()) - .add_plugin(WeatherPlugin()) - .add_plugin(TalkOfTheTownPlugin()) + .add_plugin(defaults.get_plugin()) + .add_plugin(weather.get_plugin()) + .add_plugin(talktown.get_plugin()) .add_plugin(DemonSlayerPlugin()) .build() ) - sim.world.get_resource(LifeEventLog).subscribe(lambda e: print(str(e))) - st = time.time() - sim.establish_setting() + sim.run_for(50) elapsed_time = time.time() - st - print(f"World Date: {sim.time.to_iso_str()}") + print(f"World Date: {sim.date.to_iso_str()}") print("Execution time: ", elapsed_time, "seconds") + if EXPORT_WORLD: + output_path = f"{sim.seed}_{sim.town.name.replace(' ', '_')}.json" + + with open(output_path, "w") as f: + data = NeighborlyJsonExporter().export(sim) + f.write(data) + print(f"Simulation data written to: '{output_path}'") + if __name__ == "__main__": main() diff --git a/samples/inheritance_system.py b/samples/inheritance_system.py new file mode 100644 index 0000000..9252b9d --- /dev/null +++ b/samples/inheritance_system.py @@ -0,0 +1,81 @@ +""" +This is another attempt at improving the entity generation process. As I have gained +a better understanding of how I should model characters, it has helped realize the +problems with previous interfaces. For this iteration, we are breaking apart the pieces +of characters into more individual components and placing probabilities on those +components being present at spawn-time. +""" +from __future__ import annotations + +import random +from typing import List, Optional, Set + +from neighborly.builtin.helpers import IInheritable, generate_child, inheritable +from neighborly.core.archetypes import BaseCharacterArchetype +from neighborly.core.ecs import Component, GameObject, World +from neighborly.core.engine import NeighborlyEngine +from neighborly.plugins.defaults import DefaultNameDataPlugin +from neighborly.simulation import SimulationBuilder + + +@inheritable(always_inherited=True) +class FurColor(Component, IInheritable): + + __slots__ = "values" + + def __init__(self, colors: List[str]) -> None: + super().__init__() + self.values: Set[str] = set(colors) + + def pprint(self) -> None: + print(f"{self.__class__.__name__}:\n" f"\tcolors: {self.values}") + + @classmethod + def from_parents(cls, *components: FurColor) -> FurColor: + all_colors = set() + for parent in components: + for color in parent.values: + all_colors.add(color) + + return FurColor(list(all_colors)) + + +class FuzzCharacterArchetype(BaseCharacterArchetype): + def create(self, world: World, **kwargs) -> GameObject: + gameobject = super().create(world, **kwargs) + + fur_color = random.choice( + ["Red", "Green", "Blue", "Yellow", "Orange", "White", "Black", "Purple"] + ) + + gameobject.add_component(FurColor([fur_color])) + + if world.get_resource(NeighborlyEngine).rng.random() < 0.3: + gameobject.add_component(HasHorns()) + + return gameobject + + +@inheritable(inheritance_chance=(0.5, 0.7)) +class HasHorns(Component, IInheritable): + @classmethod + def from_parents( + cls, parent_a: Optional[Component], parent_b: Optional[Component] + ) -> Component: + return HasHorns() + + +def main(): + sim = SimulationBuilder().add_plugin(DefaultNameDataPlugin()).build() + + c1 = FuzzCharacterArchetype().create(sim.world) + c2 = FuzzCharacterArchetype().create(sim.world) + c3 = generate_child(sim.world, c1, c2) + + c1.pprint() + c2.pprint() + c3.pprint() + + +if __name__ == "__main__": + main() diff --git a/samples/john_wick.py b/samples/john_wick.py index e04523a..247d08b 100644 --- a/samples/john_wick.py +++ b/samples/john_wick.py @@ -3,7 +3,7 @@ Author: Shi Johnson-Bey John Wick is a movie franchise staring Keanu Reeves, in which -his character, John Wick, is part of an underground society of +his entity, John Wick, is part of an underground society of assassins and hit-people. The member of the criminal underworld follow specific social norms that regular civilians don't. All favors come at the cost of coins, and no work-for-hire killing @@ -19,36 +19,37 @@ from dataclasses import dataclass from typing import List, Optional -from neighborly.builtin.statuses import Adult -from neighborly.core.archetypes import ( - BusinessArchetype, - BusinessArchetypeLibrary, - CharacterArchetype, +from neighborly import ( + Component, + GameObject, + Plugin, + SimDateTime, + Simulation, + SimulationBuilder, + World, ) +from neighborly.builtin.components import Active, Adult, Deceased +from neighborly.builtin.role_filters import friendship_lt +from neighborly.core import query +from neighborly.core.archetypes import BaseBusinessArchetype, BusinessArchetypes +from neighborly.core.business import IBusinessType from neighborly.core.character import GameCharacter -from neighborly.core.ecs import Component, GameObject, World from neighborly.core.engine import NeighborlyEngine +from neighborly.core.event import Event, EventRole from neighborly.core.life_event import ( - EventResult, - EventRole, - EventRoleType, + ILifeEvent, LifeEvent, - LifeEventLibrary, - LifeEventLog, - LifeEventType, + LifeEventRoleType, + LifeEvents, + PatternLifeEvent, ) -from neighborly.core.relationship import RelationshipGraph -from neighborly.core.residence import Resident -from neighborly.core.time import SimDateTime -from neighborly.plugins.default_plugin import DefaultPlugin -from neighborly.plugins.talktown import TalkOfTheTownPlugin -from neighborly.plugins.weather_plugin import WeatherPlugin -from neighborly.simulation import Plugin, Simulation, SimulationBuilder +from neighborly.exporter import NeighborlyJsonExporter +from neighborly.plugins import defaults, talktown, weather @dataclass class Assassin(Component): - """Assassin component to be attached to a character + """Assassin component to be attached to an entity Assassins mark people who are part of the criminal underworld and who may exchange coins for assassinations @@ -59,24 +60,13 @@ class Assassin(Component): kills: int = 0 -def assassin_character_archetype() -> CharacterArchetype: - return CharacterArchetype( - name="Assassin", - lifespan=85, - life_stages={ - "child": 0, - "teen": 13, - "young_adult": 18, - "adult": 30, - "elder": 65, - }, - extra_components={Assassin: {}}, - ) +class Hotel(IBusinessType): + pass -def continental_hotel() -> BusinessArchetype: - return BusinessArchetype( - name="The Continental Hotel", +def continental_hotel() -> BaseBusinessArchetype: + return BaseBusinessArchetype( + name_format="The Continental Hotel", max_instances=1, min_population=40, employee_types={ @@ -84,13 +74,16 @@ def continental_hotel() -> BusinessArchetype: "Concierge": 1, "Bartender": 2, }, + business_type=Hotel, ) -def become_an_assassin(probability: float = 0.3) -> LifeEventType: +def become_an_assassin(probability: float = 0.3) -> ILifeEvent: """Turns ordinary people into assassins""" - def bind_character(world: World, event: LifeEvent) -> Optional[GameObject]: + def bind_character( + world: World, event: Event, candidate: Optional[None] + ) -> Optional[GameObject]: candidates: List[GameObject] = [] for gid, (character, _) in world.get_components(GameCharacter, Adult): if not character.gameobject.has_component(Assassin): @@ -99,58 +92,27 @@ def bind_character(world: World, event: LifeEvent) -> Optional[GameObject]: if candidates: return world.get_resource(NeighborlyEngine).rng.choice(candidates) - def execute(world: World, event: LifeEvent): + def execute(world: World, event: Event): new_assassin = world.get_gameobject(event["Character"]) new_assassin.add_component(Assassin()) - return EventResult(generated_events=[event]) - return LifeEventType( + return LifeEvent( name="BecomeAssassin", probability=probability, - execute_fn=execute, - roles=[EventRoleType(name="Character", binder_fn=bind_character)], + effect=execute, + roles=[LifeEventRoleType(name="Character", binder_fn=bind_character)], ) def hire_assassin_event( - dislike_threshold: int, probability: float = 0.2 -) -> LifeEventType: - def bind_client(world: World, event: LifeEvent) -> Optional[GameObject]: - """Find someone who hates another character""" - rel_graph = world.get_resource(RelationshipGraph) - candidates: List[int] = [] - for gid, _ in world.get_components(Component, Resident): - for relationship in rel_graph.get_relationships(gid): - if relationship.friendship < dislike_threshold: - candidates.append(gid) - - if candidates: - return world.get_gameobject( - world.get_resource(NeighborlyEngine).rng.choice(candidates) - ) - - def bind_target(world: World, event: LifeEvent) -> Optional[GameObject]: - """Find someone that the client would want dead""" - rel_graph = world.get_resource(RelationshipGraph) - candidates: List[int] = [] - for relationship in rel_graph.get_relationships(event["Client"]): - if relationship.friendship < dislike_threshold: - candidate = world.get_gameobject(relationship.target) - if candidate.has_component(Resident): - candidates.append(relationship.target) - - if candidates: - return world.get_gameobject( - world.get_resource(NeighborlyEngine).rng.choice(candidates) - ) - - def execute_fn(world: World, event: LifeEvent): - event_log = world.get_resource(LifeEventLog) + dislike_threshold: float = 0.3, probability: float = 0.2 +) -> ILifeEvent: + def execute_fn(world: World, event: Event): date_time = world.get_resource(SimDateTime) assassin = world.get_gameobject(event["Assassin"]) assassin.get_component(Assassin).kills += 1 - death_event = LifeEvent( + Event( name="Death", timestamp=date_time.to_iso_str(), roles=[ @@ -158,52 +120,66 @@ def execute_fn(world: World, event: LifeEvent): ], ) - world.get_gameobject(event["Target"]).archive() + world.get_gameobject(event["Target"]).add_component(Deceased()) - return EventResult(generated_events=[event, death_event]) - - return LifeEventType( + return PatternLifeEvent( name="HireAssassin", probability=probability, - roles=[ - EventRoleType(name="Client", components=[GameCharacter, Adult]), - EventRoleType(name="Target", binder_fn=bind_target), - EventRoleType(name="Assassin", components=[Assassin, Adult]), - ], - execute_fn=execute_fn, + effect=execute_fn, + pattern=query.Query( + find=("Client", "Target", "Assassin"), + clauses=[ + query.where(query.has_components(GameCharacter, Active), "Client"), + query.where(query.has_components(GameCharacter, Active), "Target"), + query.where(query.has_components(Assassin, Active), "Assassin"), + query.where(friendship_lt(dislike_threshold), "Client", "Target"), + query.ne_(("Client", "Target")), + query.ne_(("Target", "Assassin")), + query.ne_(("Client", "Assassin")), + ], + ), ) class JohnWickPlugin(Plugin): def setup(self, sim: Simulation, **kwargs) -> None: - LifeEventLibrary.add(hire_assassin_event(-30)) - LifeEventLibrary.add(become_an_assassin()) - BusinessArchetypeLibrary.add(continental_hotel()) + LifeEvents.add(hire_assassin_event(-30)) + LifeEvents.add(become_an_assassin()) + BusinessArchetypes.add("The Continental Hotel", continental_hotel()) + + +EXPORT_WORLD = False def main(): sim = ( SimulationBuilder( seed=random.randint(0, 999999), - world_gen_start=SimDateTime(year=1839, month=8, day=19), - world_gen_end=SimDateTime(year=1979, month=8, day=19), + starting_date=SimDateTime(year=1990, month=0, day=0), + print_events=True, ) - .add_plugin(DefaultPlugin()) - .add_plugin(WeatherPlugin()) - .add_plugin(TalkOfTheTownPlugin()) + .add_plugin(defaults.get_plugin()) + .add_plugin(weather.get_plugin()) + .add_plugin(talktown.get_plugin()) .add_plugin(JohnWickPlugin()) .build() ) - sim.world.get_resource(LifeEventLog).subscribe(lambda e: print(str(e))) - st = time.time() - sim.establish_setting() + sim.run_for(40) elapsed_time = time.time() - st - print(f"World Date: {sim.time.to_iso_str()}") + print(f"World Date: {sim.date.to_iso_str()}") print("Execution time: ", elapsed_time, "seconds") + if EXPORT_WORLD: + output_path = f"{sim.seed}_{sim.town.name.replace(' ', '_')}.json" + + with open(output_path, "w") as f: + data = NeighborlyJsonExporter().export(sim) + f.write(data) + print(f"Simulation data written to: '{output_path}'") + if __name__ == "__main__": main() diff --git a/samples/notebooks/gui.py b/samples/notebooks/gui.py deleted file mode 100644 index 3588752..0000000 --- a/samples/notebooks/gui.py +++ /dev/null @@ -1,140 +0,0 @@ -import threading -import time -from enum import Enum -from typing import Optional, Protocol - -import ipywidgets as widgets -from IPython.display import display - -from neighborly.simulation import Simulation - - -class SimulationGUIWidget(Protocol): - def update(self, *args, **kwargs) -> None: - raise NotImplementedError() - - -class GameCharacterWidget: - """Displays information about a GameCharacter instance""" - - ... - - -class LocationWidget: - """Displays information about a Location instance""" - - ... - - -class RoutineWidget: - """Displays information about a Routine instance""" - - ... - - -class RelationshipWidget: - """Displays information about a Relationship instance""" - - -class SimulationState(Enum): - """Tracks if the simulation is running or paused""" - - PAUSED = 0 - STEPPING = 1 - RUNNING = 2 - STOPPED = 3 - - -def create_character_tab(): - """Create the GUI tab that""" - - -class SimulationGUI: - """Ipywidget GUI that displays information about a Neighborly Simulation instance""" - - def __init__(self, simulation: Simulation) -> None: - self.simulation: Simulation = simulation - self.simulation_thread = threading.Thread(target=self.run_simulation) - self.simulation_running = True - self.simulation_paused = True - self.simulation_stepping = False - self.active_widget: Optional[SimulationGUIWidget] = None - - # Create GUI - self.date_text = widgets.Text( - value=self.simulation.time.to_date_str(), - disabled=True, - layout=widgets.Layout(width="100%"), - ) - - self.play_button = widgets.Button( - description="Play", - disabled=False, - ) - self.play_button.on_click(self.play_simulation) - - self.step_button = widgets.Button( - description="Step", - disabled=False, - ) - self.step_button.on_click(self.step_simulation) - - self.pause_button = widgets.Button( - description="Pause", - disabled=True, - ) - self.pause_button.on_click(self.pause_simulation) - - self.characters_tab = widgets.VBox( - [widgets.HTML(value=f"

Characters

")] - ) # type: ignore - self.places_tab = widgets.VBox( - [widgets.HTML(value=f"

Places

")] - ) # type: ignore - - tabs = widgets.Tab([self.characters_tab, self.places_tab]) # type: ignore - tabs.set_title(0, "Characters") - tabs.set_title(1, "Places") - - self.root_widget = widgets.VBox( - [ - widgets.HBox( - [self.play_button, self.step_button, self.pause_button] - ), # type: ignore - self.date_text, - tabs, - ], - layout=widgets.Layout(width="80%", padding="12px"), - ) # type: ignore - - def update_gui(self): - self.date_text.value = self.simulation.time.to_date_str() - if self.active_widget: - self.active_widget.update() - - def run_simulation(self): - while self.simulation_running: - if not self.simulation_paused or self.simulation_stepping: - self.simulation.step() - self.update_gui() - self.simulation_stepping = False - time.sleep(0.05) - - def play_simulation(self, b): - self.simulation_paused = False - self.play_button.disabled = True - self.pause_button.disabled = False - self.step_button.disabled = True - - def step_simulation(self, b): - self.simulation_stepping = True - - def pause_simulation(self, b): - self.simulation_paused = True - self.pause_button.disabled = True - self.play_button.disabled = False - self.step_button.disabled = False - - def run(self) -> None: - display(self.root_widget) - self.simulation_thread.start() diff --git a/samples/notebooks/sample.ipynb b/samples/notebooks/sample.ipynb deleted file mode 100644 index ddf17ea..0000000 --- a/samples/notebooks/sample.ipynb +++ /dev/null @@ -1,625 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Neighborly Notebook Sample" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "metadata": {}, - "outputs": [], - "source": [ - "import random\n", - "import time\n", - "\n", - "import networkx as nx\n", - "\n", - "from neighborly.core.life_event import LifeEventLog\n", - "from neighborly.core.time import SimDateTime\n", - "from neighborly.exporter import NeighborlyJsonExporter\n", - "from neighborly.plugins.default_plugin import DefaultPlugin\n", - "from neighborly.plugins.talktown import TalkOfTheTownPlugin\n", - "from neighborly.plugins.weather_plugin import WeatherPlugin\n", - "from neighborly.simulation import Simulation" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "metadata": {}, - "outputs": [], - "source": [ - "sim = (\n", - " Simulation.create(\n", - " seed=random.randint(0, 999999),\n", - " world_gen_start=SimDateTime(year=1929, month=8, day=19),\n", - " world_gen_end=SimDateTime(year=1979, month=8, day=19),\n", - " )\n", - " .add_plugin(DefaultPlugin())\n", - " .add_plugin(WeatherPlugin())\n", - " .add_plugin(TalkOfTheTownPlugin())\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "NewResidenceBuilt [at 1929-08-19T12:00.000z] : Residence:1\n", - "NewBusinessBuilt [at 1929-08-19T12:00.000z] : Business:2\n", - "MoveIntoTown [at 1929-08-26T12:00.000z] : resident:3, resident:4, resident:5, resident:6, resident:7\n", - "BecomeAdult [at 1929-08-26T12:00.000z] : Character:3\n", - "BecomeAdult [at 1929-08-26T12:00.000z] : Character:4\n", - "BecomeTeen [at 1929-08-26T12:00.000z] : Character:5\n", - "BecomeTeen [at 1929-08-26T12:00.000z] : Character:6\n", - "BecomeTeen [at 1929-08-26T12:00.000z] : Character:7\n", - "BecameBusinessOwner [at 1929-08-26T12:00.000z] : Business:2, Owner:4\n", - "HiredAtBusiness [at 1929-08-26T12:00.000z] : Business:2, Employee:3\n", - "BecomeYoungAdult [at 1929-08-27T00:00.000z] : Character:5\n", - "BecomeYoungAdult [at 1929-08-27T00:00.000z] : Character:6\n", - "BecomeYoungAdult [at 1929-08-27T00:00.000z] : Character:7\n", - "HiredAtBusiness [at 1929-08-27T00:00.000z] : Business:2, Employee:6\n", - "BecomeAdult [at 1929-08-27T12:00.000z] : Character:5\n", - "BecomeAdult [at 1929-08-27T12:00.000z] : Character:7\n", - "HiredAtBusiness [at 1929-08-27T12:00.000z] : Business:2, Employee:5\n", - "NewResidenceBuilt [at 1929-09-11T12:00.000z] : Residence:8\n", - "MoveIntoTown [at 1929-09-12T12:00.000z] : resident:9, resident:10, resident:11\n", - "BecomeAdult [at 1929-09-12T12:00.000z] : Character:9\n", - "BecomeAdult [at 1929-09-12T12:00.000z] : Character:10\n", - "BecomeTeen [at 1929-09-12T12:00.000z] : Character:11\n", - "BecomeYoungAdult [at 1929-09-13T00:00.000z] : Character:11\n", - "BecomeAdult [at 1929-09-13T12:00.000z] : Character:11\n", - "NewResidenceBuilt [at 1929-09-21T12:00.000z] : Residence:12\n", - "NewBusinessBuilt [at 1929-09-21T12:00.000z] : Business:13\n", - "BecameBusinessOwner [at 1929-09-21T12:00.000z] : Business:13, Owner:11\n", - "HiredAtBusiness [at 1929-09-21T12:00.000z] : Business:13, Employee:9\n", - "HiredAtBusiness [at 1929-09-21T12:00.000z] : Business:13, Employee:7\n", - "HiredAtBusiness [at 1929-09-21T12:00.000z] : Business:13, Employee:10\n", - "MoveIntoTown [at 1929-09-26T12:00.000z] : resident:14, resident:15, resident:16\n", - "NewBusinessBuilt [at 1929-09-26T12:00.000z] : Business:17\n", - "BecomeAdult [at 1929-09-26T12:00.000z] : Character:15\n", - "BecomeTeen [at 1929-09-26T12:00.000z] : Character:16\n", - "HiredAtBusiness [at 1929-09-26T12:00.000z] : Business:17, Employee:14\n", - "HiredAtBusiness [at 1929-09-26T12:00.000z] : Business:17, Employee:15\n", - "BecomeYoungAdult [at 1929-09-27T00:00.000z] : Character:16\n", - "HiredAtBusiness [at 1929-09-27T00:00.000z] : Business:17, Employee:16\n", - "BecomeAdult [at 1929-09-27T12:00.000z] : Character:16\n", - "NewResidenceBuilt [at 1929-10-08T12:00.000z] : Residence:18\n", - "NewBusinessBuilt [at 1929-10-08T12:00.000z] : Business:19\n", - "MoveIntoTown [at 1929-10-12T12:00.000z] : resident:20, resident:21\n", - "BecomeAdult [at 1929-10-12T12:00.000z] : Character:20\n", - "BecomeAdult [at 1929-10-12T12:00.000z] : Character:21\n", - "BecameBusinessOwner [at 1929-10-12T12:00.000z] : Business:19, Owner:20\n", - "HiredAtBusiness [at 1929-10-12T12:00.000z] : Business:17, Employee:21\n", - "NewResidenceBuilt [at 1929-10-13T12:00.000z] : Residence:22\n", - "NewResidenceBuilt [at 1929-10-18T12:00.000z] : Residence:23\n", - "NewResidenceBuilt [at 1929-10-23T12:00.000z] : Residence:24\n", - "MoveIntoTown [at 1929-10-26T12:00.000z] : resident:25, resident:26, resident:27\n", - "MoveIntoTown [at 1929-10-26T12:00.000z] : resident:28, resident:29, resident:30\n", - "BecomeAdult [at 1929-10-26T12:00.000z] : Character:25\n", - "BecomeAdult [at 1929-10-26T12:00.000z] : Character:26\n", - "BecomeTeen [at 1929-10-26T12:00.000z] : Character:27\n", - "BecomeAdult [at 1929-10-26T12:00.000z] : Character:28\n", - "BecomeTeen [at 1929-10-26T12:00.000z] : Character:29\n", - "BecomeTeen [at 1929-10-26T12:00.000z] : Character:30\n", - "HiredAtBusiness [at 1929-10-26T12:00.000z] : Business:19, Employee:25\n", - "HiredAtBusiness [at 1929-10-26T12:00.000z] : Business:19, Employee:26\n", - "HiredAtBusiness [at 1929-10-26T12:00.000z] : Business:13, Employee:28\n", - "BecomeYoungAdult [at 1929-10-27T00:00.000z] : Character:27\n", - "BecomeYoungAdult [at 1929-10-27T00:00.000z] : Character:29\n", - "BecomeYoungAdult [at 1929-10-27T00:00.000z] : Character:30\n", - "HiredAtBusiness [at 1929-10-27T00:00.000z] : Business:13, Employee:27\n", - "BecomeAdult [at 1929-10-27T12:00.000z] : Character:27\n", - "BecomeAdult [at 1929-10-27T12:00.000z] : Character:29\n", - "NewBusinessBuilt [at 1929-11-00T12:00.000z] : Business:31\n", - "BecameBusinessOwner [at 1929-11-00T12:00.000z] : Business:31, Owner:30\n", - "HiredAtBusiness [at 1929-11-00T12:00.000z] : Business:31, Employee:29\n", - "NewBusinessBuilt [at 1929-11-10T12:00.000z] : Business:32\n", - "NewResidenceBuilt [at 1929-11-15T12:00.000z] : Residence:33\n", - "NewBusinessBuilt [at 1929-11-15T12:00.000z] : Business:34\n", - "NewBusinessBuilt [at 1929-11-20T12:00.000z] : Business:35\n", - "NewResidenceBuilt [at 1929-11-25T12:00.000z] : Residence:36\n", - "NewBusinessBuilt [at 1930-00-07T12:00.000z] : Business:37\n", - "NewResidenceBuilt [at 1930-00-12T12:00.000z] : Residence:38\n", - "NewBusinessBuilt [at 1930-00-12T12:00.000z] : Business:39\n", - "NewBusinessBuilt [at 1930-00-17T12:00.000z] : Business:40\n", - "MoveIntoTown [at 1930-00-19T12:00.000z] : resident:41, resident:42, resident:43, resident:44\n", - "BecomeAdult [at 1930-00-19T12:00.000z] : Character:41\n", - "BecomeTeen [at 1930-00-19T12:00.000z] : Character:42\n", - "BecomeTeen [at 1930-00-19T12:00.000z] : Character:43\n", - "BecomeTeen [at 1930-00-19T12:00.000z] : Character:44\n", - "BecameBusinessOwner [at 1930-00-19T12:00.000z] : Business:32, Owner:41\n", - "BecomeYoungAdult [at 1930-00-20T00:00.000z] : Character:42\n", - "BecomeYoungAdult [at 1930-00-20T00:00.000z] : Character:43\n", - "BecomeYoungAdult [at 1930-00-20T00:00.000z] : Character:44\n", - "BecameBusinessOwner [at 1930-00-20T00:00.000z] : Business:34, Owner:44\n", - "BecameBusinessOwner [at 1930-00-20T00:00.000z] : Business:35, Owner:43\n", - "BecameBusinessOwner [at 1930-00-20T00:00.000z] : Business:37, Owner:42\n", - "BecomeAdult [at 1930-00-20T12:00.000z] : Character:42\n", - "BecomeAdult [at 1930-00-20T12:00.000z] : Character:43\n", - "BecomeAdult [at 1930-00-20T12:00.000z] : Character:44\n", - "NewResidenceBuilt [at 1930-00-22T12:00.000z] : Residence:45\n", - "NewBusinessBuilt [at 1930-00-22T12:00.000z] : Business:46\n", - "NewBusinessBuilt [at 1930-00-27T12:00.000z] : Business:47\n", - "MoveIntoTown [at 1930-01-05T12:00.000z] : resident:48, resident:49, resident:50, resident:51\n", - "BecomeAdult [at 1930-01-05T12:00.000z] : Character:49\n", - "BecomeTeen [at 1930-01-05T12:00.000z] : Character:50\n", - "BecomeTeen [at 1930-01-05T12:00.000z] : Character:51\n", - "BecameBusinessOwner [at 1930-01-05T12:00.000z] : Business:39, Owner:49\n", - "BecameBusinessOwner [at 1930-01-05T12:00.000z] : Business:40, Owner:48\n", - "BecomeYoungAdult [at 1930-01-06T00:00.000z] : Character:50\n", - "BecomeYoungAdult [at 1930-01-06T00:00.000z] : Character:51\n", - "BecameBusinessOwner [at 1930-01-06T00:00.000z] : Business:46, Owner:51\n", - "BecameBusinessOwner [at 1930-01-06T00:00.000z] : Business:47, Owner:50\n", - "BecomeAdult [at 1930-01-06T12:00.000z] : Character:50\n", - "BecomeAdult [at 1930-01-06T12:00.000z] : Character:51\n", - "NewBusinessBuilt [at 1930-01-09T12:00.000z] : Business:52\n", - "MoveIntoTown [at 1930-01-12T12:00.000z] : resident:53, resident:54\n", - "BecomeAdult [at 1930-01-12T12:00.000z] : Character:53\n", - "BecomeAdult [at 1930-01-12T12:00.000z] : Character:54\n", - "BecameBusinessOwner [at 1930-01-12T12:00.000z] : Business:52, Owner:54\n", - "HiredAtBusiness [at 1930-01-12T12:00.000z] : Business:32, Employee:53\n", - "MoveIntoTown [at 1930-01-19T12:00.000z] : resident:55, resident:56, resident:57\n", - "MoveIntoTown [at 1930-01-19T12:00.000z] : resident:58, resident:59, resident:60\n", - "BecomeAdult [at 1930-01-19T12:00.000z] : Character:55\n", - "BecomeTeen [at 1930-01-19T12:00.000z] : Character:57\n", - "BecomeAdult [at 1930-01-19T12:00.000z] : Character:58\n", - "BecomeTeen [at 1930-01-19T12:00.000z] : Character:59\n", - "BecomeTeen [at 1930-01-19T12:00.000z] : Character:60\n", - "HiredAtBusiness [at 1930-01-19T12:00.000z] : Business:32, Employee:55\n", - "HiredAtBusiness [at 1930-01-19T12:00.000z] : Business:34, Employee:56\n", - "HiredAtBusiness [at 1930-01-19T12:00.000z] : Business:35, Employee:58\n", - "BecomeYoungAdult [at 1930-01-20T00:00.000z] : Character:57\n", - "BecomeYoungAdult [at 1930-01-20T00:00.000z] : Character:59\n", - "BecomeYoungAdult [at 1930-01-20T00:00.000z] : Character:60\n", - "HiredAtBusiness [at 1930-01-20T00:00.000z] : Business:37, Employee:60\n", - "HiredAtBusiness [at 1930-01-20T00:00.000z] : Business:37, Employee:59\n", - "HiredAtBusiness [at 1930-01-20T00:00.000z] : Business:37, Employee:57\n", - "BecomeAdult [at 1930-01-20T12:00.000z] : Character:57\n", - "BecomeAdult [at 1930-01-20T12:00.000z] : Character:59\n", - "BecomeAdult [at 1930-01-20T12:00.000z] : Character:60\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "BecomeAdult [at 1931-10-26T12:00.000z] : Character:30\n", - "GotPregnant [at 1932-03-19T12:00.000z] : PersonA:25, PersonB:26\n", - "ChildBirth [at 1933-00-19T12:00.000z] : Parent:25, Parent:26, Child:61\n", - "GotPregnant [at 1933-10-19T12:00.000z] : PersonA:9, PersonB:10\n", - "ChildBirth [at 1934-07-19T12:00.000z] : Parent:9, Parent:10, Child:62\n", - "BecomeElder [at 1933-10-26T00:00.000z] : Character:29\n", - "GotPregnant [at 1934-00-19T12:00.000z] : PersonA:55, PersonB:56\n", - "ChildBirth [at 1934-09-19T12:00.000z] : Parent:55, Parent:56, Child:63\n", - "BecomeElder [at 1934-01-19T00:00.000z] : Character:60\n", - "BecomeAdult [at 1934-01-19T12:00.000z] : Character:56\n", - "GotPregnant [at 1934-02-19T12:00.000z] : PersonA:4, PersonB:3\n", - "ChildBirth [at 1934-11-19T12:00.000z] : Parent:4, Parent:3, Child:64\n", - "Retire [at 1934-03-19T12:00.000z] : Retiree:60\n", - "GotPregnant [at 1934-09-19T12:00.000z] : PersonA:10, PersonB:9\n", - "ChildBirth [at 1935-06-19T12:00.000z] : Parent:10, Parent:9, Child:65\n", - "BecomeElder [at 1935-00-19T00:00.000z] : Character:42\n", - "BecomeAdult [at 1935-01-05T12:00.000z] : Character:48\n", - "Retire [at 1935-01-19T12:00.000z] : Retiree:29\n", - "BecomeAdult [at 1936-08-26T12:00.000z] : Character:6\n", - "BecomeElder [at 1936-10-26T00:00.000z] : Character:27\n", - "BecomeElder [at 1937-00-19T00:00.000z] : Character:44\n", - "Retire [at 1937-01-19T12:00.000z] : Retiree:27\n", - "GotPregnant [at 1937-06-19T12:00.000z] : PersonA:25, PersonB:26\n", - "ChildBirth [at 1938-03-19T12:00.000z] : Parent:25, Parent:26, Child:66\n", - "BecomeAdult [at 1937-09-26T12:00.000z] : Character:14\n", - "Retire [at 1938-03-19T12:00.000z] : Retiree:42\n", - "Retire [at 1938-06-19T12:00.000z] : Retiree:44\n", - "BecomeElder [at 1938-09-26T00:00.000z] : Character:16\n", - "Retire [at 1939-04-19T12:00.000z] : Retiree:16\n", - "BecomeElder [at 1939-09-12T00:00.000z] : Character:11\n", - "Retire [at 1939-09-19T12:00.000z] : Retiree:11\n", - "GotPregnant [at 1940-03-19T12:00.000z] : PersonA:25, PersonB:26\n", - "ChildBirth [at 1941-00-19T12:00.000z] : Parent:25, Parent:26, Child:67\n", - "BecomeElder [at 1941-00-19T00:00.000z] : Character:43\n", - "Retire [at 1941-04-19T12:00.000z] : Retiree:43\n", - "GotPregnant [at 1941-05-19T12:00.000z] : PersonA:56, PersonB:55\n", - "ChildBirth [at 1942-02-19T12:00.000z] : Parent:56, Parent:55, Child:68\n", - "GotPregnant [at 1942-02-19T12:00.000z] : PersonA:9, PersonB:10\n", - "ChildBirth [at 1942-11-19T12:00.000z] : Parent:9, Parent:10, Child:69\n", - "GotPregnant [at 1945-09-19T12:00.000z] : PersonA:48, PersonB:49\n", - "ChildBirth [at 1946-06-19T12:00.000z] : Parent:48, Parent:49, Child:70\n", - "GotPregnant [at 1946-06-19T12:00.000z] : PersonA:48, PersonB:49\n", - "ChildBirth [at 1947-03-19T12:00.000z] : Parent:48, Parent:49, Child:71\n", - "GotPregnant [at 1948-03-19T12:00.000z] : PersonA:15, PersonB:14\n", - "ChildBirth [at 1949-00-19T12:00.000z] : Parent:15, Parent:14, Child:72\n", - "GotPregnant [at 1949-08-19T12:00.000z] : PersonA:15, PersonB:14\n", - "ChildBirth [at 1950-05-19T12:00.000z] : Parent:15, Parent:14, Child:73\n", - "BecomeElder [at 1950-01-19T00:00.000z] : Character:59\n", - "Retire [at 1950-03-19T12:00.000z] : Retiree:59\n", - "GotPregnant [at 1950-11-19T12:00.000z] : PersonA:4, PersonB:3\n", - "ChildBirth [at 1951-08-19T12:00.000z] : Parent:4, Parent:3, Child:74\n", - "Death [at 1951-00-19T00:00.000z] : Character:41\n", - "BecomeElder [at 1951-01-19T00:00.000z] : Character:57\n", - "Retire [at 1951-02-19T12:00.000z] : Retiree:57\n", - "GotPregnant [at 1952-11-19T12:00.000z] : PersonA:55, PersonB:56\n", - "ChildBirth [at 1953-08-19T12:00.000z] : Parent:55, Parent:56, Child:75\n", - "GotPregnant [at 1953-01-19T12:00.000z] : PersonA:9, PersonB:10\n", - "ChildBirth [at 1953-10-19T12:00.000z] : Parent:9, Parent:10, Child:76\n", - "Death [at 1953-10-12T00:00.000z] : Character:20\n", - "Death [at 1953-10-26T00:00.000z] : Character:29\n", - "StartDating [at 1954-00-19T12:00.000z] : PersonA:21, PersonB:20\n", - "Death [at 1954-01-19T00:00.000z] : Character:60\n", - "GotPregnant [at 1954-03-19T12:00.000z] : PersonA:15, PersonB:14\n", - "ChildBirth [at 1955-00-19T12:00.000z] : Parent:15, Parent:14, Child:77\n", - "GotMarried [at 1954-04-19T12:00.000z] : PersonA:21, PersonB:20\n", - "Death [at 1954-09-26T12:00.000z] : Character:15\n", - "StartDating [at 1954-11-19T12:00.000z] : PersonA:15, PersonB:14\n", - "Death [at 1955-00-19T00:00.000z] : Character:42\n", - "Death [at 1955-01-12T12:00.000z] : Character:54\n", - "StartDating [at 1955-01-19T12:00.000z] : PersonA:54, PersonB:53\n", - "GotMarried [at 1955-08-19T12:00.000z] : PersonA:15, PersonB:14\n", - "BecomeElder [at 1955-08-26T12:00.000z] : Character:5\n", - "GotMarried [at 1955-10-19T12:00.000z] : PersonA:53, PersonB:54\n", - "Death [at 1955-10-26T00:00.000z] : Character:28\n", - "Retire [at 1955-11-19T12:00.000z] : Retiree:5\n", - "Death [at 1956-10-26T00:00.000z] : Character:27\n", - "Death [at 1957-00-19T00:00.000z] : Character:44\n", - "GotPregnant [at 1957-02-19T12:00.000z] : PersonA:9, PersonB:10\n", - "ChildBirth [at 1957-11-19T12:00.000z] : Parent:9, Parent:10, Child:78\n", - "Death [at 1957-10-12T00:00.000z] : Character:21\n", - "MoveIntoTown [at 1957-10-12T12:00.000z] : resident:79, resident:80\n", - "BecomeAdult [at 1957-10-12T12:00.000z] : Character:79\n", - "BecameBusinessOwner [at 1957-10-12T12:00.000z] : Business:32, Owner:79\n", - "BecameBusinessOwner [at 1957-10-12T12:00.000z] : Business:34, Owner:80\n", - "StartDating [at 1958-01-19T12:00.000z] : PersonA:20, PersonB:21\n", - "DatingBreakUp [at 1958-06-19T12:00.000z] : PersonA:20, PersonB:21\n", - "Death [at 1958-09-27T00:00.000z] : Character:16\n", - "BecomeElder [at 1959-08-26T12:00.000z] : Character:7\n", - "Death [at 1959-09-12T00:00.000z] : Character:11\n", - "GotPregnant [at 1959-09-19T12:00.000z] : PersonA:4, PersonB:3\n", - "ChildBirth [at 1960-06-19T12:00.000z] : Parent:4, Parent:3, Child:81\n", - "Retire [at 1959-10-19T12:00.000z] : Retiree:7\n", - "GotPregnant [at 1960-09-19T12:00.000z] : PersonA:10, PersonB:9\n", - "ChildBirth [at 1961-06-19T12:00.000z] : Parent:10, Parent:9, Child:82\n", - "Death [at 1961-00-19T00:00.000z] : Character:43\n", - "MoveIntoTown [at 1961-00-26T12:00.000z] : resident:83, resident:84\n", - "BecomeTeen [at 1961-00-26T12:00.000z] : Character:84\n", - "BecameBusinessOwner [at 1961-00-26T12:00.000z] : Business:35, Owner:83\n", - "BecomeYoungAdult [at 1961-00-27T00:00.000z] : Character:84\n", - "BecameBusinessOwner [at 1961-00-27T00:00.000z] : Business:37, Owner:84\n", - "BecomeAdult [at 1961-00-27T12:00.000z] : Character:84\n", - "GotPregnant [at 1962-03-19T12:00.000z] : PersonA:48, PersonB:49\n", - "ChildBirth [at 1963-00-19T12:00.000z] : Parent:48, Parent:49, Child:85\n", - "GotPregnant [at 1962-05-19T12:00.000z] : PersonA:4, PersonB:3\n", - "ChildBirth [at 1963-02-19T12:00.000z] : Parent:4, Parent:3, Child:86\n", - "BecomeElder [at 1963-01-05T12:00.000z] : Character:50\n", - "Death [at 1964-01-20T00:00.000z] : Character:55\n", - "StartDating [at 1964-02-19T12:00.000z] : PersonA:55, PersonB:56\n", - "GotPregnant [at 1964-03-19T12:00.000z] : PersonA:9, PersonB:10\n", - "ChildBirth [at 1965-00-19T12:00.000z] : Parent:9, Parent:10, Child:87\n", - "DatingBreakUp [at 1964-05-19T12:00.000z] : PersonA:56, PersonB:55\n", - "Retire [at 1964-06-19T12:00.000z] : Retiree:50\n", - "BecomeElder [at 1965-01-05T12:00.000z] : Character:51\n", - "Retire [at 1965-04-19T12:00.000z] : Retiree:51\n", - "GotPregnant [at 1965-08-19T12:00.000z] : PersonA:14, PersonB:15\n", - "ChildBirth [at 1966-05-19T12:00.000z] : Parent:14, Parent:15, Child:88\n", - "GotPregnant [at 1966-06-19T12:00.000z] : PersonA:10, PersonB:9\n", - "ChildBirth [at 1967-03-19T12:00.000z] : Parent:10, Parent:9, Child:89\n", - "BecomeAdult [at 1966-10-12T12:00.000z] : Character:80\n", - "Death [at 1966-10-26T00:00.000z] : Character:25\n", - "BecomeElder [at 1966-10-26T12:00.000z] : Character:30\n", - "Death [at 1967-01-12T00:00.000z] : Character:53\n", - "MoveIntoTown [at 1967-01-26T12:00.000z] : resident:90, resident:91, resident:92, resident:93\n", - "BecomeAdult [at 1967-01-26T12:00.000z] : Character:90\n", - "BecomeTeen [at 1967-01-26T12:00.000z] : Character:92\n", - "BecomeTeen [at 1967-01-26T12:00.000z] : Character:93\n", - "BecameBusinessOwner [at 1967-01-26T12:00.000z] : Business:13, Owner:90\n", - "BecameBusinessOwner [at 1967-01-26T12:00.000z] : Business:46, Owner:91\n", - "BecomeYoungAdult [at 1967-01-27T00:00.000z] : Character:92\n", - "BecomeYoungAdult [at 1967-01-27T00:00.000z] : Character:93\n", - "BecameBusinessOwner [at 1967-01-27T00:00.000z] : Business:47, Owner:93\n", - "BecameBusinessOwner [at 1967-01-27T00:00.000z] : Business:19, Owner:92\n", - "BecomeAdult [at 1967-01-27T12:00.000z] : Character:92\n", - "BecomeAdult [at 1967-01-27T12:00.000z] : Character:93\n", - "StartDating [at 1967-02-19T12:00.000z] : PersonA:26, PersonB:54\n", - "Retire [at 1967-05-19T12:00.000z] : Retiree:30\n", - "StartDating [at 1967-05-19T12:00.000z] : PersonA:25, PersonB:53\n", - "Death [at 1967-09-12T12:00.000z] : Character:9\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "StartDating [at 1967-10-19T12:00.000z] : PersonA:10, PersonB:9\n", - "BecomeAdult [at 1968-00-26T12:00.000z] : Character:83\n", - "GotMarried [at 1968-01-19T12:00.000z] : PersonA:10, PersonB:9\n", - "GotMarried [at 1968-03-19T12:00.000z] : PersonA:25, PersonB:53\n", - "GotMarried [at 1968-04-19T12:00.000z] : PersonA:26, PersonB:54\n", - "GotPregnant [at 1968-11-19T12:00.000z] : PersonA:15, PersonB:14\n", - "ChildBirth [at 1969-08-19T12:00.000z] : Parent:15, Parent:14, Child:94\n", - "GotPregnant [at 1969-00-19T12:00.000z] : PersonA:48, PersonB:49\n", - "ChildBirth [at 1969-09-19T12:00.000z] : Parent:48, Parent:49, Child:95\n", - "BecomeElder [at 1969-01-26T00:00.000z] : Character:93\n", - "Retire [at 1969-02-19T12:00.000z] : Retiree:93\n", - "Death [at 1969-08-26T00:00.000z] : Character:4\n", - "StartDating [at 1970-00-19T12:00.000z] : PersonA:4, PersonB:3\n", - "GotMarried [at 1970-00-19T12:00.000z] : PersonA:4, PersonB:3\n", - "Death [at 1970-01-19T00:00.000z] : Character:59\n", - "GotPregnant [at 1970-05-19T12:00.000z] : PersonA:53, PersonB:25\n", - "ChildBirth [at 1971-02-19T12:00.000z] : Parent:53, Parent:25, Child:96\n", - "Death [at 1971-01-19T00:00.000z] : Character:57\n", - "Death [at 1971-01-19T00:00.000z] : Character:58\n", - "GotPregnant [at 1971-01-19T12:00.000z] : PersonA:10, PersonB:9\n", - "ChildBirth [at 1971-10-19T12:00.000z] : Parent:10, Parent:9, Child:97\n", - "MoveIntoTown [at 1971-01-26T12:00.000z] : resident:98, resident:99\n", - "BecomeAdult [at 1971-01-26T12:00.000z] : Character:98\n", - "BecomeTeen [at 1971-01-26T12:00.000z] : Character:99\n", - "BecameBusinessOwner [at 1971-01-26T12:00.000z] : Business:2, Owner:98\n", - "BecomeYoungAdult [at 1971-01-27T00:00.000z] : Character:99\n", - "BecameBusinessOwner [at 1971-01-27T00:00.000z] : Business:47, Owner:99\n", - "BecomeElder [at 1971-08-26T12:00.000z] : Character:6\n", - "Death [at 1971-09-12T00:00.000z] : Character:10\n", - "MoveIntoTown [at 1971-09-12T12:00.000z] : resident:100, resident:101, resident:102\n", - "BecomeAdult [at 1971-09-12T12:00.000z] : Character:100\n", - "BecomeTeen [at 1971-09-12T12:00.000z] : Character:101\n", - "BecomeTeen [at 1971-09-12T12:00.000z] : Character:102\n", - "BecameBusinessOwner [at 1971-09-12T12:00.000z] : Business:52, Owner:100\n", - "BecomeYoungAdult [at 1971-09-13T00:00.000z] : Character:101\n", - "BecomeYoungAdult [at 1971-09-13T00:00.000z] : Character:102\n", - "BecameBusinessOwner [at 1971-09-13T00:00.000z] : Business:31, Owner:101\n", - "HiredAtBusiness [at 1971-09-13T00:00.000z] : Business:32, Employee:102\n", - "BecomeAdult [at 1971-09-13T12:00.000z] : Character:101\n", - "StartDating [at 1971-10-19T12:00.000z] : PersonA:9, PersonB:10\n", - "Death [at 1971-10-26T00:00.000z] : Character:26\n", - "MoveIntoTown [at 1971-10-26T12:00.000z] : resident:103, resident:104, resident:105, resident:106\n", - "BecomeAdult [at 1971-10-26T12:00.000z] : Character:104\n", - "BecomeTeen [at 1971-10-26T12:00.000z] : Character:105\n", - "BecomeTeen [at 1971-10-26T12:00.000z] : Character:106\n", - "HiredAtBusiness [at 1971-10-26T12:00.000z] : Business:32, Employee:104\n", - "HiredAtBusiness [at 1971-10-26T12:00.000z] : Business:2, Employee:103\n", - "BecomeYoungAdult [at 1971-10-27T00:00.000z] : Character:105\n", - "BecomeYoungAdult [at 1971-10-27T00:00.000z] : Character:106\n", - "HiredAtBusiness [at 1971-10-27T00:00.000z] : Business:35, Employee:105\n", - "HiredAtBusiness [at 1971-10-27T00:00.000z] : Business:37, Employee:106\n", - "BecomeAdult [at 1971-10-27T12:00.000z] : Character:106\n", - "Retire [at 1972-00-19T12:00.000z] : Retiree:6\n", - "StartDating [at 1972-02-19T12:00.000z] : PersonA:26, PersonB:54\n", - "GotMarried [at 1972-04-19T12:00.000z] : PersonA:9, PersonB:10\n", - "GotMarried [at 1972-04-19T12:00.000z] : PersonA:54, PersonB:26\n", - "BecomeAdult [at 1974-01-26T12:00.000z] : Character:91\n", - "GotPregnant [at 1974-07-19T12:00.000z] : PersonA:15, PersonB:14\n", - "ChildBirth [at 1975-04-19T12:00.000z] : Parent:15, Parent:14, Child:107\n", - "GotPregnant [at 1974-09-19T12:00.000z] : PersonA:4, PersonB:3\n", - "ChildBirth [at 1975-06-19T12:00.000z] : Parent:4, Parent:3, Child:108\n", - "Death [at 1975-08-26T00:00.000z] : Character:3\n", - "Death [at 1975-08-26T00:00.000z] : Character:5\n", - "StartDating [at 1975-09-19T12:00.000z] : PersonA:3, PersonB:4\n", - "GotMarried [at 1976-02-19T12:00.000z] : PersonA:3, PersonB:4\n", - "BecomeAdult [at 1977-01-26T12:00.000z] : Character:99\n", - "BecomeElder [at 1977-10-26T00:00.000z] : Character:106\n", - "Death [at 1978-01-05T00:00.000z] : Character:49\n", - "BecomeElder [at 1978-01-26T00:00.000z] : Character:92\n", - "GotPregnant [at 1978-08-19T12:00.000z] : PersonA:25, PersonB:53\n", - "ChildBirth [at 1979-05-19T12:00.000z] : Parent:25, Parent:53, Child:109\n", - "Retire [at 1978-10-19T12:00.000z] : Retiree:92\n", - "BecomeAdult [at 1978-10-26T12:00.000z] : Character:103\n", - "StartDating [at 1978-11-19T12:00.000z] : PersonA:49, PersonB:48\n", - "DatingBreakUp [at 1978-11-19T12:00.000z] : PersonA:48, PersonB:49\n", - "Retire [at 1979-07-19T12:00.000z] : Retiree:106\n" - ] - } - ], - "source": [ - "sim.world.get_resource(LifeEventLog).subscribe(lambda e: print(f\"{str(e)}\"))\n", - "sim.establish_setting()" - ] - }, - { - "cell_type": "code", - "execution_count": 153, - "metadata": { - "scrolled": true - }, - "outputs": [ - { - "data": { - "text/html": [ - "\n", - " \n", - " " - ], - "text/plain": [ - "" - ] - }, - "execution_count": 153, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "from neighborly.core.relationship import RelationshipGraph, RelationshipTag\n", - "import matplotlib.pyplot as plt\n", - "from pyvis.network import Network\n", - "from neighborly.core.character import GameCharacter\n", - "from neighborly.core.residence import Resident\n", - "import random\n", - "\n", - "\n", - "rel_graph = sim.world.get_resource(RelationshipGraph)\n", - "\n", - "\n", - "# G = nx.MultiGraph()\n", - "G = Network(width=800, height=800, notebook=True, bgcolor='#ffffff', directed=True)\n", - "\n", - "# Randomly select a character present in the town\n", - "start_id = random.choice(sim.world.get_components(GameCharacter, Resident))[0]\n", - "# start_id = 67\n", - "\n", - "# Starting from that character id, perform a breadth first search\n", - "# up to a given depth\n", - "depth_limit = 1\n", - "\n", - "# Queue of nodes to visit\n", - "queue = [(0, start_id)]\n", - " \n", - "# Nodes that have been visited during this search\n", - "visited_nodes = set()\n", - "\n", - "while queue:\n", - " depth, character_id = queue.pop(0)\n", - " character = sim.world.get_gameobject(character_id).get_component(GameCharacter)\n", - " \n", - " \n", - " # Insert a node for this character\n", - " visited_nodes.add(character_id)\n", - " G.add_node(\n", - " character_id, \n", - " label=str(character.name),\n", - " color='blue',\n", - " size=10\n", - " )\n", - " \n", - " if depth >= depth_limit:\n", - " continue\n", - " \n", - " # Insert edges for each relationship this character has\n", - " for relationship in rel_graph.get_relationships(character_id):\n", - " other_character = sim.world.get_gameobject(relationship.target)\n", - " if other_character.has_component(Resident):\n", - " G.add_node(\n", - " relationship.target, \n", - " label=str(other_character.get_component(GameCharacter).name),\n", - " color='blue',\n", - " size=10\n", - " )\n", - "\n", - " G.add_edge(\n", - " relationship.owner, \n", - " relationship.target, \n", - " color='green', \n", - " label=str(int(relationship.friendship)),\n", - "\n", - " )\n", - " \n", - " G.add_edge(\n", - " relationship.owner, \n", - " relationship.target, \n", - " color='red',\n", - " label=str(int(relationship.romance)),\n", - " )\n", - "\n", - " if relationship.target not in visited_nodes:\n", - " queue.append((depth + 1, relationship.target))\n", - " \n", - "\n", - "# for gid, (character, _) in sim.world.get_components(GameCharacter, Resident):\n", - " \n", - "# if gid not in created_nodes:\n", - "# created_nodes.add(gid)\n", - "# G.add_node(\n", - "# gid, \n", - "# label=character.name.firstname,\n", - "# color='red',\n", - "# size=35\n", - "# )\n", - "# for rel in rel_graph.get_relationships(gid):\n", - "# if sim.world.get_gameobject(rel.target).has_component(Resident) and rel.friendship >= 30:\n", - "# # if rel.has_tags(RelationshipTag.Parent):\n", - "# G.add_edge(\n", - "# rel.owner, \n", - "# rel.target, \n", - "# color='black', \n", - "# weight=abs(rel.friendship // 15)\n", - " \n", - "# )\n", - " \n", - "# if rel.target not in created_nodes:\n", - "# created_nodes.add(rel.target)\n", - "# G.add_node(\n", - "# rel.target, \n", - "# label=(\n", - "# sim.world.get_gameobject(rel.target)\n", - "# .get_component(GameCharacter)\n", - "# .name\n", - "# .firstname\n", - "# ),\n", - "# color='red',\n", - "# size=35\n", - "# )\n", - "\n", - " \n", - " \n", - "\n", - "# g = Network(width=800, height=800, notebook=True, bgcolor='#ffffff')\n", - "# g.from_nx(G)\n", - "G.force_atlas_2based()\n", - "G.show(\"sample.html\")\n", - "\n", - "# nx.shell_layout(G)\n", - "# ax = plt.subplot()\n", - "# nx.draw(G)\n", - "\n", - "# widths = nx.get_edge_attributes(G, 'weight')\n", - "# nodelist = G.nodes()\n", - "\n", - "# plt.figure(figsize=(12,8))\n", - "\n", - "# pos = nx.shell_layout(G)\n", - "# nx.draw_networkx_nodes(G,pos,\n", - "# nodelist=nodelist,\n", - "# node_size=1500,\n", - "# node_color='black',\n", - "# alpha=0.7)\n", - "# nx.draw_networkx_edges(G,pos,\n", - "# edgelist = widths.keys(),\n", - "# width=list(widths.values()),\n", - "# edge_color='lightblue',\n", - "# alpha=0.6)\n", - "# nx.draw_networkx_labels(G, pos=pos,\n", - "# labels=dict(zip(nodelist,nodelist)),\n", - "# font_color='white')\n", - "# plt.box(False)\n", - "# plt.show()" - ] - } - ], - "metadata": { - "interpreter": { - "hash": "a1809e2d9770292bda7d555079a015bd98a16d4eced5698bdfadad6467bb37ec" - }, - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.9.12" - } - }, - "nbformat": 4, - "nbformat_minor": 4 -} diff --git a/samples/pattern_queries.py b/samples/pattern_queries.py new file mode 100644 index 0000000..268d64e --- /dev/null +++ b/samples/pattern_queries.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +from typing import List, Optional, Set, Tuple, Type + +from neighborly import SimulationBuilder +from neighborly.builtin.helpers import IInheritable, inheritable +from neighborly.core.archetypes import BaseCharacterArchetype +from neighborly.core.character import GameCharacter +from neighborly.core.ecs import Component, GameObject, World +from neighborly.core.engine import NeighborlyEngine +from neighborly.core.query import EcsFindClause, Query, ne_, where, where_not +from neighborly.core.relationship import Relationships +from neighborly.plugins.defaults import DefaultNameDataPlugin + + +def friendship_gt(threshold: float) -> EcsFindClause: + """Returns a list of all the GameObjects with the given component""" + + def precondition(world: World): + results: List[Tuple[int, ...]] = [] + for gid, relationships in world.get_component(Relationships): + for r in relationships.get_all(): + if r.friendship > threshold: + results.append((gid, r.target)) + return results + + return precondition + + +def friendship_lt(threshold: float) -> EcsFindClause: + """Returns a list of all the GameObjects with the given component""" + + def precondition(world: World): + results: List[Tuple[int, ...]] = [] + for gid, relationships in world.get_component(Relationships): + for r in relationships.get_all(): + if r.friendship < threshold: + results.append((gid, r.target)) + return results + + return precondition + + +def has_component(component_type: Type[Component]) -> EcsFindClause: + """Returns a list of all the GameObjects with the given component""" + + def precondition(world: World): + return list( + map(lambda result: (result[0],), world.get_component(component_type)) + ) + + return precondition + + +def has_fur_color(fur_color: str) -> EcsFindClause: + """Returns a list of all the GameObjects with the given fur color""" + + def precondition(world: World): + return list( + map( + lambda result: (result[0],), + filter( + lambda result: fur_color in result[1].values, + world.get_component(FurColor), + ), + ) + ) + + return precondition + + +@inheritable(always_inherited=True) +class FurColor(Component, IInheritable): + + __slots__ = "values" + + def __init__(self, colors: List[str]) -> None: + super().__init__() + self.values: Set[str] = set(colors) + + def pprint(self) -> None: + print(f"{self.__class__.__name__}:\n" f"\tcolors: {self.values}") + + @classmethod + def from_parents(cls, *components: FurColor) -> FurColor: + all_colors = set() + for parent in components: + for color in parent.values: + all_colors.add(color) + + return FurColor(list(all_colors)) + + +class FuzzCharacterArchetype(BaseCharacterArchetype): + def create(self, world: World, **kwargs) -> GameObject: + gameobject = super().create(world, **kwargs) + + fur_color = world.get_resource(NeighborlyEngine).rng.choice( + ["Red", "Green", "Blue", "Yellow", "Orange", "White", "Black", "Purple"] + ) + + gameobject.add_component(FurColor([fur_color])) + gameobject.add_component(Relationships()) + + if world.get_resource(NeighborlyEngine).rng.random() < 0.3: + gameobject.add_component(HasHorns()) + + return gameobject + + +@inheritable(inheritance_chance=(0.5, 0.7)) +class HasHorns(Component, IInheritable): + @classmethod + def from_parents( + cls, parent_a: Optional[Component], parent_b: Optional[Component] + ) -> Component: + return HasHorns() + + +def bind_roles(world, query: Query) -> Optional[Tuple[GameObject, ...]]: + """Searches the ECS for a game object that meets the given conditions""" + + result_set = query.execute(world) + + if len(result_set): + return tuple( + map( + lambda gid: world.get_gameobject(gid), + world.get_resource(NeighborlyEngine).rng.choice(list(result_set)), + ) + ) + + return None + + +def main(): + sim = SimulationBuilder(seed=1010).add_plugin(DefaultNameDataPlugin()).build() + + for _ in range(30): + FuzzCharacterArchetype().create(sim.world) + + query = Query( + find=("X", "Y"), + clauses=[ + where(has_component(GameCharacter), "X"), + where(has_component(HasHorns), "X"), + where(has_component(GameCharacter), "Y"), + where_not(has_component(HasHorns), "Y"), + ne_(("X", "Y")), + ], + ) + + result = bind_roles(sim.world, query) + + if result: + for r in result: + r.pprint() + else: + print(None) + + +if __name__ == "__main__": + main() diff --git a/samples/pygame/pygame_sample.py b/samples/pygame/pygame_sample.py deleted file mode 100644 index fb14a2d..0000000 --- a/samples/pygame/pygame_sample.py +++ /dev/null @@ -1,759 +0,0 @@ -import random -from abc import ABC, abstractmethod -from collections import defaultdict -from dataclasses import dataclass -from enum import Enum -from typing import ( - Callable, - DefaultDict, - Dict, - Iterable, - List, - Sequence, - Tuple, - Union, - cast, -) - -import pygame -import pygame_gui - -from neighborly.core.character import GameCharacter -from neighborly.core.ecs import GameObject -from neighborly.core.position import Position2D -from neighborly.core.town import LandGrid -from neighborly.plugins.default_plugin import DefaultPlugin -from neighborly.plugins.talktown import TalkOfTheTownPlugin -from neighborly.simulation import Simulation, SimulationBuilder - -# COMMON COLORS -SKY_BLUE = (153, 218, 232) -COLOR_WHITE = (255, 255, 255) -COLOR_BLACK = (0, 0, 0) -COLOR_RED = (255, 0, 0) -COLOR_GREEN = (99, 133, 108) -COLOR_BLUE = (0, 0, 255) -CHARACTER_COLOR = (66, 242, 245) -BUILDING_COLOR = (194, 194, 151) -BACKGROUND_COLOR = (232, 232, 213) -LOT_COLOR = (79, 107, 87) -GROUND_COLOR = (127, 130, 128) - -# Number of pixels between lots -LOT_SIZE = 64 -LOT_PADDING = 8 - - -class CharacterInfoWindow(pygame_gui.elements.UIWindow): - """ - Wraps a pygame_ui panel to display information - about a given character - """ - - def __init__( - self, character: GameObject, sim: Simulation, ui_manager: pygame_gui.UIManager - ) -> None: - super().__init__( - pygame.Rect((10, 10), (320, 240)), - ui_manager, - window_display_title=str(character.get_component(GameCharacter).name), - object_id=f"{character.id}", - ) - self.ui_manager = ui_manager - self.text = pygame_gui.elements.UITextBox( - f"{character.get_component(GameCharacter)}", - pygame.Rect(0, 0, 320, 240), - manager=ui_manager, - container=self, - parent_element=self, - ) - - def process_event(self, event: pygame.event.Event) -> bool: - handled = super().process_event(event) - if ( - event.type == pygame.USEREVENT - and event.type == pygame_gui.UI_BUTTON_PRESSED - and event.ui_object_id == "#character_window.#title_bar" - and event.ui_element == self.title_bar - ): - handled = True - - event_data = { - "type": "character_window_selected", - "ui_element": self, - "ui_object_id": self.most_specific_combined_id, - } - - window_selected_event = pygame.event.Event(pygame.USEREVENT, event_data) - - pygame.event.post(window_selected_event) - - return handled - - -class PlaceInfoWindow(pygame_gui.elements.UIWindow): - """ - Wraps a pygame_ui panel to display information - about a given character - """ - - def __init__( - self, place: GameObject, sim: Simulation, ui_manager: pygame_gui.UIManager - ) -> None: - super().__init__( - pygame.Rect((10, 10), (320, 240)), - ui_manager, - window_display_title=str(place.name), - object_id=f"{place.id}", - ) - self.ui_manager = ui_manager - self.text = pygame_gui.elements.UITextBox( - f"{place}", - pygame.Rect(0, 0, 320, 240), - manager=ui_manager, - container=self, - parent_element=self, - ) - - def process_event(self, event: pygame.event.Event) -> bool: - handled = super().process_event(event) - if ( - event.type == pygame.USEREVENT - and event.type == pygame_gui.UI_BUTTON_PRESSED - and event.ui_object_id == "#character_window.#title_bar" - and event.ui_element == self.title_bar - ): - handled = True - - event_data = { - "type": "character_window_selected", - "ui_element": self, - "ui_object_id": self.most_specific_combined_id, - } - - window_selected_event = pygame.event.Event(pygame.USEREVENT, event_data) - - pygame.event.post(window_selected_event) - - return handled - - -@dataclass -class GameConfig: - width: int = 900 - height: int = 500 - fps: int = 60 - show_debug: bool = False - - -class BoxSprite(pygame.sprite.Sprite): - """Draws a colored box to the screen""" - - def __init__( - self, - color: Tuple[int, int, int], - width: int, - height: int, - x: int = 0, - y: int = 0, - ) -> None: - super().__init__() - self.image = pygame.Surface([width, height]) - self.image.fill(color) - self.rect = self.image.get_rect() - self.position = pygame.Vector2(x, y) - self.rect.topleft = (round(self.position.x), round(self.position.y)) - - -class PlaceSprite(pygame.sprite.Sprite): - """Sprite used to represent a place in the town""" - - def __init__( - self, - place: GameObject, - color: Tuple[int, int, int] = BUILDING_COLOR, - ) -> None: - super().__init__() - self.place = place - self.image = pygame.Surface([LOT_SIZE, LOT_SIZE]) - self.image.fill(color) - self.rect = self.image.get_rect() - pos = place.get_component(Position2D) - self.position = pygame.Vector2( - LOT_PADDING + pos.x * (LOT_SIZE + LOT_PADDING), - LOT_PADDING + pos.y * (LOT_SIZE + LOT_PADDING), - ) - - self.rect.topleft = (round(self.position.x), round(self.position.y)) - - -class YSortCameraGroup(pygame.sprite.Group): - def __init__( - self, - surface: pygame.Surface, - *sprites: Union[pygame.sprite.Sprite, Sequence[pygame.sprite.Sprite]], - ) -> None: - super().__init__(*sprites) - self.surface = surface - self.half_width = self.surface.get_width() // 2 - self.half_height = self.surface.get_height() // 2 - self.offset = pygame.math.Vector2() - - def custom_draw(self, camera_focus: pygame.math.Vector2) -> None: - self.offset.x = camera_focus.x - self.half_width - self.offset.y = camera_focus.y - self.half_height - - for sprite in self.sprites(): - if sprite.rect and sprite.image: - offset_pos = pygame.math.Vector2(sprite.rect.topleft) - self.offset - self.surface.blit(sprite.image, offset_pos) - - -class PlacesSpriteGroup(pygame.sprite.Group): - def __init__( - self, - surface: pygame.Surface, - *sprites: Union[PlaceSprite, Sequence[PlaceSprite]], - ) -> None: - super().__init__(*sprites) - self.surface = surface - self.half_width = self.surface.get_width() // 2 - self.half_height = self.surface.get_height() // 2 - self.offset = pygame.math.Vector2() - self.sprite_dict: Dict[int, PlaceSprite] = {} - - def get_places(self) -> List[PlaceSprite]: - return cast(List[PlaceSprite], self.sprites()) - - def add( - self, - *sprites: Union[ - PlaceSprite, - "PlacesSpriteGroup", - Iterable[Union[PlaceSprite, "PlacesSpriteGroup"]], - ], - ) -> None: - super(PlacesSpriteGroup, self).add(*sprites) - for sprite in sprites: - self.sprite_dict[sprite.place.id] = sprite - - def remove(self, *sprites: PlaceSprite) -> None: - super().remove(*sprites) - for sprite in sprites: - del self.sprite_dict[sprite.place.id] - - def custom_draw(self, camera_focus: pygame.math.Vector2) -> None: - self.offset.x = camera_focus.x - self.half_width - self.offset.y = camera_focus.y - self.half_height - - for sprite in self.sprites(): - if sprite.rect and sprite.image: - offset_pos = pygame.math.Vector2(sprite.rect.topleft) - self.offset - self.surface.blit(sprite.image, offset_pos) - - -class InputHandler: - """Manages handling input events for the game""" - - __slots__ = "callbacks", "key_callbacks" - - def __init__(self) -> None: - self.callbacks: DefaultDict[ - int, List[Callable[[pygame.event.Event], None]] - ] = defaultdict(list) - self.key_callbacks: Dict[ - int, DefaultDict[int, List[Callable[[pygame.event.Event], None]]] - ] = {pygame.KEYUP: defaultdict(list), pygame.KEYDOWN: defaultdict(list)} - - def emit(self, event: pygame.event.Event) -> None: - if event.type == pygame.KEYUP or event.type == pygame.KEYDOWN: - for cb in self.key_callbacks[event.type][event.key]: - cb(event) - return - for cb in self.callbacks[event.type]: - cb(event) - - def on_key_up( - self, key: int, callback: Callable[[pygame.event.Event], None] - ) -> None: - self.key_callbacks[pygame.KEYUP][key].append(callback) - - def on_key_down( - self, key: int, callback: Callable[[pygame.event.Event], None] - ) -> None: - self.key_callbacks[pygame.KEYDOWN][key].append(callback) - - def on(self, event: int, callback: Callable[[pygame.event.Event], None]) -> None: - self.callbacks[event].append(callback) - - -class Scene(ABC): - """Scenes manage the visuals and controls for the current state of the game""" - - @abstractmethod - def update(self, delta_time: float) -> None: - """ - Update the scene - - Parameters - ---------- - delta_time: float - The number of seconds that have elapsed since the last update - """ - raise NotImplementedError - - @abstractmethod - def draw(self) -> None: - """Draw the scene to the display""" - raise NotImplementedError - - @abstractmethod - def handle_event(self, event: pygame.event.Event) -> None: - """Handle PyGame event""" - raise NotImplementedError - - -class Camera: - """ - Manages the offset for drawing the game world to the window. - """ - - __slots__ = "position", "speed" - - def __init__(self, position: pygame.math.Vector2 = None) -> None: - self.position: pygame.math.Vector2 = ( - position if position else pygame.math.Vector2() - ) - - -class CameraController: - """Manages the state of buttons pressed""" - - __slots__ = ( - "up", - "right", - "down", - "left", - "camera", - "speed", - ) - - def __init__(self, camera: Camera, speed: int = 300) -> None: - self.up: bool = False - self.right: bool = False - self.down: bool = False - self.left: bool = False - self.camera: Camera = camera - self.speed: int = speed - - def move_up(self, value: bool) -> None: - self.up = value - - def move_right(self, value: bool) -> None: - self.right = value - - def move_down(self, value: bool) -> None: - self.down = value - - def move_left(self, value: bool) -> None: - self.left = value - - def update(self, delta_time: float) -> None: - if self.up: - self.camera.position += pygame.Vector2(0, -1) * delta_time * self.speed - if self.left: - self.camera.position += pygame.Vector2(-1, 0) * delta_time * self.speed - if self.down: - self.camera.position += pygame.Vector2(0, 1) * delta_time * self.speed - if self.right: - self.camera.position += pygame.Vector2(1, 0) * delta_time * self.speed - - def __str__(self) -> str: - return self.__repr__() - - def __repr__(self) -> str: - return "{}(up={}, right={}, down={}, left={})".format( - self.__class__.__name__, self.up, self.right, self.down, self.left - ) - - -class SimulationStatus(Enum): - STOPPED = 0 - STEPPING = 1 - RUNNING = 2 - - -class GameScene(Scene): - """ - Main Scene for the game that shows the town changing over time. - The player can pause, play, and step through time. In this mode - players can inspect the state of the town, its buildings, and - townspeople. - - Attributes - ---------- - display: pygame.Surface - """ - - __slots__ = ( - "display", - "background", - "ui_manager", - "camera", - "sim", - "sim_status", - "input_handler", - ) - - def __init__( - self, - display: pygame.Surface, - ui_manager: pygame_gui.UIManager, - config: GameConfig, - ) -> None: - self.display: pygame.Surface = display - self.background: pygame.Surface = pygame.Surface((config.width, config.height)) - self.background.fill(BACKGROUND_COLOR) - self.ui_manager: pygame_gui.UIManager = ui_manager - self.places_group: PlacesSpriteGroup = PlacesSpriteGroup(display) - self.background_group: YSortCameraGroup = YSortCameraGroup(display) - self.camera: Camera = Camera() - self.camera_controller: CameraController = CameraController(self.camera) - self.input_handler: InputHandler = InputHandler() - - self.sim: Simulation = self._init_sim() - self.sim_status: SimulationStatus = SimulationStatus.STOPPED - - self._create_background( - self.background_group, self.sim.world.get_resource(LandGrid).grid.shape - ) - - self.ui_elements = { - "step-btn": pygame_gui.elements.UIButton( - relative_rect=pygame.Rect((0, 0), (100, 50)), - text="Step", - manager=self.ui_manager, - ), - "play-btn": pygame_gui.elements.UIButton( - relative_rect=pygame.Rect((100, 0), (100, 50)), - text="Play", - manager=self.ui_manager, - ), - "pause-btn": pygame_gui.elements.UIButton( - relative_rect=pygame.Rect((200, 0), (100, 50)), - text="Pause", - manager=self.ui_manager, - ), - } - - info_panel_rect = pygame.Rect((0, 0), (self.display.get_width(), 50)) - info_panel_rect.bottomleft = (-self.display.get_width(), 0) - self.info_panel = pygame_gui.elements.UIPanel( - relative_rect=info_panel_rect, - manager=self.ui_manager, - starting_layer_height=1, - anchors={ - "left": "right", - "right": "right", - "top": "bottom", - "bottom": "bottom", - }, - ) - self.info_panel_text = pygame_gui.elements.UILabel( - relative_rect=pygame.Rect((0, 0), (self.display.get_width(), 50)), - text=f"Town: {self.sim.town.name}, Date: {self.sim.time.to_date_str()}", - manager=self.ui_manager, - container=self.info_panel, - parent_element=self.info_panel, - ) - - self._setup_input_handlers() - - @staticmethod - def _init_sim() -> Simulation: - - sim = ( - SimulationBuilder(seed=random.randint(0, 999999)) - .add_plugin(DefaultPlugin()) - .add_plugin(TalkOfTheTownPlugin()) - .build() - ) - - return sim - - @staticmethod - def _create_background( - sprite_group: pygame.sprite.Group, town_size: Tuple[int, int] - ) -> None: - ground = BoxSprite( - GROUND_COLOR, - town_size[0] * LOT_SIZE + LOT_PADDING * (town_size[0] + 1), - town_size[1] * LOT_SIZE + LOT_PADDING * (town_size[1] + 1), - ) - sprite_group.add(ground) - - for row in range(town_size[1]): - y_offset = LOT_PADDING + row * (LOT_SIZE + LOT_PADDING) - for col in range(town_size[0]): - x_offset = LOT_PADDING + col * (LOT_SIZE + LOT_PADDING) - lot_sprite = BoxSprite( - LOT_COLOR, - LOT_SIZE, - LOT_SIZE, - x_offset, - y_offset, - ) - sprite_group.add(lot_sprite) - - def draw(self) -> None: - """Draw to the screen while active""" - self.display.blit(self.background, (0, 0)) - self.background_group.custom_draw(self.camera.position) - self.places_group.custom_draw(self.camera.position) - - def update(self, delta_time: float) -> None: - """Update the state of the scene""" - - # Update the camera position - self.camera_controller.update(delta_time) - - # Update the sprites (also remove sprites for deleted entities) - self.places_group.update(delta_time=delta_time) - - # Update the UI - self.info_panel_text.set_text( - f"Town: {self.sim.town.name}, Date: {self.sim.time.to_date_str()}" - ) - - # Only update the simulation when the simulation is running - # and the characters are no longer moving - if self.sim_status == SimulationStatus.RUNNING: - self._step_simulation() - # - # # TODO: Add a procedure for adding place and character sprites. - # # For now, just take the set difference between the groups - # # and the state of the ECS. Then create new sprites with those. - # - # existing_characters: Set[int] = set( - # map(lambda res: res[0], self.sim.world.get_component(GameCharacter)) - # ) - # - # entities_with_sprites: Set[int] = set( - # map( - # lambda sprite: sprite.character.id, - # self.character_group.get_characters(), - # ) - # ) - # - # new_character_entities = existing_characters - entities_with_sprites - # - # for entity in new_character_entities: - # self.character_group.add( - # CharacterSprite(self.sim.world.get_gameobject(entity)) - # ) - # - # existing_places: Set[int] = set( - # map(lambda res: res[0], self.sim.world.get_component(Location)) - # ) - # - # places_with_sprites: Set[int] = set( - # map(lambda sprite: sprite.place.id, self.places_group.get_places()) - # ) - # - # new_place_entities = existing_places - places_with_sprites - # - # for entity in new_place_entities: - # self.places_group.add( - # PlaceSprite(self.sim.world.get_gameobject(entity)) - # ) - # - # for character_sprite in self.character_group.get_characters(): - # loc_id = character_sprite.character.get_component( - # GameCharacter - # ).location - # if loc_id in self.places_group.sprite_dict: - # loc = self.places_group.sprite_dict[loc_id] - # - # if ( - # character_sprite.previous_destination - # and character_sprite.previous_destination[0] - # in self.places_group.sprite_dict - # ): - # previous_loc = self.places_group.sprite_dict[ - # character_sprite.previous_destination[0] - # ] - # previous_loc.free_space( - # character_sprite.previous_destination[1] - # ) - # - # destination_space, destination_pos = loc.get_available_space() - # character_sprite.destination = ( - # loc_id, - # destination_space, - # destination_pos, - # ) - - def _setup_input_handlers(self) -> None: - self.input_handler.on(pygame_gui.UI_BUTTON_PRESSED, self._handle_button_click) - self.input_handler.on(pygame.MOUSEBUTTONUP, self._handle_mouse_click) - self.input_handler.on_key_up( - pygame.K_UP, lambda e: self.camera_controller.move_up(False) - ) - self.input_handler.on_key_up( - pygame.K_RIGHT, lambda e: self.camera_controller.move_right(False) - ) - self.input_handler.on_key_up( - pygame.K_DOWN, lambda e: self.camera_controller.move_down(False) - ) - self.input_handler.on_key_up( - pygame.K_LEFT, lambda e: self.camera_controller.move_left(False) - ) - self.input_handler.on_key_down( - pygame.K_UP, lambda e: self.camera_controller.move_up(True) - ) - self.input_handler.on_key_down( - pygame.K_RIGHT, lambda e: self.camera_controller.move_right(True) - ) - self.input_handler.on_key_down( - pygame.K_DOWN, lambda e: self.camera_controller.move_down(True) - ) - self.input_handler.on_key_down( - pygame.K_LEFT, lambda e: self.camera_controller.move_left(True) - ) - self.input_handler.on_key_up( - pygame.K_SPACE, lambda e: self._toggle_simulation_running() - ) - - def _step_simulation(self) -> None: - self.sim.step() - - def _handle_button_click(self, event: pygame.event.Event) -> None: - if event.type == pygame_gui.UI_BUTTON_PRESSED: - if event.ui_element == self.ui_elements["step-btn"]: - self._step_simulation() - if event.ui_element == self.ui_elements["play-btn"]: - self.sim_running = True - if event.ui_element == self.ui_elements["pause-btn"]: - self.sim_running = False - - def _handle_mouse_click(self, event: pygame.event.Event) -> None: - mouse_screen_pos = pygame.math.Vector2(pygame.mouse.get_pos()) - mouse_camera_offset = ( - pygame.math.Vector2( - self.display.get_width() // 2, self.display.get_height() // 2 - ) - - self.camera.position - ) - mouse_pos = mouse_screen_pos - mouse_camera_offset - - # Check if the user clicked a building - for place_sprite in self.places_group.get_places(): - if place_sprite.rect and place_sprite.rect.collidepoint( - mouse_pos.x, mouse_pos.y - ): - PlaceInfoWindow(place_sprite.place, self.sim, self.ui_manager) - return - - def _toggle_simulation_running(self) -> None: - self.sim_running = not self.sim_running - - def handle_event(self, event: pygame.event.Event) -> bool: - """Handle PyGame events while active""" - self.input_handler.emit(event) - return True - - -class Game: - """ - Simple class that runs the game - - Attributes - ---------- - config: GameConfig - Configuration settings for the game - display: pygame.Surface - The surface that is drawn on by the scene - window: pygame.Surface - The surface representing the PyGame window - clock: pygame.time.Clock - Manages the time between frames - running: boolean - Remains True while the game is running - ui_manager: pygame_gui.UIManager - Manages all the UI windows and elements - scene: GameScene - The current scene being updated and drawn to the display - """ - - __slots__ = "config", "display", "window", "clock", "running", "ui_manager", "scene" - - def __init__(self, config: GameConfig) -> None: - pygame.init() - self.config: GameConfig = config - self.display: pygame.Surface = pygame.Surface((config.width, config.height)) - self.window: pygame.Surface = pygame.display.set_mode( - (config.width, config.height) - ) - pygame.display.set_caption("Neighborly PyGame Sample") - self.clock: pygame.time.Clock = pygame.time.Clock() - self.running: bool = False - self.ui_manager: pygame_gui.UIManager = pygame_gui.UIManager( - (config.width, config.height) - ) - self.scene: Scene = GameScene(self.display, self.ui_manager, config) - - def update(self, delta_time: float) -> None: - """Update the active scene""" - self.ui_manager.update(delta_time) - self.scene.update(delta_time) - - def draw(self) -> None: - """Draw the current scene""" - self.scene.draw() - self.ui_manager.draw_ui(self.display) - self.window.blit(self.display, (0, 0)) - pygame.display.update() - - def handle_events(self): - """Active mode handles PyGame events""" - for event in pygame.event.get(): - - if event.type == pygame.QUIT: - self.quit() - continue - - if event.type == pygame.KEYDOWN: - if event.key == pygame.K_ESCAPE: - self.quit() - continue - - if self.ui_manager.process_events(event): - continue - - self.scene.handle_event(event) - - def run(self) -> None: - """Main game loop""" - self.running = True - while self.running: - time_delta = self.clock.tick(self.config.fps) / 1000.0 - self.handle_events() - self.update(time_delta) - self.draw() - pygame.quit() - - def quit(self) -> None: - """Stop the game""" - self.running = False - - -def main(): - config = GameConfig(width=1024, height=768, fps=60, show_debug=True) - - game = Game(config) - - game.run() - - -if __name__ == "__main__": - main() diff --git a/samples/revised_relationship_model.py b/samples/revised_relationship_model.py new file mode 100644 index 0000000..295d744 --- /dev/null +++ b/samples/revised_relationship_model.py @@ -0,0 +1,85 @@ +""" +This is another attempt at improving the entity generation process. As I have gained +a better understanding of how I should model characters, it has helped realize the +problems with previous interfaces. For this iteration, we are breaking apart the pieces +of characters into more individual components and placing probabilities on those +components being present at spawn-time. +""" +from __future__ import annotations + +import random +from typing import List, Optional, Set + +from neighborly.builtin.helpers import IInheritable, inheritable +from neighborly.core.archetypes import BaseCharacterArchetype +from neighborly.core.ecs import Component, GameObject, World +from neighborly.core.engine import NeighborlyEngine +from neighborly.core.relationship import Relationships +from neighborly.plugins.defaults import DefaultNameDataPlugin +from neighborly.simulation import SimulationBuilder + + +@inheritable(always_inherited=True) +class FurColor(Component, IInheritable): + + __slots__ = "values" + + def __init__(self, colors: List[str]) -> None: + super().__init__() + self.values: Set[str] = set(colors) + + def pprint(self) -> None: + print(f"{self.__class__.__name__}:\n" f"\tcolors: {self.values}") + + @classmethod + def from_parents(cls, *components: FurColor) -> FurColor: + all_colors = set() + for parent in components: + for color in parent.values: + all_colors.add(color) + + return FurColor(list(all_colors)) + + +class FuzzCharacterArchetype(BaseCharacterArchetype): + def create(self, world: World, **kwargs) -> GameObject: + gameobject = super().create(world, **kwargs) + + fur_color = random.choice( + ["Red", "Green", "Blue", "Yellow", "Orange", "White", "Black", "Purple"] + ) + + gameobject.add_component(FurColor([fur_color])) + + if world.get_resource(NeighborlyEngine).rng.random() < 0.3: + gameobject.add_component(HasHorns()) + + gameobject.add_component(Relationships()) + + return gameobject + + +@inheritable(inheritance_chance=(0.5, 0.7)) +class HasHorns(Component, IInheritable): + @classmethod + def from_parents( + cls, parent_a: Optional[Component], parent_b: Optional[Component] + ) -> Component: + return HasHorns() + + +def main(): + sim = SimulationBuilder().add_plugin(DefaultNameDataPlugin()).build() + + c1 = FuzzCharacterArchetype().create(sim.world) + c2 = FuzzCharacterArchetype().create(sim.world) + + print(c1.get_component(Relationships).get(c2.id)) + c1.get_component(Relationships).get(c2.id).friendship.increase(3) + print(c1.get_component(Relationships).get(c2.id)) + c1.get_component(Relationships).get(c2.id).friendship.decrease(1) + print(c1.get_component(Relationships).get(c2.id)) + + +if __name__ == "__main__": + main() diff --git a/samples/talktown.py b/samples/talktown.py index 455fdac..85f9f1d 100644 --- a/samples/talktown.py +++ b/samples/talktown.py @@ -10,38 +10,35 @@ import random import time -from neighborly.core.life_event import LifeEventLog -from neighborly.core.time import SimDateTime +from neighborly import SimDateTime, SimulationBuilder from neighborly.exporter import NeighborlyJsonExporter -from neighborly.plugins.default_plugin import DefaultPlugin -from neighborly.plugins.talktown import TalkOfTheTownPlugin -from neighborly.plugins.weather_plugin import WeatherPlugin -from neighborly.simulation import SimulationBuilder +from neighborly.plugins import defaults, talktown, weather EXPORT_WORLD = False +DEBUG_LOGGING = False if __name__ == "__main__": - logging.basicConfig(level=logging.DEBUG) + + if DEBUG_LOGGING: + logging.basicConfig(level=logging.DEBUG) sim = ( SimulationBuilder( seed=random.randint(0, 999999), - world_gen_start=SimDateTime(year=1839, month=8, day=19), - world_gen_end=SimDateTime(year=1979, month=8, day=19), + starting_date=SimDateTime(year=1839, month=8, day=19), + print_events=True, ) - .add_plugin(DefaultPlugin()) - .add_plugin(WeatherPlugin()) - .add_plugin(TalkOfTheTownPlugin()) + .add_plugin(defaults.get_plugin()) + .add_plugin(weather.get_plugin()) + .add_plugin(talktown.get_plugin()) .build() ) - sim.world.get_resource(LifeEventLog).subscribe(lambda e: print(str(e))) - st = time.time() - sim.establish_setting() + sim.run_for(140) elapsed_time = time.time() - st - print(f"World Date: {sim.time.to_iso_str()}") + print(f"World Date: {sim.date.to_iso_str()}") print("Execution time: ", elapsed_time, "seconds") if EXPORT_WORLD: diff --git a/setup.cfg b/setup.cfg index 1a69e86..3e1aa32 100644 --- a/setup.cfg +++ b/setup.cfg @@ -3,62 +3,53 @@ name = neighborly version = attr: neighborly.__version__ author = Shi Johnson-Bey author_email = shijbey@ucsc.edu -description = An extensible social simulation engine for generating towns of characters +description = An extensible social simulation framework for generating towns of characters long_description = file: README.md long_description_content_type = text/markdown url = https://github.com/ShiJbey/neighborly project_urls = Bug Tracker = https://github.com/ShiJbey/neighborly/issues + Documentation = https://github.com/ShiJbey/neighborly/wiki + Changelog = https://github.com/ShiJbey/neighborly/blob/main/CHANGELOG.md license = MIT -keywords = social simulation, emergent narrative, life simulator framework, multi-agent, +keywords = social simulation, emergent narrative, life simulator, multi-agent framework classifiers = - Development Status :: 4 - Beta - Programming Language :: Python :: 3.9 - Programming Language :: Python :: 3.8 + Intended Audience :: Developers License :: OSI Approved :: MIT License Operating System :: OS Independent + Programming Language :: Python :: 3 + Programming Language :: Python :: 3 :: Only Topic :: Games/Entertainment :: Simulation Topic :: Scientific/Engineering :: Artificial Intelligence Topic :: Scientific/Engineering :: Artificial Life Topic :: Sociology + Topic :: Software Development :: Libraries Typing :: Typed + [options] package_dir = = src packages = find: python_requires = >=3.8 +include_package_data = True install_requires = ordered-set - tqdm pydantic ==1.8.2 numpy PyYAML esper + pandas + tracery [options.packages.find] where = src +[options.entry_points] +console_scripts = + neighborly = neighborly.__main__:main + [options.extras_require] tests = pytest pytest-cov -samples = - pygame - pygame_gui==0.6 - notebook - jupyterlab - ipycanvas - ipywidgets - matplotlib - -[flake8] -exclude = build,.git,docs,tests,.vscode,.idea, -extend-ignore = E203 -max-line-length = 120 -max-complexity = 10 - -[isort] -profile = black -default_section = THIRDPARTY -known_first_party = neighborly diff --git a/src/neighborly/__init__.py b/src/neighborly/__init__.py index c598173..b5aa3cb 100644 --- a/src/neighborly/__init__.py +++ b/src/neighborly/__init__.py @@ -1 +1,8 @@ -__version__ = "0.9.3" +__version__ = "0.9.4" + +from neighborly.core.ecs import Component, GameObject, ISystem, World +from neighborly.core.engine import NeighborlyEngine +from neighborly.core.system import System +from neighborly.core.time import SimDateTime +from neighborly.core.town import Town +from neighborly.simulation import Plugin, Simulation, SimulationBuilder diff --git a/src/neighborly/__main__.py b/src/neighborly/__main__.py index 95da576..873744a 100644 --- a/src/neighborly/__main__.py +++ b/src/neighborly/__main__.py @@ -15,9 +15,8 @@ import neighborly import neighborly.core.utils.utilities as utilities -from neighborly.core.life_event import LifeEventLog +from neighborly import SimDateTime from neighborly.exporter import NeighborlyJsonExporter -from neighborly.server import NeighborlyServer from neighborly.simulation import Plugin, PluginSetupError, SimulationBuilder logger = logging.getLogger(__name__) @@ -27,10 +26,10 @@ class PluginConfig(BaseModel): """ Settings for loading and constructing a plugin - Attributes + Fields ---------- name: str - Name of the plugin to load + Name of the plugin's python module path: Optional[str] The path where the plugin is located options: Dict[str, Any] @@ -47,11 +46,22 @@ class NeighborlyConfig(BaseModel): """ Static configuration setting for the Neighborly - Attributes + Fields ---------- - simulation: SimulationConfig - Static configuration settings specifically for - the simulation instance + quiet: bool + Should the simulation not print to the console + seed: int + Seed used for random number generation + hours_per_timestep: str + How many hours elapse each simulation step + world_gen_start: str + Date when world generation starts (YYYY-MM-DD) + world_gen_end: str + Date when world generation ends (YYYY-MM-DD) + town_name: str + Name to give the generated town + town_size: Literal["small", "medium", "large"] + The size of th town to create plugins: List[Union[str, PluginConfig]] Names of plugins to load or names combined with instantiation parameters @@ -60,10 +70,11 @@ class NeighborlyConfig(BaseModel): """ quiet: bool = False - seed: int + seed: Union[int, str] hours_per_timestep: int world_gen_start: str world_gen_end: str + years_to_simulate: int town_name: str town_size: Literal["small", "medium", "large"] plugins: List[Union[str, PluginConfig]] = Field(default_factory=list) @@ -98,7 +109,7 @@ def load_plugin(module_name: str, path: Optional[str] = None) -> Plugin: try: plugin_module = importlib.import_module(module_name) - plugin: Plugin = plugin_module.__dict__["get_plugin"]() + plugin: Plugin = getattr(plugin_module, "get_plugin")() return plugin except KeyError: raise PluginSetupError( @@ -186,9 +197,6 @@ def get_arg_parser() -> argparse.ArgumentParser: action="store_true", help="Disable all printing to stdout", ) - parser.add_argument( - "--server", default=False, action="store_true", help="Run web server UI" - ) return parser @@ -207,11 +215,12 @@ def main(): config: NeighborlyConfig = NeighborlyConfig( seed=random.randint(0, 999999), hours_per_timestep=6, - world_gen_start="1839-08-19T00:00.000z", - world_gen_end="1979-08-19T00:00.000z", + world_gen_start="1839-08-19", + world_gen_end="1979-08-19", + years_to_simulate=100, town_size="medium", town_name="#town_name#", - plugins=["neighborly.plugins.default_plugin", "neighborly.plugins.talktown"], + plugins=["neighborly.plugins.defaults", "neighborly.plugins.talktown"], ) if args.config: @@ -227,9 +236,9 @@ def main(): sim_builder = SimulationBuilder( seed=config.seed, - world_gen_start=config.world_gen_start, - world_gen_end=config.world_gen_end, + starting_date=config.world_gen_start, time_increment_hours=config.hours_per_timestep, + print_events=not args.quiet, ) for plugin_entry in config.plugins: @@ -242,27 +251,19 @@ def main(): sim = sim_builder.build() - config.quiet = args.quiet - if not config.quiet: - sim.world.get_resource(LifeEventLog).subscribe(lambda e: print(str(e))) - - if args.server: - server = NeighborlyServer(sim) - server.run() - else: - sim.establish_setting() + sim.run_until(SimDateTime.from_str(config.world_gen_end)) - if not args.no_emit: - output_path = ( - args.output - if args.output - else f"{sim.seed}_{sim.town.name.replace(' ', '_')}.json" - ) + if not args.no_emit: + output_path = ( + args.output + if args.output + else f"{sim.seed}_{sim.town.name.replace(' ', '_')}.json" + ) - with open(output_path, "w") as f: - data = NeighborlyJsonExporter().export(sim) - f.write(data) - logger.debug(f"Simulation data written to: '{output_path}'") + with open(output_path, "w") as f: + data = NeighborlyJsonExporter().export(sim) + f.write(data) + logger.debug(f"Simulation data written to: '{output_path}'") if __name__ == "__main__": 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/archetypes.py b/src/neighborly/builtin/archetypes.py new file mode 100644 index 0000000..3929396 --- /dev/null +++ b/src/neighborly/builtin/archetypes.py @@ -0,0 +1,173 @@ +from typing import Optional + +from neighborly.builtin.components import ( + Adult, + Age, + CanAge, + CanGetPregnant, + Child, + Elder, + Female, + Lifespan, + LifeStages, + Male, + NonBinary, + Teen, +) +from neighborly.core.archetypes import BaseCharacterArchetype +from neighborly.core.business import InTheWorkforce, Unemployed +from neighborly.core.character import CharacterName, LifeStageAges +from neighborly.core.ecs import Component, GameObject, World +from neighborly.core.engine import NeighborlyEngine + + +class HumanArchetype(BaseCharacterArchetype): + __slots__ = "life_stage_ages", "lifespan" + + def __init__( + self, + life_stage_ages: Optional[LifeStageAges] = None, + lifespan: int = 73, + spawn_frequency: int = 1, + chance_spawn_with_spouse: float = 0.5, + max_children_at_spawn: int = 0, + ) -> None: + super().__init__( + spawn_frequency=spawn_frequency, + chance_spawn_with_spouse=chance_spawn_with_spouse, + max_children_at_spawn=max_children_at_spawn, + ) + self.life_stage_ages: LifeStageAges = ( + life_stage_ages + if life_stage_ages is not None + else { + "child": 0, + "teen": 13, + "young_adult": 18, + "adult": 30, + "elder": 65, + } + ) + self.lifespan: int = lifespan + + def create(self, world: World, **kwargs) -> GameObject: + # Perform calculations first and return the base character GameObject + gameobject = super().create(world, **kwargs) + + engine = world.get_resource(NeighborlyEngine) + + life_stage: str = kwargs.get("life_stage", "young_adult") + age: Optional[int] = kwargs.get("age") + + if "life_stage_ages" in kwargs: + self.life_stage_ages = kwargs["life_stage_ages"] + + gameobject.add_component(Lifespan(self.lifespan)) + gameobject.add_component(CanAge()) + gameobject.add_component(LifeStages(self.life_stage_ages)) + + if age is not None: + # Age takes priority over life stage if both are given + gameobject.add_component(Age(age)) + gameobject.add_component(self._life_stage_from_age(age)) + else: + gameobject.add_component(self._life_stage_from_str(life_stage)) + gameobject.add_component( + Age(self._generate_age_from_life_stage(world, life_stage)) + ) + + # gender + gender: Component = engine.rng.choice([Male, Female, NonBinary])() + gameobject.add_component(gender) + + # Initialize employment status + if life_stage == "young_adult" or life_stage == "adult": + gameobject.add_component(InTheWorkforce()) + gameobject.add_component(Unemployed()) + + if self._fertility_from_gender(world, gender): + gameobject.add_component(CanGetPregnant()) + + # name + gameobject.add_component(self._generate_name_from_gender(world, gender)) + + return gameobject + + def _life_stage_from_age(self, age: int) -> Component: + """Determine the life stage of a character given an age""" + if 0 <= age < self.life_stage_ages["teen"]: + return Child() + elif self.life_stage_ages["teen"] <= age < self.life_stage_ages["young_adult"]: + return Teen() + elif self.life_stage_ages["young_adult"] <= age < self.life_stage_ages["adult"]: + return Adult() + elif self.life_stage_ages["adult"] <= age < self.life_stage_ages["elder"]: + return Adult() + else: + return Elder() + + def _generate_age_from_life_stage(self, world: World, life_stage: str) -> int: + """Generates a random age given a life stage""" + engine = world.get_resource(NeighborlyEngine) + + if life_stage == "child": + return engine.rng.randint(0, self.life_stage_ages["teen"] - 1) + elif life_stage == "teen": + return engine.rng.randint( + self.life_stage_ages["teen"], + self.life_stage_ages["young_adult"] - 1, + ) + elif life_stage == "young_adult": + return engine.rng.randint( + self.life_stage_ages["young_adult"], + self.life_stage_ages["adult"] - 1, + ) + elif life_stage == "adult": + return engine.rng.randint( + self.life_stage_ages["adult"], + self.life_stage_ages["elder"] - 1, + ) + else: + return self.life_stage_ages["elder"] + + def _life_stage_from_str(self, life_stage: str) -> Component: + """Return the proper component given the life stage""" + if life_stage == "child": + return Child() + elif life_stage == "teen": + return Teen() + elif life_stage == "young_adult": + return Adult() + elif life_stage == "adult": + return Adult() + else: + return Elder() + + def _fertility_from_gender(self, world: World, gender: Component) -> bool: + """Return true if this character can get pregnant given their gender""" + engine = world.get_resource(NeighborlyEngine) + + if type(gender) == Female: + return engine.rng.random() < 0.8 + elif type(gender) == Male: + return False + else: + return engine.rng.random() < 0.4 + + def _generate_name_from_gender( + self, world: World, gender: Component + ) -> CharacterName: + """Generate a name for the character given their gender""" + engine = world.get_resource(NeighborlyEngine) + + if type(gender) == Male: + first_name_category = "#masculine_first_name#" + elif type(gender) == Female: + first_name_category = "#feminine_first_name#" + else: + first_name_category = "#first_name#" + + return CharacterName( + engine.name_generator.get_name(first_name_category), + engine.name_generator.get_name("#family_name#"), + ) diff --git a/src/neighborly/builtin/components.py b/src/neighborly/builtin/components.py new file mode 100644 index 0000000..a8bf593 --- /dev/null +++ b/src/neighborly/builtin/components.py @@ -0,0 +1,317 @@ +from typing import Any, Dict + +from neighborly.core.character import LifeStageAges +from neighborly.core.ecs import Component, component_info +from neighborly.core.time import SimDateTime + + +class Departed(Component): + + pass + + +class Vacant(Component): + """A tag component for residences that do not currently have any one living there""" + + pass + + +class Human(Component): + """Marks an entity as a Human""" + + pass + + +class MaxCapacity(Component): + + __slots__ = "capacity" + + def __init__(self, capacity: int) -> None: + super().__init__() + self.capacity = capacity + + def to_dict(self) -> Dict[str, Any]: + return {**super().to_dict(), "capacity": self.capacity} + + def pprint(self) -> None: + print(f"{self.__class__.__name__}:\n" f"\tcapacity: {self.capacity}") + + +class Name(Component): + """ + The string name of an entity + """ + + __slots__ = "value" + + def __init__(self, name: str = "") -> None: + super().__init__() + self.value = name + + def to_dict(self) -> Dict[str, Any]: + return {**super().to_dict(), "value": self.value} + + def __repr__(self): + return f"{self.__class__.__name__}({self.value})" + + def pprint(self) -> None: + print(f"{self.__class__.__name__}:\n" f"\tvalue: {self.value}") + + +class Age(Component): + """ + Tracks the number of years old that an entity is + """ + + __slots__ = "value" + + def __init__(self, age: float = 0.0) -> None: + super().__init__() + self.value: float = age + + def to_dict(self) -> Dict[str, Any]: + return {**super().to_dict(), "value": self.value} + + def __repr__(self): + return f"{self.__class__.__name__}({self.value})" + + def pprint(self) -> None: + print(f"{self.__class__.__name__}:\n" f"\tvalue: {self.value}") + + +class CanAge(Component): + """ + This component flags an entity as being able to age when time passes. + """ + + pass + + +class Mortal(Component): + pass + + +class Immortal(Component): + pass + + +class OpenToPublic(Component): + """ + This is an empty component that flags a location as one that characters + may travel to when they don't have somewhere to be in the Routine component + """ + + pass + + +class CurrentLocation(Component): + """Tracks the current location of this game object""" + + __slots__ = "location" + + def __init__(self, location: int) -> None: + super().__init__() + self.location: int = location + + def to_dict(self) -> Dict[str, Any]: + return {"location": self.location} + + def __repr__(self): + return f"{self.__class__.__name__}({self.location})" + + def pprint(self) -> None: + print(f"{self.__class__.__name__}:\n" f"\tlocation: {self.location}") + + +class Lifespan(Component): + """How long this entity usually lives""" + + __slots__ = "value" + + def __init__(self, lifespan: float) -> None: + super().__init__() + self.value: float = lifespan + + def to_dict(self) -> Dict[str, Any]: + return {**super().to_dict(), "value": self.value} + + def __repr__(self): + return f"{self.__class__.__name__}({self.value})" + + def pprint(self) -> None: + print(f"{self.__class__.__name__}:\n" f"\tvalue: {self.value}") + + +class LocationAliases(Component): + """ + Keeps record of strings mapped the IDs of locations in the world + """ + + __slots__ = "aliases" + + def __init__(self) -> None: + super().__init__() + self.aliases: Dict[str, int] = {} + + def to_dict(self) -> Dict[str, Any]: + return {**super().to_dict(), "aliases": {**self.aliases}} + + def __contains__(self, item: str) -> bool: + return item in self.aliases + + def __getitem__(self, item: str) -> int: + return self.aliases[item] + + def __setitem__(self, key: str, value: int) -> None: + self.aliases[key] = value + + def __delitem__(self, key) -> None: + del self.aliases[key] + + def __repr__(self): + return f"{self.__class__.__name__}({self.aliases})" + + def pprint(self) -> None: + print(f"{self.__class__.__name__}:\n" f"\taliases: {self.aliases}") + + +class LifeStages(Component): + """Tracks what stage of life an entity is in""" + + __slots__ = "stages" + + def __init__(self, stages: LifeStageAges) -> None: + super().__init__() + self.stages: LifeStageAges = stages + + def to_dict(self) -> Dict[str, Any]: + return {**super().to_dict(), "stages": {**self.stages}} + + def __repr__(self): + return f"{self.__class__.__name__}({self.stages})" + + +class CanGetPregnant(Component): + """Indicates that an entity is capable of giving birth""" + + pass + + +@component_info("Child", "Character is seen as a child in the eyes of society") +class Child(Component): + pass + + +@component_info( + "Adolescent", + "Character is seen as an adolescent in the eyes of society", +) +class Teen(Component): + pass + + +@component_info( + "Young Adult", + "Character is seen as a young adult in the eyes of society", +) +class YoungAdult(Component): + pass + + +@component_info( + "Adult", + "Character is seen as an adult in the eyes of society", +) +class Adult(Component): + pass + + +@component_info( + "Senior", + "Character is seen as a senior in the eyes of society", +) +class Elder(Component): + pass + + +@component_info( + "Deceased", + "This entity is dead", +) +class Deceased(Component): + pass + + +@component_info("Retired", "This entity retired from their last occupation") +class Retired(Component): + pass + + +@component_info("Dependent", "This entity is dependent on their parents") +class Dependent(Component): + pass + + +class CanDate(Component): + pass + + +class CanGetMarried(Component): + pass + + +class IsSingle(Component): + pass + + +class Pregnant(Component): + + __slots__ = "partner_name", "partner_id", "due_date" + + def __init__( + self, partner_name: str, partner_id: int, due_date: SimDateTime + ) -> None: + super().__init__() + self.partner_name: str = partner_name + self.partner_id: int = partner_id + self.due_date: SimDateTime = due_date + + def to_dict(self) -> Dict[str, Any]: + return { + **super().to_dict(), + "partner_name": self.partner_name, + "partner_id": self.partner_id, + "due_date": self.partner_id, + } + + def pprint(self) -> None: + print( + f"{self.__class__.__name__}:\n" + f"\tpartner: {self.partner_name}({self.partner_id})" + f"\tdue date: {self.due_date.to_date_str()}" + ) + + +@component_info("Male", "This entity is perceived as masculine.") +class Male(Component): + pass + + +@component_info("Female", "This entity is perceived as feminine.") +class Female(Component): + pass + + +@component_info("NonBinary", "This entity is perceived as non-binary.") +class NonBinary(Component): + pass + + +@component_info("College Graduate", "This entity graduated from college.") +class CollegeGraduate(Component): + pass + + +@component_info("Active", "This entity is in the town and active in the simulation") +class Active(Component): + pass diff --git a/src/neighborly/builtin/events.py b/src/neighborly/builtin/events.py index cb2139a..92c4c8c 100644 --- a/src/neighborly/builtin/events.py +++ b/src/neighborly/builtin/events.py @@ -1,465 +1,377 @@ from __future__ import annotations -from typing import List, Optional +from typing import Any, List, Optional, Tuple, cast -from neighborly.builtin.helpers import move_residence -from neighborly.builtin.role_filters import is_single -from neighborly.builtin.statuses import ( +import neighborly.core.query as querylib +from neighborly.builtin.components import ( + Active, Adult, - Dating, + Age, + CanGetPregnant, + Deceased, + Departed, Elder, - Married, + Lifespan, + OpenToPublic, Pregnant, Retired, + Vacant, +) +from neighborly.builtin.helpers import move_residence, move_to_location +from neighborly.builtin.role_filters import ( + friendship_gt, + friendship_lt, + is_single, + relationship_has_tags, + romance_gt, + romance_lt, +) +from neighborly.core.business import ( + Business, + ClosedForBusiness, + Occupation, + OpenForBusiness, Unemployed, ) -from neighborly.core.business import Occupation -from neighborly.core.character import GameCharacter +from neighborly.core.character import CharacterName, GameCharacter from neighborly.core.ecs import GameObject, World from neighborly.core.engine import NeighborlyEngine +from neighborly.core.event import Event from neighborly.core.life_event import ( - EventResult, - EventRole, - EventRoleType, + ILifeEvent, LifeEvent, - LifeEventLog, - LifeEventType, - join_filters, + LifeEventRoleType, + PatternLifeEvent, ) -from neighborly.core.relationship import RelationshipGraph, RelationshipTag +from neighborly.core.relationship import Relationships from neighborly.core.residence import Residence, Resident from neighborly.core.time import SimDateTime def become_friends_event( - threshold: int = 25, probability: float = 1.0 -) -> LifeEventType: + threshold: float = 0.7, probability: float = 1.0 +) -> ILifeEvent: """Defines an event where two characters become friends""" - def bind_potential_friend(world: World, event: LifeEvent): - """ - Return a Character that has a mutual friendship score - with PersonA that is above a given threshold - """ - rel_graph = world.get_resource(RelationshipGraph) - engine = world.get_resource(NeighborlyEngine) - person_a_relationships = rel_graph.get_relationships(event["PersonA"]) - eligible_characters: List[GameObject] = [] - for rel in person_a_relationships: - if ( - world.has_gameobject(rel.target) - and rel_graph.has_connection(rel.target, event["PersonA"]) - and ( - rel_graph.get_connection(rel.target, event["PersonA"]).friendship - >= threshold - ) - and not rel_graph.get_connection(rel.target, event["PersonA"]).has_tags( - RelationshipTag.Friend - ) - and not rel.has_tags(RelationshipTag.Friend) - and rel.friendship >= threshold - ): - eligible_characters.append(world.get_gameobject(rel.target)) + def effect(world: World, event: Event): + world.get_gameobject(event["Initiator"]).get_component(Relationships).get( + event["Other"] + ).add_tags("Friend") - if eligible_characters: - return engine.rng.choice(eligible_characters) - return None - - def execute(world: World, event: LifeEvent) -> EventResult: - rel_graph = world.get_resource(RelationshipGraph) - rel_graph.get_connection(event["PersonA"], event["PersonB"]).add_tags( - RelationshipTag.Friend - ) - rel_graph.get_connection(event["PersonB"], event["PersonA"]).add_tags( - RelationshipTag.Friend - ) - return EventResult(generated_events=[event]) + world.get_gameobject(event["Other"]).get_component(Relationships).get( + event["Initiator"] + ).add_tags("Friend") - return LifeEventType( + return PatternLifeEvent( name="BecomeFriends", - roles=[ - EventRoleType(name="PersonA", components=[GameCharacter]), - EventRoleType(name="PersonB", binder_fn=bind_potential_friend), - ], - execute_fn=execute, + pattern=querylib.Query( + find=("Initiator", "Other"), + clauses=[ + querylib.where(querylib.has_components(GameCharacter), "Initiator"), + querylib.where(querylib.has_components(Active), "Initiator"), + querylib.where(querylib.has_components(GameCharacter), "Other"), + querylib.where(querylib.has_components(Active), "Other"), + querylib.ne_(("Initiator", "Other")), + querylib.where(friendship_gt(threshold), "Initiator", "Other"), + querylib.where(friendship_gt(threshold), "Other", "Initiator"), + querylib.where_not( + relationship_has_tags("Friend"), "Initiator", "Other" + ), + ], + ), + effect=effect, probability=probability, ) def become_enemies_event( - threshold: int = -25, probability: float = 1.0 -) -> LifeEventType: + threshold: float = 0.3, probability: float = 1.0 +) -> ILifeEvent: """Defines an event where two characters become friends""" - def bind_potential_enemy(world: World, event: LifeEvent): - """ - Return a Character that has a mutual friendship score - with PersonA that is above a given threshold - """ - rel_graph = world.get_resource(RelationshipGraph) - engine = world.get_resource(NeighborlyEngine) - person_a_relationships = rel_graph.get_relationships(event["PersonA"]) - eligible_characters: List[GameObject] = [] - for rel in person_a_relationships: - if ( - world.has_gameobject(rel.target) - and rel_graph.has_connection(rel.target, event["PersonA"]) - and ( - rel_graph.get_connection(rel.target, event["PersonA"]).friendship - <= threshold - ) - and not rel_graph.get_connection(rel.target, event["PersonA"]).has_tags( - RelationshipTag.Friend - ) - and not rel.has_tags(RelationshipTag.Friend) - and rel.friendship <= threshold - ): - eligible_characters.append(world.get_gameobject(rel.target)) - - if eligible_characters: - return engine.rng.choice(eligible_characters) - return None + def effect(world: World, event: Event): + world.get_gameobject(event["Initiator"]).get_component(Relationships).get( + event["Other"] + ).add_tags("Enemy") - def execute(world: World, event: LifeEvent): - rel_graph = world.get_resource(RelationshipGraph) - rel_graph.get_connection(event["PersonA"], event["PersonB"]).add_tags( - RelationshipTag.Enemy - ) - rel_graph.get_connection(event["PersonB"], event["PersonA"]).add_tags( - RelationshipTag.Enemy - ) - return EventResult(generated_events=[event]) + world.get_gameobject(event["Other"]).get_component(Relationships).get( + event["Initiator"] + ).add_tags("Enemy") - return LifeEventType( + return PatternLifeEvent( name="BecomeEnemies", - roles=[ - EventRoleType(name="PersonA", components=[GameCharacter]), - EventRoleType( - name="PersonB", - binder_fn=bind_potential_enemy, - ), - ], - execute_fn=execute, + pattern=querylib.Query( + find=("Initiator", "Other"), + clauses=[ + querylib.where(querylib.has_components(GameCharacter), "Initiator"), + querylib.where(querylib.has_components(Active), "Initiator"), + querylib.where(querylib.has_components(GameCharacter), "Other"), + querylib.where(querylib.has_components(Active), "Other"), + querylib.ne_(("Initiator", "Other")), + querylib.where(friendship_lt(threshold), "Initiator", "Other"), + querylib.where(friendship_lt(threshold), "Other", "Initiator"), + querylib.where_not( + relationship_has_tags("Enemy"), "Initiator", "Other" + ), + ], + ), + effect=effect, probability=probability, ) -def start_dating_event(threshold: int = 25, probability: float = 0.8) -> LifeEventType: +def start_dating_event(threshold: float = 0.7, probability: float = 1.0) -> ILifeEvent: """Defines an event where two characters become friends""" - def potential_partner_filter( - world: World, gameobject: GameObject, **kwargs - ) -> bool: - event: LifeEvent = kwargs["event"] - rel_graph = world.get_resource(RelationshipGraph) - if rel_graph.has_connection( - event["PersonA"], gameobject.id - ) and rel_graph.has_connection(gameobject.id, event["PersonA"]): - return ( - rel_graph.get_connection(event["PersonA"], gameobject.id).romance - >= threshold - and rel_graph.get_connection(gameobject.id, event["PersonA"]).romance - >= threshold - ) - else: - return False + def effect(world: World, event: Event): + world.get_gameobject(event["Initiator"]).get_component(Relationships).get( + event["Other"] + ).add_tags("Dating", "Significant Other") - def execute(world: World, event: LifeEvent): - rel_graph = world.get_resource(RelationshipGraph) + world.get_gameobject(event["Other"]).get_component(Relationships).get( + event["Initiator"] + ).add_tags("Dating", "Significant Other") - rel_graph.get_connection(event["PersonA"], event["PersonB"]).add_tags( - RelationshipTag.SignificantOther - ) - rel_graph.get_connection(event["PersonB"], event["PersonA"]).add_tags( - RelationshipTag.SignificantOther - ) - - person_a = world.get_gameobject(event["PersonA"]) - person_b = world.get_gameobject(event["PersonB"]) - - person_a.add_component( - Dating( - partner_id=person_b.id, - partner_name=str(person_b.get_component(GameCharacter).name), - ) - ) - person_b.add_component( - Dating( - partner_id=person_a.id, - partner_name=str(person_a.get_component(GameCharacter).name), - ) - ) - return EventResult(generated_events=[event]) - - return LifeEventType( + return PatternLifeEvent( name="StartDating", - roles=[ - EventRoleType( - name="PersonA", components=[GameCharacter], filter_fn=is_single - ), - EventRoleType( - name="PersonB", - components=[GameCharacter], - filter_fn=join_filters(potential_partner_filter, is_single), - ), - ], - execute_fn=execute, + pattern=querylib.Query( + find=("Initiator", "Other"), + clauses=[ + querylib.where(querylib.has_components(GameCharacter), "Initiator"), + querylib.where(querylib.has_components(Active), "Initiator"), + querylib.where(querylib.has_components(GameCharacter), "Other"), + querylib.where(querylib.has_components(Active), "Other"), + querylib.ne_(("Initiator", "Other")), + querylib.where(romance_gt(threshold), "Initiator", "Other"), + querylib.where(romance_gt(threshold), "Other", "Initiator"), + querylib.where_not( + relationship_has_tags("Significant Other"), "Other", "Other_Curr_SO" + ), + querylib.where_not( + relationship_has_tags("Significant Other"), + "Initiator", + "Initiator_Curr_SO", + ), + querylib.where_not( + relationship_has_tags("Significant Other"), "Other", "Initiator" + ), + querylib.where_not( + relationship_has_tags("Family"), "Other", "Initiator" + ), + querylib.where( + querylib.to_clause(is_single, GameCharacter), "Initiator" + ), + querylib.where(querylib.to_clause(is_single, GameCharacter), "Other"), + ], + ), + effect=effect, probability=probability, ) -def dating_break_up_event( - threshold: int = 5, probability: float = 0.8 -) -> LifeEventType: - """Defines an event where two characters stop dating""" - - def bind_potential_ex(world: World, event: LifeEvent): - rel_graph = world.get_resource(RelationshipGraph) - dating = world.get_gameobject(event["PersonA"]).get_component(Dating) - partner = world.get_gameobject(dating.partner_id) - - if ( - rel_graph.get_connection(event["PersonA"], dating.partner_id).romance - < threshold - ): - return partner - - if ( - rel_graph.get_connection(dating.partner_id, event["PersonA"]).romance - < threshold - ): - return partner - - # Just break up for no reason at all - if world.get_resource(NeighborlyEngine).rng.random() < 0.15: - return partner +def stop_dating_event(threshold: float = 0.4, probability: float = 1.0) -> ILifeEvent: + """Defines an event where two characters become friends""" - return None + def effect(world: World, event: Event): + world.get_gameobject(event["Initiator"]).get_component(Relationships).get( + event["Other"] + ).remove_tags("Dating", "Significant Other") - def execute(world: World, event: LifeEvent): - rel_graph = world.get_resource(RelationshipGraph) + world.get_gameobject(event["Other"]).get_component(Relationships).get( + event["Initiator"] + ).remove_tags("Dating", "Significant Other") - rel_graph.get_connection(event["PersonA"], event["PersonB"]).remove_tags( - RelationshipTag.SignificantOther - ) - rel_graph.get_connection(event["PersonB"], event["PersonA"]).remove_tags( - RelationshipTag.SignificantOther - ) - - world.get_gameobject(event["PersonA"]).remove_component(Dating) - world.get_gameobject(event["PersonB"]).remove_component(Dating) - return EventResult(generated_events=[event]) - - return LifeEventType( + return PatternLifeEvent( name="DatingBreakUp", - roles=[ - EventRoleType(name="PersonA", components=[GameCharacter, Dating]), - EventRoleType(name="PersonB", binder_fn=bind_potential_ex), - ], - execute_fn=execute, + pattern=querylib.Query( + find=("Initiator", "Other"), + clauses=[ + querylib.where(querylib.has_components(GameCharacter), "Initiator"), + querylib.where(querylib.has_components(Active), "Initiator"), + querylib.where(querylib.has_components(GameCharacter), "Other"), + querylib.where(querylib.has_components(Active), "Other"), + querylib.ne_(("Initiator", "Other")), + querylib.where(romance_lt(threshold), "Initiator", "Other"), + querylib.where(romance_lt(threshold), "Other", "Initiator "), + querylib.where(relationship_has_tags("Dating"), "Initiator", "Other"), + ], + ), + effect=effect, probability=probability, ) -def divorce_event(threshold: int = -25, probability: float = 0.5) -> LifeEventType: - """Defines an event where two characters stop dating""" - - def current_partner_filter(world: World, gameobject: GameObject, **kwargs) -> bool: - event: LifeEvent = kwargs["event"] - rel_graph = world.get_resource(RelationshipGraph) - - if gameobject.has_component(Married): - if gameobject.get_component(Married).partner_id == event["PersonA"]: - return ( - rel_graph.get_connection(event["PersonA"], gameobject.id).romance - < threshold - ) - - return False - - def execute(world: World, event: LifeEvent): - rel_graph = world.get_resource(RelationshipGraph) - - rel_graph.get_connection(event["PersonA"], event["PersonB"]).remove_tags( - RelationshipTag.SignificantOther | RelationshipTag.Spouse - ) - rel_graph.get_connection(event["PersonB"], event["PersonA"]).remove_tags( - RelationshipTag.SignificantOther | RelationshipTag.Spouse - ) - - world.get_gameobject(event["PersonA"]).remove_component(Married) - world.get_gameobject(event["PersonB"]).remove_component(Married) - return EventResult(generated_events=[event]) +def divorce_event(threshold: float = 0.4, probability: float = 1.0) -> ILifeEvent: + """Defines an event where two characters become friends""" - return LifeEventType( - name="GotDivorced", - roles=[ - EventRoleType(name="PersonA", components=[GameCharacter]), - EventRoleType( - name="PersonB", - components=[GameCharacter], - filter_fn=current_partner_filter, - ), - ], - execute_fn=execute, + def effect(world: World, event: Event): + world.get_gameobject(event["Initiator"]).get_component(Relationships).get( + event["Other"] + ).remove_tags("Spouse", "Significant Other") + + world.get_gameobject(event["Other"]).get_component(Relationships).get( + event["Initiator"] + ).remove_tags("Spouse", "Significant Other") + + return PatternLifeEvent( + name="Divorce", + pattern=querylib.Query( + find=("Initiator", "Other"), + clauses=[ + querylib.where(querylib.has_components(GameCharacter), "Initiator"), + querylib.where(querylib.has_components(Active), "Initiator"), + querylib.where(querylib.has_components(GameCharacter), "Other"), + querylib.where(querylib.has_components(Active), "Other"), + querylib.ne_(("Initiator", "Other")), + querylib.where(romance_lt(threshold), "Initiator", "Other"), + querylib.where(romance_lt(threshold), "Other", "Initiator "), + querylib.where(relationship_has_tags("Spouse"), "Initiator", "Other"), + ], + ), + effect=effect, probability=probability, ) -def marriage_event(threshold: int = 35, probability: float = 0.5) -> LifeEventType: +def marriage_event(threshold: float = 0.7, probability: float = 1.0) -> ILifeEvent: """Defines an event where two characters become friends""" - def bind_potential_spouse(world: World, event: LifeEvent): - character = world.get_gameobject(event["PersonA"]) - potential_spouse = world.get_gameobject( - character.get_component(Dating).partner_id - ) - rel_graph = world.get_resource(RelationshipGraph) - - character_meets_thresh = ( - rel_graph.get_connection(character.id, potential_spouse.id).romance - >= threshold - ) - - potential_spouse_meets_thresh = ( - rel_graph.get_connection(potential_spouse.id, character.id).romance - >= threshold - ) - - if character_meets_thresh and potential_spouse_meets_thresh: - return potential_spouse - - return None - - def execute(world: World, event: LifeEvent): - rel_graph = world.get_resource(RelationshipGraph) - - rel_graph.get_connection(event["PersonA"], event["PersonB"]).add_tags( - RelationshipTag.SignificantOther | RelationshipTag.Spouse - ) - rel_graph.get_connection(event["PersonB"], event["PersonA"]).add_tags( - RelationshipTag.SignificantOther | RelationshipTag.Spouse - ) - - person_a = world.get_gameobject(event["PersonA"]) - person_b = world.get_gameobject(event["PersonB"]) - - person_a.remove_component(Dating) - person_b.remove_component(Dating) - - person_a.add_component( - Married( - partner_id=person_b.id, - partner_name=str(person_b.get_component(GameCharacter).name), - ) - ) - person_b.add_component( - Married( - partner_id=person_a.id, - partner_name=str(person_a.get_component(GameCharacter).name), - ) - ) - return EventResult(generated_events=[event]) - - return LifeEventType( - name="GotMarried", - roles=[ - EventRoleType(name="PersonA", components=[GameCharacter, Dating]), - EventRoleType(name="PersonB", binder_fn=bind_potential_spouse), - ], - execute_fn=execute, + def effect(world: World, event: Event): + world.get_gameobject(event["Initiator"]).get_component(Relationships).get( + event["Other"] + ).add_tags("Spouse", "Significant Other") + + world.get_gameobject(event["Initiator"]).get_component(Relationships).get( + event["Other"] + ).remove_tags("Dating") + + world.get_gameobject(event["Other"]).get_component(Relationships).get( + event["Initiator"] + ).add_tags("Spouse", "Significant Other") + + world.get_gameobject(event["Other"]).get_component(Relationships).get( + event["Initiator"] + ).remove_tags("Dating") + + return PatternLifeEvent( + name="GetMarried", + pattern=querylib.Query( + find=("Initiator", "Other"), + clauses=[ + querylib.where(querylib.has_components(GameCharacter), "Initiator"), + querylib.where(querylib.has_components(Active), "Initiator"), + querylib.where(querylib.has_components(GameCharacter), "Other"), + querylib.where(querylib.has_components(Active), "Other"), + querylib.ne_(("Initiator", "Other")), + querylib.where(romance_gt(threshold), "Initiator", "Other"), + querylib.where(romance_gt(threshold), "Other", "Initiator "), + querylib.where(relationship_has_tags("Dating"), "Initiator", "Other"), + ], + ), + effect=effect, probability=probability, ) -def depart_due_to_unemployment() -> LifeEventType: - def bind_unemployed_character(world: World, event: LifeEvent): +def depart_due_to_unemployment() -> ILifeEvent: + def bind_unemployed_character( + world: World, event: Event, candidate: Optional[GameObject] + ): eligible_characters: List[GameObject] = [] - for _, unemployed in world.get_component(Unemployed): + for _, (unemployed, _) in world.get_components(Unemployed, Active): if unemployed.duration_days > 30: eligible_characters.append(unemployed.gameobject) if eligible_characters: return world.get_resource(NeighborlyEngine).rng.choice(eligible_characters) return None - def effect(world: World, event: LifeEvent): - world.get_gameobject(event["Person"]).archive() - return EventResult(generated_events=[event]) - - return LifeEventType( - name="DepartTown", - roles=[EventRoleType(name="Person", binder_fn=bind_unemployed_character)], - execute_fn=effect, + def effect(world: World, event: Event): + departed = world.get_gameobject(event["Person"]) + departed.remove_component(Active) + departed.add_component(Departed()) + move_to_location(world, departed, None) + + return LifeEvent( + name="DepartDueToUnemployment", + roles=[LifeEventRoleType(name="Person", binder_fn=bind_unemployed_character)], + effect=effect, + probability=1, ) -def pregnancy_event(probability: float = 0.3) -> LifeEventType: +def pregnancy_event() -> ILifeEvent: """Defines an event where two characters stop dating""" - def can_get_pregnant_filter(world: World, gameobject: GameObject, **kwargs) -> bool: - return gameobject.get_component( - GameCharacter - ).can_get_pregnant and not gameobject.has_component(Pregnant) - - def bind_current_partner(world: World, event: LifeEvent) -> Optional[GameObject]: - person_a = world.get_gameobject(event["PersonA"]) - if person_a.has_component(Married): - return world.get_gameobject(person_a.get_component(Married).partner_id) - return None - - def execute(world: World, event: LifeEvent): + def execute(world: World, event: Event): due_date = SimDateTime.from_iso_str( world.get_resource(SimDateTime).to_iso_str() ) due_date.increment(months=9) - world.get_gameobject(event["PersonA"]).add_component( + world.get_gameobject(event["PregnantOne"]).add_component( Pregnant( partner_name=str( - world.get_gameobject(event["PersonB"]) - .get_component(GameCharacter) - .name + world.get_gameobject(event["Other"]).get_component(CharacterName) ), - partner_id=event["PersonB"], + partner_id=event["Other"], due_date=due_date, ) ) - return EventResult(generated_events=[event]) - return LifeEventType( + def prob_fn(world: World, event: Event): + gameobject = world.get_gameobject(event["PregnantOne"]) + children = gameobject.get_component(Relationships).get_all_with_tags("Child") + if len(children) >= 5: + return 0.0 + else: + return 4.0 - len(children) / 8.0 + + return PatternLifeEvent( name="GotPregnant", - roles=[ - EventRoleType( - name="PersonA", - components=[GameCharacter], - filter_fn=can_get_pregnant_filter, - ), - EventRoleType(name="PersonB", binder_fn=bind_current_partner), - ], - execute_fn=execute, - probability=probability, + pattern=querylib.Query( + find=("PregnantOne", "Other"), + clauses=[ + querylib.where(querylib.has_components(GameCharacter), "PregnantOne"), + querylib.where(querylib.has_components(Active), "PregnantOne"), + querylib.where(querylib.has_components(CanGetPregnant), "PregnantOne"), + querylib.where_not(querylib.has_components(Pregnant), "PregnantOne"), + querylib.where(querylib.has_components(GameCharacter), "Other"), + querylib.where(querylib.has_components(Active), "Other"), + querylib.ne_(("PregnantOne", "Other")), + querylib.where_any( + querylib.where( + relationship_has_tags("Dating"), "PregnantOne", "Other" + ), + querylib.where( + relationship_has_tags("Married"), "PregnantOne", "Other" + ), + ), + ], + ), + effect=execute, + probability=prob_fn, ) -def retire_event(probability: float = 0.4) -> LifeEventType: +def retire_event(probability: float = 0.4) -> ILifeEvent: """ Event for characters retiring from working after reaching elder status Parameters ---------- probability: float - Probability that a character will retire from their job + Probability that an entity will retire from their job when they are an elder Returns ------- - LifeEventType + LifeEvent LifeEventType instance with all configuration defined """ - def bind_retiree(world: World, event: LifeEvent): + def bind_retiree(world: World, event: Event, candidate: Optional[GameObject]): eligible_characters: List[GameObject] = [] - for gid, _ in world.get_components(Elder, Occupation): + for gid, _ in world.get_components(Elder, Occupation, Active): gameobject = world.get_gameobject(gid) if not gameobject.has_component(Retired): eligible_characters.append(gameobject) @@ -467,40 +379,46 @@ def bind_retiree(world: World, event: LifeEvent): return world.get_resource(NeighborlyEngine).rng.choice(eligible_characters) return None - def execute(world: World, event: LifeEvent): + def bind_business(world: World, event: Event, candidate: Optional[GameObject]): + return world.get_gameobject( + world.get_gameobject(event["Retiree"]).get_component(Occupation).business + ) + + def execute(world: World, event: Event): retiree = world.get_gameobject(event["Retiree"]) - retiree.remove_component(Occupation) retiree.add_component(Retired()) - return EventResult(generated_events=[event]) - return LifeEventType( + return LifeEvent( name="Retire", - roles=[EventRoleType(name="Retiree", binder_fn=bind_retiree)], - execute_fn=execute, + roles=[ + LifeEventRoleType(name="Retiree", binder_fn=bind_retiree), + LifeEventRoleType(name="Business", binder_fn=bind_business), + ], + effect=execute, probability=probability, ) -def find_own_place_event(probability: float = 0.1) -> LifeEventType: - def bind_potential_mover(world: World, event: LifeEvent): - eligible: List[int] = [] - for gid, (_, _, _, resident) in world.get_components( - GameCharacter, Occupation, Adult, Resident +def find_own_place_event(probability: float = 0.1) -> ILifeEvent: + def bind_potential_mover(world: World) -> List[Tuple[Any, ...]]: + eligible: List[Tuple[Any, ...]] = [] + + for gid, (_, _, _, resident, _) in world.get_components( + GameCharacter, Occupation, Adult, Resident, Active ): + resident = cast(Resident, resident) residence = world.get_gameobject(resident.residence).get_component( Residence ) if gid not in residence.owners: - eligible.append(gid) - return None + eligible.append((gid,)) + + return eligible def find_vacant_residences(world: World) -> List[Residence]: """Try to find a vacant residence to move into""" return list( - filter( - lambda res: res.is_vacant(), - map(lambda pair: pair[1], world.get_component(Residence)), - ) + map(lambda pair: pair[1][0], world.get_components(Residence, Vacant)) ) def choose_random_vacant_residence(world: World) -> Optional[Residence]: @@ -510,29 +428,135 @@ def choose_random_vacant_residence(world: World) -> Optional[Residence]: return world.get_resource(NeighborlyEngine).rng.choice(vacancies) return None - def execute(world: World, event: LifeEvent): + def execute(world: World, event: Event): # Try to find somewhere to live character = world.get_gameobject(event["Character"]) vacant_residence = choose_random_vacant_residence(world) if vacant_residence: # Move into house with any dependent children - move_residence(character.get_component(GameCharacter), vacant_residence) + move_residence(character, vacant_residence.gameobject) # Depart if no housing could be found else: - world.get_gameobject(event["Person"]).archive() - world.get_resource(LifeEventLog).record_event( - LifeEvent( - "DepartTown", - timestamp=world.get_resource(SimDateTime).to_iso_str(), - roles=[EventRole("Departee", event["Person"])], - ) - ) - return EventResult(generated_events=[event]) + depart = depart_event() + + residence = world.get_gameobject( + character.get_component(Resident).residence + ).get_component(Residence) + + depart.try_execute_event(world, Character=character) - return LifeEventType( + # Have all spouses depart + # Allows for polygamy + for rel in character.get_component(Relationships).get_all_with_tags( + "Spouse" + ): + spouse = world.get_gameobject(rel.target) + depart.try_execute_event(world, Character=spouse) + + # Have all children living in the same house depart + for rel in character.get_component(Relationships).get_all_with_tags( + "Child" + ): + child = world.get_gameobject(rel.target) + if child.id in residence.residents and child.id not in residence.owners: + depart.try_execute_event(world, Character=child) + + return PatternLifeEvent( name="FindOwnPlace", probability=probability, - roles=[EventRoleType("Character", binder_fn=bind_potential_mover)], - execute_fn=execute, + pattern=querylib.Query( + ("Character",), [querylib.where(bind_potential_mover, "Character")] + ), + effect=execute, + ) + + +def depart_event() -> ILifeEvent: + def execute(world: World, event: Event): + character = world.get_gameobject(event["Character"]) + character.remove_component(Active) + character.add_component(Departed()) + move_to_location(world, character, None) + + return LifeEvent( + name="Depart", + roles=[LifeEventRoleType("Character")], + effect=execute, + probability=1, + ) + + +def die_of_old_age(probability: float = 0.8) -> ILifeEvent: + def execute(world: World, event: Event) -> None: + deceased = world.get_gameobject(event["Deceased"]) + deceased.add_component(Deceased()) + deceased.remove_component(Active) + + return PatternLifeEvent( + name="DieOfOldAge", + probability=probability, + pattern=querylib.Query( + ("Deceased",), + [ + querylib.where( + querylib.has_components(GameCharacter, Active), "Deceased" + ), + querylib.where( + querylib.component_attr(Age, "value"), "Deceased", "Age" + ), + querylib.where( + querylib.component_attr(Lifespan, "value"), "Deceased", "Lifespan" + ), + querylib.ge_(("Age", "Lifespan")), + ], + ), + effect=execute, + ) + + +def death_event() -> ILifeEvent: + def execute(world: World, event: Event): + deceased = world.get_gameobject(event["Deceased"]) + deceased.add_component(Deceased()) + deceased.remove_component(Active) + move_to_location(world, deceased, None) + + return LifeEvent( + name="Death", + roles=[LifeEventRoleType("Deceased")], + effect=execute, + probability=1.0, + ) + + +def go_out_of_business_event() -> ILifeEvent: + def effect(world: World, event: Event): + business = world.get_gameobject(event["Business"]) + business.remove_component(OpenForBusiness) + business.add_component(ClosedForBusiness()) + if business.has_component(OpenToPublic): + business.remove_component(OpenToPublic) + + def probability_fn(world: World, event: Event) -> float: + business = world.get_gameobject(event["Business"]) + lifespan = business.get_component(Lifespan).value + age = business.get_component(Age).value + if age < lifespan: + return age / lifespan + else: + return 0.8 + + return PatternLifeEvent( + name="GoOutOfBusiness", + pattern=querylib.Query( + find=("Business",), + clauses=[ + querylib.where(querylib.has_components(Business), "Business"), + querylib.where(querylib.has_components(OpenForBusiness), "Business"), + querylib.where(querylib.has_components(Active), "Business"), + ], + ), + effect=effect, + probability=probability_fn, ) diff --git a/src/neighborly/builtin/helpers.py b/src/neighborly/builtin/helpers.py index 767372c..4d3a41a 100644 --- a/src/neighborly/builtin/helpers.py +++ b/src/neighborly/builtin/helpers.py @@ -1,343 +1,527 @@ from __future__ import annotations import logging -from typing import List, Optional, Tuple, cast - -import numpy as np - -import neighborly.core.behavior_tree as bt -from neighborly.builtin.statuses import ( - Adult, - Child, - CollegeGraduate, - Elder, - InTheWorkforce, - Teen, - Unemployed, - YoungAdult, +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Dict, List, Optional, Tuple, Type, TypeVar, Union, cast + +from neighborly.builtin.components import CurrentLocation, LocationAliases, Vacant +from neighborly.core.archetypes import ( + BaseCharacterArchetype, + CharacterArchetypes, + ICharacterArchetype, ) -from neighborly.core.activity import ActivityLibrary -from neighborly.core.archetypes import CharacterArchetype -from neighborly.core.business import Business -from neighborly.core.character import GameCharacter -from neighborly.core.ecs import GameObject, World +from neighborly.core.building import Building +from neighborly.core.business import ( + Business, + ClosedForBusiness, + Occupation, + WorkHistory, +) +from neighborly.core.ecs import Component, GameObject, World from neighborly.core.engine import NeighborlyEngine -from neighborly.core.life_event import EventRole, LifeEvent, LifeEventLog +from neighborly.core.event import Event, EventLog, EventRole from neighborly.core.location import Location -from neighborly.core.personal_values import PersonalValues -from neighborly.core.relationship import ( - Relationship, - RelationshipGraph, - RelationshipTag, -) +from neighborly.core.position import Position2D +from neighborly.core.relationship import Relationships from neighborly.core.residence import Residence, Resident from neighborly.core.time import SimDateTime +from neighborly.core.town import LandGrid logger = logging.getLogger(__name__) -def get_top_activities(character_values: PersonalValues, n: int = 3) -> Tuple[str, ...]: - """Return the top activities a character would enjoy given their values""" +def at_same_location(a: GameObject, b: GameObject) -> bool: + """Return True if these characters are at the same location""" + a_location = a.get_component(CurrentLocation).location + b_location = b.get_component(CurrentLocation).location + return ( + a_location is not None and b_location is not None and a_location == b_location + ) - scores: List[Tuple[int, str]] = [] - for activity in ActivityLibrary.get_all(): - score: int = int(np.dot(character_values.traits, activity.personal_values)) - scores.append((score, activity.name)) +# def get_top_activities(character_values: PersonalValues, n: int = 3) -> Tuple[str, ...]: +# """Return the top activities an entity would enjoy given their values""" +# +# scores: List[Tuple[int, str]] = [] +# +# for activity in ActivityLibrary.get_all(): +# score: int = int(np.dot(character_values.traits, activity.personal_values)) +# scores.append((score, activity.name)) +# +# return tuple( +# [ +# activity_score[1] +# for activity_score in sorted(scores, key=lambda s: s[0], reverse=True) +# ][:n] +# ) +# +# +# def find_places_with_activities(world: World, *activities: str) -> List[int]: +# """Return a list of entity ID for locations that have the given activities""" +# locations: List[Tuple[int, List[Location, Activities]]] = world.get_components( +# Location, Activities +# ) +# +# matches: List[int] = [] +# +# for location_id, (_, activities_comp) in locations: +# if all([activities_comp.has_activity(a) for a in activities]): +# matches.append(location_id) +# +# return matches +# +# +# def find_places_with_any_activities(world: World, *activities: str) -> List[int]: +# """Return a list of entity ID for locations that have any of the given activities +# +# Results are sorted by how many activities they match +# """ +# +# def score_location(act_comp: Activities) -> int: +# return sum([act_comp.has_activity(a) for a in activities]) +# +# locations: List[Tuple[int, List[Location, Activities]]] = world.get_components( +# Location, Activities +# ) +# +# matches: List[Tuple[int, int]] = [] +# +# for location_id, (_, activities_comp) in locations: +# score = score_location(activities_comp) +# if score > 0: +# matches.append((score, location_id)) +# +# return [match[1] for match in sorted(matches, key=lambda m: m[0], reverse=True)] + + +def add_coworkers(world: World, character: GameObject, business: Business) -> None: + """Add coworker tags to current coworkers in relationship network""" + for employee_id in business.get_employees(): + if employee_id == character.id: + continue - return tuple( - [ - activity_score[1] - for activity_score in sorted(scores, key=lambda s: s[0], reverse=True) - ][:n] - ) + coworker = world.get_gameobject(employee_id) + character.get_component(Relationships).get(employee_id).add_tags("Coworker") -def find_places_with_activities(world: World, *activities: str) -> List[int]: - """Return a list of entity ID for locations that have the given activities""" - locations = world.get_component(Location) + coworker.get_component(Relationships).get(character.id).add_tags("Coworker") - matches: List[int] = [] - for location_id, location in locations: - if location.has_flags(*ActivityLibrary.get_flags(*activities)): - matches.append(location_id) +def remove_coworkers(world: World, character: GameObject, business: Business) -> None: + """Remove coworker tags from current coworkers in relationship network""" + for employee_id in business.get_employees(): + if employee_id == character.id: + continue - return matches + coworker = world.get_gameobject(employee_id) + character.get_component(Relationships).get(employee_id).remove_tags("Coworker") -def find_places_with_any_activities(world: World, *activities: str) -> List[int]: - """Return a list of entity ID for locations that have any of the given activities + coworker.get_component(Relationships).get(character.id).remove_tags("Coworker") - Results are sorted by how many activities they match + +def move_to_location( + world: World, gameobject: GameObject, destination: Optional[Union[int, str]] +) -> None: + if type(destination) == str: + # Check for a location aliases component + if location_aliases := gameobject.try_component(LocationAliases): + destination_id = location_aliases[destination] + else: + raise RuntimeError( + "Gameobject does not have a LocationAliases component. Destination cannot be a string." + ) + else: + destination_id = destination + + # A location cant move to itself + if destination_id == gameobject.id: + return + + # Update old location if needed + if current_location_comp := gameobject.try_component(CurrentLocation): + current_location = world.get_gameobject( + current_location_comp.location + ).get_component(Location) + current_location.remove_entity(gameobject.id) + gameobject.remove_component(CurrentLocation) + + # Move to new location if needed + if destination_id is not None: + destination = world.get_gameobject(destination_id).get_component(Location) + destination.add_entity(gameobject.id) + gameobject.add_component(CurrentLocation(destination_id)) + + +def remove_residence_owner(character: GameObject, residence: GameObject) -> None: """ + Remove a character as the owner of a residence - flags = ActivityLibrary.get_flags(*activities) + Parameters + ---------- + character: GameObject + Character to remove as owner + residence: GameObject + Residence to remove them from + """ + residence.get_component(Residence).remove_owner(character.id) - def score_location(loc: Location) -> int: - location_score: int = 0 - for flag in flags: - if loc.activity_flags & flag > 0: - location_score += 1 - return location_score - locations = world.get_component(Location) +def add_residence_owner(character: GameObject, residence: GameObject) -> None: + """ + Add an entity as the new owner of a residence - matches: List[Tuple[int, int]] = [] + Parameters + ---------- + character: GameObject + entity that purchased the residence - for location_id, location in locations: - score = score_location(location) - if score > 0: - matches.append((score, location_id)) + residence: GameObject + residence that was purchased + """ + residence.get_component(Residence).add_owner(character.id) - return [match[1] for match in sorted(matches, key=lambda m: m[0], reverse=True)] +def move_out_of_residence(character: GameObject, former_residence: GameObject) -> None: + """ + Removes a character as a resident at a given residence -def add_coworkers(character: GameObject, business: Business) -> None: - """Add coworker tags to current coworkers in relationship network""" + Parameters + ---------- + character: GameObject + Character to remove + former_residence: GameObject + Residence to remove the character from + """ + former_residence.get_component(Residence).remove_resident(character.id) + character.remove_component(Resident) - world: World = character.world - rel_graph = world.get_resource(RelationshipGraph) + if len(former_residence.get_component(Residence).residents) <= 0: + former_residence.add_component(Vacant()) - for employee_id in business.get_employees(): - if employee_id == character.id: - continue + if location_aliases := character.try_component(LocationAliases): + del location_aliases["home"] - if not rel_graph.has_connection(character.id, employee_id): - rel_graph.add_relationship(Relationship(character.id, employee_id)) - if not rel_graph.has_connection(employee_id, character.id): - rel_graph.add_relationship(Relationship(employee_id, character.id)) +def move_residence(character: GameObject, new_residence: GameObject) -> None: + """ + Sets an entity's primary residence, possibly replacing the previous - rel_graph.get_connection(character.id, employee_id).add_tags( - RelationshipTag.Coworker - ) + Parameters + ---------- + character + new_residence - rel_graph.get_connection(employee_id, character.id).add_tags( - RelationshipTag.Coworker - ) + Returns + ------- + """ -def remove_coworkers(character: GameObject, business: Business) -> None: - """Remove coworker tags from current coworkers in relationship network""" world = character.world - rel_graph = world.get_resource(RelationshipGraph) - for employee_id in business.get_employees(): - if employee_id == character.id: - continue + if resident := character.try_component(Resident): + # This character is currently a resident at another location + former_residence = world.get_gameobject(resident.residence) + move_out_of_residence(character, former_residence) - if rel_graph.has_connection(character.id, employee_id): - rel_graph.get_connection(character.id, employee_id).remove_tags( - RelationshipTag.Coworker - ) + # Move into new residence + new_residence.get_component(Residence).add_resident(character.id) - if rel_graph.has_connection(employee_id, character.id): - rel_graph.get_connection(employee_id, character.id).remove_tags( - RelationshipTag.Coworker - ) + if not character.has_component(LocationAliases): + character.add_component(LocationAliases()) + character.get_component(LocationAliases)["home"] = new_residence.id + character.add_component(Resident(new_residence.id)) -def move_to_location( - world: World, character: GameCharacter, destination: Location -) -> None: - if destination.gameobject.id == character.location: - return + if new_residence.has_component(Vacant): + new_residence.remove_component(Vacant) - if character.location is not None: - current_location: Location = world.get_gameobject( - character.location - ).get_component(Location) - current_location.remove_character(character.gameobject.id) - destination.add_character(character.gameobject.id) - character.location = destination.gameobject.id +def demolish_building(gameobject: GameObject) -> None: + """Remove the building component and free the land grid space""" + world = gameobject.world + gameobject.remove_component(Building) + position = gameobject.get_component(Position2D) + land_grid = world.get_resource(LandGrid) + land_grid[int(position.x), int(position.y)] = None + gameobject.remove_component(Position2D) + +def close_for_business(business: Business) -> None: + """Close a business and remove all employees and the owner""" + world = business.gameobject.world + date = world.get_resource(SimDateTime) -def get_locations(world: World) -> List[Tuple[int, Location]]: - return sorted( - cast(List[Tuple[int, Location]], world.get_component(Location)), - key=lambda pair: pair[0], + business.gameobject.add_component(ClosedForBusiness()) + + close_for_business_event = Event( + name="ClosedForBusiness", + timestamp=date.to_date_str(), + roles=[ + EventRole("Business", business.gameobject.id), + ], ) + world.get_resource(EventLog).record_event(world, close_for_business_event) -def move_residence(character: GameCharacter, new_residence: Residence) -> None: - """Move a character into a residence""" + for employee in business.get_employees(): + layoff_employee(business, world.get_gameobject(employee)) - world = character.gameobject.world + if business.owner_type is not None: + layoff_employee(business, world.get_gameobject(business.owner)) + business.owner = None - # Move out of the old residence - if "home" in character.location_aliases: - old_residence = world.get_gameobject( - character.location_aliases["home"] - ).get_component(Residence) - old_residence.remove_resident(character.gameobject.id) - if old_residence.is_owner(character.gameobject.id): - old_residence.remove_owner(character.gameobject.id) - old_residence.gameobject.get_component(Location).whitelist.remove( - character.gameobject.id - ) - # Move into new residence - new_residence.add_resident(character.gameobject.id) - character.location_aliases["home"] = new_residence.gameobject.id - new_residence.gameobject.get_component(Location).whitelist.add( - character.gameobject.id +def leave_job(world: World, employee: GameObject) -> None: + """Character leaves the job of their own free will""" + occupation = employee.get_component(Occupation) + + business = world.get_gameobject(occupation.business) + + fired_event = Event( + name="LeaveJob", + timestamp=world.get_resource(SimDateTime).to_iso_str(), + roles=[ + EventRole("Business", business.id), + EventRole("Character", employee.id), + ], ) - character.gameobject.add_component(Resident(new_residence.gameobject.id)) + world.get_resource(EventLog).record_event(world, fired_event) -############################################ -# GENERATING CHARACTERS OF DIFFERENT AGES -############################################ + business.get_component(Business).remove_employee(employee.id) + if not employee.has_component(WorkHistory): + employee.add_component(WorkHistory()) -def generate_child_character( - world: World, engine: NeighborlyEngine, archetype: CharacterArchetype -) -> GameObject: - character = world.spawn_archetype(archetype) - character.add_component(Child()) - return character + employee.get_component(WorkHistory).add_entry( + occupation_type=occupation.occupation_type, + business=business.id, + start_date=occupation.start_date, + end_date=world.get_resource(SimDateTime).copy(), + reason_for_leaving=fired_event, + ) + employee.remove_component(Occupation) -def generate_teen_character( - world: World, engine: NeighborlyEngine, archetype: CharacterArchetype -) -> GameObject: - character = world.spawn_archetype(archetype) - character.add_component(Teen()) - return character +def layoff_employee(business: Business, employee: GameObject) -> None: + """Remove an employee""" + world = business.gameobject.world + date = world.get_resource(SimDateTime) + business.remove_employee(employee.id) -def generate_young_adult_character( - world: World, engine: NeighborlyEngine, archetype: CharacterArchetype -) -> GameObject: - """ - Create a new Young-adult-aged character + occupation = employee.get_component(Occupation) - Parameters - ---------- - world: World - The world to spawn the character into - engine: NeighborlyEngine - The engine instance that holds the character archetypes - archetype: Optional[str] - The name of the archetype to generate. A random archetype - will be generated if not provided + fired_event = Event( + name="LaidOffFromJob", + timestamp=date.to_iso_str(), + roles=[ + EventRole("Business", business.gameobject.id), + EventRole("Character", employee.id), + ], + ) - Returns - ------- - GameObject - The final generated gameobject - """ - character = world.spawn_archetype(archetype) - character.add_component(Unemployed()) - character.add_component(YoungAdult()) - character.add_component(InTheWorkforce()) - return character + world.get_resource(EventLog).record_event(world, fired_event) + if not employee.has_component(WorkHistory): + employee.add_component(WorkHistory()) -def generate_adult_character( - world: World, engine: NeighborlyEngine, archetype: CharacterArchetype -) -> GameObject: - character = world.spawn_archetype(archetype) - character.add_component(Unemployed()) - character.add_component(Adult()) - character.add_component(InTheWorkforce()) - return character + employee.get_component(WorkHistory).add_entry( + occupation_type=occupation.occupation_type, + business=business.gameobject.id, + start_date=occupation.start_date, + end_date=date.copy(), + reason_for_leaving=fired_event, + ) + employee.remove_component(Occupation) -def generate_elderly_character( - world: World, engine: NeighborlyEngine, archetype: CharacterArchetype -) -> GameObject: - character = world.spawn_archetype(archetype) - character.add_component(Elder()) - character.add_component(InTheWorkforce()) - return character +def choose_random_character_archetype( + engine: NeighborlyEngine, +) -> Optional[ICharacterArchetype]: + """Performs a weighted random selection across all character archetypes""" + archetype_choices: List[ICharacterArchetype] = [] + archetype_weights: List[int] = [] -############################################ -# Actions -############################################ + for archetype in CharacterArchetypes.get_all(): + archetype_choices.append(archetype) + archetype_weights.append(archetype.get_spawn_frequency()) + if archetype_choices: + # Choose an archetype at random + archetype: ICharacterArchetype = engine.rng.choices( + population=archetype_choices, weights=archetype_weights, k=1 + )[0] -def become_adult_behavior(chance_depart: float) -> bt.BehaviorNode: - return bt.sequence(chance_node(chance_depart), depart_action) + return archetype + else: + return None -def chance_node(chance: float) -> bt.BehaviorNode: - """Returns BehaviorTree node that returns True with a given probability""" +@dataclass() +class InheritableComponentInfo: + """ + Fields + ------ + inheritance_chance: Tuple[float, float] + Probability that a character inherits a component when only on parent has + it and the probability if both characters have it + always_inherited: bool + Indicates that a component should be inherited regardless of + """ - def fn(world: World, event: LifeEvent, **kwargs) -> bool: - return world.get_resource(NeighborlyEngine).rng.random() < chance + inheritance_chance: Tuple[float, float] + always_inherited: bool + requires_both_parents: bool - return fn +_inheritable_components: Dict[Type[Component], InheritableComponentInfo] = {} -def action_node(fn) -> bt.BehaviorNode: - def wrapper(world: World, event: LifeEvent, **kwargs) -> bool: - fn(world, event, **kwargs) - return True - return wrapper +class IInheritable(ABC): + @classmethod + @abstractmethod + def from_parents( + cls, parent_a: Optional[Component], parent_b: Optional[Component] + ) -> Component: + """Build a new instance of the component using instances from the parents""" + raise NotImplementedError -@action_node -def go_to_college(world: World, event: LifeEvent, **kwargs) -> None: - gameobject = world.get_gameobject(event["Unemployed"]) - gameobject.add_component(CollegeGraduate()) - # Reset the unemployment counter since they graduate from school - gameobject.get_component(Unemployed).duration_days = 0 +_CT = TypeVar("_CT", bound="Component") - world.get_resource(LifeEventLog).record_event( - LifeEvent( - name="GraduatedCollege", - roles=[EventRole("Graduate", gameobject.id)], - timestamp=world.get_resource(SimDateTime).to_iso_str(), - ) - ) +def inheritable( + requires_both_parents: bool = False, + inheritance_chance: Union[int, Tuple[float, float]] = (0.5, 0.5), + always_inherited: bool = False, +): + """Class decorator for components that can be inherited from characters' parents""" -@action_node -def death_action(world: World, event: LifeEvent, **kwargs) -> None: - gameobject = world.get_gameobject(event["Deceased"]) - gameobject.archive() + def wrapped(cls: Type[_CT]) -> Type[_CT]: + if not callable(getattr(cls, "from_parents", None)): + raise RuntimeError("Component does not implement IInheritable interface.") - world.get_resource(LifeEventLog).record_event( - LifeEvent( - name="Death", - roles=[EventRole("Deceased", gameobject.id)], - timestamp=world.get_resource(SimDateTime).to_iso_str(), + _inheritable_components[cls] = InheritableComponentInfo( + requires_both_parents=requires_both_parents, + inheritance_chance=( + inheritance_chance + if type(inheritance_chance) == tuple + else (inheritance_chance, inheritance_chance) + ), + always_inherited=always_inherited, ) - ) + return cls + return wrapped -@action_node -def depart_action(world: World, event: LifeEvent, **kwargs) -> None: - gameobject = world.get_gameobject(event["Departee"]) - gameobject.archive() - # Get the character's dependent nuclear family - rel_graph = world.get_resource(RelationshipGraph) +def is_inheritable(component_type: Type[Component]) -> bool: + """Returns True if a component is inheritable from parent to child""" + return component_type in _inheritable_components - spouse_rel = rel_graph.get_all_relationships_with_tags( - gameobject.id, RelationshipTag.Spouse - ) - if spouse_rel: - world.get_gameobject(spouse_rel[0].target).archive() - event.roles.append(EventRole("Departee", spouse_rel[0].target)) +def get_inheritable_components(gameobject: GameObject) -> List[Type[Component]]: + """Returns all the component type associated with the GameObject that are inheritable""" + inheritable_components = list() + # Get inheritable components from parent_a + for component_type in gameobject.get_component_types(): + if is_inheritable(component_type): + inheritable_components.append(component_type) + return inheritable_components + - children = rel_graph.get_all_relationships_with_tags( - gameobject.id, RelationshipTag.Child | RelationshipTag.NuclearFamily +def get_inheritable_traits_given_parents( + parent_a: GameObject, parent_b: GameObject +) -> Tuple[List[Type[Component]], List[Tuple[float, Type[Component]]]]: + """ + Returns a + Parameters + ---------- + parent_a + parent_b + + Returns + ------- + List[Type[Component]] + The component types that can be inherited from + """ + + parent_a_inheritables = set(get_inheritable_components(parent_a)) + + parent_b_inheritables = set(get_inheritable_components(parent_b)) + + shared_inheritables = parent_a_inheritables.intersection(parent_b_inheritables) + + all_inheritables = parent_a_inheritables.union(parent_b_inheritables) + + required_components = [] + random_pool = [] + + for component_type in all_inheritables: + if _inheritable_components[component_type].always_inherited: + required_components.append(component_type) + continue + + if _inheritable_components[component_type].requires_both_parents: + if component_type in shared_inheritables: + required_components.append(component_type) + continue + + if component_type in shared_inheritables: + random_pool.append( + ( + _inheritable_components[component_type].inheritance_chance[1], + component_type, + ) + ) + else: + random_pool.append( + ( + _inheritable_components[component_type].inheritance_chance[0], + component_type, + ) + ) + + return required_components, random_pool + + +def generate_child( + world: World, parent_a: GameObject, parent_b: GameObject +) -> GameObject: + child = BaseCharacterArchetype().create(world) + + required_components, random_pool = get_inheritable_traits_given_parents( + parent_a, parent_b ) - for child_rel in children: - world.get_gameobject(child_rel.target).archive() - event.roles.append(EventRole("Departee", child_rel.target)) - - world.get_resource(LifeEventLog).record_event( - LifeEvent( - name="Depart", - roles=event.roles, - timestamp=world.get_resource(SimDateTime).to_iso_str(), + + for component_type in required_components: + component = cast(IInheritable, component_type).from_parents( + parent_a.try_component(component_type), + parent_b.try_component(component_type), ) - ) + child.add_component(component) + + rng = world.get_resource(NeighborlyEngine).rng + + rng.shuffle(random_pool) + + remaining_traits = 3 + + for probability, component_type in random_pool: + if rng.random() < probability: + child.add_component( + component_type.from_parents( + parent_a.try_component(component_type), + parent_b.try_component(component_type), + ) + ) + remaining_traits -= 1 + + if remaining_traits <= 0: + break + + return child diff --git a/src/neighborly/builtin/role_filters.py b/src/neighborly/builtin/role_filters.py index de82dfd..cd71bec 100644 --- a/src/neighborly/builtin/role_filters.py +++ b/src/neighborly/builtin/role_filters.py @@ -1,75 +1,138 @@ from __future__ import annotations -from typing import Any, Literal, Type - -from neighborly.builtin.statuses import ( - CollegeGraduate, - Dating, - Female, - InTheWorkforce, - Male, - Married, - NonBinary, - Retired, -) -from neighborly.core.business import Occupation, WorkHistory -from neighborly.core.character import GameCharacter -from neighborly.core.ecs import Component, GameObject, World -from neighborly.core.life_event import RoleFilterFn -from neighborly.core.relationship import RelationshipGraph, RelationshipTag +from typing import Any, List, Literal, Tuple + +from neighborly.builtin.components import Age, CollegeGraduate, Female, Male, NonBinary +from neighborly.core.business import Occupation, Unemployed, WorkHistory +from neighborly.core.ecs import GameObject, World +from neighborly.core.query import EcsFindClause +from neighborly.core.relationship import Relationships +from neighborly.core.role import RoleFilterFn from neighborly.core.time import SimDateTime +def friendship_gt(threshold: float) -> EcsFindClause: + """Returns a list of all the GameObjects with the given component""" + + def precondition(world: World): + results: List[Tuple[int, ...]] = [] + for gid, relationships in world.get_component(Relationships): + for r in relationships.get_all(): + if r.friendship > threshold: + results.append((gid, r.target)) + return results + + return precondition + + +def friendship_lt(threshold: float) -> EcsFindClause: + """Returns a list of all the GameObjects with the given component""" + + def precondition(world: World): + results: List[Tuple[int, ...]] = [] + for gid, relationships in world.get_component(Relationships): + for r in relationships.get_all(): + if r.friendship < threshold: + results.append((gid, r.target)) + return results + + return precondition + + +def romance_gt(threshold: float) -> EcsFindClause: + """Returns a list of all the GameObjects with the given component""" + + def precondition(world: World): + results: List[Tuple[int, ...]] = [] + for gid, relationships in world.get_component(Relationships): + for r in relationships.get_all(): + if r.romance > threshold: + results.append((gid, r.target)) + return results + + return precondition + + +def romance_lt(threshold: float) -> EcsFindClause: + """Returns a list of all the GameObjects with the given component""" + + def precondition(world: World): + results: List[Tuple[int, ...]] = [] + for gid, relationships in world.get_component(Relationships): + for r in relationships.get_all(): + if r.romance < threshold: + results.append((gid, r.target)) + return results + + return precondition + + +def relationship_has_tags(*tags: str) -> EcsFindClause: + """Returns a list of all the GameObjects with the given component""" + + def precondition(world: World): + results: List[Tuple[int, ...]] = [] + for gid, relationships in world.get_component(Relationships): + for r in relationships.get_all_with_tags(*tags): + results.append((gid, r.target)) + return results + + return precondition + + +def has_relationship_with_tags(*tags: str) -> EcsFindClause: + """Returns a list of all the GameObjects with the given component""" + + def precondition(world: World): + results: List[Tuple[int, ...]] = [] + for gid, relationships in world.get_component(Relationships): + if relationships.get_all_with_tags(*tags): + results.append((gid,)) + return results + + return precondition + + def over_age(age: int) -> RoleFilterFn: - def fn(world: World, gameobject: GameObject, **kwargs) -> bool: - return gameobject.get_component(GameCharacter).age > age + def fn(world: World, gameobject: GameObject) -> bool: + age_component = gameobject.try_component(Age) + if age_component is not None: + return age_component.value > age return fn -def is_man(world: World, gameobject: GameObject, **kwargs) -> bool: +def is_man(world: World, gameobject: GameObject) -> bool: """Return true if GameObject is a man""" return gameobject.has_component(Male) -def older_than(age: int): - def precondition_fn(world: World, gameobject: GameObject, **kwargs) -> bool: - return gameobject.get_component(GameCharacter).age > age - - return precondition_fn - - -def is_single(world: World, gameobject: GameObject, **kwargs) -> bool: - """Return True if this character has no relationships tagged as significant others""" - rel_graph = world.get_resource(RelationshipGraph) - significant_other_relationships = rel_graph.get_all_relationships_with_tags( - gameobject.id, RelationshipTag.SignificantOther - ) +def is_single(world: World, gameobject: GameObject) -> bool: + """Return True if this entity has no relationships tagged as significant others""" return ( - bool(significant_other_relationships) - and not gameobject.has_component(Married) - and not gameobject.has_component(Dating) + len( + gameobject.get_component(Relationships).get_all_with_tags( + "Significant Other" + ) + ) + == 0 ) -def is_unemployed(world: World, gameobject: GameObject, **kwargs) -> bool: - """Returns True if this character does not have a job""" - return ( - not gameobject.has_component(Occupation) - and gameobject.has_component(InTheWorkforce) - and not gameobject.has_component(Retired) - ) +def is_unemployed(world: World, gameobject: GameObject) -> bool: + """Returns True if this entity does not have a job""" + return gameobject.has_component(Unemployed) -def is_employed(world: World, gameobject: GameObject, **kwargs) -> bool: - """Returns True if this character has a job""" +def is_employed(world: World, gameobject: GameObject) -> bool: + """Returns True if this entity has a job""" return gameobject.has_component(Occupation) def before_year(year: int) -> RoleFilterFn: """Return precondition function that checks if the date is before a given year""" - def fn(world: World, gameobject: GameObject, **kwargs: Any) -> bool: + def fn(world: World, gameobject: GameObject) -> bool: return world.get_resource(SimDateTime).year < year return fn @@ -85,19 +148,19 @@ def fn(world: World, gameobject: GameObject, **kwargs: Any) -> bool: def is_gender(gender: Literal["male", "female", "non-binary"]) -> RoleFilterFn: - """Return precondition function that checks if a character is a given gender""" + """Return precondition function that checks if an entity is a given gender""" gender_component_types = {"male": Male, "female": Female, "non-binary": NonBinary} - def fn(world: World, gameobject: GameObject, **kwargs: Any) -> bool: + def fn(world: World, gameobject: GameObject) -> bool: return gameobject.has_component(gender_component_types[gender]) return fn def has_any_work_experience() -> RoleFilterFn: - """Return True if the character has any work experience at all""" + """Return True if the entity has any work experience at all""" - def fn(world: World, gameobject: GameObject, **kwargs: Any) -> bool: + def fn(world: World, gameobject: GameObject) -> bool: return len(gameobject.get_component(WorkHistory)) > 0 return fn @@ -107,7 +170,7 @@ def has_experience_as_a( occupation_type: str, years_experience: int = 0 ) -> RoleFilterFn: """ - Returns Precondition function that returns true if the character + Returns Precondition function that returns true if the entity has experience as a given occupation type. Parameters @@ -115,7 +178,7 @@ def has_experience_as_a( occupation_type: str The name of the occupation to check for years_experience: int - The number of years of experience the character needs to have + The number of years of experience the entity needs to have Returns ------- @@ -123,29 +186,32 @@ def has_experience_as_a( The precondition function used when filling the occupation """ - def fn(world: World, gameobject: GameObject, **kwargs: Any) -> bool: - work_history = gameobject.get_component(WorkHistory) - return ( - work_history.has_experience_as_a(occupation_type) - and work_history.total_experience_as_a(occupation_type) >= years_experience - ) + def fn(world: World, gameobject: GameObject) -> bool: + total_experience: int = 0 - return fn + work_history = gameobject.try_component(WorkHistory) + if work_history is None: + return False -def is_college_graduate() -> RoleFilterFn: - """Return True if the character is a college graduate""" + for entry in work_history.entries[:-1]: + if entry.occupation_type == occupation_type: + total_experience += entry.years_held - def fn(world: World, gameobject: GameObject, **kwargs: Any) -> bool: - return gameobject.has_component(CollegeGraduate) + if gameobject.has_component(Occupation): + occupation = gameobject.get_component(Occupation) + if occupation.occupation_type == occupation_type: + total_experience += occupation.years_held + + return total_experience >= years_experience return fn -def has_component(component_type: Type[Component]) -> RoleFilterFn: - """Return tru if the gameobject has a component""" +def is_college_graduate() -> RoleFilterFn: + """Return True if the entity is a college graduate""" - def fn(world: World, gameobject: GameObject, **kwargs: Any) -> bool: - return gameobject.has_component(component_type) + def fn(world: World, gameobject: GameObject) -> bool: + return gameobject.has_component(CollegeGraduate) return fn diff --git a/src/neighborly/builtin/statuses.py b/src/neighborly/builtin/statuses.py deleted file mode 100644 index 8ca9ba7..0000000 --- a/src/neighborly/builtin/statuses.py +++ /dev/null @@ -1,200 +0,0 @@ -from neighborly.core.status import Status -from neighborly.core.time import SimDateTime - - -class Child(Status): - def __init__(self) -> None: - super().__init__( - "child", - "Character is seen as a child in the eyes of society", - ) - - -class Teen(Status): - def __init__(self) -> None: - super().__init__( - "Adolescent", - "Character is seen as an adolescent in the eyes of society", - ) - - -class YoungAdult(Status): - def __init__(self) -> None: - super().__init__( - "Young Adult", - "Character is seen as a young adult in the eyes of society", - ) - - -class Adult(Status): - def __init__(self) -> None: - super().__init__( - "Adult", - "Character is seen as an adult in the eyes of society", - ) - - -class Elder(Status): - def __init__(self) -> None: - super().__init__( - "Senior", - "Character is seen as a senior in the eyes of society", - ) - - -class Deceased(Status): - def __init__(self) -> None: - super().__init__( - "Deceased", - "This character is dead", - ) - - -class Retired(Status): - def __init__(self) -> None: - super().__init__( - "Retired", - "This character retired from their last occupation", - ) - - -class Dependent(Status): - def __init__(self) -> None: - super().__init__("Dependent", "This character is dependent on their parents") - - -class Unemployed(Status): - __slots__ = "duration_days" - - def __init__(self) -> None: - super().__init__( - "Unemployed", - "Character doesn't have a job", - ) - self.duration_days: float = 0 - - -class Dating(Status): - __slots__ = "duration_years", "partner_id", "partner_name" - - def __init__(self, partner_id: int, partner_name: str) -> None: - super().__init__( - "Dating", - "This character is in a relationship with another", - ) - self.duration_years: float = 0.0 - self.partner_id: int = partner_id - self.partner_name: str = partner_name - - def on_archive(self) -> None: - """Remove status on this character and the partner""" - self.gameobject.remove_component(type(self)) - self.gameobject.world.get_gameobject(self.partner_id).remove_component( - type(self) - ) - - -class Married(Status): - __slots__ = "duration_years", "partner_id", "partner_name" - - def __init__(self, partner_id: int, partner_name: str) -> None: - super().__init__( - "Married", - "This character is married to another", - ) - self.duration_years = 0.0 - self.partner_id: int = partner_id - self.partner_name: str = partner_name - - def on_archive(self) -> None: - """Remove status on this character and the partner""" - self.gameobject.remove_component(type(self)) - self.gameobject.world.get_gameobject(self.partner_id).remove_component( - type(self) - ) - - -class InRelationship(Status): - __slots__ = "duration_years", "partner_id", "partner_name", "relationship_type" - - def __init__( - self, relationship_type: str, partner_id: int, partner_name: str - ) -> None: - super().__init__( - "Married", - "This character is married to another", - ) - self.relationship_type: str = relationship_type - self.duration_years = 0.0 - self.partner_id: int = partner_id - self.partner_name: str = partner_name - - def on_archive(self) -> None: - """Remove status on this character and the partner""" - self.gameobject.remove_component(type(self)) - self.gameobject.world.get_gameobject(self.partner_id).remove_component( - type(self) - ) - - -class BusinessOwner(Status): - __slots__ = "duration", "business_id", "business_name" - - def __init__(self, business_id: int, business_name: str) -> None: - super().__init__( - "Business Owner", - "This character owns a business", - ) - self.duration = 0.0 - self.business_id: int = business_id - self.business_name: str = business_name - - def on_archive(self) -> None: - """Remove status on this character and the partner""" - self.gameobject.remove_component(type(self)) - - -class Pregnant(Status): - def __init__( - self, partner_name: str, partner_id: int, due_date: SimDateTime - ) -> None: - super().__init__("Pregnant", "This character is pregnant") - self.partner_name: str = partner_name - self.partner_id: int = partner_id - self.due_date: SimDateTime = due_date - - def on_archive(self) -> None: - """Remove status on this character and the partner""" - self.gameobject.remove_component(type(self)) - - -class Male(Status): - def __init__(self): - super().__init__("Male", "This character is perceived as masculine.") - - -class Female(Status): - def __init__(self): - super().__init__("Female", "This character is perceived as feminine.") - - -class NonBinary(Status): - def __init__(self): - super().__init__("NonBinary", "This character is perceived as non-binary.") - - -class CollegeGraduate(Status): - def __init__(self) -> None: - super().__init__("College Graduate", "This character graduated from college.") - - -class InTheWorkforce(Status): - def __init__(self) -> None: - super().__init__( - "In the Workforce", - "This Character is eligible for employment opportunities.", - ) - - def on_archive(self) -> None: - """Remove status on this character and the partner""" - self.gameobject.remove_component(type(self)) diff --git a/src/neighborly/builtin/systems.py b/src/neighborly/builtin/systems.py index 10d78e0..9ca30c3 100644 --- a/src/neighborly/builtin/systems.py +++ b/src/neighborly/builtin/systems.py @@ -1,62 +1,77 @@ from __future__ import annotations -import itertools import logging -import math -from typing import List, Optional, Set, cast +from typing import List, Optional, Set, Tuple, cast -from neighborly.builtin.helpers import ( - generate_child_character, - generate_young_adult_character, - get_locations, - move_residence, - move_to_location, -) -from neighborly.builtin.statuses import ( +from neighborly.builtin.components import ( + Active, Adult, - BusinessOwner, + Age, + CanAge, Child, - Dating, + CurrentLocation, Deceased, + Departed, Elder, - InRelationship, - InTheWorkforce, - Married, + LifeStages, + LocationAliases, + Name, + OpenToPublic, Pregnant, + Retired, Teen, - Unemployed, + Vacant, YoungAdult, ) +from neighborly.builtin.events import depart_event +from neighborly.builtin.helpers import ( + add_residence_owner, + choose_random_character_archetype, + demolish_building, + generate_child, + layoff_employee, + move_out_of_residence, + move_residence, + move_to_location, + remove_residence_owner, +) from neighborly.core.archetypes import ( - BusinessArchetype, - BusinessArchetypeLibrary, - CharacterArchetype, - CharacterArchetypeLibrary, - ResidenceArchetype, - ResidenceArchetypeLibrary, + BusinessArchetypes, + IBusinessArchetype, + IResidenceArchetype, + ResidenceArchetypes, ) +from neighborly.core.building import Building from neighborly.core.business import ( Business, - BusinessStatus, + ClosedForBusiness, + InTheWorkforce, Occupation, - OccupationTypeLibrary, + OccupationTypes, + OpenForBusiness, + PendingOpening, + Unemployed, + end_job, + start_job, ) from neighborly.core.character import CharacterName, GameCharacter -from neighborly.core.ecs import GameObject, ISystem, World +from neighborly.core.ecs import GameObject, ISystem from neighborly.core.engine import NeighborlyEngine -from neighborly.core.life_event import EventRole, LifeEvent, LifeEventLog +from neighborly.core.event import Event, EventLog, EventRole from neighborly.core.location import Location -from neighborly.core.personal_values import PersonalValues from neighborly.core.position import Position2D -from neighborly.core.relationship import ( - Relationship, - RelationshipGraph, - RelationshipTag, -) -from neighborly.core.residence import Residence -from neighborly.core.rng import DefaultRNG +from neighborly.core.relationship import Relationships +from neighborly.core.residence import Residence, Resident from neighborly.core.routine import Routine -from neighborly.core.time import DAYS_PER_YEAR, HOURS_PER_YEAR, SimDateTime, TimeDelta +from neighborly.core.system import System +from neighborly.core.time import ( + DAYS_PER_YEAR, + HOURS_PER_DAY, + HOURS_PER_YEAR, + SimDateTime, + TimeDelta, + Weekday, +) from neighborly.core.town import LandGrid, Town logger = logging.getLogger(__name__) @@ -64,46 +79,61 @@ class RoutineSystem(ISystem): """ - Moves characters based on their Routine component - characters with routines move to positions + GameCharacters with Routine components move to locations designated by their + routines. If they do not have a routine entry, then they move to a location + that is open to the public. """ def process(self, *args, **kwargs) -> None: date = self.world.get_resource(SimDateTime) engine = self.world.get_resource(NeighborlyEngine) - for _, (character, routine) in self.world.get_components( - GameCharacter, Routine + for _, (character, routine, _) in self.world.get_components( + GameCharacter, Routine, Active ): character = cast(GameCharacter, character) routine = cast(Routine, routine) + location_aliases = character.gameobject.try_component(LocationAliases) - routine_entries = routine.get_entries(date.weekday_str, date.hour) + routine_entry = routine.get_entry(date.weekday, date.hour) - if routine_entries: - chosen_entry = engine.rng.choice(routine_entries) - location_id = ( - character.location_aliases[str(chosen_entry.location)] - if isinstance(chosen_entry.location, str) - else chosen_entry.location - ) + if ( + routine_entry + and isinstance(routine_entry.location, str) + and location_aliases + ): move_to_location( self.world, - character, - self.world.get_gameobject(location_id).get_component(Location), + character.gameobject, + location_aliases.aliases[routine_entry.location], ) - else: - potential_locations = get_locations(self.world) + elif routine_entry: + move_to_location( + self.world, character.gameobject, routine_entry.location + ) + + else: + potential_locations: List[int] = list( + map( + lambda res: res[0], + self.world.get_components(Location, OpenToPublic), + ) + ) if potential_locations: - _, location = engine.rng.choice(potential_locations) - move_to_location(self.world, character, location) + location = engine.rng.choice(potential_locations) + move_to_location(self.world, character.gameobject, location) class LinearTimeSystem(ISystem): """ Advances simulation time using a linear time increment + + Attributes + ---------- + increment: TimeDelta + How much should time be progressed each simulation step """ __slots__ = "increment" @@ -115,26 +145,51 @@ def __init__(self, increment: TimeDelta) -> None: def process(self, *args, **kwargs) -> None: """Advance time""" current_date = self.world.get_resource(SimDateTime) - current_date += self.increment + current_date.increment(hours=self.increment.total_hours) class DynamicLoDTimeSystem(ISystem): """ Updates the current date/time in the simulation - using a variable level-of-detail where a subset + using a variable level-of-detail (LOD) where a subset of the days during a year receive more simulation - ticks + ticks. Attributes ---------- - days_per_year: int + _low_lod_time_increment: TimeDelta + The amount to increment time by during low LOD phases + _high_lod_time_increment: TimeDelta + The amount to increment time by during high LOD phases + _days_per_year: int How many high LoD days to simulate per year - + _high_lod_days_for_year: Set[int] + Ordinal dates of days in the current year that will be simulated in higher LOD + _current_year: int + The current year in the simulation """ - def __init__(self, days_per_year: int) -> None: + __slots__ = ( + "_low_lod_time_increment", + "_high_lod_time_increment", + "days_per_year", + "_high_lod_days_for_year", + "_current_year", + ) + + def __init__( + self, + days_per_year: int, + low_lod_time_increment: Optional[TimeDelta] = None, + high_lod_time_increment: Optional[TimeDelta] = None, + ) -> None: super().__init__() - self._low_lod_delta_time: int = 6 + self._low_lod_time_increment: TimeDelta = ( + low_lod_time_increment if low_lod_time_increment else TimeDelta(hours=24) + ) + self._high_lod_time_increment: TimeDelta = ( + high_lod_time_increment if high_lod_time_increment else TimeDelta(hours=6) + ) self._days_per_year: int = days_per_year self._high_lod_days_for_year: Set[int] = set() self._current_year: int = -1 @@ -163,89 +218,68 @@ def process(self, *args, **kwargs): if current_date.to_ordinal() in self._high_lod_days_for_year: # Increment the time using a smaller time increment (High LoD) - current_date.increment(hours=self._low_lod_delta_time) - return + current_date += self._high_lod_time_increment else: # Increment by one whole day (Low LoD) - current_date.increment(hours=24) - - @staticmethod - def _generate_sample_days( - world: World, start_date: SimDateTime, end_date: SimDateTime, n: int - ) -> List[int]: - """Samples n days from each year between the start and end dates""" - ordinal_start_date: int = start_date.to_ordinal() - ordinal_end_date: int = end_date.to_ordinal() - - sampled_ordinal_dates: List[int] = [] - - current_date = ordinal_start_date - - while current_date < ordinal_end_date: - sampled_dates = world.get_resource(DefaultRNG).sample( - range(current_date, current_date + DAYS_PER_YEAR), n - ) - sampled_ordinal_dates.extend(sorted(sampled_dates)) - current_date = min(current_date + DAYS_PER_YEAR, ordinal_end_date) - - return sampled_ordinal_dates + current_date += self._low_lod_time_increment class FindBusinessOwnerSystem(ISystem): def process(self, *args, **kwargs) -> None: - unemployed_characters: List[GameCharacter] = list( - map(lambda x: x[1][0], self.world.get_components(GameCharacter, Unemployed)) - ) - - engine = self.world.get_resource(NeighborlyEngine) - event_log = self.world.get_resource(LifeEventLog) + event_log = self.world.get_resource(EventLog) date = self.world.get_resource(SimDateTime) - for _, business in self.world.get_component(Business): + for _, (business, _, _) in self.world.get_components( + Business, PendingOpening, Building + ): + business = cast(Business, business) + if not business.needs_owner(): + # This business is free to hire employees and does not need to hire an + # owner first + business.gameobject.add_component(OpenForBusiness()) + business.gameobject.remove_component(PendingOpening) continue - if len(unemployed_characters) == 0: - break - - character = engine.rng.choice(unemployed_characters) - character.gameobject.add_component( - BusinessOwner(business.gameobject.id, business.name) + result = OccupationTypes.get(business.owner_type).fill_role( + self.world, business ) - character.gameobject.remove_component(Unemployed) - character.gameobject.add_component( - Occupation( - OccupationTypeLibrary.get(business.owner_type), - business.gameobject.id, + + if result: + candidate, occupation = result + + start_job( + business, + candidate.get_component(GameCharacter), + occupation, + is_owner=True, ) - ) - business.owner = character.gameobject.id - unemployed_characters.remove(character) - event_log.record_event( - LifeEvent( - name="BecameBusinessOwner", - timestamp=date.to_iso_str(), - roles=[ - EventRole("Business", business.gameobject.id), - EventRole("Owner", character.gameobject.id), - ], - position=business.owner_type, + event_log.record_event( + self.world, + Event( + name="BecameBusinessOwner", + timestamp=date.to_iso_str(), + roles=[ + EventRole("Business", business.gameobject.id), + EventRole("Owner", candidate.id), + ], + position=business.owner_type, + ) ) - ) class UnemploymentSystem(ISystem): """ - Handles updating the amount of time that a character - has been unemployed. If a character has been unemployed + Handles updating the amount of time that an entity + has been unemployed. If an entity has been unemployed for longer than a specified amount of time, they will depart from the simulation. Attributes ---------- days_to_departure: int - The number of days a character is allowed to be + The number of days an entity is allowed to be unemployed before they choose to depart from the simulation """ @@ -258,313 +292,121 @@ def __init__(self, days_to_departure: int = 30) -> None: def process(self, *args, **kwargs): date = self.world.get_resource(SimDateTime) - for _, unemployed in self.world.get_component(Unemployed): + for gid, (unemployed, _) in self.world.get_components(Unemployed, Active): # Convert delta time from hours to days unemployed.duration_days += date.delta_time / 24 - # Trigger the DepartAction and cast this character + # Trigger the DepartAction and cast this entity # as the departee if unemployed.duration_days >= self.days_to_departure: - pass # TODO: Add DepartAction constructor and execute - + spouses = unemployed.gameobject.get_component( + Relationships + ).get_all_with_tags("Spouse") + + # Do not depart if one or more of the entity's spouses has a job + if any( + [ + self.world.get_gameobject(rel.target).has_component(Occupation) + for rel in spouses + ] + ): + continue -class RelationshipStatusSystem(ISystem): - def process(self, *args, **kwargs): - date = self.world.get_resource(SimDateTime) + else: + depart = depart_event() - for _, marriage in self.world.get_component(Married): - # Convert delta time from hours to days - marriage.duration_years += date.delta_time / HOURS_PER_YEAR + residence = self.world.get_gameobject( + unemployed.gameobject.get_component(Resident).residence + ).get_component(Residence) - for _, dating in self.world.get_component(Dating): - # Convert delta time from hours to days - dating.duration_years += date.delta_time / HOURS_PER_YEAR + depart.try_execute_event( + self.world, Character=unemployed.gameobject + ) - for _, relationship in self.world.get_component(InRelationship): - # Convert delta time from hours to days - relationship.duration_years += date.delta_time / HOURS_PER_YEAR + # Have all spouses depart + # Allows for polygamy + for rel in spouses: + spouse = self.world.get_gameobject(rel.target) + depart.try_execute_event(self.world, Character=spouse) + + # Have all children living in the same house depart + children = unemployed.gameobject.get_component( + Relationships + ).get_all_with_tags("Child") + for rel in children: + child = self.world.get_gameobject(rel.target) + if ( + child.id in residence.residents + and child.id not in residence.owners + ): + depart.try_execute_event(self.world, Character=child) class FindEmployeesSystem(ISystem): def process(self, *args, **kwargs) -> None: - unemployed_characters: List[GameCharacter] = list( - map(lambda x: x[1][0], self.world.get_components(GameCharacter, Unemployed)) - ) - - engine = self.world.get_resource(NeighborlyEngine) date = self.world.get_resource(SimDateTime) - event_log = self.world.get_resource(LifeEventLog) + event_log = self.world.get_resource(EventLog) - for _, business in self.world.get_component(Business): + for _, (business, _, _, _) in self.world.get_components( + Business, OpenForBusiness, Building, Active + ): open_positions = business.get_open_positions() for position in open_positions: - if len(unemployed_characters) == 0: - break - character = engine.rng.choice(unemployed_characters) - character.gameobject.remove_component(Unemployed) - - business.add_employee(character.gameobject.id, position) - character.gameobject.add_component( - Occupation( - OccupationTypeLibrary.get(position), - business.gameobject.id, - ) - ) - unemployed_characters.remove(character) + result = OccupationTypes.get(position).fill_role(self.world, business) - event_log.record_event( - LifeEvent( - "HiredAtBusiness", - date.to_iso_str(), - roles=[ - EventRole("Business", business.gameobject.id), - EventRole("Employee", character.gameobject.id), - ], - position=position, - ) - ) - - -class SocializeSystem(ISystem): - """ - Every timestep, characters interact with other characters - at the same location. Characters meeting for the first time - form relationships based on the compatibility of their - personal values. - """ + if result: + candidate, occupation = result - __slots__ = "chance_of_interaction" - - def __init__(self, chance_of_interaction: float = 0.7) -> None: - super().__init__() - self.chance_of_interaction: float = chance_of_interaction - - @staticmethod - def get_compatibility(character_a: GameObject, character_b: GameObject) -> float: - """Return value [-1.0, 1.0] representing the compatibility of two characters""" - return PersonalValues.compatibility( - character_a.get_component(PersonalValues), - character_b.get_component(PersonalValues), - ) - - @staticmethod - def job_level_difference_romance_debuff( - character_a: GameObject, character_b: GameObject - ) -> float: - """ - This makes people with job-level differences less likely to develop romantic feelings - for one another (missing source) - """ - character_a_job = character_a.try_component(Occupation) - character_b_job = character_b.try_component(Occupation) - character_a_level = character_a_job.get_type().level if character_a_job else 0 - character_b_level = character_b_job.get_type().level if character_b_job else 0 - - return max( - 0.05, 1 - (abs(math.sqrt(character_a_level) - math.sqrt(character_b_level))) - ) - - @staticmethod - def age_difference_romance_debuff( - character_a: GameObject, character_b: GameObject - ) -> float: - """How does age difference affect developing romantic feelings - People with larger age gaps are less likely to develop romantic feelings - (missing source) - """ - character_a_age = character_a.get_component(GameCharacter).age - character_b_age = character_b.get_component(GameCharacter).age - return max( - 0.01, - 1 - (abs(math.sqrt(character_a_age) - math.sqrt(character_b_age)) / 1.5), - ) - - @staticmethod - def age_difference_friendship_debuff( - character_a: GameObject, character_b: GameObject - ) -> float: - """ - This makes people with large age differences more indifferent about potentially - becoming friends or enemies - """ - character_a_age = character_a.get_component(GameCharacter).age - character_b_age = character_b.get_component(GameCharacter).age - return max( - 0.05, - 1 - (abs(math.sqrt(character_a_age) - math.sqrt(character_b_age)) / 4.5), - ) - - @staticmethod - def job_level_difference_friendship_debuff( - character_a: GameObject, character_b: GameObject - ) -> float: - """This makes people with job-level differences more indifferent about potentially - becoming friends or enemies - """ - - character_a_job = character_a.try_component(Occupation) - character_b_job = character_b.try_component(Occupation) - character_a_level = character_a_job.get_type().level if character_a_job else 0 - character_b_level = character_b_job.get_type().level if character_b_job else 0 - - return max( - 0.05, 1 - (abs(math.sqrt(character_a_level) - math.sqrt(character_b_level))) - ) - - def process(self, *args, **kwargs) -> None: - - engine = self.world.get_resource(NeighborlyEngine) - rel_graph = self.world.get_resource(RelationshipGraph) - - for _, location in self.world.get_component(Location): - for character_id, other_character_id in itertools.combinations( - location.characters_present, 2 - ): - assert character_id != other_character_id - character = self.world.get_gameobject(character_id).get_component( - GameCharacter - ) - other_character = self.world.get_gameobject( - other_character_id - ).get_component(GameCharacter) - - if character.age < 3 or other_character.age < 3: - continue - - if engine.rng.random() >= self.chance_of_interaction: - continue - - if not rel_graph.has_connection( - character.gameobject.id, other_character.gameobject.id - ): - rel_graph.add_relationship( - Relationship( - character.gameobject.id, other_character.gameobject.id - ) - ) - - if not rel_graph.has_connection( - other_character.gameobject.id, character.gameobject.id - ): - rel_graph.add_relationship( - Relationship( - other_character.gameobject.id, character.gameobject.id - ) + start_job( + business, candidate.get_component(GameCharacter), occupation ) - compatibility: float = SocializeSystem.get_compatibility( - character.gameobject, other_character.gameobject - ) - - # This should be replaced with something more intelligent that allows - # for other decision-making systems to decide how to interact - friendship_score = engine.rng.randrange(-1, 1) - if (friendship_score < 0 and compatibility < 0) or ( - friendship_score > 0 and compatibility > 0 - ): - # negative social interaction should be buffed by negative compatibility - friendship_score *= 1 + abs(compatibility) - else: - # debuff the score if compatibility and friendship score signs differ - friendship_score *= 1 - abs(compatibility) - - friendship_score += ( - SocializeSystem.job_level_difference_friendship_debuff( - character.gameobject, other_character.gameobject - ) - ) - friendship_score += SocializeSystem.age_difference_friendship_debuff( - character.gameobject, other_character.gameobject - ) - - if ( - character.age < character.character_def.life_stages["teen"] - or other_character.age - < other_character.character_def.life_stages["teen"] - ): - romance_score = 0.0 - elif ( - rel_graph.get_connection(character_id, other_character_id).has_tags( - RelationshipTag.Parent - ) - or rel_graph.get_connection( - other_character_id, character_id - ).has_tags(RelationshipTag.Parent) - or rel_graph.get_connection( - character_id, other_character_id - ).has_tags(RelationshipTag.Sibling) - or rel_graph.get_connection( - other_character_id, character_id - ).has_tags(RelationshipTag.Sibling) - ): - romance_score = 0.0 - else: - romance_score = engine.rng.random() * compatibility - romance_score += ( - SocializeSystem.job_level_difference_romance_debuff( - character.gameobject, other_character.gameobject + event_log.record_event( + self.world, + Event( + "HiredAtBusiness", + date.to_iso_str(), + roles=[ + EventRole("Business", business.gameobject.id), + EventRole("Employee", candidate.id), + ], + position=position, ) ) - romance_score += SocializeSystem.age_difference_romance_debuff( - character.gameobject, other_character.gameobject - ) - if not rel_graph.has_connection(character_id, other_character_id): - rel_graph.add_relationship( - Relationship( - character_id, - other_character_id, - compatibility=compatibility, - ) - ) - rel_graph.get_connection( - character_id, other_character_id - ).increment_romance(romance_score) - rel_graph.get_connection( - character_id, other_character_id - ).increment_friendship(friendship_score) - - if not rel_graph.has_connection(other_character_id, character_id): - rel_graph.add_relationship( - Relationship( - other_character_id, - character_id, - compatibility=compatibility, - ), - ) - rel_graph.get_connection( - other_character_id, character_id - ).increment_romance(romance_score) - rel_graph.get_connection( - other_character_id, character_id - ).increment_friendship(friendship_score) +class BuildHousingSystem(System): + """ + Builds housing archetypes on unoccupied spaces on the land grid + Attributes + ---------- + chance_of_build: float + Probability that a new residential building will be built + if there is space available + """ -class BuildResidenceSystem(ISystem): - __slots__ = "chance_of_build", "interval", "next_trigger" + __slots__ = "chance_of_build" def __init__( - self, chance_of_build: float = 0.5, interval: TimeDelta = None + self, chance_of_build: float = 0.5, interval: Optional[TimeDelta] = None ) -> None: - super().__init__() + super().__init__(interval=interval) self.chance_of_build: float = chance_of_build - self.interval: TimeDelta = interval if interval else TimeDelta() - self.next_trigger: SimDateTime = SimDateTime() def choose_random_archetype( self, engine: NeighborlyEngine - ) -> Optional[ResidenceArchetype]: - archetype_choices: List[ResidenceArchetype] = [] + ) -> Optional[IResidenceArchetype]: + archetype_choices: List[IResidenceArchetype] = [] archetype_weights: List[int] = [] - for archetype in ResidenceArchetypeLibrary.get_all(): + for archetype in ResidenceArchetypes.get_all(): archetype_choices.append(archetype) - archetype_weights.append(archetype.spawn_multiplier) + archetype_weights.append(archetype.get_spawn_frequency()) if archetype_choices: # Choose an archetype at random - archetype: ResidenceArchetype = engine.rng.choices( + archetype: IResidenceArchetype = engine.rng.choices( population=archetype_choices, weights=archetype_weights, k=1 )[0] @@ -572,18 +414,10 @@ def choose_random_archetype( return None - def process(self, *args, **kwargs) -> None: + def run(self, *args, **kwargs) -> None: """Build a new residence when there is space""" - date = self.world.get_resource(SimDateTime) - - if date < self.next_trigger: - return - else: - self.next_trigger = date.copy() + self.interval - land_grid = self.world.get_resource(LandGrid) engine = self.world.get_resource(NeighborlyEngine) - event_log = self.world.get_resource(LifeEventLog) # Return early if the random-roll is not sufficient if engine.rng.random() > self.chance_of_build: @@ -593,8 +427,14 @@ def process(self, *args, **kwargs) -> None: if not land_grid.has_vacancy(): return + vacancies = land_grid.get_vacancies() + + # Don't build more housing if 60% of the land is used for residential buildings + if len(vacancies) / float(len(land_grid)) < 0.4: + return + # Pick a random lot from those available - lot = engine.rng.choice(land_grid.get_vacancies()) + lot = engine.rng.choice(vacancies) archetype = self.choose_random_archetype(engine) @@ -602,37 +442,40 @@ def process(self, *args, **kwargs) -> None: return None # Construct a random residence archetype - residence = self.world.spawn_archetype(archetype) + residence = archetype.create(self.world) # Reserve the space - land_grid.reserve_space(lot, residence.id) + land_grid[lot] = residence.id # Set the position of the residence residence.add_component(Position2D(lot[0], lot[1])) + residence.add_component(Building(building_type="residential")) + residence.add_component(Active()) + residence.add_component(Vacant()) + logger.debug(f"Built residential building ({residence.id})") - event_log.record_event( - LifeEvent( - "NewResidenceBuilt", - date.to_iso_str(), - roles=[EventRole("Residence", residence.id)], - ) - ) +class BuildBusinessSystem(System): + """ + Build a new business building at a random free space on the land grid. + + Attributes + ---------- + chance_of_build: float + The probability that a new business may be built this timestep + """ -class BuildBusinessSystem(ISystem): - __slots__ = "chance_of_build", "interval", "next_trigger" + __slots__ = "chance_of_build" def __init__( - self, chance_of_build: float = 0.5, interval: TimeDelta = None + self, chance_of_build: float = 0.5, interval: Optional[TimeDelta] = None ) -> None: - super().__init__() + super().__init__(interval) self.chance_of_build: float = chance_of_build - self.interval: TimeDelta = interval if interval else TimeDelta() - self.next_trigger: SimDateTime = SimDateTime() def choose_random_eligible_business( self, engine: NeighborlyEngine - ) -> Optional[BusinessArchetype]: + ) -> Optional[IBusinessArchetype]: """ Return all business archetypes that may be built given the state of the simulation @@ -640,21 +483,25 @@ def choose_random_eligible_business( town = self.world.get_resource(Town) date = self.world.get_resource(SimDateTime) - archetype_choices: List[BusinessArchetype] = [] + archetype_choices: List[IBusinessArchetype] = [] archetype_weights: List[int] = [] - for archetype in BusinessArchetypeLibrary.get_all(): + for archetype in BusinessArchetypes.get_all(): if ( - archetype.instances < archetype.max_instances - and town.population >= archetype.min_population - and archetype.year_available <= date.year < archetype.year_obsolete + 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_choices.append(archetype) - archetype_weights.append(archetype.spawn_multiplier) + archetype_weights.append(archetype.get_spawn_frequency()) if archetype_choices: # Choose an archetype at random - archetype: BusinessArchetype = engine.rng.choices( + archetype: IBusinessArchetype = engine.rng.choices( population=archetype_choices, weights=archetype_weights, k=1 )[0] @@ -662,18 +509,10 @@ def choose_random_eligible_business( return None - def process(self, *args, **kwargs) -> None: + def run(self, *args, **kwargs) -> None: """Build a new business when there is space""" - date = self.world.get_resource(SimDateTime) - - if date < self.next_trigger: - return - else: - self.next_trigger = date.copy() + self.interval - land_grid = self.world.get_resource(LandGrid) engine = self.world.get_resource(NeighborlyEngine) - event_log = self.world.get_resource(LifeEventLog) # Return early if the random-roll is not sufficient if engine.rng.random() > self.chance_of_build: @@ -693,311 +532,245 @@ def process(self, *args, **kwargs) -> None: return # Build a random business archetype - business = self.world.spawn_archetype(archetype) + business = archetype.create(self.world) # Reserve the space - land_grid.reserve_space(lot, business.id) + land_grid[lot] = business.id # Set the position of the residence business.get_component(Position2D).x = lot[0] business.get_component(Position2D).y = lot[1] - event_log.record_event( - LifeEvent( - "NewBusinessBuilt", - date.to_iso_str(), - roles=[EventRole("Business", business.id)], + # Give the business a building + business.add_component(Building(building_type="commercial")) + business.add_component(PendingOpening()) + business.add_component(Active()) + logger.debug( + "Built new business({}) {}".format( + business.id, str(business.get_component(Name)) ) ) -class SpawnResidentSystem(ISystem): +class SpawnResidentSystem(System): """Adds new characters to the simulation""" - __slots__ = "chance_spawn", "chance_married", "max_kids", "interval", "next_trigger" + __slots__ = "chance_spawn" def __init__( self, chance_spawn: float = 0.5, - chance_married: float = 0.5, - max_kids: int = 3, - interval: TimeDelta = None, + interval: Optional[TimeDelta] = None, ) -> None: - super().__init__() + super().__init__(interval=interval) self.chance_spawn: float = chance_spawn - self.chance_married: float = chance_married - self.max_kids: int = max_kids - self.interval: TimeDelta = interval if interval else TimeDelta() - self.next_trigger: SimDateTime = SimDateTime() - - def choose_random_character_archetype(self, engine: NeighborlyEngine): - archetype_choices: List[CharacterArchetype] = [] - archetype_weights: List[int] = [] - for archetype in CharacterArchetypeLibrary.get_all(): - archetype_choices.append(archetype) - archetype_weights.append(archetype.spawn_multiplier) - - if archetype_choices: - # Choose an archetype at random - archetype: CharacterArchetype = engine.rng.choices( - population=archetype_choices, weights=archetype_weights, k=1 - )[0] - return archetype - else: - return None - - def process(self, *args, **kwargs) -> None: + def run(self, *args, **kwargs) -> None: date = self.world.get_resource(SimDateTime) - - if date < self.next_trigger: - return - else: - self.next_trigger = date.copy() + self.interval - town = self.world.get_resource(Town) engine = self.world.get_resource(NeighborlyEngine) - rel_graph = self.world.get_resource(RelationshipGraph) - event_logger = self.world.get_resource(LifeEventLog) - - for _, residence in self.world.get_component(Residence): - # Skip occupied residences - if not residence.is_vacant(): - continue + event_logger = self.world.get_resource(EventLog) + for _, (residence, _, _, _) in self.world.get_components( + Residence, Building, Active, Vacant + ): # Return early if the random-roll is not sufficient if engine.rng.random() > self.chance_spawn: return - archetype = self.choose_random_character_archetype(engine) + archetype = choose_random_character_archetype(engine) + # There are no archetypes available to spawn if archetype is None: return - # Create a new character - character = generate_young_adult_character(self.world, engine, archetype) - residence.add_owner(character.id) - town.population += 1 + # Create a new entity using the archetype + character = archetype.create(self.world, life_stage="young_adult") - move_residence(character.get_component(GameCharacter), residence) - move_to_location( - self.world, - character.get_component(GameCharacter), - residence.gameobject.get_component(Location), - ) + add_residence_owner(character, residence.gameobject) + move_residence(character, residence.gameobject) + move_to_location(self.world, character, residence.gameobject.id) + town.increment_population() spouse: Optional[GameObject] = None - # Potentially generate a spouse for this character - if engine.rng.random() < self.chance_married: - spouse = generate_young_adult_character(self.world, engine, archetype) - spouse.get_component( - GameCharacter - ).name.surname = character.get_component(GameCharacter).name.surname - residence.add_owner(spouse.id) - town.population += 1 - - move_residence(spouse.get_component(GameCharacter), residence) - move_to_location( - self.world, - spouse.get_component(GameCharacter), - residence.gameobject.get_component(Location), - ) - - character.add_component( - Married( - partner_id=spouse.id, - partner_name=str(spouse.get_component(GameCharacter).name), - ) + # Potentially generate a spouse for this entity + if engine.rng.random() < archetype.get_chance_spawn_with_spouse(): + # Create another character + spouse = archetype.create(self.world, life_stage="young_adult") + + # Match the last names since they are supposed to be married + spouse.get_component(CharacterName).surname = character.get_component( + CharacterName + ).surname + + # Move them into the home with the first character + add_residence_owner(spouse, residence.gameobject) + move_residence(spouse, residence.gameobject) + move_to_location(self.world, spouse, residence.gameobject.id) + town.increment_population() + + # Configure relationship from character to spouse + character.get_component(Relationships).get(spouse.id).add_tags("Spouse") + character.get_component(Relationships).get(spouse.id).romance.increase( + 45 ) - - spouse.add_component( - Married( - partner_id=character.id, - partner_name=str(character.get_component(GameCharacter).name), - ) - ) - - # character to spouse - rel_graph.add_relationship( - Relationship( - character.id, - spouse.id, - base_friendship=30, - base_romance=50, - tags=( - RelationshipTag.Friend - | RelationshipTag.Spouse - | RelationshipTag.SignificantOther - ), - ) - ) - - # spouse to character - rel_graph.add_relationship( - Relationship( - spouse.id, - character.id, - base_friendship=30, - base_romance=50, - tags=( - RelationshipTag.Friend - | RelationshipTag.Spouse - | RelationshipTag.SignificantOther - ), - ) + character.get_component(Relationships).get( + spouse.id + ).friendship.increase(30) + + # Configure relationship from spouse to character + spouse.get_component(Relationships).get(character.id).add_tags("Spouse") + spouse.get_component(Relationships).get(character.id).romance.increase( + 45 ) + spouse.get_component(Relationships).get( + character.id + ).friendship.increase(30) # Note: Characters can spawn as single parents with kids - num_kids = engine.rng.randint(0, self.max_kids) + num_kids = engine.rng.randint(0, archetype.get_max_children_at_spawn()) children: List[GameObject] = [] for _ in range(num_kids): - child = generate_child_character(self.world, engine, archetype) - child.get_component( - GameCharacter - ).name.surname = character.get_component(GameCharacter).name.surname - town.population += 1 + child = archetype.create(self.world, life_stage="child") + + # Match the last names since they are supposed to be married + spouse.get_component(CharacterName).surname = character.get_component( + CharacterName + ).surname + + # Move them into the home with the first character + move_residence(child, residence.gameobject) + move_to_location(self.world, child, residence.gameobject.id) + town.increment_population() - move_residence(child.get_component(GameCharacter), residence) - move_to_location( - self.world, - child.get_component(GameCharacter), - residence.gameobject.get_component(Location), - ) children.append(child) - # child to character - rel_graph.add_relationship( - Relationship( - child.id, - character.id, - base_friendship=20, - tags=RelationshipTag.Parent, - ) - ) + # Relationship of child to character + child.get_component(Relationships).get(character.id).add_tags("Parent") + child.get_component(Relationships).get(character.id).add_tags("Family") + child.get_component(Relationships).get( + character.id + ).friendship.increase(20) - # character to child - rel_graph.add_relationship( - Relationship( - character.id, - child.id, - base_friendship=20, - tags=RelationshipTag.Child, - ) - ) + # Relationship of character to child + character.get_component(Relationships).get(child.id).add_tags("Child") + character.get_component(Relationships).get(child.id).add_tags("Family") + character.get_component(Relationships).get( + child.id + ).friendship.increase(20) if spouse: - # child to spouse - rel_graph.add_relationship( - Relationship( - child.id, - spouse.id, - base_friendship=20, - tags=RelationshipTag.Parent, - ) - ) - - # spouse to child - rel_graph.add_relationship( - Relationship( - spouse.id, - child.id, - base_friendship=20, - tags=RelationshipTag.Child, - ) - ) + # Relationship of child to spouse + child.get_component(Relationships).get(spouse.id).add_tags("Parent") + child.get_component(Relationships).get(spouse.id).add_tags("Family") + child.get_component(Relationships).get( + spouse.id + ).friendship.increase(20) + + # Relationship of spouse to child + spouse.get_component(Relationships).get(child.id).add_tags("Child") + spouse.get_component(Relationships).get(child.id).add_tags("Family") + spouse.get_component(Relationships).get( + child.id + ).friendship.increase(20) for sibling in children: - # child to sibling - rel_graph.add_relationship( - Relationship( - child.id, - sibling.id, - base_friendship=20, - tags=RelationshipTag.Sibling, - ) + # Relationship of child to sibling + child.get_component(Relationships).get(sibling.id).add_tags( + "Sibling" + ) + child.get_component(Relationships).get(sibling.id).add_tags( + "Family" ) + child.get_component(Relationships).get( + sibling.id + ).friendship.increase(20) - # sibling to child - rel_graph.add_relationship( - Relationship( - sibling.id, - child.id, - base_friendship=20, - tags=RelationshipTag.Sibling, - ) + # Relationship of sibling to child + sibling.get_component(Relationships).get(child.id).add_tags( + "Sibling" ) + sibling.get_component(Relationships).get(child.id).add_tags( + "Family" + ) + sibling.get_component(Relationships).get( + child.id + ).friendship.increase(20) # Record a life event event_logger.record_event( - LifeEvent( + self.world, + Event( name="MoveIntoTown", timestamp=date.to_iso_str(), roles=[ - EventRole("resident", r.id) - for r in [character, spouse, *children] - if r is not None + EventRole("Residence", residence.gameobject.id), + *[ + EventRole("Resident", r.id) + for r in [character, spouse, *children] + if r is not None + ], ], ) ) -class BusinessUpdateSystem(ISystem): - def process(self, *args, **kwargs) -> None: - time = self.world.get_resource(SimDateTime) - rng = self.world.get_resource(NeighborlyEngine).rng - for _, business in self.world.get_component(Business): - if business.status == BusinessStatus.OpenForBusiness: - # Increment the age of the business - business.increment_years_in_business(time.delta_time / HOURS_PER_YEAR) - - # Check if this business is going to close - if rng.random() < 0.3: - # Go Out of business - business.set_business_status(BusinessStatus.ClosedForBusiness) - business.owner = None - for employee in business.get_employees(): - business.remove_employee(employee) - self.world.get_gameobject(employee).remove_component(Occupation) - business.gameobject.archive() - - # Attempt to hire characters for open job positions - for position in business.get_open_positions(): - OccupationTypeLibrary.get(position) - - -class CharacterAgingSystem(ISystem): +class BusinessUpdateSystem(System): + @staticmethod + def is_within_operating_hours(current_hour: int, hours: Tuple[int, int]) -> bool: + """Return True if the given hour is within the hours 24-hour time interval""" + start, end = hours + if start <= end: + return start <= current_hour <= end + else: + # The time interval overflows to the next day + return current_hour <= end or current_hour >= start + + def run(self, *args, **kwargs) -> None: + date = self.world.get_resource(SimDateTime) + + for _, (business, _, _) in self.world.get_components( + Business, Building, OpenForBusiness + ): + # Open/Close based on operating hours + if business.operating_hours.get(Weekday[date.weekday_str]) is not None: + business.gameobject.add_component(OpenToPublic()) + + +class CharacterAgingSystem(System): """ Updates the ages of characters, adds/removes life stage components (Adult, Child, Elder, ...), and - handles character deaths. + handles entity deaths. Notes ----- This system runs every time step """ - def process(self, *args, **kwargs) -> None: - date_time = self.world.get_resource(SimDateTime) - engine = self.world.get_resource(NeighborlyEngine) - event_log = self.world.get_resource(LifeEventLog) + def run(self, *args, **kwargs) -> None: + current_date = self.world.get_resource(SimDateTime) + event_log = self.world.get_resource(EventLog) - for _, character in self.world.get_component(GameCharacter): - if character.gameobject.has_component(Deceased): - continue + age_increment = float(self.elapsed_time.total_hours) / HOURS_PER_YEAR - character.age += float(date_time.delta_time) / HOURS_PER_YEAR + for _, (character, age, life_stages, _, _) in self.world.get_components( + GameCharacter, Age, LifeStages, CanAge, Active + ): + age.value += age_increment if ( character.gameobject.has_component(Child) - and character.age >= character.character_def.life_stages["teen"] + and age.value >= life_stages.stages["teen"] ): - character.gameobject.remove_component(Child) character.gameobject.add_component(Teen()) + character.gameobject.remove_component(Child) event_log.record_event( - LifeEvent( + self.world, + Event( name="BecomeTeen", - timestamp=date_time.to_iso_str(), + timestamp=current_date.to_iso_str(), roles=[ EventRole("Character", character.gameobject.id), ], @@ -1006,7 +779,7 @@ def process(self, *args, **kwargs) -> None: elif ( character.gameobject.has_component(Teen) - and character.age >= character.character_def.life_stages["young_adult"] + and age.value >= life_stages.stages["young_adult"] ): character.gameobject.remove_component(Teen) character.gameobject.add_component(YoungAdult()) @@ -1017,9 +790,10 @@ def process(self, *args, **kwargs) -> None: character.gameobject.add_component(Unemployed()) event_log.record_event( - LifeEvent( + self.world, + Event( name="BecomeYoungAdult", - timestamp=date_time.to_iso_str(), + timestamp=current_date.to_iso_str(), roles=[ EventRole("Character", character.gameobject.id), ], @@ -1028,13 +802,14 @@ def process(self, *args, **kwargs) -> None: elif ( character.gameobject.has_component(YoungAdult) - and character.age >= character.character_def.life_stages["adult"] + and age.value >= life_stages.stages["adult"] ): character.gameobject.remove_component(YoungAdult) event_log.record_event( - LifeEvent( + self.world, + Event( name="BecomeAdult", - timestamp=date_time.to_iso_str(), + timestamp=current_date.to_iso_str(), roles=[ EventRole("Character", character.gameobject.id), ], @@ -1044,142 +819,138 @@ def process(self, *args, **kwargs) -> None: elif ( character.gameobject.has_component(Adult) and not character.gameobject.has_component(Elder) - and character.age >= character.character_def.life_stages["elder"] + and age.value >= life_stages.stages["elder"] ): character.gameobject.add_component(Elder()) event_log.record_event( - LifeEvent( + self.world, + Event( name="BecomeElder", - timestamp=date_time.to_iso_str(), + timestamp=current_date.to_iso_str(), roles=[ EventRole("Character", character.gameobject.id), ], ) ) - if ( - character.age >= character.character_def.lifespan - and engine.rng.random() < 0.8 - ): - character.gameobject.add_component(Deceased()) - event_log.record_event( - LifeEvent( - name="Death", - timestamp=date_time.to_iso_str(), - roles=[ - EventRole("Character", character.gameobject.id), - ], - ) - ) - - # Archive GameObject instead of removing it - character.gameobject.archive() - class PregnancySystem(ISystem): """ Pregnancy system is responsible for managing ChildBirth events. - It checks if the current date is after pregnant characters' - due dates, and triggers childbirths if it is. + It checks if the current date is after a pregnant entity's + due date, and triggers a childbirth if it is. """ def process(self, *args, **kwargs): current_date = self.world.get_resource(SimDateTime) - rel_graph = self.world.get_resource(RelationshipGraph) - event_logger = self.world.get_resource(LifeEventLog) + event_logger = self.world.get_resource(EventLog) + town = self.world.get_resource(Town) - for _, (character, pregnancy) in self.world.get_components( - GameCharacter, Pregnant + for _, (character, pregnancy, _) in self.world.get_components( + GameCharacter, Pregnant, Active ): - if current_date > pregnancy.due_date: - continue + # Cast for the type-checker + character = cast(GameCharacter, character) + pregnancy = cast(Pregnant, pregnancy) - baby = self.world.spawn_archetype(character.gameobject.archetype) + birthing_parent = character.gameobject + other_parent = self.world.get_gameobject(pregnancy.partner_id) - baby.get_component(GameCharacter).age = ( - current_date - pregnancy.due_date - ).hours / HOURS_PER_YEAR + birthing_parent_name = character.gameobject.get_component(CharacterName) - baby.get_component(GameCharacter).name = CharacterName( - baby.get_component(GameCharacter).name.firstname, character.name.surname - ) + if current_date >= pregnancy.due_date: + continue - move_to_location( + baby = generate_child( self.world, - baby.get_component(GameCharacter), - self.world.get_gameobject(character.location).get_component(Location), + character.gameobject, + self.world.get_gameobject(pregnancy.partner_id), ) - # Birthing parent to child - rel_graph.add_relationship( - Relationship( - character.gameobject.id, - baby.id, - tags=RelationshipTag.Child, + town.increment_population() + + baby.get_component(Age).value = ( + 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( + self.world, + baby, + current_location.location, + ) + + baby.add_component(LocationAliases()) + + move_residence( + baby, + self.world.get_gameobject( + character.gameobject.get_component(Resident).residence + ), ) + + # Birthing parent to child + character.gameobject.get_component(Relationships).get(baby.id).add_tags( + "Child" ) # Child to birthing parent - rel_graph.add_relationship( - Relationship( - baby.id, - character.gameobject.id, - tags=RelationshipTag.Parent, - ), + baby.get_component(Relationships).get(character.gameobject.id).add_tags( + "Parent" ) # Other parent to child - rel_graph.add_relationship( - Relationship( - pregnancy.partner_id, - baby.id, - tags=RelationshipTag.Child, - ) - ) + other_parent.get_component(Relationships).get(baby.id).add_tags("Child") + other_parent.get_component(Relationships).get(baby.id).add_tags("Family") # Child to other parent - rel_graph.add_relationship( - Relationship( - baby.id, - pregnancy.partner_id, - tags=RelationshipTag.Parent, - ), - ) + baby.get_component(Relationships).get(other_parent.id).add_tags("Parent") + baby.get_component(Relationships).get(other_parent.id).add_tags("Family") # Create relationships with children of birthing parent - for rel in rel_graph.get_all_relationships_with_tags( - character.gameobject.id, RelationshipTag.Child + for rel in birthing_parent.get_component(Relationships).get_all_with_tags( + "Child" ): if rel.target == baby.id: continue + # Baby to sibling - rel_graph.add_relationship( - Relationship(baby.id, rel.target, tags=RelationshipTag.Sibling) - ) + baby.get_component(Relationships).get(rel.target).add_tags("Sibling") + baby.get_component(Relationships).get(rel.target).add_tags("Family") # Sibling to baby - rel_graph.add_relationship( - Relationship(rel.target, baby.id, tags=RelationshipTag.Sibling) - ) + self.world.get_gameobject(rel.target).get_component(Relationships).get( + baby.id + ).add_tags("Sibling") + self.world.get_gameobject(rel.target).get_component(Relationships).get( + baby.id + ).add_tags("Family") # Create relationships with children of other parent - for rel in rel_graph.get_all_relationships_with_tags( - pregnancy.partner_id, RelationshipTag.Child + for rel in other_parent.get_component(Relationships).get_all_with_tags( + "Child" ): if rel.target == baby.id: continue + + sibling = self.world.get_gameobject(rel.target) + # Baby to sibling - rel_graph.add_relationship( - Relationship(baby.id, rel.target, tags=RelationshipTag.Sibling) - ) + baby.get_component(Relationships).get(rel.target).add_tags("Sibling") + # Sibling to baby - rel_graph.add_relationship( - Relationship(rel.target, baby.id, tags=RelationshipTag.Sibling) - ) + sibling.get_component(Relationships).get(baby.id).add_tags("Sibling") # Pregnancy event dates are retconned to be the actual date that the # child was due. event_logger.record_event( - LifeEvent( + self.world, + Event( name="ChildBirth", timestamp=pregnancy.due_date.to_iso_str(), roles=[ @@ -1190,10 +961,143 @@ def process(self, *args, **kwargs): ) ) - character.gameobject.remove_component(Pregnant) - # character.gameobject.remove_component(InTheWorkforce) + birthing_parent.remove_component(Pregnant) + # birthing_parent.remove_component(InTheWorkforce) + # + # if birthing_parent.has_component(Occupation): + # leave_job(self.world, birthing_parent) + + +class PendingOpeningSystem(System): + """ + Tracks how long a business has been present in the town, but not + officially open. If a business stays in the pending state for longer + than a given amount of time, it will go out of business + + Attributes + ---------- + days_before_demolishing: int + The number of days that a business can be in the pending state before it is + demolished. + """ + + __slots__ = "days_before_demolishing" + + def __init__(self, days_before_demolishing: int = 60) -> None: + super().__init__() + self.days_before_demolishing: int = days_before_demolishing + + def run(self, *args, **kwargs): + for gid, pending_opening in self.world.get_component(PendingOpening): + pending_opening.duration += self.elapsed_time.total_hours / HOURS_PER_DAY + + if pending_opening.duration >= self.days_before_demolishing: + pending_opening.gameobject.remove_component(PendingOpening) + pending_opening.gameobject.add_component(ClosedForBusiness()) + logger.debug( + "{} has closed for business after never finding an owner.".format( + str(pending_opening.gameobject.get_component(Name)) + ) + ) + + +class OpenForBusinessSystem(System): + """ + Tracks how long a business has been active + """ + + def run(self, *args, **kwargs): + for _, (open_for_business, age, _) in self.world.get_components( + OpenForBusiness, Age, Active + ): + # Increment the amount of time that the business has been open + age.value += self.elapsed_time.total_hours / HOURS_PER_DAY + + +class ClosedForBusinessSystem(ISystem): + """ + This system is responsible for removing Businesses from play that are marked as + ClosedForBusiness. It removes the building from play, keeps the GameObject for + sifting purposes, and displaces the characters within the building. + """ + + def process(self, *args, **kwargs): + for gid, (out_of_business, location, business, _) in self.world.get_components( + ClosedForBusiness, Location, Business, Active + ): + # Send all the entities here somewhere else + for entity_id in location.entities: + entity = self.world.get_gameobject(entity_id) + + if entity.has_component(GameCharacter): + # Send all the characters that are present back to their homes + move_to_location( + self.world, + entity, + None, + ) + else: + # Delete everything that is not a character + # assume that it will get lost in the demolition + self.world.delete_gameobject(entity_id) + + # Fire all the employees and owner + for employee_id in business.get_employees(): + layoff_employee(business, self.world.get_gameobject(employee_id)) + + # Free up the space on the board + demolish_building(out_of_business.gameobject) + + business.gameobject.remove_component(Active) + + +class RemoveDeceasedFromResidences(ISystem): + def process(self, *args, **kwargs): + for gid, (deceased, resident) in self.world.get_components(Deceased, Resident): + residence = self.world.get_gameobject(resident.residence) + move_out_of_residence(resident.gameobject, residence) + if residence.get_component(Residence).is_owner(gid): + remove_residence_owner(deceased.gameobject, residence) + + +class RemoveDeceasedFromOccupation(ISystem): + def process(self, *args, **kwargs): + for gid, (deceased, occupation) in self.world.get_components( + Deceased, Occupation + ): + business = self.world.get_gameobject(occupation.business).get_component( + Business + ) + + end_job(business, deceased.gameobject, occupation) - # if character.gameobject.has_component(Occupation): - # character.gameobject.remove_component(Occupation) - # TODO: Birthing parent should also leave their job +class RemoveDepartedFromResidences(ISystem): + def process(self, *args, **kwargs): + for gid, (departed, resident) in self.world.get_components(Departed, Resident): + residence = self.world.get_gameobject(resident.residence) + move_out_of_residence(resident.gameobject, residence) + if residence.get_component(Residence).is_owner(gid): + remove_residence_owner(departed.gameobject, residence) + + +class RemoveDepartedFromOccupation(ISystem): + def process(self, *args, **kwargs): + for gid, (departed, occupation) in self.world.get_components( + Departed, Occupation + ): + business = self.world.get_gameobject(occupation.business).get_component( + Business + ) + end_job(business, departed.gameobject, occupation) + + +class RemoveRetiredFromOccupation(ISystem): + def process(self, *args, **kwargs): + for gid, (departed, occupation) in self.world.get_components( + Retired, Occupation + ): + business = self.world.get_gameobject(occupation.business).get_component( + Business + ) + end_job(business, departed.gameobject, occupation) 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/activity.py b/src/neighborly/core/activity.py deleted file mode 100644 index 7786bbe..0000000 --- a/src/neighborly/core/activity.py +++ /dev/null @@ -1,64 +0,0 @@ -from __future__ import annotations - -from typing import Dict, List, Tuple - -import numpy as np - -from neighborly.core.personal_values import PersonalValues - - -class Activity: - """Activities that a character can do at a location in the town - - Attributes - ---------- - name: str - The name of the activity - trait_names: Tuple[str, ...] - Character values that associated with this activity - personal_values: PersonalValues - The list of trait_names encoded as a vector of 0's and 1's - for non-applicable and applicable character values respectively. - """ - - __slots__ = "name", "trait_names", "personal_values" - - def __init__(self, name: str, trait_names: List[str]) -> None: - self.name: str = name - self.trait_names: List[str] = trait_names - self.personal_values: np.array = PersonalValues( - {name: 1 for name in self.trait_names}, default=0 - ).traits - - -class ActivityLibrary: - """ - Stores information about what kind of activities carious locations - are known for. Activities help characters make decisions about - where they want to travel when they have free time. - """ - - _activity_registry: Dict[str, Activity] = {} - _activity_flags: Dict[str, int] = {} - - @classmethod - def add(cls, activity: Activity) -> None: - """Registers an activity instance for use in other places""" - next_flag = 1 << len(cls._activity_registry.keys()) - cls._activity_registry[activity.name] = activity - cls._activity_flags[activity.name] = next_flag - - @classmethod - def get_flags(cls, *activities: str) -> Tuple[int, ...]: - """Return flags corresponding to given activities""" - return tuple([cls._activity_flags[activity] for activity in activities]) - - @classmethod - def get(cls, activity: str) -> Activity: - """Return Activity instance corresponding to a given string""" - return cls._activity_registry[activity] - - @classmethod - def get_all(cls) -> List[Activity]: - """Return all activity instances in the registry""" - return list(cls._activity_registry.values()) 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 1ff35af..b8d7339 100644 --- a/src/neighborly/core/archetypes.py +++ b/src/neighborly/core/archetypes.py @@ -1,83 +1,256 @@ from __future__ import annotations -from typing import Any, Dict, List, Optional, Type - -from neighborly.core.business import Business, BusinessService, logger -from neighborly.core.character import GameCharacter, LifeStages -from neighborly.core.ecs import Component, EntityArchetype +import logging +from abc import ABC, abstractmethod +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, + Services, + ServiceType, + ServiceTypes, + WorkHistory, + parse_operating_hour_str, +) +from neighborly.core.character import CharacterName, GameCharacter +from neighborly.core.ecs import Component, GameObject, World +from neighborly.core.engine import NeighborlyEngine from neighborly.core.location import Location from neighborly.core.personal_values import PersonalValues from neighborly.core.position import Position2D +from neighborly.core.relationship import Relationships from neighborly.core.residence import Residence from neighborly.core.routine import Routine +logger = logging.getLogger(__name__) -class CharacterArchetype(EntityArchetype): - """ - Archetype subclass for building characters - """ - __slots__ = "name_format", "spawn_multiplier" +class CharacterArchetypes: + """Static class that manages factories that create character archetypes""" - def __init__( - self, - name: str, - lifespan: int, - life_stages: LifeStages, - name_format: str = "#first_name# #family_name#", - chance_can_get_pregnant: float = 0.5, - spawn_multiplier: int = 1, - extra_components: Dict[Type[Component], Dict[str, Any]] = None, - ) -> None: - super().__init__(name) - self.name_format: str = name_format - self.spawn_multiplier: int = spawn_multiplier - - self.add( - GameCharacter, - character_type=name, - name_format=name_format, - lifespan=lifespan, - life_stages=life_stages, - chance_can_get_pregnant=chance_can_get_pregnant, - ) + _registry: Dict[str, ICharacterArchetype] = {} - self.add(Routine) - self.add(PersonalValues) + @classmethod + def add(cls, name: str, archetype: ICharacterArchetype) -> None: + """Register a new archetype by name""" + if name in cls._registry: + logger.debug(f"Overwrote ICharacterPrefab: ({name})") + cls._registry[name] = archetype - if extra_components: - for component_type, params in extra_components.items(): - self.add(component_type, **params) + @classmethod + def get_all(cls) -> List[ICharacterArchetype]: + """Get all stored archetypes""" + return list(cls._registry.values()) + @classmethod + def get(cls, name: str) -> ICharacterArchetype: + """Get an archetype by name""" + try: + return cls._registry[name] + except KeyError: + raise ArchetypeNotFoundError(name) + + +class BusinessArchetypes: + """Static class that manages factories that create business archetypes""" -class CharacterArchetypeLibrary: - _registry: Dict[str, CharacterArchetype] = {} + _registry: Dict[str, IBusinessArchetype] = {} @classmethod - def add(cls, archetype: CharacterArchetype, name: Optional[str] = None) -> None: - """Register a new LifeEventType mapped to a name""" - cls._registry[name if name else archetype.name] = archetype + def add(cls, name: str, archetype: IBusinessArchetype) -> None: + """Register a new archetype by name""" + if name in cls._registry: + logger.debug(f"Overwrote Business Archetype: ({name})") + cls._registry[name] = archetype @classmethod - def get_all(cls) -> List[CharacterArchetype]: + def get_all(cls) -> List[IBusinessArchetype]: + """Get all stored archetypes""" return list(cls._registry.values()) @classmethod - def get(cls, name: str) -> CharacterArchetype: - """Get a LifeEventType using a name""" + def get(cls, name: str) -> IBusinessArchetype: + """Get an archetype by name""" try: return cls._registry[name] except KeyError: raise ArchetypeNotFoundError(name) -class BusinessArchetype(EntityArchetype): +class ResidenceArchetypes: + _registry: Dict[str, IResidenceArchetype] = {} + + @classmethod + def add( + cls, + name: str, + archetype: IResidenceArchetype, + ) -> None: + """Register a new archetype by name""" + if name in cls._registry: + logger.debug(f"Overwrote Residence Archetype: ({name})") + cls._registry[name] = archetype + + @classmethod + def get_all(cls) -> List[IResidenceArchetype]: + """Get all stored archetypes""" + return list(cls._registry.values()) + + @classmethod + def get(cls, name: str) -> IResidenceArchetype: + """Get an archetype by name""" + try: + return cls._registry[name] + except KeyError: + raise ArchetypeNotFoundError(name) + + +class ArchetypeNotFoundError(Exception): + """Error thrown when an archetype is not found in the engine""" + + def __init__(self, archetype_name: str) -> None: + super().__init__() + self.archetype_name: str = archetype_name + self.message: str = f"Could not find archetype with name '{archetype_name}'" + + def __str__(self) -> str: + return self.message + + +class IEntityArchetype(ABC): + @abstractmethod + def get_spawn_frequency(self) -> int: + """Return the relative frequency that this prefab appears""" + raise NotImplementedError + + @abstractmethod + def create(self, world: World, **kwargs) -> GameObject: + """Create a new instance of this prefab""" + raise NotImplementedError + + +class ICharacterArchetype(IEntityArchetype): + """Interface for archetypes that construct characters""" + + @abstractmethod + def get_max_children_at_spawn(self) -> int: + """Return the maximum amount of children this prefab can have when spawning""" + raise NotImplementedError + + @abstractmethod + def get_chance_spawn_with_spouse(self) -> float: + """Return the chance that a character from this prefab spawns with a spouse""" + raise NotImplementedError + + +class BaseCharacterArchetype(ICharacterArchetype): + """Base factory class for constructing new characters""" + + __slots__ = ( + "spawn_frequency", + "chance_spawn_with_spouse", + "max_children_at_spawn", + ) + + def __init__( + self, + spawn_frequency: int = 1, + chance_spawn_with_spouse: float = 0.5, + max_children_at_spawn: int = 0, + ) -> None: + self.spawn_frequency: int = spawn_frequency + self.max_children_at_spawn: int = max_children_at_spawn + self.chance_spawn_with_spouse: float = chance_spawn_with_spouse + + def get_spawn_frequency(self) -> int: + return self.spawn_frequency + + def get_max_children_at_spawn(self) -> int: + """Return the maximum amount of children this prefab can have when spawning""" + return self.max_children_at_spawn + + def get_chance_spawn_with_spouse(self) -> float: + """Return the chance that a character from this prefab spawns with a spouse""" + return self.chance_spawn_with_spouse + + def create(self, world: World, **kwargs) -> GameObject: + # Perform calculations first and return the base character GameObject + return world.spawn_gameobject( + [ + Active(), + GameCharacter(), + Routine(), + Age(), + CharacterName("First", "Last"), + WorkHistory(), + LifeStages( + { + "child": 0, + "teen": 13, + "young_adult": 18, + "adult": 30, + "elder": 65, + } + ), + PersonalValues.create(world), + Relationships(), + MovementAI(DefaultMovementModule()) + ] + ) + + +class IBusinessArchetype(IEntityArchetype): + """Interface for archetypes that construct businesses""" + + @abstractmethod + def get_business_type(self) -> Type[IBusinessType]: + """Get the IBusiness Type that the archetype constructs""" + raise NotImplementedError + + @abstractmethod + def get_min_population(self) -> int: + """Return the minimum population needed for this business to be constructed""" + raise NotImplementedError + + @abstractmethod + def get_year_available(self) -> int: + """Return the year that this business is available to construct""" + raise NotImplementedError + + @abstractmethod + def get_year_obsolete(self) -> int: + """Return the year that this business is no longer available to construct""" + raise NotImplementedError + + @abstractmethod + def get_instances(self) -> int: + """Get the number of active instances of this archetype""" + raise NotImplementedError + + @abstractmethod + def set_instances(self, value: int) -> None: + """Set the number of active instances of this archetype""" + raise NotImplementedError + + @abstractmethod + def get_max_instances(self) -> int: + """Return the maximum instances of this prefab that may exist""" + raise NotImplementedError + + +class BaseBusinessArchetype(IBusinessArchetype): """ Shared information about all businesses that have this type """ __slots__ = ( + "business_type", "hours", "name_format", "owner_type", @@ -85,141 +258,135 @@ class BusinessArchetype(EntityArchetype): "min_population", "employee_types", "services", - "service_flags", - "spawn_multiplier", + "spawn_frequency", "year_available", "year_obsolete", + "instances", ) def __init__( self, - name: str, - hours: List[str] = None, - name_format: str = None, - owner_type: str = None, + business_type: Type[IBusinessType], + name_format: str, + hours: str = "day", + owner_type: Optional[str] = None, max_instances: int = 9999, min_population: int = 0, - employee_types: Dict[str, int] = None, - services: List[str] = None, - spawn_multiplier: int = 1, - extra_components: Dict[Type[Component], Dict[str, Any]] = None, + employee_types: Optional[Dict[str, int]] = None, + services: Optional[List[str]] = None, + spawn_frequency: int = 1, year_available: int = -1, year_obsolete: int = 9999, + average_lifespan: int = 20, ) -> None: - super().__init__(name) - self.hours: List[str] = hours if hours else ["day"] - self.name_format: str = name_format if name_format else name + self.business_type: Type[IBusinessType] = business_type + self.hours: str = hours + self.name_format: str = name_format self.owner_type: Optional[str] = owner_type self.max_instances: int = max_instances self.min_population: int = min_population self.employee_types: Dict[str, int] = employee_types if employee_types else {} - self.services: List[str] = services if services else {} - self.service_flags: BusinessService = BusinessService.NONE - self.spawn_multiplier: int = spawn_multiplier - for service_name in self.services: - self.service_flags |= BusinessService[ - service_name.strip().upper().replace(" ", "_") - ] + self.services: List[str] = services if services else [] + self.spawn_frequency: int = spawn_frequency self.year_available: int = year_available self.year_obsolete: int = year_obsolete - - self.add( - Business, - business_type=self.name, - name_format=self.name_format, - hours=self.hours, - owner_type=self.owner_type, - employee_types=self.employee_types, - services=self.service_flags, + self.instances: int = 0 + self.average_lifespan: int = average_lifespan + + def get_spawn_frequency(self) -> int: + """Return the relative frequency that this prefab appears""" + return self.spawn_frequency + + def get_min_population(self) -> int: + """Return the minimum population needed for this business to be constructed""" + return self.min_population + + def get_year_available(self) -> int: + """Return the year that this business is available to construct""" + return self.year_available + + def get_year_obsolete(self) -> int: + """Return the year that this business is no longer available to construct""" + return self.year_obsolete + + def get_business_type(self) -> Type[IBusinessType]: + return self.business_type + + def get_instances(self) -> int: + return self.instances + + def set_instances(self, value: int) -> None: + self.instances = value + + def get_max_instances(self) -> int: + return self.max_instances + + def create(self, world: World, **kwargs) -> GameObject: + engine = world.get_resource(NeighborlyEngine) + + services: Set[ServiceType] = set() + + for service in self.services: + services.add(ServiceTypes.get(service)) + + return world.spawn_gameobject( + [ + self.business_type(), + Business( + operating_hours=parse_operating_hour_str(self.hours), + owner_type=self.owner_type, + open_positions=self.employee_types, + ), + Age(0), + Services(services), + Name(engine.name_generator.get_name(self.name_format)), + Position2D(), + Location(), + Lifespan(self.average_lifespan), + ] ) - self.add(Location) - self.add(Position2D) - if extra_components: - for component_type, params in extra_components.items(): - self.add(component_type, **params) +class ResidentialZoning(Enum): + SingleFamily = 0 + MultiFamily = 0 -class BusinessArchetypeLibrary: - _registry: Dict[str, BusinessArchetype] = {} - @classmethod - def add( - cls, archetype: BusinessArchetype, name: str = None, overwrite_ok: bool = False - ) -> None: - """Register a new LifeEventType mapped to a name""" - entry_key = name if name else archetype.name - if entry_key in cls._registry and not overwrite_ok: - logger.warning(f"Attempted to overwrite BusinessArchetype: ({entry_key})") - return - cls._registry[entry_key] = archetype +class IResidenceArchetype(IEntityArchetype): + """Interface for archetypes that construct residences""" - @classmethod - def get_all(cls) -> List[BusinessArchetype]: - return list(cls._registry.values()) + @abstractmethod + def get_zoning(self) -> ResidentialZoning: + raise NotImplementedError - @classmethod - def get(cls, name: str) -> BusinessArchetype: - """Get a LifeEventType using a name""" - try: - return cls._registry[name] - except KeyError: - raise ArchetypeNotFoundError(name) - -class ResidenceArchetype(EntityArchetype): - __slots__ = ("spawn_multiplier",) +class BaseResidenceArchetype(IResidenceArchetype): + __slots__ = ("spawn_frequency", "zoning") def __init__( self, - name: str, - spawn_multiplier: int = 1, - extra_components: Dict[Type[Component], Dict[str, Any]] = None, + zoning: ResidentialZoning = ResidentialZoning.SingleFamily, + spawn_frequency: int = 1, ) -> None: - super().__init__(name) - self.spawn_multiplier: int = spawn_multiplier - - self.add(Residence) - self.add(Location) - self.add(Position2D) + self.spawn_frequency: int = spawn_frequency + self.zoning: ResidentialZoning = zoning - if extra_components: - for component_type, params in extra_components.items(): - self.add(component_type, **params) - - -class ResidenceArchetypeLibrary: - _registry: Dict[str, ResidenceArchetype] = {} - - @classmethod - def add( - cls, - archetype: ResidenceArchetype, - name: str = None, - ) -> None: - """Register a new LifeEventType mapped to a name""" - cls._registry[name if name else archetype.name] = archetype + def get_spawn_frequency(self) -> int: + return self.spawn_frequency - @classmethod - def get_all(cls) -> List[ResidenceArchetype]: - return list(cls._registry.values()) + def get_zoning(self) -> ResidentialZoning: + return self.zoning - @classmethod - def get(cls, name: str) -> ResidenceArchetype: - """Get a LifeEventType using a name""" - try: - return cls._registry[name] - except KeyError: - raise ArchetypeNotFoundError(name) + def create(self, world: World, **kwargs) -> GameObject: + return world.spawn_gameobject([Residence(), Location(), Position2D()]) -class ArchetypeNotFoundError(Exception): - """Error thrown when an archetype is not found in the engine""" +class ArchetypeRef(Component): + __slots__ = "name" - def __init__(self, archetype_name: str) -> None: + def __init__(self, name: str) -> None: super().__init__() - self.archetype_name: str = archetype_name - self.message: str = f"Could not find archetype with name '{archetype_name}'" + self.name: str = name - def __str__(self) -> str: - return self.message + def __repr__(self): + return f"{self.__class__.__name__}({self.name})" diff --git a/src/neighborly/core/behavior_tree.py b/src/neighborly/core/behavior_tree.py deleted file mode 100644 index c226d57..0000000 --- a/src/neighborly/core/behavior_tree.py +++ /dev/null @@ -1,58 +0,0 @@ -from abc import abstractmethod -from typing import Protocol - -from neighborly.core.ecs import World -from neighborly.core.life_event import LifeEvent - - -class BehaviorNode(Protocol): - """A single node in the behavior tree""" - - @abstractmethod - def __call__(self, world: World, event: LifeEvent, **kwargs) -> bool: - """Evaluate the behavior tree node""" - raise NotImplemented - - -def invert(node: BehaviorNode) -> BehaviorNode: - """ - Returns precondition function that checks if the - current year is less than the given year - """ - - def fn(world: World, event: LifeEvent, **kwargs) -> bool: - return not node(world, event, **kwargs) - - return fn - - -def selector(*nodes: BehaviorNode) -> BehaviorNode: - """ - Returns precondition function that checks if the - current year is less than the given year - """ - - def fn(world: World, event: LifeEvent, **kwargs) -> bool: - for node in nodes: - res = node(world, event, **kwargs) - if res is True: - return True - return False - - return fn - - -def sequence(*nodes: BehaviorNode) -> BehaviorNode: - """ - Returns precondition function that checks if the - current year is less than the given year - """ - - def fn(world: World, event: LifeEvent, **kwargs) -> bool: - for node in nodes: - res = node(world, event, **kwargs) - if res is False: - return False - return True - - return fn diff --git a/src/neighborly/core/building.py b/src/neighborly/core/building.py new file mode 100644 index 0000000..1009685 --- /dev/null +++ b/src/neighborly/core/building.py @@ -0,0 +1,28 @@ +from typing import Any, Dict + +from neighborly.core.ecs import Component + + +class Building(Component): + """ + Building components are attached to structures (like businesses and residences) + that are currently present in the town. + + Attributes + ---------- + _building_type: str + What kind of building is this + """ + + __slots__ = "_building_type" + + def __init__(self, building_type: str) -> None: + super().__init__() + self._building_type: str = building_type + + def to_dict(self) -> Dict[str, Any]: + return {**super().to_dict(), "building_type": self.building_type} + + @property + def building_type(self) -> str: + return self._building_type diff --git a/src/neighborly/core/business.py b/src/neighborly/core/business.py index fbb142b..684cfb5 100644 --- a/src/neighborly/core/business.py +++ b/src/neighborly/core/business.py @@ -1,26 +1,41 @@ from __future__ import annotations -import functools import logging import math +import re +from abc import ABC from dataclasses import dataclass -from enum import Enum, IntFlag -from typing import Any, ClassVar, Dict, List, Optional, Protocol, Tuple +from typing import Any, Dict, List, Optional, Protocol, Set, Tuple +from neighborly.builtin.components import Active from neighborly.core.character import GameCharacter -from neighborly.core.ecs import Component, GameObject, World +from neighborly.core.ecs import Component, GameObject, World, component_info from neighborly.core.engine import NeighborlyEngine -from neighborly.core.life_event import LifeEvent -from neighborly.core.residence import Resident -from neighborly.core.routine import RoutineEntry, RoutinePriority -from neighborly.core.time import SimDateTime +from neighborly.core.event import Event +from neighborly.core.routine import ( + Routine, + RoutineEntry, + RoutinePriority, + time_str_to_int, +) +from neighborly.core.time import SimDateTime, Weekday logger = logging.getLogger(__name__) +@dataclass +class BusinessInfo: + min_population: int + max_instances: int + demise: int + + +__business_info_registry: Dict[str, BusinessInfo] = {} + + class IOccupationPreconditionFn(Protocol): """ - A function that must evaluate to True for a character to + A function that must evaluate to True for an entity to be eligible to hold the occupation. Notes @@ -32,7 +47,7 @@ class IOccupationPreconditionFn(Protocol): def __call__(self, world: World, gameobject: GameObject, **kwargs: Any) -> bool: """ - A function that must evaluate to True for a character to + A function that must evaluate to True for an entity to be eligible to hold the occupation. Arguments @@ -44,7 +59,7 @@ def __call__(self, world: World, gameobject: GameObject, **kwargs: Any) -> bool: Returns ------- - bool: True if the character is eligible for the occupation + bool: True if the entity is eligible for the occupation False otherwise """ raise NotImplementedError() @@ -93,11 +108,14 @@ class OccupationType: name: str level: int = 1 + description: str = "" precondition: Optional[IOccupationPreconditionFn] = None - def fill_role(self, world: World, business: Business) -> Optional[Occupation]: + def fill_role( + self, world: World, business: Business + ) -> Optional[Tuple[GameObject, Occupation]]: """ - Attempt to find a component character that meets the preconditions + Attempt to find a component entity that meets the preconditions for this occupation """ candidate_list: List[GameObject] = list( @@ -105,7 +123,7 @@ def fill_role(self, world: World, business: Business) -> Optional[Occupation]: lambda g: self.precondition(world, g) if self.precondition else True, map( lambda res: world.get_gameobject(res[0]), - world.get_components(GameCharacter, Resident), + world.get_components(GameCharacter, Unemployed, Active), ), ) ) @@ -114,116 +132,146 @@ def fill_role(self, world: World, business: Business) -> Optional[Occupation]: chosen_candidate = world.get_resource(NeighborlyEngine).rng.choice( candidate_list ) - occupation = Occupation(self, business.id) - chosen_candidate.add_component(occupation) - return occupation + return chosen_candidate, Occupation( + occupation_type=self.name, + business=business.gameobject.id, + level=self.level, + start_date=world.get_resource(SimDateTime).copy(), + ) return None + def fill_role_with( + self, world: World, business: Business, candidate: GameObject + ) -> Optional[Occupation]: + if self.precondition: + if self.precondition(world, candidate): + return Occupation( + occupation_type=self.name, + business=business.gameobject.id, + level=self.level, + start_date=world.get_resource(SimDateTime).copy(), + ) + else: + return None + else: + return Occupation( + occupation_type=self.name, + business=business.gameobject.id, + level=self.level, + start_date=world.get_resource(SimDateTime).copy(), + ) + + def __repr__(self) -> str: + return f"OccupationType(name={self.name}, level={self.level})" + -class OccupationTypeLibrary: +class OccupationTypes: """Stores OccupationType instances mapped to strings for lookup at runtime""" - _registry: ClassVar[Dict[str, OccupationType]] = {} + _registry: Dict[str, OccupationType] = {} @classmethod def add( cls, occupation_type: OccupationType, - name: str = None, - overwrite_ok: bool = False, + name: Optional[str] = None, ) -> None: entry_key = name if name else occupation_type.name - # if entry_key in cls._registry and not overwrite_ok: - # logger.warning(f"Attempted to overwrite OcuppationType: ({entry_key})") - # return + if entry_key in cls._registry: + logger.debug(f"Overwriting OccupationType: ({entry_key})") cls._registry[entry_key] = occupation_type @classmethod def get(cls, name: str) -> OccupationType: + """ + Get an OccupationType by name + + Parameters + ---------- + name: str + The registered name of the OccupationType + + Returns + ------- + OccupationType + + Raises + ------ + KeyError + When there is not an OccupationType + registered to that name + """ return cls._registry[name] class Occupation(Component): """ - Employment Information about a character + Employment Information about an entity """ - __slots__ = "_occupation_def", "_years_held", "_business" - - _definition_registry: Dict[str, OccupationType] = {} + __slots__ = "_occupation_type", "_years_held", "_business", "_level", "_start_date" def __init__( self, - occupation_type: OccupationType, + occupation_type: str, business: int, + level: int, + start_date: SimDateTime, ) -> None: super().__init__() - self._occupation_def: OccupationType = occupation_type + self._occupation_type: str = occupation_type self._business: int = business + self._level: int = level self._years_held: float = 0.0 + self._start_date: SimDateTime = start_date def to_dict(self) -> Dict[str, Any]: return { **super().to_dict(), - "occupation_def": self._occupation_def.name, + "occupation_type": self._occupation_type, + "level": self._level, "business": self._business, - "years_held": self.get_years_held(), + "years_held": self._years_held, } - def get_type(self) -> OccupationType: - return self._occupation_def - - def get_business(self) -> int: + @property + def business(self) -> int: return self._business - def get_years_held(self) -> int: + @property + def years_held(self) -> int: return math.floor(self._years_held) + @property + def level(self) -> int: + return self._level + + @property + def occupation_type(self) -> str: + return self._occupation_type + + @property + def start_date(self) -> SimDateTime: + return self._start_date + def increment_years_held(self, years: float) -> None: self._years_held += years - @classmethod - def create(cls, world, business: int = -1, **kwargs) -> Occupation: - type_name = kwargs["name"] - level = kwargs.get("level", 1) - preconditions = kwargs.get("preconditions", []) - return Occupation( - cls.get_occupation_definition(type_name, level, preconditions), - business=business, + def __repr__(self) -> str: + return "Occupation(occupation_type={}, business={}, level={}, years_held={})".format( + self.occupation_type, self.business, self.level, self.years_held ) - @classmethod - def get_occupation_definition( - cls, name: str, level: int, precondition: IOccupationPreconditionFn - ) -> OccupationType: - if name not in cls._definition_registry: - cls._definition_registry[name] = OccupationType( - name=name, level=level, precondition=precondition - ) - return cls._definition_registry[name] - - def on_remove(self) -> None: - """Run when the component is removed from the GameObject""" - world = self.gameobject.world - workplace = world.get_gameobject(self._business).get_component(Business) - if workplace.owner != self.gameobject.id: - workplace.remove_employee(self.gameobject.id) - else: - workplace.owner = None - - def on_archive(self) -> None: - self.gameobject.remove_component(type(self)) - @dataclass class WorkHistoryEntry: - """Record of a job held by a character""" + """Record of a job held by an entity""" occupation_type: str business: int start_date: SimDateTime end_date: SimDateTime - reason_for_leaving: Optional[LifeEvent] = None + reason_for_leaving: Optional[Event] = None @property def years_held(self) -> int: @@ -246,10 +294,20 @@ def to_dict(self) -> Dict[str, Any]: return ret + def __repr__(self) -> str: + return ( + "WorkHistoryEntry(type={}, business={}, start_date={}, end_date={})".format( + self.occupation_type, + self.business, + self.start_date.to_iso_str(), + self.end_date.to_iso_str() if self.end_date else "N/A", + ) + ) + class WorkHistory(Component): """ - Stores information about all the jobs that a character + Stores information about all the jobs that an entity has held Attributes @@ -265,13 +323,17 @@ def __init__(self) -> None: self._chronological_history: List[WorkHistoryEntry] = [] self._categorical_history: Dict[str, List[WorkHistoryEntry]] = {} + @property + def entries(self) -> List[WorkHistoryEntry]: + return self._chronological_history + def add_entry( self, occupation_type: str, business: int, start_date: SimDateTime, end_date: SimDateTime, - reason_for_leaving: Optional[LifeEvent] = None, + reason_for_leaving: Optional[Event] = None, ) -> None: """Add an entry to the work history""" entry = WorkHistoryEntry( @@ -289,17 +351,11 @@ def add_entry( self._categorical_history[occupation_type].append(entry) - def has_experience_as_a(self, occupation_type: str) -> bool: - """Return True if the work history has an entry for a given occupation type""" - return occupation_type in self._categorical_history - - def total_experience_as_a(self, occupation_type: str) -> int: - """Return the total years of experience someone has as a given occupation type""" - return functools.reduce( - lambda _sum, _entry: _sum + _entry.years_held, - self._categorical_history.get(occupation_type, []), - 0, - ) + def get_last_entry(self) -> Optional[WorkHistoryEntry]: + """Get the latest entry to WorkHistory""" + if self._chronological_history: + return self._chronological_history[-1] + return None def to_dict(self) -> Dict[str, Any]: return { @@ -310,93 +366,152 @@ def to_dict(self) -> Dict[str, Any]: def __len__(self) -> int: return len(self._chronological_history) + def __repr__(self) -> str: + return "WorkHistory({})".format( + [e.__repr__() for e in self._chronological_history] + ) + + +class ServiceType: + """A service that can be offered by a business establishment""" + + __slots__ = "_uid", "_name" -class BusinessService(IntFlag): - NONE = 0 - DRINKING = 1 << 0 - BANKING = 1 << 1 - COLLEGE_EDUCATION = 1 << 2 - CONSTRUCTION = 1 << 3 - COSMETICS = 1 << 4 - CLOTHING = 1 << 5 - FIRE_EMERGENCY = 1 << 6 - FOOD = 1 << 7 - HARDWARE = 1 << 8 - HOME_IMPROVEMENT = 1 << 9 - HOUSING = 1 << 10 - LEGAL = 1 << 11 - MEDICAL_EMERGENCY = 1 << 12 - MORTICIAN = 1 << 13 - RECREATION = 1 << 14 - PUBLIC_SERVICE = 1 << 15 - PRIMARY_EDUCATION = 1 << 16 - REALTY = 1 << 17 - SECONDARY_EDUCATION = 1 << 18 - SHOPPING = 1 << 19 - SOCIALIZING = 1 << 20 - ERRANDS = 1 << 21 - - -class BusinessStatus(Enum): - PendingOpening = 0 - OpenForBusiness = 1 - ClosedForBusiness = 2 + def __init__(self, uid: int, name: str) -> None: + self._uid = uid + self._name = name + + @property + def uid(self) -> int: + return self._uid + + @property + def name(self) -> str: + return self._name + + def __hash__(self) -> int: + return self._uid + + def __eq__(self, other: ServiceType) -> bool: + return self.uid == other.uid + + +class ServiceTypes: + """ + Repository of various services offered + """ + + _next_id: int = 1 + _name_to_service: Dict[str, ServiceType] = {} + _id_to_name: Dict[int, str] = {} + + @classmethod + def __contains__(cls, service_name: str) -> bool: + """Return True if a service type exists with the given name""" + return service_name.lower() in cls._name_to_service + + @classmethod + def get(cls, service_name: str) -> ServiceType: + lc_service_name = service_name.lower() + + if lc_service_name in cls._name_to_service: + return cls._name_to_service[lc_service_name] + + uid = cls._next_id + cls._next_id = cls._next_id + 1 + service_type = ServiceType(uid, lc_service_name) + cls._name_to_service[lc_service_name] = service_type + cls._id_to_name[uid] = lc_service_name + + +class Services(Component): + + __slots__ = "_services" + + def __init__(self, services: Set[ServiceType]) -> None: + super().__init__() + self._services: Set[ServiceType] = services + + def __contains__(self, service_name: str) -> bool: + return ServiceTypes.get(service_name) in self._services + + def has_service(self, service: ServiceType) -> bool: + return service in self._services + + +@component_info( + "Closed For Business", + "This business is no longer open and nobody works here.", +) +class ClosedForBusiness(Component): + pass + + +@component_info( + "Open For Business", + "This business open for business and people can travel here.", +) +class OpenForBusiness(Component): + + __slots__ = "duration" + + def __init__(self) -> None: + super().__init__() + self.duration: float = 0.0 + + +@component_info( + "Pending Opening", + "This business is built, but has no owner.", +) +class PendingOpening(Component): + + __slots__ = "duration" + + def __init__(self) -> None: + super().__init__() + self.duration: float = 0.0 + + +class IBusinessType(Component, ABC): + """Empty interface for creating types of businesses like Restaurants, ETC""" + + pass class Business(Component): __slots__ = ( - "business_type", - "name", - "_years_in_business", "operating_hours", "_employees", "_open_positions", "owner", "owner_type", - "status", - "services", ) def __init__( self, - business_type: str, - name: str, - owner_type: str = None, - owner: int = None, - open_positions: Dict[str, int] = None, - operating_hours: Dict[str, List[RoutineEntry]] = None, - services: BusinessService = BusinessService.NONE, + owner_type: Optional[str] = None, + owner: Optional[int] = None, + open_positions: Optional[Dict[str, int]] = None, + operating_hours: Optional[Dict[Weekday, Tuple[int, int]]] = None, ) -> None: super().__init__() - self.business_type: str = business_type self.owner_type: Optional[str] = owner_type - self.name: str = name - # self._operating_hours: Dict[str, List[RoutineEntry]] = self._create_routines( - # parse_schedule_str(business_def.hours) - # ) - self.operating_hours: Dict[str, List[RoutineEntry]] = ( + self.operating_hours: Dict[Weekday, Tuple[int, int]] = ( operating_hours if operating_hours else {} ) self._open_positions: Dict[str, int] = open_positions if open_positions else {} self._employees: Dict[int, str] = {} self.owner: Optional[int] = owner - self.status: BusinessStatus = BusinessStatus.PendingOpening - self._years_in_business: float = 0.0 - self.services: BusinessService = services - - @property - def years_in_business(self) -> int: - return math.floor(self._years_in_business) def to_dict(self) -> Dict[str, Any]: return { **super().to_dict(), - "business_type": self.business_type, - "name": self.name, "operating_hours": self.operating_hours, - "open_positions": self.operating_hours, + "open_positions": self._open_positions, "employees": self.get_employees(), "owner": self.owner if self.owner is not None else -1, + "owner_type": self.owner_type if self.owner_type is not None else "", } def needs_owner(self) -> bool: @@ -409,28 +524,24 @@ def get_employees(self) -> List[int]: """Return a list of IDs for current employees""" return list(self._employees.keys()) + def set_owner(self, owner: Optional[int]) -> None: + """Set the ID for the owner of the business""" + self.owner = owner + def add_employee(self, character: int, position: str) -> None: - """Add character to employees and remove a vacant position""" + """Add entity to employees and remove a vacant position""" self._employees[character] = position self._open_positions[position] -= 1 def remove_employee(self, character: int) -> None: - """Remove a character as an employee and add a vacant position""" + """Remove an entity as an employee and add a vacant position""" position = self._employees[character] del self._employees[character] self._open_positions[position] += 1 - def set_business_status(self, status: BusinessStatus) -> None: - self.status = status - - def increment_years_in_business(self, years: float) -> None: - self._years_in_business += years - def __repr__(self) -> str: """Return printable representation""" - return "Business(type='{}', name='{}', owner={}, employees={}, openings={})".format( - self.business_type, - self.name, + return "Business(owner={}, employees={}, openings={})".format( self.owner, self._employees, self._open_positions, @@ -438,97 +549,156 @@ def __repr__(self) -> str: @classmethod def create(cls, world: World, **kwargs) -> Business: - engine = world.get_resource(NeighborlyEngine) - business_type: str = kwargs["business_type"] - business_name: str = engine.name_generator.get_name( - kwargs.get("name_format", business_type) - ) owner_type: Optional[str] = kwargs.get("owner_type") employee_types: Dict[str, int] = kwargs.get("employee_types", {}) - services: BusinessService = kwargs.get("services", BusinessService.NONE) - operating_hours: Dict[str, List[RoutineEntry]] = to_operating_hours( - kwargs.get("hours", []) + operating_hours: Dict[Weekday, Tuple[int, int]] = parse_operating_hour_str( + kwargs.get("hours", "day") ) return Business( - business_type=business_type, - name=business_name, open_positions=employee_types, - services=services, owner_type=owner_type, operating_hours=operating_hours, ) - @staticmethod - def _create_routines( - times: Dict[str, Tuple[int, int]] - ) -> Dict[str, List[RoutineEntry]]: + def create_routines(self) -> Dict[Weekday, RoutineEntry]: """Create routine entries given tuples of time intervals mapped to days of the week""" - schedules: Dict[str, list[RoutineEntry]] = {} - - for day, (opens, closes) in times.items(): - routine_entries: List[RoutineEntry] = [] - - if opens > closes: - # Night shift business have their schedules - # split between two entries - routine_entries.append( - RoutineEntry( - start=opens, - end=24, - activity="working", - location="work", - priority=RoutinePriority.HIGH, - ) - ) + routine_entries: Dict[Weekday, RoutineEntry] = {} + + for day, (opens, closes) in self.operating_hours.items(): + routine_entries[day] = RoutineEntry( + start=opens, + end=closes, + location=self.gameobject.id, + priority=RoutinePriority.HIGH, + tags=["work"], + ) - routine_entries.append( - RoutineEntry( - start=0, - end=closes, - activity="working", - location="work", - priority=RoutinePriority.HIGH, - ) - ) - elif opens < closes: - # Day shift business - routine_entries.append( - RoutineEntry( - start=opens, - end=closes, - activity="working", - location="work", - priority=RoutinePriority.HIGH, - ) - ) - else: - raise ValueError("Opening and closing times must be different") + return routine_entries - schedules[day] = routine_entries - return schedules +@component_info( + "Unemployed", + "Character doesn't have a job", +) +class Unemployed(Component): + __slots__ = "duration_days" + def __init__(self) -> None: + super().__init__() + self.duration_days: float = 0 -def to_operating_hours(str_hours: List[str]) -> Dict[str, List[RoutineEntry]]: + def to_dict(self) -> Dict[str, Any]: + return {**super().to_dict(), "duration_days": self.duration_days} + + +@component_info( + "In the Workforce", + "This Character is eligible for employment opportunities.", +) +class InTheWorkforce(Component): + pass + + +def start_job( + business: Business, + character: GameCharacter, + occupation: Occupation, + is_owner: bool = False, +) -> None: + if is_owner: + business.owner = character.gameobject.id + else: + business.add_employee(character.gameobject.id, occupation.occupation_type) + + character.gameobject.add_component(occupation) + + if character.gameobject.has_component(Unemployed): + character.gameobject.remove_component(Unemployed) + + character_routine = character.gameobject.get_component(Routine) + for day, interval in business.operating_hours.items(): + character_routine.add_entries( + f"work_@_{business.gameobject.id}", + [day], + RoutineEntry( + start=interval[0], + end=interval[1], + location=business.gameobject.id, + priority=RoutinePriority.MED, + ), + ) + + +def end_job(business: Business, character: GameObject, occupation: Occupation) -> None: + world = character.world + + if business.owner_type is not None and business.owner == character.id: + business.set_owner(None) + else: + business.remove_employee(character.id) + + if not character.has_component(WorkHistory): + character.add_component(WorkHistory()) + + character.remove_component(Occupation) + + character.get_component(WorkHistory).add_entry( + occupation_type=occupation.occupation_type, + business=business.gameobject.id, + start_date=occupation.start_date, + end_date=world.get_resource(SimDateTime).copy(), + ) + + # Remove routine entries + character_routine = character.get_component(Routine) + for day, _ in business.operating_hours.items(): + character_routine.remove_entries( + [day], + f"work_@_{business.gameobject.id}", + ) + + +def parse_operating_hour_str( + operating_hours_str: str, +) -> Dict[Weekday, Tuple[int, int]]: """ - Convert a list of string with times of day and convert - them to a list of RoutineEntries for when employees - should report to work and when a business is open to the - public. + Convert a string representing the hours of operation + for a business to a dictionary representing the days + of the week mapped to tuple time intervals for when + the business is open. Parameters ---------- - str_hours: List[str] - The times of day that this business is open + operating_hours_str: str + String indicating the operating hours + + Notes + ----- + The following a re valid formats for the operating hours string + (1a interval 24HR) ## - ## + Opening hour - closing hour + Assumes that the business is open all days of the week + (1b interval 12HR AM/PM) ## AM - ## PM + Twelve-hour time interval + (2 interval-alias) "morning", "day", "night", or ... + Single string that maps to a preset time interval + Assumes that the business is open all days of the week + (3 days + interval) MTWRFSU: ## - ## + Specify the time interval and the specific days of the + week that the business is open + (4 days + interval-alias) MTWRFSU: "morning", or ... + Specify the days of the week and a time interval for + when the business will be open Returns ------- - List[RoutineEntry] - Routine entries for when this business is operational + Dict[str, Tuple[int, int]] + Days of the week mapped to lists of time intervals """ - times_to_intervals = { + + interval_aliases = { "morning": (5, 12), "late-morning": (11, 12), "early-morning": (5, 8), @@ -538,26 +708,78 @@ def to_operating_hours(str_hours: List[str]) -> Dict[str, List[RoutineEntry]]: "night": (21, 23), } - operating_hours: Dict[str, List[RoutineEntry]] = { - "monday": [], - "tuesday": [], - "wednesday": [], - "thursday": [], - "friday": [], - "saturday": [], - "sunday": [], - } + # time_alias = { + # "early-morning": "02:00", + # "dawn": "06:00", + # "morning": "08:00", + # "late-morning": "10:00", + # "noon": "12:00", + # "afternoon": "14:00", + # "evening": "17:00", + # "night": "21:00", + # "midnight": "23:00", + # } + + operating_hours_str = operating_hours_str.strip() + + # Check for number interval + if match := re.fullmatch( + r"[0-2]?[0-9]\s*(PM|AM)?\s*-\s*[0-2]?[0-9]\s*(PM|AM)?", operating_hours_str + ): + interval_strs: List[str] = list( + map(lambda s: s.strip(), match.group(0).split("-")) + ) + + interval: Tuple[int, int] = ( + time_str_to_int(interval_strs[0]), + time_str_to_int(interval_strs[1]), + ) - routines: List[RoutineEntry] = [] + if 23 < interval[0] < 0: + raise ValueError(f"Interval start not within bounds [0,23]: {interval}") + if 23 < interval[1] < 0: + raise ValueError(f"Interval end not within bounds [0,23]: {interval}") - for time_of_day in str_hours: - try: - start, end = times_to_intervals[time_of_day] - routines.append(RoutineEntry(start, end, location="work", activity="work")) - except KeyError: - raise ValueError(f"{time_of_day} is not a valid time of day.") + return {d: interval for d in list(Weekday)} - for key in operating_hours: - operating_hours[key].extend(routines) + # Check for interval alias + elif match := re.fullmatch(r"[a-zA-Z]+", operating_hours_str): + alias = match.group(0) + if alias in interval_aliases: + interval = interval_aliases[alias] + return {d: interval for d in list(Weekday)} + else: + raise ValueError(f"Invalid interval alias in: '{operating_hours_str}'") + + # Check for days with number interval + elif match := re.fullmatch( + r"[MTWRFSU]+\s*:\s*[0-2]?[0-9]\s*-\s*[0-2]?[0-9]", operating_hours_str + ): + days_section, interval_section = tuple(match.group(0).split(":")) + days_section = days_section.strip() + interval_strs: List[str] = list( + map(lambda s: s.strip(), interval_section.strip().split("-")) + ) + interval: Tuple[int, int] = (int(interval_strs[0]), int(interval_strs[1])) + + if 23 < interval[0] < 0: + raise ValueError(f"Interval start not within bounds [0,23]: {interval}") + if 23 < interval[1] < 0: + raise ValueError(f"Interval end not within bounds [0,23]: {interval}") + + return {Weekday.from_abbr(d): interval for d in days_section.strip()} + + # Check for days with alias interval + elif match := re.fullmatch(r"[MTWRFSU]+\s*:\s*[a-zA-Z]+", operating_hours_str): + days_section, alias = tuple(match.group(0).split(":")) + days_section = days_section.strip() + alias = alias.strip() + if alias in interval_aliases: + interval = interval_aliases[alias] + return {Weekday.from_abbr(d): interval for d in days_section.strip()} + else: + raise ValueError( + f"Invalid interval alias ({alias}) in: '{operating_hours_str}'" + ) - return operating_hours + raise ValueError(f"Invalid operating hours string: '{operating_hours_str}'") diff --git a/src/neighborly/core/character.py b/src/neighborly/core/character.py index 6a3a7e1..4d19fd7 100644 --- a/src/neighborly/core/character.py +++ b/src/neighborly/core/character.py @@ -1,16 +1,13 @@ from __future__ import annotations -from dataclasses import dataclass -from typing import Any, ClassVar, Dict, Optional +from typing import Any, Dict from typing_extensions import TypedDict from neighborly.core.ecs import Component, World -from neighborly.core.engine import NeighborlyEngine -from neighborly.core.location import Location -class LifeStages(TypedDict): +class LifeStageAges(TypedDict): """Ages when characters are in certain stages of their lives""" child: int @@ -20,169 +17,37 @@ class LifeStages(TypedDict): elder: int -@dataclass -class CharacterDefinition: - """Configuration parameters for characters - - Fields - ------ - lifecycle: LifeCycleConfig - Configuration parameters for a characters lifecycle - """ - - _type_registry: ClassVar[Dict[str, CharacterDefinition]] = {} - - type_name: str - life_stages: LifeStages - lifespan: int - chance_can_get_pregnant: float = 0.5 - - @classmethod - def register_type(cls, type_config: CharacterDefinition) -> None: - """Registers a character config with the shared registry""" - cls._type_registry[type_config.type_name] = type_config - - @classmethod - def get_type(cls, name: str) -> CharacterDefinition: - """Retrieve a CharacterConfig from the shared registry""" - return cls._type_registry[name] - - -class CharacterName: +class CharacterName(Component): __slots__ = "firstname", "surname" def __init__(self, firstname: str, surname: str) -> None: + super().__init__() self.firstname: str = firstname self.surname: str = surname + def to_dict(self) -> Dict[str, Any]: + return { + **super().to_dict(), + "firstname": self.firstname, + "surname": self.surname, + } + def __repr__(self) -> str: return f"{self.firstname} {self.surname}" def __str__(self) -> str: return f"{self.firstname} {self.surname}" - -class GameCharacter(Component): - """ - The state of a single character within the world - - Attributes - ---------- - character_def: CharacterType - Configuration settings for the character - name: CharacterName - The character's name - age: float - The character's current age in years - location: int - Entity ID of the location where this character current is - location_aliases: Dict[str, int] - Maps string names to entity IDs of locations in the world - """ - - __slots__ = ( - "character_def", - "name", - "age", - "location", - "location_aliases", - "can_get_pregnant", - ) - - character_def_registry: Dict[str, CharacterDefinition] = {} - - def __init__( - self, - character_def: CharacterDefinition, - name: CharacterName, - age: float, - can_get_pregnant: bool = False, - ) -> None: - super().__init__() - self.character_def = character_def - self.name: CharacterName = name - self.age: float = age - self.location: Optional[int] = None - self.location_aliases: Dict[str, int] = {} - self.can_get_pregnant: bool = can_get_pregnant - - def on_remove(self) -> None: - if self.location: - location = self.gameobject.world.get_gameobject(self.location) - location.get_component(Location).remove_character(self.gameobject.id) - self.location = None + def pprint(self) -> None: + print(f"{self.__class__.__name__}:\n" f"\tname: {str(self)}") @classmethod - def create(cls, world: World, **kwargs) -> GameCharacter: - """Create a new instance of a character""" - engine: NeighborlyEngine = world.get_resource(NeighborlyEngine) - - character_type_name = kwargs["character_type"] + def create(cls, world: World, **kwargs) -> Component: first_name, surname = kwargs.get( "name_format", "#first_name# #family_name#" ).split(" ") - lifespan: int = kwargs["lifespan"] - life_stages: LifeStages = kwargs["life_stages"] - chance_can_get_pregnant: float = kwargs.get("chance_can_get_pregnant", 0.5) - - character_def = cls.get_character_def( - CharacterDefinition( - type_name=character_type_name, - lifespan=lifespan, - life_stages=life_stages, - ) - ) - - name = CharacterName( - engine.name_generator.get_name(first_name), - engine.name_generator.get_name(surname), - ) - - can_get_pregnant = engine.rng.random() < chance_can_get_pregnant - - # generate an age - age = engine.rng.randint( - character_def.life_stages["young_adult"], - character_def.life_stages["elder"] - 1, - ) - - return GameCharacter( - character_def=character_def, - name=name, - age=float(age), - can_get_pregnant=can_get_pregnant, - ) - - @classmethod - def get_character_def( - cls, character_def: CharacterDefinition - ) -> CharacterDefinition: - """Returns an existing CharacterDefinition with the same name or creates a new one""" - if character_def.type_name not in cls.character_def_registry: - cls.character_def_registry[character_def.type_name] = character_def - return cls.character_def_registry[character_def.type_name] - - def to_dict(self) -> Dict[str, Any]: - return { - **super().to_dict(), - "character_def": self.character_def.type_name, - "name": str(self.name), - "age": self.age, - "location": self.location, - "location_aliases": self.location_aliases, - "can_get_pregnant": self.can_get_pregnant, - } + return cls(first_name, surname) - def __str__(self) -> str: - return f"{str(self.name)}({self.gameobject.id})" - def __repr__(self) -> str: - """Return printable representation""" - return "{}(name={}, age={}, location={}, location_aliases={}, can_get_pregnant={})".format( - self.__class__.__name__, - str(self.name), - round(self.age), - self.location, - self.location_aliases, - self.can_get_pregnant, - ) +class GameCharacter(Component): + pass diff --git a/src/neighborly/core/constants.py b/src/neighborly/core/constants.py new file mode 100644 index 0000000..129b6e2 --- /dev/null +++ b/src/neighborly/core/constants.py @@ -0,0 +1,9 @@ +# World systems phases organize when certain systems should run + +TIME_UPDATE_PHASE = 999 +TOWN_SYSTEMS_PHASE = 800 +CHARACTER_UPDATE_PHASE = 500 +CHARACTER_ACTION_PHASE = 499 +BUILDING_UPDATE_PHASE = 400 +BUSINESS_UPDATE_PHASE = 300 +CLEAN_UP_PHASE = 100 diff --git a/src/neighborly/core/ecs.py b/src/neighborly/core/ecs.py index 0c4568e..8098dba 100644 --- a/src/neighborly/core/ecs.py +++ b/src/neighborly/core/ecs.py @@ -10,18 +10,65 @@ """ from __future__ import annotations -import hashlib import logging from abc import ABC -from typing import Any, Dict, Iterable, List, Optional, Tuple, Type, TypeVar -from uuid import uuid1 +from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple, Type, TypeVar import esper logger = logging.getLogger(__name__) + +# Store string names of components for lookup later +_component_names: Dict[Type[Component], str] = {} + + +# Text descriptions of components (mostly used by GUI applications) +_component_descriptions: Dict[Type[Component], str] = {} + + _CT = TypeVar("_CT", bound="Component") _RT = TypeVar("_RT", bound="Any") +_ST = TypeVar("_ST", bound="ISystem") + + +class ResourceNotFoundError(Exception): + def __init__(self, resource_type: Type[Any]) -> None: + super().__init__() + self.resource_type: Type = resource_type + self.message = f"Could not find resource with type: {resource_type.__name__}" + + def __str__(self) -> str: + return self.message + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.resource_type})" + + +class GameObjectNotFoundError(Exception): + def __init__(self, gid: int) -> None: + super().__init__() + self.gid: int = gid + self.message = f"Could not find GameObject with id: {gid}." + + def __str__(self) -> str: + return self.message + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.gid})" + + +class ComponentNotFoundError(Exception): + def __init__(self, component_type: Type[Component]) -> None: + super().__init__() + self.component_type: Type[Component] = component_type + self.message = f"Could not find Component with type: {component_type.__name__}." + + def __str__(self) -> str: + return self.message + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.component_type})" class GameObject: @@ -32,36 +79,28 @@ class GameObject: Attributes ---------- id: int - unique identifier - name: str + unique identifier + _name: str name of the GameObject - world: World + _world: World the World instance this GameObject belongs to - components: List[Components] + _components: List[Components] Components attached to this GameObject """ - __slots__ = ( - "_id", - "_name", - "_components", - "_world", - "_archetype", - ) + __slots__ = "_id", "_name", "_components", "_world" def __init__( self, - unique_id: Optional[int] = None, - name: str = "GameObject", - components: Iterable[Component] = (), - world: Optional[World] = None, - archetype: Optional[EntityArchetype] = None, + unique_id: int, + world: World, + name: Optional[str] = None, + components: Optional[Iterable[Component]] = None, ) -> None: - self._name: str = name - self._id: int = unique_id if unique_id else self.generate_id() - self._world: Optional[World] = world - self._components: Dict[Type[_CT], Component] = {} - self._archetype: Optional[EntityArchetype] = archetype + self._name: str = name if name else f"GameObject ({unique_id})" + self._id: int = unique_id + self._world: World = world + self._components: Dict[Type[Component], Component] = {} if components: for component in components: @@ -77,74 +116,68 @@ def name(self) -> str: """Get the name of the GameObject""" return self._name - @property - def archetype(self) -> Optional[EntityArchetype]: - """Return the name of the archetype for creating this GameObject""" - return self._archetype - @property def world(self) -> World: """Return the world that this GameObject belongs to""" - if self._world: - return self._world - raise TypeError("World is None for GameObject") + return self._world @property def components(self) -> List[Component]: + """Returns the component instances associated with this GameObject""" return list(self._components.values()) - def set_world(self, world: Optional[World]) -> None: - """set the world instance""" - self._world = world + def get_component_types(self) -> List[Type[Component]]: + """Returns the types of components attached to this character""" + return list(self._components.keys()) def add_component(self, component: Component) -> GameObject: """Add a component to this GameObject""" component.set_gameobject(self) self._components[type(component)] = component self.world.ecs.add_component(self.id, component) - component.on_add() return self - def remove_component(self, component_type: Type[_CT]) -> None: + def remove_component(self, component_type: Type[Component]) -> None: """Add a component to this GameObject""" - component = self._components[component_type] - component.on_remove() - self.world.ecs.remove_component(self.id, component_type) + if not self.has_component(component_type): + return del self._components[component_type] + self.world.ecs.remove_component(self.id, component_type) def get_component(self, component_type: Type[_CT]) -> _CT: - return self._components[component_type] + try: + return self._components[component_type] # type: ignore + except KeyError: + raise ComponentNotFoundError(component_type) - def has_component(self, *component_type: Type[_CT]) -> bool: + def has_component(self, *component_type: Type[Component]) -> bool: return all([ct in self._components for ct in component_type]) def try_component(self, component_type: Type[_CT]) -> Optional[_CT]: - return self._components.get(component_type) - - def archive(self) -> None: - """ - Deactivates the GameObject by removing excess components. - - The GameObject stays in the ECS though. - """ - for component in self.components: - component.on_archive() + return self._components.get(component_type) # type: ignore def to_dict(self) -> Dict[str, Any]: - return { + ret = { "id": self.id, "name": self.name, "components": [c.to_dict() for c in self._components.values()], - "archetype": self.archetype.name if self.archetype else "", } + return ret + + def pprint(self) -> None: + print(f"== GameObject({self.id}) ==\n") + for c in self.components: + c.pprint() + def __hash__(self) -> int: return self._id - @staticmethod - def generate_id() -> int: - """Create a new unique int ID""" - return int.from_bytes(hashlib.sha256(uuid1().bytes).digest()[:8], "little") + def __str__(self) -> str: + return f"GameObject(id={self.id})" + + def __repr__(self) -> str: + return f"GameObject(id={self.id})" class Component(ABC): @@ -174,17 +207,8 @@ def set_gameobject(self, gameobject: Optional[GameObject]) -> None: """set the gameobject instance for this component""" self._gameobject = gameobject - def on_add(self) -> None: - """Run when the component is added to the GameObject""" - return - - def on_remove(self) -> None: - """Run when the component is removed from the GameObject""" - return - - def on_archive(self) -> None: - """Run when the GameObject this is connected to is archived""" - return + def pprint(self) -> None: + print(f"{self.__class__.__name__}:\n") @classmethod def create(cls, world: World, **kwargs) -> Component: @@ -195,6 +219,25 @@ def to_dict(self) -> Dict[str, Any]: """Serialize the component to a dict""" return {"type": self.__class__.__name__} + def __repr__(self) -> str: + return "{}".format(self.__class__.__name__) + + +def component_info( + name: Optional[str] = None, + description: Optional[str] = None, +) -> Callable[[Type[_CT]], Type[_CT]]: + """Decorator that registers a name for this component""" + + def decorator(cls: Type[_CT]) -> Type[_CT]: + if name is not None: + _component_names[cls] = name + if description is not None: + _component_descriptions[cls] = description + return cls + + return decorator + class ISystem(ABC, esper.Processor): world: World @@ -231,17 +274,17 @@ def __init__(self) -> None: self._ecs: esper.World = esper.World() self._gameobjects: Dict[int, GameObject] = {} self._dead_gameobjects: List[int] = [] - self._resources: Dict[str, Any] = {} + self._resources: Dict[Type[Any], Any] = {} @property def ecs(self) -> esper.World: return self._ecs def spawn_gameobject( - self, *components: Component, name: Optional[str] = None + self, components: Optional[List[Component]] = None, name: Optional[str] = None ) -> GameObject: """Create a new gameobject and attach any given component instances""" - entity_id = self._ecs.create_entity(*components) + entity_id = self._ecs.create_entity(*components if components else []) gameobject = GameObject( unique_id=entity_id, components=components, @@ -251,29 +294,12 @@ def spawn_gameobject( self._gameobjects[gameobject.id] = gameobject return gameobject - def spawn_archetype(self, archetype: EntityArchetype) -> GameObject: - component_instances: List[Component] = [] - for component_type, options in archetype.components.items(): - component_instances.append(component_type.create(self, **options)) - - entity_id = self._ecs.create_entity(*component_instances) - gameobject = GameObject( - unique_id=entity_id, - components=component_instances, - world=self, - name=f"{archetype.name}({entity_id})", - archetype=archetype, - ) - - archetype.increment_instances() - - self._gameobjects[gameobject.id] = gameobject - - return gameobject - def get_gameobject(self, gid: int) -> GameObject: """Retrieve the GameObject with the given id""" - return self._gameobjects[gid] + try: + return self._gameobjects[gid] + except KeyError: + raise GameObjectNotFoundError(gid) def get_gameobjects(self) -> List[GameObject]: """Get all gameobjects""" @@ -289,7 +315,6 @@ def try_gameobject(self, gid: int) -> Optional[GameObject]: def delete_gameobject(self, gid: int) -> None: """Remove gameobject from world""" - self._ecs.delete_entity(gid) self._dead_gameobjects.append(gid) def get_component(self, component_type: Type[_CT]) -> List[Tuple[int, _CT]]: @@ -298,27 +323,21 @@ def get_component(self, component_type: Type[_CT]) -> List[Tuple[int, _CT]]: def get_components( self, *component_types: Type[_CT] - ) -> List[Tuple[int, List[_CT, ...]]]: + ) -> List[Tuple[int, List[_CT]]]: """Get all game objects with the given components""" return self._ecs.get_components(*component_types) - def try_components( - self, entity: int, *component_types: Type[_CT] - ) -> Optional[List[List[_CT]]]: - """Try to get a multiple component types for a GameObject.""" - return self._ecs.try_components(entity, *component_types) - def _clear_dead_gameobjects(self) -> None: """Delete gameobjects that were removed from the world""" for gameobject_id in self._dead_gameobjects: gameobject = self._gameobjects[gameobject_id] - for component in gameobject.components: - component.on_remove() - if gameobject.archetype: - gameobject.archetype.decrement_instances() - gameobject.set_world(None) del self._gameobjects[gameobject_id] + # You need to check if the gameobject has any components + # If it doesn't then esper will not have it stored + if gameobject.components: + self._ecs.delete_entity(gameobject_id, True) + self._dead_gameobjects.clear() def add_system(self, system: ISystem, priority: int = 0) -> None: @@ -326,10 +345,13 @@ def add_system(self, system: ISystem, priority: int = 0) -> None: self._ecs.add_processor(system, priority=priority) system.world = self + def get_system(self, system_type: Type[_ST]) -> Optional[_ST]: + """Get a System of the given type""" + return self._ecs.get_processor(system_type) + def remove_system(self, system: Type[ISystem]) -> None: """Remove a System from the World""" self._ecs.remove_processor(system) - system.world = None def step(self, **kwargs) -> None: """Call the process method on all systems""" @@ -345,77 +367,32 @@ def add_resource(self, resource: Any) -> None: def remove_resource(self, resource_type: Any) -> None: """remove a global resource to the world""" - del self._resources[resource_type] + try: + del self._resources[resource_type] + except KeyError: + raise ResourceNotFoundError(resource_type) def get_resource(self, resource_type: Type[_RT]) -> _RT: """Add a global resource to the world""" - return self._resources[resource_type] + try: + return self._resources[resource_type] + except KeyError: + raise ResourceNotFoundError(resource_type) def has_resource(self, resource_type: Any) -> bool: """Return true if the world has the given resource""" return resource_type in self._resources + def try_resource(self, resource_type: Type[_RT]) -> Optional[_RT]: + """Attempt to get resource with type. Return None if not found""" + return self._resources.get(resource_type) + + def get_all_resources(self) -> List[Any]: + """Get all resources attached to this World instance""" + return list(self._resources.values()) + def __repr__(self) -> str: return "World(gameobjects={}, resources={})".format( len(self._gameobjects), list(self._resources.values()), ) - - -class EntityArchetype: - """ - Organizes information for constructing components that compose GameObjects. - - Attributes - ---------- - _name: str - (Read-only) The name of the entity archetype - _components: Dict[Type[Component], Dict[str, Any]] - Dict of components used to construct this archetype - """ - - __slots__ = "_name", "_components", "_instances" - - def __init__(self, name: str) -> None: - self._name: str = name - self._components: Dict[Type[Component], Dict[str, Any]] = {} - self._instances: int = 0 - - @property - def name(self) -> str: - """Returns the name of this archetype""" - return self._name - - @property - def components(self) -> Dict[Type[Component], Dict[str, Any]]: - """Returns a list of components in this archetype""" - return {**self._components} - - @property - def instances(self) -> int: - return self._instances - - def add(self, component_type: Type[Component], **kwargs: Any) -> EntityArchetype: - """ - Add a component to this archetype - - Parameters - ---------- - component_type: subclass of neighborly.core.ecs.Component - The component type to add to the entity archetype - **kwargs: Dict[str, Any] - Attribute overrides to pass to the component - """ - self._components[component_type] = {**kwargs} - return self - - def increment_instances(self) -> None: - self._instances += 1 - - def decrement_instances(self) -> None: - self._instances -= 1 - - def __repr__(self) -> str: - return "{}(name={}, components={})".format( - self.__class__.__name__, self._name, self._components - ) diff --git a/src/neighborly/core/engine.py b/src/neighborly/core/engine.py index 78ea0d5..90bf135 100644 --- a/src/neighborly/core/engine.py +++ b/src/neighborly/core/engine.py @@ -3,7 +3,6 @@ import random from typing import Dict, Optional, Type -import neighborly.core.utils.tracery as tracery from neighborly.core.ecs import Component from neighborly.core.name_generation import TraceryNameFactory @@ -26,9 +25,9 @@ class NeighborlyEngine: ) def __init__(self, seed: Optional[int] = None) -> None: + random.seed(seed) self._rng: random.Random = random.Random(seed) self._name_generator: TraceryNameFactory = TraceryNameFactory() - tracery.set_grammar_rng(self._rng) self._component_types: Dict[str, Type[Component]] = {} @property diff --git a/src/neighborly/core/event.py b/src/neighborly/core/event.py new file mode 100644 index 0000000..8b5d338 --- /dev/null +++ b/src/neighborly/core/event.py @@ -0,0 +1,268 @@ +from __future__ import annotations + +from collections import defaultdict +from typing import Any, Callable, DefaultDict, Dict, List, Protocol + +from neighborly import World + + +class Event: + """ + LifeEvents contain information about occurrences that + happened in the story world. + + Attributes + ---------- + name: str + Name of the event + timestamp: str + Timestamp for when the event occurred + roles: List[neighborly.core.life_event.EventRole] + GameObjects involved with this event + metadata: Dict[str, Any] + Additional information about this event + _sorted_roles: Dict[str, List[EventRole]] + (Internal us only) Roles divided by name since there may + be multiple of the same role present + """ + + __slots__ = "timestamp", "name", "roles", "metadata", "_sorted_roles" + + def __init__( + self, name: str, timestamp: str, roles: List[EventRole], **kwargs + ) -> None: + self.name: str = name + self.timestamp: str = timestamp + self.roles: List[EventRole] = [] + self.metadata: Dict[str, Any] = {**kwargs} + self._sorted_roles: Dict[str, List[EventRole]] = {} + for role in roles: + self.add_role(role) + + def add_role(self, role: EventRole) -> None: + """Add role to the event""" + self.roles.append(role) + if role.name not in self._sorted_roles: + self._sorted_roles[role.name] = [] + self._sorted_roles[role.name].append(role) + + def get_type(self) -> str: + """Return the type of this event""" + return self.name + + def to_dict(self) -> Dict[str, Any]: + """Serialize this LifeEvent to a dictionary""" + return { + "name": self.name, + "timestamp": self.timestamp, + "roles": [role.to_dict() for role in self.roles], + "metadata": {**self.metadata}, + } + + def get_all(self, role_name: str) -> List[int]: + """Return the IDs of all GameObjects bound to the given role name""" + return list(map(lambda role: role.gid, self._sorted_roles[role_name])) + + def __getitem__(self, role_name: str) -> int: + return self._sorted_roles[role_name][0].gid + + def __le__(self, other: Event) -> bool: + return self.timestamp <= other.timestamp + + def __lt__(self, other: Event) -> bool: + return self.timestamp < other.timestamp + + def __ge__(self, other: Event) -> bool: + return self.timestamp >= other.timestamp + + def __gt__(self, other: Event) -> bool: + return self.timestamp > other.timestamp + + def __repr__(self) -> str: + return "LifeEvent(name={}, timestamp={}, roles=[{}], metadata={})".format( + self.name, self.timestamp, self.roles, self.metadata + ) + + def __str__(self) -> str: + return f"{self.name} [at {self.timestamp}] : {', '.join(map(lambda r: f'{r.name}:{r.gid}', self.roles))}" + + +class EventRole: + """ + Role is a role that has a GameObject bound to it. + It does not contain any information about filtering for + the role. + + Attributes + ---------- + name: str + Name of the role + gid: int + Unique identifier for the GameObject bound to this role + """ + + __slots__ = "name", "gid" + + def __init__(self, name: str, gid: int) -> None: + self.name: str = name + self.gid: int = gid + + def to_dict(self) -> Dict[str, Any]: + return {"name": self.name, "gid": self.gid} + + def __str__(self) -> str: + return self.__repr__() + + def __repr__(self) -> str: + return f"{self.__class__.__name__}(name={self.name}, gid={self.gid})" + + +class EventEffectFn(Protocol): + """Callback function called when an event is executed""" + + def __call__(self, world: World, event: Event) -> None: + raise NotImplementedError + + +class EventProbabilityFn(Protocol): + """Function called to determine the probability of an event executing""" + + def __call__(self, world: World, event: Event) -> float: + raise NotImplementedError + + +class EventLog: + """ + Global resource that manages all the LifeEvents that have occurred in the simulation. + + This component should always be present in the simulation. + + Attributes + ---------- + event_history: List[Event] + All the events that have occurred thus far in the simulation + _per_gameobject: DefaultDict[int, List[Event]] + Dictionary of all the LifEvents that have occurred divided by participant ID + _subscribers: List[Callable[[LifeEvent], None]] + Callback functions executed everytime a LifeEvent occurs + _per_gameobject_subscribers: DefaultDict[int, List[Callable[[LifeEvent], None]] + Callback functions divided by the GameObject to which they are subscribed + """ + + __slots__ = ( + "event_history", + "_subscribers", + "_per_gameobject", + "_per_gameobject_subscribers", + "_listeners" + ) + + def __init__(self) -> None: + self.event_history: List[Event] = [] + self._per_gameobject: DefaultDict[int, List[Event]] = defaultdict(list) + self._subscribers: List[Callable[[Event], 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, world: World, event: Event) -> None: + """ + Adds a LifeEvent to the history and calls all registered callback functions + + Parameters + ---------- + world: World + + event: Event + The event that occurred + """ + self.event_history.append(event) + + for role in event.roles: + self._per_gameobject[role.gid].append(event) + if role.gid in self._per_gameobject_subscribers: + 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 + + Parameters + ---------- + cb: Callable[[LifeEvent], None] + Function to call + """ + self._subscribers.append(cb) + + def unsubscribe(self, cb: Callable[[Event], None]) -> None: + """ + Remove a function from being called whenever a LifeEvent occurs + + Parameters + ---------- + cb: Callable[[LifeEvent], None] + Function to call + """ + self._subscribers.remove(cb) + + def subscribe_to_gameobject(self, gid: int, cb: Callable[[Event], None]) -> None: + """ + Add a function to be called whenever the gameobject with the given gid + is involved in a LifeEvent + + Parameters + ---------- + gid: int + ID of the GameObject to subscribe to + + cb: Callable[[LifeEvent], None] + Callback function executed when a life event occurs + """ + self._per_gameobject_subscribers[gid].append(cb) + + def unsubscribe_from_gameobject( + self, gid: int, cb: Callable[[Event], None] + ) -> None: + """ + Remove a function from being called whenever the gameobject with the given gid + is involved in a LifeEvent + + Parameters + ---------- + gid: int + ID of the GameObject to subscribe to + + cb: Callable[[LifeEvent], None] + Callback function executed when a life event occurs + """ + if gid in self._per_gameobject_subscribers: + self._per_gameobject_subscribers[gid].remove(cb) + + def get_events_for(self, gid: int) -> List[Event]: + """ + Get all the LifeEvents where the GameObject with the given gid played a role + + Parameters + ---------- + gid: int + ID of the GameObject to retrieve events for + + Returns + ------- + List[Event] + Events recorded for the given GameObject + """ + return self._per_gameobject[gid] diff --git a/src/neighborly/core/life_event.py b/src/neighborly/core/life_event.py index 4e07af6..0ea6722 100644 --- a/src/neighborly/core/life_event.py +++ b/src/neighborly/core/life_event.py @@ -1,422 +1,295 @@ from __future__ import annotations -from abc import ABC, abstractmethod -from dataclasses import dataclass, field -from typing import Any, Callable, Dict, List, Optional, Protocol, Type +import logging +from abc import abstractmethod +from typing import Dict, List, Optional, Protocol, Tuple, Union -from neighborly.core.ecs import Component, GameObject, ISystem, World +from neighborly.core.ecs import GameObject, World from neighborly.core.engine import NeighborlyEngine +from neighborly.core.event import ( + Event, + EventEffectFn, + EventLog, + EventProbabilityFn, + EventRole, +) +from neighborly.core.query import Query +from neighborly.core.role import IRoleType, RoleBinderFn +from neighborly.core.system import System from neighborly.core.time import SimDateTime, TimeDelta from neighborly.core.town import Town +logger = logging.getLogger(__name__) -class LifeEvent: - """ - LifeEvents contain information about occurrences that - happened in the story world. - - Attributes - ---------- - name: str - Name of the event - timestamp: str - Timestamp for when the event occurred - roles: List[EventRole] - Roles that were involved in this event - """ - - __slots__ = "timestamp", "name", "roles", "metadata" - - def __init__( - self, name: str, timestamp: str, roles: List[EventRole], **kwargs - ) -> None: - self.name: str = name - self.timestamp: str = timestamp - self.roles: List[EventRole] = [*roles] - self.metadata: Dict[str, Any] = {**kwargs} - def get_type(self) -> str: - """Return the type of this event""" - return self.name +class ILifeEvent(Protocol): + """Interface for classes that create life events""" - def to_dict(self) -> Dict[str, Any]: - """Serialize this LifeEvent to a dictionary""" - return { - "name": self.name, - "timestamp": self.timestamp, - "roles": [role.to_dict() for role in self.roles], - } - - def __getitem__(self, role_name: str) -> int: - for role in self.roles: - if role.name == role_name: - return role.gid - raise KeyError(role_name) - - def __repr__(self) -> str: - return "LifeEvent(name={}, timestamp={}, roles=[{}], metadata={})".format( - self.name, self.timestamp, self.roles, self.metadata - ) - - def __str__(self) -> str: - return f"{self.name} [at {self.timestamp}] : {', '.join(map(lambda r: f'{r.name}:{r.gid}', self.roles))}" - - -class EventRole: - """ - EventRole is a role that has a GameObject bound to it. - It does not contain any information about filtering for - the role. - - Attributes - ---------- - name: str - Name of the role - gid: int - Unique identifier for the GameObject bound to this role - """ - - __slots__ = "name", "gid" - - def __init__(self, name: str, gid: int) -> None: - self.name: str = name - self.gid: int = gid - - def to_dict(self) -> Dict[str, Any]: - return {"name": self.name, "gid": self.gid} - - -class RoleBinderFn(Protocol): - """Callable that returns a GameObject that meets requirements for a given Role""" - - def __call__(self, world: World, event: LifeEvent) -> Optional[GameObject]: + @abstractmethod + def get_name(self) -> str: raise NotImplementedError - -class RoleFilterFn(Protocol): - """Function that filters GameObjects for an EventRole""" - - def __call__(self, world: World, gameobject: GameObject, **kwargs) -> bool: + @abstractmethod + def instantiate(self, world: World, **bindings: GameObject) -> Optional[Event]: + """Attempts to create a new Event instance""" raise NotImplementedError - -def join_filters(*filters: RoleFilterFn) -> RoleFilterFn: - """Joins a bunch of filters into one function""" - - def fn(world: World, gameobject: GameObject, **kwargs) -> bool: - return all([f(world, gameobject, **kwargs) for f in filters]) - - return fn - - -def or_filters( - *preconditions: RoleFilterFn, -) -> RoleFilterFn: - """Only one of the given preconditions has to pass to return True""" - - def wrapper(world: World, gameobject: GameObject, **kwargs: Any) -> bool: - for p in preconditions: - if p(world, gameobject, **kwargs): - return True - return False - - return wrapper - - -class AbstractEventRoleType(ABC): - """ - Abstract base class for defining roles that - GameObjects can be bound to when executing - LifeEvents - """ - - __slots__ = "name" - - def __init__(self, name: str) -> None: - self.name: str = name - @abstractmethod - def fill_role(self, world: World, event: LifeEvent) -> Optional[EventRole]: - """Find a GameObject to bind to this role given the event""" + def execute(self, world: World, event: Event) -> None: + """Executes the event""" raise NotImplementedError @abstractmethod - def fill_role_with( - self, world: World, event: LifeEvent, candidate: GameObject - ) -> Optional[EventRole]: - """Attempt to bind the candidate GameObject to this role given the event""" + def try_execute_event(self, world: World, **bindings: GameObject) -> bool: + """Attempts to instantiate and execute the event""" raise NotImplementedError -class EventRoleType(AbstractEventRoleType): +class LifeEventRoleType: """ Information about a role within a LifeEvent, and logic for how to filter which gameobjects can be bound to the role. """ - __slots__ = "binder_fn", "components", "filter_fn" + __slots__ = "binder_fn", "name" def __init__( self, name: str, - components: List[Type[Component]] = None, - filter_fn: Optional[RoleFilterFn] = None, binder_fn: Optional[RoleBinderFn] = None, ) -> None: - super().__init__(name) - self.components: List[Type[Component]] = components if components else [] - self.filter_fn: Optional[RoleFilterFn] = filter_fn + self.name: str = name self.binder_fn: Optional[RoleBinderFn] = binder_fn def fill_role( - self, - world: World, - event: LifeEvent, + self, world: World, event: Event, candidate: Optional[GameObject] = None ) -> Optional[EventRole]: - if self.binder_fn is not None: - obj = self.binder_fn(world, event) - return EventRole(self.name, obj.id) if obj is not None else None - - candidate_list: List[int] = list( - map( - lambda entry: entry[0], - filter( - lambda res: self.filter_fn( - world, world.get_gameobject(res[0]), event=event - ) - if self.filter_fn - else True, - world.get_components(*self.components), - ), - ) - ) - - if any(candidate_list): - chosen_candidate = world.get_resource(NeighborlyEngine).rng.choice( - candidate_list - ) - return EventRole(self.name, chosen_candidate) - - return None - - def fill_role_with( - self, - world: World, - event: LifeEvent, - candidate: GameObject, - ) -> Optional[EventRole]: - if candidate.has_component(*self.components): - if self.filter_fn and self.filter_fn(world, candidate, event=event): - return EventRole(self.name, candidate.id) + if self.binder_fn is None: + if candidate is None: + return None else: return EventRole(self.name, candidate.id) - return None + if gameobject := self.binder_fn(world, event, candidate): + return EventRole(self.name, gameobject.id) + return None -class LifeEventEffectFn(Protocol): - """Callback function called when a life event is executed""" - def __call__(self, world: World, event: LifeEvent) -> EventResult: - raise NotImplementedError +class LifeEvent: + """ + User-facing class for implementing behaviors around life events + This is adapted from: + https://github.com/ianhorswill/CitySimulator/blob/master/Assets/Codes/Action/Actions/ActionType.cs -class AbstractLifeEventType(ABC): - """ - Abstract base class for defining LifeEventTypes + Attributes + ---------- + name: str + Name of the LifeEventType and the LifeEvent it instantiates + roles: List[neighborly.core.life_event.LifeEventRoleType] + The roles that need to be cast for this event to be executed + probability: EventProbabilityFn + The relative frequency of this event compared to other events + effect: EventEffectFn + Function that executes changes to the world state base don the event """ - __slots__ = "name", "probability", "roles" + __slots__ = "name", "probability", "roles", "effect" def __init__( - self, name: str, roles: List[EventRoleType], frequency: float = 1.0 + self, + name: str, + roles: List[IRoleType], + probability: Union[EventProbabilityFn, float], + effect: Optional[EventEffectFn] = None, ) -> None: self.name: str = name - self.roles: List[EventRoleType] = roles - self.probability: float = frequency + self.roles: List[IRoleType] = roles + self.probability: EventProbabilityFn = ( + probability if callable(probability) else (lambda world, event: probability) + ) + self.effect: Optional[EventEffectFn] = effect + + def get_name(self) -> str: + return self.name - def instantiate(self, world: World, **kwargs: GameObject) -> Optional[LifeEvent]: + def instantiate(self, world: World, **bindings: GameObject) -> Optional[Event]: """ Attempts to create a new LifeEvent instance - **Do Not Override this method unless absolutely necessary** + Parameters + ---------- + world: World + Neighborly world instance + **bindings: Dict[str, GameObject] + Attempted bindings of GameObjects to RoleTypes """ - life_event = LifeEvent( - self.name, world.get_resource(SimDateTime).to_iso_str(), [] - ) + life_event = Event(self.name, world.get_resource(SimDateTime).to_iso_str(), []) for role_type in self.roles: - bound_object = kwargs.get(role_type.name) - if bound_object is not None: - temp = role_type.fill_role_with( - world, life_event, candidate=bound_object - ) - if temp is not None: - life_event.roles.append(temp) - else: - # Return none if the role candidate is not a fit - return None + filled_role = role_type.fill_role( + world, life_event, candidate=bindings.get(role_type.name) + ) + if filled_role is not None: + life_event.add_role(filled_role) # type: ignore else: - temp = role_type.fill_role(world, life_event) - if temp is not None: - life_event.roles.append(temp) - else: - # Return None if there are no available entities to fill - # the current role - return None + # Return None if there are no available entities to fill + # the current role + return None return life_event - def execute(self, world: World, event: LifeEvent) -> None: - return - - -@dataclass -class EventResult: - generated_events: List[LifeEvent] = field(default_factory=list) + def execute(self, world: World, event: Event) -> None: + """Run the effects function using the given event""" + world.get_resource(EventLog).record_event(world, event) + self.effect(world, event) + def try_execute_event(self, world: World, **bindings: GameObject) -> bool: + """ + Attempt to instantiate and execute this LifeEventType + + Parameters + ---------- + world: World + Neighborly world instance + **bindings: Dict[str, GameObject] + Attempted bindings of GameObjects to RoleTypes + + Returns + ------- + bool + Returns True if the event is instantiated successfully and executed + """ + event = self.instantiate(world, **bindings) + rng = world.get_resource(NeighborlyEngine).rng + if event is not None and rng.random() < self.probability(world, event): + self.execute(world, event) + return True + return False -class LifeEventType(AbstractLifeEventType): - """ - User-facing class for implementing behaviors around life events - - This is adapted from: - https://github.com/ianhorswill/CitySimulator/blob/master/Assets/Codes/Action/Actions/ActionType.cs - - Attributes - ---------- - name: str - Name of the LifeEventType and the LifeEvent it instantiates - roles: List[EventRoleType] - The roles that need to be cast for this event to be executed - probability: int (default: 1) - The relative frequency of this event compared to other events - execute_fn: Callable[..., None] - Function that executes changes to the world state base don the event - """ - __slots__ = "execute_fn" +class PatternLifeEvent: + __slots__ = "name", "probability", "pattern", "effect" def __init__( self, name: str, - roles: List[EventRoleType], - probability: float = 1.0, - execute_fn: Optional[LifeEventEffectFn] = None, + pattern: Query, + probability: Union[EventProbabilityFn, float] = 1.0, + effect: Optional[EventEffectFn] = None, ) -> None: - super().__init__(name, roles, probability) - self.execute_fn: Optional[LifeEventEffectFn] = execute_fn + self.name: str = name + self.pattern: Query = pattern + self.probability: EventProbabilityFn = ( + probability if callable(probability) else (lambda world, event: probability) + ) + self.effect: Optional[EventEffectFn] = effect - def execute(self, world: World, event: LifeEvent) -> None: - self.execute_fn(world, event) + def get_name(self) -> str: + return self.name + def _bind_roles( + self, world: World, **bindings: GameObject + ) -> Optional[Dict[str, int]]: + """Searches the ECS for a game object that meets the given conditions""" -class EventRoleLibrary: - _registry: Dict[str, EventRoleType] = {} + result_set = self.pattern.execute( + world, **{role_name: gameobject.id for role_name, gameobject in bindings} + ) - @classmethod - def add(cls, name: str, event_role_type: EventRoleType) -> None: - """Register a new LifeEventType mapped to a name""" - cls._registry[name] = event_role_type + if len(result_set): + chosen_result: Tuple[int, ...] = world.get_resource( + NeighborlyEngine + ).rng.choice(result_set) + return dict(zip(self.pattern.get_symbols(), chosen_result)) - @classmethod - def get_all(cls) -> List[EventRoleType]: - return list(cls._registry.values()) + return None - @classmethod - def get(cls, name: str) -> EventRoleType: - """Get a LifeEventType using a name""" - return cls._registry[name] + def instantiate(self, world: World, **bindings: GameObject) -> Optional[Event]: + """Create an event instance using the pattern""" + if roles := self._bind_roles(world, **bindings): + return Event( + name=self.name, + timestamp=world.get_resource(SimDateTime).to_iso_str(), + roles=[EventRole(n, gid) for n, gid in roles.items()], + ) + + return None + + def execute(self, world: World, event: Event) -> None: + """Run the effects function using the given event""" + world.get_resource(EventLog).record_event(world, event) + self.effect(world, event) + + def try_execute_event(self, world: World, **bindings: GameObject) -> bool: + """ + Attempt to instantiate and execute this LifeEventType + + Parameters + ---------- + world: World + Neighborly world instance + **bindings: Dict[str, GameObject] + Attempted bindings of GameObjects to RoleTypes + + Returns + ------- + bool + Returns True if the event is instantiated successfully and executed + """ + event = self.instantiate(world, **bindings) + rng = world.get_resource(NeighborlyEngine).rng + if event is not None and rng.random() < self.probability(world, event): + self.execute(world, event) + return True + return False -class LifeEventLibrary: +class LifeEvents: """ Static class used to store instances of LifeEventTypes for use at runtime. """ - _registry: Dict[str, LifeEventType] = {} + _registry: Dict[str, ILifeEvent] = {} @classmethod - def add(cls, life_event_type: LifeEventType, name: str = None) -> None: + def add(cls, life_event: ILifeEvent, name: Optional[str] = None) -> None: """Register a new LifeEventType mapped to a name""" - cls._registry[name if name else life_event_type.name] = life_event_type + key_name = name if name else life_event.get_name() + if key_name in cls._registry: + logger.debug(f"Overwriting LifeEventType: ({key_name})") + cls._registry[key_name] = life_event @classmethod - def get_all(cls) -> List[LifeEventType]: + def get_all(cls) -> List[ILifeEvent]: + """Get all LifeEventTypes stores in the Library""" return list(cls._registry.values()) @classmethod - def get(cls, name: str) -> LifeEventType: + def get(cls, name: str) -> ILifeEvent: """Get a LifeEventType using a name""" return cls._registry[name] -class LifeEventLog: - """ - Global resource for storing and accessing LifeEvents - """ - - __slots__ = "event_history", "_subscribers" - - def __init__(self) -> None: - self.event_history: List[LifeEvent] = [] - self._subscribers: List[Callable[[LifeEvent], None]] = [] - - def record_event(self, event: LifeEvent) -> None: - self.event_history.append(event) - for cb in self._subscribers: - cb(event) - - def subscribe(self, cb: Callable[[LifeEvent], None]) -> None: - self._subscribers.append(cb) - - def unsubscribe(self, cb: Callable[[LifeEvent], None]) -> None: - self._subscribers.remove(cb) - - -class LifeEventSimulator(ISystem): +class LifeEventSystem(System): """ LifeEventSimulator handles firing LifeEvents for characters - and performing character behaviors + and performing entity behaviors """ - __slots__ = "interval", "next_trigger" - - def __init__(self, interval: TimeDelta = None) -> None: - super().__init__() - self.interval: TimeDelta = interval if interval else TimeDelta(days=14) - self.next_trigger: SimDateTime = SimDateTime() + def __init__(self, interval: Optional[TimeDelta] = None) -> None: + super().__init__(interval=interval) - def try_execute_event(self, world: World, event_type: LifeEventType) -> None: - """Execute the given LifeEventType if successfully instantiated""" - event: LifeEvent = event_type.instantiate(world) - if event is not None: - if event_type.execute_fn is not None: - result = event_type.execute_fn(world, event) - for e in result.generated_events: - world.get_resource(LifeEventLog).record_event(e) - - def process(self, *args, **kwargs) -> None: + def run(self, *args, **kwargs) -> None: """Simulate LifeEvents for characters""" - date = self.world.get_resource(SimDateTime) - - if date < self.next_trigger: - return - else: - self.next_trigger = date.copy() + self.interval - town = self.world.get_resource(Town) rng = self.world.get_resource(NeighborlyEngine).rng # Perform number of events equal to 10% of the population - for _ in range(town.population // 10): - event_type = rng.choice(LifeEventLibrary.get_all()) - if rng.random() < event_type.probability: - self.try_execute_event(self.world, event_type) - - # for event_type in LifeEvents.events(): - # if rng.random() < event_type.probability: - # self.try_execute_event(self.world, event_type) + + 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/core/location.py b/src/neighborly/core/location.py index b6e35f1..90eadb0 100644 --- a/src/neighborly/core/location.py +++ b/src/neighborly/core/location.py @@ -1,80 +1,38 @@ from __future__ import annotations -from typing import Any, Dict, List, Optional, Set +from typing import Any, Dict from ordered_set import OrderedSet -from neighborly.core.ecs import Component, World +from neighborly.core.ecs import Component class Location(Component): """Anywhere where game characters may be""" - __slots__ = ( - "characters_present", - "max_capacity", - "name", - "whitelist", - "is_private", - "activity_flags", - ) + __slots__ = "entities" - def __init__( - self, - max_capacity: int, - name: str = "", - whitelist: Optional[List[int]] = None, - is_private: bool = False, - ) -> None: + def __init__(self) -> None: super().__init__() - self.name: str = name - self.max_capacity: int = max_capacity - self.characters_present: OrderedSet[int] = OrderedSet([]) - self.whitelist: Set[int] = set(whitelist if whitelist else []) - self.is_private: bool = is_private - self.activity_flags: int = 0 + self.entities: OrderedSet[int] = OrderedSet([]) def to_dict(self) -> Dict[str, Any]: return { **super().to_dict(), - "max_capacity": self.max_capacity, - "characters_present": list(self.characters_present), - "whitelist": list(self.whitelist), - "is_private": self.is_private, + "entities": list(self.entities), } - def can_enter(self, character_id: int) -> bool: - """Return true if the given character is allowed to enter this location""" - if self.is_private is False: - return True + def add_entity(self, entity: int) -> None: + self.entities.append(entity) - return character_id in self.whitelist + def remove_entity(self, entity: int) -> None: + self.entities.remove(entity) - def add_character(self, character: int) -> None: - self.characters_present.append(character) - - def remove_character(self, character: int) -> None: - self.characters_present.remove(character) - - def has_character(self, character: int) -> bool: - return character in self.characters_present - - def has_flags(self, *flags: int): - return all([self.activity_flags & f for f in flags]) + def has_entity(self, entity: int) -> bool: + return entity in self.entities def __repr__(self) -> str: - return "{}(name={}, present={}, max_capacity={}, whitelist={}, is_private={})".format( + return "{}(entities={})".format( self.__class__.__name__, - self.name, - self.characters_present, - self.max_capacity, - self.whitelist, - self.is_private, + self.entities, ) - - @classmethod - def create(cls, world: World, **kwargs) -> Location: - return cls(max_capacity=kwargs.get("max capacity", 9999)) - - def on_archive(self) -> None: - self.gameobject.remove_component(type(self)) diff --git a/src/neighborly/core/name_generation.py b/src/neighborly/core/name_generation.py index 12e11b8..aebe0dd 100644 --- a/src/neighborly/core/name_generation.py +++ b/src/neighborly/core/name_generation.py @@ -1,31 +1,15 @@ from pathlib import Path from typing import Dict, List, Optional, Union -import neighborly.core.utils.tracery as tracery -import neighborly.core.utils.tracery.modifiers as tracery_modifiers - -_all_name_rules: Dict[str, Union[str, List[str]]] = {} -_grammar: tracery.Grammar = tracery.Grammar(_all_name_rules) +import tracery as tracery +import tracery.modifiers as tracery_modifiers AnyPath = Union[str, Path] -def register_rule(self, name: str, rule: Union[str, List[str]]) -> None: - """Add a rule to the name factory""" - global _all_name_rules, _grammar - _all_name_rules[name] = rule - _grammar = tracery.Grammar(self._all_name_rules) - _grammar.add_modifiers(tracery_modifiers.base_english) - - -def get_name(self, seed_str: str) -> str: - """Return a name generated using the grammar rules""" - return self._grammar.flatten(seed_str) - - class TraceryNameFactory: """ - Generates town names using Tracery + Generates names using Tracery """ def __init__(self) -> None: diff --git a/src/neighborly/core/personal_values.py b/src/neighborly/core/personal_values.py index 9b4a12a..f12608e 100755 --- a/src/neighborly/core/personal_values.py +++ b/src/neighborly/core/personal_values.py @@ -1,7 +1,7 @@ from __future__ import annotations from enum import Enum -from typing import Dict, List, Optional +from typing import Any, Dict, List, Optional import numpy as np import numpy.typing as npt @@ -49,14 +49,14 @@ class ValueTrait(Enum): class PersonalValues(Component): """ - Values are what a character believes in. They are used + Values are what an entity believes in. They are used for decision-making and relationship compatibility among other things. Individual values are integers on the range [-50,50], inclusive. - This model of character values is borrowed from Dwarf Fortress' - model of character beliefs/values outlined at the following link + This model of entity values is borrowed from Dwarf Fortress' + model of entity beliefs/values outlined at the following link https://dwarffortresswiki.org/index.php/DF2014:Personality_trait """ @@ -113,6 +113,15 @@ def __str__(self) -> str: def __repr__(self) -> str: return "{}({})".format(self.__class__.__name__, self._traits.__repr__()) + def to_dict(self) -> Dict[str, Any]: + return { + **super().to_dict(), + "traits": { + str(p_value.value): self._traits[_VALUE_INDICES[str(p_value.value)]] + for p_value in list(PersonalValues) + }, + } + @classmethod def create(cls, world: World, **kwargs) -> Component: engine = world.get_resource(NeighborlyEngine) diff --git a/src/neighborly/core/position.py b/src/neighborly/core/position.py index 3009152..cc447db 100644 --- a/src/neighborly/core/position.py +++ b/src/neighborly/core/position.py @@ -8,6 +8,7 @@ @dataclass class Position2D(Component): + x: float = 0.0 y: float = 0.0 @@ -17,6 +18,3 @@ def to_dict(self) -> Dict[str, Any]: @classmethod def create(cls, world: World, **kwargs) -> Position2D: return Position2D(x=kwargs.get("x", 0.0), y=kwargs.get("y", 0.0)) - - def on_archive(self) -> None: - self.gameobject.remove_component(type(self)) diff --git a/src/neighborly/core/query.py b/src/neighborly/core/query.py new file mode 100644 index 0000000..c0ae551 --- /dev/null +++ b/src/neighborly/core/query.py @@ -0,0 +1,447 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Callable, Dict, List, Optional, Protocol, Tuple, Type + +import pandas as pd +from ordered_set import OrderedSet + +from neighborly.core.ecs import Component, GameObject, World + + +class EcsFindClause(Protocol): + def __call__(self, world: World) -> List[Tuple[Any, ...]]: + raise NotImplementedError + + +def has_components(*component_type: Type[Component]) -> EcsFindClause: + """Returns a list of all the GameObjects with the given component""" + + def precondition(world: World): + return list( + map(lambda result: (result[0],), world.get_components(*component_type)) + ) + + return precondition + + +def component_attr(component_type: Type[Component], attr: str) -> EcsFindClause: + """Returns a list of all the GameObjects with the given component""" + + def precondition(world: World): + return list( + map( + lambda result: (int(result[0]), getattr(result[1], attr)), + world.get_component(component_type), + ) + ) + + return precondition + + +def component_method( + component_type: Type[Component], method: str, *args, **kwargs +) -> EcsFindClause: + def precondition(world: World): + return list( + map( + lambda result: ( + int(result[0]), + getattr(result[1], method)(*args, **kwargs), + ), + world.get_component(component_type), + ) + ) + + return precondition + + +class Relation: + """ + Relation is a collection of values associated with variables + from a query. + """ + + __slots__ = "_data_frame" + + def __init__(self, data: pd.DataFrame) -> None: + self._data_frame: pd.DataFrame = data + + @classmethod + def create_empty(cls, *symbols: str) -> Relation: + """ + Create an empty Relation + + Parameters + ---------- + symbols: Tuple[str] + Starting symbols for the Relation + + Returns + ------- + Relation + """ + df = pd.DataFrame({s: [] for s in OrderedSet(symbols)}) + return Relation(df) + + @classmethod + def from_bindings(cls, **bindings: int) -> Relation: + """ + Creates a new Relation with symbols bound to starting values + + Parameters + ---------- + **bindings: Dict[str, Optional[int]] + Query variables mapped to GameObject IDs + + Returns + ------- + Relation + """ + df = pd.DataFrame( + {k: [v] if v is not None else [] for k, v in bindings.items()} + ) + return Relation(df) + + def get_symbols(self) -> Tuple[str]: + """Get the symbols contained in this relation""" + return tuple(self._data_frame.columns.tolist()) + + @property + def empty(self) -> bool: + """Returns True is the Relation has no data""" + return self._data_frame.empty + + def get_tuples(self, *symbols: str) -> List[Tuple[int, ...]]: + """ + Get tuples of results mapped to the given symbols + + Returns + ------- + List[Tuple[int, ...]] + Results contained in this relation of values mapped to the given symbols + """ + if symbols: + try: + return list( + self._data_frame[list(symbols)].itertuples(index=False, name=None) + ) + except KeyError: + # If any symbols are missing, return an empty list + return [] + else: + return list(self._data_frame.itertuples(index=False, name=None)) + + def get_data_frame(self) -> pd.DataFrame: + """Returns a Pandas DataFrame object representing the relation""" + return self._data_frame + + def unify(self, other: Relation) -> Relation: + """ + Joins two relations + + Parameters + ---------- + other: Relation + The relation to unify with + + Returns + ------- + Relation + A new Relation instance + """ + + if self.empty or other.empty: + return Relation.create_empty() + + shared_symbols = set(self.get_symbols()).intersection(set(other.get_symbols())) + + if shared_symbols: + new_data = self._data_frame.merge( + other._data_frame, on=list(shared_symbols) + ) + new_data.drop_duplicates() + return Relation(new_data) + + else: + new_data = self._data_frame.merge(other._data_frame, how="cross") + new_data.drop_duplicates() + return Relation(new_data) + + def copy(self) -> Relation: + """Create a deep copy of the relation""" + return Relation(self._data_frame.copy()) + + def __bool__(self) -> bool: + return self.empty + + def __str__(self) -> str: + return str(self._data_frame) + + def __repr__(self) -> str: + return self._data_frame.__repr__() + + +class IQueryClause(Protocol): + """A callable sued in a Query""" + + def __call__(self, ctx: QueryContext, world: World) -> Relation: + raise NotImplementedError + + +@dataclass +class QueryContext: + relation: Optional[Relation] = None + + +def le_(symbols: Tuple[str, str]): + """Query function that removes all instances where two variables are the same""" + + def run(ctx: QueryContext, world: World) -> Relation: + if ctx.relation is None: + raise RuntimeError("not_equal clause is missing relation within context") + + if ctx.relation.empty: + return ctx.relation.copy() + + df = ctx.relation.get_data_frame() + new_data = df[df[symbols[0]] <= df[symbols[1]]] + return Relation(pd.DataFrame(new_data)) + + return run + + +def lt_(symbols: Tuple[str, str]): + """Query function that removes all instances where two variables are the same""" + + def run(ctx: QueryContext, world: World) -> Relation: + if ctx.relation is None: + raise RuntimeError("not_equal clause is missing relation within context") + + if ctx.relation.empty: + return ctx.relation.copy() + + df = ctx.relation.get_data_frame() + new_data = df[df[symbols[0]] < df[symbols[1]]] + return Relation(pd.DataFrame(new_data)) + + return run + + +def ge_(symbols: Tuple[str, str]): + """Query function that removes all instances where two variables are the same""" + + def run(ctx: QueryContext, world: World) -> Relation: + if ctx.relation is None: + raise RuntimeError("not_equal clause is missing relation within context") + + if ctx.relation.empty: + return ctx.relation.copy() + + df = ctx.relation.get_data_frame() + new_data = df[df[symbols[0]] >= df[symbols[1]]] + return Relation(pd.DataFrame(new_data)) + + return run + + +def gt_(symbols: Tuple[str, str]): + """Query function that removes all instances where two variables are the same""" + + def run(ctx: QueryContext, world: World) -> Relation: + if ctx.relation is None: + raise RuntimeError("not_equal clause is missing relation within context") + + if ctx.relation.empty: + return ctx.relation.copy() + + df = ctx.relation.get_data_frame() + new_data = df[df[symbols[0]] > df[symbols[1]]] + return Relation(pd.DataFrame(new_data)) + + return run + + +def ne_(symbols: Tuple[str, str]): + """Query function that removes all instances where two variables are the same""" + + def run(ctx: QueryContext, world: World) -> Relation: + if ctx.relation is None: + raise RuntimeError("not_equal clause is missing relation within context") + + if ctx.relation.empty: + return ctx.relation.copy() + + df = ctx.relation.get_data_frame() + new_data = df[df[symbols[0]] != df[symbols[1]]] + return Relation(pd.DataFrame(new_data)) + + return run + + +def eq_(symbols: Tuple[str, str]): + """Query function that removes all instances where two variables are not the same""" + + def run(ctx: QueryContext, world: World) -> Relation: + if ctx.relation is None: + raise RuntimeError("equal clause is missing relation within context") + df = ctx.relation.get_data_frame() + new_data = df[df[symbols[0]] == df[symbols[1]]] + return Relation(pd.DataFrame(new_data)) + + return run + + +def where(fn: EcsFindClause, *symbols: str): + """Finds entities that match its given clause""" + + def run(ctx: QueryContext, world: World) -> Relation: + results = fn(world) + values_per_symbol = list(zip(*results)) + + if values_per_symbol: + data = {s: values_per_symbol[i] for i, s in enumerate(symbols)} + + if ctx.relation is None: + return Relation(pd.DataFrame(data)) + + return ctx.relation.unify(Relation(pd.DataFrame(data))) + + return Relation.create_empty() + + return run + + +def where_not(fn: EcsFindClause, *symbols: str): + """Performs a NOT operation removing entities that match its clause""" + + def run(ctx: QueryContext, world: World) -> Relation: + results = fn(world) + values_per_symbol = list(zip(*results)) + if values_per_symbol: + data = pd.DataFrame( + {s: values_per_symbol[i] for i, s in enumerate(symbols)} + ) + + if ctx.relation is None: + raise RuntimeError( + "where_not clause is missing relation within context" + ) + + new_data = ctx.relation.get_data_frame().merge( + data, how="outer", indicator=True + ) + new_data = new_data.loc[new_data["_merge"] == "left_only"] + new_data = new_data.drop(columns=["_merge"]) + new_relation = Relation(new_data) + return new_relation + else: + if ctx.relation is None: + return Relation.create_empty() + + return ctx.relation.copy() + + return run + + +def where_any(*clauses: IQueryClause): + """Performs an OR operation matching variables to any of the given clauses""" + + def run(ctx: QueryContext, world: World) -> Relation: + relations: List[Relation] = [] + + # Run evaluate each clause, union the result + for clause in clauses: + relation = clause(ctx, world) + relations.append(relation) + + new_relation = Relation( + pd.concat( + [*[r.get_data_frame() for r in relations]], + ignore_index=True, + ).drop_duplicates() + ) + + if ctx.relation is None: + return new_relation + else: + return ctx.relation.unify(new_relation) + + return run + + +def to_clause( + precondition: Callable[[World, GameObject], bool], *component_types: Type[Component] +) -> EcsFindClause: + """Wraps a precondition function and returns an ECSFindClause""" + + def fn(world: World): + results: List[Tuple[int, ...]] = [] + for gid, _ in world.get_components(*component_types): + gameobject = world.get_gameobject(gid) + if precondition(world, gameobject) is True: + results.append((gid,)) + return results + + return fn + + +class Query: + """ + Queries allow users to find one or more entities that match given specifications. + """ + + __slots__ = "_clauses", "_symbols" + + def __init__(self, find: Tuple[str, ...], clauses: List[IQueryClause]) -> None: + """ + _summary_ + + Parameters + ---------- + find : Tuple[str, ...] + Logical variable names used within the query and returned when executed + clauses : List[IQueryClause] + List of clauses executed to find entities + """ + self._clauses: List[IQueryClause] = clauses + self._symbols: Tuple[str, ...] = find + + def get_symbols(self) -> Tuple[str, ...]: + """Get the output symbols for this pattern""" + return self._symbols + + def execute(self, world: World, **bindings: int) -> List[Tuple[int, ...]]: + """ + Perform a query on the world instance + + Parameters + ---------- + world: World + The world instance to run the query on + + bindings: Dict[str, int] + Symbol strings mapped to GameObjectID to match to + + Returns + ------- + List[Tuple[int, ...]] + Tuples of GameObjectIDs that match the requirements of the query + """ + # Construct a starting relation with the variables mapped to values + ctx = QueryContext() + + if len(bindings): + ctx.relation = Relation.from_bindings(**bindings) + + for clause in self._clauses: + current_relation = clause(ctx, world) + ctx.relation = current_relation + + if ctx.relation.empty: + # Return an empty list because the last clause failed to + # find proper results + return [] + + # Return tuples for only the symbols specified in the constructor + return ctx.relation.get_tuples(*self._symbols) diff --git a/src/neighborly/core/relationship.py b/src/neighborly/core/relationship.py index a9b2fa2..9b606c2 100644 --- a/src/neighborly/core/relationship.py +++ b/src/neighborly/core/relationship.py @@ -1,105 +1,100 @@ from __future__ import annotations -from dataclasses import dataclass -from enum import IntFlag -from typing import Any, ClassVar, Dict, List +from typing import Any, Dict, List, Set -from neighborly.core.utils.graph import DirectedGraph +from neighborly.core.ecs import Component -FRIENDSHIP_MAX: float = 50 -FRIENDSHIP_MIN: float = -50 -ROMANCE_MAX: float = 50 -ROMANCE_MIN: float = -50 - - -def clamp(value: float, minimum: float, maximum: float) -> float: +def clamp(value: int, minimum: int, maximum: int) -> int: """Clamp a floating point value within a min,max range""" return min(maximum, max(minimum, value)) -class RelationshipTag(IntFlag): - """Relationship Tags are bitwise flags that indicate certain relationship types""" - - Acquaintance = 0 - Father = 1 << 0 - Mother = 1 << 1 - Parent = 1 << 2 - Brother = 1 << 3 - Sister = 1 << 4 - Sibling = 1 << 5 - Son = 1 << 6 - Daughter = 1 << 7 - Coworker = 1 << 8 - Boss = 1 << 9 - Spouse = 1 << 10 - Friend = 1 << 11 - Enemy = 1 << 12 - BestFriend = 1 << 13 - WorstEnemy = 1 << 14 - Rival = 1 << 15 - BiologicalFamily = 1 << 16 - Neighbors = 1 << 17 - SignificantOther = 1 << 18 - LoveInterest = 1 << 19 - Child = 1 << 20 - NuclearFamily = 1 << 21 - - -@dataclass -class RelationshipModifier: - """ - Modifiers apply buffs to the friendship, romance, and salience - stats on relationship instances +class RelationshipStat: - Attributes - ---------- - name: str - Name of the tag (used when searching for relationships with - specific tags) - description: str - String description of what this relationship modifier means - friendship_boost: float, default 0 - Flat value to apply the friendship value by - romance_boost: float, default 0 - Flat value to apply the romance value - salience_boost: float, default 0 - Flat value to apply to the salience value - friendship_increment: float, default 0 - Flat value to apply when incrementing the friendship value - romance_increment: float, default 0 - Flat value to apply when incrementing the romance value - salience_increment: float, default 0 - Flat value to apply when incrementing the salience value - """ + STAT_MAX: int = 50 + STAT_MIN: int = -50 + + __slots__ = ( + "_raw_value", + "_clamped_value", + "_normalized_value", + "_total_changes", + "_positive_changes", + "_negative_changes", + ) + + def __init__(self) -> None: + self._raw_value: int = 0 + self._clamped_value: int = 0 + self._normalized_value: float = 0.5 + self._total_changes: int = 0 + self._positive_changes: int = 0 + self._negative_changes: int = 0 + + @property + def raw(self) -> int: + return self._raw_value + + @property + def clamped(self) -> int: + return self._clamped_value + + @property + def value(self) -> float: + return self._normalized_value + + def increase(self, change: int) -> None: + """Increase the stat by n-times the increment value""" + self._total_changes += change + self._positive_changes += change + self._normalized_value = self._positive_changes / self._total_changes + self._raw_value += change + self._clamped_value = clamp(self._raw_value, self.STAT_MIN, self.STAT_MAX) + + def decrease(self, change: int) -> None: + """Increase the stat by n-times the increment value""" + self._total_changes += change + self._negative_changes += change + self._normalized_value = self._positive_changes / self._total_changes + self._raw_value -= change + self._clamped_value = clamp(self._raw_value, self.STAT_MIN, self.STAT_MAX) + + def __repr__(self) -> str: + return "{}(value={}, clamped={}, raw={})".format( + self.__class__.__name__, self.value, self.clamped, self.raw + ) + + def __lt__(self, other: float) -> bool: + return self._normalized_value < other + + def __gt__(self, other: float) -> bool: + return self._normalized_value > other - _tag_registry: ClassVar[Dict[str, RelationshipModifier]] = {} + def __le__(self, other: float) -> bool: + return self._normalized_value <= other - name: str - description: str = "" - friendship_boost: float = 0 - romance_boost: float = 0 - salience_boost: float = 0 - friendship_increment: float = 0 - romance_increment: float = 0 - salience_increment: float = 0 + def __ge__(self, other: float) -> bool: + return self._normalized_value <= other - @classmethod - def register_tag(cls, tag: RelationshipModifier) -> None: - """Add a tag to the internal registry for finding later""" - cls._tag_registry[tag.name] = tag + def __eq__(self, other: float) -> bool: + return self._normalized_value == other - @classmethod - def get_tag(cls, name: str) -> RelationshipModifier: - """Retrieve a tag from the internal registry of RelationshipModifiers""" - return cls._tag_registry[name] + def __ne__(self, other: float) -> bool: + return self._normalized_value != other + + def __int__(self) -> int: + return self._clamped_value + + def __float__(self) -> float: + return self._normalized_value class Relationship: """ Relationships are one of the core factors of a social simulation next to the characters. They - track how one character feels about another. And + track how one entity feels about another. And they evolve as a function of how many times two characters interact. @@ -113,25 +108,13 @@ class Relationship: _owner: int Character who owns the relationship _target: int - The character who this relationship is directed toward - _friendship: float + The entity who this relationship is directed toward + _friendship: RelationshipStat Friendship score on the scale [FRIENDSHIP_MIN, FRIENDSHIP_MAX] where a max means best friends and min means worst-enemies - _friendship_base: float - Friendship score without any modifiers from tags - _friendship_increment: float - Amount to increment the friendship score by after each interaction - _romance: float + _romance: RelationshipStat Romance score on the scale [ROMANCE_MIN, ROMANCE_MAX] where a max is complete infatuation and the min is repulsion - _romance_base: float - Romance score without any modifiers from tags - _romance_increment: float - Amount to increment the romance score by after each interaction - _is_dirty: bool - Used internally to mark when score need to be recalculated after a change - _modifiers: Dict[str, RelationshipModifier] - All the modifiers active on the Relationship instance _tags: RelationshipTag All the tags that are attached to this """ @@ -140,39 +123,16 @@ class Relationship: "_owner", "_target", "_friendship", - "_friendship_base", - "_friendship_increment", - "_compatibility", "_romance", - "_romance_base", - "_romance_increment", - "_is_dirty", - "_modifiers", "_tags", - "_romance_disabled", ) - def __init__( - self, - owner: int, - target: int, - base_friendship: int = 0, - base_romance: int = 0, - tags: RelationshipTag = RelationshipTag.Acquaintance, - compatibility: float = 0.1, - romance_disabled: bool = False, - ) -> None: + def __init__(self, owner: int, target: int) -> None: self._owner: int = owner self._target: int = target - self._friendship: float = base_friendship - self._romance: float = 0 - self._friendship_base: float = 0 - self._romance_base: float = base_romance - self._is_dirty: bool = True - self._modifiers: Dict[str, RelationshipModifier] = {} - self._tags: RelationshipTag = tags - self._compatibility: float = compatibility - self._romance_disabled: bool = romance_disabled + self._friendship: RelationshipStat = RelationshipStat() + self._romance: RelationshipStat = RelationshipStat() + self._tags: Set[str] = set() @property def target(self) -> int: @@ -183,145 +143,85 @@ def owner(self) -> int: return self._owner @property - def friendship(self) -> float: - if self._is_dirty: - self._recalculate_stats() + def friendship(self) -> RelationshipStat: return self._friendship @property - def romance(self) -> float: - if self._is_dirty: - self._recalculate_stats() + def romance(self) -> RelationshipStat: return self._romance - def set_compatibility(self, value: float) -> None: - self._compatibility = value - - def increment_friendship(self, value: float) -> None: - self._friendship_base += value - self._is_dirty = True - - def increment_romance(self, value: float) -> None: - self._romance_base += value - self._is_dirty = True - - def add_tags(self, tags: RelationshipTag) -> None: + def add_tags(self, *tags: str) -> None: """Return add a tag to this Relationship""" - self._tags |= tags + for tag in tags: + self._tags.add(tag) - def has_tags(self, tags: RelationshipTag) -> bool: + def has_tag(self, tag: str) -> bool: """Return True if a relationship has a tag""" - return bool(self._tags & tags) + return tag in self._tags - def remove_tags(self, tags: RelationshipTag) -> None: + def remove_tags(self, *tags: str) -> None: """Return True if a relationship has a tag""" - self._tags = self._tags & (~tags) - - def get_modifiers(self) -> List[RelationshipModifier]: - """Return a list of the modifiers attached to this Relationship instance""" - return list(self._modifiers.values()) - - def has_modifier(self, name: str) -> bool: - """Return true if the relationship has a modifier with the given name""" - return name in self._modifiers - - def add_modifier(self, modifier: RelationshipModifier) -> None: - """Add a RelationshipModifier to the relationship instance""" - self._modifiers[modifier.name] = modifier - self._is_dirty = True - - def remove_modifier(self, name: str) -> None: - """Remove modifier with the given name""" - del self._modifiers[name] - self._is_dirty = True - - def update(self) -> None: - """Update the relationship when the two characters interact""" - if self._is_dirty: - self._recalculate_stats() - - self._romance_base += self._romance_increment - self._friendship_base += self._friendship_increment - - self._recalculate_stats() + for tag in tags: + self._tags.remove(tag) def to_dict(self) -> Dict[str, Any]: return { "owner": self.owner, "target": self.target, - "friendship": self.friendship, - "romance": self.romance, - "tags": self._tags, - "modifiers": [m for m in self._modifiers.keys()], + "friendship": float(self.friendship), + "friendship_clamped": self.friendship.clamped, + "romance": float(self.romance), + "romance_clamped": self.romance.clamped, + "tags": list(self._tags), } - def _recalculate_stats(self) -> None: - """Recalculate all the stats after a change""" - # Reset the increments - self._romance_increment = 0.0 - self._friendship_increment = 0.0 - - # Reset final values back to base values - self._friendship = self._friendship_base - self._romance = self._romance_base - - # Apply modifiers in tags - for modifier in self._modifiers.values(): - # Apply boosts to relationship scores - self._romance += modifier.romance_boost - self._friendship += modifier.friendship_boost - # Apply increment boosts - self._romance_increment += modifier.romance_increment - self._friendship_increment += modifier.friendship_increment - - self._romance = clamp(self._romance, ROMANCE_MIN, ROMANCE_MAX) - self._friendship = clamp(self._friendship, FRIENDSHIP_MIN, FRIENDSHIP_MAX) - - self._is_dirty = False - def __repr__(self) -> str: - return "{}(owner={}, target={}, romance={}, friendship={}, tags={}, modifiers={})".format( + return "{}(owner={}, target={}, romance={}, friendship={}, tags={})".format( self.__class__.__name__, self.owner, self.target, self.romance, self.friendship, self._tags, - list(self._modifiers.keys()), ) -class RelationshipGraph(DirectedGraph[Relationship]): +class Relationships(Component): + """Manages relationship instances between this GameObject and others""" + + __slots__ = "_relationships" + def __init__(self) -> None: super().__init__() + self._relationships: Dict[int, Relationship] = {} - def add_relationship(self, relationship: Relationship) -> None: - """Add a new relationship to the graph""" - self.add_connection(relationship.owner, relationship.target, relationship) + def get(self, target: int) -> Relationship: + """Get an existing or new relationship to the target GameObject""" + if target not in self._relationships: + self._relationships[target] = Relationship(self.gameobject.id, target) + return self._relationships[target] - def get_relationships(self, owner: int) -> List[Relationship]: - """Get all the outgoing relationships for this character""" - owner_node = self._nodes[owner] - return [self._edges[owner, target] for target in owner_node.outgoing] - - def get_all_relationships_with_tags( - self, owner: int, tags: RelationshipTag - ) -> List[Relationship]: - owner_node = self._nodes[owner] + def get_all(self) -> List[Relationship]: + return list(self._relationships.values()) + def get_all_with_tags(self, *tags: str) -> List[Relationship]: + """ + Get all the relationships between a character and others with specific tags + """ return list( filter( - lambda rel: rel.has_tags(tags), - [self._edges[owner, target] for target in owner_node.outgoing], + lambda rel: all([rel.has_tag(t) for t in tags]), + self._relationships.values(), ) ) - def to_dict(self) -> Dict[int, Dict[int, Dict[str, Any]]]: - network_dict: Dict[int, Dict[int, Dict[str, Any]]] = {} + def to_dict(self) -> Dict[str, Any]: + return {k: v.to_dict() for k, v in self._relationships.items()} - for character_id in self._nodes.keys(): - network_dict[character_id] = {} - for relationship in self.get_relationships(character_id): - network_dict[character_id][relationship.target] = relationship.to_dict() + def __getitem__(self, item: int) -> Relationship: + if item not in self._relationships: + self._relationships[item] = Relationship(self.gameobject.id, item) + return self._relationships[item] - return network_dict + def __contains__(self, item: int) -> bool: + return item in self._relationships diff --git a/src/neighborly/core/residence.py b/src/neighborly/core/residence.py index b1eb3d0..a018de2 100644 --- a/src/neighborly/core/residence.py +++ b/src/neighborly/core/residence.py @@ -18,10 +18,6 @@ def __init__(self) -> None: self.former_owners: OrderedSet[int] = OrderedSet([]) self.residents: OrderedSet[int] = OrderedSet([]) self.former_residents: OrderedSet[int] = OrderedSet([]) - self._vacant: bool = True - - def on_archive(self) -> None: - self.gameobject.remove_component(type(self)) def to_dict(self) -> Dict[str, Any]: return { @@ -30,7 +26,6 @@ def to_dict(self) -> Dict[str, Any]: "former_owners": list(self.former_owners), "residents": list(self.residents), "former_residents": list(self.former_residents), - "vacant": self._vacant, } def add_owner(self, owner: int) -> None: @@ -42,28 +37,22 @@ def remove_owner(self, owner: int) -> None: self.owners.remove(owner) def is_owner(self, character: int) -> bool: - """Return True if the character is an owner of this residence""" + """Return True if the entity is an owner of this residence""" return character in self.owners def add_resident(self, resident: int) -> None: """Add a tenant to this residence""" self.residents.add(resident) - self._vacant = False def remove_resident(self, resident: int) -> None: """Remove a tenant rom this residence""" self.residents.remove(resident) self.former_residents.add(resident) - self._vacant = len(self.residents) == 0 def is_resident(self, character: int) -> bool: - """Return True if the given character is a resident""" + """Return True if the given entity is a resident""" return character in self.residents - def is_vacant(self) -> bool: - """Return True if the residence is vacant""" - return self._vacant - class Resident(Component): """Component attached to characters indicating that they live in the town""" @@ -74,12 +63,5 @@ def __init__(self, residence: int) -> None: super().__init__() self.residence: int = residence - def on_remove(self) -> None: - world = self.gameobject.world - residence = world.get_gameobject(self.residence).get_component(Residence) - residence.remove_resident(self.gameobject.id) - if residence.is_owner(self.gameobject.id): - residence.remove_owner(self.gameobject.id) - - def on_archive(self) -> None: - self.gameobject.remove_component(type(self)) + def to_dict(self) -> Dict[str, Any]: + return {**super().to_dict(), "residence": self.residence} diff --git a/src/neighborly/core/rng.py b/src/neighborly/core/rng.py deleted file mode 100644 index 3ec8f55..0000000 --- a/src/neighborly/core/rng.py +++ /dev/null @@ -1,101 +0,0 @@ -import random -from abc import abstractmethod -from typing import List, MutableSequence, Optional, Protocol, Sequence, TypeVar - -_T = TypeVar("_T") - - -class IRandNumGenerator(Protocol): - """Abstract interface for a class that handles generating random numbers""" - - @abstractmethod - def random(self) -> float: - """Return a random float between [0.0, 1.0] inclusive""" - raise NotImplementedError() - - @abstractmethod - def choice(self, s: Sequence[_T]) -> _T: - """Return an item from this sequence""" - raise NotImplementedError() - - @abstractmethod - def choices( - self, - s: Sequence[_T], - weights: Optional[List[int]] = None, - cum_weights: Optional[List[int]] = None, - k: int = 1, - ) -> List[_T]: - """Return one or more items from a sequence using weighted random selection""" - raise NotImplementedError() - - @abstractmethod - def sample(self, s: Sequence[_T], n: int) -> List[_T]: - """Return an n-number of random items from the sequence""" - raise NotImplementedError() - - @abstractmethod - def normal(self, mu: float, sigma: float) -> float: - """Return an item from this sequence""" - raise NotImplementedError() - - @abstractmethod - def uniform(self, low: float, high: float) -> float: - """Return an item from this sequence""" - raise NotImplementedError() - - @abstractmethod - def randint(self, low: int, high: int) -> int: - """Return an item from this sequence""" - raise NotImplementedError() - - @abstractmethod - def shuffle(self, seq: MutableSequence) -> None: - """Shuffle the items in a sequence""" - raise NotImplementedError() - - -class DefaultRNG: - """Default RNG that wraps an instance of Python's random""" - - __slots__ = "_rng" - - def __init__(self, seed: Optional[int] = None) -> None: - self._rng: random.Random = random.Random(seed) - - def random(self) -> float: - """Return a random float between [0.0, 1.0] inclusive""" - return self._rng.random() - - def choice(self, s: Sequence[_T]) -> _T: - """Return an item from this sequence""" - return self._rng.choice(s) - - def choices( - self, - s: Sequence[_T], - weights: Optional[List[int]] = None, - cum_weights: Optional[List[int]] = None, - k: int = 1, - ) -> List[_T]: - """Return one or more items from a sequence using weighted random selection""" - return self._rng.choices(s, weights=weights, cum_weights=cum_weights, k=k) - - def sample(self, s: Sequence[_T], n: int) -> List[_T]: - """Return an n-number of random items from the sequence""" - return self._rng.sample(s, k=n) - - def normal(self, mu: float, sigma: float) -> float: - """Return an item from this sequence""" - return self._rng.gauss(mu, sigma) - - def uniform(self, low: float, high: float) -> float: - """Return an item from this sequence""" - return self._rng.uniform(low, high) - - def randint(self, low: int, high: int) -> int: - """Return an item from this sequence""" - return self._rng.randint(low, high) - - def shuffle(self, seq: MutableSequence) -> None: - self._rng.shuffle(seq) diff --git a/src/neighborly/core/role.py b/src/neighborly/core/role.py new file mode 100644 index 0000000..bf26e06 --- /dev/null +++ b/src/neighborly/core/role.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from abc import abstractmethod +from typing import Optional, Protocol + +from neighborly import GameObject, World +from neighborly.core.event import Event, EventRole + + +class IRoleType(Protocol): + """ + Interface for defining roles that GameObjects can be bound to when executing life + events + """ + + @abstractmethod + def fill_role( + self, world: World, event: Event, candidate: Optional[GameObject] = None + ) -> Optional[EventRole]: + """ + Attempt to bind a role to a GameObject + + Parameters + ---------- + world: World + Current World instance + + event: Event + The event that we are binding a role for + + candidate: Optional[GameObject] (optional) + ID of the GameObject we want to bind this role to. + If not specified, the role will search for a Gameobject that matches + + Returns + ------- + Optional[EventRole] + """ + raise NotImplementedError + + +class RoleBinderFn(Protocol): + """Callable that returns a GameObject that meets requirements for a given Role""" + + def __call__( + self, world: World, event: Event, candidate: Optional[GameObject] = None + ) -> Optional[GameObject]: + raise NotImplementedError + + +class RoleFilterFn(Protocol): + """Function that filters GameObjects for an EventRole""" + + def __call__(self, world: World, gameobject: GameObject) -> bool: + raise NotImplementedError diff --git a/src/neighborly/core/routine.py b/src/neighborly/core/routine.py index 0168b1c..56e93aa 100644 --- a/src/neighborly/core/routine.py +++ b/src/neighborly/core/routine.py @@ -1,37 +1,10 @@ from __future__ import annotations from enum import IntEnum -from typing import Dict, List, Optional, Tuple, Union - -from neighborly.core.ecs import Component - -TIME_ALIAS = { - "early morning": "02:00", - "dawn": "06:00", - "morning": "08:00", - "late-morning": "10:00", - "noon": "12:00", - "afternoon": "14:00", - "evening": "17:00", - "night": "21:00", - "midnight": "23:00", -} - -DAY_ALIAS = { - "weekdays": "MTWRF", - "weekends": "SU", - "everyday": "MTWRFSU", -} - -DAY_ABBREVIATION = { - "M": "Monday", - "T": "Tuesday", - "W": "Wednesday", - "R": "Thursday", - "F": "Friday", - "S": "Saturday", - "U": "Sunday", -} +from typing import Dict, List, Optional, Set, Tuple, Union + +from neighborly.core.ecs import Component, World +from neighborly.core.time import Weekday class RoutinePriority(IntEnum): @@ -41,14 +14,32 @@ class RoutinePriority(IntEnum): class RoutineEntry: + """ + An entry within a routine for when an entity needs to be + at a specific location and for how long + + Attributes + ---------- + start: int + The time that this routine task begins + end: int + The time that this routine task ends + priority: RoutinePriority + The priority associated with this task. High priority tasks + override lower priority tasks + location: Union[str, int] + The location or location alias for a location. Location + aliases can be looked up on the GameCharacter class + tags: Set[str] + A set of tags associated with this entry + """ + __slots__ = ( "start", "end", "priority", - "description", "tags", "location", - "activity", ) def __init__( @@ -56,47 +47,57 @@ def __init__( start: int, end: int, location: Union[str, int], - activity: str, priority: RoutinePriority = RoutinePriority.LOW, tags: Optional[List[str]] = None, ) -> None: self.start: int = start self.end: int = end self.location: Union[str, int] = location - self.activity: str = activity self.priority: RoutinePriority = priority - self.tags: List[str] = [*tags] if tags else [] + self.tags: Set[str] = set(*tags) if tags else set() + if start < 0 or start > 23: raise ValueError("Start time must be within range [0, 23] inclusive.") - if end < 0 or end > 24: + if end < 0 or end > 23: raise ValueError("End time must be within range [0,23] inclusive.") - if start >= end: - raise ValueError("Start time must be less than end time.") def __repr__(self) -> str: - return "{}({:2d}:00-{:2d}:00, location={}. activity={}, priority={}, tags={})".format( + return "{}({:2d}:00-{:2d}:00, location={}, priority={}, tags={})".format( self.__class__.__name__, self.start, self.end, self.location, - self.activity, self.priority, self.tags, ) class DailyRoutine: + """ + A collection of RoutineEntries that manage where an + entity should be for a given day + + Attributes + ---------- + _entries: Dict[str, RoutineEntry] + All the RoutineEntry instances associated with this DailyRoutine + _tracks: Dict[RoutinePriority, List[List[RoutineEntry]] + Pointers to the RoutineEntry instances organized by hour + """ + __slots__ = "_entries", "_tracks" def __init__(self) -> None: - self._entries: List[RoutineEntry] = [] - self._tracks: dict[RoutinePriority, List[List[RoutineEntry]]] = { + self._entries: Dict[str, RoutineEntry] = {} + # Each track holds 24 slots, one for each hour + # Each slot holds a list of events registered to that time + self._tracks: Dict[RoutinePriority, List[List[RoutineEntry]]] = { RoutinePriority.LOW: [list() for _ in range(24)], RoutinePriority.MED: [list() for _ in range(24)], RoutinePriority.HIGH: [list() for _ in range(24)], } - def get_entries(self, hour: int) -> List[RoutineEntry]: + def get(self, hour: int) -> List[RoutineEntry]: """Get highest-priority entries for a given hour""" high_priority_entries = self._tracks[RoutinePriority.HIGH][hour] if high_priority_entries: @@ -108,29 +109,57 @@ def get_entries(self, hour: int) -> List[RoutineEntry]: return self._tracks[RoutinePriority.LOW][hour] - def add_entries(self, *entries: RoutineEntry) -> None: - """Add an entry to the DailyRoutine""" - for entry in entries: - if entry in self._entries: - continue + def add(self, entry_id: str, entry: RoutineEntry) -> None: + """ + Add an entry to the DailyRoutine - self._entries.append(entry) + Parameters + ---------- + entry_id: str + Unique ID used to remove this entry at a later time + entry: RoutineEntry + Entry to add to the routine + """ + if entry_id in self._entries: + return - track = self._tracks[entry.priority] + self._entries[entry_id] = entry + track = self._tracks[entry.priority] + + if entry.start > entry.end: + duration = (23 - entry.start) + entry.end + for hour in range(entry.start, entry.start + duration): + track[hour % 24].append(entry) + + else: for hour in range(entry.start, entry.end): track[hour].append(entry) - def remove_entries(self, *entries: RoutineEntry) -> None: - """Remove an entry from this DailyRoutine""" - for entry in entries: - self._entries.remove(entry) + def remove(self, entry_id: str) -> None: + """ + Remove an entry from this DailyRoutine + + Parameters + ---------- + entry_id: str + Unique ID of the entry to remove from the routine + """ + entry = self._entries[entry_id] - track = self._tracks[entry.priority] + track = self._tracks[entry.priority] + if entry.start > entry.end: + duration = (23 - entry.start) + entry.end + for hour in range(entry.start, entry.start + duration): + track[hour % 24].remove(entry) + + else: for hour in range(entry.start, entry.end): track[hour].remove(entry) + del self._entries[entry_id] + def __repr__(self) -> str: return "{}({})".format( self.__class__.__name__, @@ -140,42 +169,67 @@ def __repr__(self) -> str: class Routine(Component): """ - Manage a character's routine for the week + Collection of DailyRoutine Instances that manages an entity's + behavior for a 7-day week + + Attributes + ---------- + _daily_routines: Tuple[DailyRoutine, DailyRoutine, DailyRoutine, + DailyRoutine, DailyRoutine, DailyRoutine, DailyRoutine] + DailyRoutines in order from day zero to seven """ __slots__ = "_daily_routines" def __init__(self) -> None: super().__init__() - self._daily_routines: Dict[str, DailyRoutine] = { - "monday": DailyRoutine(), - "tuesday": DailyRoutine(), - "wednesday": DailyRoutine(), - "thursday": DailyRoutine(), - "friday": DailyRoutine(), - "saturday": DailyRoutine(), - "sunday": DailyRoutine(), - } - - def get_entries(self, day: str, hour: int) -> List[RoutineEntry]: + self._daily_routines: List[DailyRoutine] = [ + DailyRoutine(), + DailyRoutine(), + DailyRoutine(), + DailyRoutine(), + DailyRoutine(), + DailyRoutine(), + DailyRoutine(), + ] + + def get_entry(self, day: int, hour: int) -> Optional[RoutineEntry]: + """Get a single activity for a given day and time""" + entries = self._daily_routines[day].get(hour) + if entries: + return entries[-1] + return None + + def get_entries(self, day: int, hour: int) -> List[RoutineEntry]: """Get the scheduled activity for a given day and time""" - try: - return self._daily_routines[day.lower()].get_entries(hour) - except KeyError as e: - raise ValueError(f"Expected day of the week, but received '{day}'") from e + return self._daily_routines[day].get(hour) - def add_entries(self, days: List[str], *entries: RoutineEntry) -> None: + def add_entries( + self, entry_id: str, days: List[int], *entries: RoutineEntry + ) -> None: """Add one or more entries to the daily routines on the given days""" for day in days: - self._daily_routines[day].add_entries(*entries) + for entry in entries: + self._daily_routines[day].add(entry_id, entry) - def remove_entries(self, days: List[str], *entries: RoutineEntry) -> None: + def remove_entries(self, days: List[int], entry_id: str) -> None: """Remove one or more entries from the daily routines on the given days""" for day in days: - self._daily_routines[day].remove_entries(*entries) + self._daily_routines[day].remove(entry_id) - def on_archive(self) -> None: - self.gameobject.remove_component(type(self)) + @classmethod + def create(cls, world: World, **kwargs) -> Component: + routine = cls() + + presets = kwargs.get("presets") + + if presets == "default": + at_home = RoutineEntry(20, 8, "home") + routine.add_entries( + "at_home_default", [d.value for d in list(Weekday)], at_home + ) + + return routine def __repr__(self) -> str: return f"Routine({self._daily_routines})" @@ -205,11 +259,14 @@ def time_str_to_int(s: str) -> int: # This is a 12-hour string return (int(s.lower().split("pm")[0].strip()) % 12) + 12 else: - # Make no assumptions about what time they are using - # throw an error to be safe - raise ValueError( - f"Given string '{s}' is not of the form ##:00 or ##AM or ##PM." - ) + try: + return int(s) + except ValueError: + # Make no assumptions about what time they are using + # throw an error to be safe + raise ValueError( + f"Given string '{s}' is not an int or of the form ##:00 or ##AM or ##PM." + ) def parse_schedule_str(s: str) -> Dict[str, Tuple[int, int]]: @@ -234,6 +291,25 @@ def parse_schedule_str(s: str) -> Dict[str, Tuple[int, int]]: - weekdays 9:00 to 17:00, weekends 10:00 to 15:00 - weekdays morning to evening, weekends late-morning to evening """ + + day_alias = { + "weekdays": "MTWRF", + "weekends": "SU", + "everyday": "MTWRFSU", + } + + time_alias = { + "early morning": "02:00", + "dawn": "06:00", + "morning": "08:00", + "late-morning": "10:00", + "noon": "12:00", + "afternoon": "14:00", + "evening": "17:00", + "night": "21:00", + "midnight": "23:00", + } + clauses = s.split(",") schedule: dict[str, tuple[int, int]] = {} @@ -243,20 +319,20 @@ def parse_schedule_str(s: str) -> Dict[str, Tuple[int, int]]: # skip the "to" days, start_str, _, end_str = clause.split(" ") - if days in DAY_ALIAS: - days = DAY_ALIAS[days] + if days in day_alias: + days = day_alias[days] - if start_str in TIME_ALIAS: - start_str = TIME_ALIAS[start_str] + if start_str in time_alias: + start_str = time_alias[start_str] - if end_str in TIME_ALIAS: - end_str = TIME_ALIAS[end_str] + if end_str in time_alias: + end_str = time_alias[end_str] start_hour = time_str_to_int(start_str) end_hour = time_str_to_int(end_str) for abbrev in days: - day = DAY_ABBREVIATION[abbrev] + day = str(Weekday.from_abbr(abbrev)) schedule[day] = (start_hour, end_hour) return schedule diff --git a/src/neighborly/core/serializable.py b/src/neighborly/core/serializable.py new file mode 100644 index 0000000..88a7627 --- /dev/null +++ b/src/neighborly/core/serializable.py @@ -0,0 +1,11 @@ +from abc import ABC, abstractmethod +from typing import Any, Dict + + +class ISerializable(ABC): + """Interface implemented by objects that can be serialized by an exporter""" + + @abstractmethod + def to_dict(self) -> Dict[str, Any]: + """Serialize the object to a dictionary""" + raise NotImplementedError diff --git a/src/neighborly/core/status.py b/src/neighborly/core/status.py deleted file mode 100644 index 4a8bb74..0000000 --- a/src/neighborly/core/status.py +++ /dev/null @@ -1,23 +0,0 @@ -from abc import ABC - -from neighborly.core.ecs import Component - - -class Status(Component, ABC): - """ - A temporary or permanent status applied to a GameObject - - Attributes - ---------- - name: str - Name of the status - description: str - Brief description of what the status does - """ - - __slots__ = "name", "description" - - def __init__(self, name: str, description: str) -> None: - super().__init__() - self.name: str = name - self.description: str = description diff --git a/src/neighborly/core/system.py b/src/neighborly/core/system.py new file mode 100644 index 0000000..6abd35c --- /dev/null +++ b/src/neighborly/core/system.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from abc import abstractmethod +from typing import Optional + +from neighborly.core.ecs import ISystem +from neighborly.core.time import SimDateTime, TimeDelta + + +class System(ISystem): + """ + System is a more fully-featured System abstraction that + handles common calculations like calculating the elapsed + time between calls. + """ + + __slots__ = "_interval", "_last_run", "_elapsed_time", "_next_run" + + def __init__( + self, + interval: Optional[TimeDelta] = None, + ) -> None: + super().__init__() + self._last_run: Optional[SimDateTime] = None + self._interval: TimeDelta = interval if interval else TimeDelta() + self._next_run: SimDateTime = SimDateTime() + self._interval + self._elapsed_time: TimeDelta = TimeDelta() + + @property + def elapsed_time(self) -> TimeDelta: + """Returns the amount of simulation time since the last update""" + return self._elapsed_time + + def process(self, *args, **kwargs) -> None: + """Handles internal bookkeeping before running the system""" + date = self.world.get_resource(SimDateTime) + + if date >= self._next_run: + if self._last_run is None: + self._elapsed_time = TimeDelta() + else: + self._elapsed_time = date - self._last_run + self._last_run = date.copy() + self._next_run = date + self._interval + self.run(*args, **kwargs) + + @abstractmethod + def run(self, *args, **kwargs) -> None: + raise NotImplementedError diff --git a/src/neighborly/core/time.py b/src/neighborly/core/time.py index d4e5020..2832e15 100644 --- a/src/neighborly/core/time.py +++ b/src/neighborly/core/time.py @@ -1,6 +1,7 @@ from __future__ import annotations from dataclasses import dataclass +from enum import IntEnum from typing import List HOURS_PER_DAY = 24 @@ -19,20 +20,43 @@ *(["night"] * 5), # (19:00-23:59) ] -_DAYS_OF_WEEK: List[str] = [ - "Sunday", - "Monday", - "Tuesday", - "Wednesday", - "Thursday", - "Friday", - "Saturday", -] +class Weekday(IntEnum): + Sunday = 0 + Monday = 1 + Tuesday = 2 + Wednesday = 3 + Thursday = 4 + Friday = 5 + Saturday = 6 + + def __str__(self) -> str: + return self.name + + def abbr(self) -> str: + abbreviations = [ + "M", + "T", + "W", + "R", + "F", + "S", + "U", + ] + return abbreviations[self.value()] -def get_time_of_day(hour: int) -> str: - """Return a string corresponding to the time of day for the given hour""" - return _TIME_OF_DAY[hour] + @classmethod + def from_abbr(cls, value: str) -> Weekday: + abbreviations = { + "M": Weekday.Monday, + "T": Weekday.Tuesday, + "W": Weekday.Wednesday, + "R": Weekday.Thursday, + "F": Weekday.Friday, + "S": Weekday.Saturday, + "U": Weekday.Sunday, + } + return abbreviations[value] @dataclass(frozen=True) @@ -168,7 +192,7 @@ def delta_time(self) -> int: @property def weekday_str(self) -> str: - return _DAYS_OF_WEEK[self._weekday] + return str(Weekday(self._weekday)) def copy(self) -> SimDateTime: return SimDateTime( @@ -210,21 +234,37 @@ def __add__(self, other: TimeDelta) -> SimDateTime: """Add a TimeDelta to this data""" if not isinstance(other, TimeDelta): raise TypeError(f"expected TimeDelta object but was {type(other)}") + date_copy = self.copy() + date_copy.increment( + hours=other.hours, days=other.days, months=other.months, years=other.years + ) + return date_copy + + def __iadd__(self, other: TimeDelta) -> SimDateTime: self.increment( hours=other.hours, days=other.days, months=other.months, years=other.years ) return self def __le__(self, other: SimDateTime) -> bool: + return self.to_iso_str() <= other.to_iso_str() + + def __lt__(self, other: SimDateTime) -> bool: + return self.to_iso_str() < other.to_iso_str() + + def __ge__(self, other: SimDateTime) -> bool: if not isinstance(other, SimDateTime): raise TypeError(f"expected TimeDelta object but was {type(other)}") - return self.to_iso_str() < other.to_iso_str() + return self.to_iso_str() >= other.to_iso_str() - def __gt__(self, other) -> bool: + def __gt__(self, other: SimDateTime) -> bool: if not isinstance(other, SimDateTime): raise TypeError(f"expected TimeDelta object but was {type(other)}") return self.to_iso_str() > other.to_iso_str() + def __eq__(self, other: SimDateTime) -> bool: + return self.to_hours() == other.to_hours() + def to_date_str(self) -> str: return "{}, {:02d}/{:02d}/{:04d} @ {:02d}:00".format( self.weekday_str[:3], self.day, self.month, self.year, self.hour @@ -253,6 +293,10 @@ def to_ordinal(self) -> int: + (self.year * MONTHS_PER_YEAR * DAYS_PER_MONTH) ) + def get_time_of_day(self) -> str: + """Return a string corresponding to the time of day for the given hour""" + return _TIME_OF_DAY[self.hour] + @classmethod def from_ordinal(cls, ordinal_date: int) -> SimDateTime: date = cls() @@ -272,10 +316,25 @@ def from_iso_str(cls, iso_date: str) -> SimDateTime: @classmethod def from_str(cls, time_str: str) -> SimDateTime: time = cls() - year, month, day, hour = tuple(time_str.split("-")) - time._year = int(year) - time._month = int(month) - time._weekday = int(day) % 7 - time._day = int(day) - time._hour = int(hour) - return time + items = tuple(time_str.split("-")) + + if len(items) == 4: + year, month, day, hour = items + time._year = int(year) + time._month = int(month) + time._weekday = int(day) % 7 + time._day = int(day) + time._hour = int(hour) + return time + + elif len(items) == 3: + year, month, day = items + time._year = int(year) + time._month = int(month) + time._weekday = int(day) % 7 + time._day = int(day) + time._hour = 0 + return time + + else: + raise ValueError(f"Invalid date string: {time_str}") diff --git a/src/neighborly/core/town.py b/src/neighborly/core/town.py index 4ed8ed1..4d09394 100644 --- a/src/neighborly/core/town.py +++ b/src/neighborly/core/town.py @@ -1,15 +1,13 @@ from __future__ import annotations import itertools -from dataclasses import dataclass -from enum import Enum -from typing import Any, Callable, Dict, Generic, List, Optional, Set, Tuple, TypeVar +from typing import Any, Dict, List, Optional, Set, Tuple -from neighborly.core.ecs import World -from neighborly.core.engine import NeighborlyEngine +from neighborly.core.serializable import ISerializable +from neighborly.core.utils.grid import Grid -class Town: +class Town(ISerializable): """ Simulated town where characters live @@ -21,16 +19,26 @@ class Town: ---------- name: str The name of the town - population: int + _population: int The number of active town residents """ - __slots__ = "name", "population" + __slots__ = "name", "_population" - def __init__(self, name: str) -> None: + def __init__(self, name: str, population: int = 0) -> None: super().__init__() self.name: str = name - self.population: int = 0 + self._population: int = population + + @property + def population(self) -> int: + return self._population + + def increment_population(self) -> None: + self._population += 1 + + def decrement_population(self) -> None: + self._population -= 1 def to_dict(self) -> Dict[str, Any]: return {"name": self.name, "population": self.population} @@ -41,177 +49,66 @@ def __str__(self) -> str: def __repr__(self) -> str: return f"Town(name={self.name}, population={self.population})" - @classmethod - def create(cls, world: World, **kwargs) -> Town: - """ - Create a town instance - - Parameters - ---------- - kwargs.name: str - The name of the town as a string or Tracery pattern - """ - town_name = kwargs.get("name", "Town") - town_name = world.get_resource(NeighborlyEngine).name_generator.get_name( - town_name - ) - return cls(name=town_name) - - -@dataclass -class LayoutGridSpace: - # ID of the gameobject for the place occupying this space - occupant: Optional[int] = None - - def reset(self) -> None: - self.occupant = None - - -class CompassDirection(Enum): - """Compass directions to string""" - - NORTH = 0 - SOUTH = 1 - EAST = 2 - WEST = 3 - - def __str__(self) -> str: - """Convert compass direction to string""" - mapping = { - CompassDirection.NORTH: "north", - CompassDirection.SOUTH: "south", - CompassDirection.EAST: "east", - CompassDirection.WEST: "west", - } - - return mapping[self] - def to_direction_tuple(self) -> Tuple[int, int]: - """Convert direction to (x,y) tuple for the direction""" - mapping = { - CompassDirection.NORTH: (0, -1), - CompassDirection.SOUTH: (0, 1), - CompassDirection.EAST: (1, 0), - CompassDirection.WEST: (-1, 0), - } - - return mapping[self] - - -_GT = TypeVar("_GT") - - -class Grid(Generic[_GT]): +class LandGrid(ISerializable): """ - Grids house spatially-related data using a graph implemented - as a grid of nodes. Nodes are stored in column-major order - for easier interfacing with higher-level positioning systems + Manages what spaces are available to place buildings on within the town """ - __slots__ = "_width", "_length", "_grid" - - def __init__( - self, width: int, length: int, default_factory: Callable[[], _GT] - ) -> None: - self._width: int = width - self._length: int = length - self._grid: List[_GT] = [default_factory() for _ in range(width * length)] - - @property - def shape(self) -> Tuple[int, int]: - return self._width, self._length - - def __setitem__(self, point: Tuple[int, int], value: _GT) -> None: - index = (point[0] * self.shape[1]) + point[1] - self._grid[index] = value - - def __getitem__(self, point: Tuple[int, int]) -> _GT: - index = (point[0] * self.shape[1]) + point[1] - return self._grid[index] - - def get_adjacent_cells(self, point: Tuple[int, int]) -> Dict[str, Tuple[int, int]]: - adjacent_cells: Dict[str, Tuple[int, int]] = {} - - if point[0] > 0: - adjacent_cells["west"] = (point[0] - 1, point[1]) - - if point[0] < self.shape[0] - 1: - adjacent_cells["east"] = (point[0] + 1, point[1]) - - if point[1] > 0: - adjacent_cells["north"] = (point[0], point[1] - 1) - - if point[1] < self.shape[1] - 1: - adjacent_cells["south"] = (point[0], point[1] + 1) - - return adjacent_cells - - def to_dict(self) -> Dict[str, Any]: - return { - "height": self._length, - "width": self._width, - "grid": [str(cell) for cell in self._grid], - } - - -class LandGrid: - """ - Manages an occupancy grid of what tiles in the town - currently have places built on them - - Attributes - ---------- - width : int - Size of the grid in the x-direction - length : int - Size of the grid in the y-direction - """ - - __slots__ = "_unoccupied", "_occupied", "_grid" + __slots__ = ("_unoccupied", "_occupied", "_grid") def __init__(self, size: Tuple[int, int]) -> None: + super().__init__() width, length = size - self._grid: Grid[LayoutGridSpace] = Grid( - width, length, lambda: LayoutGridSpace() - ) + assert width >= 0 and length >= 0 + self._grid: Grid[Optional[int]] = Grid(size, lambda: None) self._unoccupied: List[Tuple[int, int]] = list( itertools.product(list(range(width)), list(range(length))) ) self._occupied: Set[Tuple[int, int]] = set() @property - def grid(self) -> Grid[LayoutGridSpace]: - return self._grid + def shape(self) -> Tuple[int, int]: + return self._grid.shape + + def in_bounds(self, point: Tuple[int, int]) -> bool: + """Returns True if the given point is within the grid""" + return self._grid.in_bounds(point) + + def get_neighbors( + self, point: Tuple[int, int], include_diagonals: bool = False + ) -> List[Tuple[int, int]]: + return self._grid.get_neighbors(point, include_diagonals) def get_vacancies(self) -> List[Tuple[int, int]]: """Return the positions that are unoccupied in town""" return self._unoccupied - def get_num_vacancies(self) -> int: - """Return number of vacant spaces""" - return len(self._unoccupied) - def has_vacancy(self) -> bool: """Returns True if there are empty spaces available in the town""" return bool(self._unoccupied) - def reserve_space( - self, - position: Tuple[int, int], - occupant_id: int, - ) -> None: - """Allocates a space for a location, setting it as occupied""" - - if position in self._occupied: - raise RuntimeError("Grid space already occupied") - - space = self._grid[position] - space.occupant = occupant_id - self._unoccupied.remove(position) - self._occupied.add(position) - - def free_space(self, space: Tuple[int, int]) -> None: - """Frees up a space in the town to be used by another location""" - self._grid[space].reset() - self._unoccupied.append(space) - self._occupied.remove(space) + def __len__(self) -> int: + return self.shape[0] * self.shape[1] + + def __setitem__(self, position: Tuple[int, int], value: Optional[int]) -> None: + if value is None: + self._grid[position] = None + self._unoccupied.append(position) + self._occupied.remove(position) + else: + if position in self._occupied: + raise RuntimeError("Grid space already occupied") + self._grid[position] = value + self._unoccupied.remove(position) + self._occupied.add(position) + + def __repr__(self) -> str: + return f"Land Grid(shape={self.shape}, vacant: {len(self._unoccupied)}, occupied: {len(self._occupied)})" + + def to_dict(self) -> Dict[str, Any]: + return { + "width": self._grid.shape[0], + "length": self._grid.shape[1], + "grid": [cell for cell in self._grid.get_cells()], + } diff --git a/src/neighborly/core/utils/graph.py b/src/neighborly/core/utils/graph.py index b5a8654..f447894 100644 --- a/src/neighborly/core/utils/graph.py +++ b/src/neighborly/core/utils/graph.py @@ -1,5 +1,5 @@ from collections import defaultdict -from typing import TypeVar, NamedTuple, Generic, DefaultDict, Dict, Tuple, cast +from typing import DefaultDict, Dict, Generic, NamedTuple, Tuple, TypeVar, cast from ordered_set import OrderedSet @@ -38,6 +38,9 @@ def get_connection(self, owner: int, target: int) -> _T: def remove_node(self, node: int) -> None: """Remove a node and delete incoming and outgoing connections""" + if node not in self._nodes: + raise KeyError + node_to_remove = self._nodes[node] # Delete all the outgoing connections diff --git a/src/neighborly/core/utils/grid.py b/src/neighborly/core/utils/grid.py new file mode 100644 index 0000000..397fb4f --- /dev/null +++ b/src/neighborly/core/utils/grid.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from typing import Callable, Generic, List, Tuple, TypeVar + +_GT = TypeVar("_GT") + + +class Grid(Generic[_GT]): + """ + Grids house spatially-related data using a graph implemented + as a grid of nodes. Nodes are stored in column-major order + for easier interfacing with higher-level positioning systems + """ + + __slots__ = "_width", "_length", "_grid" + + def __init__( + self, size: Tuple[int, int], default_factory: Callable[[], _GT] + ) -> None: + self._width: int = size[0] + self._length: int = size[1] + self._grid: List[_GT] = [ + default_factory() for _ in range(self._width * self._length) + ] + + @property + def shape(self) -> Tuple[int, int]: + return self._width, self._length + + def in_bounds(self, point: Tuple[int, int]) -> bool: + """Returns True if the given point is within the grid""" + return 0 <= point[0] < self._width and 0 <= point[1] < self._length + + def get_neighbors( + self, point: Tuple[int, int], include_diagonals: bool = False + ) -> List[Tuple[int, int]]: + neighbors: List[Tuple[int, int]] = [] + + # North-West (Diagonal) + if self.in_bounds((point[0] - 1, point[1] - 1)) and include_diagonals: + neighbors.append((point[0] - 1, point[1] - 1)) + + # North + if self.in_bounds((point[0], point[1] - 1)): + neighbors.append((point[0], point[1] - 1)) + + # North-East (Diagonal) + if self.in_bounds((point[0] + 1, point[1] - 1)) and include_diagonals: + neighbors.append((point[0] + 1, point[1] - 1)) + + # East + if self.in_bounds((point[0] + 1, point[1])): + neighbors.append((point[0] + 1, point[1])) + + # South-East (Diagonal) + if self.in_bounds((point[0] + 1, point[1] + 1)) and include_diagonals: + neighbors.append((point[0] + 1, point[1] + 1)) + + # South + if self.in_bounds((point[0], point[1] + 1)): + neighbors.append((point[0], point[1] + 1)) + + # South-West (Diagonal) + if self.in_bounds((point[0] - 1, point[1] + 1)) and include_diagonals: + neighbors.append((point[0] - 1, point[1] + 1)) + + # West + if self.in_bounds((point[0] - 1, point[1])): + neighbors.append((point[0] - 1, point[1])) + + return neighbors + + def get_cells(self) -> List[_GT]: + """Return all the cells in the grid""" + return self._grid + + def __setitem__(self, point: Tuple[int, int], value: _GT) -> None: + if 0 <= point[0] <= self._width and 0 <= point[1] <= self._length: + index = (point[0] * self.shape[1]) + point[1] + self._grid[index] = value + else: + raise IndexError(point) + + def __getitem__(self, point: Tuple[int, int]) -> _GT: + if 0 <= point[0] <= self._width and 0 <= point[1] <= self._length: + index = (point[0] * self.shape[1]) + point[1] + return self._grid[index] + else: + raise IndexError(point) diff --git a/src/neighborly/core/utils/tracery/LICENSE b/src/neighborly/core/utils/tracery/LICENSE deleted file mode 100644 index 1464ff1..0000000 --- a/src/neighborly/core/utils/tracery/LICENSE +++ /dev/null @@ -1,14 +0,0 @@ -Copyright 2016 Allison Parrish -Based on code by Kate Compton - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. diff --git a/src/neighborly/core/utils/tracery/__init__.py b/src/neighborly/core/utils/tracery/__init__.py deleted file mode 100644 index b98ac98..0000000 --- a/src/neighborly/core/utils/tracery/__init__.py +++ /dev/null @@ -1,370 +0,0 @@ -import random -import re - -basestring = (str, bytes) - -_rng = random.Random() - - -def set_grammar_rng(rng) -> None: - global _rng - _rng = rng - - -class Node(object): - def __init__(self, parent, child_index, settings): - self.errors = [] - if settings.get("raw", None) is None: - self.errors.append("Empty input for node") - settings["raw"] = "" - if isinstance(parent, Grammar): - self.grammar = parent - self.parent = None - self.depth = 0 - self.child_index = 0 - else: - self.grammar = parent.grammar - self.parent = parent - self.depth = parent.depth + 1 - self.child_index = child_index - self.raw = settings["raw"] - self.type = settings.get("type", None) - self.is_expanded = False - - def expand_children(self, child_rule, prevent_recursion=False): - self.children = [] - self.finished_text = "" - - self.child_rule = child_rule - if self.child_rule is not None: - sections, errors = parse(child_rule) - self.errors.extend(errors) - for i, section in enumerate(sections): - node = Node(self, i, section) - self.children.append(node) - if not prevent_recursion: - node.expand(prevent_recursion) - self.finished_text += node.finished_text - else: - self.errors.append("No child rule provided, can't expand children") - - def expand(self, prevent_recursion=False): - if not self.is_expanded: - self.is_expanded = True - self.expansion_errors = [] - # Types of nodes - # -1: raw, needs parsing - # 0: Plaintext - # 1: Tag ("#symbol.mod.mod2.mod3#" or - # "#[pushTarget:pushRule]symbol.mod") - # 2: Action ("[pushTarget:pushRule], [pushTarget:POP]", - # more in the future) - if self.type == -1: - self.expand_children(self.raw, prevent_recursion) - - elif self.type == 0: - self.finished_text = self.raw - - elif self.type == 1: - self.preactions = [] - self.postactions = [] - parsed = parse_tag(self.raw) - self.symbol = parsed["symbol"] - self.modifiers = parsed["modifiers"] - for preaction in parsed["preactions"]: - self.preactions.append(NodeAction(self, preaction["raw"])) - for preaction in self.preactions: - if preaction.type == 0: - self.postactions.append(preaction.create_undo()) - for preaction in self.preactions: - preaction.activate() - self.finished_text = self.raw - selected_rule = self.grammar.select_rule(self.symbol, self, self.errors) - self.expand_children(selected_rule, prevent_recursion) - - # apply modifiers - for mod_name in self.modifiers: - mod_params = [] - if mod_name.find("(") > 0: - regexp = re.compile(r"\(([^)]+)\)") - matches = regexp.findall(mod_name) - if len(matches) > 0: - mod_params = matches[0].split(",") - mod_name = mod_name[: mod_name.find("(")] - mod = self.grammar.modifiers.get(mod_name, None) - if mod is None: - self.errors.append("Missing modifier " + mod_name) - self.finished_text += "((." + mod_name + "))" - else: - self.finished_text = mod(self.finished_text, *mod_params) - - elif self.type == 2: - self.action = NodeAction(self, self.raw) - self.action.activate() - self.finished_text = "" - - def clear_escape_chars(self): - self.finished_text = ( - self.finished_text.replace("\\\\", "DOUBLEBACKSLASH") - .replace("\\", "") - .replace("DOUBLEBACKSLASH", "\\") - ) - - -class NodeAction(object): # has a 'raw' attribute - def __init__(self, node, raw): - self.node = node - sections = raw.split(":") - self.target = sections[0] - if len(sections) == 1: - self.type = 2 - else: - self.rule = sections[1] - if self.rule == "POP": - self.type = 1 - else: - self.type = 0 - - def create_undo(self): - if self.type == 0: - return NodeAction(self.node, self.target + ":POP") - return None - - def activate(self): - grammar = self.node.grammar - if self.type == 0: - self.rule_sections = self.rule.split(",") - self.finished_rules = [] - self.rule_nodes = [] - for rule_section in self.rule_sections: - n = Node(grammar, 0, {"type": -1, "raw": rule_section}) - n.expand() - self.finished_rules.append(n.finished_text) - grammar.push_rules(self.target, self.finished_rules, self) - elif self.type == 1: - grammar.pop_rules(self.target) - elif self.type == 2: - grammar.flatten(self.target, True) - - def to_text(self): - pass # FIXME - - -class RuleSet(object): - def __init__(self, grammar, raw): - self.raw = raw - self.grammar = grammar - self.default_uses = [] - if isinstance(raw, list): - self.default_rules = raw - elif isinstance(raw, basestring): - self.default_rules = [raw] - else: - self.default_rules = [] - - def select_rule(self): - # in kate's code there's a bunch of stuff for different methods of - # selecting a rule, none of which seem to be implemented yet! so for - # now I'm just going to ... - return _rng.choice(self.default_rules) - - def clear_state(self): - self.default_uses = [] - - -class Symbol(object): - def __init__(self, grammar, key, raw_rules): - self.grammar = grammar - self.key = key - self.raw_rules = raw_rules - self.base_rules = RuleSet(grammar, raw_rules) - self.clear_state() - - def clear_state(self): - self.stack = [self.base_rules] - self.uses = [] - self.base_rules.clear_state() - - def push_rules(self, raw_rules): - rules = RuleSet(self.grammar, raw_rules) - self.stack.append(rules) - - def pop_rules(self): - self.stack.pop() - - def select_rule(self, node, errors): - self.uses.append({"node": node}) - if len(self.stack) == 0: - errors.append( - "The rule stack for '" + self.key + "' is empty, too many pops?" - ) - return self.stack[-1].select_rule() - - def get_active_rules(self): - if len(self.stack) == 0: - return None - return self.stack[-1].select_rule() - - -class Grammar(object): - def __init__(self, raw, settings=None): - self.modifiers = {} - self.load_from_raw_obj(raw) - self.errors = [] - if settings is None: - self.settings = {} - - def clear_state(self): - for val in self.symbols.values(): - val.clear_state() - - def add_modifiers(self, mods): - # not sure what this is for yet - for key in mods: - self.modifiers[key] = mods[key] - - def load_from_raw_obj(self, raw): - self.raw = raw - self.symbols = dict() - self.subgrammars = list() - if raw: - self.symbols = dict((k, Symbol(self, k, v)) for k, v in raw.items()) - - def create_root(self, rule): - return Node(self, 0, {"type": -1, "raw": rule}) - - def expand(self, rule, allow_escape_chars=False): - root = self.create_root(rule) - root.expand() - if not allow_escape_chars: - root.clear_escape_chars() - self.errors.extend(root.errors) - return root - - def flatten(self, rule, allow_escape_chars=False): - root = self.expand(rule, allow_escape_chars) - return root.finished_text - - def push_rules(self, key, raw_rules, source_action=None): - if key not in self.symbols: - self.symbols[key] = Symbol(self, key, raw_rules) - else: - self.symbols[key].push_rules(raw_rules) - - def pop_rules(self, key): - if key not in self.symbols: - self.errors.append("Can't pop: no symbol for key " + key) - else: - self.symbols[key].pop_rules() - - def select_rule(self, key, node, errors): - if key in self.symbols: - return self.symbols[key].select_rule(node, errors) - else: - if key is None: - key = str(None) - self.errors.append("No symbol for " + key) - return "((" + key + "))" - - -def parse_tag(tag_contents): - """ - returns a dictionary with 'symbol', 'modifiers', 'preactions', - 'postactions' - """ - parsed = dict(symbol=None, preactions=[], postactions=[], modifiers=[]) - sections, errors = parse(tag_contents) - symbol_section = None - for section in sections: - if section["type"] == 0: - if symbol_section is None: - symbol_section = section["raw"] - else: - raise Exception("multiple main sections in " + tag_contents) - else: - parsed["preactions"].append(section) - if symbol_section is not None: - components = symbol_section.split(".") - parsed["symbol"] = components[0] - parsed["modifiers"] = components[1:] - return parsed - - -def parse(rule): - depth = 0 - in_tag = False - sections = list() - escaped = False - errors = [] - start = 0 - escaped_substring = "" - last_escaped_char = None - - if rule is None: - return sections - - def create_section(start, end, type_): - if end - start < 1: - if type_ == 1: - errors.append(str(start) + ": empty tag") - elif type_ == 2: - errors.append(str(start) + ": empty action") - raw_substring = None - if last_escaped_char is not None: - raw_substring = escaped_substring + "\\" + rule[last_escaped_char + 1: end] - else: - raw_substring = rule[start:end] - sections.append({"type": type_, "raw": raw_substring}) - - for i, c in enumerate(rule): - if not escaped: - if c == "[": - if depth == 0 and not in_tag: - if start < i: - create_section(start, i, 0) - last_escaped_char = None - escaped_substring = "" - start = i + 1 - depth += 1 - elif c == "]": - depth -= 1 - if depth == 0 and not in_tag: - create_section(start, i, 2) - last_escaped_char = None - escaped_substring = "" - start = i + 1 - elif c == "#": - if depth == 0: - if in_tag: - create_section(start, i, 1) - last_escaped_char = None - escaped_substring = "" - start = i + 1 - else: - if start < i: - create_section(start, i, 0) - last_escaped_char = None - escaped_substring = "" - start = i + 1 - in_tag = not in_tag - elif c == "\\": - escaped = True - escaped_substring = escaped_substring + rule[start:i] - start = i + 1 - last_escaped_char = i - else: - escaped = False - if start < len(rule): - create_section(start, len(rule), 0) - last_escaped_char = None - escaped_substring = "" - - if in_tag: - errors.append("unclosed tag") - if depth > 0: - errors.append("too many [") - if depth < 0: - errors.append("too many ]") - - sections = [s for s in sections if not (s["type"] == 0 and len(s["raw"]) == 0)] - return sections, errors diff --git a/src/neighborly/core/utils/tracery/modifiers.py b/src/neighborly/core/utils/tracery/modifiers.py deleted file mode 100644 index 42efe1a..0000000 --- a/src/neighborly/core/utils/tracery/modifiers.py +++ /dev/null @@ -1,69 +0,0 @@ -def replace(text, *params): - return text.replace(params[0], params[1]) - - -def capitalizeAll(text, *params): - return text.title() - - -def capitalize_(text, *params): - return text[0].upper() + text[1:] - - -def a(text, *params): - if len(text) > 0: - if text[0] in "uU": - if len(text) > 2: - if text[2] in "iI": - return "a " + text - if text[0] in "aeiouAEIOU": - return "an " + text - return "a " + text - - -def firstS(text, *params): - text2 = text.split(" ") - return " ".join([s(text2[0])] + text2[1:]) - - -def s(text, *params): - if text[-1] in "shxSHX": - return text + "es" - elif text[-1] in "yY": - if text[-2] not in "aeiouAEIOU": - return text[:-1] + "ies" - else: - return text + "s" - else: - return text + "s" - - -def ed(text, *params): - if text[-1] in "eE": - return text + "d" - elif text[-1] in "yY": - if text[-2] not in "aeiouAEIOU": - return text[:-1] + "ied" - else: - return text + "ed" - - -def uppercase(text, *params): - return text.upper() - - -def lowercase(text, *params): - return text.lower() - - -base_english = { - 'replace': replace, - 'capitalizeAll': capitalizeAll, - 'capitalize': capitalize_, - 'a': a, - 'firstS': firstS, - 's': s, - 'ed': ed, - 'uppercase': uppercase, - 'lowercase': lowercase -} diff --git a/src/neighborly/core/utils/utilities.py b/src/neighborly/core/utils/utilities.py index 423deb3..20e1f09 100644 --- a/src/neighborly/core/utils/utilities.py +++ b/src/neighborly/core/utils/utilities.py @@ -1,4 +1,4 @@ -from typing import Dict, Tuple, Generator, TypeVar, List +from typing import Dict, Tuple def merge(source: Dict, destination: Dict) -> Dict: @@ -48,20 +48,3 @@ def parse_number_range(range_str: str) -> Tuple[int, int]: map(lambda s: int(s.strip()), range_str.strip().split("-")) ) return range_min, range_max - - -_T = TypeVar("_T") - - -def chunk_list(lst: List[_T], n: int) -> Generator[List[_T], None, None]: - """Yield successive n-sized chunks from lst.""" - for i in range(0, len(lst), n): - yield lst[i: i + n] - - -_NT = TypeVar("_NT", int, float) - - -def clamp(value: _NT, min_value: _NT, max_value: _NT) -> _NT: - """Clamp a numerical value between a min and max range""" - return min(max_value, max(min_value, value)) diff --git a/src/neighborly/exporter.py b/src/neighborly/exporter.py index 89edf08..e1b82bc 100644 --- a/src/neighborly/exporter.py +++ b/src/neighborly/exporter.py @@ -1,9 +1,7 @@ import json -from neighborly.core.life_event import LifeEventLog -from neighborly.core.relationship import RelationshipGraph +from neighborly.core.serializable import ISerializable from neighborly.core.time import SimDateTime -from neighborly.core.town import LandGrid, Town from neighborly.simulation import Simulation @@ -16,12 +14,10 @@ def export(self, sim: Simulation) -> str: "seed": sim.seed, "date": sim.world.get_resource(SimDateTime).to_iso_str(), "gameobjects": {g.id: g.to_dict() for g in sim.world.get_gameobjects()}, - "events": [ - f.to_dict() - for f in sim.world.get_resource(LifeEventLog).event_history - ], - "relationships": sim.world.get_resource(RelationshipGraph).to_dict(), - "town": sim.world.get_resource(Town).to_dict(), - "land": sim.world.get_resource(LandGrid).grid.to_dict(), + "resources": { + r.__class__.__name__: r.to_dict() + for r in sim.world.get_all_resources() + if isinstance(r, ISerializable) + }, } ) diff --git a/src/neighborly/inspection_tools.py b/src/neighborly/inspection_tools.py deleted file mode 100644 index 2062100..0000000 --- a/src/neighborly/inspection_tools.py +++ /dev/null @@ -1,48 +0,0 @@ -from pprint import pprint - -from neighborly.core.character import GameCharacter -from neighborly.core.location import Location -from neighborly.core.relationship import RelationshipGraph -from neighborly.simulation import Simulation - - -def list_characters(sim: Simulation) -> None: - """Print the IDs, names, ages, and locations of all the characters""" - print("{:30s} {:25s} {:5s}".format("ID", "Name", "Age")) - for gid, character in sim.world.get_component(GameCharacter): - print(f"{gid:<30} {str(character.name)!r:<25} {int(character.age):<5}") - - -def list_relationships(sim: Simulation, character_id: int) -> None: - """List the relationships for a single character""" - gameobject = sim.world.get_gameobject(character_id) - character = gameobject.get_component(GameCharacter) - - rel_graph = sim.world.get_resource(RelationshipGraph) - - relationships = rel_graph.get_relationships(character_id) - - print(f"Showing {len(relationships)} relationships for: {str(character.name)}") - - print("{:30s} {:12s} {:12s}".format("Target", "Friendship", "Romance")) - for r in relationships: - print(f"{r.target:<30} {r.friendship:<12} {r.romance:<12}") - - -def display_gameobject(sim: Simulation, gid: int) -> None: - pprint(sim.world.get_gameobject(gid).to_dict()) - - -# def list_event_history(sim: Simulation, gid: int) -> None: -# event_logger = sim.world.get_resource(LifeEventLogger) -# -# for e in event_logger.get_events_for(gid): -# print(str(e)) - - -def list_locations(sim: Simulation) -> None: - print("{:30s} {:25s} {:5s}".format("ID", "Name", "Occupancy")) - for gid, location in sim.world.get_component(Location): - print( - f"{gid:<30} {str(location.name)!r:<25} {int(len(location.characters_present)):<5}" - ) diff --git a/src/neighborly/loaders.py b/src/neighborly/loaders.py index 5faec01..b7f96a2 100644 --- a/src/neighborly/loaders.py +++ b/src/neighborly/loaders.py @@ -8,15 +8,16 @@ import yaml -from neighborly.core.activity import Activity, ActivityLibrary from neighborly.core.archetypes import ( - BusinessArchetype, - BusinessArchetypeLibrary, - CharacterArchetype, - CharacterArchetypeLibrary, - ResidenceArchetype, - ResidenceArchetypeLibrary, + BaseBusinessArchetype, + BaseCharacterArchetype, + BaseResidenceArchetype, + BusinessArchetypes, + CharacterArchetypes, + ICharacterArchetype, + ResidenceArchetypes, ) +from neighborly.core.ecs import GameObject, World logger = logging.getLogger(__name__) @@ -82,27 +83,64 @@ def decorator(loader_fn: ISectionLoader) -> ISectionLoader: @YamlDataLoader.section_loader("Characters") def _load_character_archetypes(data: List[Dict[str, Any]]) -> None: - """Process data defining character archetypes""" + """Process data defining entity archetypes""" for archetype_data in data: - CharacterArchetypeLibrary.add(CharacterArchetype(**archetype_data)) + CharacterArchetypes.add( + archetype_data["name"], BaseCharacterArchetype(**archetype_data) + ) @YamlDataLoader.section_loader("Businesses") def _load_business_archetypes(data: List[Dict[str, Any]]) -> None: """Process data defining business archetypes""" for archetype_data in data: - BusinessArchetypeLibrary.add(BusinessArchetype(**archetype_data)) + BusinessArchetypes.add( + archetype_data["name"], BaseBusinessArchetype(**archetype_data) + ) @YamlDataLoader.section_loader("Residences") def _load_residence_data(data: List[Dict[str, Any]]) -> None: """Process data defining residence archetypes""" for archetype_data in data: - ResidenceArchetypeLibrary.add(ResidenceArchetype(**archetype_data)) + ResidenceArchetypes.add( + archetype_data["name"], BaseResidenceArchetype(**archetype_data) + ) -@YamlDataLoader.section_loader("Activities") -def _load_activity_data(data: List[Dict[str, Any]]) -> None: - """Process data defining activities""" - for entry in data: - ActivityLibrary.add(Activity(entry["name"], trait_names=entry["traits"])) +class YamlDefinedCharacterArchetype(ICharacterArchetype): + def __init__( + self, + base: ICharacterArchetype, + options: Dict[str, Any], + max_children_at_spawn: Optional[int] = None, + chance_spawn_with_spouse: Optional[int] = None, + spawn_frequency: Optional[int] = None, + ) -> None: + self._base: ICharacterArchetype = base + self._max_children_at_spawn: int = ( + max_children_at_spawn + if max_children_at_spawn + else base.get_max_children_at_spawn() + ) + self._chance_spawn_with_spouse: float = ( + chance_spawn_with_spouse + if chance_spawn_with_spouse + else base.get_chance_spawn_with_spouse() + ) + self._spawn_frequency: int = ( + spawn_frequency if spawn_frequency else base.get_spawn_frequency() + ) + self._options = options + + def get_max_children_at_spawn(self) -> int: + return self._base.get_max_children_at_spawn() + + def get_chance_spawn_with_spouse(self) -> float: + return self._base.get_chance_spawn_with_spouse() + + def get_spawn_frequency(self) -> int: + return self._base.get_spawn_frequency() + + def create(self, world: World, **kwargs) -> GameObject: + return self._base.create(world, **{**self._options, **kwargs}) diff --git a/src/neighborly/plugins/default_plugin/__init__.py b/src/neighborly/plugins/default_plugin/__init__.py deleted file mode 100644 index c083b14..0000000 --- a/src/neighborly/plugins/default_plugin/__init__.py +++ /dev/null @@ -1,86 +0,0 @@ -import os -from pathlib import Path - -from neighborly.builtin.events import ( - become_enemies_event, - become_friends_event, - dating_break_up_event, - depart_due_to_unemployment, - divorce_event, - find_own_place_event, - marriage_event, - pregnancy_event, - retire_event, - start_dating_event, -) -from neighborly.core.engine import NeighborlyEngine -from neighborly.core.life_event import LifeEventLibrary -from neighborly.simulation import Plugin, Simulation - -_RESOURCES_DIR = Path(os.path.abspath(__file__)).parent / "data" - - -class DefaultNameDataPlugin(Plugin): - def setup(self, sim: Simulation, **kwargs) -> None: - self.initialize_tracery_name_factory(sim.engine) - - def initialize_tracery_name_factory(self, engine: NeighborlyEngine) -> None: - # Load character name data - engine.name_generator.load_names( - rule_name="family_name", filepath=_RESOURCES_DIR / "names" / "surnames.txt" - ) - engine.name_generator.load_names( - rule_name="first_name", - filepath=_RESOURCES_DIR / "names" / "neutral_names.txt", - ) - engine.name_generator.load_names( - rule_name="feminine_first_name", - filepath=_RESOURCES_DIR / "names" / "feminine_names.txt", - ) - engine.name_generator.load_names( - rule_name="masculine_first_name", - filepath=_RESOURCES_DIR / "names" / "masculine_names.txt", - ) - - # Load potential names for different structures in the town - engine.name_generator.load_names( - rule_name="restaurant_name", - filepath=_RESOURCES_DIR / "names" / "restaurant_names.txt", - ) - engine.name_generator.load_names( - rule_name="bar_name", filepath=_RESOURCES_DIR / "names" / "bar_names.txt" - ) - - # Load potential names for the town - engine.name_generator.load_names( - rule_name="town_name", - filepath=_RESOURCES_DIR / "names" / "US_settlement_names.txt", - ) - - -class DefaultLifeEventPlugin(Plugin): - def setup(self, sim: Simulation, **kwargs) -> None: - LifeEventLibrary.add(marriage_event()) - # LifeEvents.register(become_friends_event()) - # LifeEvents.register(become_enemies_event()) - LifeEventLibrary.add(start_dating_event()) - LifeEventLibrary.add(dating_break_up_event()) - LifeEventLibrary.add(divorce_event()) - LifeEventLibrary.add(marriage_event()) - LifeEventLibrary.add(pregnancy_event()) - LifeEventLibrary.add(depart_due_to_unemployment()) - LifeEventLibrary.add(retire_event()) - LifeEventLibrary.add(find_own_place_event()) - - -class DefaultPlugin(Plugin): - def setup(self, sim: Simulation, **kwargs) -> None: - name_data_plugin = DefaultNameDataPlugin() - life_event_plugin = DefaultLifeEventPlugin() - - name_data_plugin.setup(sim, **kwargs) - life_event_plugin.setup(sim, **kwargs) - - -def get_plugin() -> Plugin: - return DefaultPlugin() diff --git a/src/neighborly/plugins/defaults/__init__.py b/src/neighborly/plugins/defaults/__init__.py new file mode 100644 index 0000000..e2cdfda --- /dev/null +++ b/src/neighborly/plugins/defaults/__init__.py @@ -0,0 +1,194 @@ +import os +from pathlib import Path +from typing import Optional, Tuple + +from neighborly.builtin.components import Age +from neighborly.builtin.events import ( + depart_due_to_unemployment, + die_of_old_age, + divorce_event, + find_own_place_event, + go_out_of_business_event, + marriage_event, + pregnancy_event, + retire_event, + start_dating_event, + stop_dating_event, +) +from neighborly.builtin.systems import ( + BuildBusinessSystem, + BuildHousingSystem, + BusinessUpdateSystem, + CharacterAgingSystem, + ClosedForBusinessSystem, + FindBusinessOwnerSystem, + FindEmployeesSystem, + OpenForBusinessSystem, + PendingOpeningSystem, + PregnancySystem, + SpawnResidentSystem, +) +from neighborly.core.business import Occupation +from neighborly.core.constants import ( + BUSINESS_UPDATE_PHASE, + CHARACTER_UPDATE_PHASE, + TOWN_SYSTEMS_PHASE, +) +from neighborly.core.ecs import World +from neighborly.core.engine import NeighborlyEngine +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 + +_RESOURCES_DIR = Path(os.path.abspath(__file__)).parent / "data" + + +class DefaultNameDataPlugin(Plugin): + def setup(self, sim: Simulation, **kwargs) -> None: + self.initialize_tracery_name_factory(sim.engine) + + def initialize_tracery_name_factory(self, engine: NeighborlyEngine) -> None: + # Load entity name data + engine.name_generator.load_names( + rule_name="family_name", filepath=_RESOURCES_DIR / "names" / "surnames.txt" + ) + engine.name_generator.load_names( + rule_name="first_name", + filepath=_RESOURCES_DIR / "names" / "neutral_names.txt", + ) + engine.name_generator.load_names( + rule_name="feminine_first_name", + filepath=_RESOURCES_DIR / "names" / "feminine_names.txt", + ) + engine.name_generator.load_names( + rule_name="masculine_first_name", + filepath=_RESOURCES_DIR / "names" / "masculine_names.txt", + ) + + # Load potential names for different structures in the town + engine.name_generator.load_names( + rule_name="restaurant_name", + filepath=_RESOURCES_DIR / "names" / "restaurant_names.txt", + ) + engine.name_generator.load_names( + rule_name="bar_name", filepath=_RESOURCES_DIR / "names" / "bar_names.txt" + ) + + # Load potential names for the town + engine.name_generator.load_names( + rule_name="town_name", + filepath=_RESOURCES_DIR / "names" / "US_settlement_names.txt", + ) + + +class DefaultLifeEventPlugin(Plugin): + def setup(self, sim: Simulation, **kwargs) -> None: + LifeEvents.add(marriage_event()) + # LifeEvents.add(become_friends_event()) + # LifeEvents.add(become_enemies_event()) + LifeEvents.add(depart_due_to_unemployment()) + LifeEvents.add(start_dating_event()) + LifeEvents.add(stop_dating_event()) + LifeEvents.add(divorce_event()) + LifeEvents.add(pregnancy_event()) + LifeEvents.add(retire_event()) + LifeEvents.add(find_own_place_event()) + LifeEvents.add(die_of_old_age()) + LifeEvents.add(go_out_of_business_event()) + + +def get_values_compatibility( + world: World, subject_id: int, target_id: int +) -> Optional[Tuple[int, int]]: + """Return value [-1.0, 1.0] representing the compatibility of two characters""" + subject_values = world.get_gameobject(subject_id).try_component(PersonalValues) + target_values = world.get_gameobject(target_id).try_component(PersonalValues) + + if subject_values is not None and target_values is not None: + compatibility = PersonalValues.compatibility(subject_values, target_values) * 5 + return int(compatibility), int(compatibility) + + +def job_level_difference_debuff( + world: World, subject_id: int, target_id: int +) -> Optional[Tuple[int, int]]: + """ + This makes people with job-level differences less likely to develop romantic feelings + for one another (missing source) + """ + subject_job = world.get_gameobject(subject_id).try_component(Occupation) + target_job = world.get_gameobject(target_id).try_component(Occupation) + + if subject_job is not None and target_job is None: + return 0, -1 + + if subject_job is None and target_job is not None: + return 0, 1 + + if subject_job is not None and target_job is not None: + character_a_level = subject_job.level if subject_job else 0 + character_b_level = target_job.level if target_job else 0 + compatibility = int(5 - (abs(character_a_level - character_b_level))) + return compatibility, compatibility + + +def age_difference_debuff( + world: World, subject_id: int, target_id: int +) -> Optional[Tuple[int, int]]: + """How does age difference affect developing romantic feelings + People with larger age gaps are less likely to develop romantic feelings + (missing source) + """ + character_a_age = world.get_gameobject(subject_id).get_component(Age).value + character_b_age = world.get_gameobject(target_id).get_component(Age).value + + friendship_buff = int(12 / (character_b_age - character_a_age)) + romance_buff = int(8 / (character_b_age - character_a_age)) + + return friendship_buff, romance_buff + + +class DefaultPlugin(Plugin): + def setup(self, sim: Simulation, **kwargs) -> None: + name_data_plugin = DefaultNameDataPlugin() + life_event_plugin = DefaultLifeEventPlugin() + + name_data_plugin.setup(sim, **kwargs) + life_event_plugin.setup(sim, **kwargs) + + # SocializeSystem.add_compatibility_check(get_values_compatibility) + # SocializeSystem.add_compatibility_check(job_level_difference_debuff) + # 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(BusinessUpdateSystem(), priority=BUSINESS_UPDATE_PHASE) + sim.world.add_system(FindBusinessOwnerSystem(), priority=BUSINESS_UPDATE_PHASE) + sim.world.add_system(FindEmployeesSystem(), priority=BUSINESS_UPDATE_PHASE) + # self.add_system( + # UnemploymentSystem(days_to_departure=30), priority=CHARACTER_UPDATE_PHASE + # ) + # self.add_system(SocializeSystem(), priority=CHARACTER_ACTION_PHASE) + sim.world.add_system(PregnancySystem(), priority=CHARACTER_UPDATE_PHASE) + sim.world.add_system( + PendingOpeningSystem(days_before_demolishing=9999), + priority=BUSINESS_UPDATE_PHASE, + ) + sim.world.add_system(OpenForBusinessSystem(), priority=BUSINESS_UPDATE_PHASE) + sim.world.add_system(ClosedForBusinessSystem(), priority=BUSINESS_UPDATE_PHASE) + + sim.world.add_system( + BuildHousingSystem(chance_of_build=1.0), priority=TOWN_SYSTEMS_PHASE + ) + sim.world.add_system( + SpawnResidentSystem(interval=TimeDelta(days=7), chance_spawn=1.0), + priority=TOWN_SYSTEMS_PHASE, + ) + sim.world.add_system( + BuildBusinessSystem(interval=TimeDelta(days=5)), priority=TOWN_SYSTEMS_PHASE + ) + + +def get_plugin() -> Plugin: + return DefaultPlugin() diff --git a/src/neighborly/plugins/default_plugin/data/data.yaml b/src/neighborly/plugins/defaults/data/data.yaml similarity index 100% rename from src/neighborly/plugins/default_plugin/data/data.yaml rename to src/neighborly/plugins/defaults/data/data.yaml diff --git a/src/neighborly/plugins/default_plugin/data/names/US_settlement_names.txt b/src/neighborly/plugins/defaults/data/names/US_settlement_names.txt similarity index 100% rename from src/neighborly/plugins/default_plugin/data/names/US_settlement_names.txt rename to src/neighborly/plugins/defaults/data/names/US_settlement_names.txt diff --git a/src/neighborly/plugins/default_plugin/data/names/bar_names.txt b/src/neighborly/plugins/defaults/data/names/bar_names.txt similarity index 100% rename from src/neighborly/plugins/default_plugin/data/names/bar_names.txt rename to src/neighborly/plugins/defaults/data/names/bar_names.txt diff --git a/src/neighborly/plugins/default_plugin/data/names/feminine_names.txt b/src/neighborly/plugins/defaults/data/names/feminine_names.txt similarity index 100% rename from src/neighborly/plugins/default_plugin/data/names/feminine_names.txt rename to src/neighborly/plugins/defaults/data/names/feminine_names.txt diff --git a/src/neighborly/plugins/default_plugin/data/names/masculine_names.txt b/src/neighborly/plugins/defaults/data/names/masculine_names.txt similarity index 100% rename from src/neighborly/plugins/default_plugin/data/names/masculine_names.txt rename to src/neighborly/plugins/defaults/data/names/masculine_names.txt diff --git a/src/neighborly/plugins/default_plugin/data/names/neutral_names.txt b/src/neighborly/plugins/defaults/data/names/neutral_names.txt similarity index 100% rename from src/neighborly/plugins/default_plugin/data/names/neutral_names.txt rename to src/neighborly/plugins/defaults/data/names/neutral_names.txt diff --git a/src/neighborly/plugins/default_plugin/data/names/restaurant_names.txt b/src/neighborly/plugins/defaults/data/names/restaurant_names.txt similarity index 100% rename from src/neighborly/plugins/default_plugin/data/names/restaurant_names.txt rename to src/neighborly/plugins/defaults/data/names/restaurant_names.txt diff --git a/src/neighborly/plugins/default_plugin/data/names/surnames.txt b/src/neighborly/plugins/defaults/data/names/surnames.txt similarity index 100% rename from src/neighborly/plugins/default_plugin/data/names/surnames.txt rename to src/neighborly/plugins/defaults/data/names/surnames.txt diff --git a/src/neighborly/plugins/talktown/README.md b/src/neighborly/plugins/talktown/README.md index e90bc1a..76d013b 100644 --- a/src/neighborly/plugins/talktown/README.md +++ b/src/neighborly/plugins/talktown/README.md @@ -1,7 +1,7 @@ # Neighborly Talk of the Town Plugin This is a _Talk of the Town_ plugin designed to work with the Neighborly. -It modified implementations of data definitions and logic from the orginal +It modified implementations of data definitions and logic from the original _Talk of the Town_. ## How to use diff --git a/src/neighborly/plugins/talktown/__init__.py b/src/neighborly/plugins/talktown/__init__.py index bd61635..1ad59b9 100644 --- a/src/neighborly/plugins/talktown/__init__.py +++ b/src/neighborly/plugins/talktown/__init__.py @@ -4,17 +4,14 @@ import neighborly.plugins.talktown.business_archetypes as tot_businesses import neighborly.plugins.talktown.occupation_types as tot_occupations +from neighborly.builtin.archetypes import HumanArchetype from neighborly.core.archetypes import ( - BusinessArchetypeLibrary, - CharacterArchetype, - CharacterArchetypeLibrary, - ResidenceArchetype, - ResidenceArchetypeLibrary, + BaseResidenceArchetype, + BusinessArchetypes, + CharacterArchetypes, + ResidenceArchetypes, ) -from neighborly.core.business import OccupationTypeLibrary -from neighborly.core.ecs import World -from neighborly.core.rng import DefaultRNG -from neighborly.core.town import LandGrid +from neighborly.core.business import OccupationTypes from neighborly.plugins.talktown.school import SchoolSystem from neighborly.simulation import Plugin, Simulation @@ -23,205 +20,163 @@ _RESOURCES_DIR = pathlib.Path(os.path.abspath(__file__)).parent -def establish_town(world: World, **kwargs) -> None: - """ - Adds an initial set of families and businesses - to the start of the town. - - This system runs once, then removes itself from - the ECS to free resources. - - Parameters - ---------- - world : World - The world instance of the simulation - - Notes - ----- - This function is based on the original Simulation.establish_setting - method in talktown. - """ - vacant_lots = town.get_component(LandGrid).layout.get_vacancies() - # Each family requires 2 lots (1 for a house, 1 for a business) - # Save two lots for either a coalmine, quarry, or farm - n_families_to_add = (len(vacant_lots) // 2) - 1 - - for _ in range(n_families_to_add - 1): - # create residents - # create Farm - farm = world.spawn_archetype(BusinessArchetypeLibrary.get("Farm")) - # trigger hiring event - # trigger home move event - - random_num = world.get_resource(DefaultRNG).random() - if random_num < 0.2: - # Create a Coalmine 20% of the time - coal_mine = world.spawn_archetype(BusinessArchetypeLibrary.get("Coal Mine")) - elif 0.2 <= random_num < 0.35: - # Create a Quarry 15% of the time - quarry = world.spawn_archetype(BusinessArchetypeLibrary.get("Quarry")) - else: - # Create Farm 65% of the time - farm = world.spawn_archetype(BusinessArchetypeLibrary.get("Farm")) - - logger.debug("Town established. 'establish_town' function removed from systems") - - class TalkOfTheTownPlugin(Plugin): def setup(self, sim: Simulation, **kwargs) -> None: sim.world.add_system(SchoolSystem()) # Talk of the town only has one residence archetype - ResidenceArchetypeLibrary.add(ResidenceArchetype(name="House")) + ResidenceArchetypes.add("House", BaseResidenceArchetype()) - # Talk of the town only has one character archetype - CharacterArchetypeLibrary.add( - CharacterArchetype( - name="Person", - name_format="#first_name# #family_name#", - lifespan=85, - life_stages={ + # Talk of the town only has one entity archetype + CharacterArchetypes.add( + "Person", + HumanArchetype( + life_stage_ages={ "child": 0, "teen": 13, "young_adult": 18, "adult": 30, "elder": 65, }, - ) + chance_spawn_with_spouse=1.0, + max_children_at_spawn=3, + ), ) # Register OccupationTypes - OccupationTypeLibrary.add(tot_occupations.apprentice) - OccupationTypeLibrary.add(tot_occupations.architect) - OccupationTypeLibrary.add(tot_occupations.baker) - OccupationTypeLibrary.add(tot_occupations.banker) - OccupationTypeLibrary.add(tot_occupations.bank_teller) - OccupationTypeLibrary.add(tot_occupations.barber) - OccupationTypeLibrary.add(tot_occupations.barkeeper) - OccupationTypeLibrary.add(tot_occupations.bartender) - OccupationTypeLibrary.add(tot_occupations.bottler) - OccupationTypeLibrary.add(tot_occupations.blacksmith) - OccupationTypeLibrary.add(tot_occupations.brewer) - OccupationTypeLibrary.add(tot_occupations.bricklayer) - OccupationTypeLibrary.add(tot_occupations.builder) - OccupationTypeLibrary.add(tot_occupations.busboy) - OccupationTypeLibrary.add(tot_occupations.bus_driver) - OccupationTypeLibrary.add(tot_occupations.butcher) - OccupationTypeLibrary.add(tot_occupations.carpenter) - OccupationTypeLibrary.add(tot_occupations.cashier) - OccupationTypeLibrary.add(tot_occupations.clothier) - OccupationTypeLibrary.add(tot_occupations.concierge) - OccupationTypeLibrary.add(tot_occupations.cook) - OccupationTypeLibrary.add(tot_occupations.cooper) - OccupationTypeLibrary.add(tot_occupations.daycare_provider) - OccupationTypeLibrary.add(tot_occupations.dentist) - OccupationTypeLibrary.add(tot_occupations.dishwasher) - OccupationTypeLibrary.add(tot_occupations.distiller) - OccupationTypeLibrary.add(tot_occupations.doctor) - OccupationTypeLibrary.add(tot_occupations.dressmaker) - OccupationTypeLibrary.add(tot_occupations.druggist) - OccupationTypeLibrary.add(tot_occupations.engineer) - OccupationTypeLibrary.add(tot_occupations.farmer) - OccupationTypeLibrary.add(tot_occupations.farmhand) - OccupationTypeLibrary.add(tot_occupations.fire_chief) - OccupationTypeLibrary.add(tot_occupations.fire_fighter) - OccupationTypeLibrary.add(tot_occupations.grocer) - OccupationTypeLibrary.add(tot_occupations.groundskeeper) - OccupationTypeLibrary.add(tot_occupations.hotel_maid) - OccupationTypeLibrary.add(tot_occupations.inn_keeper) - OccupationTypeLibrary.add(tot_occupations.insurance_agent) - OccupationTypeLibrary.add(tot_occupations.janitor) - OccupationTypeLibrary.add(tot_occupations.jeweler) - OccupationTypeLibrary.add(tot_occupations.joiner) - OccupationTypeLibrary.add(tot_occupations.laborer) - OccupationTypeLibrary.add(tot_occupations.landlord) - OccupationTypeLibrary.add(tot_occupations.lawyer) - OccupationTypeLibrary.add(tot_occupations.manager) - OccupationTypeLibrary.add(tot_occupations.miner) - OccupationTypeLibrary.add(tot_occupations.milkman) - OccupationTypeLibrary.add(tot_occupations.mayor) - OccupationTypeLibrary.add(tot_occupations.molder) - OccupationTypeLibrary.add(tot_occupations.mortician) - OccupationTypeLibrary.add(tot_occupations.nurse) - OccupationTypeLibrary.add(tot_occupations.optometrist) - OccupationTypeLibrary.add(tot_occupations.owner) - OccupationTypeLibrary.add(tot_occupations.painter) - OccupationTypeLibrary.add(tot_occupations.pharmacist) - OccupationTypeLibrary.add(tot_occupations.plasterer) - OccupationTypeLibrary.add(tot_occupations.plastic_surgeon) - OccupationTypeLibrary.add(tot_occupations.plumber) - OccupationTypeLibrary.add(tot_occupations.police_chief) - OccupationTypeLibrary.add(tot_occupations.principal) - OccupationTypeLibrary.add(tot_occupations.professor) - OccupationTypeLibrary.add(tot_occupations.proprietor) - OccupationTypeLibrary.add(tot_occupations.puddler) - OccupationTypeLibrary.add(tot_occupations.quarry_man) - OccupationTypeLibrary.add(tot_occupations.realtor) - OccupationTypeLibrary.add(tot_occupations.seamstress) - OccupationTypeLibrary.add(tot_occupations.secretary) - OccupationTypeLibrary.add(tot_occupations.stocker) - OccupationTypeLibrary.add(tot_occupations.shoemaker) - OccupationTypeLibrary.add(tot_occupations.stonecutter) - OccupationTypeLibrary.add(tot_occupations.tailor) - OccupationTypeLibrary.add(tot_occupations.tattoo_artist) - OccupationTypeLibrary.add(tot_occupations.taxi_driver) - OccupationTypeLibrary.add(tot_occupations.teacher) - OccupationTypeLibrary.add(tot_occupations.turner) - OccupationTypeLibrary.add(tot_occupations.waiter) - OccupationTypeLibrary.add(tot_occupations.whitewasher) - OccupationTypeLibrary.add(tot_occupations.woodworker) + OccupationTypes.add(tot_occupations.apprentice) + OccupationTypes.add(tot_occupations.architect) + OccupationTypes.add(tot_occupations.baker) + OccupationTypes.add(tot_occupations.banker) + OccupationTypes.add(tot_occupations.bank_teller) + OccupationTypes.add(tot_occupations.barber) + OccupationTypes.add(tot_occupations.barkeeper) + OccupationTypes.add(tot_occupations.bartender) + OccupationTypes.add(tot_occupations.bottler) + OccupationTypes.add(tot_occupations.blacksmith) + OccupationTypes.add(tot_occupations.brewer) + OccupationTypes.add(tot_occupations.bricklayer) + OccupationTypes.add(tot_occupations.builder) + OccupationTypes.add(tot_occupations.busboy) + OccupationTypes.add(tot_occupations.bus_driver) + OccupationTypes.add(tot_occupations.butcher) + OccupationTypes.add(tot_occupations.carpenter) + OccupationTypes.add(tot_occupations.cashier) + OccupationTypes.add(tot_occupations.clothier) + OccupationTypes.add(tot_occupations.concierge) + OccupationTypes.add(tot_occupations.cook) + OccupationTypes.add(tot_occupations.cooper) + OccupationTypes.add(tot_occupations.daycare_provider) + OccupationTypes.add(tot_occupations.dentist) + OccupationTypes.add(tot_occupations.dishwasher) + OccupationTypes.add(tot_occupations.distiller) + OccupationTypes.add(tot_occupations.doctor) + OccupationTypes.add(tot_occupations.dressmaker) + OccupationTypes.add(tot_occupations.druggist) + OccupationTypes.add(tot_occupations.engineer) + OccupationTypes.add(tot_occupations.farmer) + OccupationTypes.add(tot_occupations.farmhand) + OccupationTypes.add(tot_occupations.fire_chief) + OccupationTypes.add(tot_occupations.fire_fighter) + OccupationTypes.add(tot_occupations.grocer) + OccupationTypes.add(tot_occupations.groundskeeper) + OccupationTypes.add(tot_occupations.hotel_maid) + OccupationTypes.add(tot_occupations.inn_keeper) + OccupationTypes.add(tot_occupations.insurance_agent) + OccupationTypes.add(tot_occupations.janitor) + OccupationTypes.add(tot_occupations.jeweler) + OccupationTypes.add(tot_occupations.joiner) + OccupationTypes.add(tot_occupations.laborer) + OccupationTypes.add(tot_occupations.landlord) + OccupationTypes.add(tot_occupations.lawyer) + OccupationTypes.add(tot_occupations.manager) + OccupationTypes.add(tot_occupations.miner) + OccupationTypes.add(tot_occupations.milkman) + OccupationTypes.add(tot_occupations.mayor) + OccupationTypes.add(tot_occupations.molder) + OccupationTypes.add(tot_occupations.mortician) + OccupationTypes.add(tot_occupations.nurse) + OccupationTypes.add(tot_occupations.optometrist) + OccupationTypes.add(tot_occupations.owner) + OccupationTypes.add(tot_occupations.painter) + OccupationTypes.add(tot_occupations.pharmacist) + OccupationTypes.add(tot_occupations.plasterer) + OccupationTypes.add(tot_occupations.plastic_surgeon) + OccupationTypes.add(tot_occupations.plumber) + OccupationTypes.add(tot_occupations.police_chief) + OccupationTypes.add(tot_occupations.police_officer) + OccupationTypes.add(tot_occupations.principal) + OccupationTypes.add(tot_occupations.professor) + OccupationTypes.add(tot_occupations.proprietor) + OccupationTypes.add(tot_occupations.puddler) + OccupationTypes.add(tot_occupations.quarry_man) + OccupationTypes.add(tot_occupations.realtor) + OccupationTypes.add(tot_occupations.seamstress) + OccupationTypes.add(tot_occupations.secretary) + OccupationTypes.add(tot_occupations.stocker) + OccupationTypes.add(tot_occupations.shoemaker) + OccupationTypes.add(tot_occupations.stonecutter) + OccupationTypes.add(tot_occupations.tailor) + OccupationTypes.add(tot_occupations.tattoo_artist) + OccupationTypes.add(tot_occupations.taxi_driver) + OccupationTypes.add(tot_occupations.teacher) + OccupationTypes.add(tot_occupations.turner) + OccupationTypes.add(tot_occupations.waiter) + OccupationTypes.add(tot_occupations.whitewasher) + OccupationTypes.add(tot_occupations.woodworker) # Register Business Archetypes - BusinessArchetypeLibrary.add(tot_businesses.apartment_complex) - BusinessArchetypeLibrary.add(tot_businesses.bakery) - BusinessArchetypeLibrary.add(tot_businesses.bank) - BusinessArchetypeLibrary.add(tot_businesses.bar) - BusinessArchetypeLibrary.add(tot_businesses.barbershop) - BusinessArchetypeLibrary.add(tot_businesses.bus_depot) - BusinessArchetypeLibrary.add(tot_businesses.carpentry_company) - BusinessArchetypeLibrary.add(tot_businesses.cemetery) - BusinessArchetypeLibrary.add(tot_businesses.city_hall) - BusinessArchetypeLibrary.add(tot_businesses.clothing_store) - BusinessArchetypeLibrary.add(tot_businesses.coal_mine) - BusinessArchetypeLibrary.add(tot_businesses.construction_firm) - BusinessArchetypeLibrary.add(tot_businesses.dairy) - BusinessArchetypeLibrary.add(tot_businesses.day_care) - BusinessArchetypeLibrary.add(tot_businesses.deli) - BusinessArchetypeLibrary.add(tot_businesses.dentist_office) - BusinessArchetypeLibrary.add(tot_businesses.department_store) - BusinessArchetypeLibrary.add(tot_businesses.diner) - BusinessArchetypeLibrary.add(tot_businesses.distillery) - BusinessArchetypeLibrary.add(tot_businesses.drug_store) - BusinessArchetypeLibrary.add(tot_businesses.farm) - BusinessArchetypeLibrary.add(tot_businesses.fire_station) - BusinessArchetypeLibrary.add(tot_businesses.foundry) - BusinessArchetypeLibrary.add(tot_businesses.furniture_store) - BusinessArchetypeLibrary.add(tot_businesses.general_store) - BusinessArchetypeLibrary.add(tot_businesses.grocery_store) - BusinessArchetypeLibrary.add(tot_businesses.hardware_store) - BusinessArchetypeLibrary.add(tot_businesses.hospital) - BusinessArchetypeLibrary.add(tot_businesses.hotel) - BusinessArchetypeLibrary.add(tot_businesses.inn) - BusinessArchetypeLibrary.add(tot_businesses.insurance_company) - BusinessArchetypeLibrary.add(tot_businesses.jewelry_shop) - BusinessArchetypeLibrary.add(tot_businesses.law_firm) - BusinessArchetypeLibrary.add(tot_businesses.optometry_clinic) - BusinessArchetypeLibrary.add(tot_businesses.painting_company) - BusinessArchetypeLibrary.add(tot_businesses.park) - BusinessArchetypeLibrary.add(tot_businesses.pharmacy) - BusinessArchetypeLibrary.add(tot_businesses.plastic_surgery_clinic) - BusinessArchetypeLibrary.add(tot_businesses.plumbing_company) - BusinessArchetypeLibrary.add(tot_businesses.police_station) - BusinessArchetypeLibrary.add(tot_businesses.quarry) - BusinessArchetypeLibrary.add(tot_businesses.realty_firm) - BusinessArchetypeLibrary.add(tot_businesses.restaurant) - BusinessArchetypeLibrary.add(tot_businesses.school) - BusinessArchetypeLibrary.add(tot_businesses.shoemaker_shop) - BusinessArchetypeLibrary.add(tot_businesses.supermarket) - BusinessArchetypeLibrary.add(tot_businesses.tailor_shop) - BusinessArchetypeLibrary.add(tot_businesses.tattoo_parlor) - BusinessArchetypeLibrary.add(tot_businesses.tavern) - BusinessArchetypeLibrary.add(tot_businesses.taxi_depot) + BusinessArchetypes.add("Bakery", tot_businesses.bakery) + BusinessArchetypes.add("Bank", tot_businesses.bank) + BusinessArchetypes.add("Bar", tot_businesses.bar) + BusinessArchetypes.add("BarberShop", tot_businesses.barbershop) + BusinessArchetypes.add("BusDepot", tot_businesses.bus_depot) + BusinessArchetypes.add("CarpentryCompany", tot_businesses.carpentry_company) + BusinessArchetypes.add("Cemetery", tot_businesses.cemetery) + BusinessArchetypes.add("CityHall", tot_businesses.city_hall) + BusinessArchetypes.add("ClothingStore", tot_businesses.clothing_store) + BusinessArchetypes.add("CoalMine", tot_businesses.coal_mine) + BusinessArchetypes.add("ConstructionFirm", tot_businesses.construction_firm) + BusinessArchetypes.add("Dairy", tot_businesses.dairy) + BusinessArchetypes.add("DayCare", tot_businesses.day_care) + BusinessArchetypes.add("Deli", tot_businesses.deli) + BusinessArchetypes.add("DentistOffice", tot_businesses.dentist_office) + BusinessArchetypes.add("DepartmentStore", tot_businesses.department_store) + BusinessArchetypes.add("Dinner", tot_businesses.diner) + BusinessArchetypes.add("Distillery", tot_businesses.distillery) + BusinessArchetypes.add("DrugStore", tot_businesses.drug_store) + BusinessArchetypes.add("Farm", tot_businesses.farm) + BusinessArchetypes.add("FireStation", tot_businesses.fire_station) + BusinessArchetypes.add("Foundry", tot_businesses.foundry) + BusinessArchetypes.add("FurnitureStore", tot_businesses.furniture_store) + BusinessArchetypes.add("GeneralStore", tot_businesses.general_store) + BusinessArchetypes.add("GroceryStore", tot_businesses.grocery_store) + BusinessArchetypes.add("HardwareStore", tot_businesses.hardware_store) + BusinessArchetypes.add("Hospital", tot_businesses.hospital) + BusinessArchetypes.add("Hotel", tot_businesses.hotel) + BusinessArchetypes.add("Inn", tot_businesses.inn) + BusinessArchetypes.add("InsuranceCompany", tot_businesses.insurance_company) + BusinessArchetypes.add("JewelryShop", tot_businesses.jewelry_shop) + BusinessArchetypes.add("LawFirm", tot_businesses.law_firm) + BusinessArchetypes.add("OptometryClinic", tot_businesses.optometry_clinic) + BusinessArchetypes.add("PaintingCompany", tot_businesses.painting_company) + BusinessArchetypes.add("Park", tot_businesses.park) + BusinessArchetypes.add("Pharmacy", tot_businesses.pharmacy) + BusinessArchetypes.add( + "PlasticSurgeryClinic", tot_businesses.plastic_surgery_clinic + ) + BusinessArchetypes.add("PlumbingCompany", tot_businesses.plumbing_company) + BusinessArchetypes.add("PoliceStation", tot_businesses.police_station) + BusinessArchetypes.add("Quarry", tot_businesses.quarry) + BusinessArchetypes.add("RealtyFirm", tot_businesses.realty_firm) + BusinessArchetypes.add("Restaurant", tot_businesses.restaurant) + BusinessArchetypes.add("School", tot_businesses.school) + BusinessArchetypes.add("ShoemakerShop", tot_businesses.shoemaker_shop) + BusinessArchetypes.add("SuperMarket", tot_businesses.supermarket) + BusinessArchetypes.add("TailorShop", tot_businesses.tailor_shop) + BusinessArchetypes.add("TattooParlor", tot_businesses.tattoo_parlor) + BusinessArchetypes.add("Tavern", tot_businesses.tavern) + BusinessArchetypes.add("TaxiDepot", tot_businesses.taxi_depot) def get_plugin() -> Plugin: diff --git a/src/neighborly/plugins/talktown/business_archetypes.py b/src/neighborly/plugins/talktown/business_archetypes.py index 90756d9..932ece0 100644 --- a/src/neighborly/plugins/talktown/business_archetypes.py +++ b/src/neighborly/plugins/talktown/business_archetypes.py @@ -1,50 +1,95 @@ -from neighborly.core.archetypes import BusinessArchetype - -apartment_complex = BusinessArchetype( - name="Apartment Complex", - hours=["day"], - owner_type="Landlord", - max_instances=99, - min_population=50, - services=["housing"], - # TODO: Create and add component for ApartmentComplexes - extra_components={}, - employee_types={ - "Janitor": 1, - }, -) - - -bakery = BusinessArchetype( - name="Bakery", +from neighborly.core.archetypes import BaseBusinessArchetype +from neighborly.plugins.talktown.business_components import ( + Bakery, + Bank, + Bar, + Barbershop, + BusDepot, + CarpentryCompany, + Cemetery, + CityHall, + ClothingStore, + CoalMine, + ConstructionFirm, + Dairy, + DaycareCenter, + Deli, + DentistOffice, + DepartmentStore, + Diner, + Distillery, + DrugStore, + Farm, + FireStation, + Foundry, + FurnitureStore, + GeneralStore, + GroceryStore, + HardwareStore, + Hospital, + Hotel, + Inn, + InsuranceCompany, + JeweleryShop, + LawFirm, + OptometryClinic, + PaintingCompany, + Park, + Pharmacy, + PlasticSurgeryClinic, + PlumbingCompany, + PoliceStation, + Quarry, + RealtyFirm, + Restaurant, + School, + ShoemakerShop, + Supermarket, + TailorShop, + TattooParlor, + Tavern, + TaxiDepot, +) + +bakery = BaseBusinessArchetype( + business_type=Bakery, + name_format="Bakery", owner_type="Baker", employee_types={"Apprentice": 1}, services=["shopping", "errands"], ) -bank = BusinessArchetype( - name="Bank", - hours=["day", "afternoon"], + +bank = BaseBusinessArchetype( + business_type=Bank, + name_format="Bank", + hours="day", owner_type="Banker", services=["errands"], employee_types={"Bank Teller": 3, "Janitor": 1, "Manager": 1}, ) -bar = BusinessArchetype( - name="Bar", - hours=["evening", "night"], +bar = BaseBusinessArchetype( + business_type=Bar, + name_format="Bar", + hours="evening", owner_type="Owner", employee_types={"Bartender": 4, "Manager": 1}, services=["drinking", "socializing"], ) -barbershop = BusinessArchetype( - name="Barbershop", hours=["day"], owner_type="Barber", employee_types={"Cashier": 1} +barbershop = BaseBusinessArchetype( + business_type=Barbershop, + name_format="Barbershop", + hours="day", + owner_type="Barber", + employee_types={"Cashier": 1}, ) -bus_depot = BusinessArchetype( - name="Bus Depot", - hours=["day", "evening"], +bus_depot = BaseBusinessArchetype( + business_type=BusDepot, + name_format="Bus Depot", + hours="day", employee_types={ "Secretary": 1, "Bus Driver": 3, @@ -52,24 +97,27 @@ }, ) -carpentry_company = BusinessArchetype( - name="Carpentry Company", +carpentry_company = BaseBusinessArchetype( + business_type=CarpentryCompany, + name_format="Carpentry Company", owner_type="Carpenter", employee_types={"Apprentice": 1, "Builder": 1}, min_population=70, max_instances=1, ) -cemetery = BusinessArchetype( - name="Cemetery", +cemetery = BaseBusinessArchetype( + business_type=Cemetery, + name_format="Cemetery", owner_type="Mortician", employee_types={"Secretary": 1, "Apprentice": 1, "Groundskeeper": 1}, max_instances=1, ) -city_hall = BusinessArchetype( - name="City Hall", +city_hall = BaseBusinessArchetype( + business_type=CityHall, + name_format="City Hall", employee_types={ "Secretary": 2, "Janitor": 1, @@ -78,47 +126,53 @@ max_instances=1, ) -clothing_store = BusinessArchetype( - name="Clothing Store", +clothing_store = BaseBusinessArchetype( + business_type=ClothingStore, + name_format="Clothing Store", owner_type="Clothier", employee_types={"Cashier": 2, "Seamstress": 1, "Dressmaker": 1, "Tailor": 1}, max_instances=2, services=["shopping"], ) -coal_mine = BusinessArchetype( - name="Coal Mine", +coal_mine = BaseBusinessArchetype( + business_type=CoalMine, + name_format="Coal Mine", owner_type="Owner", employee_types={"Miner": 2, "Manager": 1}, max_instances=1, ) -construction_firm = BusinessArchetype( - name="Construction Firm", +construction_firm = BaseBusinessArchetype( + business_type=ConstructionFirm, + name_format="Construction Firm", max_instances=1, min_population=80, owner_type="Architect", employee_types={"Secretary": 1, "Builder": 3, "Bricklayer": 1}, ) -dairy = BusinessArchetype( - name="Dairy", +dairy = BaseBusinessArchetype( + business_type=Dairy, + name_format="Dairy", owner_type="Milkman", employee_types={"Apprentice": 1, "Bottler": 1}, max_instances=1, min_population=30, ) -day_care = BusinessArchetype( - name="Daycare Service", +day_care = BaseBusinessArchetype( + business_type=DaycareCenter, + name_format="Daycare Service", owner_type="Daycare Provider", employee_types={"Daycare Provider": 2}, min_population=200, max_instances=1, ) -deli = BusinessArchetype( - name="Deli", +deli = BaseBusinessArchetype( + business_type=Deli, + name_format="Deli", owner_type="Proprietor", employee_types={"Cashier": 2, "Manager": 1}, min_population=100, @@ -126,15 +180,17 @@ services=["shopping"], ) -dentist_office = BusinessArchetype( - name="Dentist Office", +dentist_office = BaseBusinessArchetype( + business_type=DentistOffice, + name_format="Dentist Office", owner_type="Dentist", employee_types={"Nurse": 2, "Secretary": 1}, min_population=75, ) -department_store = BusinessArchetype( - name="Department Store", +department_store = BaseBusinessArchetype( + business_type=DepartmentStore, + name_format="Department Store", owner_type="Owner", employee_types={"Cashier": 2, "Manager": 1}, min_population=200, @@ -142,8 +198,9 @@ services=["shopping"], ) -diner = BusinessArchetype( - name="Diner", +diner = BaseBusinessArchetype( + business_type=Diner, + name_format="Diner", owner_type="Proprietor", employee_types={"Cook": 1, "Waiter": 1, "Busboy": 1, "Manager": 1}, year_available=1945, @@ -152,15 +209,17 @@ services=["food", "socializing"], ) -distillery = BusinessArchetype( - name="Distillery", +distillery = BaseBusinessArchetype( + business_type=Distillery, + name_format="Distillery", owner_type="Distiller", employee_types={"Bottler": 1, "Cooper": 1}, max_instances=1, ) -drug_store = BusinessArchetype( - name="Drug Store", +drug_store = BaseBusinessArchetype( + business_type=DrugStore, + name_format="Drug Store", owner_type="Druggist", employee_types={"Cashier": 1}, min_population=30, @@ -168,19 +227,25 @@ services=["shopping"], ) -farm = BusinessArchetype( - name="Farm", owner_type="Farmer", employee_types={"Farmhand": 2}, max_instances=99 +farm = BaseBusinessArchetype( + business_type=Farm, + name_format="Farm", + owner_type="Farmer", + employee_types={"Farmhand": 2}, + max_instances=99, ) -fire_station = BusinessArchetype( - name="Fire Station", +fire_station = BaseBusinessArchetype( + business_type=FireStation, + name_format="Fire Station", employee_types={"Fire Chief": 1, "Fire Fighter": 2}, min_population=100, max_instances=1, ) -foundry = BusinessArchetype( - name="Foundry", +foundry = BaseBusinessArchetype( + business_type=Foundry, + name_format="Foundry", owner_type="Owner", employee_types={"Molder": 1, "Puddler": 1}, year_available=1830, @@ -189,8 +254,9 @@ max_instances=1, ) -furniture_store = BusinessArchetype( - name="Furniture Store", +furniture_store = BaseBusinessArchetype( + business_type=FurnitureStore, + name_format="Furniture Store", owner_type="Woodworker", employee_types={"Apprentice": 1}, min_population=20, @@ -198,8 +264,9 @@ services=["shopping"], ) -general_store = BusinessArchetype( - name="General Store", +general_store = BaseBusinessArchetype( + business_type=GeneralStore, + name_format="General Store", owner_type="Grocer", employee_types={"Manager": 1, "Stocker": 1, "Cashier": 1}, min_population=20, @@ -207,8 +274,9 @@ services=["shopping"], ) -grocery_store = BusinessArchetype( - name="Grocery Store", +grocery_store = BaseBusinessArchetype( + business_type=GroceryStore, + name_format="Grocery Store", owner_type="Grocer", employee_types={"Manager": 1, "Stocker": 1, "Cashier": 1}, min_population=20, @@ -216,8 +284,9 @@ services=["shopping"], ) -hardware_store = BusinessArchetype( - name="Hardware Store", +hardware_store = BaseBusinessArchetype( + business_type=HardwareStore, + name_format="Hardware Store", owner_type="Proprietor", employee_types={"Manager": 1, "Stocker": 1, "Cashier": 1}, min_population=40, @@ -225,33 +294,38 @@ services=["shopping"], ) -hospital = BusinessArchetype( - name="Hospital", +hospital = BaseBusinessArchetype( + business_type=Hospital, + name_format="Hospital", employee_types={"Doctor": 2, "Nurse": 1, "Secretary": 1}, min_population=200, ) -hotel = BusinessArchetype( - name="Hotel", +hotel = BaseBusinessArchetype( + business_type=Hotel, + name_format="Hotel", owner_type="Owner", employee_types={"Hotel Maid": 2, "Concierge": 1, "Manager": 1}, ) -inn = BusinessArchetype( - name="Inn", +inn = BaseBusinessArchetype( + business_type=Inn, + name_format="Inn", owner_type="Innkeeper", employee_types={"Hotel Maid": 2, "Concierge": 1, "Manager": 1}, ) -insurance_company = BusinessArchetype( - name="Insurance Company", +insurance_company = BaseBusinessArchetype( + business_type=InsuranceCompany, + name_format="Insurance Company", owner_type="Insurance Agent", employee_types={"Secretary": 1, "Insurance Agent": 2}, max_instances=1, ) -jewelry_shop = BusinessArchetype( - name="Jewelry Shop", +jewelry_shop = BaseBusinessArchetype( + business_type=JeweleryShop, + name_format="Jewelry Shop", owner_type="Jeweler", employee_types={"Cashier": 1, "Apprentice": 1}, min_population=200, @@ -259,8 +333,9 @@ services=["shopping"], ) -law_firm = BusinessArchetype( - name="Law Firm", +law_firm = BaseBusinessArchetype( + business_type=LawFirm, + name_format="Law Firm", owner_type="Lawyer", employee_types={"Lawyer": 1, "Secretary": 1}, min_population=150, @@ -268,36 +343,41 @@ ) -optometry_clinic = BusinessArchetype( - name="Optometry Clinic", +optometry_clinic = BaseBusinessArchetype( + business_type=OptometryClinic, + name_format="Optometry Clinic", owner_type="Optometrist", employee_types={"Secretary": 1, "Nurse": 1}, year_available=1990, ) -painting_company = BusinessArchetype( - name="Painting Company", +painting_company = BaseBusinessArchetype( + business_type=PaintingCompany, + name_format="Painting Company", owner_type="Painter", employee_types={"Painter": 1, "Plasterer": 1}, ) -park = BusinessArchetype( - name="Park", +park = BaseBusinessArchetype( + business_type=Park, + name_format="Park", employee_types={"Groundskeeper": 1}, min_population=100, max_instances=99, services=["shopping", "socializing", "recreation"], ) -pharmacy = BusinessArchetype( - name="Pharmacy", +pharmacy = BaseBusinessArchetype( + business_type=Pharmacy, + name_format="Pharmacy", owner_type="Pharmacist", employee_types={"Pharmacist": 1, "Cashier": 1}, year_available=1960, ) -plastic_surgery_clinic = BusinessArchetype( - name="Plastic Surgery Clinic", +plastic_surgery_clinic = BaseBusinessArchetype( + business_type=PlasticSurgeryClinic, + name_format="Plastic Surgery Clinic", owner_type="Plastic Surgeon", employee_types={"Nurse": 1, "Secretary": 1}, min_population=200, @@ -305,59 +385,67 @@ year_available=1970, ) -plumbing_company = BusinessArchetype( - name="Plumbing Company", +plumbing_company = BaseBusinessArchetype( + business_type=PlumbingCompany, + name_format="Plumbing Company", owner_type="Plumber", employee_types={"Apprentice": 1, "Secretary": 1}, min_population=100, max_instances=2, ) -police_station = BusinessArchetype( - name="Police Station", +police_station = BaseBusinessArchetype( + business_type=PoliceStation, + name_format="Police Station", employee_types={"Police Chief": 1, "Police Officer": 2}, min_population=100, max_instances=1, ) -quarry = BusinessArchetype( - name="Quarry", +quarry = BaseBusinessArchetype( + business_type=Quarry, + name_format="Quarry", owner_type="Owner", employee_types={"Quarryman": 1, "Stone Cutter": 1, "Laborer": 1, "Engineer": 1}, max_instances=1, ) -realty_firm = BusinessArchetype( - name="Realty Firm", +realty_firm = BaseBusinessArchetype( + business_type=RealtyFirm, + name_format="Realty Firm", owner_type="Realtor", employee_types={"Realtor": 1, "Secretary": 1}, min_population=80, max_instances=2, ) -restaurant = BusinessArchetype( - name="Restaurant", +restaurant = BaseBusinessArchetype( + business_type=Restaurant, + name_format="Restaurant", owner_type="Proprietor", employee_types={"Waiter": 1, "Cook": 1, "Busboy": 1, "Manager": 1}, min_population=50, services=["food", "socializing"], ) -school = BusinessArchetype( - name="School", +school = BaseBusinessArchetype( + business_type=School, + name_format="School", employee_types={"Principal": 1, "Teacher": 2, "Janitor": 1}, max_instances=1, ) -shoemaker_shop = BusinessArchetype( - name="Shoemaker Shop", +shoemaker_shop = BaseBusinessArchetype( + business_type=ShoemakerShop, + name_format="Shoemaker Shop", owner_type="Shoemaker", employee_types={"Apprentice": 1}, services=["shopping"], ) -supermarket = BusinessArchetype( - name="Supermarket", +supermarket = BaseBusinessArchetype( + business_type=Supermarket, + name_format="Supermarket", owner_type="Owner", employee_types={"Cashier": 1, "Stocker": 1, "Manager": 1}, min_population=200, @@ -365,8 +453,9 @@ services=["shopping"], ) -tailor_shop = BusinessArchetype( - name="Tailor Shop", +tailor_shop = BaseBusinessArchetype( + business_type=TailorShop, + name_format="Tailor Shop", owner_type="Tailor", employee_types={"Apprentice": 1}, min_population=40, @@ -374,31 +463,34 @@ services=["shopping"], ) -tattoo_parlor = BusinessArchetype( - name="Tattoo Parlor", +tattoo_parlor = BaseBusinessArchetype( + business_type=TattooParlor, + name_format="Tattoo Parlor", year_available=1970, min_population=300, max_instances=1, owner_type="Tattoo Artist", employee_types={"Cashier": 1}, - hours=["afternoon"], + hours="afternoon", ) -tavern = BusinessArchetype( - name="Tavern", - hours=["evening", "night"], +tavern = BaseBusinessArchetype( + business_type=Tavern, + name_format="Tavern", + hours="evening", employee_types={"Cook": 1, "Bartender": 1, "Waiter": 1}, min_population=20, services=["drinking", "socializing"], ) -taxi_depot = BusinessArchetype( - name="Taxi Depot", +taxi_depot = BaseBusinessArchetype( + business_type=TaxiDepot, + name_format="Taxi Depot", owner_type="Proprietor", employee_types={"Taxi Driver": 3}, max_instances=1, year_available=1930, year_obsolete=9999, - hours=["day", "evening"], + hours="day", ) diff --git a/src/neighborly/plugins/talktown/business_components.py b/src/neighborly/plugins/talktown/business_components.py new file mode 100644 index 0000000..72e06a4 --- /dev/null +++ b/src/neighborly/plugins/talktown/business_components.py @@ -0,0 +1,350 @@ +from ordered_set import OrderedSet + +from neighborly.core.business import IBusinessType + + +class ApartmentComplex(IBusinessType): + """ + Apartment complex manages gameobjects with Apartment component instances. + + When the apartment complex is closed, all apartments are deleted, and + the residents are displaced + """ + + pass + + +class Bakery(IBusinessType): + """A bakery.""" + + pass + + +class Bank(IBusinessType): + """A bank.""" + + pass + + +class Bar(IBusinessType): + """A bar.""" + + pass + + +class Barbershop(IBusinessType): + """A barbershop.""" + + pass + + +class BlacksmithShop(IBusinessType): + """A blacksmith business.""" + + pass + + +class Brewery(IBusinessType): + """A brewery.""" + + pass + + +class BusDepot(IBusinessType): + """A bus depot.""" + + pass + + +class ButcherShop(IBusinessType): + """A butcher business.""" + + pass + + +class CandyStore(IBusinessType): + """A candy store.""" + + pass + + +class CarpentryCompany(IBusinessType): + """A carpentry company.""" + + pass + + +class Cemetery(IBusinessType): + """A cemetery on a tract in a town.""" + + pass + + +class CityHall(IBusinessType): + """The city hall.""" + + pass + + +class ClothingStore(IBusinessType): + """A store that sells clothing only; i.e., not a department store.""" + + pass + + +class CoalMine(IBusinessType): + """A coal mine.""" + + pass + + +class ConstructionFirm(IBusinessType): + """A construction firm.""" + + pass + + +class Dairy(IBusinessType): + """A store where milk is sold and from which milk is distributed.""" + + pass + + +class DaycareCenter(IBusinessType): + """A day care center for young children.""" + + pass + + +class Deli(IBusinessType): + """A delicatessen.""" + + pass + + +class DentistOffice(IBusinessType): + """A dentist office.""" + + pass + + +class DepartmentStore(IBusinessType): + """A department store.""" + + pass + + +class Diner(IBusinessType): + """A diner.""" + + pass + + +class Distillery(IBusinessType): + """A whiskey distillery.""" + + pass + + +class DrugStore(IBusinessType): + """A drug store.""" + + pass + + +class Farm(IBusinessType): + """A farm on a tract in a town.""" + + pass + + +class FireStation(IBusinessType): + """A fire station.""" + + pass + + +class Foundry(IBusinessType): + """A metal foundry.""" + + pass + + +class FurnitureStore(IBusinessType): + """A furniture store.""" + + pass + + +class GeneralStore(IBusinessType): + """A general store.""" + + pass + + +class GroceryStore(IBusinessType): + """A grocery store.""" + + pass + + +class HardwareStore(IBusinessType): + """A hardware store.""" + + pass + + +class Hospital(IBusinessType): + """A hospital.""" + + pass + + +class Hotel(IBusinessType): + """A hotel.""" + + pass + + +class Inn(IBusinessType): + """An inn.""" + + pass + + +class InsuranceCompany(IBusinessType): + """An insurance company.""" + + pass + + +class JeweleryShop(IBusinessType): + """A jewelry company.""" + + pass + + +class LawFirm(IBusinessType): + """A law firm.""" + + pass + + +class OptometryClinic(IBusinessType): + """An optometry clinic.""" + + pass + + +class PaintingCompany(IBusinessType): + """A painting company.""" + + pass + + +class Park(IBusinessType): + """A park on a tract in a town.""" + + pass + + +class Pharmacy(IBusinessType): + """A pharmacy.""" + + pass + + +class PlasticSurgeryClinic(IBusinessType): + """A plastic-surgery clinic.""" + + pass + + +class PlumbingCompany(IBusinessType): + """A plumbing company.""" + + pass + + +class PoliceStation(IBusinessType): + """A police station.""" + + pass + + +class Quarry(IBusinessType): + """A rock quarry.""" + + pass + + +class RealtyFirm(IBusinessType): + """A realty firm.""" + + pass + + +class Restaurant(IBusinessType): + """A restaurant.""" + + pass + + +class School(IBusinessType): + """The local K-12 school.""" + + __slots__ = "students" + + def __init__(self) -> None: + super().__init__() + self.students: OrderedSet[int] = OrderedSet() + + def add_student(self, student: int) -> None: + """Add student to the school""" + self.students.add(student) + + def remove_student(self, student: int) -> None: + """Remove student from the school""" + self.students.add(student) + + +class ShoemakerShop(IBusinessType): + """A shoemaker's company.""" + + pass + + +class Supermarket(IBusinessType): + """A supermarket on a lot in a town.""" + + pass + + +class TailorShop(IBusinessType): + """A tailor.""" + + pass + + +class TattooParlor(IBusinessType): + """A tattoo parlor.""" + + pass + + +class Tavern(IBusinessType): + """A place where alcohol is served in the 19th century, maintained by a barkeeper.""" + + pass + + +class TaxiDepot(IBusinessType): + """A taxi depot.""" + + pass + + +class University(IBusinessType): + """The local university.""" + + pass diff --git a/src/neighborly/plugins/talktown/components.py b/src/neighborly/plugins/talktown/components.py deleted file mode 100644 index 212b5cb..0000000 --- a/src/neighborly/plugins/talktown/components.py +++ /dev/null @@ -1,12 +0,0 @@ -from neighborly.core.ecs import Component - - -class ApartmentComplex(Component): - """ - Apartment complex manages gameobjects with Apartment component instances. - - When the apartment complex is closed, all apartments are deleted, and - the residents are displaced - """ - - pass diff --git a/src/neighborly/plugins/talktown/occupation_types.py b/src/neighborly/plugins/talktown/occupation_types.py index 93a1225..db660e7 100644 --- a/src/neighborly/plugins/talktown/occupation_types.py +++ b/src/neighborly/plugins/talktown/occupation_types.py @@ -7,354 +7,293 @@ """ from neighborly.builtin.role_filters import ( - after_year, has_any_work_experience, has_experience_as_a, is_college_graduate, is_gender, ) -from neighborly.core.business import ( - OccupationType, - join_preconditions, - or_preconditions, -) +from neighborly.core.business import OccupationType, join_preconditions apprentice = OccupationType(name="Apprentice", level=1, precondition=is_gender("male")) architect = OccupationType( name="Architect", level=4, - precondition=join_preconditions( - or_preconditions(is_gender("male"), after_year(1977)), is_college_graduate() - ), + precondition=join_preconditions(is_college_graduate()), ) bottler = OccupationType( name="Bottler", level=1, - precondition=or_preconditions(is_gender("male"), after_year(1943)), ) bricklayer = OccupationType( name="Bricklayer", level=1, - precondition=or_preconditions(is_gender("male")), ) builder = OccupationType( name="Builder", level=1, - precondition=or_preconditions(is_gender("male"), after_year(1977)), ) cashier = OccupationType( name="Cashier", level=1, - precondition=or_preconditions(is_gender("male"), after_year(1917)), ) cook = OccupationType( name="Cook", level=1, - precondition=or_preconditions(is_gender("male"), after_year(1966)), ) dishwasher = OccupationType( name="Dishwasher", level=1, - precondition=or_preconditions(is_gender("male"), after_year(1966)), ) groundskeeper = OccupationType( name="Groundskeeper", level=1, - precondition=or_preconditions(is_gender("male"), after_year(1977)), ) hotel_maid = OccupationType( name="Hotel Maid", level=1, - precondition=is_gender("female"), ) janitor = OccupationType( name="Janitor", level=1, - precondition=or_preconditions(is_gender("male"), after_year(1966)), ) laborer = OccupationType( name="Laborer", level=1, - precondition=or_preconditions(is_gender("male")), ) secretary = OccupationType( name="Secretary", level=1, - precondition=or_preconditions(is_gender("female")), ) waiter = OccupationType( name="Waiter", level=1, - precondition=or_preconditions(is_gender("male"), after_year(1917)), ) whitewasher = OccupationType( name="Whitewasher", level=1, - precondition=or_preconditions(is_gender("male"), after_year(1977)), ) busboy = OccupationType( name="Busboy", level=1, - precondition=or_preconditions(is_gender("male"), after_year(1977)), ) stocker = OccupationType( name="Stocker", level=1, - precondition=or_preconditions(is_gender("male"), after_year(1943)), ) seamstress = OccupationType( name="Seamstress", level=1, - precondition=or_preconditions(is_gender("female")), ) farmer = OccupationType( name="Farmer", level=2, - precondition=or_preconditions(is_gender("male")), ) farmhand = OccupationType( name="Farmhand", level=1, - precondition=or_preconditions(is_gender("male")), ) miner = OccupationType( name="Miner", level=1, - precondition=or_preconditions(is_gender("male")), ) painter = OccupationType( name="Painter", level=1, - precondition=or_preconditions(is_gender("male"), after_year(1977)), ) banker = OccupationType( name="Banker", level=4, - precondition=or_preconditions(is_gender("male"), after_year(1968)), ) bank_teller = OccupationType( name="Bank Teller", level=2, - precondition=or_preconditions(is_gender("male"), after_year(1950)), ) grocer = OccupationType( name="Grocer", level=1, - precondition=or_preconditions(is_gender("male"), after_year(1966)), ) bartender = OccupationType( name="Bartender", level=1, - precondition=or_preconditions(is_gender("male"), after_year(1968)), ) concierge = OccupationType( name="Concierge", level=1, - precondition=or_preconditions(is_gender("male"), after_year(1968)), ) daycare_provider = OccupationType( name="Daycare Provider", level=1, - precondition=or_preconditions(is_gender("female"), after_year(1977)), ) landlord = OccupationType( name="Landlord", level=1, - precondition=or_preconditions(is_gender("male"), after_year(1925)), ) baker = OccupationType( name="Baker", level=1, - precondition=or_preconditions(is_gender("male"), after_year(1935)), ) cooper = OccupationType( name="Cooper", level=2, - precondition=or_preconditions(is_gender("male"), after_year(1977)), ) barkeeper = OccupationType( name="Barkeeper", level=1, - precondition=or_preconditions(is_gender("male"), after_year(1977)), ) milkman = OccupationType( name="Milkman", level=1, - precondition=or_preconditions(is_gender("male"), after_year(1977)), ) plasterer = OccupationType( name="Plasterer", level=1, - precondition=or_preconditions(is_gender("male"), after_year(1977)), ) barber = OccupationType( name="Barber", level=1, - precondition=or_preconditions(is_gender("male"), after_year(1977)), ) butcher = OccupationType( name="Butcher", level=1, - precondition=or_preconditions(is_gender("male")), ) fire_fighter = OccupationType( name="Fire Fighter", level=1, - precondition=or_preconditions(is_gender("male")), ) carpenter = OccupationType( name="Carpenter", level=1, - precondition=or_preconditions(is_gender("male")), ) taxi_driver = OccupationType( name="Taxi Driver", level=1, - precondition=or_preconditions(is_gender("male")), ) bus_driver = OccupationType( name="Bus Driver", level=1, - precondition=or_preconditions(is_gender("male"), after_year(1972)), ) blacksmith = OccupationType( name="Blacksmith", level=1, - precondition=or_preconditions(is_gender("male")), ) woodworker = OccupationType( name="Woodworker", level=1, - precondition=or_preconditions(is_gender("male"), after_year(1977)), ) stonecutter = OccupationType( name="Stone Cutter", level=1, - precondition=or_preconditions(is_gender("male")), ) dressmaker = OccupationType( name="Dressmaker", level=1, - precondition=or_preconditions(is_gender("female"), after_year(1972)), ) distiller = OccupationType( name="Distiller", level=1, - precondition=or_preconditions(is_gender("male")), ) plumber = OccupationType( name="Plumber", level=1, - precondition=or_preconditions(is_gender("male")), ) joiner = OccupationType( name="Joiner", level=1, - precondition=or_preconditions(is_gender("male"), after_year(1977)), ) inn_keeper = OccupationType( name="Innkeeper", level=1, - precondition=or_preconditions(is_gender("male"), after_year(1928)), ) nurse = OccupationType( name="Nurse", level=1, - precondition=or_preconditions(is_gender("female"), after_year(1977)), ) shoemaker = OccupationType( name="Shoemaker", level=2, - precondition=or_preconditions(is_gender("male"), after_year(1960)), ) brewer = OccupationType( name="Brewer", level=2, - precondition=or_preconditions(is_gender("male")), ) tattoo_artist = OccupationType( name="Tattoo Artist", level=2, - precondition=or_preconditions(is_gender("male"), after_year(1972)), ) puddler = OccupationType( name="Puddler", level=1, - precondition=or_preconditions(is_gender("male")), ) clothier = OccupationType( name="Clothier", level=2, - precondition=or_preconditions(is_gender("male"), after_year(1930)), ) teacher = OccupationType( name="Teacher", level=2, - precondition=or_preconditions(is_gender("female"), after_year(1955)), ) principal = OccupationType( name="Principal", level=3, precondition=join_preconditions( - or_preconditions(is_gender("male"), after_year(1965)), has_experience_as_a("Teacher"), ), ) @@ -362,47 +301,39 @@ tailor = OccupationType( name="Tailor", level=2, - precondition=or_preconditions(is_gender("male"), after_year(1955)), ) molder = OccupationType( name="Molder", level=2, - precondition=or_preconditions(is_gender("male")), ) turner = OccupationType( name="Turner", level=2, - precondition=or_preconditions(is_gender("male"), after_year(1977)), ) quarry_man = OccupationType( name="Quarryman", level=2, - precondition=or_preconditions(is_gender("male")), ) proprietor = OccupationType( name="Proprietor", level=2, - precondition=or_preconditions(is_gender("male"), after_year(1955)), ) dentist = OccupationType( name="Dentist", level=4, - precondition=join_preconditions( - or_preconditions(is_gender("male"), after_year(1972)), is_college_graduate() - ), + precondition=join_preconditions(is_college_graduate()), ) doctor = OccupationType( name="Doctor", level=4, precondition=join_preconditions( - or_preconditions(is_gender("male"), after_year(1972)), has_any_work_experience(), is_college_graduate(), ), @@ -411,35 +342,28 @@ druggist = OccupationType( name="Druggist", level=3, - precondition=or_preconditions(is_gender("male")), ) engineer = OccupationType( name="Engineer", level=4, - precondition=join_preconditions( - or_preconditions(is_gender("male"), after_year(1977)), is_college_graduate() - ), + precondition=join_preconditions(is_college_graduate()), ) fire_chief = OccupationType( name="Fire Chief", level=3, - precondition=join_preconditions( - is_gender("male"), has_experience_as_a("Fire Fighter") - ), + precondition=join_preconditions(has_experience_as_a("Fire Fighter")), ) insurance_agent = OccupationType( name="Insurance Agent", level=3, - precondition=or_preconditions(is_gender("male"), after_year(1972)), ) jeweler = OccupationType( name="Jeweler", level=3, - precondition=or_preconditions(is_gender("male"), after_year(1972)), ) @@ -447,7 +371,6 @@ name="Lawyer", level=4, precondition=join_preconditions( - or_preconditions(is_gender("male"), after_year(1977)), has_any_work_experience(), is_college_graduate(), ), @@ -456,57 +379,46 @@ manager = OccupationType( name="Manager", level=2, - precondition=or_preconditions(is_gender("male"), after_year(1972)), ) mayor = OccupationType( name="Mayor", level=5, - precondition=or_preconditions(is_gender("male"), after_year(1977)), ) mortician = OccupationType( name="Mortician", level=3, - precondition=or_preconditions(is_gender("male"), after_year(1977)), ) owner = OccupationType( name="Owner", level=5, - precondition=or_preconditions(is_gender("male"), after_year(1977)), ) professor = OccupationType( name="Professor", level=4, - precondition=join_preconditions( - or_preconditions(is_gender("male"), after_year(1962)), is_college_graduate() - ), + precondition=join_preconditions(is_college_graduate()), ) optometrist = OccupationType( name="Optometrist", level=4, - precondition=join_preconditions( - or_preconditions(is_gender("male"), after_year(1972)), is_college_graduate() - ), + precondition=join_preconditions(is_college_graduate()), ) pharmacist = OccupationType( name="Pharmacist", level=4, - precondition=join_preconditions( - or_preconditions(is_gender("male"), after_year(1972)), is_college_graduate() - ), + precondition=join_preconditions(is_college_graduate()), ) plastic_surgeon = OccupationType( name="Plastic Surgeon", level=4, precondition=join_preconditions( - or_preconditions(is_gender("male"), after_year(1972)), has_any_work_experience(), is_college_graduate(), ), @@ -515,14 +427,17 @@ police_chief = OccupationType( name="Police Chief", level=3, - precondition=join_preconditions( - is_gender("male"), has_experience_as_a("Police Officer") - ), + precondition=join_preconditions(has_experience_as_a("Police Chief")), +) + +police_officer = OccupationType( + name="Police Officer", + level=1, + precondition=join_preconditions(has_experience_as_a("Police Officer")), ) realtor = OccupationType( name="Realtor", level=3, - precondition=or_preconditions(is_gender("male"), after_year(1966)), ) diff --git a/src/neighborly/plugins/talktown/personality.py b/src/neighborly/plugins/talktown/personality.py index 8ab8b1c..c8b925d 100644 --- a/src/neighborly/plugins/talktown/personality.py +++ b/src/neighborly/plugins/talktown/personality.py @@ -1,6 +1,6 @@ """ Talk of the Town uses a Big 5 personality model -to make character decisions and determine +to make entity decisions and determine compatibility in social interactions """ from __future__ import annotations @@ -56,24 +56,24 @@ def create(cls, world: World, **kwargs) -> BigFivePersonality: engine: NeighborlyEngine = kwargs["engine"] openness: float = ( - engine.rng.random() * (BIG_FIVE_CAP - BIG_FIVE_FLOOR) - ) + BIG_FIVE_FLOOR + engine.rng.random() * (BIG_FIVE_CAP - BIG_FIVE_FLOOR) + ) + BIG_FIVE_FLOOR conscientiousness: float = ( - engine.rng.random() * (BIG_FIVE_CAP - BIG_FIVE_FLOOR) - ) + BIG_FIVE_FLOOR + engine.rng.random() * (BIG_FIVE_CAP - BIG_FIVE_FLOOR) + ) + BIG_FIVE_FLOOR extroversion: float = ( - engine.rng.random() * (BIG_FIVE_CAP - BIG_FIVE_FLOOR) - ) + BIG_FIVE_FLOOR + engine.rng.random() * (BIG_FIVE_CAP - BIG_FIVE_FLOOR) + ) + BIG_FIVE_FLOOR agreeableness: float = ( - engine.rng.random() * (BIG_FIVE_CAP - BIG_FIVE_FLOOR) - ) + BIG_FIVE_FLOOR + engine.rng.random() * (BIG_FIVE_CAP - BIG_FIVE_FLOOR) + ) + BIG_FIVE_FLOOR neuroticism: float = ( - engine.rng.random() * (BIG_FIVE_CAP - BIG_FIVE_FLOOR) - ) + BIG_FIVE_FLOOR + engine.rng.random() * (BIG_FIVE_CAP - BIG_FIVE_FLOOR) + ) + BIG_FIVE_FLOOR return cls( openness, conscientiousness, extroversion, agreeableness, neuroticism diff --git a/src/neighborly/plugins/talktown/school.py b/src/neighborly/plugins/talktown/school.py index 240ef5f..84f3723 100644 --- a/src/neighborly/plugins/talktown/school.py +++ b/src/neighborly/plugins/talktown/school.py @@ -1,40 +1,20 @@ -from ordered_set import OrderedSet - -from neighborly.builtin.statuses import Child, Teen, YoungAdult -from neighborly.core.ecs import Component, ISystem -from neighborly.core.life_event import EventRole, LifeEvent, LifeEventLog -from neighborly.core.status import Status +from neighborly.builtin.components import Child, Teen, YoungAdult +from neighborly.core.ecs import Component, ISystem, component_info +from neighborly.core.event import Event, EventLog, EventRole from neighborly.core.time import SimDateTime +from neighborly.plugins.talktown.business_components import School -class School(Component): - """A school is where Characters that are Children through Teen study""" - - __slots__ = "students" - - def __init__(self) -> None: - super().__init__() - self.students: OrderedSet[int] = OrderedSet() - - def add_student(self, student: int) -> None: - """Add student to the school""" - self.students.add(student) - - def remove_student(self, student: int) -> None: - """Remove student from the school""" - self.students.add(student) - - -class Student(Status): - def __init__(self): - super().__init__("Student", "This character is a student at the local school") +@component_info("Student", "This entity is a student at the local school") +class Student(Component): + pass class SchoolSystem(ISystem): """Enrolls new students and graduates old students""" def process(self, *args, **kwargs) -> None: - event_logger = self.world.get_resource(LifeEventLog) + event_logger = self.world.get_resource(EventLog) date = self.world.get_resource(SimDateTime) for _, school in self.world.get_component(School): @@ -55,7 +35,8 @@ def process(self, *args, **kwargs) -> None: school.remove_student(gid) young_adult.gameobject.remove_component(Student) event_logger.record_event( - LifeEvent( + self.world, + Event( "GraduatedSchool", date.to_iso_str(), [EventRole("Student", gid)], diff --git a/src/neighborly/plugins/weather_plugin.py b/src/neighborly/plugins/weather.py similarity index 95% rename from src/neighborly/plugins/weather_plugin.py rename to src/neighborly/plugins/weather.py index 574502f..485e057 100644 --- a/src/neighborly/plugins/weather_plugin.py +++ b/src/neighborly/plugins/weather.py @@ -33,6 +33,9 @@ def __init__(self, default: Weather = Weather.SUNNY) -> None: self.current_weather: Weather = default self.time_before_change: int = 0 + def __repr__(self) -> str: + return f"Weather({str(self.current_weather.value)})" + class WeatherSystem(ISystem): """Updates the current weather state diff --git a/src/neighborly/server/__init__.py b/src/neighborly/server/__init__.py deleted file mode 100644 index e4d388f..0000000 --- a/src/neighborly/server/__init__.py +++ /dev/null @@ -1,49 +0,0 @@ -from flask import Flask, render_template -from flask_socketio import SocketIO, emit - -from neighborly.core.character import GameCharacter -from neighborly.core.location import Location -from neighborly.simulation import Simulation - - -class NeighborlyServer: - def __init__(self, simulation: Simulation) -> None: - self.app = Flask(__name__) - self.app.config["SECRET"] = "neighborly-server-secret" - self.socketio = SocketIO(self.app, cors_allowed_origins="*") - self.sim = simulation - self._configure_socketio() - self._configure_routes() - - def _configure_socketio(self) -> None: - @self.socketio.on("step") - def step_simulation(): - self.sim.step() - emit("simulation-updated", broadcast=True) - - def _configure_routes(self) -> None: - @self.app.route("/characters") - def get_characters(): - return { - gid: character.to_dict() - for gid, character in self.sim.world.get_component(GameCharacter) - } - - @self.app.route("/locations") - def get_locations(): - return { - gid: location.to_dict() - for gid, location in self.sim.world.get_component(Location) - } - - @self.app.route("/gameobject/") - def get_gameobject(gid): - gameobject = self.sim.world.get_gameobject(int(gid)) - return {"components": [c.to_dict() for c in gameobject.components]} - - @self.app.route("/") - def index(): - return render_template("index.html") - - def run(self, host: str = "localhost") -> None: - self.socketio.run(self.app, host=host, debug=True) diff --git a/src/neighborly/server/static/js/index.js b/src/neighborly/server/static/js/index.js deleted file mode 100644 index 161cb0d..0000000 --- a/src/neighborly/server/static/js/index.js +++ /dev/null @@ -1,9 +0,0 @@ -const ReactAppFromCDN = () => { - return ( -
- This is a test of React-Bootstrap -
- ); -}; - -ReactDOM.render(, document.querySelector("#root")); diff --git a/src/neighborly/server/templates/index.html b/src/neighborly/server/templates/index.html deleted file mode 100644 index f200584..0000000 --- a/src/neighborly/server/templates/index.html +++ /dev/null @@ -1,101 +0,0 @@ - - - - - - - - Document - - - - - - - - - - - -

Neighborly

- - -
-
- -
-
-

-

-

Age:

-
-
- -
- - - - - - - diff --git a/src/neighborly/simulation.py b/src/neighborly/simulation.py index 577f997..f2f8797 100644 --- a/src/neighborly/simulation.py +++ b/src/neighborly/simulation.py @@ -6,24 +6,19 @@ from typing import Any, Dict, List, Literal, Optional, Tuple, Union from neighborly.builtin.systems import ( - BuildBusinessSystem, - BuildResidenceSystem, - BusinessUpdateSystem, - CharacterAgingSystem, - FindBusinessOwnerSystem, - FindEmployeesSystem, LinearTimeSystem, - PregnancySystem, - RelationshipStatusSystem, - RoutineSystem, - SocializeSystem, - SpawnResidentSystem, - UnemploymentSystem, + RemoveDeceasedFromOccupation, + RemoveDeceasedFromResidences, + RemoveDepartedFromOccupation, + RemoveDepartedFromResidences, + RemoveRetiredFromOccupation, ) +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.life_event import LifeEventLog, LifeEventSimulator -from neighborly.core.relationship import RelationshipGraph +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 @@ -31,11 +26,19 @@ class PluginSetupError(Exception): + """Exception thrown when an error occurs while loading a plugin""" + def __init__(self, *args: object) -> None: super().__init__(*args) class Plugin(ABC): + """ + Plugins are loaded before the simulation runs and can modify + a Simulation's World instance to add new components, systems, + resources, and entity archetypes. + """ + @classmethod def get_name(cls) -> str: """Return the name of this plugin""" @@ -58,14 +61,19 @@ class Simulation: ---------- world: World Entity-component system (ECS) that manages entities and procedures in the virtual world + engine: NeighborlyEngine + Engine instance used for PRNG and name generation + seed: int + The seed passed to the random number generator + starting_date: SimDateTime + The starting date for the simulation """ __slots__ = ( "world", "engine", "seed", - "world_gen_start", - "world_gen_end", + "starting_date", ) def __init__( @@ -73,20 +81,23 @@ def __init__( seed: int, world: World, engine: NeighborlyEngine, - world_gen_start: SimDateTime, - world_gen_end: SimDateTime, + starting_date: SimDateTime, ) -> None: self.seed: int = seed self.world: World = world self.engine: NeighborlyEngine = engine self.world.add_resource(engine) - self.world_gen_start: SimDateTime = world_gen_start - self.world_gen_end: SimDateTime = world_gen_end + self.starting_date: SimDateTime = starting_date - def establish_setting(self) -> None: - """Run the simulation until it reaches the end date in the config""" + def run_for(self, years: int) -> None: + """Run the simulation for a given number of years""" + stop_date = self.date.copy() + TimeDelta(years=years) + self.run_until(stop_date) + + def run_until(self, stop_date: SimDateTime) -> None: + """Run the simulation until a specific date is reached""" try: - while self.world_gen_end >= self.time: + while stop_date >= self.date: self.step() except KeyboardInterrupt: print("\nStopping Simulation") @@ -96,7 +107,7 @@ def step(self) -> None: self.world.step() @property - def time(self) -> SimDateTime: + def date(self) -> SimDateTime: """Get the simulated DateTime instance used by the simulation""" return self.world.get_resource(SimDateTime) @@ -107,56 +118,66 @@ def town(self) -> Town: class SimulationBuilder: - """Builder class for Neighborly Simulation instances""" + """ + Builder class for Neighborly Simulation instances + + Attributes + ---------- + time_increment_hours: int + How many hours should time advance each tick of the simulation + starting_date: SimDateTime + What date should the simulation start from + town_size: Tuple[int, int] + Tuple containing the width and length of the grid of land the town is built on + seed: int + The value used to seed the random number generator + systems: List[Tuple[ISystem, int]] + The systems to add to the simulation instance and their associated priorities + resources: List[Any] + Resource instances to add to the simulation instance + plugins: List[Tuple[Plugin, Dict[str, Any]]] + Plugins to add to the simulation + """ __slots__ = ( "time_increment_hours", - "world_gen_start", - "world_gen_end", + "starting_date", "town_name", "town_size", "seed", "systems", "resources", "plugins", + "print_events", + "life_event_interval_hours" ) def __init__( self, - seed: Optional[int] = None, - world_gen_start: Union[str, SimDateTime] = "0000-00-00", - world_gen_end: Union[str, SimDateTime] = "0100-00-00", + seed: Optional[Union[int, str]] = None, + starting_date: Union[str, SimDateTime] = "0000-00-00", time_increment_hours: int = 12, town_name: str = "#town_name#", town_size: TownSize = "medium", + print_events: bool = True, + life_event_interval_hours: int = 336 ) -> None: - self.seed: int = seed if seed is not None else random.randint(0, 99999999) + self.seed: int = hash(seed if seed is not None else random.randint(0, 99999999)) self.time_increment_hours: int = time_increment_hours - self.world_gen_start: SimDateTime = ( - world_gen_start - if isinstance(world_gen_start, SimDateTime) - else SimDateTime.from_iso_str(world_gen_start) - ) - self.world_gen_end: SimDateTime = ( - world_gen_end - if isinstance(world_gen_end, SimDateTime) - else SimDateTime.from_iso_str(world_gen_end) + self.starting_date: SimDateTime = ( + starting_date + if isinstance(starting_date, SimDateTime) + else SimDateTime.from_iso_str(starting_date) ) self.town_name: str = town_name - self.town_size: Tuple[int, int] = SimulationBuilder.convert_town_size(town_size) + self.town_size: Tuple[int, int] = SimulationBuilder._convert_town_size( + town_size + ) self.systems: List[Tuple[ISystem, int]] = [] self.resources: List[Any] = [] self.plugins: List[Tuple[Plugin, Dict[str, Any]]] = [] - - def add_system(self, system: ISystem, priority: int = 0) -> SimulationBuilder: - """Add a new system to the simulation""" - self.systems.append((system, priority)) - return self - - def add_resource(self, resource: Any) -> SimulationBuilder: - """Add a new resource to the simulation""" - self.resources.append(resource) - return self + 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""" @@ -169,20 +190,13 @@ def _create_town( ) -> SimulationBuilder: """Create a new grid of land to build the town on""" # create town - sim.world.add_resource(Town.create(sim.world, name=self.town_name)) + generated_name = sim.world.get_resource( + NeighborlyEngine + ).name_generator.get_name(self.town_name) + sim.world.add_resource(Town(generated_name)) # Create the land - if isinstance(self.town_size, tuple): - land_size = self.town_size - else: - if self.town_size == "small": - land_size = (3, 3) - elif self.town_size == "medium": - land_size = (5, 5) - else: - land_size = (7, 7) - - land_grid = LandGrid(land_size) + land_grid = LandGrid(self.town_size) sim.world.add_resource(land_grid) @@ -196,44 +210,43 @@ def build( seed=self.seed, world=World(), engine=NeighborlyEngine(), - world_gen_start=self.world_gen_start, - world_gen_end=self.world_gen_end, + starting_date=self.starting_date, + ) + + # These resources are required by all games + sim.world.add_resource(self.starting_date.copy()) + sim.world.add_resource(EventLog()) + + # The following systems are loaded by default + sim.world.add_system( + 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 ) - self.add_resource(self.world_gen_start.copy()) - self.add_system(LinearTimeSystem(TimeDelta(hours=self.time_increment_hours))) - self.add_system(LifeEventSimulator(interval=TimeDelta(months=1))) - self.add_resource(LifeEventLog()) - self.add_system(BuildResidenceSystem(interval=TimeDelta(days=5))) - self.add_system(SpawnResidentSystem(interval=TimeDelta(days=7))) - self.add_system(BuildBusinessSystem(interval=TimeDelta(days=5))) - self.add_resource(RelationshipGraph()) - self.add_system(CharacterAgingSystem()) - self.add_system(RoutineSystem(), 5) - self.add_system(BusinessUpdateSystem()) - self.add_system(FindBusinessOwnerSystem()) - self.add_system(FindEmployeesSystem()) - self.add_system(UnemploymentSystem(days_to_departure=30)) - self.add_system(RelationshipStatusSystem()) - self.add_system(SocializeSystem()) - self.add_system(PregnancySystem()) - - for system, priority in self.systems: - sim.world.add_system(system, priority) - - for resource in self.resources: - sim.world.add_resource(resource) + sim.world.add_system(RemoveDeceasedFromResidences()) + sim.world.add_system(RemoveDepartedFromResidences()) + sim.world.add_system(RemoveDepartedFromOccupation()) + sim.world.add_system(RemoveDeceasedFromOccupation()) + sim.world.add_system(RemoveRetiredFromOccupation()) for plugin, options in self.plugins: plugin.setup(sim, **options) logger.debug(f"Successfully loaded plugin: {plugin.get_name()}") + if self.print_events: + sim.world.get_resource(EventLog).subscribe(lambda e: print(str(e))) + self._create_town(sim) return sim @staticmethod - def convert_town_size(town_size: TownSize) -> Tuple[int, int]: + def _convert_town_size(town_size: TownSize) -> Tuple[int, int]: """Convert a TownSize to a tuple of ints""" if isinstance(town_size, tuple): land_size = town_size diff --git a/tests/test_business.py b/tests/test_business.py index 776686e..234276f 100644 --- a/tests/test_business.py +++ b/tests/test_business.py @@ -5,21 +5,21 @@ import pytest -from neighborly.core.archetypes import BusinessArchetype +from neighborly.core.archetypes import BaseBusinessArchetype from neighborly.core.business import ( Business, - BusinessStatus, - Occupation, OccupationType, - OccupationTypeLibrary, + OccupationTypes, + parse_operating_hour_str, ) -from neighborly.core.ecs import GameObject, World -from neighborly.core.status import Status +from neighborly.core.ecs import Component, GameObject, World +from neighborly.core.time import Weekday +from neighborly.plugins.talktown.business_components import Restaurant +from neighborly.simulation import SimulationBuilder -class CollegeGraduate(Status): - def __init__(self) -> None: - super().__init__("College Graduate", "This character graduated from college.") +class CollegeGraduate(Component): + pass @pytest.fixture @@ -29,62 +29,47 @@ def is_college_graduate( ) -> bool: return gameobject.has_component(CollegeGraduate) - ceo_occupation_type = OccupationType( - "CEO", - 5, - is_college_graduate, - ) + ceo_occupation_type = OccupationType("CEO", 5) return {"ceo": ceo_occupation_type} -@pytest.fixture -def sample_business_types(): - restaurant_type = BusinessArchetype(name="Restaurant", hours=["day"]) - - return {"restaurant": restaurant_type} - - def test_register_occupation_type(sample_occupation_types: Dict[str, OccupationType]): ceo_occupation_type = sample_occupation_types["ceo"] assert ceo_occupation_type.name == "CEO" assert ceo_occupation_type.level == 5 - OccupationTypeLibrary.add(ceo_occupation_type) - - assert ceo_occupation_type == OccupationTypeLibrary.get("CEO") - + OccupationTypes.add(ceo_occupation_type) -def test_occupation(sample_occupation_types: Dict[str, OccupationType]): - OccupationTypeLibrary.add(sample_occupation_types["ceo"]) + assert ceo_occupation_type == OccupationTypes.get("CEO") - ceo = Occupation(OccupationTypeLibrary.get("CEO"), 1) - assert ceo.get_type().name == "CEO" - assert ceo.get_business() == 1 - assert ceo.get_years_held() == 0 - - ceo.increment_years_held(0.5) - - assert ceo.get_years_held() == 0 - - ceo.increment_years_held(0.5) - - assert ceo.get_years_held() == 1 - - -def test_construct_occupation(): - """Constructing Occupations from OccupationTypes""" - pass +# def test_occupation(sample_occupation_types: Dict[str, OccupationType]): +# OccupationTypeLibrary.add(sample_occupation_types["ceo"]) +# +# ceo = Occupation(OccupationTypeLibrary.get("CEO"), 1) +# +# assert ceo.get_type().name == "CEO" +# assert ceo.get_business() == 1 +# assert ceo.get_years_held() == 0 +# +# ceo.increment_years_held(0.5) +# +# assert ceo.get_years_held() == 0 +# +# ceo.increment_years_held(0.5) +# +# assert ceo.get_years_held() == 1 +# def test_construct_business(): """Constructing business components using BusinessArchetypes""" - restaurant_Archetype = BusinessArchetype( - "Restaurant", + restaurant_archetype = BaseBusinessArchetype( + business_type=Restaurant, name_format="#restaurant_name#", - hours=["day", "evening"], + hours="11AM - 10PM", owner_type="Proprietor", employee_types={ "Cook": 1, @@ -93,13 +78,73 @@ def test_construct_business(): }, ) - world = World() + sim = SimulationBuilder().build() - restaurant = world.spawn_archetype(restaurant_Archetype) + restaurant = restaurant_archetype.create(world=sim.world) restaurant_business = restaurant.get_component(Business) - assert restaurant_business.business_type == "Restaurant" assert restaurant_business.owner_type == "Proprietor" - assert restaurant_business.status == BusinessStatus.PendingOpening assert restaurant_business.owner is None assert restaurant_business.needs_owner() is True + assert restaurant_business.operating_hours[Weekday.Monday] == (11, 22) + + +def test_parse_operating_hours_str(): + # Time Interval + assert parse_operating_hour_str("00-11")[Weekday.Sunday] == (0, 11) + assert parse_operating_hour_str("0-11")[Weekday.Monday] == (0, 11) + assert parse_operating_hour_str("2-17")[Weekday.Tuesday] == (2, 17) + assert parse_operating_hour_str("00-23")[Weekday.Wednesday] == (0, 23) + assert parse_operating_hour_str("21-04")[Weekday.Thursday] == (21, 4) + assert parse_operating_hour_str("23-06")[Weekday.Friday] == (23, 6) + assert parse_operating_hour_str("23-5")[Weekday.Saturday] == (23, 5) + + # Time Interval Alias + assert parse_operating_hour_str("day")[Weekday.Sunday] == (8, 11) + assert parse_operating_hour_str("day")[Weekday.Monday] == (8, 11) + assert parse_operating_hour_str("day")[Weekday.Tuesday] == (8, 11) + assert parse_operating_hour_str("day")[Weekday.Wednesday] == (8, 11) + assert parse_operating_hour_str("day")[Weekday.Thursday] == (8, 11) + assert parse_operating_hour_str("day")[Weekday.Friday] == (8, 11) + assert parse_operating_hour_str("day")[Weekday.Saturday] == (8, 11) + + # Days + Time Interval + assert parse_operating_hour_str("MT: 08-11")[Weekday.Monday] == (8, 11) + with pytest.raises(KeyError): + assert parse_operating_hour_str("MT: 08-11")[Weekday.Wednesday] == (8, 11) + + assert parse_operating_hour_str("WMF: 8-11")[Weekday.Friday] == (8, 11) + with pytest.raises(KeyError): + assert parse_operating_hour_str("MWF: 8-11")[Weekday.Tuesday] == (8, 11) + + assert parse_operating_hour_str("SU: 08 - 11")[Weekday.Sunday] == (8, 11) + with pytest.raises(KeyError): + assert parse_operating_hour_str("SU: 8 - 11")[Weekday.Friday] == (8, 11) + + assert parse_operating_hour_str("US: 08 - 11")[Weekday.Sunday] == (8, 11) + with pytest.raises(KeyError): + assert parse_operating_hour_str("SU: 8-11")[Weekday.Friday] == (8, 11) + + # Days + Time Interval Alias + assert parse_operating_hour_str("MT: day")[Weekday.Monday] == (8, 11) + with pytest.raises(KeyError): + assert parse_operating_hour_str("MT: day")[Weekday.Wednesday] == (8, 11) + + assert parse_operating_hour_str("WMF: day")[Weekday.Friday] == (8, 11) + with pytest.raises(KeyError): + assert parse_operating_hour_str("MWF: day")[Weekday.Tuesday] == (8, 11) + + assert parse_operating_hour_str("SU: day")[Weekday.Sunday] == (8, 11) + with pytest.raises(KeyError): + assert parse_operating_hour_str("SU: day")[Weekday.Friday] == (8, 11) + + assert parse_operating_hour_str("US: day")[Weekday.Sunday] == (8, 11) + with pytest.raises(KeyError): + assert parse_operating_hour_str("SU: day")[Weekday.Friday] == (8, 11) + + # Invalid values + with pytest.raises(ValueError): + parse_operating_hour_str("MONDAY") + parse_operating_hour_str("MONDAY: day") + parse_operating_hour_str("day - night") + parse_operating_hour_str("M: 9 - 24") diff --git a/tests/test_character.py b/tests/test_character.py deleted file mode 100644 index 9b4de95..0000000 --- a/tests/test_character.py +++ /dev/null @@ -1,54 +0,0 @@ -from typing import Any, Dict - -import pytest -import yaml - -from neighborly.core.character import CharacterDefinition - - -@pytest.fixture() -def sample_definitions() -> str: - return """ - CharacterDefinitions: - - - name: BaseCharacter - generation: - first_name: "#first_name#" - last_name: "#family_name#" - family: - probability_spouse: 0.5 - probability_children: 0.5 - num_children: "0-2" - lifecycle: - can_age: yes - can_die: yes - chance_of_death: 0.8 - romantic_feelings_age: 13 - marriageable_age: 18 - age_ranges: - child: "0-12" - adolescent: "13-19" - young_adult: "20-29" - adult: "30-65" - senior: "65-100" - """ - - -def test_merge_definitions(sample_definitions: str): - """Test that CharacterDefinitions can inherit from a parent""" - assert True - - -def test_parse_character_definition(sample_definitions: str): - """Test that character definitions are properly parsed""" - data: Dict[str, Any] = yaml.safe_load(sample_definitions) - - assert "CharacterDefinitions" in data - - base_character_def_data = data["CharacterDefinitions"][0] - - assert "BaseCharacter" == base_character_def_data["name"] - - base_character_def = CharacterDefinition(**base_character_def_data) - - assert "BaseCharacter" == base_character_def.name diff --git a/tests/test_ecs.py b/tests/test_ecs.py index ff97d1d..c9c30f8 100644 --- a/tests/test_ecs.py +++ b/tests/test_ecs.py @@ -2,7 +2,14 @@ import pytest -from neighborly.core.ecs import Component, World +from neighborly.core.ecs import ( + Component, + ComponentNotFoundError, + GameObjectNotFoundError, + ISystem, + ResourceNotFoundError, + World, +) class SimpleGameCharacter(Component): @@ -45,6 +52,223 @@ class FakeResource: config_value: int = 5 +@dataclass +class AnotherFakeResource: + config_value: int = 43 + + +class FakeSystemBaseA(ISystem): + def process(self, *args, **kwargs): + for _, a in self.world.get_component(A): + a.value += 1 + + +######################################### +# TEST WORLD GAMEOBJECT-RELATED METHODS +######################################### + + +def test_spawn_gameobject(): + world = World() + + adrian = world.spawn_gameobject( + [SimpleRoutine(False), SimpleGameCharacter("Adrian")] + ) + + assert world.ecs.entity_exists(adrian.id) + + jamie = world.spawn_gameobject([SimpleRoutine(False), SimpleGameCharacter("Jamie")]) + + assert world.ecs.entity_exists(jamie.id) + + park = world.spawn_gameobject([SimpleLocation()], name="Park") + + assert world.ecs.entity_exists(park.id) + + office_building = world.spawn_gameobject( + [SimpleLocation(18)], name="Office Building" + ) + + assert world.ecs.entity_exists(office_building.id) + + assert jamie.get_component(SimpleGameCharacter).name == "Jamie" + + assert len(world.get_component(SimpleGameCharacter)) == 2 + assert len(world.get_component(SimpleLocation)) == 2 + + assert park.get_component(SimpleLocation).capacity == 999 + assert office_building.get_component(SimpleLocation).capacity == 18 + + assert len(world.get_components(SimpleGameCharacter, SimpleRoutine)) == 2 + + +def test_get_gameobject(): + world = World() + gameobject = world.spawn_gameobject() + assert world.get_gameobject(gameobject.id) == gameobject + + +def test_get_gameobject_raises_exception(): + with pytest.raises(GameObjectNotFoundError): + world = World() + world.get_gameobject(7) + + +def test_has_gameobject(): + world = World() + assert world.has_gameobject(1) is False + gameobject = world.spawn_gameobject() + assert world.has_gameobject(gameobject.id) is True + + +def test_get_gameobjects(): + world = World() + g1 = world.spawn_gameobject() + g2 = world.spawn_gameobject() + g3 = world.spawn_gameobject() + assert world.get_gameobjects() == [g1, g2, g3] + + +def test_try_gameobject(): + world = World() + assert world.try_gameobject(1) is None + world.spawn_gameobject() + assert world.try_gameobject(1) is not None + + +def test_delete_gameobject(): + + world = World() + + g1 = world.spawn_gameobject([A()]) + + # Make sure that the game objects exists + assert world.has_gameobject(g1.id) is True + + world.delete_gameobject(g1.id) + + # the GameObject should still exist and be removed + # at the start of the next step + assert world.has_gameobject(g1.id) is True + + world.step() + + # Now the gameobject should be deleted + assert world.has_gameobject(g1.id) is False + + # Ensure that GameObjects that were always empty + # are properly removed + g2 = world.spawn_gameobject() + assert world.has_gameobject(g2.id) is True + world.delete_gameobject(g2.id) + world.step() + assert world.has_gameobject(g2.id) is False + + # Ensure that GameObjects that are empty, but + # once held components are properly removed + g3 = world.spawn_gameobject([A()]) + assert g3.has_component(A) is True + g3.remove_component(A) + assert g3.has_component(A) is False + assert world.has_gameobject(g3.id) is True + # When you remove the last component from an entity, + # it technically does not exist within esper anymore + assert world.ecs.entity_exists(g3.id) is False + world.delete_gameobject(g3.id) + world.step() + assert world.has_gameobject(g3.id) is False + + +######################################### +# TEST WORLD COMPONENT-RELATED METHODS +######################################### + + +def test_world_get_component(): + world = World() + world.spawn_gameobject([A()]) + world.spawn_gameobject([B()]) + world.spawn_gameobject([A(), B()]) + + with_a = world.get_component(A) + + assert list(zip(*with_a))[0] == (1, 3) + + with_b = world.get_component(B) + + assert list(zip(*with_b))[0] == (2, 3) + + +def test_world_get_components(): + world = World() + world.spawn_gameobject([A()]) + world.spawn_gameobject([B()]) + world.spawn_gameobject([A(), B()]) + + with_a = world.get_components(A) + + assert list(zip(*with_a))[0] == (1, 3) + + with_b = world.get_components(B) + + assert list(zip(*with_b))[0] == (2, 3) + + +######################################### +# TEST WORLD SYSTEM-RELATED METHODS +######################################### + + +def test_world_add_get_system(): + world = World() + + assert world.get_system(FakeSystemBaseA) is None + world.add_system(FakeSystemBaseA()) + assert world.get_system(FakeSystemBaseA) is not None + + +def test_world_remove_system(): + world = World() + + assert world.get_system(FakeSystemBaseA) is None + world.add_system(FakeSystemBaseA()) + assert world.get_system(FakeSystemBaseA) is not None + world.remove_system(FakeSystemBaseA) + assert world.get_system(FakeSystemBaseA) is None + + +def test_world_step(): + world = World() + world.add_system(FakeSystemBaseA()) + + g1 = world.spawn_gameobject([A(1)]) + g2 = world.spawn_gameobject([A(2)]) + g3 = world.spawn_gameobject([A(3), B()]) + + world.step() + + assert g1.get_component(A).value == 2 + assert g2.get_component(A).value == 3 + assert g3.get_component(A).value == 4 + + +######################################### +# TEST WORLD RESOURCE-RELATED METHODS +######################################### + + +def test_get_all_resources(): + world = World() + + fake_resource = FakeResource() + another_fake_resource = AnotherFakeResource + + world.add_resource(fake_resource) + world.add_resource(another_fake_resource) + + assert world.get_all_resources() == [fake_resource, another_fake_resource] + + def test_has_resource(): world = World() assert world.has_resource(FakeResource) is False @@ -56,24 +280,50 @@ def test_get_resource(): world = World() fake_resource = FakeResource() assert world.has_resource(FakeResource) is False - # This should throw a key error when not present - with pytest.raises(KeyError): - assert world.get_resource(FakeResource) world.add_resource(fake_resource) assert world.get_resource(FakeResource) == fake_resource +def test_get_resource_raises_exception(): + """ + Test that the .get_resource(...) method throws + a ResourceNotFoundError when attempting to get + a resource that does not exist in the world instance. + """ + world = World() + with pytest.raises(ResourceNotFoundError): + assert world.get_resource(FakeResource) + + def test_remove_resource(): world = World() world.add_resource(FakeResource()) assert world.has_resource(FakeResource) is True world.remove_resource(FakeResource) assert world.has_resource(FakeResource) is False - # This should throw a key error when not present - with pytest.raises(KeyError): + + +def test_remove_resource_raises_exception(): + """ + Test that .remove_resource(...) method throws a + ResourceNotFoundError when attempting to remove a + resource that does not exist in the World instance. + """ + world = World() + with pytest.raises(ResourceNotFoundError): world.remove_resource(FakeResource) +def test_try_resource(): + world = World() + + assert world.try_resource(FakeResource) is None + + world.add_resource(FakeResource()) + + assert world.try_resource(FakeResource) is not None + + def test_add_resource(): world = World() fake_resource = FakeResource() @@ -82,19 +332,45 @@ def test_add_resource(): assert world.has_resource(FakeResource) is True -def test_gameobject() -> None: +######################################### +# TEST GAMEOBJECT METHODS +######################################### + + +def test_add_component(): world = World() - adrian = world.spawn_gameobject(SimpleRoutine(False), SimpleGameCharacter("Adrian"), name="Adrian") - jamie = world.spawn_gameobject(SimpleRoutine(False), SimpleGameCharacter("Jamie"), name="Jamie") - park = world.spawn_gameobject(SimpleLocation(), name="Park") - office_building = world.spawn_gameobject(SimpleLocation(18), name="Office Building") + g1 = world.spawn_gameobject() + assert g1.has_component(A) is False + g1.add_component(A()) + assert g1.has_component(A) is True - assert jamie.get_component(SimpleGameCharacter).name == "Jamie" - assert len(world.get_component(SimpleGameCharacter)) == 2 - assert len(world.get_component(SimpleLocation)) == 2 +def test_get_component(): + world = World() + a_component = A() + g1 = world.spawn_gameobject([a_component]) + assert g1.get_component(A) == a_component - assert park.get_component(SimpleLocation).capacity == 999 - assert office_building.get_component(SimpleLocation).capacity == 18 - assert len(world.get_components(SimpleGameCharacter, SimpleRoutine)) == 2 +def test_get_component_raises_exception(): + with pytest.raises(ComponentNotFoundError): + world = World() + g1 = world.spawn_gameobject() + g1.get_component(A) + g1.get_component(B) + + +def test_remove_component(): + world = World() + g1 = world.spawn_gameobject([A(), B()]) + assert g1.has_component(A) is True + g1.remove_component(A) + assert g1.has_component(A) is False + + +def test_try_component(): + world = World() + g1 = world.spawn_gameobject() + assert g1.try_component(A) is None + g1.add_component(A()) + assert g1.try_component(A) is not None diff --git a/tests/test_graph.py b/tests/test_graph.py new file mode 100644 index 0000000..81e51ed --- /dev/null +++ b/tests/test_graph.py @@ -0,0 +1,134 @@ +""" +Test the class methods for the DirectedGraph and UndirectedGraph +classes located in the neighborly.core.utils package. +""" +import pytest + +from neighborly.core.utils.graph import DirectedGraph, UndirectedGraph + + +@pytest.fixture() +def sample_directed() -> DirectedGraph: + graph = DirectedGraph[str]() + graph.add_connection(1, 2, "friends") + graph.add_connection(2, 1, "enemies") + graph.add_connection(2, 3, "married") + graph.add_connection(3, 2, "married") + graph.add_connection(3, 1, "crush") + return graph + + +@pytest.fixture() +def sample_undirected() -> UndirectedGraph: + graph = UndirectedGraph[str]() + graph.add_connection(1, 2, "friends") + graph.add_connection(2, 3, "married") + graph.add_connection(3, 1, "crush") + return graph + + +def test_directed_has_connection(sample_directed: DirectedGraph[str]): + assert sample_directed.has_connection(1, 2) + assert sample_directed.has_connection(2, 1) + assert sample_directed.has_connection(1, 3) is False + + +def test_directed_get_connection(sample_directed: DirectedGraph[str]): + assert sample_directed.get_connection(1, 2) == "friends" + assert sample_directed.get_connection(2, 1) == "enemies" + assert sample_directed.get_connection(2, 3) == "married" + assert sample_directed.get_connection(3, 1) == "crush" + + +def test_directed_get_connection_exception(sample_directed: DirectedGraph[str]): + with pytest.raises(KeyError): + sample_directed.get_connection(1, 3) + sample_directed.get_connection(1, 1) + sample_directed.get_connection(3, 2) + + +def test_directed_remove_node(sample_directed: DirectedGraph[str]): + sample_directed.remove_node(1) + assert sample_directed.has_connection(1, 2) is False + assert sample_directed.has_connection(2, 1) is False + assert sample_directed.has_connection(3, 1) is False + + +def test_directed_remove_node_exception(sample_directed: DirectedGraph[str]): + sample_directed.remove_node(3) + + with pytest.raises(KeyError): + sample_directed.remove_node(3) + sample_directed.remove_node(4) + sample_directed.remove_node(5) + + +def test_directed_remove_connection(sample_directed: DirectedGraph[str]): + assert sample_directed.has_connection(1, 2) is True + sample_directed.remove_connection(1, 2) + assert sample_directed.has_connection(1, 2) is False + + assert sample_directed.has_connection(3, 1) is True + sample_directed.remove_connection(3, 1) + assert sample_directed.has_connection(3, 1) is False + + +def test_directed_remove_connection_exception(sample_directed: DirectedGraph[str]): + with pytest.raises(KeyError): + sample_directed.remove_connection(1, 3) + sample_directed.remove_connection(4, 3) + + +def test_undirected_has_connection(sample_undirected: UndirectedGraph[str]): + assert sample_undirected.has_connection(2, 1) is True + assert sample_undirected.has_connection(3, 2) is True + assert sample_undirected.has_connection(1, 3) is True + + +def test_undirected_get_connection(sample_undirected: UndirectedGraph[str]): + assert sample_undirected.get_connection(1, 2) == "friends" + assert sample_undirected.get_connection(2, 1) == "friends" + assert sample_undirected.get_connection(3, 2) == "married" + assert sample_undirected.get_connection(2, 3) == "married" + assert sample_undirected.get_connection(1, 3) == "crush" + assert sample_undirected.get_connection(3, 1) == "crush" + + +def test_undirected_get_connection_exception(sample_undirected: UndirectedGraph[str]): + with pytest.raises(KeyError): + sample_undirected.get_connection(1, 1) + sample_undirected.get_connection(3, 3) + sample_undirected.get_connection(4, 3) + + +def test_undirected_remove_node(sample_undirected: DirectedGraph[str]): + sample_undirected.remove_node(1) + assert sample_undirected.has_connection(1, 2) is False + assert sample_undirected.has_connection(2, 1) is False + assert sample_undirected.has_connection(3, 1) is False + assert sample_undirected.has_connection(1, 3) is False + + +def test_undirected_remove_node_exception(sample_undirected: UndirectedGraph[str]): + sample_undirected.remove_node(3) + + with pytest.raises(KeyError): + sample_undirected.remove_node(3) + sample_undirected.remove_node(4) + sample_undirected.remove_node(5) + + +def test_undirected_remove_connection(sample_undirected: UndirectedGraph[str]): + assert sample_undirected.has_connection(1, 2) is True + assert sample_undirected.has_connection(2, 1) is True + sample_undirected.remove_connection(1, 2) + assert sample_undirected.has_connection(1, 2) is False + assert sample_undirected.has_connection(2, 1) is False + + +def test_undirected_remove_connection_exception( + sample_undirected: UndirectedGraph[str], +): + with pytest.raises(KeyError): + sample_undirected.remove_connection(4, 3) + sample_undirected.remove_connection(5, 6) diff --git a/tests/test_grid.py b/tests/test_grid.py new file mode 100644 index 0000000..dcc1720 --- /dev/null +++ b/tests/test_grid.py @@ -0,0 +1,30 @@ +import pytest + +from neighborly.core.utils.grid import Grid + + +@pytest.fixture +def int_grid() -> Grid[int]: + grid: Grid[int] = Grid((3, 3), lambda: -1) + grid[1, 1] = -99 + return grid + + +def test_get_item(int_grid): + assert int_grid[1, 1] == -99 + assert int_grid[0, 0] == -1 + + +def test_get_item_raises_index_error(int_grid): + with pytest.raises(IndexError): + assert int_grid[-1, 0] + + +def test_set_item(int_grid): + int_grid[2, 2] = 56 + assert int_grid[2, 2] == 56 + + +def test_set_item_raises_index_error(int_grid): + with pytest.raises(IndexError): + assert int_grid[-1, 0] == 88 diff --git a/tests/test_life_event.py b/tests/test_life_event.py new file mode 100644 index 0000000..8ef754f --- /dev/null +++ b/tests/test_life_event.py @@ -0,0 +1,62 @@ +import pytest + +from neighborly.core.event import Event, EventRole + + +@pytest.fixture +def sample_event(): + return Event( + name="Price Dispute", + timestamp="2022-01-01T00:00:00.000000", + roles=[ + EventRole("Merchant", 1), + EventRole("Customer", 2), + ], + quoted_price=34, + asking_price=65, + ) + + +@pytest.fixture +def shared_role_event(): + return Event( + name="Declare Rivalry", + timestamp="2022-01-01T00:00:00.000000", + roles=[ + EventRole("Actor", 1), + EventRole("Actor", 2), + ], + ) + + +def test_life_event_get_type(sample_event): + assert sample_event.name == "Price Dispute" + + +def test_life_event_to_dict(sample_event): + serialized_event = sample_event.to_dict() + assert serialized_event["name"] == "Price Dispute" + assert serialized_event["timestamp"] == "2022-01-01T00:00:00.000000" + assert serialized_event["roles"][0] == {"name": "Merchant", "gid": 1} + assert serialized_event["roles"][1] == {"name": "Customer", "gid": 2} + assert serialized_event["metadata"]["quoted_price"] == 34 + assert serialized_event["metadata"]["asking_price"] == 65 + + +def test_life_event_get_all(shared_role_event): + assert shared_role_event.get_all("Actor") == [1, 2] + + +def test_life_event_get_all_raises_key_error(shared_role_event): + with pytest.raises(KeyError): + shared_role_event.get_all("Pizza") + + +def test_life_event_get_item(sample_event): + assert sample_event["Merchant"] == 1 + assert sample_event["Customer"] == 2 + + +def test_life_event_get_item_raises_key_error(sample_event): + with pytest.raises(KeyError): + assert sample_event["Clerk"] diff --git a/tests/test_query.py b/tests/test_query.py new file mode 100644 index 0000000..930bf03 --- /dev/null +++ b/tests/test_query.py @@ -0,0 +1,316 @@ +import pandas as pd +import pytest + +from neighborly import Component, World +from neighborly.builtin.components import Name, NonBinary, Retired +from neighborly.core.character import GameCharacter +from neighborly.core.query import ( + Query, + Relation, + eq_, + has_components, + ne_, + where, + where_any, + where_not, +) + + +def test_relation_create_empty(): + r0 = Relation.create_empty() + assert r0.empty is True + assert r0.get_symbols() == () + + r1 = Relation.create_empty("Apprentice", "Boss") + assert r1.empty is True + assert r1.get_symbols() == ("Apprentice", "Boss") + + +def test_relation_from_bindings(): + r0 = Relation.from_bindings(Initiator=0, LoveInterest=1) + assert r0.empty is False + assert r0.get_symbols() == ("Initiator", "LoveInterest") + + r0 = Relation.from_bindings(Rival=0, LoveInterest=1, Protagonist=4) + assert r0.empty is False + assert r0.get_symbols() == ("Rival", "LoveInterest", "Protagonist") + + +def test_relation_get_symbols(): + r0 = Relation.create_empty("Employee", "OldEmployer", "NewEmployer") + assert r0.get_symbols() == ("Employee", "OldEmployer", "NewEmployer") + + r1 = Relation.create_empty("Antagonist") + assert r1.get_symbols() == ("Antagonist",) + + +def test_relation_is_empty(): + r0 = Relation.create_empty() + assert r0.empty is True + + r1 = Relation.create_empty("Hero", "DemonKing") + assert r1.empty is True + + r2 = Relation(pd.DataFrame()) + assert r2.empty is True + + +def test_relation_get_tuples(): + r0 = Relation(pd.DataFrame({"Hero": [1, 1, 1], "DemonKing": [3, 4, 5]})) + assert r0.get_tuples() == [(1, 3), (1, 4), (1, 5)] + + r1 = Relation.from_bindings(Hero=1, DemonKing=4) + assert r1.get_tuples() == [(1, 4)] + + +def test_relation_get_data_frame(): + df = pd.DataFrame() + r0 = Relation(df) + assert id(r0.get_data_frame()) == id(df) + + +def test_relation_unify(): + + r0 = Relation.create_empty() + r1 = Relation(pd.DataFrame({"Hero": [1, 1, 1], "DemonKing": [3, 4, 5]})) + r2 = Relation(pd.DataFrame({"Hero": [1, 2], "LoveInterest": [4, 6]})) + r3 = Relation(pd.DataFrame({"Rival": [5, 3]})) + + # Test an empty Relation attempting to unify with a non-empty Relation + assert r0.unify(r1).empty is True + assert r0.unify(r1).get_symbols() == () + assert r0.unify(r1).get_tuples() == [] + + # Test a non-empty Relation attempting to unify with an empty Relation + assert r1.unify(r0).empty is True + assert r1.unify(r0).get_symbols() == () + assert r1.unify(r0).get_tuples() == [] + + # Test unify relations with shared symbols (DataFrame columns) + assert r1.unify(r2).empty is False + assert r1.unify(r2).get_symbols() == ("Hero", "DemonKing", "LoveInterest") + assert r1.unify(r2).get_tuples() == [ + (1, 3, 4), + (1, 4, 4), + (1, 5, 4), + ] + + # Test unify relations without shared symbols + assert r2.unify(r3).empty is False + assert r2.unify(r3).get_symbols() == ("Hero", "LoveInterest", "Rival") + assert r2.unify(r3).get_tuples() == [(1, 4, 5), (1, 4, 3), (2, 6, 5), (2, 6, 3)] + + +def test_relation_copy(): + r0 = Relation.create_empty() + r1 = r0.copy() + assert id(r0) != id(r1) + + +class Hero(Component): + pass + + +class DemonKing(Component): + pass + + +@pytest.fixture() +def sample_world() -> World: + world = World() + + world.spawn_gameobject([Hero(), GameCharacter(), Name("Shi")]) + world.spawn_gameobject([Hero(), GameCharacter(), Name("Astrid"), NonBinary()]) + world.spawn_gameobject([DemonKing(), GameCharacter(), Name("-Shi"), Retired()]) + world.spawn_gameobject( + [DemonKing(), GameCharacter(), Name("Palpatine"), NonBinary()] + ) + + return world + + +def test_where(sample_world): + query = Query(("Hero",), [where(has_components(Hero), "Hero")]) + result = set(query.execute(sample_world)) + expected = {(1,), (2,)} + assert result == expected + + query = Query(("NB",), [where(has_components(GameCharacter, NonBinary), "NB")]) + result = set(query.execute(sample_world)) + expected = {(2,), (4,)} + assert result == expected + + query = Query( + ("HERO", "VILLAIN"), + [ + where(has_components(GameCharacter, Hero), "HERO"), + where(has_components(DemonKing), "VILLAIN"), + where(has_components(Retired), "VILLAIN"), + ], + ) + result = set(query.execute(sample_world)) + expected = {(1, 3), (2, 3)} + assert result == expected + + +def test_where_not(sample_world): + query = Query( + ("Hero",), + [ + where(has_components(Hero), "Hero"), + where_not(has_components(NonBinary), "Hero"), + ], + ) + result = set(query.execute(sample_world)) + expected = {(1,)} + assert result == expected + + query = Query( + ("C",), + [ + where(has_components(GameCharacter, NonBinary), "C"), + where_not(has_components(Hero), "C"), + ], + ) + result = set(query.execute(sample_world)) + expected = {(4,)} + assert result == expected + + query = Query( + ("HERO", "VILLAIN"), + [ + where(has_components(GameCharacter, Hero), "HERO"), + where(has_components(DemonKing), "VILLAIN"), + where_not(has_components(Retired), "VILLAIN"), + ], + ) + result = set(query.execute(sample_world)) + expected = {(1, 4), (2, 4)} + assert result == expected + + +def test_where_either(sample_world): + query = Query( + ("X",), + [ + where(has_components(GameCharacter, Hero), "X"), + where_any(where(has_components(NonBinary), "X")), + ], + ) + result = set(query.execute(sample_world)) + expected = {(2,)} + assert result == expected + + query = Query( + ("X",), + [ + where(has_components(GameCharacter), "X"), + where_any( + where(has_components(NonBinary), "X"), + where(has_components(Retired), "X"), + ), + ], + ) + result = set(query.execute(sample_world)) + expected = {(2,), (3,), (4,)} + assert result == expected + + query = Query( + ("X", "Y"), + [ + where(has_components(GameCharacter), "X"), + where(has_components(GameCharacter), "Y"), + where_any( + where(has_components(NonBinary), "X"), + where(has_components(Retired), "Y"), + ), + ], + ) + result = set(query.execute(sample_world)) + expected = { + (1, 3), + (2, 1), + (2, 2), + (2, 3), + (2, 4), + (3, 3), + (4, 1), + (4, 2), + (4, 3), + (4, 4), + } + assert result == expected + + +def test_equal(sample_world): + query = Query( + ("X", "Y"), + [ + where(has_components(GameCharacter, Hero), "X"), + where(has_components(GameCharacter, Hero), "Y"), + ], + ) + result = set(query.execute(sample_world)) + expected = {(1, 1), (1, 2), (2, 1), (2, 2)} + assert result == expected + + query = Query( + ("X", "Y"), + [ + where(has_components(GameCharacter, Hero), "X"), + where(has_components(GameCharacter, Hero), "Y"), + eq_(("X", "Y")), + ], + ) + result = set(query.execute(sample_world)) + expected = {(1, 1), (2, 2)} + assert result == expected + + +def test_not_equal(sample_world): + query = Query( + ("X", "Y"), + [ + where(has_components(GameCharacter, Hero), "X"), + where(has_components(GameCharacter, Hero), "Y"), + ], + ) + result = set(query.execute(sample_world)) + expected = {(1, 1), (1, 2), (2, 1), (2, 2)} + assert result == expected + + query = Query( + ("X", "Y"), + [ + where(has_components(GameCharacter, Hero), "X"), + where(has_components(GameCharacter, Hero), "Y"), + ne_(("X", "Y")), + ], + ) + result = set(query.execute(sample_world)) + expected = {(1, 2), (2, 1)} + assert result == expected + + +def test_query_bindings(sample_world): + query = Query(("Hero",), [where(has_components(Hero), "Hero")]) + result = set(query.execute(sample_world, Hero=2)) + expected = {(2,)} + assert result == expected + + query = Query(("NB",), [where(has_components(GameCharacter, NonBinary), "NB")]) + result = set(query.execute(sample_world, NB=4)) + expected = {(4,)} + assert result == expected + + query = Query( + ("HERO", "VILLAIN"), + [ + where(has_components(GameCharacter, Hero), "HERO"), + where(has_components(DemonKing), "VILLAIN"), + where(has_components(Retired), "VILLAIN"), + ], + ) + result = set(query.execute(sample_world, HERO=2)) + expected = {(2, 3)} + assert result == expected diff --git a/tests/test_relationship.py b/tests/test_relationship.py index cba6674..3255876 100755 --- a/tests/test_relationship.py +++ b/tests/test_relationship.py @@ -1,113 +1,28 @@ -import pytest +from neighborly import GameObject, World +from neighborly.core.character import GameCharacter +from neighborly.core.relationship import Relationships -from neighborly.core.relationship import ( - Relationship, - RelationshipGraph, - RelationshipModifier, - RelationshipTag, -) +def create_character(world: World) -> GameObject: + return world.spawn_gameobject([GameCharacter(), Relationships()]) -class FriendModifier(RelationshipModifier): - """Indicated a friendship""" - def __init__(self) -> None: - super().__init__(name="Friend", salience_boost=10, friendship_increment=1) - - -class EnemyModifier(RelationshipModifier): - """Indicated an enmity""" - - def __init__(self) -> None: - super().__init__(name="Enemy", salience_boost=10, friendship_increment=-1) - - -class AcquaintanceModifier(RelationshipModifier): - """Indicated an enmity""" - - def __init__(self) -> None: - super().__init__( - name="Acquaintance", - salience_boost=0.0, - ) - - -@pytest.fixture -def create_tags(): - RelationshipModifier.register_tag(FriendModifier()) - RelationshipModifier.register_tag(EnemyModifier()) - RelationshipModifier.register_tag(AcquaintanceModifier()) - - -@pytest.mark.usefixtures("create_tags") -def test_load_relationship_tags(): - assert RelationshipModifier.get_tag("Acquaintance") is not None - assert RelationshipModifier.get_tag("Friend") is not None - assert RelationshipModifier.get_tag("Enemy") is not None - - -@pytest.mark.usefixtures("create_tags") -def test_add_remove_modifiers(): - relationship = Relationship(1, 2) - - compatibility_tag = RelationshipModifier("Compatibility", friendship_increment=1) - - relationship.add_modifier(compatibility_tag) - - assert relationship.has_modifier("Compatibility") - - relationship.update() - - assert relationship.has_modifier("Compatibility") - - -def test_relationship_network(): +def test_relationships(): # Create characters as ints - homer = 0 - lisa = 1 - krusty = 2 - bart = 3 - maggie = 4 - - # Construct the social graph - social_graph = RelationshipGraph() - - homer_to_lisa_rel = Relationship(homer, lisa) - lisa_to_homer_rel = Relationship(lisa, homer) - - social_graph.add_connection(homer, lisa, homer_to_lisa_rel) - social_graph.add_connection(lisa, homer, lisa_to_homer_rel) - - assert social_graph.get_connection(homer, lisa) == homer_to_lisa_rel - - social_graph.add_connection(homer, bart, Relationship(homer, bart)) - social_graph.add_connection(bart, krusty, Relationship(bart, krusty)) - social_graph.add_connection(krusty, bart, Relationship(krusty, bart)) - - social_graph.add_connection(lisa, bart, Relationship(lisa, bart)) - social_graph.get_connection(lisa, bart).add_tags( - RelationshipTag.Brother | RelationshipTag.Sibling - ) - - social_graph.add_connection(lisa, maggie, Relationship(lisa, maggie)) - social_graph.get_connection(lisa, maggie).add_tags( - RelationshipTag.Sister | RelationshipTag.Sibling - ) + world = World() - assert social_graph.has_connection(homer, krusty) is False - assert social_graph.has_connection(lisa, homer) is True - assert social_graph.has_connection(bart, lisa) is False + homer = create_character(world) + lisa = create_character(world) + bart = create_character(world) + maggie = create_character(world) - social_graph.get_connection(homer, lisa).add_tags(RelationshipTag.Daughter) - social_graph.get_connection(homer, lisa).has_tags(RelationshipTag.Daughter) + lisa.get_component(Relationships).get(bart.id).add_tags("Sibling") + assert lisa.get_component(Relationships).get(bart.id).has_tag("Sibling") - assert ( - len(social_graph.get_all_relationships_with_tags(lisa, RelationshipTag.Sibling)) - == 2 - ) + lisa.get_component(Relationships).get(maggie.id).add_tags("Sibling") + assert lisa.get_component(Relationships).get(maggie.id).has_tag("Sibling") - social_graph.remove_node(homer) + homer.get_component(Relationships).get(lisa.id).add_tags("Child") + assert homer.get_component(Relationships).get(lisa.id).has_tag("Child") - assert social_graph.has_connection(lisa, homer) is False - assert social_graph.has_connection(homer, lisa) is False - assert social_graph.has_connection(homer, bart) is False + assert len(lisa.get_component(Relationships).get_all_with_tags("Sibling")) == 2 diff --git a/tests/test_routine.py b/tests/test_routine.py index 0f50943..af4e7df 100644 --- a/tests/test_routine.py +++ b/tests/test_routine.py @@ -1,5 +1,6 @@ import pytest +from neighborly.core.ecs import World from neighborly.core.routine import ( DailyRoutine, Routine, @@ -8,31 +9,32 @@ parse_schedule_str, time_str_to_int, ) +from neighborly.core.time import Weekday def test_construct_routine_entry(): """Test that routines entries are created properly""" - entry_0 = RoutineEntry(8, 12, "home", "leisure", RoutinePriority.HIGH) + entry_0 = RoutineEntry(8, 12, "home", RoutinePriority.HIGH) assert entry_0.start == 8 assert entry_0.end == 12 assert entry_0.location == "home" assert entry_0.priority == RoutinePriority.HIGH - entry_1 = RoutineEntry(12, 15, "park", "leisure") + entry_1 = RoutineEntry(12, 15, "park") assert entry_1.priority == RoutinePriority.LOW with pytest.raises(ValueError): # start greater than the end - RoutineEntry(6, 3, "home", "leisure") + RoutineEntry(6, 3, "home") # start less than zero - RoutineEntry(-1, 12, "work", "work") + RoutineEntry(-1, 12, "work") # end greater than 23 - RoutineEntry(10, 24, "work", "work") + RoutineEntry(10, 24, "work") # start and end times are the same - RoutineEntry(10, 10, "home", "painting") + RoutineEntry(10, 10, "home") def test_daily_routine(): @@ -41,28 +43,38 @@ def test_daily_routine(): daily_routine = DailyRoutine() # Check that there are no entries - assert daily_routine.get_entries(8) == [] + assert daily_routine.get(8) == [] # Add a low priority entry - go_to_park = RoutineEntry(12, 15, "park", "leisure") - daily_routine.add_entries(go_to_park) - assert daily_routine.get_entries(12) == [go_to_park] + go_to_park = RoutineEntry(19, 15, "park") + daily_routine.add("go_to_park", go_to_park) + assert daily_routine.get(12) == [go_to_park] # add two mid-level priority entries - buy_milk = RoutineEntry(12, 13, "store", "errands", RoutinePriority.MED) - walk_dog = RoutineEntry(11, 13, "park", "walk dog", RoutinePriority.MED) - daily_routine.add_entries(walk_dog, buy_milk) - assert daily_routine.get_entries(12) == [walk_dog, buy_milk] + buy_milk = RoutineEntry(12, 13, "store", RoutinePriority.MED) + walk_dog = RoutineEntry(11, 13, "park", RoutinePriority.MED) + daily_routine.add("walk_dog", walk_dog) + daily_routine.add("buy_milk", buy_milk) + assert daily_routine.get(12) == [walk_dog, buy_milk] # add one high-level entry - mail_taxes = RoutineEntry(11, 16, "post office", "errands", RoutinePriority.HIGH) - daily_routine.add_entries(mail_taxes) - assert daily_routine.get_entries(12) == [mail_taxes] + mail_taxes = RoutineEntry(11, 16, "post office", RoutinePriority.HIGH) + daily_routine.add("mail_taxes", mail_taxes) + assert daily_routine.get(12) == [mail_taxes] # remove the high-level entry - daily_routine.remove_entries(mail_taxes) - daily_routine.remove_entries(walk_dog) - assert daily_routine.get_entries(12) == [buy_milk] + daily_routine.remove("mail_taxes") + daily_routine.remove("walk_dog") + assert daily_routine.get(12) == [buy_milk] + + +def test_create_routine(): + world = World() + gameobject = world.spawn_gameobject() + gameobject.add_component(Routine.create(world, presets="default")) + routine = gameobject.get_component(Routine) + + assert routine.get_entries(Weekday.Monday, 0)[0].location == "home" def test_routine(): @@ -70,22 +82,22 @@ def test_routine(): routine = Routine() - buy_milk = RoutineEntry(12, 13, "store", "errands", RoutinePriority.MED) - walk_dog = RoutineEntry(11, 13, "park", "walk dog", RoutinePriority.MED) - mail_taxes = RoutineEntry(11, 16, "post office", "errands", RoutinePriority.HIGH) + buy_milk = RoutineEntry(12, 13, "store", RoutinePriority.MED) + walk_dog = RoutineEntry(11, 13, "park", RoutinePriority.MED) + mail_taxes = RoutineEntry(11, 16, "post office", RoutinePriority.HIGH) - routine.add_entries(["monday", "tuesday"], walk_dog) - routine.add_entries(["monday"], mail_taxes) - routine.add_entries(["tuesday"], buy_milk) + routine.add_entries("walk_dog", [Weekday.Monday, Weekday.Tuesday], walk_dog) + routine.add_entries("mail_taxes", [Weekday.Monday], mail_taxes) + routine.add_entries("buy_milk", [Weekday.Tuesday], buy_milk) - assert routine.get_entries("monday", 12) == [mail_taxes] - assert routine.get_entries("tuesday", 12) == [walk_dog, buy_milk] + assert routine.get_entries(Weekday.Monday, 12) == [mail_taxes] + assert routine.get_entries(Weekday.Tuesday, 12) == [walk_dog, buy_milk] - routine.remove_entries(["monday"], mail_taxes) - routine.remove_entries(["tuesday"], buy_milk) + routine.remove_entries([Weekday.Monday], "mail_taxes") + routine.remove_entries([Weekday.Tuesday], "buy_milk") - assert routine.get_entries("monday", 12) == [walk_dog] - assert routine.get_entries("tuesday", 12) == [walk_dog] + assert routine.get_entries(Weekday.Monday, 12) == [walk_dog] + assert routine.get_entries(Weekday.Tuesday, 12) == [walk_dog] def test_time_str_to_int(): diff --git a/tests/test_system.py b/tests/test_system.py new file mode 100644 index 0000000..cbfbeec --- /dev/null +++ b/tests/test_system.py @@ -0,0 +1,57 @@ +from typing import List + +import pytest + +from neighborly import SimDateTime, Simulation, World +from neighborly.builtin.systems import LinearTimeSystem +from neighborly.core.system import System +from neighborly.core.time import TimeDelta + + +class TestSystem(System): + def __init__( + self, + interval: TimeDelta, + elapsed_times: List[int], + run_times: List[SimDateTime], + ) -> None: + super().__init__(interval) + self.elapsed_times: List[int] = elapsed_times + self.run_times: List[SimDateTime] = run_times + + def run(self, *args, **kwargs) -> None: + self.elapsed_times.append(self.elapsed_time.total_hours) + self.run_times.append(self.world.get_resource(SimDateTime).copy()) + + +@pytest.fixture() +def test_world() -> World: + world = World() + world.add_resource(SimDateTime()) + world.add_system(LinearTimeSystem(increment=TimeDelta(hours=4))) + return world + + +def test_elapsed_time(test_world): + elapsed_times = [] + test_world.add_system(TestSystem(TimeDelta(), elapsed_times, [])) + test_world.step() + test_world.step() + test_world.step() + assert elapsed_times == [0, 4, 4] + + +def test_interval_run(test_world): + run_times = [] + test_world.add_system(TestSystem(TimeDelta(hours=6), [], run_times)) + test_world.step() + test_world.step() + test_world.step() + test_world.step() + test_world.step() + test_world.step() + assert run_times == [ + SimDateTime(hour=8), + SimDateTime(hour=16), + SimDateTime(day=1, hour=0), + ] diff --git a/tests/test_time.py b/tests/test_time.py index 3d70d35..dc4856d 100644 --- a/tests/test_time.py +++ b/tests/test_time.py @@ -1,52 +1,165 @@ import pytest -from neighborly.core.time import SimDateTime, get_time_of_day +from neighborly.core.time import SimDateTime, TimeDelta def test_get_time_of_day(): - with pytest.raises(IndexError): - get_time_of_day(-1) - get_time_of_day(24) + assert SimDateTime(hour=5).get_time_of_day() == "night" + assert SimDateTime(hour=6).get_time_of_day() == "morning" + assert SimDateTime(hour=7).get_time_of_day() == "day" + assert SimDateTime(hour=17).get_time_of_day() == "day" + assert SimDateTime(hour=18).get_time_of_day() == "evening" + assert SimDateTime(hour=19).get_time_of_day() == "night" - assert get_time_of_day(5) == 'night' - assert get_time_of_day(6) == 'morning' - assert get_time_of_day(7) == 'day' - assert get_time_of_day(17) == 'day' - assert get_time_of_day(18) == 'evening' - assert get_time_of_day(19) == 'night' +def test_copy(): + original_date = SimDateTime() + copy_date = original_date.copy() -def test_increment_time(): - time = SimDateTime() - time.increment(hours=26) - assert time.hour == 2 - assert time.day == 1 - time.increment(hours=24, days=4) - assert time.hour == 2 - assert time.day == 6 - time.increment(days=28) - assert time.hour == 2 - assert time.day == 6 - assert time.month == 1 - time.increment(months=12) - assert time.month == 1 - assert time.year == 1 - - -def test_time_serialize(): - time = SimDateTime.from_str('2-10-3-1') - assert time.day == 3 - assert time.hour == 1 - assert time.weekday == 3 - assert time.month == 10 - assert time.year == 2 - - -def test_time_init(): + assert id(original_date) != id(copy_date) + + +def test__sub__(): + date_2 = SimDateTime(year=1, month=3, day=23) + date_1 = SimDateTime(year=1, month=2, day=23) + + diff = date_2 - date_1 + + assert diff.years == 0 + assert diff.months == 1 + assert diff.hours == 0 + assert diff.total_days == 28 + assert diff.total_hours == 28 * 24 + + +def test__add__(): + date = SimDateTime() + new_date = date + TimeDelta(months=5, days=27) + assert new_date.month == 5 + assert new_date.day == 27 + assert date.month == 0 + assert date.day == 0 + + +def test__iadd__(): + date = SimDateTime() + date += TimeDelta(months=5, days=27) + assert date.month == 5 + assert date.day == 27 + + +def test__le__(): + assert (SimDateTime() <= SimDateTime()) is True + assert (SimDateTime() <= SimDateTime(year=2000)) is True + assert (SimDateTime(year=3000) <= SimDateTime()) is False + + +def test__lt__(): + assert (SimDateTime() < SimDateTime()) is False + assert (SimDateTime() < SimDateTime(year=2000)) is True + assert (SimDateTime(year=3000) < SimDateTime()) is False + + +def test__ge__(): + assert (SimDateTime() >= SimDateTime()) is True + assert (SimDateTime() >= SimDateTime(year=2000)) is False + assert (SimDateTime(year=3000) >= SimDateTime()) is True + + +def test__gt__(): + assert (SimDateTime() > SimDateTime()) is False + assert (SimDateTime() > SimDateTime(year=2000)) is False + assert (SimDateTime(year=3000) > SimDateTime()) is True + + +def test__eq__(): + assert (SimDateTime() == SimDateTime()) is True + assert (SimDateTime() == SimDateTime(year=2000)) is False + assert (SimDateTime(year=3000) == SimDateTime()) is False + assert (SimDateTime(year=3000) == SimDateTime(year=3000)) is True + + +def test_to_date_str(): + date = SimDateTime(2022, 6, 27) + assert date.to_date_str() == "Sat, 27/06/2022 @ 00:00" + + date = SimDateTime(2022, 9, 3) + assert date.to_date_str() == "Wed, 03/09/2022 @ 00:00" + + +def test_to_iso_str(): + date = SimDateTime(2022, 6, 27) + assert date.to_iso_str() == "2022-06-27T00:00.000z" + + date = SimDateTime(2022, 9, 3) + assert date.to_iso_str() == "2022-09-03T00:00.000z" + + +def test_to_hours(): + date = SimDateTime(2022, 6, 27) + assert date.to_hours() == 16310088 + + date = SimDateTime(2022, 9, 3) + assert date.to_hours() == 16311528 + + +def test_to_ordinal(): + date = SimDateTime(2022, 6, 27) + assert date.to_ordinal() == 679587 + + date = SimDateTime(2022, 9, 3) + assert date.to_ordinal() == 679647 + + +def test_from_ordinal(): + date = SimDateTime.from_ordinal(679710) + assert date.day == 10 + assert date.hour == 0 + assert date.weekday == 3 + assert date.month == 11 + assert date.year == 2022 + + +def test_from_iso_str(): + date = SimDateTime.from_iso_str("2022-11-10T00:36:19.362Z") + assert date.day == 10 + assert date.hour == 0 + assert date.weekday == 3 + assert date.month == 11 + assert date.year == 2022 + + +def test_from_str(): + date = SimDateTime.from_str("2-10-3-1") + assert date.day == 3 + assert date.hour == 1 + assert date.weekday == 3 + assert date.month == 10 + assert date.year == 2 + + +def test_increment(): + date = SimDateTime() + date.increment(hours=26) + assert date.hour == 2 + assert date.day == 1 + date.increment(hours=24, days=4) + assert date.hour == 2 + assert date.day == 6 + date.increment(days=28) + assert date.hour == 2 + assert date.day == 6 + assert date.month == 1 + date.increment(months=12) + assert date.month == 1 + assert date.year == 1 + + +def test__init__(): time = SimDateTime() assert time.day == 0 assert time.hour == 0 assert time.weekday == 0 assert time.month == 0 assert time.year == 0 - assert time.weekday_str == 'Sunday' + assert time.weekday_str == "Sunday" diff --git a/tests/test_town.py b/tests/test_town.py index 055203f..017baa7 100644 --- a/tests/test_town.py +++ b/tests/test_town.py @@ -1,19 +1,108 @@ -from neighborly.core.town import LandGrid +import pytest +from neighborly.core.town import LandGrid, Town -def test_town_layout(): - layout = LandGrid((5, 4)) - assert layout.grid.shape == (5, 4) - assert layout.has_vacancy() is True - space = layout.reserve_space(0) - assert space == (0, 0) +def test_town_increment_population(): + town = Town("Test Town") + town.increment_population() + town.increment_population() + assert town.population == 2 - for i in range(19): - layout.reserve_space(0) - assert layout.has_vacancy() is False +def test_town_decrement_population(): + town = Town("Test Town", 2) + town.decrement_population() + assert town.population == 1 - layout.free_space((3, 3)) - assert layout.has_vacancy() is True +def test_town_to_dict(): + town = Town("Test Town", 3) + town_dict = town.to_dict() + assert town_dict["name"] == "Test Town" + assert town_dict["population"] == 3 + + +def test_land_grid_shape(): + grid = LandGrid((5, 4)) + assert grid.shape == (5, 4) + + with pytest.raises(AssertionError): + LandGrid((-1, 8)) + + +def test_land_grid_in_bounds(): + grid = LandGrid((5, 4)) + assert grid.in_bounds((1, 3)) is True + assert grid.in_bounds((0, 5)) is False + assert grid.in_bounds((5, 4)) is False + assert grid.in_bounds((-1, -1)) is False + + +def test_land_grid_get_neighbors(): + grid = LandGrid((5, 4)) + + # Without diagonals + assert grid.get_neighbors((0, 0)) == [(1, 0), (0, 1)] + assert grid.get_neighbors((4, 0)) == [(4, 1), (3, 0)] + assert grid.get_neighbors((4, 3)) == [(4, 2), (3, 3)] + assert grid.get_neighbors((0, 3)) == [(0, 2), (1, 3)] + + # With diagonals + assert grid.get_neighbors((0, 0), True) == [(1, 0), (1, 1), (0, 1)] + assert grid.get_neighbors((4, 0), True) == [(4, 1), (3, 1), (3, 0)] + assert grid.get_neighbors((4, 3), True) == [(3, 2), (4, 2), (3, 3)] + assert grid.get_neighbors((0, 3), True) == [(0, 2), (1, 2), (1, 3)] + + +def test_land_grid_get_vacancies(): + land_grid = LandGrid((5, 3)) + assert len(land_grid.get_vacancies()) == 15 + + land_grid = LandGrid((1, 1)) + assert land_grid.get_vacancies() == [(0, 0)] + + +def test_land_grid_has_vacancy(): + land_grid = LandGrid((5, 3)) + assert land_grid.has_vacancy() + + +def test_land_grid_len(): + land_grid = LandGrid((5, 3)) + assert len(land_grid) == 15 + + land_grid = LandGrid((1, 1)) + assert len(land_grid) == 1 + + +def test_land_grid_get_set_item(): + land_grid = LandGrid((5, 3)) + + assert land_grid.has_vacancy() + + assert len(land_grid.get_vacancies()) == 15 + + land_grid[2, 2] = 8080 + + assert len(land_grid.get_vacancies()) == 14 + + for pos in sorted(list(land_grid.get_vacancies())): + land_grid[pos] = 8080 + + assert land_grid.has_vacancy() is False + + assert len(land_grid.get_vacancies()) == 0 + + land_grid[2, 2] = None + + assert land_grid.has_vacancy() is True + + assert len(land_grid.get_vacancies()) == 1 + + +def test_land_grid_setitem_raises_runtime_error(): + land_grid = LandGrid((5, 3)) + land_grid[2, 2] = 8080 + with pytest.raises(RuntimeError): + land_grid[2, 2] = 700