├── src └── neighborly │ ├── py.typed │ ├── defs │ ├── __init__.py │ └── definition_compiler.py │ ├── events │ └── __init__.py │ ├── components │ ├── __init__.py │ ├── skills.py │ ├── character.py │ ├── residence.py │ ├── location.py │ └── spawn_table.py │ ├── effects │ ├── __init__.py │ └── base_types.py │ ├── helpers │ ├── __init__.py │ ├── residence.py │ ├── character.py │ ├── content_selection.py │ ├── skills.py │ ├── stats.py │ ├── location.py │ ├── business.py │ ├── traits.py │ └── settlement.py │ ├── plugins │ ├── __init__.py │ ├── default_traits.py │ ├── default_settlement_names.py │ └── default_character_names.py │ ├── preconditions │ ├── __init__.py │ ├── base_types.py │ └── defaults.py │ ├── __version__.py │ ├── __init__.py │ ├── config.py │ ├── tracery.py │ ├── datetime.py │ ├── data_collection.py │ └── loaders.py ├── tests ├── data │ ├── traits.json │ ├── sample.tracery.json │ ├── skills.json │ ├── characters.json │ ├── settlements.json │ ├── residences.json │ ├── job_roles.json │ ├── businesses.json │ └── districts.json ├── test_tag_selection.py ├── test_character.py ├── test_business.py ├── test_residence.py ├── test_location_preferences.py ├── test_stats.py ├── test_simulation.py ├── test_datetime.py ├── test_relationship.py ├── test_loaders.py ├── test_traits.py └── test_settlement.py ├── docs ├── source │ ├── api │ │ ├── modules.rst │ │ ├── neighborly.events.rst │ │ ├── neighborly.defs.rst │ │ ├── neighborly.effects.rst │ │ ├── neighborly.preconditions.rst │ │ ├── neighborly.plugins.rst │ │ ├── neighborly.helpers.rst │ │ ├── neighborly.components.rst │ │ └── neighborly.rst │ ├── _static │ │ ├── NeighborlyLogo.ico │ │ └── NeighborlyLogo.png │ ├── requirements.txt │ ├── plugins.rst │ ├── design-tips.rst │ ├── conf.py │ ├── residences.rst │ ├── location-preferences.rst │ ├── index.rst │ ├── settlements.rst │ ├── ecs.rst │ ├── relationships.rst │ ├── traits.rst │ └── characters.rst ├── Makefile ├── make.bat └── README.md ├── .editorconfig ├── MANIFEST.in ├── CITATION.bib ├── .vscode ├── settings.json └── launch.json ├── notebooks └── sample_plugin.py ├── LICENSE ├── .readthedocs.yaml ├── CONTRIBUTING.md ├── samples └── sample.py ├── pyproject.toml ├── CODE_OF_CONDUCT.md └── .gitignore /src/neighborly/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/neighborly/defs/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/neighborly/events/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/neighborly/components/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/neighborly/effects/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/neighborly/helpers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/neighborly/plugins/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/neighborly/preconditions/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/data/traits.json: -------------------------------------------------------------------------------- 1 | { 2 | "flirtatious": { 3 | "display_name": "Flirtatious" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /docs/source/api/modules.rst: -------------------------------------------------------------------------------- 1 | neighborly 2 | ========== 3 | 4 | .. toctree:: 5 | :maxdepth: 4 6 | 7 | neighborly 8 | -------------------------------------------------------------------------------- /docs/source/_static/NeighborlyLogo.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShiJbey/neighborly/HEAD/docs/source/_static/NeighborlyLogo.ico -------------------------------------------------------------------------------- /docs/source/_static/NeighborlyLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShiJbey/neighborly/HEAD/docs/source/_static/NeighborlyLogo.png -------------------------------------------------------------------------------- /docs/source/requirements.txt: -------------------------------------------------------------------------------- 1 | esper==2.1 2 | ordered-set==4.0.2 3 | tracery3==1.0.1 4 | polars==0.19.11 5 | tabulate==0.9.0 6 | PyYAML==6.0.1 7 | Sphinx==4.5.0 8 | sphinx-rtd-theme==1.0.0 9 | -------------------------------------------------------------------------------- /tests/data/sample.tracery.json: -------------------------------------------------------------------------------- 1 | { 2 | "simpsons_name": [ 3 | "Homer", 4 | "Marge", 5 | "Maggie", 6 | "Lisa", 7 | "Bart" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /src/neighborly/__version__.py: -------------------------------------------------------------------------------- 1 | """Neighborly version information. 2 | 3 | """ 4 | 5 | MAJOR_VERSION = 2 6 | MINOR_VERSION = 5 7 | PATCH_VERSION = 0 8 | VERSION = f"{MAJOR_VERSION}.{MINOR_VERSION}.{PATCH_VERSION}" 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # https://EditorConfig.org 2 | 3 | root = true 4 | 5 | # Unix-style newlines with a newline ending every file 6 | [*] 7 | end_of_line = lf 8 | insert_final_newline = true 9 | 10 | # 4 space indentation 11 | [*.py] 12 | charset = utf-8 13 | indent_style = space 14 | indent_size = 4 15 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include CHANGELOG.md 3 | include CODE_OF_CONDUCT.md 4 | include README.md 5 | include MANIFEST.in 6 | include src/neighborly/py.typed 7 | graft src/neighborly/plugins/data/ 8 | global-exclude *.py[co] 9 | global-exclude __pycache__ 10 | global-exclude *~ 11 | global-exclude *.ipynb_checkpoints/* 12 | -------------------------------------------------------------------------------- /CITATION.bib: -------------------------------------------------------------------------------- 1 | @inproceedings{johnsonbey2022neighborly, 2 | title = {Neighborly: A Sandbox for Simulation-based Emergent Narrative}, 3 | author = {Johnson-Bey, Shi and Nelson, Mark J and Mateas, Michael}, 4 | booktitle = {2022 IEEE Conference on Games (CoG)}, 5 | pages = {425--432}, 6 | year = {2022}, 7 | organization = {IEEE} 8 | } 9 | -------------------------------------------------------------------------------- /tests/data/skills.json: -------------------------------------------------------------------------------- 1 | { 2 | "blacksmithing": { 3 | "display_name": "Blacksmithing", 4 | "description": "A character's skill at forging metal objects." 5 | }, 6 | "farming": { 7 | "display_name": "Farming", 8 | "description": "A measure of a character's ability to plat and harvest crops." 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /tests/data/characters.json: -------------------------------------------------------------------------------- 1 | { 2 | "person": { 3 | "species": "human", 4 | "sex": "Male" 5 | }, 6 | "farmer": { 7 | "species": "human", 8 | "sex": "Male" 9 | }, 10 | "merchant": { 11 | "species": "human", 12 | "sex": "Male" 13 | }, 14 | "nobility": { 15 | "species": "human", 16 | "sex": "Male" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/neighborly/plugins/default_traits.py: -------------------------------------------------------------------------------- 1 | """Loads a default set of traits. 2 | 3 | """ 4 | 5 | import pathlib 6 | 7 | from neighborly.loaders import load_traits 8 | from neighborly.simulation import Simulation 9 | 10 | _DATA_DIR = pathlib.Path(__file__).parent / "data" 11 | 12 | 13 | def load_plugin(sim: Simulation) -> None: 14 | """Load the plugin's content.""" 15 | 16 | load_traits(sim, _DATA_DIR / "traits.json") 17 | -------------------------------------------------------------------------------- /src/neighborly/plugins/default_settlement_names.py: -------------------------------------------------------------------------------- 1 | """Loads a default set of names for settlements. 2 | 3 | """ 4 | 5 | import pathlib 6 | 7 | from neighborly.loaders import load_tracery 8 | from neighborly.simulation import Simulation 9 | 10 | _DATA_DIR = pathlib.Path(__file__).parent / "data" 11 | 12 | 13 | def load_plugin(sim: Simulation) -> None: 14 | """Load the plugin's content.""" 15 | 16 | load_tracery(sim, _DATA_DIR / "settlement_names.tracery.json") 17 | -------------------------------------------------------------------------------- /docs/source/api/neighborly.events.rst: -------------------------------------------------------------------------------- 1 | neighborly.events package 2 | ========================= 3 | 4 | Submodules 5 | ---------- 6 | 7 | neighborly.events.defaults module 8 | --------------------------------- 9 | 10 | .. automodule:: neighborly.events.defaults 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | Module contents 16 | --------------- 17 | 18 | .. automodule:: neighborly.events 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | -------------------------------------------------------------------------------- /src/neighborly/plugins/default_character_names.py: -------------------------------------------------------------------------------- 1 | """Loads a default set of first names, and last names for characters. 2 | 3 | """ 4 | 5 | import pathlib 6 | 7 | from neighborly.loaders import load_tracery 8 | from neighborly.simulation import Simulation 9 | 10 | _DATA_DIR = pathlib.Path(__file__).parent / "data" 11 | 12 | 13 | def load_plugin(sim: Simulation) -> None: 14 | """Load the plugin's content.""" 15 | 16 | load_tracery(sim, _DATA_DIR / "character_names.tracery.json") 17 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[python]": { 3 | "editor.defaultFormatter": "ms-python.black-formatter" 4 | }, 5 | "autoDocstring.docstringFormat": "numpy", 6 | "python.testing.pytestArgs": ["tests"], 7 | "python.testing.unittestEnabled": false, 8 | "python.testing.pytestEnabled": true, 9 | "markdownlint.config": { 10 | "MD024": false, 11 | "MD036": false, 12 | "MD033": false 13 | }, 14 | "esbonio.sphinx.confDir": "", 15 | "python.REPL.enableREPLSmartSend": false 16 | } 17 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Sample", 9 | "type": "python", 10 | "request": "launch", 11 | "program": "${workspaceFolder}/samples/sample.py", 12 | "console": "integratedTerminal", 13 | "justMyCode": true 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /tests/data/settlements.json: -------------------------------------------------------------------------------- 1 | { 2 | "basic_settlement": { 3 | "districts": [ 4 | { 5 | "with_tags": [ 6 | "urban", 7 | "suburban" 8 | ] 9 | }, 10 | { 11 | "with_id": "market_district" 12 | }, 13 | { 14 | "with_id": "entertainment_district" 15 | }, 16 | { 17 | "with_id": "upper_class_residences" 18 | } 19 | ] 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/neighborly/__init__.py: -------------------------------------------------------------------------------- 1 | """Neighborly Character-Driven Social Simulation Framework. 2 | 3 | Neighborly is an extensible, data-driven, agent-based modeling framework 4 | designed to simulate towns of characters for games. It is intended to be a 5 | tool for exploring simulationist approaches to character-driven emergent 6 | narratives. Neighborly's simulation architecture is inspired by roguelikes 7 | such as Caves of Qud and Dwarf Fortress. 8 | 9 | """ 10 | 11 | from neighborly.__version__ import VERSION 12 | 13 | __all__ = [ 14 | "VERSION", 15 | ] 16 | -------------------------------------------------------------------------------- /docs/source/api/neighborly.defs.rst: -------------------------------------------------------------------------------- 1 | neighborly.defs package 2 | ======================= 3 | 4 | Submodules 5 | ---------- 6 | 7 | neighborly.defs.base\_types module 8 | ---------------------------------- 9 | 10 | .. automodule:: neighborly.defs.base_types 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | neighborly.defs.defaults module 16 | ------------------------------- 17 | 18 | .. automodule:: neighborly.defs.defaults 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | Module contents 24 | --------------- 25 | 26 | .. automodule:: neighborly.defs 27 | :members: 28 | :undoc-members: 29 | :show-inheritance: 30 | -------------------------------------------------------------------------------- /docs/source/api/neighborly.effects.rst: -------------------------------------------------------------------------------- 1 | neighborly.effects package 2 | ========================== 3 | 4 | Submodules 5 | ---------- 6 | 7 | neighborly.effects.base\_types module 8 | ------------------------------------- 9 | 10 | .. automodule:: neighborly.effects.base_types 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | neighborly.effects.effects module 16 | --------------------------------- 17 | 18 | .. automodule:: neighborly.effects.effects 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | Module contents 24 | --------------- 25 | 26 | .. automodule:: neighborly.effects 27 | :members: 28 | :undoc-members: 29 | :show-inheritance: 30 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/source/api/neighborly.preconditions.rst: -------------------------------------------------------------------------------- 1 | neighborly.preconditions package 2 | ================================ 3 | 4 | Submodules 5 | ---------- 6 | 7 | neighborly.preconditions.base\_types module 8 | ------------------------------------------- 9 | 10 | .. automodule:: neighborly.preconditions.base_types 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | neighborly.preconditions.defaults module 16 | ---------------------------------------- 17 | 18 | .. automodule:: neighborly.preconditions.defaults 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | Module contents 24 | --------------- 25 | 26 | .. automodule:: neighborly.preconditions 27 | :members: 28 | :undoc-members: 29 | :show-inheritance: 30 | -------------------------------------------------------------------------------- /tests/test_tag_selection.py: -------------------------------------------------------------------------------- 1 | """Test the dynamic tag selection. 2 | 3 | """ 4 | 5 | from neighborly.helpers.content_selection import get_with_tags 6 | 7 | 8 | def test_get_with_required_tags() -> None: 9 | """Test required tag selection.""" 10 | 11 | results = get_with_tags( 12 | [("cat", ("animal",)), ("chicken", ("food", "animal")), ("pizza", ("food",))], 13 | ["food"], 14 | ) 15 | 16 | assert "chicken" in results 17 | assert "pizza" in results 18 | 19 | 20 | def test_get_with_optional_tags() -> None: 21 | """Test optional tag selection.""" 22 | 23 | results = get_with_tags( 24 | [("cat", ("animal",)), ("chicken", ("food", "animal")), ("pizza", ("food",))], 25 | ["food", "~animal"], 26 | ) 27 | 28 | assert "chicken" in results 29 | assert "pizza" not in results 30 | -------------------------------------------------------------------------------- /tests/test_character.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | 3 | from neighborly.helpers.character import create_character 4 | from neighborly.loaders import load_characters, load_skills 5 | from neighborly.plugins import default_traits 6 | from neighborly.simulation import Simulation 7 | from neighborly.systems import InitializeSettlementSystem 8 | 9 | _TEST_DATA_DIR = pathlib.Path(__file__).parent / "data" 10 | 11 | 12 | def test_create_character() -> None: 13 | sim = Simulation() 14 | 15 | load_characters(sim, _TEST_DATA_DIR / "characters.json") 16 | load_skills(sim, _TEST_DATA_DIR / "skills.json") 17 | 18 | default_traits.load_plugin(sim) 19 | 20 | sim.world.system_manager.get_system(InitializeSettlementSystem).set_active(False) 21 | 22 | sim.initialize() 23 | 24 | character = create_character(sim.world, "farmer") 25 | 26 | assert character is not None 27 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | %SPHINXBUILD% >NUL 2>NUL 14 | if errorlevel 9009 ( 15 | echo. 16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 17 | echo.installed, then set the SPHINXBUILD environment variable to point 18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 19 | echo.may add the Sphinx directory to PATH. 20 | echo. 21 | echo.If you don't have Sphinx installed, grab it from 22 | echo.https://www.sphinx-doc.org/ 23 | exit /b 1 24 | ) 25 | 26 | if "%1" == "" goto help 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /tests/data/residences.json: -------------------------------------------------------------------------------- 1 | { 2 | "house": { 3 | "display_name": "House", 4 | "residential_units": 1, 5 | "spawn_frequency": 3 6 | }, 7 | "mansion": { 8 | "display_name": "Mansion", 9 | "residential_units": 1, 10 | "spawn_frequency": 1 11 | }, 12 | "small_apartment_building": { 13 | "display_name": "Small Apartment Building", 14 | "residential_units": 4, 15 | "spawn_frequency": 3, 16 | "required_population": 0 17 | }, 18 | "medium_apartment_building": { 19 | "display_name": "Medium Apartment Building", 20 | "residential_units": 6, 21 | "spawn_frequency": 1, 22 | "required_population": 20 23 | }, 24 | "large_apartment_building": { 25 | "display_name": "Large Apartment Building", 26 | "residential_units": 10, 27 | "spawn_frequency": 1, 28 | "required_population": 30 29 | } 30 | } -------------------------------------------------------------------------------- /notebooks/sample_plugin.py: -------------------------------------------------------------------------------- 1 | """A plugin with sample content. 2 | 3 | """ 4 | 5 | import pathlib 6 | 7 | from neighborly.loaders import ( 8 | load_businesses, 9 | load_characters, 10 | load_districts, 11 | load_job_roles, 12 | load_residences, 13 | load_settlements, 14 | load_skills, 15 | ) 16 | from neighborly.simulation import Simulation 17 | 18 | _TEST_DATA_DIR = pathlib.Path(__file__).parent.parent / "tests" / "data" 19 | 20 | 21 | def load_plugin(sim: Simulation) -> None: 22 | """Load plugin content.""" 23 | 24 | load_districts(sim, _TEST_DATA_DIR / "districts.json") 25 | load_settlements(sim, _TEST_DATA_DIR / "settlements.json") 26 | load_businesses(sim, _TEST_DATA_DIR / "businesses.json") 27 | load_characters(sim, _TEST_DATA_DIR / "characters.json") 28 | load_residences(sim, _TEST_DATA_DIR / "residences.json") 29 | load_job_roles(sim, _TEST_DATA_DIR / "job_roles.json") 30 | load_skills(sim, _TEST_DATA_DIR / "skills.json") 31 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Neighborly Documentation 2 | 3 | Neighborly uses Sphinx to build the documentation from reStructured Text files. The latest version of the live docs can be found on [Read the Docs](https:/neighborly.readthedocs.io/en/latest/index.html). 4 | 5 | The docs is made up of two parts: 6 | 7 | 1. Wiki pages that explain Neighborly's core concepts and abstractions 8 | 2. Documentation for the Python source code 9 | 10 | ## Building the docs 11 | 12 | **Note:** All file paths provided below are relative to `neighborly/docs` 13 | 14 | Whenever new source code is added, run the following command to have sphinx-autodoc generate the proper documentation pages. All these pages are stored in the `./source/api` folder to keep them separated from the hand-authored wiki pages. 15 | 16 | ```bash 17 | sphinx-apidoc -o ./source/api ../src/neighborly 18 | ``` 19 | 20 | Finally, you can build the html files with the command below 21 | 22 | ```bash 23 | # macOS/Linux 24 | make html 25 | 26 | # Windows 27 | ./make.bat html 28 | ``` 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | # MIT LICENSE 2 | 3 | Copyright 2022-2024 Shi Johnson-Bey 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this 6 | software and associated documentation files (the "Software"), to deal in the Software 7 | without restriction, including without limitation the rights to use, copy, modify, 8 | merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | permit persons to whom the Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all copies 13 | or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 16 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 17 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 18 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF 19 | CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE 20 | OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # Read the Docs configuration file for Sphinx projects 2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 3 | 4 | # Required 5 | version: 2 6 | 7 | # Set the OS, Python version and other tools you might need 8 | build: 9 | os: ubuntu-22.04 10 | tools: 11 | python: "3.12" 12 | # You can also specify other tool versions: 13 | # nodejs: "20" 14 | # rust: "1.70" 15 | # golang: "1.20" 16 | 17 | # Build documentation in the "docs/" directory with Sphinx 18 | sphinx: 19 | configuration: docs/source/conf.py 20 | # You can configure Sphinx to use a different builder, for instance use the dirhtml builder for simpler URLs 21 | # builder: "dirhtml" 22 | # Fail on all warnings to avoid broken references 23 | # fail_on_warning: true 24 | 25 | # Optionally build your docs in additional formats such as PDF and ePub 26 | # formats: 27 | # - pdf 28 | # - epub 29 | 30 | # Optional but recommended, declare the Python requirements required 31 | # to build your documentation 32 | # See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html 33 | python: 34 | install: 35 | - requirements: docs/source/requirements.txt 36 | -------------------------------------------------------------------------------- /src/neighborly/effects/base_types.py: -------------------------------------------------------------------------------- 1 | """Abstract base types for implementing Effects. 2 | 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | from abc import ABC, abstractmethod 8 | from typing import Any 9 | 10 | from neighborly.ecs import GameObject, World 11 | 12 | 13 | class Effect(ABC): 14 | """Abstract base class for all effect objects.""" 15 | 16 | @property 17 | @abstractmethod 18 | def description(self) -> str: 19 | """Get a string description of the effect.""" 20 | raise NotImplementedError() 21 | 22 | @abstractmethod 23 | def apply(self, target: GameObject) -> None: 24 | """Apply the effects of this effect.""" 25 | raise NotImplementedError() 26 | 27 | @abstractmethod 28 | def remove(self, target: GameObject) -> None: 29 | """Remove the effects of this effect.""" 30 | raise NotImplementedError() 31 | 32 | @classmethod 33 | @abstractmethod 34 | def instantiate(cls, world: World, params: dict[str, Any]) -> Effect: 35 | """Construct a new instance of the effect type using a data dict.""" 36 | raise NotImplementedError() 37 | 38 | def __str__(self) -> str: 39 | return self.description 40 | -------------------------------------------------------------------------------- /docs/source/api/neighborly.plugins.rst: -------------------------------------------------------------------------------- 1 | neighborly.plugins package 2 | ========================== 3 | 4 | Submodules 5 | ---------- 6 | 7 | neighborly.plugins.default\_character\_names module 8 | --------------------------------------------------- 9 | 10 | .. automodule:: neighborly.plugins.default_character_names 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | neighborly.plugins.default\_events module 16 | ----------------------------------------- 17 | 18 | .. automodule:: neighborly.plugins.default_events 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | neighborly.plugins.default\_settlement\_names module 24 | ---------------------------------------------------- 25 | 26 | .. automodule:: neighborly.plugins.default_settlement_names 27 | :members: 28 | :undoc-members: 29 | :show-inheritance: 30 | 31 | neighborly.plugins.default\_traits module 32 | ----------------------------------------- 33 | 34 | .. automodule:: neighborly.plugins.default_traits 35 | :members: 36 | :undoc-members: 37 | :show-inheritance: 38 | 39 | Module contents 40 | --------------- 41 | 42 | .. automodule:: neighborly.plugins 43 | :members: 44 | :undoc-members: 45 | :show-inheritance: 46 | -------------------------------------------------------------------------------- /src/neighborly/config.py: -------------------------------------------------------------------------------- 1 | """Simulation configuration. 2 | 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | import random 8 | from typing import Union 9 | 10 | import attrs 11 | 12 | 13 | @attrs.define 14 | class LoggingConfig: 15 | """Configuration settings for logging within a simulation.""" 16 | 17 | logging_enabled: bool = True 18 | """Toggles if logging messages are sent anywhere.""" 19 | 20 | log_level: str = "INFO" 21 | """The logging level to use.""" 22 | 23 | log_file_path: str = "./neighborly.log" 24 | """Toggles if logging output should be save to this file name in log_directory.""" 25 | 26 | log_to_terminal: bool = True 27 | """Toggles if logs should be printed to the terminal or saved to a file.""" 28 | 29 | 30 | @attrs.define 31 | class SimulationConfig: 32 | """Configuration settings for a Simulation instance.""" 33 | 34 | seed: Union[str, int] = attrs.field(factory=lambda: random.randint(0, 9999999)) 35 | """Value used for pseudo-random number generation.""" 36 | 37 | logging: LoggingConfig = attrs.field(factory=LoggingConfig) 38 | """Configuration settings for logging.""" 39 | 40 | settlement: Union[str, list[str]] = attrs.field(factory=list[str]) 41 | """Settlement definition ID to instantiate during simulation initialization.""" 42 | -------------------------------------------------------------------------------- /tests/test_business.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | 3 | from neighborly.components.business import Business 4 | from neighborly.helpers.business import create_business 5 | from neighborly.helpers.settlement import create_district, create_settlement 6 | from neighborly.loaders import ( 7 | load_businesses, 8 | load_characters, 9 | load_districts, 10 | load_job_roles, 11 | load_residences, 12 | load_settlements, 13 | ) 14 | from neighborly.simulation import Simulation 15 | 16 | _TEST_DATA_DIR = pathlib.Path(__file__).parent / "data" 17 | 18 | 19 | def test_create_business() -> None: 20 | sim = Simulation() 21 | 22 | load_districts(sim, _TEST_DATA_DIR / "districts.json") 23 | load_settlements(sim, _TEST_DATA_DIR / "settlements.json") 24 | load_businesses(sim, _TEST_DATA_DIR / "businesses.json") 25 | load_characters(sim, _TEST_DATA_DIR / "characters.json") 26 | load_residences(sim, _TEST_DATA_DIR / "residences.json") 27 | load_job_roles(sim, _TEST_DATA_DIR / "job_roles.json") 28 | 29 | sim.initialize() 30 | 31 | settlement = create_settlement(sim.world, "basic_settlement") 32 | 33 | district = create_district(sim.world, settlement, "entertainment_district") 34 | 35 | business = create_business(sim.world, district, "blacksmith_shop") 36 | 37 | assert business.get_component(Business).owner_role is not None 38 | assert business.get_component(Business).district == district 39 | -------------------------------------------------------------------------------- /src/neighborly/helpers/residence.py: -------------------------------------------------------------------------------- 1 | """Helper functions for managing residences and residents. 2 | 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | from typing import Optional 8 | 9 | from neighborly.defs.base_types import ResidenceDef, ResidenceGenOptions 10 | from neighborly.ecs import GameObject, World 11 | from neighborly.libraries import ResidenceLibrary 12 | 13 | 14 | def create_residence( 15 | world: World, 16 | district: GameObject, 17 | definition_id: str, 18 | options: Optional[ResidenceGenOptions] = None, 19 | ) -> GameObject: 20 | """Create a new residence instance.""" 21 | library = world.resource_manager.get_resource(ResidenceLibrary) 22 | 23 | residence_def = library.get_definition(definition_id) 24 | 25 | options = options if options else ResidenceGenOptions() 26 | 27 | residence = residence_def.instantiate(world, district, options) 28 | 29 | return residence 30 | 31 | 32 | def register_residence_def(world: World, definition: ResidenceDef) -> None: 33 | """Add a new residence definition for the ResidenceLibrary. 34 | 35 | Parameters 36 | ---------- 37 | world 38 | The world instance containing the residence library. 39 | definition 40 | The definition to add. 41 | """ 42 | world.resource_manager.get_resource(ResidenceLibrary).add_definition(definition) 43 | world.resource_manager.get_resource(ResidenceLibrary).add_definition(definition) 44 | -------------------------------------------------------------------------------- /tests/test_residence.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | 3 | from neighborly.components.residence import ResidentialBuilding 4 | from neighborly.helpers.residence import create_residence 5 | from neighborly.helpers.settlement import create_district, create_settlement 6 | from neighborly.loaders import ( 7 | load_businesses, 8 | load_characters, 9 | load_districts, 10 | load_job_roles, 11 | load_residences, 12 | load_settlements, 13 | ) 14 | from neighborly.simulation import Simulation 15 | 16 | _TEST_DATA_DIR = pathlib.Path(__file__).parent / "data" 17 | 18 | 19 | def test_create_residence() -> None: 20 | sim = Simulation() 21 | 22 | load_districts(sim, _TEST_DATA_DIR / "districts.json") 23 | load_settlements(sim, _TEST_DATA_DIR / "settlements.json") 24 | load_businesses(sim, _TEST_DATA_DIR / "businesses.json") 25 | load_characters(sim, _TEST_DATA_DIR / "characters.json") 26 | load_residences(sim, _TEST_DATA_DIR / "residences.json") 27 | load_job_roles(sim, _TEST_DATA_DIR / "job_roles.json") 28 | 29 | settlement = create_settlement(sim.world, "basic_settlement") 30 | 31 | district = create_district(sim.world, settlement, "entertainment_district") 32 | 33 | r0 = create_residence(sim.world, district, "house") 34 | r0_units = list(r0.get_component(ResidentialBuilding).units) 35 | assert len(r0_units) == 1 36 | 37 | r1 = create_residence(sim.world, district, "large_apartment_building") 38 | r1_units = list(r1.get_component(ResidentialBuilding).units) 39 | assert len(r1_units) == 10 40 | -------------------------------------------------------------------------------- /src/neighborly/preconditions/base_types.py: -------------------------------------------------------------------------------- 1 | """Abstract base types for implementing preconditions. 2 | 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | from abc import ABC, abstractmethod 8 | from typing import Any 9 | 10 | from neighborly.ecs import GameObject, World 11 | 12 | 13 | class Precondition(ABC): 14 | """Abstract base class for all precondition objects.""" 15 | 16 | @property 17 | @abstractmethod 18 | def description(self) -> str: 19 | """Get a string description of the effect.""" 20 | raise NotImplementedError() 21 | 22 | @abstractmethod 23 | def __call__(self, target: GameObject) -> bool: 24 | """Check if a GameObject passes the precondition. 25 | 26 | Parameters 27 | ---------- 28 | target 29 | A GameObject 30 | 31 | Returns 32 | ------- 33 | bool 34 | True if the gameobject passes the precondition, False otherwise. 35 | """ 36 | raise NotImplementedError() 37 | 38 | @classmethod 39 | @abstractmethod 40 | def instantiate(cls, world: World, params: dict[str, Any]) -> Precondition: 41 | """Construct a new instance of the precondition using a data dict. 42 | 43 | Parameters 44 | ---------- 45 | world 46 | The simulation's world instance 47 | params 48 | Keyword parameters to pass to the precondition. 49 | """ 50 | raise NotImplementedError() 51 | 52 | def __str__(self) -> str: 53 | return self.description 54 | -------------------------------------------------------------------------------- /tests/data/job_roles.json: -------------------------------------------------------------------------------- 1 | { 2 | "blacksmith": { 3 | "display_name": "Blacksmith", 4 | "job_level": 2, 5 | "requirements": [ 6 | { 7 | "type": "SkillRequirement", 8 | "skill": "blacksmithing", 9 | "level": 50 10 | } 11 | ], 12 | "monthly_effects": [ 13 | { 14 | "type": "IncreaseSkill", 15 | "skill": "blacksmithing", 16 | "amount": 0.2 17 | } 18 | ] 19 | }, 20 | "blacksmith_apprentice": { 21 | "display_name": "Blacksmith Apprentice", 22 | "job_level": 1, 23 | "monthly_effects": [ 24 | { 25 | "type": "IncreaseSkill", 26 | "skill": "blacksmithing", 27 | "amount": 3 28 | } 29 | ] 30 | }, 31 | "cafe_owner": { 32 | "display_name": "Cafe Owner" 33 | }, 34 | "barista": { 35 | "display_name": "Barista" 36 | }, 37 | "farmer": { 38 | "display_name": "Farmer" 39 | }, 40 | "farmhand": { 41 | "display_name": "Farmhand" 42 | }, 43 | "shopkeeper": { 44 | "display_name": "Shopkeeper" 45 | }, 46 | "cashier": { 47 | "display_name": "Cashier" 48 | }, 49 | "theatre_owner": { 50 | "display_name": "Theatre Owner" 51 | }, 52 | "actor": { 53 | "display_name": "Actor" 54 | }, 55 | "bartender": { 56 | "display_name": "Bartender" 57 | }, 58 | "bar_owner": { 59 | "display_name": "Bar Owner" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /tests/data/businesses.json: -------------------------------------------------------------------------------- 1 | { 2 | "cafe": { 3 | "name": "Cafe", 4 | "spawn_frequency": 1, 5 | "owner_role": "cafe_owner", 6 | "employee_roles": { 7 | "barista": 2 8 | } 9 | }, 10 | "farm": { 11 | "name": "Farm", 12 | "spawn_frequency": 1, 13 | "owner_role": "farmer", 14 | "employee_roles": { 15 | "farmhand": 2 16 | } 17 | }, 18 | "dairy_farm": { 19 | "name": "Dairy Farm", 20 | "spawn_frequency": 1, 21 | "owner_role": "farmer", 22 | "employee_roles": { 23 | "farmhand": 2 24 | } 25 | }, 26 | "shop": { 27 | "name": "Shop", 28 | "spawn_frequency": 1, 29 | "owner_role": "shopkeeper", 30 | "employee_roles": { 31 | "cashier": 1 32 | } 33 | }, 34 | "theatre": { 35 | "name": "Theatre", 36 | "spawn_frequency": 1, 37 | "owner_role": "theatre_owner", 38 | "employee_roles": { 39 | "actor": 3 40 | } 41 | }, 42 | "bar": { 43 | "name": "Bar", 44 | "spawn_frequency": 1, 45 | "owner_role": "bar_owner", 46 | "employee_roles": { 47 | "bartender": 2 48 | }, 49 | "traits": [ 50 | "serves_alcohol" 51 | ] 52 | }, 53 | "blacksmith_shop": { 54 | "name": "Blacksmith Shop", 55 | "spawn_frequency": 1, 56 | "max_instances": 2, 57 | "min_population": 10, 58 | "owner_role": "blacksmith", 59 | "employee_roles": { 60 | "blacksmith_apprentice": 1 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/neighborly/helpers/character.py: -------------------------------------------------------------------------------- 1 | """Helper functions for character operations. 2 | 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | from typing import Optional 8 | 9 | from neighborly.defs.base_types import CharacterDef, CharacterGenOptions 10 | from neighborly.ecs import GameObject, World 11 | from neighborly.libraries import CharacterLibrary 12 | 13 | 14 | def create_character( 15 | world: World, definition_id: str, options: Optional[CharacterGenOptions] = None 16 | ) -> GameObject: 17 | """Create a new character instance. 18 | 19 | Parameters 20 | ---------- 21 | world 22 | The simulation's World instance. 23 | definition_id 24 | The ID of the definition to instantiate. 25 | options 26 | Generation parameters. 27 | 28 | Returns 29 | ------- 30 | GameObject 31 | An instantiated character. 32 | """ 33 | character_library = world.resource_manager.get_resource(CharacterLibrary) 34 | 35 | character_def = character_library.get_definition(definition_id) 36 | 37 | options = options if options else CharacterGenOptions() 38 | 39 | character = character_def.instantiate(world, options) 40 | 41 | return character 42 | 43 | 44 | def register_character_def(world: World, definition: CharacterDef) -> None: 45 | """Add a new character definition for the CharacterLibrary. 46 | 47 | Parameters 48 | ---------- 49 | world 50 | The world instance containing the character library. 51 | definition 52 | The definition to add. 53 | """ 54 | world.resource_manager.get_resource(CharacterLibrary).add_definition(definition) 55 | world.resource_manager.get_resource(CharacterLibrary).add_definition(definition) 56 | -------------------------------------------------------------------------------- /src/neighborly/helpers/content_selection.py: -------------------------------------------------------------------------------- 1 | """Content selection helper functions. 2 | 3 | """ 4 | 5 | from typing import Iterable, TypeVar 6 | 7 | _T = TypeVar("_T") 8 | 9 | 10 | def get_with_tags( 11 | options: list[tuple[_T, Iterable[str]]], tags: Iterable[str] 12 | ) -> list[_T]: 13 | """Get a definition from the library with the given tags. 14 | 15 | Parameters 16 | ---------- 17 | options 18 | Tuples of items with their tags. 19 | tags 20 | A collection of mandatory and optional tags. 21 | 22 | Returns 23 | ------- 24 | List[_T] 25 | The items in options that best match the tags. 26 | """ 27 | 28 | matches: list[tuple[_T, int]] = [] 29 | 30 | mandatory_tags = set(t for t in tags if t[0] != "~") 31 | optional_tags = set(t[1:] for t in tags if t[0] == "~") 32 | 33 | for entry, entry_tags in options: 34 | entry_tag_set = set(entry_tags) 35 | unsatisfied_mandatory_tags = mandatory_tags.difference(entry_tag_set) 36 | mandatory_tags_present = len(unsatisfied_mandatory_tags) == 0 37 | 38 | satisfied_optional_tags = optional_tags.intersection(entry_tag_set) 39 | optional_tags_count = len(satisfied_optional_tags) 40 | 41 | if mandatory_tags_present: 42 | matches.append((entry, optional_tags_count)) 43 | 44 | if matches: # something exists 45 | matches.sort(key=lambda x: x[1], reverse=True) 46 | 47 | max_optional_tags_count = matches[0][1] 48 | 49 | best_matches = [ 50 | definition 51 | for definition, optional_tags_count in matches 52 | if optional_tags_count == max_optional_tags_count 53 | ] 54 | 55 | return best_matches 56 | 57 | return [] 58 | -------------------------------------------------------------------------------- /src/neighborly/tracery.py: -------------------------------------------------------------------------------- 1 | """Tracery 2 | 3 | Neighborly uses Kate Compton's Tracery to generate names for characters, items, 4 | businesses and other named objects. Users can add data to the simulations Tracery 5 | instance using JSON files loaded using the neighborly.loaders.load_tracery(...) 6 | function. 7 | 8 | """ 9 | 10 | from typing import Optional, Union 11 | 12 | import tracery 13 | import tracery.modifiers as tracery_modifiers 14 | 15 | 16 | class Tracery: 17 | """A class that wraps a tracery grammar instance.""" 18 | 19 | __slots__ = ("_grammar",) 20 | 21 | _grammar: tracery.Grammar 22 | """The grammar instance.""" 23 | 24 | def __init__(self, rng_seed: Optional[Union[str, int]] = None) -> None: 25 | self._grammar = tracery.Grammar( 26 | dict[str, str](), modifiers=tracery_modifiers.base_english 27 | ) 28 | if rng_seed is not None: 29 | self._grammar.rng.seed(rng_seed) 30 | 31 | def set_rng_seed(self, seed: Union[int, str]) -> None: 32 | """Set the seed for RNG used during rule evaluation. 33 | 34 | Parameters 35 | ---------- 36 | seed 37 | An arbitrary seed value. 38 | """ 39 | self._grammar.rng.seed(seed) 40 | 41 | def add_rules(self, rules: dict[str, list[str]]) -> None: 42 | """Add grammar rules. 43 | 44 | Parameters 45 | ---------- 46 | rules 47 | Rule names mapped to strings or lists of string to expend to. 48 | """ 49 | for rule_name, expansion in rules.items(): 50 | self._grammar.push_rules(rule_name, expansion) 51 | 52 | def generate(self, start_string: str) -> str: 53 | """Return a string generated using the grammar rules. 54 | 55 | Parameters 56 | ---------- 57 | start_string 58 | The string to expand using grammar rules. 59 | 60 | Returns 61 | ------- 62 | str 63 | The final string. 64 | """ 65 | return self._grammar.flatten(start_string) 66 | -------------------------------------------------------------------------------- /src/neighborly/helpers/skills.py: -------------------------------------------------------------------------------- 1 | """Skill System Helper Functions. 2 | 3 | """ 4 | 5 | from neighborly.components.skills import Skills 6 | from neighborly.components.stats import Stat 7 | from neighborly.ecs import GameObject 8 | from neighborly.libraries import SkillLibrary 9 | 10 | 11 | def add_skill(gameobject: GameObject, skill_id: str, base_value: float = 0.0) -> None: 12 | """Add a new skill to a character with the given base value. 13 | 14 | Parameters 15 | ---------- 16 | gameobject 17 | The character to add the skill to. 18 | skill_id 19 | The definition ID of the skill to add. 20 | base_value 21 | The base value of the skill when added. 22 | """ 23 | library = gameobject.world.resource_manager.get_resource(SkillLibrary) 24 | skill = library.get_skill(skill_id) 25 | gameobject.get_component(Skills).add_skill(skill, base_value) 26 | 27 | 28 | def has_skill(gameobject: GameObject, skill_id: str) -> bool: 29 | """Check if a character has a skill. 30 | 31 | Parameters 32 | ---------- 33 | gameobject 34 | The character to check. 35 | skill_id 36 | The ID of the skill to check for. 37 | 38 | Returns 39 | ------- 40 | bool 41 | True if the character has the skill, False otherwise. 42 | """ 43 | library = gameobject.world.resource_manager.get_resource(SkillLibrary) 44 | skill = library.get_skill(skill_id) 45 | return gameobject.get_component(Skills).has_skill(skill) 46 | 47 | 48 | def get_skill(gameobject: GameObject, skill_id: str) -> Stat: 49 | """Get a character's skill stat. 50 | 51 | Parameters 52 | ---------- 53 | gameobject 54 | The character to check. 55 | skill_id 56 | The ID of the skill to retrieve. 57 | 58 | Returns 59 | ------- 60 | Stat 61 | The stat associated with this skill. 62 | """ 63 | library = gameobject.world.resource_manager.get_resource(SkillLibrary) 64 | skill = library.get_skill(skill_id) 65 | return gameobject.get_component(Skills).get_skill(skill) 66 | -------------------------------------------------------------------------------- /docs/source/api/neighborly.helpers.rst: -------------------------------------------------------------------------------- 1 | neighborly.helpers package 2 | ========================== 3 | 4 | Submodules 5 | ---------- 6 | 7 | neighborly.helpers.business module 8 | ---------------------------------- 9 | 10 | .. automodule:: neighborly.helpers.business 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | neighborly.helpers.character module 16 | ----------------------------------- 17 | 18 | .. automodule:: neighborly.helpers.character 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | neighborly.helpers.location module 24 | ---------------------------------- 25 | 26 | .. automodule:: neighborly.helpers.location 27 | :members: 28 | :undoc-members: 29 | :show-inheritance: 30 | 31 | neighborly.helpers.relationship module 32 | -------------------------------------- 33 | 34 | .. automodule:: neighborly.helpers.relationship 35 | :members: 36 | :undoc-members: 37 | :show-inheritance: 38 | 39 | neighborly.helpers.residence module 40 | ----------------------------------- 41 | 42 | .. automodule:: neighborly.helpers.residence 43 | :members: 44 | :undoc-members: 45 | :show-inheritance: 46 | 47 | neighborly.helpers.settlement module 48 | ------------------------------------ 49 | 50 | .. automodule:: neighborly.helpers.settlement 51 | :members: 52 | :undoc-members: 53 | :show-inheritance: 54 | 55 | neighborly.helpers.skills module 56 | -------------------------------- 57 | 58 | .. automodule:: neighborly.helpers.skills 59 | :members: 60 | :undoc-members: 61 | :show-inheritance: 62 | 63 | neighborly.helpers.stats module 64 | ------------------------------- 65 | 66 | .. automodule:: neighborly.helpers.stats 67 | :members: 68 | :undoc-members: 69 | :show-inheritance: 70 | 71 | neighborly.helpers.traits module 72 | -------------------------------- 73 | 74 | .. automodule:: neighborly.helpers.traits 75 | :members: 76 | :undoc-members: 77 | :show-inheritance: 78 | 79 | Module contents 80 | --------------- 81 | 82 | .. automodule:: neighborly.helpers 83 | :members: 84 | :undoc-members: 85 | :show-inheritance: 86 | -------------------------------------------------------------------------------- /src/neighborly/helpers/stats.py: -------------------------------------------------------------------------------- 1 | """Helper functions for modifying GameObject Stats. 2 | 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | from neighborly.components.stats import Stat, Stats 8 | from neighborly.ecs import GameObject 9 | 10 | 11 | def add_stat( 12 | gameobject: GameObject, 13 | stat_id: str, 14 | stat: Stat, 15 | ) -> Stat: 16 | """Add a new stat to the gameobject. 17 | 18 | Parameters 19 | ---------- 20 | gameobject 21 | The GameObject to add a stat to. 22 | stat_id 23 | The ID to associate the stat with. 24 | stat 25 | The stat instance to add. 26 | 27 | Returns 28 | ------- 29 | Stat 30 | The newly created stat. 31 | """ 32 | gameobject.get_component(Stats).add_stat(stat_id, stat) 33 | return stat 34 | 35 | 36 | def has_stat(gameobject: GameObject, stat_id: str) -> bool: 37 | """Check if a GameObject has a stat. 38 | 39 | Parameters 40 | ---------- 41 | gameobject 42 | The GameObject to check. 43 | stat_id 44 | The definition ID of a stat to check for. 45 | 46 | Returns 47 | ------- 48 | bool 49 | True if the GameObject has the stat. False otherwise. 50 | """ 51 | return gameobject.get_component(Stats).has_stat(stat_id) 52 | 53 | 54 | def get_stat(gameobject: GameObject, stat_id: str) -> Stat: 55 | """Get a GameObject's stat. 56 | 57 | Parameters 58 | ---------- 59 | gameobject 60 | A GameObject. 61 | stat_id 62 | The definition ID of a stat to retrieve. 63 | 64 | Returns 65 | ------- 66 | Stat 67 | The stat. 68 | """ 69 | return gameobject.get_component(Stats).get_stat(stat_id) 70 | 71 | 72 | def remove_stat(gameobject: GameObject, stat_id: str) -> bool: 73 | """Remove a stat from a GameObject. 74 | 75 | Parameters 76 | ---------- 77 | gameobject 78 | A GameObject. 79 | stat_id 80 | The definition ID of a stat to remove. 81 | 82 | Returns 83 | ------- 84 | bool 85 | True if the stat was removed successfully. False otherwise. 86 | """ 87 | return gameobject.get_component(Stats).remove_stat(stat_id) 88 | -------------------------------------------------------------------------------- /docs/source/plugins.rst: -------------------------------------------------------------------------------- 1 | .. _plugins: 2 | 3 | Plugins 4 | ======= 5 | 6 | Plugins are how Neighborly enables users to inject custom code into the simulation. They may be single Python modules or entire packages. The only requirement is that when importing the plugin, there is a top-level ``load_plugin(sim)`` function available. The ``load_plugin`` function accepts a simulation instance as input and is free to load all types of new content. Including a ``load_plugin`` function is a convention to ensure that users of your plugin will intuitively know how to load it into their simulation. 7 | 8 | Take a look at the code below. This is an example of a Neighborly plugin implemented within a single Python file (module). 9 | 10 | .. code-block:: python 11 | 12 | """Example Plugin. 13 | 14 | file: example_plugin.py 15 | 16 | """ 17 | 18 | from neighborly.simulation import Simulation 19 | from neighborly.loaders import load_traits, load_skills, load_districts 20 | 21 | def load_plugin(sim: Simulation) -> None: 22 | """Add plugin data to the simulation.""" 23 | 24 | load_traits(sim, "path/to/trait_data") 25 | load_skills(sim, "path/to/skill_data") 26 | load_districts(sim, "path/to/district_data") 27 | 28 | 29 | User can then load the plugin into their simulation by importing the module and calling the ``load_plugin`` function. 30 | 31 | .. code-block:: python 32 | 33 | """Example Simulation Script. 34 | 35 | """ 36 | 37 | from neighborly.simulation import Simulation 38 | 39 | import example_plugin 40 | 41 | 42 | def main(): 43 | 44 | sim = Simulation() 45 | 46 | example_plugin.load_plugin(sim) 47 | 48 | # other_stuff ... 49 | 50 | for _ in range(1200): 51 | sim.step() 52 | 53 | 54 | if __name__ == "__main__": 55 | main() 56 | 57 | 58 | Sharing plugins 59 | --------------- 60 | 61 | If you want your plugin to be easily shared with other people/projects, please ensure that you follow recommended Python practices for `packaging and distributing code `_. You python package can then be installed from from PyPI or using a GitHub URL. 62 | -------------------------------------------------------------------------------- /src/neighborly/helpers/location.py: -------------------------------------------------------------------------------- 1 | """Helper functions for working with locations. 2 | 3 | """ 4 | 5 | from neighborly.components.location import FrequentedBy, FrequentedLocations 6 | from neighborly.ecs import GameObject 7 | 8 | 9 | def add_frequented_location(character: GameObject, location: GameObject) -> None: 10 | """Add a location to a character's collection of frequented locations. 11 | 12 | Parameters 13 | ---------- 14 | character 15 | A character. 16 | location 17 | A location. 18 | """ 19 | character.get_component(FrequentedLocations).add_location(location) 20 | location.get_component(FrequentedBy).add_character(character) 21 | 22 | 23 | def remove_frequented_location(character: GameObject, location: GameObject) -> None: 24 | """Remove a location from a character's collection of frequented locations. 25 | 26 | Parameters 27 | ---------- 28 | character 29 | A character. 30 | location 31 | A location. 32 | """ 33 | character.get_component(FrequentedLocations).remove_location(location) 34 | location.get_component(FrequentedBy).remove_character(character) 35 | 36 | 37 | def remove_all_frequented_locations(character: GameObject) -> None: 38 | """Remove all frequented locations from the character. 39 | 40 | Parameters 41 | ---------- 42 | character 43 | A character. 44 | """ 45 | frequented_locations_data = character.get_component(FrequentedLocations) 46 | locations = list(frequented_locations_data) 47 | for location in locations: 48 | location.get_component(FrequentedBy).remove_character(character) 49 | frequented_locations_data.remove_location(location) 50 | 51 | 52 | def remove_all_frequenting_characters(location: GameObject) -> None: 53 | """Remove all characters from frequenting the given location. 54 | 55 | Parameters 56 | ---------- 57 | location 58 | A location. 59 | """ 60 | frequented_by_data = location.get_component(FrequentedBy) 61 | characters = list(frequented_by_data) 62 | for character in characters: 63 | character.get_component(FrequentedLocations).remove_location(location) 64 | frequented_by_data.remove_character(character) 65 | -------------------------------------------------------------------------------- /src/neighborly/helpers/business.py: -------------------------------------------------------------------------------- 1 | """Helper functions for business operations. 2 | 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | from typing import Optional 8 | 9 | from neighborly.defs.base_types import BusinessDef, BusinessGenOptions, JobRoleDef 10 | from neighborly.ecs import GameObject, World 11 | from neighborly.libraries import BusinessLibrary, JobRoleLibrary 12 | 13 | 14 | def create_business( 15 | world: World, 16 | district: GameObject, 17 | definition_id: str, 18 | options: Optional[BusinessGenOptions] = None, 19 | ) -> GameObject: 20 | """Create a new business instance. 21 | 22 | Parameters 23 | ---------- 24 | world 25 | The World instance to spawn the business into. 26 | district 27 | The district where the business resides. 28 | definition_id 29 | The ID of the business definition to instantiate 30 | options 31 | Generation options. 32 | 33 | Returns 34 | ------- 35 | GameObject 36 | The instantiated business. 37 | """ 38 | library = world.resource_manager.get_resource(BusinessLibrary) 39 | 40 | business_def = library.get_definition(definition_id) 41 | 42 | options = options if options else BusinessGenOptions() 43 | 44 | business = business_def.instantiate(world, district, options) 45 | 46 | return business 47 | 48 | 49 | def register_business_def(world: World, definition: BusinessDef) -> None: 50 | """Add a new business definition for the BusinessLibrary. 51 | 52 | Parameters 53 | ---------- 54 | world 55 | The world instance containing the business library. 56 | definition 57 | The definition to add. 58 | """ 59 | world.resource_manager.get_resource(BusinessLibrary).add_definition(definition) 60 | 61 | 62 | def register_job_role_def(world: World, definition: JobRoleDef) -> None: 63 | """Add a new job role definition for the JobRoleLibrary. 64 | 65 | Parameters 66 | ---------- 67 | world 68 | The world instance containing the job role library. 69 | definition 70 | The definition to add. 71 | """ 72 | world.resource_manager.get_resource(JobRoleLibrary).add_definition(definition) 73 | world.resource_manager.get_resource(JobRoleLibrary).add_definition(definition) 74 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Neighborly 2 | 3 | Contributions are welcome. Below is a list of areas that someone could contribute. If you create a new Neighborly 4 | plugin, please message me. I will include a link to your plugin's GitHub repository. If you want to contribute to the 5 | core code, fork this repository, make your changes, and submit a pull request with a description of your contribution. 6 | Please remember that this project is a tool for creativity and learning. I have a 7 | [code of conduct](./CODE_OF_CONDUCT.md) to encourage healthy collaboration, and I will enforce it if necessary. 8 | 9 | 1. Proposing and implementing new features 10 | 2. Fixing bugs 11 | 3. Providing optimizations 12 | 4. Submitting issues 13 | 5. Contributing tutorials and how-to guides 14 | 6. Fixing grammar and spelling errors 15 | 7. Creating new samples and plugins 16 | 17 | ## Code Style 18 | 19 | All code contributions should adhere to the [_Black_](https://black.readthedocs.io/en/stable/) code formatter, and sorts 20 | should comply with [_isort_](https://pycqa.github.io/isort/). Both tools are downloaded when installing development 21 | dependencies and should be run before submitting a pull request. 22 | 23 | ## Documenting Python code 24 | 25 | Neighborly uses [Numpy-style](https://numpydoc.readthedocs.io/en/latest/format.html) docstrings in code. When adding 26 | docstrings for existing or new code, please use the following formatting guides: 27 | 28 | - [Sphinx Napoleon Plugin for processing Numpy Docstrings](https://www.sphinx-doc.org/en/master/usage/extensions/napoleon.html) 29 | - [Example Numpy Style Docstrings](https://www.sphinx-doc.org/en/master/usage/extensions/example_numpy.html#example-numpy) 30 | 31 | ## Contributing unit tests 32 | 33 | To contribute unit tests: 34 | 35 | 1. Fork the repo. 36 | 2. Add your test(s) to the `tests/` directory. 37 | 3. Submit a pull request with a description of your test cases. 38 | 39 | Your commits should only contain changes to files within the `tests/` directory. If you change any files in other parts 40 | of the project, your pull request will be rejected. 41 | 42 | ## Licensing 43 | 44 | This project is licensed under the [MIT License](./LICENSE). By submitting a contribution to this project, you are 45 | agreeing that your contribution will be released under the terms of this license. 46 | -------------------------------------------------------------------------------- /tests/test_location_preferences.py: -------------------------------------------------------------------------------- 1 | """Test Location Preference Functionality. 2 | 3 | """ 4 | 5 | import pathlib 6 | 7 | import pytest 8 | 9 | from neighborly.components.location import LocationPreferences 10 | from neighborly.helpers.business import create_business 11 | from neighborly.helpers.character import create_character 12 | from neighborly.helpers.settlement import create_district, create_settlement 13 | from neighborly.helpers.traits import add_trait, remove_trait 14 | from neighborly.loaders import ( 15 | load_businesses, 16 | load_characters, 17 | load_districts, 18 | load_job_roles, 19 | load_residences, 20 | load_settlements, 21 | load_skills, 22 | ) 23 | from neighborly.plugins import default_traits 24 | from neighborly.simulation import Simulation 25 | 26 | _TEST_DATA_DIR = pathlib.Path(__file__).parent / "data" 27 | 28 | 29 | def test_trait_with_location_preferences() -> None: 30 | """Test traits that apply social rules""" 31 | sim = Simulation() 32 | 33 | load_districts(sim, _TEST_DATA_DIR / "districts.json") 34 | load_settlements(sim, _TEST_DATA_DIR / "settlements.json") 35 | load_businesses(sim, _TEST_DATA_DIR / "businesses.json") 36 | load_characters(sim, _TEST_DATA_DIR / "characters.json") 37 | load_residences(sim, _TEST_DATA_DIR / "residences.json") 38 | load_job_roles(sim, _TEST_DATA_DIR / "job_roles.json") 39 | load_skills(sim, _TEST_DATA_DIR / "skills.json") 40 | 41 | default_traits.load_plugin(sim) 42 | 43 | sim.initialize() 44 | 45 | settlement = create_settlement(sim.world, "basic_settlement") 46 | 47 | district = create_district(sim.world, settlement, "entertainment_district") 48 | 49 | cafe = create_business(sim.world, district, "cafe") 50 | bar = create_business(sim.world, district, "bar") 51 | 52 | farmer = create_character(sim.world, "farmer") 53 | 54 | farmer_preferences = farmer.get_component(LocationPreferences) 55 | 56 | assert farmer_preferences.score_location(cafe) == 0.5 57 | assert farmer_preferences.score_location(bar) == 0.5 58 | 59 | add_trait(farmer, "drinks_too_much") 60 | 61 | assert farmer_preferences.score_location(cafe) == 0.5 62 | assert farmer_preferences.score_location(bar) == pytest.approx(0.65, 0.001) # type: ignore 63 | 64 | remove_trait(farmer, "drinks_too_much") 65 | 66 | assert farmer_preferences.score_location(bar) == 0.5 67 | -------------------------------------------------------------------------------- /docs/source/api/neighborly.components.rst: -------------------------------------------------------------------------------- 1 | neighborly.components package 2 | ============================= 3 | 4 | Submodules 5 | ---------- 6 | 7 | neighborly.components.business module 8 | ------------------------------------- 9 | 10 | .. automodule:: neighborly.components.business 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | neighborly.components.character module 16 | -------------------------------------- 17 | 18 | .. automodule:: neighborly.components.character 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | neighborly.components.location module 24 | ------------------------------------- 25 | 26 | .. automodule:: neighborly.components.location 27 | :members: 28 | :undoc-members: 29 | :show-inheritance: 30 | 31 | neighborly.components.relationship module 32 | ----------------------------------------- 33 | 34 | .. automodule:: neighborly.components.relationship 35 | :members: 36 | :undoc-members: 37 | :show-inheritance: 38 | 39 | neighborly.components.residence module 40 | -------------------------------------- 41 | 42 | .. automodule:: neighborly.components.residence 43 | :members: 44 | :undoc-members: 45 | :show-inheritance: 46 | 47 | neighborly.components.settlement module 48 | --------------------------------------- 49 | 50 | .. automodule:: neighborly.components.settlement 51 | :members: 52 | :undoc-members: 53 | :show-inheritance: 54 | 55 | neighborly.components.skills module 56 | ----------------------------------- 57 | 58 | .. automodule:: neighborly.components.skills 59 | :members: 60 | :undoc-members: 61 | :show-inheritance: 62 | 63 | neighborly.components.spawn\_table module 64 | ----------------------------------------- 65 | 66 | .. automodule:: neighborly.components.spawn_table 67 | :members: 68 | :undoc-members: 69 | :show-inheritance: 70 | 71 | neighborly.components.stats module 72 | ---------------------------------- 73 | 74 | .. automodule:: neighborly.components.stats 75 | :members: 76 | :undoc-members: 77 | :show-inheritance: 78 | 79 | neighborly.components.traits module 80 | ----------------------------------- 81 | 82 | .. automodule:: neighborly.components.traits 83 | :members: 84 | :undoc-members: 85 | :show-inheritance: 86 | 87 | Module contents 88 | --------------- 89 | 90 | .. automodule:: neighborly.components 91 | :members: 92 | :undoc-members: 93 | :show-inheritance: 94 | -------------------------------------------------------------------------------- /docs/source/design-tips.rst: -------------------------------------------------------------------------------- 1 | .. _design-tips: 2 | 3 | Design Tips and FAQ 4 | =================== 5 | 6 | When should I use a new component vs. a new trait? 7 | -------------------------------------------------- 8 | 9 | Create a custom component if there is GameObject-specific data that you need to track. Traits are helpful for flexibly authoring effects but cannot hold stateful information. 10 | 11 | If you want the best of both components and traits, first create a custom component, then have the component add traits to GameObject within their :py:meth:`neighborly.ecs.Component.on_add` method. For example, in the code below, we have a ``Vampire`` component that tracks vampire-specific data and adds a ``vampirism`` trait that can define specific effects like making characters immortal or buffing existing traits. Assume we load the ``vampirism`` trait from an external data file. 12 | 13 | .. code-block:: python 14 | 15 | class Vampire(Component): 16 | """Tracks information about a vampire.""" 17 | 18 | __slots__ = ("humans_bled",) 19 | 20 | humans_bled: int 21 | """The number of human's they have feasted on.""" 22 | 23 | def __init__(self): 24 | super().__init__() 25 | self.humans_bled = 0 26 | 27 | def on_add(self): 28 | add_trait(self.gameobject, "vampirism") 29 | 30 | def remove_trait(self): 31 | remove_trait(self.gameobject, "vampirism") 32 | 33 | .. code-block:: yaml 34 | 35 | vampirism: 36 | display_name: Vampirism 37 | description: This character is a vampire 38 | effects: 39 | # This effect makes them unable to die from old age because 40 | # their life decay becomes zero 41 | - type: StatBuff 42 | stat: health_decay 43 | amount: -5000 44 | # This effect makes them more less friendly toward humans 45 | - type: AddSocialRule 46 | preconditions: 47 | - type: TargetHasTrait 48 | trait: human 49 | effects: 50 | - type: StatBuff 51 | stat: reputation 52 | amount: -15 53 | 54 | 55 | When should I create new systems? 56 | --------------------------------- 57 | 58 | Consider creating a new system when you want a simulation mechanic to happen or be checked during every timestep (tick) of the simulation. For instance, health stats are decayed every tick, and character pregnancies are implemented as a system with custom components to ensure that pregnancies take the proper amount of time. 59 | -------------------------------------------------------------------------------- /src/neighborly/helpers/traits.py: -------------------------------------------------------------------------------- 1 | """Helper functions for managing GameObject Traits. 2 | 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | from neighborly.components.traits import Traits 8 | from neighborly.defs.base_types import TraitDef 9 | from neighborly.ecs import GameObject, World 10 | from neighborly.libraries import TraitLibrary 11 | 12 | 13 | def add_trait(gameobject: GameObject, trait_id: str) -> bool: 14 | """Add a trait to a GameObject. 15 | 16 | Parameters 17 | ---------- 18 | gameobject 19 | The gameobject to add the trait to. 20 | trait_id 21 | The ID of the trait. 22 | 23 | Return 24 | ------ 25 | bool 26 | True if the trait was added successfully, False if already present or 27 | if the trait conflict with existing traits. 28 | """ 29 | world = gameobject.world 30 | library = world.resource_manager.get_resource(TraitLibrary) 31 | trait = library.get_trait(trait_id) 32 | 33 | return gameobject.get_component(Traits).add_trait(trait) 34 | 35 | 36 | def remove_trait(gameobject: GameObject, trait_id: str) -> bool: 37 | """Remove a trait from a GameObject. 38 | 39 | Parameters 40 | ---------- 41 | gameobject 42 | The gameobject to remove the trait from. 43 | trait_id 44 | The ID of the trait. 45 | 46 | Returns 47 | ------- 48 | bool 49 | True if the trait was removed successfully, False otherwise. 50 | """ 51 | library = gameobject.world.resource_manager.get_resource(TraitLibrary) 52 | trait = library.get_trait(trait_id) 53 | return gameobject.get_component(Traits).remove_trait(trait) 54 | 55 | 56 | def has_trait(gameobject: GameObject, trait_id: str) -> bool: 57 | """Check if a GameObject has a given trait. 58 | 59 | Parameters 60 | ---------- 61 | gameobject 62 | The gameobject to check. 63 | trait_id 64 | The ID of the trait. 65 | 66 | Returns 67 | ------- 68 | bool 69 | True if the trait was removed successfully, False otherwise. 70 | """ 71 | library = gameobject.world.resource_manager.get_resource(TraitLibrary) 72 | trait = library.get_trait(trait_id) 73 | return gameobject.get_component(Traits).has_trait(trait) 74 | 75 | 76 | def register_trait_def(world: World, definition: TraitDef) -> None: 77 | """Add a new trait definition for the TraitLibrary. 78 | 79 | Parameters 80 | ---------- 81 | world 82 | The world instance containing the trait library. 83 | definition 84 | The definition to add. 85 | """ 86 | world.resource_manager.get_resource(TraitLibrary).add_definition(definition) 87 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | import os 14 | import sys 15 | from datetime import date 16 | 17 | sys.path.insert(0, os.path.abspath("../../src")) 18 | 19 | 20 | # -- Project information ----------------------------------------------------- 21 | 22 | project = "neighborly" 23 | copyright = f"2023-{date.today().year}, Shi Johnson-Bey" 24 | author = "Shi Johnson-Bey" 25 | 26 | # The full version, including alpha/beta/rc tags 27 | release = "2.2.0" 28 | 29 | 30 | # -- General configuration --------------------------------------------------- 31 | 32 | # Add any Sphinx extension module names here, as strings. They can be 33 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 34 | # ones. 35 | extensions = [ 36 | "sphinx.ext.autodoc", 37 | "sphinx.ext.napoleon", 38 | "sphinx.ext.viewcode", 39 | "sphinx_rtd_theme", 40 | ] 41 | 42 | # Napoleon settings 43 | napoleon_numpy_docstring = True 44 | napoleon_preprocess_types = False 45 | napoleon_attr_annotations = True 46 | 47 | # Add any paths that contain templates here, relative to this directory. 48 | templates_path = ["_templates"] 49 | 50 | # List of patterns, relative to source directory, that match files and 51 | # directories to ignore when looking for source files. 52 | # This pattern also affects html_static_path and html_extra_path. 53 | exclude_patterns = [] 54 | 55 | 56 | # -- Options for HTML output ------------------------------------------------- 57 | 58 | # The theme to use for HTML and HTML Help pages. See the documentation for 59 | # a list of builtin themes. 60 | # 61 | html_theme = "sphinx_rtd_theme" 62 | 63 | # Add any paths that contain custom static files (such as style sheets) here, 64 | # relative to this directory. They are copied after the builtin static files, 65 | # so a file named "default.css" will overwrite the builtin "default.css". 66 | html_static_path = ["_static"] 67 | 68 | # The name of an image file (relative to this directory) to place at the top 69 | # of the sidebar. 70 | html_logo = "_static/NeighborlyLogo.png" 71 | 72 | # The name of an image file (within the static path) to use as favicon of the 73 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 74 | # pixels large. 75 | html_favicon = "_static/NeighborlyLogo.ico" 76 | -------------------------------------------------------------------------------- /docs/source/api/neighborly.rst: -------------------------------------------------------------------------------- 1 | neighborly package 2 | ================== 3 | 4 | Subpackages 5 | ----------- 6 | 7 | .. toctree:: 8 | :maxdepth: 4 9 | 10 | neighborly.components 11 | neighborly.defs 12 | neighborly.effects 13 | neighborly.events 14 | neighborly.helpers 15 | neighborly.plugins 16 | neighborly.preconditions 17 | 18 | Submodules 19 | ---------- 20 | 21 | neighborly.config module 22 | ------------------------ 23 | 24 | .. automodule:: neighborly.config 25 | :members: 26 | :undoc-members: 27 | :show-inheritance: 28 | 29 | neighborly.data\_analysis module 30 | -------------------------------- 31 | 32 | .. automodule:: neighborly.data_analysis 33 | :members: 34 | :undoc-members: 35 | :show-inheritance: 36 | 37 | neighborly.data\_collection module 38 | ---------------------------------- 39 | 40 | .. automodule:: neighborly.data_collection 41 | :members: 42 | :undoc-members: 43 | :show-inheritance: 44 | 45 | neighborly.datetime module 46 | -------------------------- 47 | 48 | .. automodule:: neighborly.datetime 49 | :members: 50 | :undoc-members: 51 | :show-inheritance: 52 | 53 | neighborly.ecs module 54 | --------------------- 55 | 56 | .. automodule:: neighborly.ecs 57 | :members: 58 | :undoc-members: 59 | :show-inheritance: 60 | 61 | neighborly.inspection module 62 | ---------------------------- 63 | 64 | .. automodule:: neighborly.inspection 65 | :members: 66 | :undoc-members: 67 | :show-inheritance: 68 | 69 | neighborly.libraries module 70 | --------------------------- 71 | 72 | .. automodule:: neighborly.libraries 73 | :members: 74 | :undoc-members: 75 | :show-inheritance: 76 | 77 | neighborly.life\_event module 78 | ----------------------------- 79 | 80 | .. automodule:: neighborly.life_event 81 | :members: 82 | :undoc-members: 83 | :show-inheritance: 84 | 85 | neighborly.loaders module 86 | ------------------------- 87 | 88 | .. automodule:: neighborly.loaders 89 | :members: 90 | :undoc-members: 91 | :show-inheritance: 92 | 93 | neighborly.simulation module 94 | ---------------------------- 95 | 96 | .. automodule:: neighborly.simulation 97 | :members: 98 | :undoc-members: 99 | :show-inheritance: 100 | 101 | neighborly.systems module 102 | ------------------------- 103 | 104 | .. automodule:: neighborly.systems 105 | :members: 106 | :undoc-members: 107 | :show-inheritance: 108 | 109 | neighborly.tracery module 110 | ------------------------- 111 | 112 | .. automodule:: neighborly.tracery 113 | :members: 114 | :undoc-members: 115 | :show-inheritance: 116 | 117 | Module contents 118 | --------------- 119 | 120 | .. automodule:: neighborly 121 | :members: 122 | :undoc-members: 123 | :show-inheritance: 124 | -------------------------------------------------------------------------------- /tests/test_stats.py: -------------------------------------------------------------------------------- 1 | """Stat System Unit Tests. 2 | 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | import pathlib 8 | 9 | from neighborly.components.stats import Stat 10 | from neighborly.helpers.character import create_character 11 | from neighborly.helpers.stats import add_stat, get_stat, has_stat, remove_stat 12 | from neighborly.loaders import load_characters, load_skills 13 | from neighborly.plugins import default_traits 14 | from neighborly.simulation import Simulation 15 | 16 | _TEST_DATA_DIR = pathlib.Path(__file__).parent / "data" 17 | 18 | 19 | def test_has_stat() -> None: 20 | """Test checking for stats.""" 21 | sim = Simulation() 22 | 23 | load_characters(sim, _TEST_DATA_DIR / "characters.json") 24 | load_skills(sim, _TEST_DATA_DIR / "skills.json") 25 | 26 | default_traits.load_plugin(sim) 27 | 28 | sim.initialize() 29 | 30 | character = create_character(sim.world, "farmer") 31 | 32 | assert has_stat(character, "hunger") is False 33 | 34 | assert has_stat(character, "health") is True 35 | 36 | 37 | def test_get_stat() -> None: 38 | """Test stat retrieval.""" 39 | sim = Simulation() 40 | 41 | load_characters(sim, _TEST_DATA_DIR / "characters.json") 42 | load_skills(sim, _TEST_DATA_DIR / "skills.json") 43 | 44 | default_traits.load_plugin(sim) 45 | 46 | sim.initialize() 47 | 48 | character = create_character(sim.world, "farmer") 49 | 50 | health = get_stat(character, "health") 51 | health.base_value = 10 52 | 53 | assert health.base_value == 10 54 | 55 | health.base_value += 100 56 | 57 | assert health.base_value == 110 58 | assert health.value == 110 59 | 60 | 61 | def test_add_stat() -> None: 62 | """Test stat addition.""" 63 | 64 | sim = Simulation() 65 | 66 | load_characters(sim, _TEST_DATA_DIR / "characters.json") 67 | load_skills(sim, _TEST_DATA_DIR / "skills.json") 68 | 69 | default_traits.load_plugin(sim) 70 | 71 | sim.initialize() 72 | 73 | character = create_character(sim.world, "farmer") 74 | 75 | hunger = add_stat(character, "hunger", Stat(base_value=100, bounds=(0, 255))) 76 | 77 | assert hunger.base_value == 100 78 | 79 | 80 | def test_remove_stat() -> None: 81 | """Test removing stats.""" 82 | 83 | sim = Simulation() 84 | 85 | load_characters(sim, _TEST_DATA_DIR / "characters.json") 86 | load_skills(sim, _TEST_DATA_DIR / "skills.json") 87 | 88 | default_traits.load_plugin(sim) 89 | 90 | sim.initialize() 91 | 92 | character = create_character(sim.world, "farmer") 93 | 94 | add_stat(character, "hunger", Stat(base_value=0, bounds=(0, 255))) 95 | 96 | assert has_stat(character, "hunger") 97 | 98 | remove_stat(character, "hunger") 99 | 100 | assert has_stat(character, "hunger") is False 101 | -------------------------------------------------------------------------------- /docs/source/residences.rst: -------------------------------------------------------------------------------- 1 | .. _residences: 2 | 3 | Residential Buildings 4 | ===================== 5 | 6 | While Neighborly, does not model characters exact locations, we do model where they live. This is an important part of defining who a character is. There is a big difference in a character living within a small apartment in the slums versus a character living in a fancy penthouse apartment in the "Affluent" district. 7 | 8 | Residential buildings control the max population size of a settlement as new characters only spawn when there is available space to live. Each residential building has one or more residential units. You can think of a residential unit a slot where an individual or family might live. Houses have one residential unit, as they usually only house one family, while apartment buildings have multiple units because they are multifamily housing. 9 | 10 | As with other content, residence definitions should be done in a JSON file(s). Below are a few examples of residential building definitions. 11 | 12 | Defining new residential building types 13 | --------------------------------------- 14 | 15 | - ``display_name``: A regular text name of the residential building. 16 | - ``residential_units``: The number of units within the building. 17 | - ``spawn_frequency``: (A whole number) Relative frequency of this residence building type spawning relative to other residence definitions. 18 | 19 | .. code-block:: json 20 | 21 | { 22 | "house": { 23 | "display_name": "House", 24 | "residential_units": 1, 25 | "spawn_frequency": 3 26 | }, 27 | "mansion": { 28 | "display_name": "Mansion", 29 | "residential_units": 1, 30 | "spawn_frequency": 1 31 | }, 32 | "small_apartment_building": { 33 | "display_name": "Small Apartment Building", 34 | "residential_units": 4, 35 | "spawn_frequency": 3, 36 | "required_population": 0 37 | }, 38 | "medium_apartment_building": { 39 | "display_name": "Medium Apartment Building", 40 | "residential_units": 6, 41 | "spawn_frequency": 1, 42 | "required_population": 20 43 | }, 44 | "large_apartment_building": { 45 | "display_name": "Large Apartment Building", 46 | "residential_units": 10, 47 | "spawn_frequency": 1, 48 | "required_population": 30 49 | } 50 | } 51 | 52 | 53 | Loading definitions 54 | ------------------- 55 | 56 | Neighborly provides the ``neighborly.loaders.load_residences(sim, "path/to/file")`` function to help users load their residential building definitions fom JSON. This function handles reading in the data and registering it with the internal libraries. 57 | 58 | .. code-block:: python 59 | 60 | from neighborly.simulation import Simulation 61 | from neighborly.loaders import load_residences 62 | 63 | sim = Simulation() 64 | 65 | load_residences(sim, "path/to/file") 66 | -------------------------------------------------------------------------------- /tests/data/districts.json: -------------------------------------------------------------------------------- 1 | { 2 | "farming_district": { 3 | "name": "Farming District", 4 | "description": "Where the animals and farmers are.", 5 | "business_types": [ 6 | { 7 | "with_id": "dairy_farm" 8 | }, 9 | { 10 | "with_id": "farm", 11 | "max_instances": 2, 12 | "spawn_frequency": 2 13 | } 14 | ], 15 | "character_types": [ 16 | { 17 | "with_id": "farmer" 18 | } 19 | ], 20 | "residence_types": [ 21 | { 22 | "with_id": "house" 23 | } 24 | ], 25 | "residential_slots": 3, 26 | "business_slots": 3, 27 | "tags": [ 28 | "lazy", 29 | "rural", 30 | "urban", 31 | "suburban", 32 | "hot" 33 | ] 34 | }, 35 | "market_district": { 36 | "name": "Market District", 37 | "description": "Where people are always selling things.", 38 | "business_types": [ 39 | { 40 | "with_id": "shop" 41 | }, 42 | { 43 | "with_id": "cafe" 44 | } 45 | ], 46 | "character_types": [ 47 | { 48 | "with_id": "merchant" 49 | } 50 | ], 51 | "residence_types": [ 52 | { 53 | "with_id": "small_apartment_building" 54 | }, 55 | { 56 | "with_id": "medium_apartment_building" 57 | }, 58 | { 59 | "with_id": "large_apartment_building" 60 | } 61 | ], 62 | "business_slots": 5, 63 | "residential_slots": 2, 64 | "tags": [ 65 | "lazy", 66 | "rural", 67 | "urban", 68 | "suburban", 69 | "hot", 70 | "heat" 71 | ] 72 | }, 73 | "entertainment_district": { 74 | "name": "Entertainment District", 75 | "description": "Where characters go to have 'fun'.", 76 | "residence_types": [ 77 | { 78 | "with_id": "small_apartment_building" 79 | } 80 | ], 81 | "business_types": [ 82 | { 83 | "with_id": "theatre" 84 | }, 85 | { 86 | "with_id": "bar" 87 | } 88 | ], 89 | "business_slots": 3, 90 | "tags": [] 91 | }, 92 | "upper_class_residences": { 93 | "name": "Upper Class Residences", 94 | "description": "Where the nobility live.", 95 | "residence_types": [ 96 | { 97 | "with_id": "mansion" 98 | } 99 | ], 100 | "character_types": [ 101 | { 102 | "with_id": "nobility" 103 | } 104 | ], 105 | "business_slots": 3, 106 | "residential_slots": 3, 107 | "tags": [] 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /tests/test_simulation.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | 3 | from neighborly.components.settlement import Settlement 4 | from neighborly.config import SimulationConfig 5 | from neighborly.loaders import ( 6 | load_businesses, 7 | load_characters, 8 | load_districts, 9 | load_job_roles, 10 | load_residences, 11 | load_settlements, 12 | load_skills, 13 | ) 14 | from neighborly.plugins import ( 15 | default_character_names, 16 | default_settlement_names, 17 | default_traits, 18 | ) 19 | from neighborly.simulation import Simulation 20 | 21 | _TEST_DATA_DIR = pathlib.Path(__file__).parent / "data" 22 | 23 | 24 | def test_simulation_step() -> None: 25 | sim = Simulation() 26 | 27 | assert sim.date.month == 1 28 | assert sim.date.year == 1 29 | assert sim.date.total_months == 0 30 | 31 | sim.step() 32 | 33 | assert sim.date.month == 2 34 | assert sim.date.year == 1 35 | assert sim.date.total_months == 1 36 | 37 | # advance by many months 38 | for _ in range(13): 39 | sim.step() 40 | 41 | assert sim.date.month == 3 42 | assert sim.date.year == 2 43 | assert sim.date.total_months == 14 44 | 45 | 46 | def test_simulation_initialization() -> None: 47 | sim = Simulation(SimulationConfig(settlement="basic_settlement")) 48 | 49 | load_districts(sim, _TEST_DATA_DIR / "districts.json") 50 | load_settlements(sim, _TEST_DATA_DIR / "settlements.json") 51 | load_businesses(sim, _TEST_DATA_DIR / "businesses.json") 52 | load_characters(sim, _TEST_DATA_DIR / "characters.json") 53 | load_residences(sim, _TEST_DATA_DIR / "residences.json") 54 | load_job_roles(sim, _TEST_DATA_DIR / "job_roles.json") 55 | load_skills(sim, _TEST_DATA_DIR / "skills.json") 56 | 57 | # Settlements are created at the beginning of the first time step 58 | sim.initialize() 59 | 60 | settlements = sim.world.get_component(Settlement) 61 | 62 | assert len(settlements) == 1 63 | 64 | assert settlements[0][1].gameobject.metadata["definition_id"] == "basic_settlement" 65 | 66 | 67 | def test_simulation_to_json() -> None: 68 | sim = Simulation(SimulationConfig(settlement="basic_settlement")) 69 | 70 | load_districts(sim, _TEST_DATA_DIR / "districts.json") 71 | load_settlements(sim, _TEST_DATA_DIR / "settlements.json") 72 | load_businesses(sim, _TEST_DATA_DIR / "businesses.json") 73 | load_characters(sim, _TEST_DATA_DIR / "characters.json") 74 | load_residences(sim, _TEST_DATA_DIR / "residences.json") 75 | load_job_roles(sim, _TEST_DATA_DIR / "job_roles.json") 76 | load_skills(sim, _TEST_DATA_DIR / "skills.json") 77 | 78 | default_traits.load_plugin(sim) 79 | default_character_names.load_plugin(sim) 80 | default_settlement_names.load_plugin(sim) 81 | 82 | # Run the simulation for one year (12 months) of simulated time 83 | for _ in range(12): 84 | sim.step() 85 | 86 | output_file = pathlib.Path(__file__).parent / "output" / "test_output.json" 87 | output_file.parent.mkdir(exist_ok=True, parents=True) 88 | with open(output_file, "w", encoding="utf-8") as fp: 89 | fp.write(sim.to_json(2)) 90 | 91 | assert True 92 | -------------------------------------------------------------------------------- /tests/test_datetime.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import datetime 3 | 4 | import pytest 5 | 6 | from neighborly.datetime import SimDate 7 | 8 | 9 | def test__copy__(): 10 | d0 = SimDate() 11 | d1 = copy.copy(d0) 12 | 13 | assert id(d0) != id(d1) 14 | assert d0 == d1 15 | 16 | 17 | def test__deepcopy__(): 18 | d0 = SimDate() 19 | d1 = copy.deepcopy(d0) 20 | 21 | assert id(d0) != id(d1) 22 | assert d0 == d1 23 | 24 | 25 | def test__le__(): 26 | assert (SimDate() <= SimDate()) is True 27 | assert (SimDate() <= SimDate(year=2000)) is True 28 | assert (SimDate(year=3000) <= SimDate()) is False 29 | 30 | 31 | def test__lt__(): 32 | assert (SimDate() < SimDate()) is False 33 | assert (SimDate() < SimDate(year=2000)) is True 34 | assert (SimDate(year=3000) < SimDate()) is False 35 | 36 | 37 | def test__ge__(): 38 | assert (SimDate() >= SimDate()) is True 39 | assert (SimDate() >= SimDate(year=2000)) is False 40 | assert (SimDate(year=3000) >= SimDate()) is True 41 | 42 | 43 | def test__gt__(): 44 | assert (SimDate() > SimDate()) is False 45 | assert (SimDate() > SimDate(year=2000)) is False 46 | assert (SimDate(year=3000) > SimDate()) is True 47 | 48 | 49 | def test__eq__(): 50 | assert (SimDate() == SimDate()) is True 51 | assert (SimDate() == SimDate(year=2000)) is False 52 | assert (SimDate(year=3000) == SimDate()) is False 53 | assert (SimDate(year=3000) == SimDate(year=3000)) is True 54 | assert SimDate(1, 4) == SimDate(1, 4) 55 | assert SimDate(2023, 6) == SimDate(2023, 6) 56 | 57 | 58 | def test_to_iso_str(): 59 | date = SimDate(2022, 6) 60 | assert date.to_iso_str() == "2022-06" 61 | 62 | date = SimDate(2022, 9) 63 | assert date.to_iso_str() == "2022-09" 64 | 65 | 66 | def test_increment_month(): 67 | date = SimDate(3, 1) 68 | 69 | assert date.month == 1 70 | assert date.year == 3 71 | assert date.total_months == 24 72 | 73 | date.increment_month() 74 | 75 | assert date.month == 2 76 | assert date.year == 3 77 | assert date.total_months == 25 78 | 79 | # advance by many months 80 | for _ in range(13): 81 | date.increment_month() 82 | 83 | assert date.month == 3 84 | assert date.year == 4 85 | assert date.total_months == 38 86 | 87 | 88 | def test__init__(): 89 | d = SimDate() 90 | assert d.month == 1 91 | assert d.year == 1 92 | assert d.total_months == 0 93 | 94 | d = SimDate(2001, 7) 95 | assert d.month == 7 96 | assert d.year == 2001 97 | assert d.total_months == 24006 98 | 99 | with pytest.raises(ValueError): 100 | # Year cannot be less than 1 101 | SimDate(-1, 10) 102 | 103 | with pytest.raises(ValueError): 104 | # Month cannot be less than 1 105 | SimDate(2023, -10) 106 | 107 | with pytest.raises(ValueError): 108 | # Month cannot be greater than 12 109 | SimDate(2023, 13) 110 | 111 | 112 | def test_datetime_strptime_compat() -> None: 113 | """Test that SimDate.to_iso_str is compatible with datetime.strptime""" 114 | 115 | date = SimDate(2023, 6) 116 | parsed_date = datetime.datetime.strptime(str(date), "%Y-%m") 117 | 118 | assert parsed_date == datetime.datetime(2023, 6, 1) 119 | -------------------------------------------------------------------------------- /samples/sample.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """Sample Simulation for Terminal. 4 | 5 | """ 6 | 7 | import argparse 8 | import pathlib 9 | import random 10 | 11 | from neighborly.config import LoggingConfig, SimulationConfig 12 | from neighborly.loaders import ( 13 | load_businesses, 14 | load_characters, 15 | load_districts, 16 | load_job_roles, 17 | load_residences, 18 | load_settlements, 19 | load_skills, 20 | ) 21 | from neighborly.plugins import ( 22 | default_character_names, 23 | default_events, 24 | default_settlement_names, 25 | default_traits, 26 | ) 27 | from neighborly.simulation import Simulation 28 | 29 | TEST_DATA_DIR = pathlib.Path(__file__).parent.parent / "tests" / "data" 30 | 31 | 32 | def get_args() -> argparse.Namespace: 33 | """Configure CLI argument parser and parse args. 34 | 35 | Returns 36 | ------- 37 | argparse.Namespace 38 | parsed CLI arguments. 39 | """ 40 | 41 | parser = argparse.ArgumentParser("Neighborly Sample Simulation.") 42 | 43 | parser.add_argument( 44 | "-s", 45 | "--seed", 46 | default=str(random.randint(0, 9999999)), 47 | type=str, 48 | help="The world seed.", 49 | ) 50 | 51 | parser.add_argument( 52 | "-y", 53 | "--years", 54 | default=50, 55 | type=int, 56 | help="The number of years to simulate.", 57 | ) 58 | 59 | parser.add_argument( 60 | "-o", 61 | "--output", 62 | type=pathlib.Path, 63 | help="Specify path to write generated world data.", 64 | ) 65 | 66 | return parser.parse_args() 67 | 68 | 69 | def main() -> Simulation: 70 | """Main program entry point.""" 71 | args = get_args() 72 | 73 | sim = Simulation( 74 | SimulationConfig( 75 | seed=args.seed, 76 | settlement="basic_settlement", 77 | logging=LoggingConfig(logging_enabled=True), 78 | ) 79 | ) 80 | 81 | load_districts(sim, TEST_DATA_DIR / "districts.json") 82 | load_settlements(sim, TEST_DATA_DIR / "settlements.json") 83 | load_businesses(sim, TEST_DATA_DIR / "businesses.json") 84 | load_characters(sim, TEST_DATA_DIR / "characters.json") 85 | load_residences(sim, TEST_DATA_DIR / "residences.json") 86 | load_job_roles(sim, TEST_DATA_DIR / "job_roles.json") 87 | load_skills(sim, TEST_DATA_DIR / "skills.json") 88 | 89 | default_events.load_plugin(sim) 90 | default_traits.load_plugin(sim) 91 | default_character_names.load_plugin(sim) 92 | default_settlement_names.load_plugin(sim) 93 | 94 | total_time_steps: int = args.years * 12 95 | 96 | for _ in range(total_time_steps): 97 | sim.step() 98 | 99 | if args.output: 100 | output_path: pathlib.Path = ( 101 | args.output 102 | if args.output 103 | else pathlib.Path(__file__).parent / f"neighborly_{sim.config.seed}.json" 104 | ) 105 | 106 | with open(output_path, "w", encoding="utf-8") as file: 107 | file.write(sim.to_json()) 108 | 109 | print(f"Simulation output written to: {output_path}") 110 | 111 | return sim 112 | 113 | 114 | if __name__ == "__main__": 115 | from neighborly.inspection import * 116 | 117 | sim = main() 118 | -------------------------------------------------------------------------------- /src/neighborly/helpers/settlement.py: -------------------------------------------------------------------------------- 1 | """Helper functions for managing Settlements. 2 | 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | from typing import Optional 8 | 9 | from neighborly.components.settlement import Settlement 10 | from neighborly.defs.base_types import ( 11 | DistrictDef, 12 | DistrictGenOptions, 13 | SettlementDef, 14 | SettlementGenOptions, 15 | ) 16 | from neighborly.ecs import GameObject, World 17 | from neighborly.libraries import DistrictLibrary, SettlementLibrary 18 | 19 | 20 | def create_settlement( 21 | world: World, definition_id: str, options: Optional[SettlementGenOptions] = None 22 | ) -> GameObject: 23 | """Create a new settlement. 24 | 25 | Parameters 26 | ---------- 27 | world 28 | The world instance to spawn the settlement in. 29 | definition_id 30 | The ID of the definition to instantiate. 31 | options 32 | Generation options. 33 | 34 | Returns 35 | ------- 36 | GameObject 37 | The settlement. 38 | """ 39 | library = world.resource_manager.get_resource(SettlementLibrary) 40 | 41 | settlement_def = library.get_definition(definition_id) 42 | 43 | options = options if options else SettlementGenOptions() 44 | 45 | settlement = settlement_def.instantiate(world, options) 46 | 47 | return settlement 48 | 49 | 50 | def create_district( 51 | world: World, 52 | settlement: GameObject, 53 | definition_id: str, 54 | options: Optional[DistrictGenOptions] = None, 55 | ) -> GameObject: 56 | """Create a new district GameObject. 57 | 58 | Parameters 59 | ---------- 60 | world 61 | The world instance spawn the district in. 62 | settlement 63 | The settlement that owns district belongs to. 64 | definition_id 65 | The ID of the definition to instantiate. 66 | options 67 | Generation options. 68 | 69 | Returns 70 | ------- 71 | GameObject 72 | The district. 73 | """ 74 | library = world.resource_manager.get_resource(DistrictLibrary) 75 | 76 | district_def = library.get_definition(definition_id) 77 | 78 | options = options if options else DistrictGenOptions() 79 | 80 | district = district_def.instantiate(world, settlement, options) 81 | 82 | settlement.get_component(Settlement).add_district(district) 83 | 84 | return district 85 | 86 | 87 | def register_settlement_def(world: World, definition: SettlementDef) -> None: 88 | """Add a new settlement definition for the SettlementLibrary. 89 | 90 | Parameters 91 | ---------- 92 | world 93 | The world instance containing the settlement library. 94 | definition 95 | The definition to add. 96 | """ 97 | world.resource_manager.get_resource(SettlementLibrary).add_definition(definition) 98 | 99 | 100 | def register_district_def(world: World, definition: DistrictDef) -> None: 101 | """Add a new district definition for the DistrictLibrary. 102 | 103 | Parameters 104 | ---------- 105 | world 106 | The world instance containing the district library. 107 | definition 108 | The definition to add. 109 | """ 110 | world.resource_manager.get_resource(DistrictLibrary).add_definition(definition) 111 | world.resource_manager.get_resource(DistrictLibrary).add_definition(definition) 112 | -------------------------------------------------------------------------------- /tests/test_relationship.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=redefined-outer-name 2 | """Test Relationship Components, Systems, and Helper Functions. 3 | 4 | """ 5 | 6 | import pathlib 7 | 8 | import pytest 9 | 10 | from neighborly.helpers.character import create_character 11 | from neighborly.helpers.relationship import ( 12 | add_relationship, 13 | get_relationship, 14 | has_relationship, 15 | ) 16 | from neighborly.helpers.stats import get_stat 17 | from neighborly.helpers.traits import add_trait, remove_trait 18 | from neighborly.loaders import ( 19 | load_businesses, 20 | load_characters, 21 | load_districts, 22 | load_job_roles, 23 | load_residences, 24 | load_settlements, 25 | load_skills, 26 | ) 27 | from neighborly.plugins import default_traits 28 | from neighborly.simulation import Simulation 29 | 30 | _TEST_DATA_DIR = pathlib.Path(__file__).parent / "data" 31 | 32 | 33 | @pytest.fixture 34 | def sim() -> Simulation: 35 | """Create sample simulation to use for test cases""" 36 | simulation = Simulation() 37 | 38 | load_districts(simulation, _TEST_DATA_DIR / "districts.json") 39 | load_settlements(simulation, _TEST_DATA_DIR / "settlements.json") 40 | load_businesses(simulation, _TEST_DATA_DIR / "businesses.json") 41 | load_characters(simulation, _TEST_DATA_DIR / "characters.json") 42 | load_residences(simulation, _TEST_DATA_DIR / "residences.json") 43 | load_job_roles(simulation, _TEST_DATA_DIR / "job_roles.json") 44 | load_skills(simulation, _TEST_DATA_DIR / "skills.json") 45 | default_traits.load_plugin(simulation) 46 | 47 | simulation.initialize() 48 | 49 | return simulation 50 | 51 | 52 | def test_get_relationship(sim: Simulation) -> None: 53 | """Test that get_relationship creates new relationship if one does not exist.""" 54 | 55 | a = create_character(sim.world, "person") 56 | b = create_character(sim.world, "person") 57 | 58 | assert has_relationship(a, b) is False 59 | assert has_relationship(b, a) is False 60 | 61 | a_to_b = get_relationship(a, b) 62 | 63 | assert has_relationship(a, b) is True 64 | assert has_relationship(b, a) is False 65 | 66 | b_to_a = get_relationship(b, a) 67 | 68 | assert has_relationship(a, b) is True 69 | assert has_relationship(b, a) is True 70 | 71 | assert id(a_to_b) != id(b_to_a) 72 | 73 | a_to_b_again = get_relationship(a, b) 74 | 75 | assert id(a_to_b) == id(a_to_b_again) 76 | 77 | 78 | def test_add_relationship(sim: Simulation) -> None: 79 | """Test that adding a relationship create a new relationship or returns the old""" 80 | 81 | a = create_character(sim.world, "person") 82 | b = create_character(sim.world, "person") 83 | 84 | assert has_relationship(a, b) is False 85 | assert has_relationship(b, a) is False 86 | 87 | add_relationship(a, b) 88 | 89 | assert has_relationship(a, b) is True 90 | assert has_relationship(b, a) is False 91 | 92 | 93 | def test_trait_with_social_rules(sim: Simulation) -> None: 94 | """Test traits that apply social rules""" 95 | 96 | farmer = create_character(sim.world, "farmer") 97 | merchant = create_character(sim.world, "merchant") 98 | noble = create_character(sim.world, "nobility") 99 | 100 | rel_to_noble = add_relationship(farmer, noble) 101 | 102 | assert get_stat(rel_to_noble, "reputation").value == 0 103 | 104 | add_trait(farmer, "gullible") 105 | 106 | assert get_stat(rel_to_noble, "reputation").value == 5 107 | 108 | rel = add_relationship(farmer, merchant) 109 | 110 | assert get_stat(rel, "reputation").value == 5 111 | 112 | remove_trait(farmer, "gullible") 113 | 114 | assert get_stat(rel, "reputation").value == 0 115 | assert get_stat(rel_to_noble, "reputation").value == 0 116 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "neighborly" 7 | description = "A narrative-focused agent-based settlement simulation framework." 8 | authors = [{ name = "Shi Johnson-Bey", email = "shijbey@gmail.com" }] 9 | readme = "README.md" 10 | dynamic = ["version"] 11 | requires-python = ">=3.8" 12 | keywords = [ 13 | "social simulation", 14 | "games", 15 | "simulation", 16 | "artificial intelligence", 17 | "agent-based modeling", 18 | "multiagent systems", 19 | "emergent narrative", 20 | "narrative generation", 21 | "interactive storytelling", 22 | "settlement simulation", 23 | ] 24 | license = { file = "LICENSE.md" } 25 | classifiers = [ 26 | "Intended Audience :: Developers", 27 | "Intended Audience :: Science/Research", 28 | "License :: OSI Approved :: MIT License", 29 | "Operating System :: OS Independent", 30 | "Programming Language :: Python :: 3 :: Only", 31 | "Programming Language :: Python :: 3.8", 32 | "Programming Language :: Python :: 3.9", 33 | "Programming Language :: Python :: 3.10", 34 | "Programming Language :: Python :: 3.11", 35 | "Programming Language :: Python :: 3.12", 36 | "Topic :: Games/Entertainment :: Simulation", 37 | "Topic :: Scientific/Engineering", 38 | "Topic :: Scientific/Engineering :: Artificial Intelligence", 39 | "Topic :: Scientific/Engineering :: Artificial Life", 40 | "Topic :: Sociology", 41 | "Topic :: Software Development :: Libraries", 42 | "Topic :: Software Development :: Libraries :: Application Frameworks", 43 | "Typing :: Typed", 44 | ] 45 | dependencies = [ 46 | "esper==2.*", 47 | "ordered-set==4.*", 48 | "tracery3==1.*", 49 | "polars==0.19.*", 50 | "tabulate==0.9.*", 51 | "PyYAML==6.0.*", 52 | "tqdm==4.*", 53 | "pydantic==2.*", 54 | ] 55 | 56 | [project.optional-dependencies] 57 | samples = ["jupyterlab", "matplotlib", "ipywidgets"] 58 | development = [ 59 | "isort", 60 | "black", 61 | "black[d]", 62 | "black[jupyter]", 63 | "build", 64 | "pytest", 65 | "pytest-cov", 66 | "sphinx", 67 | "sphinx_rtd_theme", 68 | ] 69 | 70 | [project.urls] 71 | "Homepage" = "https://github.com/ShiJbey/neighborly" 72 | "Bug Tracker" = "https://github.com/ShiJbey/neighborly/issues" 73 | "Repository" = "https://github.com/ShiJBey/neighborly.git" 74 | "Changelog" = "https://github.com/ShiJbey/neighborly/blob/main/CHANGELOG.md" 75 | "Documentation" = "https://neighborly.readthedocs.io/en/latest/" 76 | 77 | [tool.setuptools.dynamic] 78 | version = { attr = "neighborly.__version__.VERSION" } 79 | 80 | [tool.setuptools.packages.find] 81 | where = ["src"] 82 | 83 | [tool.black] 84 | line-length = 88 85 | 86 | [tool.isort] 87 | profile = "black" 88 | default_section = "THIRDPARTY" 89 | known_first_party = "neighborly" 90 | src_paths = ["src/neighborly", "tests", "samples"] 91 | 92 | [tool.pytest.ini_options] 93 | minversion = "6.0" 94 | testpaths = ["tests"] 95 | 96 | [tool.pyright] 97 | reportMissingTypeStubs = "none" 98 | 99 | 100 | [tool.pylint.design] 101 | # Minimum number of public methods for a class (see R0903). 102 | min-public-methods = 0 103 | 104 | # Maximum number of public methods for a class (see R0904). 105 | max-public-methods = 25 106 | 107 | # Maximum number of attributes for a class (see R0902). 108 | max-attributes = 10 109 | 110 | # Maximum number of arguments for function / method. 111 | max-args = 8 112 | 113 | [tool.pylint.basic] 114 | # Allow us to use "_T1, _T2, _T3, ..." as typevar names 115 | typevar-rgx = "^_{0,2}(?!T[A-Z])(?:[A-Z]+|(?:[A-Z]+[a-z]+)+T?(? None: 35 | sim = Simulation() 36 | load_residences(sim, _TEST_DATA_DIR / "residences.json") 37 | library = sim.world.resource_manager.get_resource(ResidenceLibrary) 38 | 39 | residence_def = library.get_definition("house") 40 | 41 | assert residence_def.definition_id == "house" 42 | 43 | 44 | def test_load_settlements() -> None: 45 | sim = Simulation() 46 | load_settlements(sim, _TEST_DATA_DIR / "settlements.json") 47 | library = sim.world.resource_manager.get_resource(SettlementLibrary) 48 | 49 | settlement_def = library.get_definition("basic_settlement") 50 | 51 | assert settlement_def.definition_id == "basic_settlement" 52 | 53 | 54 | def test_load_business() -> None: 55 | sim = Simulation() 56 | load_businesses(sim, _TEST_DATA_DIR / "businesses.json") 57 | library = sim.world.resource_manager.get_resource(BusinessLibrary) 58 | 59 | business_def = library.get_definition("cafe") 60 | 61 | assert business_def.definition_id == "cafe" 62 | 63 | 64 | def test_load_characters() -> None: 65 | sim = Simulation() 66 | load_characters(sim, _TEST_DATA_DIR / "characters.json") 67 | library = sim.world.resource_manager.get_resource(CharacterLibrary) 68 | 69 | character_def = library.get_definition("person") 70 | 71 | assert character_def.definition_id == "person" 72 | 73 | 74 | def test_load_districts() -> None: 75 | sim = Simulation() 76 | load_districts(sim, _TEST_DATA_DIR / "districts.json") 77 | library = sim.world.resource_manager.get_resource(DistrictLibrary) 78 | 79 | district_def = library.get_definition("market_district") 80 | 81 | assert district_def.definition_id == "market_district" 82 | 83 | 84 | def test_load_traits() -> None: 85 | sim = Simulation() 86 | load_traits(sim, _TEST_DATA_DIR / "traits.json") 87 | library = sim.world.resource_manager.get_resource(TraitLibrary) 88 | 89 | trait_def = library.get_definition("flirtatious") 90 | 91 | assert trait_def.definition_id == "flirtatious" 92 | 93 | 94 | def test_load_job_roles() -> None: 95 | sim = Simulation() 96 | load_job_roles(sim, _TEST_DATA_DIR / "job_roles.json") 97 | library = sim.world.resource_manager.get_resource(JobRoleLibrary) 98 | 99 | trait_def = library.get_definition("blacksmith") 100 | 101 | assert trait_def.definition_id == "blacksmith" 102 | 103 | 104 | def test_load_names() -> None: 105 | sim = Simulation() 106 | 107 | load_tracery(sim, _TEST_DATA_DIR / "sample.tracery.json") 108 | 109 | tracery = sim.world.resource_manager.get_resource(Tracery) 110 | 111 | generated_name = tracery.generate("#simpsons_name#") 112 | 113 | assert generated_name in {"Homer", "Marge", "Maggie", "Lisa", "Bart"} 114 | 115 | 116 | def test_load_skills() -> None: 117 | """Test loading skill definitions from a data file.""" 118 | 119 | sim = Simulation() 120 | load_skills(sim, _TEST_DATA_DIR / "skills.json") 121 | library = sim.world.resource_manager.get_resource(SkillLibrary) 122 | 123 | definition = library.get_definition("blacksmithing") 124 | 125 | assert definition.definition_id == "blacksmithing" 126 | -------------------------------------------------------------------------------- /docs/source/location-preferences.rst: -------------------------------------------------------------------------------- 1 | .. _location-preferences: 2 | 3 | Location Preferences 4 | ==================== 5 | 6 | Neighborly does not model the exact location of characters, businesses, and residences within the simulated world. Since the simulation progresses in single-month steps, it instead calculates and records the locations that character is most likely to frequent during a given month. 7 | 8 | The aside from places like a character's home and work place, other locations are selected to be frequented by a character based on a character's location preferences. Location preferences are rules that a character has that provide numeric scores for how much or how little a character would like to frequent that location. For example, a ``shopaholic`` trait might give a character a location preference for businesses with ``department_store`` or ``shop`` traits. 9 | 10 | Location preferences can be added using traits and specified within the ``effects`` section of a trait definition. See the example below. Here we define the ``shopaholic`` trait and add a new location preference using the ``AddLocationPreference`` effect. This effect has two parameters, preconditions and a probability. If the location meets the preconditions, we add the probability value to it's score. The a locations final score is the average of all applied rules including a base score of 0.5. Not able exceptions are when a rule has a probability of 0.0 or < 0. A score of 0 will result in a final score of zero, regardless of the average. This helps to enforce that some characters will never be at a location. And if a rule returns a value less than zero, then the consideration is ignored and not included in the averaging calculations. 11 | 12 | .. code-block:: json 13 | 14 | { 15 | "shopaholic": { 16 | "display_name": "Shopaholic", 17 | "effects": [ 18 | { 19 | "type": "AddLocationPreference", 20 | "preconditions": [ 21 | { 22 | "type": "HasTrait", 23 | "trait": "department_store" 24 | }, 25 | ], 26 | "probability": 0.8 27 | } 28 | ] 29 | } 30 | } 31 | 32 | Working with frequented locations in Python 33 | ------------------------------------------- 34 | 35 | Users can access a character's list of frequented locations by accessing its ``FrequentedLocations`` component and they can access a locations collection of characters that frequent it by accessing the locations ``FrequentedBy`` component. 36 | 37 | If users wish to add, update, or remove a frequented location, please using the helper functions provided in the ``neighborly.helpers.location`` module. 38 | 39 | How are frequented locations updated during the simulation? 40 | ----------------------------------------------------------- 41 | 42 | Every timestep character consider new locations to frequent. By default, characters maintain a maximum of five frequented locations. They can lose locations when a business goes permanently out of business. The built-in ``UpdateFrequentedLocationsSystem`` handles all the logistics of ensuring characters maintain a fresh set of locations. 43 | 44 | How are frequented location used? 45 | --------------------------------- 46 | 47 | Neighborly uses frequented locations to sample what other characters are available to form relationships when characters meet new people outside of work or home. Ever timestep there is a probability that a character will start a new relationship with someone else who shares a common frequented location. This is all implemented within the built-in ``MeetNewPeopleSystem``. 48 | 49 | Why not have characters move based on routines? 50 | ----------------------------------------------- 51 | 52 | Moving characters between discrete locations slowed the runtime of the simulation as every character had to consult a schedule for where they needed to be. So, for performance reasons, this was cut from the simulation. 53 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. neighborly documentation master file, created by 2 | sphinx-quickstart on Sat Nov 4 22:34:51 2023. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to Neighborly's documentation! 7 | ====================================== 8 | 9 | Neighborly is an agent-based settlement simulation for emergent narrative storytelling and data analysis. It simulates generations of characters living within a village/town/city with a particular focus on their relationships and life events (starting a new job, falling in love, turning into a demon, etc.). It combines social simulation elements, like relationship tracking, with RPG elements, such as character stats and skills, to generate backstories about characters and their families. 10 | Neighborly simulates characters' traits, statuses, relationships, occupations, and life events and makes the entire simulated history available for data analysis and game development. 11 | 12 | Neighborly's was inspired by `Talk of the Town `_ and aims to be a more customizable and user-friendly alternative to support research or entertainment projects. It also draws inspiration from commercial simulation-driven emergent narrative games like *Caves of Qud*, *Dwarf Fortress*, *Crusader Kings*, *RimWorld*, and *WorldBox*. 13 | 14 | How to use these docs 15 | --------------------- 16 | 17 | This wiki explains the core building blocks of Neighborly and how to get started simulating your own procedurally generated settlements. If you're looking for a tutorial or would like to try Neighborly without downloading it, here is a `Google Colab notebook `_ that covers the basics of Neighborly. 18 | 19 | What if I find errors? 20 | ---------------------- 21 | 22 | If you notice any errors with sample code or typos within the docs, please file a GitHub issue stating the issue. We appreciate your help in making Neighborly an accessible tool for learning and experimentation. 23 | 24 | Installation 25 | ------------ 26 | 27 | Neighborly is available to install from PyPI. Please use the following command to install the latest release. 28 | 29 | .. code-block:: bash 30 | 31 | python3 -m pip install neighborly 32 | 33 | 34 | We recommend that users specify a specific Neighborly release in their ``pyproject.toml`` or ``requirements.txt`` files. For example, ``neighborly==2.*``. Neighborly's function and class interfaces may change drastically between releases, and this will prevent errors from appearing in your code if an updated version of Neighborly breaks something you rely on. 35 | 36 | Neighborly's core content types 37 | ------------------------------- 38 | 39 | Neighborly is a data-driven. So, user's need to feed it a decent amount of data to get diverse and interesting results. However, Neighborly makes it easy for people to start simulating with a small amount of data and gradually add more. Below are the main content types that users can define. 40 | 41 | - :ref:`settlements`: The overall place where characters live and start businesses. 42 | - :ref:`businesses`: Places where characters work. 43 | - :ref:`traits`: Represent characters' personalities, relationship statuses, faction affiliations, etc. 44 | - :ref:`relationships`: Track how characters feel about other characters. 45 | - :ref:`skills`: Skills that characters can cultivate during their lives 46 | - :ref:`residences`: Places where characters live 47 | - :ref:`characters`: The characters that make everything run 48 | - :ref:`life_events`: Events that implement character behaviors 49 | 50 | .. toctree:: 51 | :maxdepth: 2 52 | :caption: Contents: 53 | 54 | settlements 55 | characters 56 | businesses 57 | residences 58 | relationships 59 | traits 60 | skills 61 | effects-and-preconditions 62 | life_events 63 | location-preferences 64 | plugins 65 | ecs 66 | design-tips 67 | api/modules 68 | 69 | Indices and tables 70 | ================== 71 | 72 | * :ref:`genindex` 73 | * :ref:`modindex` 74 | * :ref:`search` 75 | -------------------------------------------------------------------------------- /src/neighborly/datetime.py: -------------------------------------------------------------------------------- 1 | """Simulation date representation. 2 | 3 | Implements a 12 month calendar 4 | 5 | """ 6 | 7 | from __future__ import annotations 8 | 9 | import copy 10 | from typing import Any 11 | 12 | MONTHS_PER_YEAR = 12 13 | """The number of months per calendar year.""" 14 | 15 | 16 | class SimDate: 17 | """Records the current date of the simulation counting in 1-month increments.""" 18 | 19 | __slots__ = "_month", "_year", "_total_months" 20 | 21 | _month: int 22 | """The current month""" 23 | 24 | _year: int 25 | """The current year""" 26 | 27 | _total_months: int 28 | """Total number of elapsed months""" 29 | 30 | def __init__(self, year: int = 1, month: int = 1) -> None: 31 | """ 32 | Parameters 33 | ---------- 34 | month 35 | The month of the year [1, 12], default 1 36 | year 37 | The current year >= 1, default 1 38 | """ 39 | if 1 <= month <= MONTHS_PER_YEAR: 40 | self._month = month - 1 41 | else: 42 | raise ValueError( 43 | f"Parameter 'month' must be between 1 and {MONTHS_PER_YEAR}" 44 | ) 45 | 46 | if year >= 1: 47 | self._year = year - 1 48 | else: 49 | raise ValueError("Parameter 'year' must be greater than or equal to 1.") 50 | 51 | self._total_months = self._month + (self._year * MONTHS_PER_YEAR) 52 | 53 | @property 54 | def month(self) -> int: 55 | """The current month of the year [1 - 12].""" 56 | return self._month + 1 57 | 58 | @property 59 | def year(self) -> int: 60 | """The current year.""" 61 | return self._year + 1 62 | 63 | @property 64 | def total_months(self) -> int: 65 | """Get the total number of elapsed months since month 1, year 1.""" 66 | return self._total_months 67 | 68 | def increment_month(self) -> None: 69 | """Increments the month by one.""" 70 | self._month += 1 71 | self._total_months += 1 72 | 73 | if self._month == MONTHS_PER_YEAR: 74 | self._month = 0 75 | self._year += 1 76 | 77 | def increment(self, months: int = 0, years: int = 0) -> None: 78 | """Increment the date by the given time.""" 79 | carry_years, current_month = divmod(self._month + months, MONTHS_PER_YEAR) 80 | self._month = current_month 81 | self._total_months += months + (MONTHS_PER_YEAR * years) 82 | self._year += carry_years + years 83 | 84 | def to_iso_str(self) -> str: 85 | """Create an ISO date string of format YYYY-MM. 86 | 87 | Returns 88 | ------- 89 | str 90 | The date string. 91 | """ 92 | return f"{self.year:04d}-{self.month:02d}" 93 | 94 | def copy(self) -> SimDate: 95 | """Create a copy of this date.""" 96 | return copy.copy(self) 97 | 98 | def __repr__(self) -> str: 99 | return f"{self.__class__.__name__}(month={self.month}, year={self.year})" 100 | 101 | def __copy__(self) -> SimDate: 102 | return SimDate(month=self.month, year=self.year) 103 | 104 | def __deepcopy__(self, memo: dict[str, Any]) -> SimDate: 105 | return SimDate(month=self.month, year=self.year) 106 | 107 | def __str__(self) -> str: 108 | return self.to_iso_str() 109 | 110 | def __le__(self, other: SimDate) -> bool: 111 | return self.total_months <= other.total_months 112 | 113 | def __lt__(self, other: SimDate) -> bool: 114 | return self.total_months < other.total_months 115 | 116 | def __ge__(self, other: SimDate) -> bool: 117 | return self.total_months >= other.total_months 118 | 119 | def __gt__(self, other: SimDate) -> bool: 120 | return self.total_months > other.total_months 121 | 122 | def __eq__(self, other: object) -> bool: 123 | if not isinstance(other, SimDate): 124 | raise TypeError(f"expected {type(self)} object but was {type(other)}") 125 | return self.total_months == other.total_months 126 | -------------------------------------------------------------------------------- /src/neighborly/components/skills.py: -------------------------------------------------------------------------------- 1 | """Skill system. 2 | 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | from typing import Any, Iterator, Mapping 8 | 9 | from neighborly.components.stats import Stat 10 | from neighborly.ecs import Component, GameObject 11 | 12 | 13 | class Skill(Component): 14 | """A skill that a character can have and improve.""" 15 | 16 | __slots__ = ( 17 | "_definition_id", 18 | "_description", 19 | "_display_name", 20 | ) 21 | 22 | _definition_id: str 23 | """The ID of this tag definition.""" 24 | _description: str 25 | """A short description of the tag.""" 26 | _display_name: str 27 | """The name of this tag printed.""" 28 | 29 | def __init__( 30 | self, 31 | definition_id: str, 32 | display_name: str, 33 | description: str, 34 | ) -> None: 35 | super().__init__() 36 | self._definition_id = definition_id 37 | self._display_name = display_name 38 | self._description = description 39 | 40 | @property 41 | def definition_id(self) -> str: 42 | """The ID of this tag definition.""" 43 | return self._definition_id 44 | 45 | @property 46 | def display_name(self) -> str: 47 | """The name of this tag printed.""" 48 | return self._display_name 49 | 50 | @property 51 | def description(self) -> str: 52 | """A short description of the tag.""" 53 | return self._description 54 | 55 | def __str__(self) -> str: 56 | return self.definition_id 57 | 58 | def to_dict(self) -> dict[str, Any]: 59 | return { 60 | "definition_id": self.definition_id, 61 | "display_name": self.display_name, 62 | "description": self.description, 63 | } 64 | 65 | 66 | class Skills(Component): 67 | """Tracks skills stats for a character.""" 68 | 69 | __slots__ = ("_skills",) 70 | 71 | _skills: dict[GameObject, Stat] 72 | """Skill names mapped to scores.""" 73 | 74 | def __init__(self) -> None: 75 | super().__init__() 76 | self._skills = {} 77 | 78 | @property 79 | def skills(self) -> Mapping[GameObject, Stat]: 80 | """Get skills.""" 81 | return self._skills 82 | 83 | def has_skill(self, skill: GameObject) -> bool: 84 | """Check if a character has a skill. 85 | 86 | Parameters 87 | ---------- 88 | skill 89 | The skill to check for. 90 | 91 | Returns 92 | ------- 93 | bool 94 | True if the skill is present, False otherwise. 95 | """ 96 | return skill in self._skills 97 | 98 | def add_skill(self, skill: GameObject, base_value: float = 0.0) -> None: 99 | """Add a new skill to the skill tracker.""" 100 | if skill not in self._skills: 101 | self._skills[skill] = Stat(base_value=base_value, bounds=(0, 255)) 102 | else: 103 | return 104 | 105 | def get_skill(self, skill: GameObject) -> Stat: 106 | """Get the stat for a skill. 107 | 108 | Parameters 109 | ---------- 110 | skill 111 | The skill to get the stat for. 112 | """ 113 | return self._skills[skill] 114 | 115 | def __getitem__(self, item: GameObject) -> Stat: 116 | """Get the value of a skill.""" 117 | return self.get_skill(item) 118 | 119 | def __str__(self) -> str: 120 | skill_value_pairs = { 121 | skill.name: stat.value for skill, stat in self._skills.items() 122 | } 123 | return f"{type(self).__name__}({skill_value_pairs})" 124 | 125 | def __repr__(self) -> str: 126 | skill_value_pairs = { 127 | skill.name: stat.value for skill, stat in self._skills.items() 128 | } 129 | return f"{type(self).__name__}({skill_value_pairs})" 130 | 131 | def __iter__(self) -> Iterator[tuple[GameObject, Stat]]: 132 | return iter(self._skills.items()) 133 | 134 | def to_dict(self) -> dict[str, Any]: 135 | return {**{skill.name: stat.value for skill, stat in self._skills.items()}} 136 | -------------------------------------------------------------------------------- /tests/test_traits.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | 3 | from neighborly.components.traits import Trait 4 | from neighborly.helpers.character import create_character 5 | from neighborly.helpers.stats import get_stat 6 | from neighborly.helpers.traits import add_trait, has_trait, remove_trait 7 | from neighborly.libraries import TraitLibrary 8 | from neighborly.loaders import load_characters, load_skills 9 | from neighborly.plugins import default_traits 10 | from neighborly.simulation import Simulation 11 | 12 | _TEST_DATA_DIR = pathlib.Path(__file__).parent / "data" 13 | 14 | 15 | def test_trait_instantiation() -> None: 16 | """Test that traits are properly initialized by the simulation.""" 17 | 18 | sim = Simulation() 19 | 20 | default_traits.load_plugin(sim) 21 | 22 | # Traits are initialized at the start of the simulation 23 | sim.initialize() 24 | 25 | library = sim.world.resource_manager.get_resource(TraitLibrary) 26 | 27 | trait = library.get_trait("flirtatious") 28 | 29 | assert trait.get_component(Trait).display_name == "Flirtatious" 30 | 31 | 32 | def test_add_trait() -> None: 33 | """Test that adding a trait makes it visible with has_trait.""" 34 | 35 | sim = Simulation() 36 | 37 | default_traits.load_plugin(sim) 38 | 39 | load_characters(sim, _TEST_DATA_DIR / "characters.json") 40 | load_skills(sim, _TEST_DATA_DIR / "skills.json") 41 | 42 | # Traits are initialized at the start of the simulation 43 | sim.initialize() 44 | 45 | character = create_character(sim.world, "farmer") 46 | 47 | assert has_trait(character, "flirtatious") is False 48 | 49 | add_trait(character, "flirtatious") 50 | 51 | assert has_trait(character, "flirtatious") is True 52 | 53 | 54 | def test_remove_trait() -> None: 55 | """Test that removing a trait makes it not available to has_trait.""" 56 | 57 | sim = Simulation() 58 | 59 | default_traits.load_plugin(sim) 60 | 61 | load_characters(sim, _TEST_DATA_DIR / "characters.json") 62 | load_skills(sim, _TEST_DATA_DIR / "skills.json") 63 | 64 | # Traits are initialized at the start of the simulation 65 | sim.step() 66 | 67 | character = create_character(sim.world, "farmer") 68 | 69 | assert has_trait(character, "flirtatious") is False 70 | 71 | add_trait(character, "flirtatious") 72 | 73 | assert has_trait(character, "flirtatious") is True 74 | 75 | remove_trait(character, "flirtatious") 76 | 77 | assert has_trait(character, "flirtatious") is False 78 | 79 | 80 | def test_add_remove_trait_effects() -> None: 81 | """Test that trait effects are added and removed with the trait.""" 82 | 83 | sim = Simulation() 84 | 85 | default_traits.load_plugin(sim) 86 | 87 | load_characters(sim, _TEST_DATA_DIR / "characters.json") 88 | load_skills(sim, _TEST_DATA_DIR / "skills.json") 89 | 90 | # Traits are initialized at the start of the simulation 91 | sim.initialize() 92 | 93 | farmer = create_character(sim.world, "farmer") 94 | 95 | get_stat(farmer, "sociability").base_value = 0 96 | 97 | success = add_trait(farmer, "gullible") 98 | 99 | assert success is True 100 | assert get_stat(farmer, "sociability").value == 3 101 | 102 | success = remove_trait(farmer, "gullible") 103 | 104 | assert success is True 105 | assert get_stat(farmer, "sociability").value == 0 106 | 107 | 108 | def test_try_add_conflicting_trait() -> None: 109 | """Test that adding a conflicting trait to a character fails""" 110 | 111 | sim = Simulation() 112 | 113 | default_traits.load_plugin(sim) 114 | 115 | load_characters(sim, _TEST_DATA_DIR / "characters.json") 116 | load_skills(sim, _TEST_DATA_DIR / "skills.json") 117 | 118 | # Traits are initialized at the start of the simulation 119 | sim.initialize() 120 | 121 | character = create_character(sim.world, "farmer") 122 | 123 | success = add_trait(character, "skeptical") 124 | 125 | assert success is True 126 | 127 | success = add_trait(character, "gullible") 128 | 129 | assert success is False 130 | 131 | success = add_trait(character, "skeptical") 132 | 133 | assert success is False 134 | -------------------------------------------------------------------------------- /docs/source/settlements.rst: -------------------------------------------------------------------------------- 1 | .. _settlements: 2 | 3 | Settlements and Districts 4 | ========================= 5 | 6 | Settlements are the starting point for the procedural generation process. They set the stage for the types of themes that might emerge from characters interactions, and are the first place that users have the ability to express their authorial intent for what the shape of the settlement will be. 7 | 8 | **Settlement Fields**: 9 | 10 | - ``settlement_name``: String text name for the city. Users can also use Tracery rules to define the settlement name 11 | - ``districts``: The IDs of district definitions that make up the settlement 12 | 13 | Settlements are divided into one or more districts. Each district defines what types of businesses exist within it and the character types that exist within it. So continuing our Night city example, we define districts within their own JSON file. Each district has a display name, text description, lists of business/residence/character types that can spawn there, and a number of residential/commercial building slots. Currently, districts cannot have subdistricts. 14 | 15 | **District Fields**: 16 | 17 | - ``display_name``: String text name for the city. 18 | - ``description``: A short text description. 19 | - ``business_types``: (1) The ID of a business definition, or (2) A mapping containing a business definition ID, and a spawn frequency override value. 20 | - ``residence_types``: (1) The ID of a residence definition, or (2) A mapping containing a residence definition ID, and a spawn frequency override value. 21 | - ``character_types``: (1) The ID of a character definition, or (2) A mapping containing a character definition ID, and a spawn frequency override value. 22 | - ``business_slots``: The maximum number of businesses the district can support. (default is 0) 23 | - ``residence_slots``: The maximum number of residential buildings the district can support. (default is 0) 24 | 25 | Below are examples of settlement and district definitions inspired by Night City from *Cyberpunk 2077*. 26 | 27 | .. code-block:: json 28 | 29 | { 30 | "cyberpunk_city": { 31 | "display_name": "Night City", 32 | "districts": [ 33 | "tech_hub", 34 | "underground_market", 35 | "nightclub_district" 36 | ] 37 | } 38 | } 39 | 40 | .. code-block:: json 41 | 42 | { 43 | "tech_hub": { 44 | "display_name": "Tech Hub District", 45 | "description": "The heart of technological innovation and corporate influence.", 46 | "business_types": [ 47 | "tech_company", 48 | { 49 | "definition_id": "cyber_clinic", 50 | "max_instances": 2, 51 | "spawn_frequency": 2 52 | } 53 | ], 54 | "character_types": [ 55 | "corporate_executive", 56 | "hacker" 57 | ], 58 | "residence_types": [ 59 | "high-rise_apartment", 60 | "luxury_condo" 61 | ], 62 | "business_slots": 5, 63 | "residential_slots": 3 64 | }, 65 | "underground_market": { 66 | "display_name": "Underground Market District", 67 | "description": "A haven for black market deals and shady characters.", 68 | "business_types": [ 69 | "black_market_vendor", 70 | "illegal_implant_clinic" 71 | ], 72 | "character_types": [ 73 | "smuggler", 74 | "fixer", 75 | "cyber-enhanced_thug" 76 | ], 77 | "residence_types": [ 78 | "cramped_apartment" 79 | ], 80 | "business_slots": 4, 81 | "residential_slots": 2 82 | }, 83 | "nightclub_district": { 84 | "display_name": "Nightclub District", 85 | "description": "Where the city comes alive at night with music and neon lights.", 86 | "business_types": [ 87 | "nightclub", 88 | "underground_rave" 89 | ], 90 | "character_types": [ 91 | "partygoer", 92 | "DJ" 93 | ], 94 | "residence_types": [ 95 | "loft_apartment", 96 | "penthouse" 97 | ], 98 | "business_slots": 6, 99 | "residential_slots": 3 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /docs/source/ecs.rst: -------------------------------------------------------------------------------- 1 | .. _ecs: 2 | 3 | Entity-Component System 4 | ======================= 5 | 6 | Entity-component systems (ECS) is a software architecture pattern that is popular in game development for its performance benefits and ability to represent combinatorially complex objects. It is a way of representing objects in the game world by composing entities out of components that contain data and running systems that operate on and modify components. ECS architectures emphasize representing objects using component composition rather than object-oriented inheritance trees. ECS architectures have a long history in Roguelike game development and have recently gained traction with large game engines like `Unity `_ and `Unreal `_. 7 | 8 | Within an ECS, the world is represented as a collection of entities. Each entity has a set of components associated with it. Components are collections of related data that model a particular aspect of the simulation. For example, a Position component might contain two numerical values, x and y. Systems are functions that manipulate the data within components to update the simulation. A common example is a PhysicsSystem that updates an entity’s Position component based on the presence and values of a Velocity component. 9 | 10 | If you want a more in-depth discussion, please refer to this `FAQ article from the Flecs ECS library `_. 11 | 12 | ECSs are designed to be an optimization strategy for data-intensive applications such as games and simulations. The main idea is to separate data from the processes that operate on that data. Then all similar data structures are stored together in memory to improve runtime performance by reducing cache misses. Neighborly does not get the same performance gains seen with C++ and C#-based ECSs, but it does enjoy the benefit of data-driven content authoring and the iterative layering of complexity through systems. 13 | 14 | Neighborly uses a custom entity-component system originally made for Neighborly. It's built on top of `Esper `_ that integrates features from other ECS and component-based architectures, like `Unity’s GameObjects `_, and global resource objects in `Bevy’s ECS `_. 15 | 16 | Parts of the ECS 17 | ---------------- 18 | 19 | The World 20 | ^^^^^^^^^ 21 | 22 | The world is the main entry point for the ECS. It manages all the entities (GameObjects), resources, and systems. Every simulation has only one world instance. We use the World instance to query for GameObjects, add/remove global shared resources, spawn GameObjects, and add/remove systems. 23 | 24 | Users never have to create new World instances. One is created automatically when we create a new Simulation. It can be accessed using the `sim.world` attribute. 25 | 26 | ``get_components(...)`` is the main method that users need to know about. It accepts a tuple of component types and returns a list of tuples containing the IDs of GameObjects paired with a tuple of references to components of the given types. So, if we were to call ``sim.world.get_components((Position, Velocity))`` it would return all the GameObjects that have both Position and Velocity components. 27 | 28 | GameObjects 29 | ^^^^^^^^^^^ 30 | 31 | Within Neighborly, entities are referred to as “GameObjects” (taken from Unity). GameObjects are spawned by a World instance and given a unique identifier. No two GameObjects should be assigned the same identifier during a single simulation run. Users can add, remove and check for components on GameObject instances. Neighborly uses GameObjects to represent characters, businesses, relationships, and residential buildings. 32 | 33 | Components 34 | ^^^^^^^^^^ 35 | 36 | Components contain data. They are used to represent various concepts such as names, ages, position, services, traits, statuses, relationship statuses, and more. 37 | 38 | Resources 39 | ^^^^^^^^^ 40 | 41 | Resources are shared object instances. Neighborly stores content definitions within specialized library classes that are exposed as shared resources. 42 | 43 | Systems and SystemGroups 44 | ^^^^^^^^^^^^^^^^^^^^^^^^ 45 | 46 | Perform operations every time step and can be grouped inside System groups to help orchestrate what order they run. 47 | 48 | By default Neighborly has the following system/system group ordering: 49 | 50 | - `InitializationSystems` (runs only once on first timestep) 51 | - `EarlyUpdateSystems` 52 | - `DataCollectionSystems` 53 | - `UpdateSystems` 54 | - `LateUpdateSystems` 55 | -------------------------------------------------------------------------------- /src/neighborly/data_collection.py: -------------------------------------------------------------------------------- 1 | """Data collection. 2 | 3 | This module contains functionality for collecting and exporting data from a simulation. 4 | 5 | Its structure is informed by the data collection layer of Mesa, an agent-based modeling 6 | library written in Python. Here we adapt their functionality to fit the ECS architecture 7 | of the simulation. 8 | 9 | """ 10 | 11 | from __future__ import annotations 12 | 13 | from typing import Any, Iterator, Optional, Sequence 14 | 15 | import polars as pl 16 | 17 | from neighborly.ecs import SystemGroup 18 | 19 | 20 | class DataTablesIterator: 21 | """Iterator for DataTables resource.""" 22 | 23 | __slots__ = ("table_names", "tables", "idx") 24 | 25 | table_names: tuple[str, ...] 26 | """table names to iterate over.""" 27 | tables: DataTables 28 | """Tables to iterate over.""" 29 | idx: int 30 | """The current index in the table names tuple.""" 31 | 32 | def __init__(self, table_names: Sequence[str], tables: DataTables) -> None: 33 | self.table_names = tuple(table_names) 34 | self.tables = tables 35 | self.idx = 0 36 | 37 | def __iter__(self) -> Iterator[tuple[str, pl.DataFrame]]: 38 | return self 39 | 40 | def __next__(self) -> tuple[str, pl.DataFrame]: 41 | if self.idx < len(self.table_names): 42 | name = self.table_names[self.idx] 43 | df = self.tables.get_data_frame(name) 44 | self.idx += 1 45 | return name, df 46 | raise StopIteration 47 | 48 | 49 | class DataTables: 50 | """A shared resource that collects data from the simulation into tables.""" 51 | 52 | __slots__ = ("_tables",) 53 | 54 | _tables: dict[str, dict[str, list[Any]]] 55 | """Table names mapped to dicts with column names mapped to data entries.""" 56 | 57 | def __init__( 58 | self, 59 | tables: Optional[dict[str, tuple[str, ...]]] = None, 60 | ) -> None: 61 | """ 62 | Parameters 63 | ---------- 64 | tables 65 | Table names mapped to dicts with column names mapped to data entries. 66 | """ 67 | self._tables = {} 68 | 69 | # Construct all the tables 70 | if tables: 71 | for table_name, column_names in tables.items(): 72 | self.create_table(table_name, column_names) 73 | 74 | def create_table(self, table_name: str, column_names: tuple[str, ...]) -> None: 75 | """Create a new table for data collection. 76 | 77 | Parameters 78 | ---------- 79 | table_name 80 | The name of the new table. 81 | column_names 82 | The names of columns within the table. 83 | """ 84 | new_table: dict[str, list[Any]] = {column: [] for column in column_names} 85 | self._tables[table_name] = new_table 86 | 87 | def add_data_row(self, table_name: str, row_data: dict[str, Any]) -> None: 88 | """Add a new row of data to a table. 89 | 90 | Parameters 91 | ---------- 92 | table_name 93 | The table to add the row to. 94 | row_data 95 | A row of data to add to the table where each dict key is the 96 | name of the column. 97 | """ 98 | if table_name not in self._tables: 99 | raise ValueError(f"Could not find table with name: {table_name}") 100 | 101 | for column in self._tables[table_name]: 102 | if column in row_data: 103 | self._tables[table_name][column].append(row_data[column]) 104 | else: 105 | raise KeyError(f"Row data is missing column: {column}") 106 | 107 | def get_data_frame(self, table_name: str) -> pl.DataFrame: 108 | """Create a Polars data frame from a table. 109 | 110 | Parameters 111 | ---------- 112 | table_name 113 | The name of the table to retrieve. 114 | 115 | Returns 116 | ------- 117 | pl.DataFrame 118 | A polars DataFrame. 119 | """ 120 | return pl.DataFrame(self._tables[table_name]) 121 | 122 | def __iter__(self) -> Iterator[tuple[str, pl.DataFrame]]: 123 | return DataTablesIterator(list(self._tables.keys()), self) 124 | 125 | def to_dict(self) -> dict[str, Any]: 126 | """Serialize the object to a JSON-serializable dict.""" 127 | return {**self._tables} 128 | 129 | 130 | class DataCollectionSystems(SystemGroup): 131 | """System group for collecting data. 132 | 133 | Any system that collects data during the course of the simulation should 134 | belong to this group. 135 | """ 136 | -------------------------------------------------------------------------------- /src/neighborly/defs/definition_compiler.py: -------------------------------------------------------------------------------- 1 | """Neighborly Definition Compiler. 2 | 3 | This script contains a best attempt at recreating the YAML configuration file workflow 4 | described by Patrick Kemp @ Spry Fox Games. 5 | 6 | It is based on this talk: 7 | https://www.youtube.com/watch?v=rWPJ5fW1UH8&t=538s 8 | 9 | This script aims to reproduce the following capabilities: 10 | 11 | 1) Allow character definitions to include other character definitions as boilerplate 12 | data 13 | 2) Enable users to specify definition variants that expand to final definitions 14 | 3) Support additive tags. So a definition's tag set is a combination of its defined 15 | tags and the tags of any parent definitions. 16 | 17 | """ 18 | 19 | from typing import Any, Iterable, Type, TypeVar 20 | 21 | from neighborly.defs.base_types import ContentDefinition 22 | 23 | _T = TypeVar("_T", bound=ContentDefinition) 24 | 25 | 26 | def compile_definitions( 27 | definitions: Iterable[_T], 28 | ) -> list[_T]: 29 | """Compile final definitions from a collection of raw definitions.""" 30 | 31 | unprocessed_defs: dict[str, _T] = {d.definition_id: d for d in definitions} 32 | processed_defs: dict[str, _T] = {} 33 | 34 | for definition in definitions: 35 | 36 | if definition.definition_id in processed_defs: 37 | # This one was already processed while processing another. 38 | continue 39 | 40 | _process_definition( 41 | type(definition), definition, unprocessed_defs, processed_defs 42 | ) 43 | 44 | final_results: list[_T] = [] 45 | 46 | for definition in processed_defs.values(): 47 | definition.extends.clear() 48 | definition.variants.clear() 49 | final_results.append(definition) 50 | 51 | return final_results 52 | 53 | 54 | def _process_definition( 55 | definition_type: Type[_T], 56 | definition: _T, 57 | unprocessed_defs: dict[str, _T], 58 | processed_defs: dict[str, _T], 59 | ) -> None: 60 | """Compile a single definition.""" 61 | # We have to do the following to ensure that 'is_template' has the 'set' flag 62 | # and is not excluded from model_dump(...) 63 | if definition.is_template is False: 64 | definition.is_template = False 65 | 66 | # Variables to hold cumulative definition data 67 | final_definition_data: dict[str, Any] = {} 68 | final_definition_tags: set[str] = set() 69 | 70 | # Update the final definition data with all the parents data 71 | for parent_def_id in definition.extends: 72 | if parent_def_id not in processed_defs: 73 | _process_definition( 74 | definition_type, 75 | unprocessed_defs[parent_def_id], 76 | unprocessed_defs, 77 | processed_defs, 78 | ) 79 | 80 | parent_def = processed_defs[parent_def_id] 81 | 82 | # Update cumulative variables with parent data 83 | final_definition_data.update(parent_def.model_dump(exclude_unset=True)) 84 | final_definition_tags = final_definition_tags.union(parent_def.tags) 85 | 86 | # Lastly update cumulative variables with the current definition's data 87 | final_definition_data.update(definition.model_dump(exclude_unset=True)) 88 | final_definition_data["tags"] = final_definition_tags.union(definition.tags) 89 | 90 | # This definition has been processed. 91 | final_definition = definition_type.model_validate(final_definition_data) 92 | processed_defs[final_definition.definition_id] = final_definition 93 | 94 | # Process any variants 95 | for variant_def in final_definition.variants: 96 | # We have to do the following to ensure that 'is_template' has the 97 | # 'set' flag and is not excluded from model_dump(...) 98 | if "name" not in variant_def: 99 | raise ValueError( 100 | f"{final_definition.definition_id} has variant that is missing a name." 101 | ) 102 | 103 | variant_name = variant_def["name"] 104 | variant_tags: set[str] = set(variant_def.get("tags", [])) 105 | 106 | variant_definition_data: dict[str, Any] = {} 107 | 108 | variant_definition_data.update(final_definition.model_dump(exclude_unset=True)) 109 | 110 | variant_definition_data.update(variant_def) 111 | 112 | variant_id = f"{final_definition.definition_id}.{variant_name}" 113 | variant_definition_data["definition_id"] = variant_id 114 | variant_definition_data["tags"] = final_definition.tags.union(variant_tags) 115 | 116 | processed_defs[variant_id] = definition_type.model_validate( 117 | variant_definition_data 118 | ) 119 | -------------------------------------------------------------------------------- /tests/test_settlement.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | 3 | from neighborly.components.settlement import Settlement 4 | from neighborly.defs.base_types import SettlementDefDistrictEntry 5 | from neighborly.defs.defaults import DefaultSettlementDef 6 | from neighborly.helpers.settlement import create_settlement 7 | from neighborly.libraries import DistrictLibrary, SettlementLibrary 8 | from neighborly.loaders import ( 9 | load_businesses, 10 | load_characters, 11 | load_districts, 12 | load_job_roles, 13 | load_residences, 14 | load_settlements, 15 | ) 16 | from neighborly.simulation import Simulation 17 | 18 | _TEST_DATA_DIR = pathlib.Path(__file__).parent / "data" 19 | 20 | 21 | def test_create_settlement() -> None: 22 | sim = Simulation() 23 | 24 | load_districts(sim, _TEST_DATA_DIR / "districts.json") 25 | load_settlements(sim, _TEST_DATA_DIR / "settlements.json") 26 | load_businesses(sim, _TEST_DATA_DIR / "businesses.json") 27 | load_characters(sim, _TEST_DATA_DIR / "characters.json") 28 | load_residences(sim, _TEST_DATA_DIR / "residences.json") 29 | load_job_roles(sim, _TEST_DATA_DIR / "job_roles.json") 30 | 31 | settlement = create_settlement(sim.world, "basic_settlement") 32 | 33 | assert settlement.metadata["definition_id"] == "basic_settlement" 34 | 35 | districts = list(settlement.get_component(Settlement).districts) 36 | 37 | assert len(districts) == 4 38 | 39 | 40 | def test_required_tags() -> None: 41 | 42 | sim = Simulation() 43 | 44 | load_districts(sim, _TEST_DATA_DIR / "districts.json") 45 | # load_settlements(sim, _TEST_DATA_DIR / "settlements.json") 46 | load_businesses(sim, _TEST_DATA_DIR / "businesses.json") 47 | load_characters(sim, _TEST_DATA_DIR / "characters.json") 48 | load_residences(sim, _TEST_DATA_DIR / "residences.json") 49 | load_job_roles(sim, _TEST_DATA_DIR / "job_roles.json") 50 | 51 | sim.world.resource_manager.get_resource(SettlementLibrary).add_definition( 52 | DefaultSettlementDef( 53 | definition_id="basic_settlement", 54 | display_name="Settlement", 55 | districts=[ 56 | SettlementDefDistrictEntry(with_tags=["urban", "suburban"]), 57 | SettlementDefDistrictEntry(with_tags=["urban", "suburban"]), 58 | ], 59 | ) 60 | ) 61 | 62 | # it doesn't actually check if the tags match, just if the amount matches 63 | 64 | # sim.world.resource_manager.get_resource(SettlementLibrary).add_definition( 65 | # DefaultSettlementDef( 66 | # definition_id = "basic_settlement2", 67 | # display_name="Settlement", 68 | # districts = [ 69 | # SettlementDefDistrictEntry( 70 | # tags=["commercial", "suburban"] 71 | # ), 72 | 73 | # ] 74 | # ) 75 | # ) 76 | 77 | settlement = create_settlement(sim.world, "basic_settlement") 78 | library = settlement.world.resource_manager.get_resource(DistrictLibrary) 79 | 80 | required_tags = ["suburban", "urban"] 81 | districts = list(settlement.get_component(Settlement).districts) 82 | 83 | for district in districts: 84 | district_def = library.get_definition(district.metadata["definition_id"]) 85 | assert all(tag in district_def.tags for tag in required_tags), "Missing tags" 86 | 87 | # assert False 88 | 89 | 90 | # Both have the same issue 91 | def test_optional_tags() -> None: 92 | 93 | sim = Simulation() 94 | 95 | load_districts(sim, _TEST_DATA_DIR / "districts.json") 96 | # load_settlements(sim, _TEST_DATA_DIR / "settlements.json") 97 | load_businesses(sim, _TEST_DATA_DIR / "businesses.json") 98 | load_characters(sim, _TEST_DATA_DIR / "characters.json") 99 | load_residences(sim, _TEST_DATA_DIR / "residences.json") 100 | load_job_roles(sim, _TEST_DATA_DIR / "job_roles.json") 101 | 102 | sim.world.resource_manager.get_resource(SettlementLibrary).add_definition( 103 | DefaultSettlementDef( 104 | definition_id="basic_settlement", 105 | display_name="Settlement", 106 | districts=[ 107 | SettlementDefDistrictEntry( 108 | with_tags=["urban", "suburban", "~hot", "~heat"] 109 | ), 110 | SettlementDefDistrictEntry( 111 | with_tags=["urban", "suburban", "~hot", "~heat"] 112 | ), 113 | ], 114 | ) 115 | ) 116 | 117 | # it doesn't actually check if the tags match, just if the amount matches 118 | 119 | # sim.world.resource_manager.get_resource(SettlementLibrary).add_definition( 120 | # DefaultSettlementDef( 121 | # definition_id = "basic_settlement2", 122 | # display_name="Settlement", 123 | # districts = [ 124 | # SettlementDefDistrictEntry( 125 | # tags=["commercial", "suburban"] 126 | # ), 127 | 128 | # ] 129 | # ) 130 | # ) 131 | 132 | settlement = create_settlement(sim.world, "basic_settlement") 133 | library = settlement.world.resource_manager.get_resource(DistrictLibrary) 134 | 135 | required_tags = ["suburban", "urban"] 136 | districts = list(settlement.get_component(Settlement).districts) 137 | 138 | for district in districts: 139 | district_def = library.get_definition(district.metadata["definition_id"]) 140 | assert all(tag in district_def.tags for tag in required_tags), "Missing tags" 141 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | shijbey@ucsc.edu. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | . 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | . Translations are available at 128 | . 129 | -------------------------------------------------------------------------------- /docs/source/relationships.rst: -------------------------------------------------------------------------------- 1 | .. _relationships: 2 | 3 | Relationships 4 | ============= 5 | 6 | Relationships track how characters feel about each other. They track directed stats that represent the feelings of the owner of the relationship toward the target of the relationship. In this way, characters may have asymmetric relationships. For instance, one character having a high reputation value of the other, but the feelings not being reciprocated. 7 | 8 | Relationships are represented as GameObjects with two required components ``Relationship``, ``Traits``, and ``Stats``. The ``Relationship`` component tracks the owner and target of the relationship, the ``Traits`` component tracks all traits applied to the relationship, and the ``Stats`` component tracks stats related to the relationship. 9 | 10 | Relationship stats 11 | ------------------ 12 | 13 | The following are the stats used to represent relationships: 14 | 15 | - ``romance``: (-100 to 100) A measure of the owner's romantic affinity toward the target. 16 | - ``reputation``: (-100 to 100) A measure of the owner's general (or platonic) affinity toward the target. 17 | - ``romantic_compatibility``: (-100 to 100) A measure of how strongly romantic feelings will passively grow or decrease over time. 18 | - ``compatibility``: (-100 to 100) A measure of how strongly the reputation state will passively grow or decrease over time. 19 | - ``interaction_score``: (0 to 10)A measure of how often characters interact during a year. This affects the strength of the passive growth/decline of the romance and reputation stats. 20 | 21 | Working with stats in Python 22 | ---------------------------- 23 | 24 | The following is an example of how to create new relationships between characters, manually modify stats, and apply traits to the relationship. 25 | 26 | .. code-block:: python 27 | 28 | from neighborly.simulation import Simulation 29 | from neighborly.helpers.traits import add_trait, has_trait, remove_trait 30 | from neighborly.helpers.relationships import get_relationship 31 | from neighborly.loaders import load_traits, load_characters 32 | 33 | sim = Simulation() 34 | 35 | # Load trait and character definition data 36 | load_traits(sim, "path/to/file") 37 | load_characters(sim, "path/to/file") 38 | 39 | # Traits are initialized at the start of the simulation 40 | sim.initialize() 41 | 42 | chris = create_character(sim.world, "person") 43 | sam = create_character(sim.world, "person") 44 | 45 | # Adds two traits to the relationship from Chris to Sam 46 | # The get_relationship(...) function creates a new relationship 47 | # if one does not already exist. 48 | add_trait(get_relationship(chris, sam), "friends") 49 | add_trait(get_relationship(chris, sam), "rivals") 50 | 51 | # Adds two traits to the relationship from Sam to Chris 52 | add_trait(get_relationship(sam, chris), "friends") 53 | add_trait(get_relationship(sam, chris), "rivals") 54 | 55 | # Reduce the base value of the romance stat by 25 56 | get_stat(get_relationship(sam, chris), "romance").base_value -= 25 57 | 58 | Social Rules 59 | ------------ 60 | 61 | Social rules modify how characters feel about each other. They help to make a more socially interesting simulation. Social rules are mostly associated with traits and can be added using the ``AddSocialRule`` Effect. For example, you can have a ``prejudice-against-elves`` trait that, when attached to a character, would add a social rule that decreases the base reputation of that character toward all characters with the 'elf' trait. Social rules allow us to build very complicated relationships between characters using constructs that are quite simple to create and modify. 62 | 63 | Adding a social rule to a character will affect pre-existing and future relationships. When a social rule is removed, all relationships are recalculated. 64 | 65 | The structure of a social rule 66 | ------------------------------ 67 | 68 | Social rules have three pieces: ``preconditions``, ``effects``, and a ``source``. The preconditions are callable objects or functions that accept a relationship GameObject as a parameter and return True if the relationship meets their conditions. The effects are Effect objects applied to the relationship if all the preconditions pass. These Effects have the same structure as those associated with traits, except they are provided a relationship as the target object to modify. Finally, the source tracks what was responsible for constructing the social rule and adding it to the character's ``SocialRules`` component. We use the source reference to track which social rules are associated with which traits. 69 | 70 | Defining social rules 71 | --------------------- 72 | 73 | Currently, all social rules are authored within traits using the ``AddSocialRule`` effect. Perhaps a later feature could be authoring global social rules that are applied to all characters regardless of their associated traits. Below is an example definition of our elf prejudice trait. With this trait attached, all relationships this character has with character with the ``elf`` trait will receive an automatic reputation penalty of -10. 74 | 75 | .. code-block:: json 76 | 77 | { 78 | "prejudice_against_elves": { 79 | "display_name": "Prejudice against elves", 80 | "description": "This character does not like elves", 81 | "effects": [ 82 | { 83 | "type": "AddSocialRule", 84 | "preconditions": [ 85 | { 86 | "type": "HasTrait", 87 | "trait": "elf" 88 | } 89 | ], 90 | "effects": [ 91 | { 92 | "type": "StatBuff", 93 | "stat": "reputation", 94 | "amount": -10 95 | } 96 | ] 97 | } 98 | ] 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/neighborly/preconditions/defaults.py: -------------------------------------------------------------------------------- 1 | """Default implementations of preconditions. 2 | 3 | """ 4 | 5 | from typing import Any 6 | 7 | from neighborly.components.character import Character, LifeStage, Sex 8 | from neighborly.components.relationship import Relationship 9 | from neighborly.ecs import GameObject, World 10 | from neighborly.helpers.skills import get_skill, has_skill 11 | from neighborly.helpers.traits import has_trait 12 | from neighborly.preconditions.base_types import Precondition 13 | 14 | 15 | class HasTrait(Precondition): 16 | """A precondition that check if a GameObject has a given trait.""" 17 | 18 | __slots__ = ("trait_id",) 19 | 20 | trait_id: str 21 | """The ID of the trait to check for.""" 22 | 23 | def __init__(self, trait: str) -> None: 24 | super().__init__() 25 | self.trait_id = trait 26 | 27 | @property 28 | def description(self) -> str: 29 | return f"has the trait {self.trait_id}" 30 | 31 | def __call__(self, target: GameObject) -> bool: 32 | return has_trait(target, self.trait_id) 33 | 34 | @classmethod 35 | def instantiate(cls, world: World, params: dict[str, Any]) -> Precondition: 36 | trait = params["trait"] 37 | return cls(trait) 38 | 39 | 40 | class TargetHasTrait(Precondition): 41 | """A precondition that checks if a relationship's target has a given trait.""" 42 | 43 | __slots__ = ("trait_id",) 44 | 45 | trait_id: str 46 | """The ID of the trait to check for.""" 47 | 48 | def __init__(self, trait: str) -> None: 49 | super().__init__() 50 | self.trait_id = trait 51 | 52 | @property 53 | def description(self) -> str: 54 | return f"relationship target has the {self.trait_id} trait" 55 | 56 | def __call__(self, target: GameObject) -> bool: 57 | return has_trait(target.get_component(Relationship).target, self.trait_id) 58 | 59 | @classmethod 60 | def instantiate(cls, world: World, params: dict[str, Any]) -> Precondition: 61 | trait = params["trait"] 62 | return cls(trait) 63 | 64 | 65 | class SkillRequirement(Precondition): 66 | """A precondition that requires a GameObject to have a certain level skill.""" 67 | 68 | __slots__ = "skill_id", "skill_level" 69 | 70 | skill_id: str 71 | """The ID of the skill to check for.""" 72 | skill_level: float 73 | """The skill level to check for""" 74 | 75 | def __init__(self, skill: str, level: float = 0.0) -> None: 76 | super().__init__() 77 | self.skill_id = skill 78 | self.skill_level = level 79 | 80 | @property 81 | def description(self) -> str: 82 | return f"has {self.skill_id} skill level of at least {self.skill_level}" 83 | 84 | def __call__(self, target: GameObject) -> bool: 85 | if has_skill(target, self.skill_id): 86 | skill_stat = get_skill(target, self.skill_id) 87 | return skill_stat.value >= self.skill_level 88 | 89 | return False 90 | 91 | @classmethod 92 | def instantiate(cls, world: World, params: dict[str, Any]) -> Precondition: 93 | skill = params["skill"] 94 | level = params["level"] 95 | 96 | return cls(skill=skill, level=level) 97 | 98 | 99 | class AtLeastLifeStage(Precondition): 100 | """A precondition that requires a character to be at least a given life stage.""" 101 | 102 | __slots__ = ("life_stage",) 103 | 104 | life_stage: LifeStage 105 | """The life stage to check for.""" 106 | 107 | def __init__(self, life_stage: LifeStage) -> None: 108 | super().__init__() 109 | self.life_stage = life_stage 110 | 111 | @property 112 | def description(self) -> str: 113 | return f"is at least the {self.life_stage.name} life stage" 114 | 115 | def __call__(self, target: GameObject) -> bool: 116 | if character := target.try_component(Character): 117 | return character.life_stage >= self.life_stage 118 | 119 | return False 120 | 121 | @classmethod 122 | def instantiate(cls, world: World, params: dict[str, Any]) -> Precondition: 123 | life_stage = LifeStage[params["life_stage"]] 124 | 125 | return cls(life_stage) 126 | 127 | 128 | class TargetIsSex(Precondition): 129 | """Requires that the target of the relationship be of a specific sex.""" 130 | 131 | __slots__ = ("sex",) 132 | 133 | sex: Sex 134 | """The sex to check for.""" 135 | 136 | def __init__(self, sex: Sex) -> None: 137 | super().__init__() 138 | self.sex = sex 139 | 140 | @property 141 | def description(self) -> str: 142 | return f"relationship target is a {self.sex.name}" 143 | 144 | def __call__(self, target: GameObject) -> bool: 145 | relationship_target = target.get_component(Relationship).target 146 | return relationship_target.get_component(Character).sex == self.sex 147 | 148 | @classmethod 149 | def instantiate(cls, world: World, params: dict[str, Any]) -> Precondition: 150 | sex = Sex[params["sex"]] 151 | return cls(sex) 152 | 153 | 154 | class TargetLifeStageLT(Precondition): 155 | """Requires that the target of the relationship be less than a given life stage.""" 156 | 157 | __slots__ = ("life_stage",) 158 | 159 | life_stage: LifeStage 160 | """The life stage to check for.""" 161 | 162 | def __init__(self, life_stage: LifeStage) -> None: 163 | super().__init__() 164 | self.life_stage = life_stage 165 | 166 | @property 167 | def description(self) -> str: 168 | return f"relationship target is at least the {self.life_stage.name} life stage" 169 | 170 | def __call__(self, target: GameObject) -> bool: 171 | relationship_target = target.get_component(Relationship).target 172 | return relationship_target.get_component(Character).life_stage < self.life_stage 173 | 174 | @classmethod 175 | def instantiate(cls, world: World, params: dict[str, Any]) -> Precondition: 176 | life_stage = LifeStage[params["life_stage"]] 177 | return cls(life_stage) 178 | -------------------------------------------------------------------------------- /src/neighborly/components/character.py: -------------------------------------------------------------------------------- 1 | """Components for representing Characters. 2 | 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | import enum 8 | from typing import Any 9 | 10 | from neighborly.components.traits import Trait 11 | from neighborly.datetime import SimDate 12 | from neighborly.ecs import Component, GameObject 13 | 14 | 15 | class LifeStage(enum.IntEnum): 16 | """An enumeration of all the various life stages aging characters pass through.""" 17 | 18 | CHILD = 0 19 | ADOLESCENT = 1 20 | YOUNG_ADULT = 2 21 | ADULT = 3 22 | SENIOR = 4 23 | 24 | 25 | class Sex(enum.IntEnum): 26 | """The characters current sex.""" 27 | 28 | MALE = enum.auto() 29 | FEMALE = enum.auto() 30 | NOT_SPECIFIED = enum.auto() 31 | 32 | 33 | class Species(Component): 34 | """Configuration information about a character's species.""" 35 | 36 | __slots__ = ( 37 | "adolescent_age", 38 | "young_adult_age", 39 | "adult_age", 40 | "senior_age", 41 | "lifespan", 42 | "can_physically_age", 43 | ) 44 | 45 | def __init__( 46 | self, 47 | adolescent_age: int, 48 | young_adult_age: int, 49 | adult_age: int, 50 | senior_age: int, 51 | lifespan: int, 52 | can_physically_age: bool, 53 | ) -> None: 54 | super().__init__() 55 | self.adolescent_age = adolescent_age 56 | self.young_adult_age = young_adult_age 57 | self.adult_age = adult_age 58 | self.senior_age = senior_age 59 | self.lifespan = lifespan 60 | self.can_physically_age = can_physically_age 61 | 62 | def to_dict(self) -> dict[str, Any]: 63 | return {} 64 | 65 | 66 | class Character(Component): 67 | """A character within the story world.""" 68 | 69 | __slots__ = ("_first_name", "_last_name", "_sex", "_age", "_life_stage", "species") 70 | 71 | _first_name: str 72 | """The character's first name.""" 73 | _last_name: str 74 | """The character's last name or family name.""" 75 | _age: float 76 | """the character's current age.""" 77 | _sex: Sex 78 | """The physical sex of the character.""" 79 | _life_stage: LifeStage 80 | """The character's current life stage.""" 81 | species: GameObject 82 | """The character's species""" 83 | 84 | def __init__( 85 | self, first_name: str, last_name: str, sex: Sex, species: GameObject 86 | ) -> None: 87 | super().__init__() 88 | self._first_name = first_name 89 | self._last_name = last_name 90 | self._sex = sex 91 | self._age = 0 92 | self._life_stage = LifeStage.CHILD 93 | self.species = species 94 | 95 | @property 96 | def first_name(self) -> str: 97 | """The character's first name.""" 98 | return self._first_name 99 | 100 | @first_name.setter 101 | def first_name(self, value: str) -> None: 102 | """Set the character's first name.""" 103 | self._first_name = value 104 | self.gameobject.name = self.full_name 105 | 106 | @property 107 | def last_name(self) -> str: 108 | """The character's last name.""" 109 | return self._last_name 110 | 111 | @last_name.setter 112 | def last_name(self, value: str) -> None: 113 | """Set the character's last name.""" 114 | self._last_name = value 115 | self.gameobject.name = self.full_name 116 | 117 | @property 118 | def full_name(self) -> str: 119 | """The combined full name of the character.""" 120 | return f"{self._first_name} {self._last_name}" 121 | 122 | @property 123 | def age(self) -> float: 124 | """Get the character's age.""" 125 | return self._age 126 | 127 | @age.setter 128 | def age(self, value: float) -> None: 129 | """Set the character's age.""" 130 | self._age = value 131 | 132 | @property 133 | def sex(self) -> Sex: 134 | """Get the characters sex.""" 135 | return self._sex 136 | 137 | @property 138 | def life_stage(self) -> LifeStage: 139 | """Get the character's life stage.""" 140 | return self._life_stage 141 | 142 | @life_stage.setter 143 | def life_stage(self, value: LifeStage) -> None: 144 | """Set the character's life stage.""" 145 | self._life_stage = value 146 | 147 | def to_dict(self) -> dict[str, Any]: 148 | return { 149 | "first_name": self._first_name, 150 | "last_name": self._last_name, 151 | "sex": self.sex.name, 152 | "age": int(self.age), 153 | "life_stage": self.life_stage.name, 154 | "species": self.species.get_component(Trait).definition_id, 155 | } 156 | 157 | def __repr__(self) -> str: 158 | return ( 159 | f"{self.__class__.__name__}(name={self.full_name}, sex={self.sex}, " 160 | f"age={self.age}({self.life_stage}), species={self.species.name})" 161 | ) 162 | 163 | def __str__(self) -> str: 164 | return self.full_name 165 | 166 | 167 | class Pregnant(Component): 168 | """Tags a character as pregnant and tracks relevant information.""" 169 | 170 | __slots__ = "partner", "due_date" 171 | 172 | partner: GameObject 173 | """The GameObject ID of the character that impregnated this character.""" 174 | due_date: SimDate 175 | """The date the baby is due.""" 176 | 177 | def __init__(self, partner: GameObject, due_date: SimDate) -> None: 178 | super().__init__() 179 | self.partner = partner 180 | self.due_date = due_date.copy() 181 | 182 | def __str__(self) -> str: 183 | return f"{type(self).__name__}(partner={self.partner.name})" 184 | 185 | def __repr__(self) -> str: 186 | return f"{type(self).__name__}(partner={self.partner.name})" 187 | 188 | def to_dict(self) -> dict[str, Any]: 189 | return { 190 | **super().to_dict(), 191 | "partner": self.partner.uid, 192 | "due_date": str(self.due_date), 193 | } 194 | -------------------------------------------------------------------------------- /src/neighborly/components/residence.py: -------------------------------------------------------------------------------- 1 | """Components for representing residential buildings. 2 | 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | from typing import Any, Iterable 8 | 9 | from ordered_set import OrderedSet 10 | 11 | from neighborly.ecs import Component, GameObject, TagComponent 12 | 13 | 14 | class ResidentialUnit(Component): 15 | """A Residence is a place where characters live.""" 16 | 17 | __slots__ = "_owners", "_residents", "_district", "_building" 18 | 19 | _building: GameObject 20 | """The building this unit is in.""" 21 | _district: GameObject 22 | """The district the residence is in.""" 23 | _owners: OrderedSet[GameObject] 24 | """Characters that currently own the residence.""" 25 | _residents: OrderedSet[GameObject] 26 | """All the characters who live at the residence (including non-owners).""" 27 | 28 | def __init__(self, building: GameObject, district: GameObject) -> None: 29 | super().__init__() 30 | self._building = building 31 | self._district = district 32 | self._owners = OrderedSet([]) 33 | self._residents = OrderedSet([]) 34 | 35 | @property 36 | def building(self) -> GameObject: 37 | """Get the building the residential unit is in.""" 38 | return self._building 39 | 40 | @property 41 | def district(self) -> GameObject: 42 | """Get the district the residence is in.""" 43 | return self._district 44 | 45 | @property 46 | def owners(self) -> Iterable[GameObject]: 47 | """Get the owners of the residence.""" 48 | return self._owners 49 | 50 | @property 51 | def residents(self) -> Iterable[GameObject]: 52 | """Get all the residents of the residence.""" 53 | return self._residents 54 | 55 | def to_dict(self) -> dict[str, Any]: 56 | return { 57 | "district": self.district.uid, 58 | "owners": [entry.uid for entry in self.owners], 59 | "residents": [entry.uid for entry in self.residents], 60 | } 61 | 62 | def add_owner(self, owner: GameObject) -> None: 63 | """Add owner to the residence. 64 | 65 | Parameters 66 | ---------- 67 | owner 68 | A GameObject reference to a residence owner. 69 | """ 70 | self._owners.add(owner) 71 | 72 | def remove_owner(self, owner: GameObject) -> None: 73 | """Remove owner from residence. 74 | 75 | Parameters 76 | ---------- 77 | owner 78 | A GameObject reference to a residence owner. 79 | """ 80 | self._owners.remove(owner) 81 | 82 | def is_owner(self, character: GameObject) -> bool: 83 | """Check if a GameObject owns a residence. 84 | 85 | Parameters 86 | ---------- 87 | character 88 | A GameObject reference to a residence owner. 89 | """ 90 | return character in self._owners 91 | 92 | def add_resident(self, resident: GameObject) -> None: 93 | """Add a tenant to this residence. 94 | 95 | Parameters 96 | ---------- 97 | resident 98 | A GameObject reference to a resident. 99 | """ 100 | self._residents.add(resident) 101 | 102 | def remove_resident(self, resident: GameObject) -> None: 103 | """Remove a tenant rom this residence. 104 | 105 | Parameters 106 | ---------- 107 | resident 108 | A GameObject reference to a resident. 109 | """ 110 | self._residents.remove(resident) 111 | 112 | def is_resident(self, character: GameObject) -> bool: 113 | """Check if a GameObject is a resident. 114 | 115 | Parameters 116 | ---------- 117 | character 118 | A GameObject reference to a character 119 | """ 120 | return character in self._residents 121 | 122 | def __repr__(self) -> str: 123 | return f"Residence({self.to_dict()})" 124 | 125 | def __str__(self) -> str: 126 | return f"Residence({self.to_dict()})" 127 | 128 | def __len__(self) -> int: 129 | return len(self._residents) 130 | 131 | 132 | class ResidentialBuilding(Component): 133 | """Tags a building as managing multiple residential units.""" 134 | 135 | __slots__ = "_residential_units", "_district" 136 | 137 | _district: GameObject 138 | """The district the residence is in.""" 139 | _residential_units: list[GameObject] 140 | """The residential units that belong to this building.""" 141 | 142 | def __init__(self, district: GameObject) -> None: 143 | super().__init__() 144 | self._district = district 145 | self._residential_units = [] 146 | 147 | @property 148 | def district(self) -> GameObject: 149 | """Get the district the residential building belongs to.""" 150 | return self._district 151 | 152 | @property 153 | def units(self) -> Iterable[GameObject]: 154 | """Get the residential units within the building.""" 155 | return self._residential_units 156 | 157 | def add_residential_unit(self, residence: GameObject) -> None: 158 | """Add a residential unit to the building.""" 159 | self._residential_units.append(residence) 160 | 161 | def remove_residential_unit(self, residence: GameObject) -> None: 162 | """Add a residential unit to the building.""" 163 | self._residential_units.remove(residence) 164 | 165 | def to_dict(self) -> dict[str, Any]: 166 | return { 167 | "district": self.district.uid, 168 | "units": [u.uid for u in self._residential_units], 169 | } 170 | 171 | 172 | class Resident(Component): 173 | """A Component attached to characters that tracks where they live.""" 174 | 175 | __slots__ = ("residence",) 176 | 177 | residence: GameObject 178 | """The GameObject ID of their residence.""" 179 | 180 | def __init__(self, residence: GameObject) -> None: 181 | """ 182 | Parameters 183 | ---------- 184 | residence 185 | A GameObject reference to their residence. 186 | """ 187 | super().__init__() 188 | self.residence = residence 189 | 190 | def to_dict(self) -> dict[str, Any]: 191 | return {**super().to_dict(), "residence": self.residence.uid} 192 | 193 | def __repr__(self) -> str: 194 | return f"Resident({self.to_dict()})" 195 | 196 | def __str__(self) -> str: 197 | return f"Resident({self.to_dict()})" 198 | 199 | 200 | class Vacant(TagComponent): 201 | """Tags a residence that does not currently have anyone living there.""" 202 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/python,jupyternotebooks,visualstudiocode,pycharm 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=python,jupyternotebooks,visualstudiocode,pycharm 4 | 5 | ### JupyterNotebooks ### 6 | # gitignore template for Jupyter Notebooks 7 | # website: http://jupyter.org/ 8 | 9 | .ipynb_checkpoints 10 | */.ipynb_checkpoints/* 11 | 12 | # IPython 13 | profile_default/ 14 | ipython_config.py 15 | 16 | # Remove previous ipynb_checkpoints 17 | # git rm -r .ipynb_checkpoints/ 18 | 19 | ### PyCharm ### 20 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 21 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 22 | 23 | # User-specific stuff 24 | .idea/**/workspace.xml 25 | .idea/**/tasks.xml 26 | .idea/**/usage.statistics.xml 27 | .idea/**/dictionaries 28 | .idea/**/shelf 29 | 30 | # AWS User-specific 31 | .idea/**/aws.xml 32 | 33 | # Generated files 34 | .idea/**/contentModel.xml 35 | 36 | # Sensitive or high-churn files 37 | .idea/**/dataSources/ 38 | .idea/**/dataSources.ids 39 | .idea/**/dataSources.local.xml 40 | .idea/**/sqlDataSources.xml 41 | .idea/**/dynamic.xml 42 | .idea/**/uiDesigner.xml 43 | .idea/**/dbnavigator.xml 44 | 45 | # Gradle 46 | .idea/**/gradle.xml 47 | .idea/**/libraries 48 | 49 | # Gradle and Maven with auto-import 50 | # When using Gradle or Maven with auto-import, you should exclude module files, 51 | # since they will be recreated, and may cause churn. Uncomment if using 52 | # auto-import. 53 | # .idea/artifacts 54 | # .idea/compiler.xml 55 | # .idea/jarRepositories.xml 56 | # .idea/modules.xml 57 | # .idea/*.iml 58 | # .idea/modules 59 | # *.iml 60 | # *.ipr 61 | 62 | # CMake 63 | cmake-build-*/ 64 | 65 | # Mongo Explorer plugin 66 | .idea/**/mongoSettings.xml 67 | 68 | # File-based project format 69 | *.iws 70 | 71 | # IntelliJ 72 | out/ 73 | 74 | # mpeltonen/sbt-idea plugin 75 | .idea_modules/ 76 | 77 | # JIRA plugin 78 | atlassian-ide-plugin.xml 79 | 80 | # Cursive Clojure plugin 81 | .idea/replstate.xml 82 | 83 | # Crashlytics plugin (for Android Studio and IntelliJ) 84 | com_crashlytics_export_strings.xml 85 | crashlytics.properties 86 | crashlytics-build.properties 87 | fabric.properties 88 | 89 | # Editor-based Rest Client 90 | .idea/httpRequests 91 | 92 | # Android studio 3.1+ serialized cache file 93 | .idea/caches/build_file_checksums.ser 94 | 95 | ### PyCharm Patch ### 96 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 97 | 98 | # *.iml 99 | # modules.xml 100 | # .idea/misc.xml 101 | # *.ipr 102 | 103 | # Sonarlint plugin 104 | # https://plugins.jetbrains.com/plugin/7973-sonarlint 105 | .idea/**/sonarlint/ 106 | 107 | # SonarQube Plugin 108 | # https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin 109 | .idea/**/sonarIssues.xml 110 | 111 | # Markdown Navigator plugin 112 | # https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced 113 | .idea/**/markdown-navigator.xml 114 | .idea/**/markdown-navigator-enh.xml 115 | .idea/**/markdown-navigator/ 116 | 117 | # Cache file creation bug 118 | # See https://youtrack.jetbrains.com/issue/JBR-2257 119 | .idea/$CACHE_FILE$ 120 | 121 | # CodeStream plugin 122 | # https://plugins.jetbrains.com/plugin/12206-codestream 123 | .idea/codestream.xml 124 | 125 | ### Python ### 126 | # Byte-compiled / optimized / DLL files 127 | __pycache__/ 128 | *.py[cod] 129 | *$py.class 130 | 131 | # C extensions 132 | *.so 133 | 134 | # Distribution / packaging 135 | .Python 136 | build/ 137 | develop-eggs/ 138 | dist/ 139 | downloads/ 140 | eggs/ 141 | .eggs/ 142 | lib/ 143 | lib64/ 144 | parts/ 145 | sdist/ 146 | var/ 147 | wheels/ 148 | share/python-wheels/ 149 | *.egg-info/ 150 | .installed.cfg 151 | *.egg 152 | MANIFEST 153 | 154 | # PyInstaller 155 | # Usually these files are written by a python script from a template 156 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 157 | *.manifest 158 | *.spec 159 | 160 | # Installer logs 161 | pip-log.txt 162 | pip-delete-this-directory.txt 163 | 164 | # Unit test / coverage reports 165 | htmlcov/ 166 | .tox/ 167 | .nox/ 168 | .coverage 169 | .coverage.* 170 | .cache 171 | nosetests.xml 172 | coverage.xml 173 | *.cover 174 | *.py,cover 175 | .hypothesis/ 176 | .pytest_cache/ 177 | cover/ 178 | 179 | # Translations 180 | *.mo 181 | *.pot 182 | 183 | # Django stuff: 184 | *.log 185 | local_settings.py 186 | db.sqlite3 187 | db.sqlite3-journal 188 | 189 | # Flask stuff: 190 | instance/ 191 | .webassets-cache 192 | 193 | # Scrapy stuff: 194 | .scrapy 195 | 196 | # Sphinx documentation 197 | docs/_build/ 198 | 199 | # PyBuilder 200 | .pybuilder/ 201 | target/ 202 | 203 | # Jupyter Notebook 204 | 205 | # IPython 206 | 207 | # pyenv 208 | # For a library or package, you might want to ignore these files since the code is 209 | # intended to run in multiple environments; otherwise, check them in: 210 | # .python-version 211 | 212 | # pipenv 213 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 214 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 215 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 216 | # install all needed dependencies. 217 | #Pipfile.lock 218 | 219 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 220 | __pypackages__/ 221 | 222 | # Celery stuff 223 | celerybeat-schedule 224 | celerybeat.pid 225 | 226 | # SageMath parsed files 227 | *.sage.py 228 | 229 | # Environments 230 | .env 231 | .venv 232 | env/ 233 | venv/ 234 | ENV/ 235 | env.bak/ 236 | venv.bak/ 237 | 238 | # Spyder project settings 239 | .spyderproject 240 | .spyproject 241 | 242 | # Rope project settings 243 | .ropeproject 244 | 245 | # mkdocs documentation 246 | /site 247 | 248 | # mypy 249 | .mypy_cache/ 250 | .dmypy.json 251 | dmypy.json 252 | 253 | # Pyre type checker 254 | .pyre/ 255 | 256 | # pytype static type analyzer 257 | .pytype/ 258 | 259 | # Cython debug symbols 260 | cython_debug/ 261 | 262 | ### VisualStudioCode ### 263 | *.code-workspace 264 | 265 | # Local History for Visual Studio Code 266 | .history/ 267 | 268 | ### VisualStudioCode Patch ### 269 | # Ignore all local history of files 270 | .history 271 | .ionide 272 | 273 | # Support for Project snippet scope 274 | !.vscode/*.code-snippets 275 | 276 | **/.DS_Store 277 | # End of https://www.toptal.com/developers/gitignore/api/python,jupyternotebooks,visualstudiocode,pycharm 278 | 279 | samples/scratches/ 280 | 281 | # Docs NodeJS stuff 282 | node_modules/ 283 | /docs/node_modules/ 284 | 285 | # Removing configurations because of issues moving between platforms 286 | .idea/ 287 | 288 | # Profiling output 289 | *.prof 290 | 291 | tests/output 292 | -------------------------------------------------------------------------------- /src/neighborly/components/location.py: -------------------------------------------------------------------------------- 1 | """Location Preference System. 2 | 3 | This module contains classes and functions that help characters decide where within the 4 | settlement they spend most of their time. Since the simulation does not model 5 | characters' positions throughout the settlement, this is a way of tracking who 6 | characters have the highest likelihood of interacting with during a time step. 7 | 8 | """ 9 | 10 | from typing import Any, Iterator 11 | 12 | import attrs 13 | from ordered_set import OrderedSet 14 | 15 | from neighborly.ecs import Component, GameObject 16 | from neighborly.preconditions.base_types import Precondition 17 | 18 | 19 | class FrequentedBy(Component): 20 | """Tracks the characters that frequent a location.""" 21 | 22 | __slots__ = ("_characters",) 23 | 24 | _characters: OrderedSet[GameObject] 25 | """GameObject IDs of characters that frequent the location.""" 26 | 27 | def __init__(self) -> None: 28 | super().__init__() 29 | self._characters = OrderedSet([]) 30 | 31 | def add_character(self, character: GameObject) -> None: 32 | """Add a character. 33 | 34 | Parameters 35 | ---------- 36 | character 37 | The GameObject reference to a character. 38 | """ 39 | self._characters.add(character) 40 | 41 | def remove_character(self, character: GameObject) -> bool: 42 | """Remove a character. 43 | 44 | Parameters 45 | ---------- 46 | character 47 | The character to remove. 48 | 49 | Returns 50 | ------- 51 | bool 52 | Returns True if a character was removed. False otherwise. 53 | """ 54 | if character in self._characters: 55 | self._characters.remove(character) 56 | return True 57 | 58 | return False 59 | 60 | def to_dict(self) -> dict[str, Any]: 61 | return { 62 | "characters": [entry.uid for entry in self._characters], 63 | } 64 | 65 | def __contains__(self, item: GameObject) -> bool: 66 | return item in self._characters 67 | 68 | def __iter__(self) -> Iterator[GameObject]: 69 | return iter(self._characters) 70 | 71 | def __str__(self): 72 | return repr(self) 73 | 74 | def __repr__(self): 75 | return f"{self.__class__.__name__}({self._characters})" 76 | 77 | 78 | class FrequentedLocations(Component): 79 | """Tracks the locations that a character frequents.""" 80 | 81 | __slots__ = ("_locations",) 82 | 83 | _locations: OrderedSet[GameObject] 84 | """A set of GameObject IDs of locations.""" 85 | 86 | def __init__(self) -> None: 87 | super().__init__() 88 | self._locations = OrderedSet([]) 89 | 90 | def add_location(self, location: GameObject) -> None: 91 | """Add a new location. 92 | 93 | Parameters 94 | ---------- 95 | location 96 | A GameObject reference to a location. 97 | """ 98 | self._locations.add(location) 99 | 100 | def remove_location(self, location: GameObject) -> bool: 101 | """Remove a location. 102 | 103 | Parameters 104 | ---------- 105 | location 106 | A GameObject reference to a location to remove. 107 | 108 | Returns 109 | ------- 110 | bool 111 | Returns True of a location was removed. False otherwise. 112 | """ 113 | if location in self._locations: 114 | self._locations.remove(location) 115 | return True 116 | return False 117 | 118 | def to_dict(self) -> dict[str, Any]: 119 | return {"locations": [entry.uid for entry in self._locations]} 120 | 121 | def __contains__(self, item: GameObject) -> bool: 122 | return item in self._locations 123 | 124 | def __iter__(self) -> Iterator[GameObject]: 125 | return iter(self._locations) 126 | 127 | def __str__(self) -> str: 128 | return repr(self) 129 | 130 | def __len__(self) -> int: 131 | return len(self._locations) 132 | 133 | def __repr__(self) -> str: 134 | return f"{self.__class__.__name__}({repr(self._locations)})" 135 | 136 | 137 | @attrs.define 138 | class LocationPreferenceRule: 139 | """A rule that helps characters score how they feel about locations to frequent.""" 140 | 141 | preconditions: list[Precondition] 142 | """Precondition functions to run when scoring a location.""" 143 | probability: float 144 | """The amount to apply to the score.""" 145 | source: object 146 | """The source of this location.""" 147 | 148 | def __call__(self, gameobject: GameObject) -> float: 149 | """Check all preconditions and return a weight modifier. 150 | 151 | Parameters 152 | ---------- 153 | gameobject 154 | A location to score. 155 | 156 | Returns 157 | ------- 158 | float 159 | A probability score from [0.0, 1.0] of the character frequenting the 160 | location. Or -1 if it does not pass the preconditions. 161 | """ 162 | 163 | if all(p(gameobject) for p in self.preconditions): 164 | return self.probability 165 | 166 | return -1.0 167 | 168 | 169 | class LocationPreferences(Component): 170 | """A component that manages a character's location preference rules.""" 171 | 172 | __slots__ = ("_rules",) 173 | 174 | _rules: list[LocationPreferenceRule] 175 | """Rules added to the location preferences.""" 176 | 177 | def __init__(self) -> None: 178 | super().__init__() 179 | self._rules = [] 180 | 181 | def add_rule(self, rule: LocationPreferenceRule) -> None: 182 | """Add a location preference rule.""" 183 | self._rules.append(rule) 184 | 185 | def remove_rule(self, rule: LocationPreferenceRule) -> None: 186 | """Remove a location preference rule.""" 187 | self._rules.remove(rule) 188 | 189 | def remove_rules_from_source(self, source: object) -> None: 190 | """Remove all preference rules from the given source.""" 191 | self._rules = [rule for rule in self._rules if rule.source != source] 192 | 193 | def score_location(self, location: GameObject) -> float: 194 | """Calculate a score for a character choosing to frequent this location. 195 | 196 | Parameters 197 | ---------- 198 | location 199 | A location to score 200 | 201 | Returns 202 | ------- 203 | float 204 | A probability score from [0.0, 1.0] 205 | """ 206 | 207 | cumulative_score: float = 0.5 208 | consideration_count: int = 1 209 | 210 | for rule in self._rules: 211 | consideration_score = rule(location) 212 | 213 | # Scores greater than zero are added to the cumulative score 214 | if consideration_score > 0: 215 | cumulative_score += consideration_score 216 | consideration_count += 1 217 | 218 | # Scores equal to zero make the entire score zero (make zero a veto value) 219 | elif consideration_score == 0.0: 220 | cumulative_score = 0.0 221 | break 222 | 223 | # Scores are averaged using the arithmetic mean 224 | final_score = cumulative_score / consideration_count 225 | 226 | return final_score 227 | 228 | def to_dict(self) -> dict[str, Any]: 229 | return {} 230 | -------------------------------------------------------------------------------- /docs/source/traits.rst: -------------------------------------------------------------------------------- 1 | .. _traits: 2 | 3 | Traits 4 | ====== 5 | 6 | Traits are tags that can be applied to entities like characters, relationships, and businesses. Traits can represent personality traits, faction memberships, and relationship types/statuses ("coworker", "dating", "secret-lover", ...). Their greatest strength is that users can specify effects that are triggered when the trait is added to a GameObject. Effects can range from stat/skill buffs to location preferences to social rules. When a trait is added to an object, all the effects are immediately applied. And when a trait is removed, all the effects are also undone. For example, a ``friendly`` trait might boost a character's ``sociability`` stat and provide a ``reputation`` boost on all relationships. When the trait is added, that character will immediately get the stat buff and a boost on all their active relationships. And when the trait is removed, they will lose their ``sociability`` boost, and all relationships will lose the ``reputation`` boost. 7 | 8 | If you are interested in learning how to create custom effect types and make them available for content authoring, please see the :ref:`effects_preconditions` page. 9 | 10 | Defining new traits 11 | ------------------- 12 | 13 | Users should define all traits within a JSON file(s). All traits can be specified within the same file. Please remember that if you specify a spawn_frequency, characters may spawn with that trait. If the trait is not meant for characters, this may result in runtime issues. Trait definitions have the following optional fields: 14 | 15 | - ``display_name``: (str) A one to two-word name for displaying in text. (default is the trait ID) 16 | - ``description``: (str) A short text description of the trait. (default is "") 17 | - ``effects``: (list[dict[str, Any]]) A list of effect objects data. (default is []) 18 | - ``conflicts_with``: (list[str]) A list of IDs of traits this trait cannot coexist with on the same object. (default is []) 19 | - ``spawn_frequency``: (float) (For character traits) The relative frequency of a character being gaining the trait when generated. (default is 0) 20 | - ``inheritance_chance_single``: (float) The probability of a character inheriting this trait if one parent has it. (default is 0) 21 | - ``inheritance_chance_both``: (float) The probability of a character inheriting this trait if both parents have it. (default is 0) 22 | 23 | Below is an example of a ``Gullible`` trait. As with other definition types, we start with a unique trait ID followed by a colon. All attributes for the trait are then specified below it. Notice that it has two effects specified for when this trait is added. Each effect starts with a ``type`` key that specifies the effect type, followed by additional key-value pair parameters to use when constructing the effect object. Here we have a sociability buff and a social rule. There are buff effects for all character and relationship stats. The ``AddSocialRule`` effect is special because it is how traits change a character's feelings toward other characters. Here we have a social rule that boosts the gullible character's outgoing reputation scores. See the Social Rules section of the :ref:`relationships` page for more information about social rules. Finally, we specify that it conflict with the trait that has the ID, skeptical, as well as stats for giving character's this trait at spawn. 24 | 25 | .. code-block:: json 26 | 27 | { 28 | "gullible": { 29 | "display_name": "Gullible", 30 | "description": "This character is more trusting of others to a fault.", 31 | "effects": [ 32 | { 33 | "type": "StatBuff", 34 | "stat": "sociability", 35 | "amount": 3 36 | }, 37 | { 38 | "type": "AddSocialRule", 39 | "effects": [ 40 | { 41 | "type": "StatBuff", 42 | "stat": "reputation", 43 | "amount": 5 44 | } 45 | ] 46 | } 47 | ], 48 | "conflicts_with": [ "skeptical" ], 49 | "spawn_frequency": 1, 50 | "inheritance_chance_single": 0.25, 51 | "inheritance_chance_both": 0.5 52 | } 53 | } 54 | 55 | Below is an example of the same trait defined using YAML 56 | 57 | .. code-block:: yaml 58 | 59 | gullible: 60 | display_name: Gullible 61 | description: This character is more trusting of others to a fault. 62 | effects: 63 | - type: StatBuff 64 | stat: Sociability 65 | amount: 3 66 | - type: AddSocialRule 67 | stat: reputation 68 | amount: 5 69 | conflicts_with: 70 | - skeptical 71 | spawn_frequency: 1 72 | inheritance_chance_single: 0.25 73 | inheritance_chance_both: 0.5 74 | 75 | Last, we have an example of a trait defined directly in Python. 76 | 77 | .. code-block:: python 78 | 79 | gullible = DefaultTraitDef( 80 | definition_id="gullible", 81 | 82 | ) 83 | 84 | 85 | 86 | 87 | Loading traits into the simulation 88 | ---------------------------------- 89 | 90 | Neighborly supplies users with loaders for various types of data. JSON files should contain all of one type of data. In this case, trait files should only contain trait definitions. Users can load their trait definitions into the simulation using the following function 91 | 92 | .. code-block:: python 93 | 94 | from neighborly.simulation import Simulation 95 | from neighborly.loaders import load_traits 96 | 97 | sim = Simulation() 98 | 99 | load_traits(sim, "path/to/file") 100 | 101 | 102 | 103 | Using traits from Python 104 | ------------------------ 105 | 106 | Neighborly has a few helper functions to help users interface with traits from Python -- `add_trait`, `has_trait`, and `remove_trait`. The helper functions are located in the `neighborly.helpers.traits` module. Users should use these functions instead of interfacing directly with the `Traits` component that is attached to all characters, relationships, and businesses. Each function accepts the GameObject to modify and the definition ID of the trait. 107 | 108 | .. code-block:: python 109 | 110 | from neighborly.simulation import Simulation 111 | from neighborly.helpers.traits import add_trait, has_trait, remove_trait 112 | from neighborly.helpers.relationships import get_relationship 113 | from neighborly.loaders import load_traits, load_characters 114 | 115 | sim = Simulation() 116 | 117 | # Load trait and character definition data 118 | load_traits(sim, "path/to/file") 119 | load_characters(sim, "path/to/file") 120 | 121 | # Traits are initialized at the start of the simulation 122 | sim.initialize() 123 | 124 | chris = create_character(sim.world, "farmer") 125 | 126 | # Add a trait to Chris with the ID "flirtatious" 127 | add_trait(chris, "flirtatious") 128 | 129 | # Create another character 130 | sam = create_character(sim.world, "farmer") 131 | 132 | # Adds two traits to the relationship from Chris to Sam 133 | add_trait(get_relationship(chris, sam), "friends") 134 | add_trait(get_relationship(chris, sam), "rivals") 135 | 136 | # Adds two traits to the relationship from Sam to Chris 137 | add_trait(get_relationship(sam, chris), "friends") 138 | add_trait(get_relationship(sam, chris), "rivals") 139 | 140 | # Chris is no longer flirtatious and any effects of the trait are removed 141 | remove_trait(chris, "flirtatious") 142 | -------------------------------------------------------------------------------- /src/neighborly/loaders.py: -------------------------------------------------------------------------------- 1 | """Content Loaders. 2 | 3 | This module contains definitions of helper functions that load various 4 | simulation data into a simulation. 5 | 6 | """ 7 | 8 | from __future__ import annotations 9 | 10 | import os 11 | from typing import Any, Type, Union 12 | 13 | import yaml 14 | 15 | from neighborly.libraries import ( 16 | BusinessLibrary, 17 | CharacterLibrary, 18 | DistrictLibrary, 19 | JobRoleLibrary, 20 | LifeEventLibrary, 21 | ResidenceLibrary, 22 | SettlementLibrary, 23 | SkillLibrary, 24 | TraitLibrary, 25 | ) 26 | from neighborly.life_event import LifeEvent 27 | from neighborly.simulation import Simulation 28 | from neighborly.tracery import Tracery 29 | 30 | 31 | def load_districts( 32 | sim: Simulation, file_path: Union[os.PathLike[str], str, bytes] 33 | ) -> None: 34 | """Load settlement district definition data from a data file. 35 | 36 | Parameters 37 | ---------- 38 | sim 39 | The simulation instance to load the data into 40 | file_path 41 | The path to the data file. 42 | """ 43 | with open(file_path, "r", encoding="utf8") as file: 44 | data: dict[str, dict[str, Any]] = yaml.safe_load(file) 45 | 46 | district_library = sim.world.resource_manager.get_resource(DistrictLibrary) 47 | 48 | for district_id, params in data.items(): 49 | district_library.add_definition_from_obj( 50 | {"definition_id": district_id, **params} 51 | ) 52 | 53 | 54 | def load_residences( 55 | sim: Simulation, file_path: Union[os.PathLike[str], str, bytes] 56 | ) -> None: 57 | """Load residential building definition data from a data file. 58 | 59 | Parameters 60 | ---------- 61 | sim 62 | The simulation instance to load the data into 63 | file_path 64 | The path to the data file. 65 | """ 66 | with open(file_path, "r", encoding="utf8") as file: 67 | data: dict[str, dict[str, Any]] = yaml.safe_load(file) 68 | 69 | residence_library = sim.world.resource_manager.get_resource(ResidenceLibrary) 70 | 71 | for residence_id, params in data.items(): 72 | residence_library.add_definition_from_obj( 73 | {"definition_id": residence_id, **params} 74 | ) 75 | 76 | 77 | def load_settlements( 78 | sim: Simulation, file_path: Union[os.PathLike[str], str, bytes] 79 | ) -> None: 80 | """Load settlement definition data from a data file. 81 | 82 | Parameters 83 | ---------- 84 | sim 85 | The simulation instance to load the data into 86 | file_path 87 | The path to the data file. 88 | """ 89 | with open(file_path, "r", encoding="utf8") as file: 90 | data: dict[str, dict[str, Any]] = yaml.safe_load(file) 91 | 92 | settlement_library = sim.world.resource_manager.get_resource(SettlementLibrary) 93 | 94 | for settlement_id, params in data.items(): 95 | settlement_library.add_definition_from_obj( 96 | {"definition_id": settlement_id, **params} 97 | ) 98 | 99 | 100 | def load_businesses( 101 | sim: Simulation, file_path: Union[os.PathLike[str], str, bytes] 102 | ) -> None: 103 | """Load business definition data from a data file. 104 | 105 | Parameters 106 | ---------- 107 | sim 108 | The simulation instance to load the data into 109 | file_path 110 | The path to the data file. 111 | """ 112 | with open(file_path, "r", encoding="utf8") as file: 113 | data: dict[str, dict[str, Any]] = yaml.safe_load(file) 114 | 115 | business_library = sim.world.resource_manager.get_resource(BusinessLibrary) 116 | 117 | for business_id, params in data.items(): 118 | business_library.add_definition_from_obj( 119 | {"definition_id": business_id, **params} 120 | ) 121 | 122 | 123 | def load_job_roles( 124 | sim: Simulation, file_path: Union[os.PathLike[str], str, bytes] 125 | ) -> None: 126 | """Load business definition data from a data file. 127 | 128 | Parameters 129 | ---------- 130 | sim 131 | The simulation instance to load the data into 132 | file_path 133 | The path to the data file. 134 | """ 135 | with open(file_path, "r", encoding="utf8") as file: 136 | data: dict[str, dict[str, Any]] = yaml.safe_load(file) 137 | 138 | job_role_library = sim.world.resource_manager.get_resource(JobRoleLibrary) 139 | 140 | for entry_id, params in data.items(): 141 | job_role_library.add_definition_from_obj({"definition_id": entry_id, **params}) 142 | 143 | 144 | def load_characters( 145 | sim: Simulation, file_path: Union[os.PathLike[str], str, bytes] 146 | ) -> None: 147 | """Load character definition data from a data file. 148 | 149 | Parameters 150 | ---------- 151 | sim 152 | The simulation instance to load the data into 153 | file_path 154 | The path to the data file. 155 | """ 156 | 157 | with open(file_path, "r", encoding="utf8") as file: 158 | data: dict[str, dict[str, Any]] = yaml.safe_load(file) 159 | 160 | character_library = sim.world.resource_manager.get_resource(CharacterLibrary) 161 | 162 | for character_id, params in data.items(): 163 | character_library.add_definition_from_obj( 164 | {"definition_id": character_id, **params} 165 | ) 166 | 167 | 168 | def load_traits( 169 | sim: Simulation, file_path: Union[os.PathLike[str], str, bytes] 170 | ) -> None: 171 | """Load trait definition data from a data file. 172 | 173 | Parameters 174 | ---------- 175 | sim 176 | The simulation instance to load the data into 177 | file_path 178 | The path to the data file. 179 | """ 180 | 181 | with open(file_path, "r", encoding="utf8") as file: 182 | data: dict[str, dict[str, Any]] = yaml.safe_load(file) 183 | 184 | trait_library = sim.world.resource_manager.get_resource(TraitLibrary) 185 | 186 | for trait_id, params in data.items(): 187 | trait_library.add_definition_from_obj({"definition_id": trait_id, **params}) 188 | 189 | 190 | def load_tracery( 191 | sim: Simulation, file_path: Union[os.PathLike[str], str, bytes] 192 | ) -> None: 193 | """Loads Tracery rules from a JSON file. 194 | 195 | Parameters 196 | ---------- 197 | sim 198 | The simulation instance. 199 | file_path 200 | The path of the data file to load. 201 | """ 202 | with open(file_path, "r", encoding="utf8") as file: 203 | rule_data: dict[str, list[str]] = yaml.safe_load(file) 204 | sim.world.resource_manager.get_resource(Tracery).add_rules(rule_data) 205 | 206 | 207 | def load_skills( 208 | sim: Simulation, file_path: Union[os.PathLike[str], str, bytes] 209 | ) -> None: 210 | """Load skill definition data from a data file. 211 | 212 | Parameters 213 | ---------- 214 | sim 215 | The simulation instance to load the data into 216 | file_path 217 | The path to the data file. 218 | """ 219 | 220 | with open(file_path, "r", encoding="utf8") as file: 221 | data: dict[str, dict[str, Any]] = yaml.safe_load(file) 222 | 223 | library = sim.world.resource_manager.get_resource(SkillLibrary) 224 | 225 | for definition_id, params in data.items(): 226 | library.add_definition_from_obj({"definition_id": definition_id, **params}) 227 | 228 | 229 | def register_life_event_type(sim: Simulation, life_event_type: Type[LifeEvent]) -> None: 230 | """Register a LifeEvent subtype with the simulation's library.""" 231 | sim.world.resource_manager.get_resource(LifeEventLibrary).add_event_type( 232 | life_event_type 233 | ) 234 | -------------------------------------------------------------------------------- /src/neighborly/components/spawn_table.py: -------------------------------------------------------------------------------- 1 | """Spawn Tables. 2 | 3 | Spawn tables are used to manage the relative frequency of certain content appearing in 4 | the simulation. 5 | 6 | """ 7 | 8 | from __future__ import annotations 9 | 10 | from typing import Any, TypedDict 11 | 12 | import polars as pl 13 | 14 | from neighborly.ecs import Component 15 | 16 | 17 | class CharacterSpawnTableEntry(TypedDict): 18 | """Data for a single row in a CharacterSpawnTable.""" 19 | 20 | name: str 21 | """The name of an entry.""" 22 | spawn_frequency: int 23 | """The relative frequency that this entry should spawn relative to others.""" 24 | 25 | 26 | class CharacterSpawnTable(Component): 27 | """Manages the frequency that character defs are spawned.""" 28 | 29 | __slots__ = ("_table",) 30 | 31 | _table: pl.DataFrame 32 | """Column names mapped to column data.""" 33 | 34 | def __init__(self, entries: list[CharacterSpawnTableEntry]) -> None: 35 | """ 36 | Parameters 37 | ---------- 38 | entries 39 | Starting entries. 40 | """ 41 | super().__init__() 42 | # The following line is type ignored since pl.from_dicts(...) expects a 43 | # sequence of dict[str, Any]. Typed dict is not a subclass of that type since 44 | # it does not use arbitrary keys. The Polars maintainers should update the 45 | # type hints for Mapping[str, Any] to allow TypeDict usage. 46 | self._table = pl.from_dicts( 47 | entries, schema=[("name", str), ("spawn_frequency", int)] # type: ignore 48 | ) 49 | 50 | @property 51 | def table(self) -> pl.DataFrame: 52 | """Get the spawn table as a data frame.""" 53 | return self._table 54 | 55 | def __len__(self) -> int: 56 | return len(self._table) 57 | 58 | def to_dict(self) -> dict[str, Any]: 59 | return {} 60 | 61 | 62 | class BusinessSpawnTableEntry(TypedDict): 63 | """A single row of data from a BusinessSpawnTable.""" 64 | 65 | name: str 66 | """The name of an entry.""" 67 | spawn_frequency: int 68 | """The relative frequency that this entry should spawn relative to others.""" 69 | max_instances: int 70 | """Max number of instances of the business that may exist.""" 71 | min_population: int 72 | """The minimum settlement population required to spawn.""" 73 | instances: int 74 | """The current number of active instances.""" 75 | 76 | 77 | class BusinessSpawnTable(Component): 78 | """Manages the frequency that business types are spawned""" 79 | 80 | __slots__ = ("_table",) 81 | 82 | _table: pl.DataFrame 83 | """Table data with entries.""" 84 | 85 | def __init__(self, entries: list[BusinessSpawnTableEntry]) -> None: 86 | """ 87 | Parameters 88 | ---------- 89 | entries 90 | Starting entries. 91 | """ 92 | super().__init__() 93 | # See comment in CharacterSpawnTable.__init__ for why this is type ignored 94 | self._table = pl.from_dicts( 95 | entries, # type: ignore 96 | schema=[ 97 | ("name", str), 98 | ("spawn_frequency", int), 99 | ("max_instances", int), 100 | ("min_population", int), 101 | ("instances", int), 102 | ], 103 | ) 104 | 105 | @property 106 | def table(self) -> pl.DataFrame: 107 | """Get the spawn table as a data frame.""" 108 | return self._table 109 | 110 | def increment_count(self, name: str) -> None: 111 | """Increment the instance count for an entry. 112 | 113 | Parameters 114 | ---------- 115 | name 116 | The name of entry to update 117 | """ 118 | self._table = self._table.with_columns( # type: ignore 119 | instances=pl.when(pl.col("name") == name) # type: ignore 120 | .then(pl.col("instances") + 1) 121 | .otherwise(pl.col("instances")) 122 | ) 123 | 124 | def decrement_count(self, name: str) -> None: 125 | """Increment the instance count for an entry. 126 | 127 | Parameters 128 | ---------- 129 | name 130 | The name of entry to update 131 | """ 132 | self._table = self._table.with_columns( # type: ignore 133 | instances=pl.when(pl.col("name") == name) # type: ignore 134 | .then(pl.col("instances") - 1) 135 | .otherwise(pl.col("instances")) 136 | ) 137 | 138 | def to_dict(self) -> dict[str, Any]: 139 | return {} 140 | 141 | def __len__(self) -> int: 142 | return len(self._table) 143 | 144 | 145 | class ResidenceSpawnTableEntry(TypedDict): 146 | """Data for a single row in a ResidenceSpawnTable.""" 147 | 148 | name: str 149 | """The name of an entry.""" 150 | spawn_frequency: int 151 | """The relative frequency that this entry should spawn relative to others.""" 152 | required_population: int 153 | """The number of people that need to live in the district.""" 154 | is_multifamily: bool 155 | """Is this a multifamily residential building.""" 156 | instances: int 157 | """The number of instances of this residence type""" 158 | max_instances: int 159 | """Max number of instances of the business that may exist.""" 160 | 161 | 162 | class ResidenceSpawnTable(Component): 163 | """Manages the frequency that residence types are spawned""" 164 | 165 | __slots__ = ("_table",) 166 | 167 | _table: pl.DataFrame 168 | """Column names mapped to column data.""" 169 | 170 | def __init__(self, entries: list[ResidenceSpawnTableEntry]) -> None: 171 | """ 172 | Parameters 173 | ---------- 174 | entries 175 | Starting entries. 176 | """ 177 | super().__init__() 178 | # See comment in CharacterSpawnTable.__init__ for why this is type ignored. 179 | self._table = pl.from_dicts( 180 | entries, # type: ignore 181 | schema=[ 182 | ("name", str), 183 | ("spawn_frequency", int), 184 | ("required_population", int), 185 | ("is_multifamily", bool), 186 | ("instances", int), 187 | ("max_instances", int), 188 | ], 189 | ) 190 | 191 | @property 192 | def table(self) -> pl.DataFrame: 193 | """Get the spawn table as a data frame.""" 194 | return self._table 195 | 196 | def increment_count(self, name: str) -> None: 197 | """Increment the instance count for an entry. 198 | 199 | Parameters 200 | ---------- 201 | name 202 | The name of entry to update 203 | """ 204 | self._table = self._table.with_columns( # type: ignore 205 | instances=pl.when(pl.col("name") == name) # type: ignore 206 | .then(pl.col("instances") + 1) 207 | .otherwise(pl.col("instances")) 208 | ) 209 | 210 | def decrement_count(self, name: str) -> None: 211 | """Increment the instance count for an entry. 212 | 213 | Parameters 214 | ---------- 215 | name 216 | The name of entry to update 217 | """ 218 | self._table = self._table.with_columns( # type: ignore 219 | instances=pl.when(pl.col("name") == name) # type: ignore 220 | .then(pl.col("instances") - 1) 221 | .otherwise(pl.col("instances")) 222 | ) 223 | 224 | def __len__(self) -> int: 225 | return len(self._table) 226 | 227 | def to_dict(self) -> dict[str, Any]: 228 | return {} 229 | -------------------------------------------------------------------------------- /docs/source/characters.rst: -------------------------------------------------------------------------------- 1 | .. _characters: 2 | 3 | Characters 4 | ========== 5 | 6 | Characters inhabit the settlement, own businesses, interact, and generate events that we will later mine for emergent stories. Character definitions are specifications for what types of characters can spawn within a district. They do not represent any one particular character, but rather a class of characters. For example, a ``person`` definition might create generic human characters with a subset of randomly selected traits, while an ``blacksmith`` definition might create similar character and give them a bonus Blacksmithing skill at spawn. 7 | 8 | Each timestep there is a chance that a new character will spawn into a vacant residential unit in the settlement. By default, characters always spawn in as single adult-aged individuals and **not** as families. This simplifies the process that's required to make families look believable. 9 | 10 | Characters have the following default components that manage their internal data. Custom character implementations should ensure that these components are present when instantiating Character GameObjects. 11 | 12 | - ``Character``: Character demographic data such as their first name, last name, age, sex, and species. 13 | - ``Traits``: A collection of traits attached to the character. 14 | - ``Skills``: A mapping of Skills to skill level stats. 15 | - ``Stats``: A mapping of stat ID's to Stat instances. 16 | - ``FrequentedLocations``: A collection of locations that this character frequents. 17 | - ``Relationships``: Manages references to all the relationship instances for how this character feels about others and how others feel about this character. 18 | - ``LocationPreferences``: A collection of rules that determine what locations a character is most likely to frequent during a month. 19 | - ``SocialRules``: A collection of social rules that affect a character's relationship's stats. 20 | - ``PersonalEventHistory``: Stores a list of all the life events that have directly involved this characters. 21 | 22 | Sexes 23 | ----- 24 | 25 | Every character has a biological sex that is stored within their ``Character`` components. A character's sex may be ``MALE``, ``FEMALE``, or ``NOT_SPECIFIED``. We represent it this way to simplify reproduction calculations. 26 | 27 | Life stages 28 | ----------- 29 | 30 | As characters get older, they can age physically. Characters have the following life stages they can progress through: ``CHILD``, ``ADOLESCENT``, ``YOUNG_ADULT``, ``ADULT``, and ``SENIOR``. The ages at which character reach these life stages varies based on the character's species. Events and systems can use a character's life stage to determine when and if character's should engage in certain behaviors. 31 | 32 | Species 33 | ------- 34 | 35 | Each character has a species that defines parameters for their biological processes. For example, it handles aging parameters for when characters change life stages and character lifespans. 36 | 37 | Species are specified as a subtype of ``Trait``. This means that species can take advantage of all the same ``Effects`` that normal traits do. Species should be defined within JSON files containing other trait or species definitions. So that the system recognizes the traits as species, add a ``"definition_type": "species"`` attribute to the definition. This will tell the ``TraitLibrary`` to load the definition as a new species. 38 | 39 | The example below shows a definition for a ``human`` species. 40 | 41 | .. code-block:: json 42 | 43 | { 44 | "human": { 45 | "definition_type": "species", 46 | "display_name": "Human", 47 | "description": "A plain ol' human being.", 48 | "adolescent_age": 13, 49 | "young_adult_age": 20, 50 | "adult_age": 30, 51 | "senior_age": 65, 52 | "lifespan": 80, 53 | } 54 | } 55 | 56 | Character stats 57 | --------------- 58 | 59 | Character have the following stats by default: 60 | 61 | - ``boldness``: How bold is a character, [0, 255] 62 | - ``stewardship``: A measure of their capabilities for organization and leadership, [0, 255] 63 | - ``sociability``: A character's tendency toward social behavior, [0, 255] 64 | - ``attractiveness``: A measure of a character's aesthetic attractiveness [0, 255] 65 | - ``intelligence``: A measure of a character's education or intellect 66 | - ``reliability``: How reliable is a character [0, 255] 67 | - ``fertility``: The probability of a character being able to conceive a child. [0.0, 1.0] 68 | - ``health``: How far is a character from death. [0, inf] 69 | - ``health_decay``: The amount of health lost each year the character is alive. [-100, 100] 70 | 71 | Defining new character types 72 | ---------------------------- 73 | 74 | Users should define new character types within JSON data file that contain other character definitions. These definitions can be loaded into a simulation using the ``load_characters(sim, "path/to/file")`` function provided by the ``neighborly.loaders`` module. 75 | 76 | Default character definition parameters 77 | --------------------------------------- 78 | 79 | - ``spawn_frequency``: The frequency of spawning relative to others in the district. (Defaults to 1) 80 | - ``species``: IDs of species to choose from and assign to the character. (Defaults to []) 81 | - ``max_traits``: The max number of random traits this character type spawns with. (Defaults to 5) 82 | - ``default_traits``: List of trait IDs of trait automatically applied to the character during generation. (Defaults to []) 83 | - ``default_skills``: Key value pairs of trait IDs mapped to strings containing min-max interval values for skills. 84 | 85 | Character definitions specify parameters for creating new instances of characters. 86 | 87 | .. code-block:: json 88 | 89 | { 90 | "person": { 91 | "spawn_frequency": 1, 92 | "species": [ 93 | "human" 94 | ], 95 | "gender": [ 96 | "Male", 97 | "Female" 98 | ], 99 | "max_traits": 3 100 | }, 101 | "farmer": { 102 | "spawn_frequency": 1, 103 | "species": [ 104 | "human" 105 | ], 106 | "gender": [ 107 | "Male", 108 | "Female" 109 | ], 110 | "max_traits": 3, 111 | "skills": { 112 | "farming": "20 - 230" 113 | } 114 | } 115 | } 116 | 117 | How do characters get traits? 118 | ----------------------------- 119 | 120 | All traits that have a spawn_frequency greater than zero are considered for selection when generating a new character. The default is to select a max of 3 eligible traits. This all happens within a call to ``create_character(world, "definition_id")``. If you want to change the max number of spawned traits, add the `n_traits` keyword argument to the ``create_character`` function call. The following code would create a new character using the "aristocrat" definition with a maximum of 8 traits. 121 | 122 | .. code-block:: python 123 | 124 | create_character(sim.world, "aristocrat", n_traits=8) 125 | 126 | 127 | Reproduction 128 | ------------ 129 | 130 | Female characters have a chance to get pregnant while in romantic relationships with a male character. This depends on their fertility values. By default, a couple's chance to conceive is the average of their fertility scores. 131 | 132 | When a character becomes pregnant, they will gain a ``Pregnant`` component that contains a reference to other parent of the conceived child, and the date the child will be born. After nine months of simulation time, a new child is spawned into the simulation. Its attributes are a mix of the parents. 133 | --------------------------------------------------------------------------------