├── tests ├── __init__.py ├── lib │ ├── __init__.py │ └── test_serialized.py ├── datastore │ ├── __init__.py │ ├── test_datastore.py │ ├── test_numpy_datastore.py │ └── test_id_allocator.py ├── entity │ ├── __init__.py │ └── test_entity.py ├── task │ ├── .gitignore │ ├── sample_curriculum.pkl │ ├── test_sample_task_from_file.py │ └── test_task_system_perf.py ├── test_memory_usage.py ├── test_rollout.py ├── test_pettingzoo.py ├── conftest.py ├── render │ ├── test_load_replay.py │ └── test_render_save.py ├── core │ ├── test_immutable_tile_property.py │ ├── test_tile.py │ ├── test_map_generation.py │ └── test_gym_obs_spaces.py ├── systems │ ├── test_item.py │ ├── test_exchange.py │ └── test_skill_level.py ├── action │ └── test_monkey_action.py ├── test_determinism.py ├── test_eventlog.py └── test_performance.py ├── nmmo ├── core │ ├── __init__.py │ ├── agent.py │ ├── tile.py │ ├── map.py │ ├── log_helper.py │ └── realm.py ├── lib │ ├── __init__.py │ ├── log.py │ ├── seeding.py │ ├── team_helper.py │ ├── utils.py │ ├── spawn.py │ ├── colors.py │ ├── material.py │ └── event_log.py ├── render │ ├── __init__.py │ ├── render_client.py │ ├── render_utils.py │ ├── replay_helper.py │ ├── websocket.py │ └── overlay.py ├── datastore │ ├── __init__.py │ ├── id_allocator.py │ ├── numpy_datastore.py │ ├── datastore.py │ └── serialized.py ├── version.py ├── systems │ ├── __init__.py │ ├── ai │ │ ├── __init__.py │ │ ├── policy.py │ │ ├── move.py │ │ ├── behavior.py │ │ └── utils.py │ ├── droptable.py │ ├── combat.py │ ├── inventory.py │ └── exchange.py ├── resource │ ├── ore.png │ ├── fish.png │ ├── grass.png │ ├── herb.png │ ├── ocean.png │ ├── scrub.png │ ├── slag.png │ ├── spawn.png │ ├── stone.png │ ├── stump.png │ ├── tree.png │ ├── void.png │ ├── water.png │ ├── weeds.png │ ├── crystal.png │ ├── foilage.png │ └── fragment.png ├── entity │ ├── __init__.py │ ├── player.py │ ├── entity_manager.py │ └── npc.py ├── task │ ├── __init__.py │ ├── group.py │ ├── constraint.py │ └── task_spec.py └── __init__.py ├── scripted ├── __init__.py └── attack.py ├── MANIFEST.in ├── .gitattributes ├── utils ├── run-perf-tests.sh ├── pre-git-check.sh └── git-pr.sh ├── .github ├── ISSUE_TEMPLATE │ ├── documentation.md │ ├── bug_report.md │ ├── feature_request.md │ └── enhancement.md └── workflows │ └── pylint-test.yml ├── README.md ├── LICENSE ├── .pylintrc ├── setup.py └── .gitignore /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /nmmo/core/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /nmmo/lib/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /nmmo/render/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /scripted/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/lib/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /nmmo/datastore/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/datastore/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/entity/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include nmmo/resource/* 2 | -------------------------------------------------------------------------------- /nmmo/version.py: -------------------------------------------------------------------------------- 1 | __version__ = '2.0.0' 2 | -------------------------------------------------------------------------------- /tests/task/.gitignore: -------------------------------------------------------------------------------- 1 | test_held_out_tasks.py -------------------------------------------------------------------------------- /nmmo/systems/__init__.py: -------------------------------------------------------------------------------- 1 | from .skill import Skill 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | neural_mmo/_version.py export-subst 2 | -------------------------------------------------------------------------------- /nmmo/resource/ore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CarperAI/nmmo-environment/HEAD/nmmo/resource/ore.png -------------------------------------------------------------------------------- /nmmo/entity/__init__.py: -------------------------------------------------------------------------------- 1 | from nmmo.entity.entity import Entity 2 | from nmmo.entity.player import Player 3 | -------------------------------------------------------------------------------- /nmmo/resource/fish.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CarperAI/nmmo-environment/HEAD/nmmo/resource/fish.png -------------------------------------------------------------------------------- /nmmo/resource/grass.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CarperAI/nmmo-environment/HEAD/nmmo/resource/grass.png -------------------------------------------------------------------------------- /nmmo/resource/herb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CarperAI/nmmo-environment/HEAD/nmmo/resource/herb.png -------------------------------------------------------------------------------- /nmmo/resource/ocean.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CarperAI/nmmo-environment/HEAD/nmmo/resource/ocean.png -------------------------------------------------------------------------------- /nmmo/resource/scrub.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CarperAI/nmmo-environment/HEAD/nmmo/resource/scrub.png -------------------------------------------------------------------------------- /nmmo/resource/slag.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CarperAI/nmmo-environment/HEAD/nmmo/resource/slag.png -------------------------------------------------------------------------------- /nmmo/resource/spawn.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CarperAI/nmmo-environment/HEAD/nmmo/resource/spawn.png -------------------------------------------------------------------------------- /nmmo/resource/stone.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CarperAI/nmmo-environment/HEAD/nmmo/resource/stone.png -------------------------------------------------------------------------------- /nmmo/resource/stump.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CarperAI/nmmo-environment/HEAD/nmmo/resource/stump.png -------------------------------------------------------------------------------- /nmmo/resource/tree.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CarperAI/nmmo-environment/HEAD/nmmo/resource/tree.png -------------------------------------------------------------------------------- /nmmo/resource/void.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CarperAI/nmmo-environment/HEAD/nmmo/resource/void.png -------------------------------------------------------------------------------- /nmmo/resource/water.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CarperAI/nmmo-environment/HEAD/nmmo/resource/water.png -------------------------------------------------------------------------------- /nmmo/resource/weeds.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CarperAI/nmmo-environment/HEAD/nmmo/resource/weeds.png -------------------------------------------------------------------------------- /nmmo/resource/crystal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CarperAI/nmmo-environment/HEAD/nmmo/resource/crystal.png -------------------------------------------------------------------------------- /nmmo/resource/foilage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CarperAI/nmmo-environment/HEAD/nmmo/resource/foilage.png -------------------------------------------------------------------------------- /nmmo/resource/fragment.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CarperAI/nmmo-environment/HEAD/nmmo/resource/fragment.png -------------------------------------------------------------------------------- /nmmo/systems/ai/__init__.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=import-self 2 | from . import utils, move, behavior, policy 3 | -------------------------------------------------------------------------------- /nmmo/task/__init__.py: -------------------------------------------------------------------------------- 1 | from .game_state import * 2 | from .predicate_api import * 3 | from .task_api import * 4 | -------------------------------------------------------------------------------- /tests/task/sample_curriculum.pkl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CarperAI/nmmo-environment/HEAD/tests/task/sample_curriculum.pkl -------------------------------------------------------------------------------- /utils/run-perf-tests.sh: -------------------------------------------------------------------------------- 1 | pytest --benchmark-columns=ops,rounds,median,mean,stddev,min,max,iterations --benchmark-max-time=5 --benchmark-min-rounds=500 \ 2 | --benchmark-warmup=on --benchmark-warmup-iterations=300 tests/test_performance.py 3 | -------------------------------------------------------------------------------- /tests/test_memory_usage.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=bad-builtin, unused-variable 2 | import psutil 3 | 4 | import nmmo 5 | 6 | def test_memory_usage(): 7 | env = nmmo.Env() 8 | process = psutil.Process() 9 | print("memory", process.memory_info().rss) 10 | 11 | if __name__ == '__main__': 12 | test_memory_usage() 13 | -------------------------------------------------------------------------------- /tests/test_rollout.py: -------------------------------------------------------------------------------- 1 | import nmmo 2 | from scripted.baselines import Random 3 | 4 | def test_rollout(): 5 | config = nmmo.config.Default() 6 | config.PLAYERS = [Random] 7 | 8 | env = nmmo.Env(config) 9 | env.reset() 10 | for _ in range(128): 11 | env.step({}) 12 | 13 | if __name__ == '__main__': 14 | test_rollout() 15 | -------------------------------------------------------------------------------- /tests/test_pettingzoo.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | import nmmo 4 | from scripted import baselines 5 | 6 | def test_pettingzoo_api(): 7 | config = nmmo.config.Default() 8 | config.PLAYERS = [baselines.Random] 9 | # ensv = nmmo.Env(config) 10 | # TODO: disabled due to Env not implementing the correct PettinZoo step() API 11 | # parallel_api_test(env, num_cycles=1000) 12 | 13 | 14 | if __name__ == '__main__': 15 | test_pettingzoo_api() 16 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | 2 | #pylint: disable=unused-argument 3 | 4 | import logging 5 | logging.basicConfig(level=logging.INFO, stream=None) 6 | 7 | def pytest_benchmark_scale_unit(config, unit, benchmarks, best, worst, sort): 8 | if unit == 'seconds': 9 | prefix = 'millisec' 10 | scale = 1000 11 | elif unit == 'operations': 12 | prefix = '' 13 | scale = 1 14 | else: 15 | raise RuntimeError(f"Unexpected measurement unit {unit}") 16 | return prefix, scale 17 | -------------------------------------------------------------------------------- /nmmo/datastore/id_allocator.py: -------------------------------------------------------------------------------- 1 | from ordered_set import OrderedSet 2 | 3 | class IdAllocator: 4 | def __init__(self, max_id): 5 | # Key 0 is reserved as padding 6 | self.max_id = 1 7 | self.free = OrderedSet() 8 | self.expand(max_id) 9 | 10 | def full(self): 11 | return len(self.free) == 0 12 | 13 | def remove(self, row_id): 14 | self.free.add(row_id) 15 | 16 | def allocate(self): 17 | return self.free.pop(0) 18 | 19 | def expand(self, max_id): 20 | self.free.update(range(self.max_id, max_id)) 21 | self.max_id = max_id 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/documentation.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Documentation 3 | about: Report problems with the documentation 4 | title: "[Docs]" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | One of: 11 | 12 | **Insufficient**: Something is documented, but the current documentation is inadequate 13 | 14 | **Missing**: Something is undocumented. Note that most internal functions should be considered self-documenting -- consider submitting an enhancement report for refactoring if they are not. 15 | 16 | **Other**: Something else. We will update this template to include your problem category afterwards. 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report a bug with the Neural MMO environment or Unity3D Embyr client 4 | title: "[Bug Report]" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | Fill out as much of the below as you can. A partial bug report is better than no bug report. After submitting, link your issue on our [Discord](https://discord.gg/BkMmFUC) #support channel 11 | 12 | **OS:** Your operating system 13 | 14 | **Description:** What's wrong 15 | 16 | **Repro:** How do we reproduce the issue? Minimal scripts are best. Instructions are acceptable. "I don't know" is valid. 17 | -------------------------------------------------------------------------------- /tests/render/test_load_replay.py: -------------------------------------------------------------------------------- 1 | '''Manual test for rendering replay''' 2 | 3 | if __name__ == '__main__': 4 | import time 5 | 6 | # pylint: disable=import-error 7 | from nmmo.render.render_client import WebsocketRenderer 8 | from nmmo.render.replay_helper import FileReplayHelper 9 | 10 | # open a client 11 | renderer = WebsocketRenderer() 12 | time.sleep(3) 13 | 14 | # load a replay: replace 'replay_dev.json' with your replay file 15 | replay = FileReplayHelper.load('replay_dev.json') 16 | 17 | # run the replay 18 | for packet in replay: 19 | renderer.render_packet(packet) 20 | time.sleep(1) 21 | -------------------------------------------------------------------------------- /nmmo/core/agent.py: -------------------------------------------------------------------------------- 1 | class Agent: 2 | policy = 'Neural' 3 | 4 | def __init__(self, config, idx): 5 | '''Base class for agents 6 | 7 | Args: 8 | config: A Config object 9 | idx: Unique AgentID int 10 | ''' 11 | self.config = config 12 | self.iden = idx 13 | self._np_random = None 14 | 15 | def __call__(self, obs): 16 | '''Used by scripted agents to compute actions. Override in subclasses. 17 | 18 | Args: 19 | obs: Agent observation provided by the environment 20 | ''' 21 | 22 | def set_rng(self, np_random): 23 | '''Set the random number generator for the agent for reproducibility 24 | 25 | Args: 26 | np_random: A numpy random.Generator object 27 | ''' 28 | self._np_random = np_random 29 | 30 | class Scripted(Agent): 31 | '''Base class for scripted agents''' 32 | policy = 'Scripted' 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![figure](https://neuralmmo.github.io/_static/banner.png) 2 | 3 | # ![icon](https://neuralmmo.github.io/_build/html/_images/icon.png) Welcome to the Platform! 4 | 5 | [![PyPI version](https://badge.fury.io/py/nmmo.svg)](https://badge.fury.io/py/nmmo) 6 | [![](https://dcbadge.vercel.app/api/server/BkMmFUC?style=plastic)](https://discord.gg/BkMmFUC) 7 | [![Twitter](https://img.shields.io/twitter/url/https/twitter.com/cloudposse.svg?style=social&label=Follow%20%40jsuarez5341)](https://twitter.com/jsuarez5341) 8 | 9 | Neural MMO is a massively multiagent environment for artificial intelligence research inspired by Massively Multiplayer Online (MMO) role-playing games. The project is under active development with [Documentation](https://neuralmmo.github.io "Neural MMO Documentation") hosted by github.io. 10 | 11 | ![figure](https://neuralmmo.github.io/_build/html/_images/poster.png) 12 | -------------------------------------------------------------------------------- /tests/task/test_sample_task_from_file.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import nmmo 4 | from tests.testhelpers import ScriptedAgentTestConfig 5 | 6 | class TestSampleTaskFromFile(unittest.TestCase): 7 | def test_sample_task_from_file(self): 8 | # init the env with the pickled training task spec 9 | config = ScriptedAgentTestConfig() 10 | config.CURRICULUM_FILE_PATH = 'tests/task/sample_curriculum.pkl' 11 | env = nmmo.Env(config) 12 | 13 | # env.reset() samples and instantiates a task for each agent 14 | # when sample_traning_tasks is set True 15 | env.reset() 16 | 17 | self.assertEqual(len(env.possible_agents), len(env.tasks)) 18 | # for the training tasks, the task assignee and subject should be the same 19 | for task in env.tasks: 20 | self.assertEqual(task.assignee, task.subject) 21 | 22 | if __name__ == '__main__': 23 | unittest.main() 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Request a feature for the Neural MMO environment of Unity3D Embyr client 4 | title: "[Feature Request]" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | Eventually, feature requests will be treated as a scrum board for open-source contributors. At the current scale, you should come chat with us on the [Discord](https://discord.gg/BkMmFUC) #development channel before writing one of these. 11 | 12 | **I am trying to:** Describe your use case. What is the end result you would like to achieve? 13 | 14 | **It is hard/impossible because:" Is this a core missing feature? Is Neural MMO structured in a way that makes what you are trying to do unnecessarily hard? Is documentation missing or confusing? 15 | 16 | **The solution should look like:** Describe your ideal solution -- a requirement, an API, a restructuring, additional documentation, etc. 17 | -------------------------------------------------------------------------------- /.github/workflows/pylint-test.yml: -------------------------------------------------------------------------------- 1 | name: pylint-test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | python-version: ["3.8", "3.9", "3.10"] 11 | steps: 12 | - uses: actions/checkout@v3 13 | - name: Set up Python ${{ matrix.python-version }} 14 | uses: actions/setup-python@v3 15 | with: 16 | python-version: ${{ matrix.python-version }} 17 | - name: Install dependencies 18 | run: | 19 | python -m pip install --upgrade pip setuptools wheel 20 | pip install . 21 | - name: Running unit tests 22 | run: pytest 23 | - name: Analysing the code with pylint 24 | run: pylint --recursive=y nmmo tests 25 | - name: Looking for xcxc, just in case 26 | run: | 27 | if grep -r --include='*.py' 'xcxc'; then 28 | echo "Found xcxc in the code. Please check the file." 29 | exit 1 30 | fi 31 | -------------------------------------------------------------------------------- /nmmo/systems/ai/policy.py: -------------------------------------------------------------------------------- 1 | 2 | from nmmo.systems.ai import behavior, utils 3 | 4 | def passive(realm, entity): 5 | behavior.update(entity) 6 | actions = {} 7 | 8 | behavior.meander(realm, actions, entity) 9 | 10 | return actions 11 | 12 | def neutral(realm, entity): 13 | behavior.update(entity) 14 | actions = {} 15 | 16 | if not entity.attacker: 17 | behavior.meander(realm, actions, entity) 18 | else: 19 | entity.target = entity.attacker 20 | behavior.hunt(realm, actions, entity) 21 | 22 | return actions 23 | 24 | def hostile(realm, entity): 25 | behavior.update(entity) 26 | actions = {} 27 | 28 | # This is probably slow 29 | if not entity.target: 30 | entity.target = utils.closestTarget(entity, realm.map.tiles, 31 | rng=entity.vision) 32 | 33 | if not entity.target: 34 | behavior.meander(realm, actions, entity) 35 | else: 36 | behavior.hunt(realm, actions, entity) 37 | 38 | return actions 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 OpenAI, 2020 Joseph Suarez 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MESSAGES CONTROL] 2 | 3 | disable=W0511, # TODO/FIXME 4 | W0105, # string is used as a statement 5 | C0114, # missing module docstring 6 | C0115, # missing class docstring 7 | C0116, # missing function docstring 8 | W0221, # arguments differ from overridden method 9 | C0415, # import outside toplevel 10 | E0611, # no name in module 11 | R0901, # too many ancestors 12 | R0902, # too many instance attributes 13 | R0903, # too few public methods 14 | R0911, # too many return statements 15 | R0912, # too many branches 16 | R0913, # too many arguments 17 | R0914, # too many local variables 18 | R0914, # too many local variables 19 | R0915, # too many statements 20 | R0401, # cyclic import 21 | 22 | [INDENTATION] 23 | indent-string=' ' 24 | 25 | [MASTER] 26 | good-names-rgxs=^[_a-zA-Z][_a-z0-9]?$ # whitelist short variables 27 | known-third-party=ordered_set,numpy,gym,pettingzoo,vec_noise,imageio,scipy,tqdm 28 | load-plugins=pylint.extensions.bad_builtin 29 | 30 | [BASIC] 31 | bad-functions=print # checks if these functions are used -------------------------------------------------------------------------------- /tests/datastore/test_datastore.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import numpy as np 4 | 5 | from nmmo.datastore.numpy_datastore import NumpyDatastore 6 | 7 | 8 | class TestDatastore(unittest.TestCase): 9 | 10 | def testdatastore_record(self): 11 | datastore = NumpyDatastore() 12 | datastore.register_object_type("TestObject", 2) 13 | c1 = 0 14 | c2 = 1 15 | 16 | o = datastore.create_record("TestObject") 17 | self.assertEqual([o.get(c1), o.get(c2)], [0, 0]) 18 | 19 | o.update(c1, 1) 20 | o.update(c2, 2) 21 | self.assertEqual([o.get(c1), o.get(c2)], [1, 2]) 22 | 23 | np.testing.assert_array_equal( 24 | datastore.table("TestObject").get([o.id]), 25 | np.array([[1, 2]])) 26 | 27 | o2 = datastore.create_record("TestObject") 28 | o2.update(c2, 2) 29 | np.testing.assert_array_equal( 30 | datastore.table("TestObject").get([o.id, o2.id]), 31 | np.array([[1, 2], [0, 2]])) 32 | 33 | np.testing.assert_array_equal( 34 | datastore.table("TestObject").where_eq(c2, 2), 35 | np.array([[1, 2], [0, 2]])) 36 | 37 | o.delete() 38 | np.testing.assert_array_equal( 39 | datastore.table("TestObject").where_eq(c2, 2), 40 | np.array([[0, 2]])) 41 | 42 | if __name__ == '__main__': 43 | unittest.main() 44 | -------------------------------------------------------------------------------- /tests/core/test_immutable_tile_property.py: -------------------------------------------------------------------------------- 1 | # Test immutable invariants assumed for certain optimizations 2 | 3 | import unittest 4 | 5 | import copy 6 | import nmmo 7 | from scripted.baselines import Random 8 | 9 | def rollout(): 10 | config = nmmo.config.Default() 11 | config.PLAYERS = [Random] 12 | env = nmmo.Env(config) 13 | env.reset() 14 | start = copy.deepcopy(env.realm) 15 | for _ in range(64): 16 | env.step({}) 17 | end = copy.deepcopy(env.realm) 18 | return (start, end) 19 | 20 | class TestImmutableTileProperty(unittest.TestCase): 21 | 22 | def test_passability_immutable(self): 23 | # Used in optimization that caches the result of A* 24 | start, end = rollout() 25 | start_passable = [tile.impassible for tile in start.map.tiles.flatten()] 26 | end_passable = [tile.impassible for tile in end.map.tiles.flatten()] 27 | self.assertListEqual(start_passable, end_passable) 28 | 29 | def test_habitability_immutable(self): 30 | # Used in optimization with habitability lookup table 31 | start, end = rollout() 32 | start_habitable = [tile.habitable for tile in start.map.tiles.flatten()] 33 | end_habitable = [tile.habitable for tile in end.map.tiles.flatten()] 34 | self.assertListEqual(start_habitable, end_habitable) 35 | 36 | if __name__ == '__main__': 37 | unittest.main() 38 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/enhancement.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Enhancement 3 | about: Suggest an improvement to an API or a refactorization of existing code for 4 | better efficiency or clarity 5 | title: "[Enhancement]" 6 | labels: '' 7 | assignees: '' 8 | 9 | --- 10 | 11 | This feature template is mostly used by the developers to track ongoing tasks, but users are also free to suggest additional enhancements or submit PRs solving existing ones. At the current scale, you should come chat with us on the Discord #development channel before writing one of these. 12 | 13 | Try to match one of the templates below. If you can't, use the "other" template for now and we'll add a new template matching your issue afterwards. 14 | 15 | **Dead code**: A piece of code is unused and should be deleted. The most common case for a dead code report occurs when we have replaced an older, clunkier routine but have neglected to delete the original. Check to make sure that you are not reporting a util function or paused-development feature before submitting. 16 | 17 | **Confusing code**: A piece of code is difficult to parse and should be refactored or at least commented. These are subjective, but we take them seriously. Neural MMO is designed to be hackable -- the internals matter just as much as the user API. 18 | 19 | **Poor performance**: A function or subroutine is slow. Describe cases in which this functionality becomes a bottleneck and submit timing data. 20 | -------------------------------------------------------------------------------- /tests/datastore/test_numpy_datastore.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import numpy as np 4 | 5 | from nmmo.datastore.numpy_datastore import NumpyTable 6 | 7 | # pylint: disable=protected-access 8 | class TestNumpyTable(unittest.TestCase): 9 | def test_continous_table(self): 10 | table = NumpyTable(3, 10, np.float32) 11 | table.update(2, 0, 2.1) 12 | table.update(2, 1, 2.2) 13 | table.update(5, 0, 5.1) 14 | table.update(5, 2, 5.3) 15 | np.testing.assert_array_equal( 16 | table.get([1,2,5]), 17 | np.array([[0, 0, 0], [2.1, 2.2, 0], [5.1, 0, 5.3]], dtype=np.float32) 18 | ) 19 | 20 | def test_discrete_table(self): 21 | table = NumpyTable(3, 10, np.int32) 22 | table.update(2, 0, 11) 23 | table.update(2, 1, 12) 24 | table.update(5, 0, 51) 25 | table.update(5, 2, 53) 26 | np.testing.assert_array_equal( 27 | table.get([1,2,5]), 28 | np.array([[0, 0, 0], [11, 12, 0], [51, 0, 53]], dtype=np.int32) 29 | ) 30 | 31 | def test_expand(self): 32 | table = NumpyTable(3, 10, np.float32) 33 | 34 | table.update(2, 0, 2.1) 35 | with self.assertRaises(IndexError): 36 | table.update(10, 0, 10.1) 37 | 38 | table._expand(11) 39 | table.update(10, 0, 10.1) 40 | 41 | np.testing.assert_array_equal( 42 | table.get([10, 2]), 43 | np.array([[10.1, 0, 0], [2.1, 0, 0]], dtype=np.float32) 44 | ) 45 | 46 | if __name__ == '__main__': 47 | unittest.main() 48 | -------------------------------------------------------------------------------- /scripted/attack.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=invalid-name, unused-argument 2 | import numpy as np 3 | 4 | import nmmo 5 | from nmmo.core.observation import Observation 6 | from nmmo.entity.entity import EntityState 7 | from nmmo.lib import utils 8 | 9 | 10 | def closestTarget(config, ob: Observation): 11 | shortestDist = np.inf 12 | closestAgent = None 13 | 14 | agent = ob.agent() 15 | start = (agent.row, agent.col) 16 | 17 | for target_ent in ob.entities.values: 18 | target_ent = EntityState.parse_array(target_ent) 19 | if target_ent.id == agent.id: 20 | continue 21 | 22 | dist = utils.linf_single(start, (target_ent.row, target_ent.col)) 23 | if dist < shortestDist and dist != 0: 24 | shortestDist = dist 25 | closestAgent = target_ent 26 | 27 | if closestAgent is None: 28 | return None, None 29 | 30 | return closestAgent, shortestDist 31 | 32 | def attacker(config, ob: Observation): 33 | agent = ob.agent() 34 | 35 | attacker_id = agent.attacker_id 36 | if attacker_id == 0: 37 | return None, None 38 | 39 | target_ent = ob.entity(attacker_id) 40 | if target_ent is None: 41 | return None, None 42 | 43 | return target_ent,\ 44 | utils.linf_single((agent.row, agent.col), (target_ent.row, target_ent.col)) 45 | 46 | def target(config, actions, style, targetID): 47 | actions[nmmo.action.Attack] = { 48 | nmmo.action.Style: style, 49 | nmmo.action.Target: targetID} 50 | -------------------------------------------------------------------------------- /nmmo/systems/droptable.py: -------------------------------------------------------------------------------- 1 | class Fixed(): 2 | def __init__(self, item): 3 | self.item = item 4 | 5 | def roll(self, realm, level): 6 | return [self.item(realm, level)] 7 | 8 | class Drop: 9 | def __init__(self, item, prob): 10 | self.item = item 11 | self.prob = prob 12 | 13 | def roll(self, realm, level): 14 | # TODO: do not access realm._np_random directly 15 | # related to skill.py, all harvest skills 16 | # pylint: disable=protected-access 17 | if realm._np_random.random() < self.prob: 18 | return self.item(realm, level) 19 | 20 | return None 21 | 22 | class Standard: 23 | def __init__(self): 24 | self.drops = [] 25 | 26 | def add(self, item, prob=1.0): 27 | self.drops += [Drop(item, prob)] 28 | 29 | def roll(self, realm, level): 30 | ret = [] 31 | for e in self.drops: 32 | drop = e.roll(realm, level) 33 | if drop is not None: 34 | ret += [drop] 35 | return ret 36 | 37 | class Empty(Standard): 38 | def roll(self, realm, level): 39 | return [] 40 | 41 | class Ammunition(Standard): 42 | def __init__(self, item): 43 | super().__init__() 44 | self.item = item 45 | 46 | def roll(self, realm, level): 47 | return [self.item(realm, level)] 48 | 49 | class Consumable(Standard): 50 | def __init__(self, item): 51 | super().__init__() 52 | self.item = item 53 | 54 | def roll(self, realm, level): 55 | return [self.item(realm, level)] 56 | -------------------------------------------------------------------------------- /tests/core/test_tile.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import numpy as np 3 | 4 | import nmmo 5 | from nmmo.core.tile import Tile, TileState 6 | from nmmo.datastore.numpy_datastore import NumpyDatastore 7 | from nmmo.lib import material 8 | 9 | class MockRealm: 10 | def __init__(self): 11 | self.datastore = NumpyDatastore() 12 | self.datastore.register_object_type("Tile", TileState.State.num_attributes) 13 | self.config = nmmo.config.Small() 14 | self._np_random = np.random 15 | 16 | class MockEntity(): 17 | def __init__(self, ent_id): 18 | self.ent_id = ent_id 19 | 20 | class TestTile(unittest.TestCase): 21 | # pylint: disable=no-member 22 | def test_tile(self): 23 | mock_realm = MockRealm() 24 | np_random = np.random 25 | tile = Tile(mock_realm, 10, 20, np_random) 26 | 27 | tile.reset(material.Foilage, nmmo.config.Small(), np_random) 28 | 29 | self.assertEqual(tile.row.val, 10) 30 | self.assertEqual(tile.col.val, 20) 31 | self.assertEqual(tile.material_id.val, material.Foilage.index) 32 | 33 | tile.add_entity(MockEntity(1)) 34 | tile.add_entity(MockEntity(2)) 35 | self.assertCountEqual(tile.entities.keys(), [1, 2]) 36 | tile.remove_entity(1) 37 | self.assertCountEqual(tile.entities.keys(), [2]) 38 | 39 | tile.harvest(True) 40 | self.assertEqual(tile.depleted, True) 41 | self.assertEqual(tile.material_id.val, material.Scrub.index) 42 | 43 | if __name__ == '__main__': 44 | unittest.main() 45 | -------------------------------------------------------------------------------- /nmmo/systems/ai/move.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=cyclic-import 2 | from nmmo.core import action 3 | from nmmo.systems.ai import utils 4 | 5 | DIRECTIONS = [ # row delta, col delta, action 6 | (-1, 0, action.North), 7 | (1, 0, action.South), 8 | (0, -1, action.West), 9 | (0, 1, action.East)] * 2 10 | 11 | def habitable(realm_map, ent, np_random): 12 | r, c = ent.pos 13 | is_habitable = realm_map.habitable_tiles 14 | start = np_random.get_direction() 15 | for i in range(4): 16 | dr, dc, act = DIRECTIONS[start + i] 17 | if is_habitable[r + dr, c + dc]: 18 | return act 19 | 20 | return action.North 21 | 22 | def towards(direction, np_random): 23 | if direction == (-1, 0): 24 | return action.North 25 | if direction == (1, 0): 26 | return action.South 27 | if direction == (0, -1): 28 | return action.West 29 | if direction == (0, 1): 30 | return action.East 31 | 32 | return np_random.choice(action.Direction.edges) 33 | 34 | def bullrush(ent, targ, np_random): 35 | direction = utils.directionTowards(ent, targ) 36 | return towards(direction, np_random) 37 | 38 | def pathfind(realm_map, ent, targ, np_random): 39 | direction = utils.aStar(realm_map, ent.pos, targ.pos) 40 | return towards(direction, np_random) 41 | 42 | def antipathfind(realm_map, ent, targ, np_random): 43 | er, ec = ent.pos 44 | tr, tc = targ.pos 45 | goal = (2*er - tr , 2*ec-tc) 46 | direction = utils.aStar(realm_map, ent.pos, goal) 47 | return towards(direction, np_random) 48 | -------------------------------------------------------------------------------- /nmmo/lib/log.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | 3 | import logging 4 | 5 | 6 | class Logger: 7 | def __init__(self): 8 | self.stats = defaultdict(list) 9 | 10 | def log(self, key, val): 11 | if not isinstance(val, (int, float)): 12 | raise RuntimeError(f'{val} must be int or float') 13 | 14 | self.stats[key].append(val) 15 | return True 16 | 17 | class MilestoneLogger(Logger): 18 | def __init__(self, log_file): 19 | super().__init__() 20 | logging.basicConfig(format='%(levelname)s:%(message)s', 21 | level=logging.INFO, filename=log_file, filemode='w') 22 | 23 | def log_min(self, key, val): 24 | if key in self.stats and val >= self.stats[key][-1]: 25 | return False 26 | 27 | self.log(key, val) 28 | return True 29 | 30 | def log_max(self, key, val): 31 | if key in self.stats and val <= self.stats[key][-1]: 32 | return False 33 | 34 | self.log(key, val) 35 | return True 36 | 37 | 38 | class EventCode: 39 | # Move 40 | EAT_FOOD = 1 41 | DRINK_WATER = 2 42 | GO_FARTHEST = 3 # record when breaking the previous record 43 | 44 | # Attack 45 | SCORE_HIT = 11 46 | PLAYER_KILL = 12 47 | 48 | # Item 49 | CONSUME_ITEM = 21 50 | GIVE_ITEM = 22 51 | DESTROY_ITEM = 23 52 | HARVEST_ITEM = 24 53 | EQUIP_ITEM = 25 54 | LOOT_ITEM = 26 55 | 56 | # Exchange 57 | GIVE_GOLD = 31 58 | LIST_ITEM = 32 59 | EARN_GOLD = 33 60 | BUY_ITEM = 34 61 | #SPEND_GOLD = 35 # BUY_ITEM, price has the same info 62 | 63 | # Level up 64 | LEVEL_UP = 41 65 | -------------------------------------------------------------------------------- /nmmo/lib/seeding.py: -------------------------------------------------------------------------------- 1 | # copied from https://github.com/openai/gym/blob/master/gym/utils/seeding.py 2 | 3 | """Set of random number generator functions: seeding, generator, hashing seeds.""" 4 | from typing import Any, Optional, Tuple 5 | 6 | import numpy as np 7 | 8 | from gym import error 9 | 10 | 11 | class RandomNumberGenerator(np.random.Generator): 12 | def __init__(self, bit_generator): 13 | super().__init__(bit_generator) 14 | self._dir_seq_len = 1024 15 | self._wrap = self._dir_seq_len - 1 16 | self._dir_seq = list(self.integers(0, 4, size=self._dir_seq_len)) 17 | self._dir_idx = 0 18 | 19 | # provide a random direction from the pre-generated sequence 20 | def get_direction(self): 21 | self._dir_idx = (self._dir_idx + 1) & self._wrap 22 | return self._dir_seq[self._dir_idx] 23 | 24 | def np_random(seed: Optional[int] = None) -> Tuple[np.random.Generator, Any]: 25 | """Generates a random number generator from the seed and returns the Generator and seed. 26 | 27 | Args: 28 | seed: The seed used to create the generator 29 | 30 | Returns: 31 | The generator and resulting seed 32 | 33 | Raises: 34 | Error: Seed must be a non-negative integer or omitted 35 | """ 36 | if seed is not None and not (isinstance(seed, int) and 0 <= seed): 37 | raise error.Error(f"Seed must be a non-negative integer or omitted, not {seed}") 38 | 39 | seed_seq = np.random.SeedSequence(seed) 40 | np_seed = seed_seq.entropy 41 | rng = RandomNumberGenerator(np.random.PCG64(seed_seq)) 42 | return rng, np_seed 43 | -------------------------------------------------------------------------------- /nmmo/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from .version import __version__ 4 | 5 | from .lib import material, spawn 6 | from .render.overlay import Overlay, OverlayRegistry 7 | from .core import config, agent, action 8 | from .core.action import Action 9 | from .core.agent import Agent, Scripted 10 | from .core.env import Env 11 | from .core.terrain import MapGenerator, Terrain 12 | 13 | MOTD = rf''' ___ ___ ___ ___ 14 | /__/\ /__/\ /__/\ / /\ Version {__version__:<8} 15 | \ \:\ | |::\ | |::\ / /::\ 16 | \ \:\ | |:|:\ | |:|:\ / /:/\:\ An open source 17 | _____\__\:\ __|__|:|\:\ __|__|:|\:\ / /:/ \:\ project originally 18 | /__/::::::::\ /__/::::| \:\ /__/::::| \:\ /__/:/ \__\:\ founded by Joseph Suarez 19 | \ \:\~~\~~\/ \ \:\~~\__\/ \ \:\~~\__\/ \ \:\ / /:/ and formalized at OpenAI 20 | \ \:\ ~~~ \ \:\ \ \:\ \ \:\ /:/ 21 | \ \:\ \ \:\ \ \:\ \ \:\/:/ Now developed and 22 | \ \:\ \ \:\ \ \:\ \ \::/ maintained at MIT in 23 | \__\/ \__\/ \__\/ \__\/ Phillip Isola's lab ''' 24 | 25 | __all__ = ['Env', 'config', 'agent', 'Agent', 'Scripted', 'MapGenerator', 'Terrain', 26 | 'action', 'Action', 'material', 'spawn', 27 | 'Overlay', 'OverlayRegistry'] 28 | 29 | try: 30 | __all__.append('OpenSkillRating') 31 | except RuntimeError: 32 | logging.error('Warning: OpenSkill not installed. Ignore if you do not need this feature') 33 | -------------------------------------------------------------------------------- /tests/core/test_map_generation.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import os 3 | import shutil 4 | 5 | import nmmo 6 | 7 | class TestMapGeneration(unittest.TestCase): 8 | def test_insufficient_maps(self): 9 | config = nmmo.config.Small() 10 | config.PATH_MAPS = 'maps/test_map_gen' 11 | config.MAP_N = 20 12 | 13 | # clear the directory 14 | path_maps = os.path.join(config.PATH_CWD, config.PATH_MAPS) 15 | shutil.rmtree(path_maps, ignore_errors=True) 16 | 17 | # this generates 20 maps 18 | nmmo.Env(config) 19 | 20 | # test if MAP_FORCE_GENERATION can be overriden 21 | config.MAP_N = 30 22 | config.MAP_FORCE_GENERATION = False 23 | 24 | test_env = nmmo.Env(config) 25 | test_env.reset(map_id=config.MAP_N) 26 | 27 | # this should finish without error 28 | 29 | def test_map_preview(self): 30 | class MapConfig( 31 | nmmo.config.Small, # no fractal, grass only 32 | nmmo.config.Terrain, # water, grass, foilage, stone 33 | nmmo.config.Item, # no additional effect on the map 34 | nmmo.config.Profession, # add ore, tree, crystal, herb, fish 35 | ): 36 | PATH_MAPS = 'maps/test_preview' 37 | MAP_FORCE_GENERATION = True 38 | MAP_GENERATE_PREVIEWS = True 39 | config = MapConfig() 40 | 41 | # clear the directory 42 | path_maps = os.path.join(config.PATH_CWD, config.PATH_MAPS) 43 | shutil.rmtree(path_maps, ignore_errors=True) 44 | 45 | test_env = nmmo.Env(config) # pylint: disable=unused-variable 46 | 47 | # this should finish without error 48 | 49 | if __name__ == '__main__': 50 | unittest.main() 51 | -------------------------------------------------------------------------------- /tests/lib/test_serialized.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | import unittest 3 | 4 | from nmmo.datastore.serialized import SerializedState 5 | 6 | # pylint: disable=no-member,unused-argument,unsubscriptable-object 7 | 8 | FooState = SerializedState.subclass("FooState", [ 9 | "a", "b", "col" 10 | ]) 11 | 12 | FooState.Limits = { 13 | "a": (-10, 10), 14 | } 15 | 16 | class MockDatastoreRecord(): 17 | def __init__(self): 18 | self._data = defaultdict(lambda: 0) 19 | 20 | def get(self, name): 21 | return self._data[name] 22 | 23 | def update(self, name, value): 24 | self._data[name] = value 25 | 26 | class MockDatastore(): 27 | def create_record(self, name): 28 | return MockDatastoreRecord() 29 | 30 | def register_object_type(self, name, attributes): 31 | assert name == "FooState" 32 | assert attributes == ["a", "b", "col"] 33 | 34 | class TestSerialized(unittest.TestCase): 35 | 36 | def test_serialized(self): 37 | state = FooState(MockDatastore(), FooState.Limits) 38 | 39 | # initial value = 0 40 | self.assertEqual(state.a.val, 0) 41 | 42 | # if given value is within the range, set to the value 43 | state.a.update(1) 44 | self.assertEqual(state.a.val, 1) 45 | 46 | # if given a lower value than the min, set to min 47 | a_min, a_max = FooState.Limits["a"] 48 | state.a.update(a_min - 100) 49 | self.assertEqual(state.a.val, a_min) 50 | 51 | # if given a higher value than the max, set to max 52 | state.a.update(a_max + 100) 53 | self.assertEqual(state.a.val, a_max) 54 | 55 | if __name__ == '__main__': 56 | unittest.main() 57 | -------------------------------------------------------------------------------- /utils/pre-git-check.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo 4 | echo "Checking pylint, xcxc, pytest without touching git" 5 | echo 6 | 7 | # Check the number of physical cores only 8 | if command -v lscpu &> /dev/null 9 | then 10 | # lscpu is available 11 | cores=$(lscpu -b -p=Core,Socket | grep -v '^#' | sort -u | wc -l) 12 | else 13 | # lscpu is not available, use sysctl instead 14 | cores=$(sysctl -n hw.physicalcpu) 15 | fi 16 | 17 | # Run linter 18 | echo "--------------------------------------------------------------------" 19 | echo "Running linter..." 20 | files=$(git ls-files -m -o --exclude-standard '*.py') 21 | for file in $files; do 22 | if test -e $file; then 23 | echo $file 24 | if ! pylint --score=no --fail-under=10 $file; then 25 | echo "Lint failed. Exiting." 26 | exit 1 27 | fi 28 | fi 29 | done 30 | 31 | if ! pylint --jobs=$cores --recursive=y nmmo tests; then 32 | echo "Lint failed. Exiting." 33 | exit 1 34 | fi 35 | 36 | # Check if there are any "xcxc" strings in the code 37 | echo "--------------------------------------------------------------------" 38 | echo "Looking for xcxc..." 39 | files=$(find . -name '*.py') 40 | for file in $files; do 41 | if grep -q 'xcxc' $file; then 42 | echo "Found xcxc in $file!" >&2 43 | read -p "Do you like to stop here? (y/n) " ans 44 | if [ "$ans" = "y" ]; then 45 | exit 1 46 | fi 47 | fi 48 | done 49 | 50 | # Run unit tests 51 | echo 52 | echo "--------------------------------------------------------------------" 53 | echo "Running unit tests..." 54 | if ! pytest; then 55 | echo "Unit tests failed. Exiting." 56 | exit 1 57 | fi 58 | 59 | echo 60 | echo "Pre-git checks look good!" 61 | echo 62 | -------------------------------------------------------------------------------- /tests/core/test_gym_obs_spaces.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import nmmo 4 | 5 | class TestGymObsSpaces(unittest.TestCase): 6 | def _test_gym_obs_space(self, env): 7 | obs_spec = env.observation_space(1) 8 | obs, _, _, _ = env.step({}) 9 | 10 | for agent_obs in obs.values(): 11 | for key, val in agent_obs.items(): 12 | if key != 'ActionTargets': 13 | self.assertTrue(obs_spec[key].contains(val), 14 | f"Invalid obs format -- key: {key}, val: {val}") 15 | 16 | if 'ActionTargets' in agent_obs: 17 | val = agent_obs['ActionTargets'] 18 | for atn in nmmo.Action.edges(env.config): 19 | if atn.enabled(env.config): 20 | for arg in atn.edges: # pylint: disable=not-an-iterable 21 | mask_spec = obs_spec['ActionTargets'][atn.__name__][arg.__name__] 22 | mask_val = val[atn.__name__][arg.__name__] 23 | self.assertTrue(mask_spec.contains(mask_val), 24 | "Invalid obs format -- " + \ 25 | f"key: {atn.__name__}/{arg.__name__}, val: {mask_val}") 26 | 27 | def test_env_without_noop(self): 28 | config = nmmo.config.Default() 29 | config.PROVIDE_NOOP_ACTION_TARGET = False 30 | env = nmmo.Env(config) 31 | env.reset(seed=1) 32 | for _ in range(3): 33 | env.step({}) 34 | 35 | self._test_gym_obs_space(env) 36 | 37 | def test_env_with_noop(self): 38 | config = nmmo.config.Default() 39 | config.PROVIDE_NOOP_ACTION_TARGET = True 40 | env = nmmo.Env(config) 41 | env.reset(seed=1) 42 | for _ in range(3): 43 | env.step({}) 44 | 45 | self._test_gym_obs_space(env) 46 | 47 | if __name__ == '__main__': 48 | unittest.main() 49 | -------------------------------------------------------------------------------- /tests/datastore/test_id_allocator.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from nmmo.datastore.id_allocator import IdAllocator 4 | 5 | class TestIdAllocator(unittest.TestCase): 6 | def test_id_allocator(self): 7 | id_allocator = IdAllocator(10) 8 | 9 | for i in range(1, 10): 10 | row_id = id_allocator.allocate() 11 | self.assertEqual(i, row_id) 12 | self.assertTrue(id_allocator.full()) 13 | 14 | id_allocator.remove(5) 15 | id_allocator.remove(6) 16 | id_allocator.remove(1) 17 | self.assertFalse(id_allocator.full()) 18 | 19 | self.assertSetEqual( 20 | set(id_allocator.allocate() for i in range(3)), 21 | set([5, 6, 1]) 22 | ) 23 | self.assertTrue(id_allocator.full()) 24 | 25 | id_allocator.expand(11) 26 | self.assertFalse(id_allocator.full()) 27 | 28 | self.assertEqual(id_allocator.allocate(), 10) 29 | 30 | with self.assertRaises(KeyError): 31 | id_allocator.allocate() 32 | 33 | def test_id_reuse(self): 34 | id_allocator = IdAllocator(10) 35 | 36 | for i in range(1, 10): 37 | row_id = id_allocator.allocate() 38 | self.assertEqual(i, row_id) 39 | self.assertTrue(id_allocator.full()) 40 | 41 | id_allocator.remove(5) 42 | id_allocator.remove(6) 43 | id_allocator.remove(1) 44 | self.assertFalse(id_allocator.full()) 45 | 46 | self.assertSetEqual( 47 | set(id_allocator.allocate() for i in range(3)), 48 | set([5, 6, 1]) 49 | ) 50 | self.assertTrue(id_allocator.full()) 51 | 52 | id_allocator.expand(11) 53 | self.assertFalse(id_allocator.full()) 54 | 55 | self.assertEqual(id_allocator.allocate(), 10) 56 | 57 | with self.assertRaises(KeyError): 58 | id_allocator.allocate() 59 | 60 | id_allocator.remove(10) 61 | self.assertEqual(id_allocator.allocate(), 10) 62 | 63 | if __name__ == '__main__': 64 | unittest.main() 65 | -------------------------------------------------------------------------------- /nmmo/lib/team_helper.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List 2 | 3 | class TeamHelper(): 4 | def __init__(self, teams: Dict[int, List[int]]): 5 | self.teams = teams 6 | self.num_teams = len(teams) 7 | self.team_size = {} 8 | self.team_and_position_for_agent = {} 9 | self.agent_for_team_and_position = {} 10 | 11 | for team_id, team in teams.items(): 12 | self.team_size[team_id] = len(team) 13 | for position, agent_id in enumerate(team): 14 | self.team_and_position_for_agent[agent_id] = (team_id, position) 15 | self.agent_for_team_and_position[team_id, position] = agent_id 16 | 17 | def agent_position(self, agent_id: int) -> int: 18 | return self.team_and_position_for_agent[agent_id][1] 19 | 20 | def agent_id(self, team_id: int, position: int) -> int: 21 | return self.agent_for_team_and_position[team_id, position] 22 | 23 | def is_agent_in_team(self, agent_id:int , team_id: int) -> bool: 24 | return agent_id in self.teams[team_id] 25 | 26 | def get_target_agent(self, team_id: int, target: str): 27 | team_ids = list(self.teams.keys()) 28 | idx = team_ids.index(team_id) 29 | if target == "left_team": 30 | target_id = team_ids[(idx+1) % self.num_teams] 31 | return self.teams[target_id] 32 | if target == "left_team_leader": 33 | target_id = team_ids[(idx+1) % self.num_teams] 34 | return self.teams[target_id][0] 35 | if target == "right_team": 36 | target_id = team_ids[(idx-1) % self.num_teams] 37 | return self.teams[target_id] 38 | if target == "right_team_leader": 39 | target_id = team_ids[(idx-1) % self.num_teams] 40 | return self.teams[target_id][0] 41 | if target == "my_team_leader": 42 | return self.teams[team_id][0] 43 | if target == "all_foes": 44 | all_foes = [] 45 | for foe_team_id in team_ids: 46 | if foe_team_id != team_id: 47 | all_foes += self.teams[foe_team_id] 48 | return all_foes 49 | return None 50 | -------------------------------------------------------------------------------- /utils/git-pr.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | MASTER_BRANCH="2.0" 3 | 4 | # check if in master branch 5 | current_branch=$(git rev-parse --abbrev-ref HEAD) 6 | if [ "$current_branch" == MASTER_BRANCH ]; then 7 | echo "Please run 'git pr' from a topic branch." 8 | exit 1 9 | fi 10 | 11 | # check if there are any uncommitted changes 12 | git_status=$(git status --porcelain) 13 | 14 | if [ -n "$git_status" ]; then 15 | read -p "Uncommitted changes found. Commit before running 'git pr'? (y/n) " ans 16 | if [ "$ans" = "y" ]; then 17 | git commit -m -a "Automatic commit for git-pr" 18 | else 19 | echo "Please commit or stash changes before running 'git pr'." 20 | exit 1 21 | fi 22 | fi 23 | 24 | # Merging master 25 | echo "Merging master..." 26 | git merge origin/$MASTER_BRANCH 27 | 28 | # Checking pylint, xcxc, pytest without touching git 29 | PRE_GIT_CHECK=$(find . -name pre-git-check.sh) 30 | if test -f "$PRE_GIT_CHECK"; then 31 | $PRE_GIT_CHECK 32 | if [ $? -ne 0 ]; then 33 | echo "pre-git-check.sh failed. Exiting." 34 | exit 1 35 | fi 36 | else 37 | echo "Missing pre-git-check.sh. Exiting." 38 | exit 1 39 | fi 40 | 41 | # create a new branch from current branch and reset to master 42 | echo "Creating and switching to new topic branch..." 43 | git_user=$(git config user.email | cut -d'@' -f1) 44 | branch_name="${git_user}-git-pr-$RANDOM-$RANDOM" 45 | git checkout -b $branch_name 46 | git reset --soft origin/$MASTER_BRANCH 47 | 48 | # Verify that a commit message was added 49 | echo "Verifying commit message..." 50 | if ! git commit -a ; then 51 | echo "Commit message is empty. Exiting." 52 | exit 1 53 | fi 54 | 55 | # Push the topic branch to origin 56 | echo "Pushing topic branch to origin..." 57 | git push -u origin $branch_name 58 | 59 | # Generate a Github pull request (just the url, not actually making a PR) 60 | echo "Generating Github pull request..." 61 | pull_request_url="https://github.com/CarperAI/nmmo-environment/compare/$MASTER_BRANCH...CarperAI:nmmo-environment:$branch_name?expand=1" 62 | 63 | echo "Pull request URL: $pull_request_url" 64 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from itertools import chain 2 | 3 | from setuptools import find_packages, setup 4 | 5 | REPO_URL = "https://github.com/neuralmmo/environment" 6 | 7 | extra = { 8 | 'docs': [ 9 | 'sphinx==5.0.0', 10 | 'sphinx-rtd-theme==0.5.1', 11 | 'sphinxcontrib-youtube==1.0.1', 12 | 'myst-parser==1.0.0', 13 | 'sphinx-rtd-theme==0.5.1', 14 | 'sphinx-design==0.4.1', 15 | 'furo==2023.3.27', 16 | ], 17 | } 18 | 19 | extra['all'] = list(set(chain.from_iterable(extra.values()))) 20 | 21 | with open('nmmo/version.py', encoding="utf-8") as vf: 22 | ver = vf.read().split()[-1].strip("'") 23 | 24 | setup( 25 | name="nmmo", 26 | description="Neural MMO is a platform for multiagent intelligence research " + \ 27 | "inspired by Massively Multiplayer Online (MMO) role-playing games. " + \ 28 | "Documentation hosted at neuralmmo.github.io.", 29 | long_description_content_type="text/markdown", 30 | version=ver, 31 | packages=find_packages(), 32 | include_package_data=True, 33 | install_requires=[ 34 | 'numpy==1.23.3', 35 | 'scipy==1.10.0', 36 | 'pytest==7.3.0', 37 | 'pytest-benchmark==3.4.1', 38 | 'autobahn==19.3.3', 39 | 'Twisted==19.2.0', 40 | 'vec-noise==1.1.4', 41 | 'imageio==2.23.0', 42 | 'ordered-set==4.1.0', 43 | 'pettingzoo==1.19.0', 44 | 'gym==0.23.0', 45 | 'pylint==2.16.0', 46 | 'psutil==5.9.3', 47 | 'py==1.11.0', 48 | 'tqdm<5', 49 | 'dill==0.3.6', 50 | ], 51 | extras_require=extra, 52 | python_requires=">=3.7", 53 | license="MIT", 54 | author="Joseph Suarez", 55 | author_email="jsuarez@mit.edu", 56 | url=REPO_URL, 57 | keywords=["Neural MMO", "MMO"], 58 | classifiers=[ 59 | "Development Status :: 5 - Production/Stable", 60 | "Intended Audience :: Science/Research", 61 | "Intended Audience :: Developers", 62 | "Environment :: Console", 63 | "License :: OSI Approved :: MIT License", 64 | "Programming Language :: Python :: 3.7", 65 | "Programming Language :: Python :: 3.8", 66 | "Programming Language :: Python :: 3.9", 67 | "Programming Language :: Python :: 3.10", 68 | ], 69 | ) 70 | -------------------------------------------------------------------------------- /nmmo/lib/utils.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=all 2 | 3 | import inspect 4 | from collections import deque 5 | 6 | import numpy as np 7 | 8 | 9 | class staticproperty(property): 10 | def __get__(self, cls, owner): 11 | return self.fget.__get__(None, owner)() 12 | 13 | class classproperty(object): 14 | def __init__(self, f): 15 | self.f = f 16 | def __get__(self, obj, owner): 17 | return self.f(owner) 18 | 19 | class Iterable(type): 20 | def __iter__(cls): 21 | queue = deque(cls.__dict__.items()) 22 | while len(queue) > 0: 23 | name, attr = queue.popleft() 24 | if type(name) != tuple: 25 | name = tuple([name]) 26 | if not inspect.isclass(attr): 27 | continue 28 | yield name, attr 29 | 30 | def values(cls): 31 | return [e[1] for e in cls] 32 | 33 | class StaticIterable(type): 34 | def __iter__(cls): 35 | stack = list(cls.__dict__.items()) 36 | stack.reverse() 37 | for name, attr in stack: 38 | if name == '__module__': 39 | continue 40 | if name.startswith('__'): 41 | break 42 | yield name, attr 43 | 44 | class NameComparable(type): 45 | def __hash__(self): 46 | return hash(self.__name__) 47 | 48 | def __eq__(self, other): 49 | return self.__name__ == other.__name__ 50 | 51 | def __ne__(self, other): 52 | return self.__name__ != other.__name__ 53 | 54 | def __lt__(self, other): 55 | return self.__name__ < other.__name__ 56 | 57 | def __le__(self, other): 58 | return self.__name__ <= other.__name__ 59 | 60 | def __gt__(self, other): 61 | return self.__name__ > other.__name__ 62 | 63 | def __ge__(self, other): 64 | return self.__name__ >= other.__name__ 65 | 66 | class IterableNameComparable(Iterable, NameComparable): 67 | pass 68 | 69 | def linf(pos1, pos2): 70 | # pos could be a single (r,c) or a vector of (r,c)s 71 | diff = np.abs(np.array(pos1) - np.array(pos2)) 72 | return np.max(diff, axis=-1) 73 | 74 | def linf_single(pos1, pos2): 75 | # pos is a single (r,c) to avoid uneccessary function calls 76 | return max(abs(pos1[0]-pos2[0]), abs(pos1[1]-pos2[1])) 77 | 78 | #Bounds checker 79 | def in_bounds(r, c, shape, border=0): 80 | R, C = shape 81 | return ( 82 | r > border and 83 | c > border and 84 | r < R - border and 85 | c < C - border 86 | ) 87 | 88 | -------------------------------------------------------------------------------- /nmmo/render/render_client.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import numpy as np 3 | 4 | from nmmo.render import websocket 5 | from nmmo.render.overlay import OverlayRegistry 6 | from nmmo.render.render_utils import patch_packet 7 | 8 | 9 | # Render is external to the game 10 | class WebsocketRenderer: 11 | def __init__(self, realm=None) -> None: 12 | self._client = websocket.Application(realm) 13 | self.overlay_pos = [256, 256] 14 | 15 | self._realm = realm 16 | 17 | self.overlay = None 18 | self.registry = OverlayRegistry(realm, renderer=self) if realm else None 19 | 20 | self.packet = None 21 | 22 | def set_realm(self, realm) -> None: 23 | self._realm = realm 24 | self.registry = OverlayRegistry(realm, renderer=self) if realm else None 25 | 26 | def render_packet(self, packet) -> None: 27 | packet = { 28 | 'pos': self.overlay_pos, 29 | 'wilderness': 0, # obsolete, but maintained for compatibility 30 | **packet } 31 | 32 | self.overlay_pos, _ = self._client.update(packet) 33 | 34 | def render_realm(self) -> None: 35 | assert self._realm is not None, 'This function requires a realm' 36 | assert self._realm.tick is not None, 'render before reset' 37 | 38 | packet = { 39 | 'config': self._realm.config, 40 | 'pos': self.overlay_pos, 41 | 'wilderness': 0, 42 | **self._realm.packet() 43 | } 44 | 45 | # TODO: a hack to make the client work 46 | packet = patch_packet(packet, self._realm) 47 | 48 | if self.overlay is not None: 49 | packet['overlay'] = self.overlay 50 | self.overlay = None 51 | 52 | # save the packet for investigation 53 | self.packet = packet 54 | 55 | # pass the packet to renderer 56 | pos, cmd = self._client.update(self.packet) 57 | 58 | self.overlay_pos = pos 59 | self.registry.step(cmd) 60 | 61 | def register(self, overlay: np.ndarray) -> None: 62 | '''Register an overlay to be sent to the client 63 | 64 | The intended use of this function is: User types overlay -> 65 | client sends cmd to server -> server computes overlay update -> 66 | register(overlay) -> overlay is sent to client -> overlay rendered 67 | 68 | Args: 69 | overlay: A map-sized (self.size) array of floating point values 70 | overlay must be a numpy array of dimension (*(env.size), 3) 71 | ''' 72 | self.overlay = overlay.tolist() 73 | -------------------------------------------------------------------------------- /tests/systems/test_item.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import numpy as np 3 | 4 | import nmmo 5 | from nmmo.datastore.numpy_datastore import NumpyDatastore 6 | from nmmo.systems.item import Hat, Top, ItemState 7 | 8 | class MockRealm: 9 | def __init__(self): 10 | self.config = nmmo.config.Default() 11 | self.datastore = NumpyDatastore() 12 | self.items = {} 13 | self.datastore.register_object_type("Item", ItemState.State.num_attributes) 14 | self.players = {} 15 | 16 | # pylint: disable=no-member 17 | class TestItem(unittest.TestCase): 18 | def test_item(self): 19 | realm = MockRealm() 20 | 21 | hat_1 = Hat(realm, 1) 22 | self.assertTrue(ItemState.Query.by_id(realm.datastore, hat_1.id.val) is not None) 23 | self.assertEqual(hat_1.type_id.val, Hat.ITEM_TYPE_ID) 24 | self.assertEqual(hat_1.level.val, 1) 25 | self.assertEqual(hat_1.mage_defense.val, 10) 26 | 27 | hat_2 = Hat(realm, 10) 28 | self.assertTrue(ItemState.Query.by_id(realm.datastore, hat_2.id.val) is not None) 29 | self.assertEqual(hat_2.level.val, 10) 30 | self.assertEqual(hat_2.melee_defense.val, 100) 31 | 32 | self.assertDictEqual(realm.items, {hat_1.id.val: hat_1, hat_2.id.val: hat_2}) 33 | 34 | # also test destroy 35 | ids = [hat_1.id.val, hat_2.id.val] 36 | hat_1.destroy() 37 | hat_2.destroy() 38 | # after destroy(), the datastore entry is gone, but the class still exsits 39 | # make sure that after destroy the owner_id is 0, at least 40 | self.assertTrue(hat_1.owner_id.val == 0) 41 | self.assertTrue(hat_2.owner_id.val == 0) 42 | for item_id in ids: 43 | self.assertTrue(len(ItemState.Query.by_id(realm.datastore, item_id)) == 0) 44 | self.assertDictEqual(realm.items, {}) 45 | 46 | # create a new item with the hat_1's id, but it must still be void 47 | new_top = Top(realm, 3) 48 | new_top.id.update(ids[0]) # hat_1's id 49 | new_top.owner_id.update(100) 50 | # make sure that the hat_1 is not linked to the new_top 51 | self.assertTrue(hat_1.owner_id.val == 0) 52 | 53 | def test_owned_by(self): 54 | realm = MockRealm() 55 | 56 | hat_1 = Hat(realm, 1) 57 | hat_2 = Hat(realm, 10) 58 | 59 | hat_1.owner_id.update(1) 60 | hat_2.owner_id.update(1) 61 | 62 | np.testing.assert_array_equal( 63 | ItemState.Query.owned_by(realm.datastore, 1)[:,0], 64 | [hat_1.id.val, hat_2.id.val]) 65 | 66 | self.assertEqual(Hat.Query.owned_by(realm.datastore, 2).size, 0) 67 | 68 | if __name__ == '__main__': 69 | unittest.main() 70 | -------------------------------------------------------------------------------- /nmmo/datastore/numpy_datastore.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | import numpy as np 4 | 5 | from nmmo.datastore.datastore import Datastore, DataTable 6 | 7 | 8 | class NumpyTable(DataTable): 9 | def __init__(self, num_columns: int, initial_size: int, dtype=np.int16): 10 | super().__init__(num_columns) 11 | self._dtype = dtype 12 | self._initial_size = initial_size 13 | self._max_rows = 0 14 | self._data = np.zeros((0, self._num_columns), dtype=self._dtype) 15 | self._expand(self._initial_size) 16 | 17 | def reset(self): 18 | super().reset() # resetting _id_allocator 19 | self._max_rows = 0 20 | self._data = np.zeros((0, self._num_columns), dtype=self._dtype) 21 | self._expand(self._initial_size) 22 | 23 | def update(self, row_id: int, col: int, value): 24 | self._data[row_id, col] = value 25 | 26 | def get(self, ids: List[int]): 27 | return self._data[ids] 28 | 29 | def where_eq(self, col: int, value): 30 | return self._data[self._data[:,col] == value] 31 | 32 | def where_neq(self, col: int, value): 33 | return self._data[self._data[:,col] != value] 34 | 35 | def where_in(self, col: int, values: List): 36 | return self._data[np.isin(self._data[:,col], values)] 37 | 38 | def window(self, row_idx: int, col_idx: int, row: int, col: int, radius: int): 39 | return self._data[( 40 | (np.abs(self._data[:,row_idx] - row) <= radius) & 41 | (np.abs(self._data[:,col_idx] - col) <= radius) 42 | ).ravel()] 43 | 44 | def add_row(self) -> int: 45 | if self._id_allocator.full(): 46 | self._expand(self._max_rows * 2) 47 | row_id = self._id_allocator.allocate() 48 | return row_id 49 | 50 | def remove_row(self, row_id: int) -> int: 51 | self._id_allocator.remove(row_id) 52 | self._data[row_id] = 0 53 | 54 | def _expand(self, max_rows: int): 55 | assert max_rows > self._max_rows 56 | data = np.zeros((max_rows, self._num_columns), dtype=self._dtype) 57 | data[:self._max_rows] = self._data 58 | self._max_rows = max_rows 59 | self._id_allocator.expand(max_rows) 60 | self._data = data 61 | 62 | def is_empty(self) -> bool: 63 | all_data_zero = np.sum(self._data)==0 64 | # 0th row is reserved as padding, so # of free ids is _max_rows-1 65 | all_id_free = len(self._id_allocator.free) == self._max_rows-1 66 | return all_data_zero and all_id_free 67 | 68 | class NumpyDatastore(Datastore): 69 | def _create_table(self, num_columns: int) -> DataTable: 70 | return NumpyTable(num_columns, 100) 71 | -------------------------------------------------------------------------------- /tests/entity/test_entity.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import numpy as np 3 | 4 | import nmmo 5 | from nmmo.entity.entity import Entity, EntityState 6 | from nmmo.datastore.numpy_datastore import NumpyDatastore 7 | 8 | class MockRealm: 9 | def __init__(self): 10 | self.config = nmmo.config.Default() 11 | self.config.PLAYERS = range(100) 12 | self.datastore = NumpyDatastore() 13 | self.datastore.register_object_type("Entity", EntityState.State.num_attributes) 14 | self._np_random = np.random 15 | 16 | # pylint: disable=no-member 17 | class TestEntity(unittest.TestCase): 18 | def test_entity(self): 19 | realm = MockRealm() 20 | entity_id = 123 21 | entity = Entity(realm, (10,20), entity_id, "name") 22 | 23 | self.assertEqual(entity.id.val, entity_id) 24 | self.assertEqual(entity.row.val, 10) 25 | self.assertEqual(entity.col.val, 20) 26 | self.assertEqual(entity.damage.val, 0) 27 | self.assertEqual(entity.time_alive.val, 0) 28 | self.assertEqual(entity.freeze.val, 0) 29 | self.assertEqual(entity.item_level.val, 0) 30 | self.assertEqual(entity.attacker_id.val, 0) 31 | self.assertEqual(entity.message.val, 0) 32 | self.assertEqual(entity.gold.val, 0) 33 | self.assertEqual(entity.health.val, realm.config.PLAYER_BASE_HEALTH) 34 | self.assertEqual(entity.food.val, realm.config.RESOURCE_BASE) 35 | self.assertEqual(entity.water.val, realm.config.RESOURCE_BASE) 36 | self.assertEqual(entity.melee_level.val, 0) 37 | self.assertEqual(entity.range_level.val, 0) 38 | self.assertEqual(entity.mage_level.val, 0) 39 | self.assertEqual(entity.fishing_level.val, 0) 40 | self.assertEqual(entity.herbalism_level.val, 0) 41 | self.assertEqual(entity.prospecting_level.val, 0) 42 | self.assertEqual(entity.carving_level.val, 0) 43 | self.assertEqual(entity.alchemy_level.val, 0) 44 | 45 | def test_query_by_ids(self): 46 | realm = MockRealm() 47 | entity_id = 123 48 | entity = Entity(realm, (10,20), entity_id, "name") 49 | 50 | entities = EntityState.Query.by_ids(realm.datastore, [entity_id]) 51 | self.assertEqual(len(entities), 1) 52 | self.assertEqual(entities[0][Entity.State.attr_name_to_col["id"]], entity_id) 53 | self.assertEqual(entities[0][Entity.State.attr_name_to_col["row"]], 10) 54 | self.assertEqual(entities[0][Entity.State.attr_name_to_col["col"]], 20) 55 | 56 | entity.food.update(11) 57 | e_row = EntityState.Query.by_id(realm.datastore, entity_id) 58 | self.assertEqual(e_row[Entity.State.attr_name_to_col["food"]], 11) 59 | 60 | 61 | if __name__ == '__main__': 62 | unittest.main() 63 | -------------------------------------------------------------------------------- /tests/render/test_render_save.py: -------------------------------------------------------------------------------- 1 | '''Manual test for render client connectivity and save replay''' 2 | import nmmo 3 | from nmmo.core.config import (AllGameSystems, Combat, Communication, 4 | Equipment, Exchange, Item, Medium, Profession, 5 | Progression, Resource, Small, Terrain) 6 | from nmmo.render.render_client import WebsocketRenderer 7 | from nmmo.render.replay_helper import FileReplayHelper 8 | from scripted import baselines 9 | 10 | def create_config(base, nent, *systems): 11 | systems = (base, *systems) 12 | name = '_'.join(cls.__name__ for cls in systems) 13 | conf = type(name, systems, {})() 14 | conf.TERRAIN_TRAIN_MAPS = 1 15 | conf.TERRAIN_EVAL_MAPS = 1 16 | conf.IMMORTAL = True 17 | conf.PLAYER_N = nent 18 | conf.PLAYERS = [baselines.Random] 19 | return conf 20 | 21 | no_npc_small_1_pop_conf = create_config(Small, 1, Terrain, Resource, 22 | Combat, Progression, Item, Equipment, Profession, Exchange, Communication) 23 | 24 | no_npc_med_1_pop_conf = create_config(Medium, 1, Terrain, Resource, 25 | Combat, Progression, Item, Equipment, Profession, Exchange, Communication) 26 | 27 | no_npc_med_100_pop_conf = create_config(Medium, 100, Terrain, Resource, 28 | Combat, Progression, Item, Equipment, Profession, Exchange, Communication) 29 | 30 | all_small_1_pop_conf = create_config(Small, 1, AllGameSystems) 31 | 32 | all_med_1_pop_conf = create_config(Medium, 1, AllGameSystems) 33 | 34 | all_med_100_pop_conf = create_config(Medium, 100, AllGameSystems) 35 | 36 | conf_dict = { 37 | 'no_npc_small_1_pop': no_npc_small_1_pop_conf, 38 | 'no_npc_med_1_pop': no_npc_med_1_pop_conf, 39 | 'no_npc_med_100_pop': no_npc_med_100_pop_conf, 40 | 'all_small_1_pop': all_small_1_pop_conf, 41 | 'all_med_1_pop': all_med_1_pop_conf, 42 | 'all_med_100_pop': all_med_100_pop_conf 43 | } 44 | 45 | if __name__ == '__main__': 46 | import random 47 | from tqdm import tqdm 48 | 49 | TEST_HORIZON = 100 50 | RANDOM_SEED = random.randint(0, 9999) 51 | 52 | replay_helper = FileReplayHelper() 53 | 54 | # the renderer is external to the env, so need to manually initiate it 55 | renderer = WebsocketRenderer() 56 | 57 | for conf_name, config in conf_dict.items(): 58 | env = nmmo.Env(config) 59 | 60 | # to make replay, one should create replay_helper 61 | # and run the below line 62 | env.realm.record_replay(replay_helper) 63 | 64 | env.reset(seed=RANDOM_SEED) 65 | renderer.set_realm(env.realm) 66 | 67 | for tick in tqdm(range(TEST_HORIZON)): 68 | env.step({}) 69 | renderer.render_realm() 70 | 71 | # NOTE: save the data in uncompressed json format, since 72 | # the web client has trouble loading the compressed replay file 73 | replay_helper.save(f'replay_{conf_name}_seed_{RANDOM_SEED:04d}.json') 74 | -------------------------------------------------------------------------------- /nmmo/systems/ai/behavior.py: -------------------------------------------------------------------------------- 1 | #pylint: disable=protected-access, invalid-name 2 | 3 | import numpy as np 4 | 5 | import nmmo 6 | from nmmo.systems.ai import move, utils 7 | 8 | def update(entity): 9 | '''Update validity of tracked entities''' 10 | if not utils.validTarget(entity, entity.attacker, entity.vision): 11 | entity.attacker = None 12 | if not utils.validTarget(entity, entity.target, entity.vision): 13 | entity.target = None 14 | if not utils.validTarget(entity, entity.closest, entity.vision): 15 | entity.closest = None 16 | 17 | if entity.__class__.__name__ != 'Player': 18 | return 19 | 20 | if not utils.validResource(entity, entity.food, entity.vision): 21 | entity.food = None 22 | if not utils.validResource(entity, entity.water, entity.vision): 23 | entity.water = None 24 | 25 | 26 | def pathfind(realm, actions, entity, target): 27 | # TODO: do not access realm._np_random directly. ALSO see below for all other uses 28 | actions[nmmo.action.Move] = { 29 | nmmo.action.Direction: move.pathfind(realm.map, entity, target, realm._np_random)} 30 | 31 | 32 | def explore(realm, actions, entity): 33 | sz = realm.config.TERRAIN_SIZE 34 | r, c = entity.pos 35 | 36 | spawnR, spawnC = entity.spawnPos 37 | centR, centC = sz//2, sz//2 38 | 39 | vR, vC = centR-spawnR, centC-spawnC 40 | 41 | mmag = max(abs(vR), abs(vC)) 42 | rr = r + int(np.round(entity.vision*vR/mmag)) 43 | cc = c + int(np.round(entity.vision*vC/mmag)) 44 | 45 | tile = realm.map.tiles[rr, cc] 46 | pathfind(realm, actions, entity, tile) 47 | 48 | 49 | def meander(realm, actions, entity): 50 | actions[nmmo.action.Move] = { 51 | nmmo.action.Direction: move.habitable(realm.map, entity, realm._np_random)} 52 | 53 | def evade(realm, actions, entity): 54 | actions[nmmo.action.Move] = { 55 | nmmo.action.Direction: move.antipathfind(realm.map, entity, entity.attacker, 56 | realm._np_random)} 57 | 58 | def hunt(realm, actions, entity): 59 | # Move args 60 | distance = utils.lInfty(entity.pos, entity.target.pos) 61 | 62 | if distance > 1: 63 | actions[nmmo.action.Move] = {nmmo.action.Direction: move.pathfind(realm.map, 64 | entity, 65 | entity.target, 66 | realm._np_random)} 67 | elif distance == 0: 68 | actions[nmmo.action.Move] = { 69 | nmmo.action.Direction: move.habitable(realm.map, entity, realm._np_random)} 70 | 71 | attack(realm, actions, entity) 72 | 73 | def attack(realm, actions, entity): 74 | distance = utils.lInfty(entity.pos, entity.target.pos) 75 | if distance > entity.skills.style.attack_range(realm.config): 76 | return 77 | 78 | actions[nmmo.action.Attack] = { 79 | nmmo.action.Style: entity.skills.style, 80 | nmmo.action.Target: entity.target} 81 | -------------------------------------------------------------------------------- /tests/task/test_task_system_perf.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import nmmo 4 | from nmmo.core.env import Env 5 | from nmmo.task.task_api import Task, nmmo_default_task 6 | from tests.testhelpers import profile_env_step 7 | 8 | PROFILE_PERF = False 9 | 10 | class TestTaskSystemPerf(unittest.TestCase): 11 | def test_nmmo_default_task(self): 12 | config = nmmo.config.Default() 13 | env = Env(config) 14 | agent_list = env.possible_agents 15 | 16 | for test_mode in [None, 'no_task', 'dummy_eval_fn', 'pure_func_eval']: 17 | 18 | # create tasks 19 | if test_mode == 'pure_func_eval': 20 | def create_stay_alive_eval_wo_group(agent_id: int): 21 | return lambda gs: agent_id in gs.alive_agents 22 | tasks = [Task(create_stay_alive_eval_wo_group(agent_id), assignee=agent_id) 23 | for agent_id in agent_list] 24 | else: 25 | tasks = nmmo_default_task(agent_list, test_mode) 26 | 27 | # check tasks 28 | for agent_id in agent_list: 29 | if test_mode is None: 30 | self.assertTrue('StayAlive' in tasks[agent_id-1].name) # default task 31 | if test_mode != 'no_task': 32 | self.assertTrue(f'assignee:({agent_id},)' in tasks[agent_id-1].name) 33 | 34 | # pylint: disable=cell-var-from-loop 35 | if PROFILE_PERF: 36 | test_cond = 'default' if test_mode is None else test_mode 37 | profile_env_step(tasks=tasks, condition=test_cond) 38 | else: 39 | env.reset(make_task_fn=lambda: tasks) 40 | for _ in range(3): 41 | env.step({}) 42 | 43 | # DONE 44 | 45 | 46 | if __name__ == '__main__': 47 | unittest.main() 48 | 49 | # """ Tested on Win 11, docker 50 | # === Test condition: default (StayAlive-based Predicate) === 51 | # - env.step({}): 13.398321460997977 52 | # - env.realm.step(): 3.6524868449996575 53 | # - env._compute_observations(): 3.2038183499971638 54 | # - obs.to_gym(), ActionTarget: 2.30746804500086 55 | # - env._compute_rewards(): 2.7206644940015394 56 | 57 | # === Test condition: no_task === 58 | # - env.step({}): 10.576253965999058 59 | # - env.realm.step(): 3.674701832998835 60 | # - env._compute_observations(): 3.260661373002222 61 | # - obs.to_gym(), ActionTarget: 2.313872797996737 62 | # - env._compute_rewards(): 0.009020475001307204 63 | 64 | # === Test condition: dummy_eval_fn -based Predicate === 65 | # - env.step({}): 12.797982947995479 66 | # - env.realm.step(): 3.604593793003005 67 | # - env._compute_observations(): 3.2095355240016943 68 | # - obs.to_gym(), ActionTarget: 2.313207338003849 69 | # - env._compute_rewards(): 2.266267291997792 70 | 71 | # === Test condition: pure_func_eval WITHOUT Predicate === 72 | # - env.step({}): 10.637560240997118 73 | # - env.realm.step(): 3.633970066999609 74 | # - env._compute_observations(): 3.2308093659958104 75 | # - obs.to_gym(), ActionTarget: 2.331246039000689 76 | # - env._compute_rewards(): 0.0988905300037004 77 | # """ 78 | -------------------------------------------------------------------------------- /nmmo/render/render_utils.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from scipy import signal 3 | 4 | from nmmo.lib.colors import Neon 5 | 6 | # NOTE: added to fix json.dumps() cannot serialize numpy objects 7 | # pylint: disable=inconsistent-return-statements 8 | def np_encoder(obj): 9 | if isinstance(obj, np.generic): 10 | return obj.item() 11 | 12 | def normalize(ary: np.ndarray, norm_std=2): 13 | R, C = ary.shape 14 | preprocessed = np.zeros_like(ary) 15 | nonzero = ary[ary!= 0] 16 | mean = np.mean(nonzero) 17 | std = np.std(nonzero) 18 | if std == 0: 19 | std = 1 20 | for r in range(R): 21 | for c in range(C): 22 | val = ary[r, c] 23 | if val != 0: 24 | val = (val - mean) / (norm_std * std) 25 | val = np.clip(val+1, 0, 2)/2 26 | preprocessed[r, c] = val 27 | return preprocessed 28 | 29 | def clip(ary: np.ndarray): 30 | R, C = ary.shape 31 | preprocessed = np.zeros_like(ary) 32 | nonzero = ary[ary!= 0] 33 | mmin = np.min(nonzero) 34 | mmag = np.max(nonzero) - mmin 35 | for r in range(R): 36 | for c in range(C): 37 | val = ary[r, c] 38 | val = (val - mmin) / mmag 39 | preprocessed[r, c] = val 40 | return preprocessed 41 | 42 | def make_two_tone(ary, norm_std=2, preprocess='norm', invert=False, periods=1): 43 | if preprocess == 'norm': 44 | ary = normalize(ary, norm_std) 45 | elif preprocess == 'clip': 46 | ary = clip(ary) 47 | 48 | # if preprocess not in ['norm', 'clip'], assume no preprocessing 49 | R, C = ary.shape 50 | 51 | colorized = np.zeros((R, C, 3)) 52 | if periods != 1: 53 | ary = np.abs(signal.sawtooth(periods*3.14159*ary)) 54 | if invert: 55 | colorized[:, :, 0] = ary 56 | colorized[:, :, 1] = 1-ary 57 | else: 58 | colorized[:, :, 0] = 1-ary 59 | colorized[:, :, 1] = ary 60 | 61 | colorized *= (ary != 0)[:, :, None] 62 | 63 | return colorized 64 | 65 | # TODO: this is a hack to make the client work 66 | # by adding color, population, self to the packet 67 | # integrating with team helper could make this neat 68 | def patch_packet(packet, realm): 69 | for ent_id in packet['player']: 70 | packet['player'][ent_id]['base']['color'] = Neon.GREEN.packet() 71 | # EntityAttr: population was changed to npc_type 72 | packet['player'][ent_id]['base']['population'] = 0 73 | # old code: nmmo.Serialized.Entity.Self, no longer being used 74 | packet['player'][ent_id]['base']['self'] = 1 75 | 76 | npc_colors = { 77 | 1: Neon.YELLOW.packet(), # passive npcs 78 | 2: Neon.MAGENTA.packet(), # neutral npcs 79 | 3: Neon.BLOOD.packet() } # aggressive npcs 80 | for ent_id in packet['npc']: 81 | npc = realm.npcs.corporeal[ent_id] 82 | packet['npc'][ent_id]['base']['color'] = npc_colors[int(npc.npc_type.val)] 83 | packet['npc'][ent_id]['base']['population'] = -int(npc.npc_type.val) # note negative 84 | packet['npc'][ent_id]['base']['self'] = 1 85 | 86 | return packet 87 | -------------------------------------------------------------------------------- /nmmo/datastore/datastore.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import Dict, List 3 | from nmmo.datastore.id_allocator import IdAllocator 4 | 5 | """ 6 | This code defines a data storage system that allows for the 7 | creation, manipulation, and querying of records. 8 | 9 | The DataTable class serves as the foundation for the data 10 | storage, providing methods for updating and retrieving data, 11 | as well as filtering and querying records. 12 | 13 | The DatastoreRecord class represents a single record within 14 | a table and provides a simple interface for interacting with 15 | the data. The Datastore class serves as the main entry point 16 | for the data storage system, allowing for the creation and 17 | management of tables and records. 18 | 19 | The implementation of the DataTable class is left to the 20 | developer, but the DatastoreRecord and Datastore classes 21 | should be sufficient for most use cases. 22 | 23 | See numpy_datastore.py for an implementation. 24 | """ 25 | class DataTable: 26 | def __init__(self, num_columns: int): 27 | self._num_columns = num_columns 28 | self._id_allocator = IdAllocator(100) 29 | 30 | def reset(self): 31 | self._id_allocator = IdAllocator(100) 32 | 33 | def update(self, row_id: int, col: int, value): 34 | raise NotImplementedError 35 | 36 | def get(self, ids: List[id]): 37 | raise NotImplementedError 38 | 39 | def where_in(self, col: int, values: List): 40 | raise NotImplementedError 41 | 42 | def where_eq(self, col: str, value): 43 | raise NotImplementedError 44 | 45 | def where_neq(self, col: str, value): 46 | raise NotImplementedError 47 | 48 | def window(self, row_idx: int, col_idx: int, row: int, col: int, radius: int): 49 | raise NotImplementedError 50 | 51 | def remove_row(self, row_id: int): 52 | raise NotImplementedError 53 | 54 | def add_row(self) -> int: 55 | raise NotImplementedError 56 | 57 | def is_empty(self) -> bool: 58 | raise NotImplementedError 59 | 60 | class DatastoreRecord: 61 | def __init__(self, datastore, table: DataTable, row_id: int) -> None: 62 | self.datastore = datastore 63 | self.table = table 64 | self.id = row_id 65 | 66 | def update(self, col: int, value): 67 | self.table.update(self.id, col, value) 68 | 69 | def get(self, col: int): 70 | return self.table.get(self.id)[col] 71 | 72 | def delete(self): 73 | self.table.remove_row(self.id) 74 | 75 | class Datastore: 76 | def __init__(self) -> None: 77 | self._tables: Dict[str, DataTable] = {} 78 | 79 | def register_object_type(self, object_type: str, num_colums: int): 80 | if object_type not in self._tables: 81 | self._tables[object_type] = self._create_table(num_colums) 82 | 83 | def create_record(self, object_type: str) -> DatastoreRecord: 84 | table = self._tables[object_type] 85 | row_id = table.add_row() 86 | return DatastoreRecord(self, table, row_id) 87 | 88 | def table(self, object_type: str) -> DataTable: 89 | return self._tables[object_type] 90 | 91 | def _create_table(self, num_columns: int) -> DataTable: 92 | raise NotImplementedError 93 | -------------------------------------------------------------------------------- /nmmo/core/tile.py: -------------------------------------------------------------------------------- 1 | from types import SimpleNamespace 2 | 3 | from nmmo.datastore.serialized import SerializedState 4 | from nmmo.lib import material 5 | 6 | # pylint: disable=no-member,protected-access 7 | TileState = SerializedState.subclass( 8 | "Tile", [ 9 | "row", 10 | "col", 11 | "material_id", 12 | ]) 13 | 14 | TileState.Limits = lambda config: { 15 | "row": (0, config.MAP_SIZE-1), 16 | "col": (0, config.MAP_SIZE-1), 17 | "material_id": (0, config.MAP_N_TILE), 18 | } 19 | 20 | TileState.Query = SimpleNamespace( 21 | window=lambda ds, r, c, radius: ds.table("Tile").window( 22 | TileState.State.attr_name_to_col["row"], 23 | TileState.State.attr_name_to_col["col"], 24 | r, c, radius), 25 | get_map=lambda ds, map_size: 26 | ds.table("Tile")._data[1:(map_size*map_size+1)] 27 | .reshape((map_size,map_size,len(TileState.State.attr_name_to_col))) 28 | ) 29 | 30 | class Tile(TileState): 31 | def __init__(self, realm, r, c, np_random): 32 | super().__init__(realm.datastore, TileState.Limits(realm.config)) 33 | self.realm = realm 34 | self.config = realm.config 35 | self._np_random = np_random 36 | 37 | self.row.update(r) 38 | self.col.update(c) 39 | 40 | self.state = None 41 | self.material = None 42 | self.depleted = False 43 | self.tex = None 44 | 45 | self.entities = {} 46 | 47 | @property 48 | def repr(self): 49 | return ((self.row.val, self.col.val)) 50 | 51 | @property 52 | def pos(self): 53 | return self.row.val, self.col.val 54 | 55 | @property 56 | def habitable(self): 57 | return self.material in material.Habitable 58 | 59 | @property 60 | def impassible(self): 61 | return self.material in material.Impassible 62 | 63 | @property 64 | def void(self): 65 | return self.material == material.Void 66 | 67 | def reset(self, mat, config, np_random): 68 | self._np_random = np_random # reset the RNG 69 | self.state = mat(config) 70 | self.material = mat(config) 71 | self.material_id.update(self.state.index) 72 | 73 | self.depleted = False 74 | self.tex = self.material.tex 75 | 76 | self.entities = {} 77 | 78 | def add_entity(self, ent): 79 | assert ent.ent_id not in self.entities 80 | self.entities[ent.ent_id] = ent 81 | 82 | def remove_entity(self, ent_id): 83 | assert ent_id in self.entities 84 | del self.entities[ent_id] 85 | 86 | def step(self): 87 | if not self.depleted or self._np_random.random() > self.material.respawn: 88 | return 89 | 90 | self.depleted = False 91 | self.state = self.material 92 | self.material_id.update(self.state.index) 93 | 94 | def harvest(self, deplete): 95 | assert not self.depleted, f'{self.state} is depleted' 96 | assert self.state in material.Harvestable, f'{self.state} not harvestable' 97 | 98 | if deplete: 99 | self.depleted = True 100 | self.state = self.material.deplete(self.config) 101 | self.material_id.update(self.state.index) 102 | 103 | return self.material.harvest() 104 | -------------------------------------------------------------------------------- /tests/systems/test_exchange.py: -------------------------------------------------------------------------------- 1 | from types import SimpleNamespace 2 | import unittest 3 | import nmmo 4 | from nmmo.datastore.numpy_datastore import NumpyDatastore 5 | from nmmo.systems.exchange import Exchange 6 | from nmmo.systems.item import ItemState 7 | import nmmo.systems.item as item 8 | import numpy as np 9 | 10 | class MockRealm: 11 | def __init__(self): 12 | self.config = nmmo.config.Default() 13 | self.config.EXCHANGE_LISTING_DURATION = 3 14 | self.datastore = NumpyDatastore() 15 | self.items = {} 16 | self.datastore.register_object_type("Item", ItemState.State.num_attributes) 17 | 18 | class MockEntity: 19 | def __init__(self) -> None: 20 | self.items = [] 21 | self.inventory = SimpleNamespace( 22 | receive = lambda item: self.items.append(item), 23 | remove = lambda item: self.items.remove(item) 24 | ) 25 | 26 | class TestExchange(unittest.TestCase): 27 | def test_listings(self): 28 | realm = MockRealm() 29 | exchange = Exchange(realm) 30 | 31 | entity_1 = MockEntity() 32 | 33 | hat_1 = item.Hat(realm, 1) 34 | hat_2 = item.Hat(realm, 10) 35 | entity_1.inventory.receive(hat_1) 36 | entity_1.inventory.receive(hat_2) 37 | self.assertEqual(len(entity_1.items), 2) 38 | 39 | tick = 0 40 | exchange._list_item(hat_1, entity_1, 10, tick) 41 | self.assertEqual(len(exchange._item_listings), 1) 42 | self.assertEqual(exchange._listings_queue[0], (hat_1.id.val, 0)) 43 | 44 | tick = 1 45 | exchange._list_item(hat_2, entity_1, 20, tick) 46 | self.assertEqual(len(exchange._item_listings), 2) 47 | self.assertEqual(exchange._listings_queue[0], (hat_1.id.val, 0)) 48 | 49 | tick = 4 50 | exchange.step(tick) 51 | # hat_1 should expire and not be listed 52 | self.assertEqual(len(exchange._item_listings), 1) 53 | self.assertEqual(exchange._listings_queue[0], (hat_2.id.val, 1)) 54 | 55 | tick = 5 56 | exchange._list_item(hat_2, entity_1, 10, tick) 57 | exchange.step(tick) 58 | # hat_2 got re-listed, so should still be listed 59 | self.assertEqual(len(exchange._item_listings), 1) 60 | self.assertEqual(exchange._listings_queue[0], (hat_2.id.val, 5)) 61 | 62 | tick = 10 63 | exchange.step(tick) 64 | self.assertEqual(len(exchange._item_listings), 0) 65 | 66 | def test_for_sale_items(self): 67 | realm = MockRealm() 68 | exchange = Exchange(realm) 69 | entity_1 = MockEntity() 70 | 71 | hat_1 = item.Hat(realm, 1) 72 | hat_2 = item.Hat(realm, 10) 73 | exchange._list_item(hat_1, entity_1, 10, 0) 74 | exchange._list_item(hat_2, entity_1, 20, 10) 75 | 76 | np.testing.assert_array_equal( 77 | item.Item.Query.for_sale(realm.datastore)[:,0], [hat_1.id.val, hat_2.id.val]) 78 | 79 | # first listing should expire 80 | exchange.step(10) 81 | np.testing.assert_array_equal( 82 | item.Item.Query.for_sale(realm.datastore)[:,0], [hat_2.id.val]) 83 | 84 | # second listing should expire 85 | exchange.step(100) 86 | np.testing.assert_array_equal( 87 | item.Item.Query.for_sale(realm.datastore)[:,0], []) 88 | 89 | if __name__ == '__main__': 90 | unittest.main() -------------------------------------------------------------------------------- /nmmo/core/map.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | import numpy as np 4 | from ordered_set import OrderedSet 5 | 6 | from nmmo.core.tile import Tile 7 | from nmmo.lib import material 8 | 9 | 10 | class Map: 11 | '''Map object representing a list of tiles 12 | 13 | Also tracks a sparse list of tile updates 14 | ''' 15 | def __init__(self, config, realm, np_random): 16 | self.config = config 17 | self._repr = None 18 | self.realm = realm 19 | self.update_list = None 20 | self.pathfinding_cache = {} # Avoid recalculating A*, paths don't move 21 | 22 | sz = config.MAP_SIZE 23 | self.tiles = np.zeros((sz, sz), dtype=object) 24 | self.habitable_tiles = np.zeros((sz,sz)) 25 | 26 | for r in range(sz): 27 | for c in range(sz): 28 | self.tiles[r, c] = Tile(realm, r, c, np_random) 29 | 30 | self.dist_border_center = config.MAP_CENTER // 2 31 | self.center_coord = (config.MAP_BORDER + self.dist_border_center, 32 | config.MAP_BORDER + self.dist_border_center) 33 | 34 | @property 35 | def packet(self): 36 | '''Packet of degenerate resource states''' 37 | missing_resources = [] 38 | for e in self.update_list: 39 | missing_resources.append(e.pos) 40 | return missing_resources 41 | 42 | @property 43 | def repr(self): 44 | '''Flat matrix of tile material indices''' 45 | if not self._repr: 46 | self._repr = [[t.material.index for t in row] for row in self.tiles] 47 | 48 | return self._repr 49 | 50 | def reset(self, map_id, np_random): 51 | '''Reuse the current tile objects to load a new map''' 52 | config = self.config 53 | self.update_list = OrderedSet() # critical for determinism 54 | 55 | path_map_suffix = config.PATH_MAP_SUFFIX.format(map_id) 56 | f_path = os.path.join(config.PATH_CWD, config.PATH_MAPS, path_map_suffix) 57 | 58 | try: 59 | map_file = np.load(f_path) 60 | except FileNotFoundError: 61 | logging.error('Maps not found') 62 | raise 63 | 64 | materials = {mat.index: mat for mat in material.All} 65 | r, c = 0, 0 66 | for r, row in enumerate(map_file): 67 | for c, idx in enumerate(row): 68 | mat = materials[idx] 69 | tile = self.tiles[r, c] 70 | tile.reset(mat, config, np_random) 71 | self.habitable_tiles[r, c] = tile.habitable 72 | 73 | assert c == config.MAP_SIZE - 1 74 | assert r == config.MAP_SIZE - 1 75 | 76 | self._repr = None 77 | 78 | def step(self): 79 | '''Evaluate updatable tiles''' 80 | self.realm.log_milestone('Resource_Depleted', len(self.update_list), 81 | f'RESOURCE: Depleted {len(self.update_list)} resource tiles') 82 | 83 | for e in self.update_list.copy(): 84 | if not e.depleted: 85 | self.update_list.remove(e) 86 | e.step() 87 | 88 | def harvest(self, r, c, deplete=True): 89 | '''Called by actions that harvest a resource tile''' 90 | 91 | if deplete: 92 | self.update_list.add(self.tiles[r, c]) 93 | 94 | return self.tiles[r, c].harvest(deplete) 95 | 96 | def is_valid_pos(self, row, col): 97 | '''Check if a position is valid''' 98 | return 0 <= row < self.config.MAP_SIZE and 0 <= col < self.config.MAP_SIZE 99 | -------------------------------------------------------------------------------- /nmmo/render/replay_helper.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import logging 4 | import lzma 5 | import pickle 6 | from typing import Dict 7 | 8 | from .render_utils import np_encoder, patch_packet 9 | 10 | class ReplayHelper: 11 | def __init__(self): 12 | self._realm = None 13 | 14 | def set_realm(self, realm) -> None: 15 | self._realm = realm 16 | 17 | def reset(self): 18 | pass 19 | 20 | def update(self): 21 | pass 22 | 23 | def save(self, filename_prefix, compress): 24 | pass 25 | 26 | class DummyReplayHelper(ReplayHelper): 27 | pass 28 | 29 | class FileReplayHelper(ReplayHelper): 30 | def __init__(self, realm=None): 31 | super().__init__() 32 | self._realm = realm 33 | self.packets = None 34 | self.map = None 35 | self._i = 0 36 | 37 | def reset(self): 38 | self.packets = [] 39 | self.map = None 40 | self._i = 0 41 | self.update() # to capture the initial packet 42 | 43 | def __len__(self): 44 | return len(self.packets) 45 | 46 | def __iter__(self): 47 | self._i = 0 48 | return self 49 | 50 | def __next__(self): 51 | if self._i >= len(self.packets): 52 | raise StopIteration 53 | packet = self.packets[self._i] 54 | packet['environment'] = self.map 55 | self._i += 1 56 | return packet 57 | 58 | def _packet(self): 59 | assert self._realm is not None, 'Realm not set' 60 | 61 | # TODO: remove patch_packet 62 | packet = patch_packet(self._realm.packet(), self._realm) 63 | 64 | if "environment" in packet: 65 | self.map = packet["environment"] 66 | del packet["environment"] 67 | if "config" in packet: 68 | del packet["config"] 69 | 70 | return packet 71 | 72 | def _metadata(self) -> Dict: 73 | return { 74 | 'event_log': self._realm.event_log.get_data(), 75 | 'event_attr_col': self._realm.event_log.attr_to_col 76 | } 77 | 78 | def update(self): 79 | self.packets.append(self._packet()) 80 | 81 | def save(self, filename_prefix, compress=False): 82 | replay_file = f'{filename_prefix}.replay.json' 83 | metadata_file = f'{filename_prefix}.metadata.pkl' 84 | 85 | data = json.dumps({ 86 | 'map': self.map, 87 | 'packets': self.packets 88 | }, default=np_encoder).encode('utf8') 89 | 90 | if compress: 91 | replay_file = f'{filename_prefix}.replay.lzma' 92 | data = lzma.compress(data, format=lzma.FORMAT_ALONE) 93 | 94 | logging.info('Saving replay to %s ...', replay_file) 95 | 96 | with open(replay_file, 'wb') as out: 97 | out.write(data) 98 | 99 | with open(metadata_file, 'wb') as out: 100 | pickle.dump(self._metadata(), out) 101 | 102 | @classmethod 103 | def load(cls, replay_file): 104 | extension = os.path.splitext(replay_file)[1] 105 | with open(replay_file, 'rb') as fp: 106 | data = fp.read() 107 | 108 | if extension != '.json': 109 | data = lzma.decompress(data, format=lzma.FORMAT_ALONE) 110 | data = json.loads(data.decode('utf-8')) 111 | 112 | replay_helper = FileReplayHelper() 113 | replay_helper.map = data['map'] 114 | replay_helper.packets = data['packets'] 115 | 116 | return replay_helper 117 | -------------------------------------------------------------------------------- /tests/action/test_monkey_action.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import random 3 | from tqdm import tqdm 4 | 5 | import numpy as np 6 | 7 | from tests.testhelpers import ScriptedAgentTestConfig, ScriptedAgentTestEnv 8 | 9 | import nmmo 10 | 11 | # 30 seems to be enough to test variety of agent actions 12 | TEST_HORIZON = 30 13 | RANDOM_SEED = random.randint(0, 1000000) 14 | 15 | 16 | def make_random_actions(config, ent_obs): 17 | assert 'ActionTargets' in ent_obs, 'ActionTargets is not provided in the obs' 18 | actions = {} 19 | 20 | # atn, arg, val 21 | for atn in sorted(nmmo.Action.edges(config)): 22 | actions[atn] = {} 23 | for arg in sorted(atn.edges, reverse=True): # intentionally doing wrong 24 | mask = ent_obs["ActionTargets"][atn.__name__][arg.__name__] 25 | actions[atn][arg] = 0 26 | if np.any(mask): 27 | actions[atn][arg] += int(np.random.choice(np.where(mask)[0])) 28 | 29 | return actions 30 | 31 | # CHECK ME: this would be nice to include in the env._validate_actions() 32 | def filter_item_actions(actions, use_str_key=False): 33 | # when there are multiple actions on the same item, select one 34 | flt_atns = {} 35 | inventory_atn = {} # key: inventory idx, val: action 36 | for atn in actions: 37 | if atn in [nmmo.action.Use, nmmo.action.Sell, nmmo.action.Give, nmmo.action.Destroy]: 38 | for arg, val in actions[atn].items(): 39 | if arg == nmmo.action.InventoryItem: 40 | if val not in inventory_atn: 41 | inventory_atn[val] = [( atn, actions[atn] )] 42 | else: 43 | inventory_atn[val].append(( atn, actions[atn] )) 44 | else: 45 | flt_atns[atn] = actions[atn] 46 | 47 | # randomly select one action for each inventory item 48 | for atns in inventory_atn.values(): 49 | if len(atns) > 1: 50 | picked = random.choice(atns) 51 | flt_atns[picked[0]] = picked[1] 52 | else: 53 | flt_atns[atns[0][0]] = atns[0][1] 54 | 55 | # convert action keys to str 56 | if use_str_key: 57 | str_atns = {} 58 | for atn, args in flt_atns.items(): 59 | str_atns[atn.__name__] = {} 60 | for arg, val in args.items(): 61 | str_atns[atn.__name__][arg.__name__] = val 62 | flt_atns = str_atns 63 | 64 | return flt_atns 65 | 66 | 67 | class TestMonkeyAction(unittest.TestCase): 68 | @classmethod 69 | def setUpClass(cls): 70 | cls.config = ScriptedAgentTestConfig() 71 | cls.config.PROVIDE_ACTION_TARGETS = True 72 | 73 | @staticmethod 74 | # NOTE: this can also be used for sweeping random seeds 75 | def rollout_with_seed(config, seed, use_str_key=False): 76 | env = ScriptedAgentTestEnv(config) 77 | obs = env.reset(seed=seed) 78 | 79 | for _ in tqdm(range(TEST_HORIZON)): 80 | # sample random actions for each player 81 | actions = {} 82 | for ent_id in env.realm.players: 83 | ent_atns = make_random_actions(config, obs[ent_id]) 84 | actions[ent_id] = filter_item_actions(ent_atns, use_str_key) 85 | obs, _, _, _ = env.step(actions) 86 | 87 | def test_monkey_action(self): 88 | try: 89 | self.rollout_with_seed(self.config, RANDOM_SEED) 90 | except: # pylint: disable=bare-except 91 | assert False, f"Monkey action failed. seed: {RANDOM_SEED}" 92 | 93 | def test_monkey_action_with_str_key(self): 94 | self.rollout_with_seed(self.config, RANDOM_SEED, use_str_key=True) 95 | 96 | if __name__ == '__main__': 97 | unittest.main() 98 | -------------------------------------------------------------------------------- /nmmo/task/group.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import Dict, Union, Iterable, TYPE_CHECKING 3 | from collections import OrderedDict 4 | from collections.abc import Set, Sequence 5 | import weakref 6 | 7 | if TYPE_CHECKING: 8 | from nmmo.task.game_state import GameState, GroupView 9 | 10 | class Group(Sequence, Set): 11 | ''' An immutable, ordered, unique group of agents involved in a task 12 | ''' 13 | def __init__(self, 14 | agents: Union(Iterable[int], int), 15 | name: str=None): 16 | 17 | if isinstance(agents, int): 18 | agents = (agents,) 19 | assert len(agents) > 0, "Team must have at least one agent" 20 | self.name = name if name else f"Agent({','.join([str(e) for e in agents])})" 21 | # Remove duplicates 22 | self._agents = tuple(OrderedDict.fromkeys(sorted(agents)).keys()) 23 | if not isinstance(self._agents,tuple): 24 | self._agents = (self._agents,) 25 | 26 | self._sd: GroupView = None 27 | self._gs: GameState = None 28 | 29 | self._hash = hash(self._agents) 30 | 31 | @property 32 | def agents(self): 33 | return self._agents 34 | 35 | def union(self, o: Group): 36 | return Group(self._agents + o.agents) 37 | 38 | def intersection(self, o: Group): 39 | return Group(set(self._agents).intersection(set(o.agents))) 40 | 41 | def __eq__(self, o): 42 | return self._agents == o 43 | 44 | def __len__(self): 45 | return len(self._agents) 46 | 47 | def __hash__(self): 48 | return self._hash 49 | 50 | def __getitem__(self, key): 51 | if len(self) == 1 and key == 0: 52 | return self 53 | return Group((self._agents[key],), f"{self.name}.{key}") 54 | 55 | def __contains__(self, key): 56 | if isinstance(key, int): 57 | return key in self.agents 58 | return Sequence.__contains__(self, key) 59 | 60 | def __str__(self) -> str: 61 | return str(self._agents) 62 | 63 | def __int__(self) -> int: 64 | assert len(self._agents) == 1, "Group is not a singleton" 65 | return int(self._agents[0]) 66 | 67 | def __copy__(self): 68 | return self 69 | def __deepcopy__(self, memo): 70 | return Group(self.agents, self.name) 71 | 72 | def description(self) -> Dict: 73 | return { 74 | "type": "Group", 75 | "name": self.name, 76 | "agents": self._agents 77 | } 78 | 79 | def clear_prev_state(self) -> None: 80 | if self._gs is not None: 81 | self._gs.clear_cache() # prevent memory leak 82 | self._gs = None 83 | if self._sd is not None: 84 | weakref.ref(self._sd) # prevent memory leak 85 | self._sd = None 86 | 87 | def update(self, gs: GameState) -> None: 88 | self.clear_prev_state() 89 | self._gs = gs 90 | self._sd = gs.get_subject_view(self) 91 | 92 | def __getattr__(self, attr): 93 | return self._sd.__getattribute__(attr) 94 | 95 | def union(*groups: Group) -> Group: 96 | """ Performs a big union over groups 97 | """ 98 | agents = [] 99 | for group in groups: 100 | for agent in group.agents: 101 | agents.append(agent) 102 | return Group(agents) 103 | 104 | def complement(group: Group, universe: Group) -> Group: 105 | """ Returns the complement of group in universe 106 | """ 107 | agents = [] 108 | for agent in universe.agents: 109 | if not agent in group: 110 | agents.append(agent) 111 | return Group(agents) 112 | -------------------------------------------------------------------------------- /tests/systems/test_skill_level.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import numpy as np 4 | 5 | import nmmo 6 | import nmmo.systems.skill 7 | from tests.testhelpers import ScriptedAgentTestConfig, ScriptedAgentTestEnv 8 | 9 | 10 | class TestSkillLevel(unittest.TestCase): 11 | @classmethod 12 | def setUpClass(cls): 13 | cls.config = ScriptedAgentTestConfig() 14 | cls.config.PROGRESSION_EXP_THRESHOLD = [0, 10, 20, 30, 40, 50] 15 | cls.config.PROGRESSION_LEVEL_MAX = len(cls.config.PROGRESSION_EXP_THRESHOLD) 16 | cls.env = ScriptedAgentTestEnv(cls.config) 17 | 18 | def test_experience_calculator(self): 19 | exp_calculator = nmmo.systems.skill.ExperienceCalculator(self.config) 20 | 21 | self.assertTrue(np.array_equal(self.config.PROGRESSION_EXP_THRESHOLD, 22 | exp_calculator.exp_threshold)) 23 | 24 | for level in range(1, self.config.PROGRESSION_LEVEL_MAX + 1): 25 | self.assertEqual(exp_calculator.level_at_exp(exp_calculator.exp_at_level(level)), level) 26 | 27 | self.assertEqual(exp_calculator.exp_at_level(-1), # invalid level 28 | min(self.config.PROGRESSION_EXP_THRESHOLD)) 29 | self.assertEqual(exp_calculator.exp_at_level(30), # level above the max 30 | max(self.config.PROGRESSION_EXP_THRESHOLD)) 31 | 32 | self.assertEqual(exp_calculator.level_at_exp(0), 1) 33 | self.assertEqual(exp_calculator.level_at_exp(5), 1) 34 | self.assertEqual(exp_calculator.level_at_exp(45), 5) 35 | self.assertEqual(exp_calculator.level_at_exp(50), 6) 36 | self.assertEqual(exp_calculator.level_at_exp(100), 6) 37 | 38 | def test_add_xp(self): 39 | self.env.reset() 40 | player = self.env.realm.players[1] 41 | 42 | skill_list = ["melee", "range", "mage", 43 | "fishing", "herbalism", "prospecting", "carving", "alchemy"] 44 | 45 | # check the initial levels and exp 46 | for skill in skill_list: 47 | self.assertEqual(getattr(player.skills, skill).level.val, 1) 48 | self.assertEqual(getattr(player.skills, skill).exp.val, 0) 49 | 50 | # add 1 exp to melee, does NOT level up 51 | player.skills.melee.add_xp(1) 52 | for skill in skill_list: 53 | if skill == "melee": 54 | self.assertEqual(getattr(player.skills, skill).level.val, 1) 55 | self.assertEqual(getattr(player.skills, skill).exp.val, 1) 56 | else: 57 | self.assertEqual(getattr(player.skills, skill).level.val, 1) 58 | self.assertEqual(getattr(player.skills, skill).exp.val, 0) 59 | 60 | # add 30 exp to fishing, levels up to 3 61 | player.skills.fishing.add_xp(30) 62 | for skill in skill_list: 63 | if skill == "melee": 64 | self.assertEqual(getattr(player.skills, skill).level.val, 1) 65 | self.assertEqual(getattr(player.skills, skill).exp.val, 1) 66 | elif skill == "fishing": 67 | self.assertEqual(getattr(player.skills, skill).level.val, 4) 68 | self.assertEqual(getattr(player.skills, skill).exp.val, 30) 69 | else: 70 | self.assertEqual(getattr(player.skills, skill).level.val, 1) 71 | self.assertEqual(getattr(player.skills, skill).exp.val, 0) 72 | 73 | 74 | if __name__ == '__main__': 75 | unittest.main() 76 | 77 | # config = nmmo.config.Default() 78 | # exp_calculator = nmmo.systems.skill.ExperienceCalculator(config) 79 | 80 | # print(exp_calculator.exp_threshold) 81 | # print(exp_calculator.exp_at_level(10)) 82 | # print(exp_calculator.level_at_exp(150)) # 2 83 | # print(exp_calculator.level_at_exp(300)) # 3 84 | # print(exp_calculator.level_at_exp(1000)) # 7 85 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Game maps 2 | maps/ 3 | *.swp 4 | runs/* 5 | wandb/* 6 | 7 | # local replay file from test_render_save.py 8 | tests/replay_local*.pickle 9 | replay* 10 | eval* 11 | 12 | .vscode 13 | 14 | # Byte-compiled / optimized / DLL files 15 | __pycache__/ 16 | *.py[cod] 17 | *$py.class 18 | 19 | # C extensions 20 | *.so 21 | 22 | # Distribution / packaging 23 | .Python 24 | build/ 25 | develop-eggs/ 26 | dist/ 27 | downloads/ 28 | eggs/ 29 | .eggs/ 30 | #lib/ 31 | lib64/ 32 | parts/ 33 | sdist/ 34 | var/ 35 | wheels/ 36 | share/python-wheels/ 37 | *.egg-info/ 38 | .installed.cfg 39 | *.egg 40 | MANIFEST 41 | 42 | # PyInstaller 43 | # Usually these files are written by a python script from a template 44 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 45 | *.manifest 46 | *.spec 47 | 48 | # Installer logs 49 | pip-log.txt 50 | pip-delete-this-directory.txt 51 | 52 | # Unit test / coverage reports 53 | htmlcov/ 54 | .tox/ 55 | .nox/ 56 | .coverage 57 | .coverage.* 58 | .cache 59 | nosetests.xml 60 | coverage.xml 61 | *.cover 62 | *.py,cover 63 | .hypothesis/ 64 | .pytest_cache/ 65 | cover/ 66 | 67 | # Translations 68 | *.mo 69 | *.pot 70 | 71 | # Django stuff: 72 | *.log 73 | local_settings.py 74 | db.sqlite3 75 | db.sqlite3-journal 76 | 77 | # Flask stuff: 78 | instance/ 79 | .webassets-cache 80 | 81 | # Scrapy stuff: 82 | .scrapy 83 | 84 | # Sphinx documentation 85 | docs/_build/ 86 | 87 | # PyBuilder 88 | .pybuilder/ 89 | target/ 90 | 91 | # Jupyter Notebook 92 | .ipynb_checkpoints 93 | 94 | # IPython 95 | profile_default/ 96 | ipython_config.py 97 | 98 | # pyenv 99 | # For a library or package, you might want to ignore these files since the code is 100 | # intended to run in multiple environments; otherwise, check them in: 101 | # .python-version 102 | 103 | # pipenv 104 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 105 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 106 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 107 | # install all needed dependencies. 108 | #Pipfile.lock 109 | 110 | # poetry 111 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 112 | # This is especially recommended for binary packages to ensure reproducibility, and is more 113 | # commonly ignored for libraries. 114 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 115 | #poetry.lock 116 | 117 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 118 | __pypackages__/ 119 | 120 | # Celery stuff 121 | celerybeat-schedule 122 | celerybeat.pid 123 | 124 | # SageMath parsed files 125 | *.sage.py 126 | 127 | # Environments 128 | .env 129 | .venv 130 | env/ 131 | venv/ 132 | ENV/ 133 | env.bak/ 134 | venv.bak/ 135 | 136 | # Spyder project settings 137 | .spyderproject 138 | .spyproject 139 | 140 | # Rope project settings 141 | .ropeproject 142 | 143 | # mkdocs documentation 144 | /site 145 | 146 | # mypy 147 | .mypy_cache/ 148 | .dmypy.json 149 | dmypy.json 150 | 151 | # Pyre type checker 152 | .pyre/ 153 | 154 | # pytype static type analyzer 155 | .pytype/ 156 | 157 | # Cython debug symbols 158 | cython_debug/ 159 | 160 | # PyCharm 161 | # JetBrains specific template is maintainted in a separate JetBrains.gitignore that can 162 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 163 | # and can be added to the global gitignore or merged into this file. For a more nuclear 164 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 165 | #.idea/ 166 | 167 | profile.run -------------------------------------------------------------------------------- /nmmo/lib/spawn.py: -------------------------------------------------------------------------------- 1 | class SequentialLoader: 2 | '''config.PLAYER_LOADER that spreads out agent populations''' 3 | def __init__(self, config, np_random): 4 | items = config.PLAYERS 5 | 6 | self.items = items 7 | self.idx = -1 8 | 9 | # np_random is the env-level rng 10 | self.candidate_spawn_pos = spawn_concurrent(config, np_random) 11 | 12 | def __iter__(self): 13 | return self 14 | 15 | def __next__(self): 16 | self.idx = (self.idx + 1) % len(self.items) 17 | return self.items[self.idx] 18 | 19 | # pylint: disable=unused-argument 20 | def get_spawn_position(self, agent_id): 21 | # the basic SequentialLoader just provides a random spawn position 22 | return self.candidate_spawn_pos.pop() 23 | 24 | def spawn_continuous(config, np_random): 25 | '''Generates spawn positions for new agents 26 | 27 | Randomly selects spawn positions around 28 | the borders of the square game map 29 | 30 | Returns: 31 | tuple(int, int): 32 | 33 | position: 34 | The position (row, col) to spawn the given agent 35 | ''' 36 | #Spawn at edges 37 | mmax = config.MAP_CENTER + config.MAP_BORDER 38 | mmin = config.MAP_BORDER 39 | 40 | # np_random is the env-level RNG, a drop-in replacement of numpy.random 41 | var = np_random.integers(mmin, mmax) 42 | fixed = np_random.choice([mmin, mmax]) 43 | r, c = int(var), int(fixed) 44 | if np_random.random() > 0.5: 45 | r, c = c, r 46 | return (r, c) 47 | 48 | def get_edge_tiles(config): 49 | '''Returns a list of all edge tiles''' 50 | # Accounts for void borders in coord calcs 51 | left = config.MAP_BORDER 52 | right = config.MAP_CENTER + config.MAP_BORDER 53 | lows = config.MAP_CENTER * [left] 54 | highs = config.MAP_CENTER * [right] 55 | inc = list(range(config.MAP_BORDER, config.MAP_CENTER+config.MAP_BORDER)) 56 | 57 | # All edge tiles in order 58 | sides = [] 59 | sides.append(list(zip(lows, inc))) 60 | sides.append(list(zip(inc, highs))) 61 | sides.append(list(zip(highs, inc[::-1]))) 62 | sides.append(list(zip(inc[::-1], lows))) 63 | 64 | return sides 65 | 66 | def spawn_concurrent(config, np_random): 67 | '''Generates spawn positions for new agents 68 | 69 | Evenly spaces agents around the borders 70 | of the square game map, assuming the edge tiles are all habitable 71 | 72 | Returns: 73 | list of tuple(int, int): 74 | 75 | position: 76 | The position (row, col) to spawn the given agent 77 | ''' 78 | team_size = config.PLAYER_TEAM_SIZE 79 | team_n = len(config.PLAYERS) 80 | teammate_sep = config.PLAYER_SPAWN_TEAMMATE_DISTANCE 81 | 82 | # Number of total border tiles 83 | total_tiles = 4 * config.MAP_CENTER 84 | 85 | # Number of tiles, including within-team sep, occupied by each team 86 | tiles_per_team = teammate_sep*(team_size-1) + team_size 87 | 88 | # Number of total tiles dedicated to separating teams 89 | buffer_tiles = 0 90 | if team_n > 1: 91 | buffer_tiles = total_tiles - tiles_per_team*team_n 92 | 93 | # Number of tiles between teams 94 | team_sep = buffer_tiles // team_n 95 | 96 | sides = [] 97 | for side in get_edge_tiles(config): 98 | sides += side 99 | 100 | if team_n > 1: 101 | # Space across and within teams 102 | spawn_positions = [] 103 | for idx in range(team_sep//2, len(sides), tiles_per_team+team_sep): 104 | for offset in list(range(0, tiles_per_team, teammate_sep+1)): 105 | if len(spawn_positions) >= config.PLAYER_N: 106 | continue 107 | pos = sides[idx + offset] 108 | spawn_positions.append(pos) 109 | else: 110 | # team_n = 1: to fit 128 agents in a small map, ignore spacing and spawn randomly 111 | # np_random is the env-level RNG, a drop-in replacement of numpy.random 112 | np_random.shuffle(sides) 113 | spawn_positions = sides[:config.PLAYER_N] 114 | 115 | return spawn_positions 116 | -------------------------------------------------------------------------------- /nmmo/datastore/serialized.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from ast import Tuple 3 | 4 | import math 5 | from types import SimpleNamespace 6 | from typing import Dict, List 7 | from nmmo.datastore.datastore import Datastore, DatastoreRecord 8 | 9 | """ 10 | This code defines classes for serializing and deserializing data 11 | in a structured way. 12 | 13 | The SerializedAttribute class represents a single attribute of a 14 | record and provides methods for updating and querying its value, 15 | as well as enforcing minimum and maximum bounds on the value. 16 | 17 | The SerializedState class serves as a base class for creating 18 | serialized representations of specific types of data, using a 19 | list of attribute names to define the structure of the data. 20 | The subclass method is a factory method for creating subclasses 21 | of SerializedState that are tailored to specific types of data. 22 | """ 23 | 24 | class SerializedAttribute(): 25 | def __init__(self, 26 | name: str, 27 | datastore_record: DatastoreRecord, 28 | column: int, min_val=-math.inf, max_val=math.inf) -> None: 29 | self._name = name 30 | self.datastore_record = datastore_record 31 | self._column = column 32 | self._min = min_val 33 | self._max = max_val 34 | self._val = 0 35 | 36 | @property 37 | def val(self): 38 | return self._val 39 | 40 | def update(self, value): 41 | if value > self._max: 42 | value = self._max 43 | elif value < self._min: 44 | value = self._min 45 | self.datastore_record.update(self._column, value) 46 | self._val = value 47 | 48 | @property 49 | def min(self): 50 | return self._min 51 | 52 | @property 53 | def max(self): 54 | return self._max 55 | 56 | def increment(self, val=1, max_v=math.inf): 57 | self.update(min(max_v, self.val + val)) 58 | return self 59 | 60 | def decrement(self, val=1, min_v=-math.inf): 61 | self.update(max(min_v, self.val - val)) 62 | return self 63 | 64 | @property 65 | def empty(self): 66 | return self.val == 0 67 | 68 | def __eq__(self, other): 69 | return self.val == other 70 | 71 | def __ne__(self, other): 72 | return self.val != other 73 | 74 | def __lt__(self, other): 75 | return self.val < other 76 | 77 | def __le__(self, other): 78 | return self.val <= other 79 | 80 | def __gt__(self, other): 81 | return self.val > other 82 | 83 | def __ge__(self, other): 84 | return self.val >= other 85 | 86 | class SerializedState(): 87 | @staticmethod 88 | def subclass(name: str, attributes: List[str]): 89 | class Subclass(SerializedState): 90 | _name = name 91 | State = SimpleNamespace( 92 | attr_name_to_col = {a: i for i, a in enumerate(attributes)}, 93 | num_attributes = len(attributes), 94 | table = lambda ds: ds.table(name) 95 | ) 96 | 97 | def __init__(self, datastore: Datastore, 98 | limits: Dict[str, Tuple[float, float]] = None): 99 | 100 | limits = limits or {} 101 | self.datastore_record = datastore.create_record(name) 102 | 103 | for attr, col in self.State.attr_name_to_col.items(): 104 | try: 105 | setattr(self, attr, 106 | SerializedAttribute(attr, self.datastore_record, col, 107 | *limits.get(attr, (-math.inf, math.inf)))) 108 | except Exception as exc: 109 | raise RuntimeError('Failed to set attribute' + attr) from exc 110 | 111 | @classmethod 112 | def parse_array(cls, data) -> SimpleNamespace: 113 | # Takes in a data array and returns a SimpleNamespace object with 114 | # attribute names as keys and corresponding values from the input 115 | # data array. 116 | assert len(data) == cls.State.num_attributes, \ 117 | f"Expected {cls.State.num_attributes} attributes, got {len(data)}" 118 | return SimpleNamespace(**{ 119 | attr: data[col] for attr, col in cls.State.attr_name_to_col.items() 120 | }) 121 | 122 | return Subclass 123 | -------------------------------------------------------------------------------- /tests/test_determinism.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from timeit import timeit 3 | import numpy as np 4 | from tqdm import tqdm 5 | 6 | import nmmo 7 | from nmmo.lib import seeding 8 | from tests.testhelpers import ScriptedAgentTestConfig, ScriptedAgentTestEnv 9 | from tests.testhelpers import observations_are_equal 10 | 11 | # 30 seems to be enough to test variety of agent actions 12 | TEST_HORIZON = 30 13 | RANDOM_SEED = np.random.randint(0, 100000) 14 | 15 | 16 | class TestDeterminism(unittest.TestCase): 17 | def test_np_random_get_direction(self): 18 | # pylint: disable=protected-access,bad-builtin,unnecessary-lambda 19 | np_random_1, np_seed_1 = seeding.np_random(RANDOM_SEED) 20 | np_random_2, np_seed_2 = seeding.np_random(RANDOM_SEED) 21 | self.assertEqual(np_seed_1, np_seed_2) 22 | 23 | # also test get_direction, which was added for speed optimization 24 | self.assertTrue(np.array_equal(np_random_1._dir_seq, np_random_2._dir_seq)) 25 | 26 | print('---test_np_random_get_direction---') 27 | print('np_random.integers():', timeit(lambda: np_random_1.integers(0,4), 28 | number=100000, globals=globals())) 29 | print('np_random.get_direction():', timeit(lambda: np_random_1.get_direction(), 30 | number=100000, globals=globals())) 31 | 32 | def test_map_determinism(self): 33 | config = nmmo.config.Default() 34 | config.MAP_FORCE_GENERATION = True 35 | config.TERRAIN_FLIP_SEED = False 36 | 37 | map_generator = config.MAP_GENERATOR(config) 38 | np_random1, _ = seeding.np_random(RANDOM_SEED) 39 | np_random1_1, _ = seeding.np_random(RANDOM_SEED) 40 | 41 | terrain1, tiles1 = map_generator.generate_map(0, np_random1) 42 | terrain1_1, tiles1_1 = map_generator.generate_map(0, np_random1_1) 43 | 44 | self.assertTrue(np.array_equal(terrain1, terrain1_1)) 45 | self.assertTrue(np.array_equal(tiles1, tiles1_1)) 46 | 47 | # test flip seed 48 | config2 = nmmo.config.Default() 49 | config2.MAP_FORCE_GENERATION = True 50 | config2.TERRAIN_FLIP_SEED = True 51 | 52 | map_generator2 = config2.MAP_GENERATOR(config2) 53 | np_random2, _ = seeding.np_random(RANDOM_SEED) 54 | terrain2, tiles2 = map_generator2.generate_map(0, np_random2) 55 | 56 | self.assertFalse(np.array_equal(terrain1, terrain2)) 57 | self.assertFalse(np.array_equal(tiles1, tiles2)) 58 | 59 | def test_env_level_rng(self): 60 | # two envs running independently should return the same results 61 | 62 | # config to always generate new maps, to test map determinism 63 | config1 = ScriptedAgentTestConfig() 64 | setattr(config1, 'MAP_FORCE_GENERATION', True) 65 | setattr(config1, 'PATH_MAPS', 'maps/det1') 66 | setattr(config1, 'RESOURCE_RESILIENT_POPULATION', 0.2) # uses np_random 67 | config2 = ScriptedAgentTestConfig() 68 | setattr(config2, 'MAP_FORCE_GENERATION', True) 69 | setattr(config2, 'PATH_MAPS', 'maps/det2') 70 | setattr(config2, 'RESOURCE_RESILIENT_POPULATION', 0.2) 71 | 72 | # to create the same maps, seed must be provided 73 | env1 = ScriptedAgentTestEnv(config1, seed=RANDOM_SEED) 74 | env2 = ScriptedAgentTestEnv(config2, seed=RANDOM_SEED) 75 | envs = [env1, env2] 76 | 77 | init_obs = [env.reset(seed=RANDOM_SEED+1) for env in envs] 78 | 79 | self.assertTrue(observations_are_equal(init_obs[0], init_obs[0])) # sanity check 80 | self.assertTrue(observations_are_equal(init_obs[0], init_obs[1]), 81 | f"The multi-env determinism failed. Seed: {RANDOM_SEED}.") 82 | 83 | for _ in tqdm(range(TEST_HORIZON)): 84 | # step returns a tuple of (obs, rewards, dones, infos) 85 | step_results = [env.step({}) for env in envs] 86 | self.assertTrue(observations_are_equal(step_results[0][0], step_results[1][0]), 87 | f"The multi-env determinism failed. Seed: {RANDOM_SEED}.") 88 | 89 | event_logs = [env.realm.event_log.get_data() for env in envs] 90 | self.assertTrue(np.array_equal(event_logs[0], event_logs[1]), 91 | f"The multi-env determinism failed. Seed: {RANDOM_SEED}.") 92 | 93 | 94 | if __name__ == '__main__': 95 | unittest.main() 96 | -------------------------------------------------------------------------------- /nmmo/lib/colors.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=all 2 | 3 | #Various Enums used for handling materials, entity types, etc. 4 | #Data texture pairs are used for enums that require textures. 5 | #These textures are filled in by the Render class at run time. 6 | 7 | import numpy as np 8 | import colorsys 9 | 10 | def rgb(h): 11 | h = h.lstrip('#') 12 | return tuple(int(h[i:i+2], 16) for i in (0, 2, 4)) 13 | 14 | def rgbNorm(h): 15 | h = h.lstrip('#') 16 | return tuple(int(h[i:i+2], 16)/255.0 for i in (0, 2, 4)) 17 | 18 | def makeColor(idx, h=1, s=1, v=1): 19 | r, g, b = colorsys.hsv_to_rgb(h, s, v) 20 | rgbval = tuple(int(255*e) for e in [r, g, b]) 21 | hexval = '%02x%02x%02x' % rgbval 22 | return Color(str(idx), hexval) 23 | 24 | class Color: 25 | def __init__(self, name, hexVal): 26 | self.name = name 27 | self.hex = hexVal 28 | self.rgb = rgb(hexVal) 29 | self.norm = rgbNorm(hexVal) 30 | self.value = self.rgb #Emulate enum 31 | 32 | def packet(self): 33 | return self.hex 34 | 35 | class Color256: 36 | def make256(): 37 | parh, parv = np.meshgrid(np.linspace(0.075, 1, 16), np.linspace(0.25, 1, 16)[::-1]) 38 | parh, parv = parh.T.ravel(), parv.T.ravel() 39 | idxs = np.arange(256) 40 | params = zip(idxs, parh, parv) 41 | colors = [makeColor(idx, h=h, s=1, v=v) for idx, h, v in params] 42 | return colors 43 | colors = make256() 44 | 45 | class Color16: 46 | def make(): 47 | hues = np.linspace(0, 1, 16) 48 | idxs = np.arange(256) 49 | params = zip(idxs, hues) 50 | colors = [makeColor(idx, h=h, s=1, v=1) for idx, h in params] 51 | return colors 52 | colors = make() 53 | 54 | class Tier: 55 | BLACK = Color('BLACK', '#000000') 56 | WOOD = Color('WOOD', '#784d1d') 57 | BRONZE = Color('BRONZE', '#db4508') 58 | SILVER = Color('SILVER', '#dedede') 59 | GOLD = Color('GOLD', '#ffae00') 60 | PLATINUM = Color('PLATINUM', '#cd75ff') 61 | DIAMOND = Color('DIAMOND', '#00bbbb') 62 | 63 | class Swatch: 64 | def colors(): 65 | '''Return list of swatch colors''' 66 | return 67 | 68 | def rand(): 69 | '''Return random swatch color''' 70 | all_colors = Swatch.colors() 71 | randInd = np.random.randint(0, len(all_colors)) 72 | return all_colors[randInd] 73 | 74 | 75 | class Neon(Swatch): 76 | RED = Color('RED', '#ff0000') 77 | ORANGE = Color('ORANGE', '#ff8000') 78 | YELLOW = Color('YELLOW', '#ffff00') 79 | 80 | GREEN = Color('GREEN', '#00ff00') 81 | MINT = Color('MINT', '#00ff80') 82 | CYAN = Color('CYAN', '#00ffff') 83 | 84 | BLUE = Color('BLUE', '#0000ff') 85 | PURPLE = Color('PURPLE', '#8000ff') 86 | MAGENTA = Color('MAGENTA', '#ff00ff') 87 | 88 | FUCHSIA = Color('FUCHSIA', '#ff0080') 89 | SPRING = Color('SPRING', '#80ff80') 90 | SKY = Color('SKY', '#0080ff') 91 | 92 | WHITE = Color('WHITE', '#ffffff') 93 | GRAY = Color('GRAY', '#666666') 94 | BLACK = Color('BLACK', '#000000') 95 | 96 | BLOOD = Color('BLOOD', '#bb0000') 97 | BROWN = Color('BROWN', '#7a3402') 98 | GOLD = Color('GOLD', '#eec600') 99 | SILVER = Color('SILVER', '#b8b8b8') 100 | 101 | TERM = Color('TERM', '#41ff00') 102 | MASK = Color('MASK', '#d67fff') 103 | 104 | def colors(): 105 | return ( 106 | Neon.CYAN, Neon.MINT, Neon.GREEN, 107 | Neon.BLUE, Neon.PURPLE, Neon.MAGENTA, 108 | Neon.FUCHSIA, Neon.SPRING, Neon.SKY, 109 | Neon.RED, Neon.ORANGE, Neon.YELLOW) 110 | 111 | class Solid(Swatch): 112 | BLUE = Color('BLUE', '#1f77b4') 113 | ORANGE = Color('ORANGE', '#ff7f0e') 114 | GREEN = Color('GREEN', '#2ca02c') 115 | 116 | RED = Color('RED', '#D62728') 117 | PURPLE = Color('PURPLE', '#9467bd') 118 | BROWN = Color('BROWN', '#8c564b') 119 | 120 | PINK = Color('PINK', '#e377c2') 121 | GREY = Color('GREY', '#7f7f7f') 122 | CHARTREUSE = Color('CHARTREUSE', '#bcbd22') 123 | 124 | SKY = Color('SKY', '#17becf') 125 | 126 | def colors(): 127 | return ( 128 | Solid.BLUE, Solid.ORANGE, Solid.GREEN, 129 | Solid.RED, Solid.PURPLE, Solid.BROWN, 130 | Solid.PINK, Solid.CHARTREUSE, Solid.SKY, 131 | Solid.GREY) 132 | 133 | class Palette: 134 | def __init__(self, initial_swatch=Neon): 135 | self.colors = {} 136 | for idx, color in enumerate(initial_swatch.colors()): 137 | self.colors[idx] = color 138 | 139 | def color(self, idx): 140 | if idx in self.colors: 141 | return self.colors[idx] 142 | 143 | color = makeColor(idx, h=np.random.rand(), s=1, v=1) 144 | self.colors[idx] = color 145 | return color 146 | -------------------------------------------------------------------------------- /nmmo/render/websocket.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=all 2 | 3 | import numpy as np 4 | 5 | from signal import signal, SIGINT 6 | import json 7 | import os 8 | import sys 9 | import time 10 | import threading 11 | 12 | from twisted.internet import reactor 13 | from twisted.python import log 14 | from twisted.web.server import Site 15 | from twisted.web.static import File 16 | 17 | from autobahn.twisted.websocket import WebSocketServerFactory, \ 18 | WebSocketServerProtocol 19 | from autobahn.twisted.resource import WebSocketResource 20 | 21 | from .render_utils import np_encoder 22 | 23 | class GodswordServerProtocol(WebSocketServerProtocol): 24 | def __init__(self): 25 | super().__init__() 26 | print("Created a server") 27 | self.frame = 0 28 | 29 | #"connected" is already used by WSSP 30 | self.sent_environment = False 31 | self.isConnected = False 32 | 33 | self.pos = [0, 0] 34 | self.cmd = None 35 | 36 | def onOpen(self): 37 | print("Opened connection to server") 38 | 39 | def onClose(self, wasClean, code=None, reason=None): 40 | self.isConnected = False 41 | print('Connection closed') 42 | 43 | def connectionMade(self): 44 | super().connectionMade() 45 | self.factory.clientConnectionMade(self) 46 | 47 | def connectionLost(self, reason): 48 | super().connectionLost(reason) 49 | self.factory.clientConnectionLost(self) 50 | self.sent_environment = False 51 | 52 | #Not used without player interaction 53 | def onMessage(self, packet, isBinary): 54 | print("Server packet", packet) 55 | packet = packet.decode() 56 | _, packet = packet.split(';') #Strip headeer 57 | r, c, cmd = packet.split(' ') #Split camera coords 58 | if len(cmd) == 0 or cmd == '\t': 59 | cmd = None 60 | 61 | self.pos = [int(r), int(c)] 62 | self.cmd = cmd 63 | 64 | self.isConnected = True 65 | 66 | def onConnect(self, request): 67 | print("WebSocket connection request: {}".format(request)) 68 | realm = self.factory.realm 69 | self.realm = realm 70 | self.frame += 1 71 | 72 | def serverPacket(self): 73 | data = self.realm.packet 74 | return data 75 | 76 | def sendUpdate(self, data): 77 | packet = {} 78 | packet['resource'] = data['resource'] 79 | packet['player'] = data['player'] 80 | packet['npc'] = data['npc'] 81 | packet['pos'] = data['pos'] 82 | packet['wilderness'] = data['wilderness'] 83 | packet['market'] = data['market'] 84 | 85 | print('Is Connected? : {}'.format(self.isConnected)) 86 | if not self.sent_environment: 87 | packet['map'] = data['environment'] 88 | packet['border'] = data['border'] 89 | packet['size'] = data['size'] 90 | self.sent_environment=True 91 | 92 | if 'overlay' in data: 93 | packet['overlay'] = data['overlay'] 94 | print('SENDING OVERLAY: ', len(packet['overlay'])) 95 | 96 | packet = json.dumps(packet, default=np_encoder).encode('utf8') 97 | self.sendMessage(packet, False) 98 | 99 | 100 | class WSServerFactory(WebSocketServerFactory): 101 | def __init__(self, ip, realm): 102 | super().__init__(ip) 103 | self.realm = realm 104 | self.time = time.time() 105 | self.clients = [] 106 | 107 | self.pos = [0, 0] 108 | self.cmd = None 109 | self.tickRate = 0.6 110 | self.tick = 0 111 | 112 | def update(self, packet): 113 | self.tick += 1 114 | uptime = np.round(self.tickRate*self.tick, 1) 115 | delta = time.time() - self.time 116 | print('Wall Clock: ', str(delta)[:5], 'Uptime: ', uptime, ', Tick: ', self.tick) 117 | delta = self.tickRate - delta 118 | if delta > 0: 119 | time.sleep(delta) 120 | self.time = time.time() 121 | 122 | for client in self.clients: 123 | client.sendUpdate(packet) 124 | if client.pos is not None: 125 | self.pos = client.pos 126 | self.cmd = client.cmd 127 | 128 | return self.pos, self.cmd 129 | 130 | def clientConnectionMade(self, client): 131 | self.clients.append(client) 132 | 133 | def clientConnectionLost(self, client): 134 | self.clients.remove(client) 135 | 136 | class Application: 137 | def __init__(self, realm): 138 | signal(SIGINT, self.kill) 139 | log.startLogging(sys.stdout) 140 | 141 | port = 8080 142 | self.factory = WSServerFactory(u'ws://localhost:{}'.format(port), realm) 143 | self.factory.protocol = GodswordServerProtocol 144 | resource = WebSocketResource(self.factory) 145 | 146 | root = File(".") 147 | root.putChild(b"ws", resource) 148 | site = Site(root) 149 | 150 | reactor.listenTCP(port, site) 151 | 152 | def run(): 153 | reactor.run(installSignalHandlers=0) 154 | 155 | threading.Thread(target=run).start() 156 | 157 | def update(self, packet): 158 | return self.factory.update(packet) 159 | 160 | def kill(*args): 161 | print("Killed by user") 162 | reactor.stop() 163 | os._exit(0) -------------------------------------------------------------------------------- /nmmo/render/overlay.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from nmmo.lib.colors import Neon 4 | from nmmo.systems import combat 5 | 6 | from .render_utils import normalize 7 | 8 | # pylint: disable=unused-argument 9 | class OverlayRegistry: 10 | def __init__(self, realm, renderer): 11 | '''Manager class for overlays 12 | 13 | Args: 14 | config: A Config object 15 | realm: An environment 16 | ''' 17 | self.initialized = False 18 | 19 | self.realm = realm 20 | self.config = realm.config 21 | self.renderer = renderer 22 | 23 | self.overlays = { 24 | #'counts': Counts, # TODO: change population to team 25 | 'skills': Skills} 26 | 27 | def init(self, *args): 28 | self.initialized = True 29 | for cmd, overlay in self.overlays.items(): 30 | self.overlays[cmd] = overlay(self.config, self.realm, self.renderer, *args) 31 | return self 32 | 33 | def step(self, cmd): 34 | '''Per-tick overlay updates 35 | 36 | Args: 37 | cmd: User command returned by the client 38 | ''' 39 | if not self.initialized: 40 | self.init() 41 | 42 | for overlay in self.overlays.values(): 43 | overlay.update() 44 | 45 | if cmd in self.overlays: 46 | self.overlays[cmd].register() 47 | 48 | 49 | class Overlay: 50 | '''Define a overlay for visualization in the client 51 | 52 | Overlays are color images of the same size as the game map. 53 | They are rendered over the environment with transparency and 54 | can be used to gain insight about agent behaviors.''' 55 | def __init__(self, config, realm, renderer, *args): 56 | ''' 57 | Args: 58 | config: A Config object 59 | realm: An environment 60 | ''' 61 | self.config = config 62 | self.realm = realm 63 | self.renderer = renderer 64 | 65 | self.size = config.MAP_SIZE 66 | self.values = np.zeros((self.size, self.size)) 67 | 68 | def update(self): 69 | '''Compute per-tick updates to this overlay. Override per overlay. 70 | 71 | Args: 72 | obs: Observation returned by the environment 73 | ''' 74 | 75 | def register(self): 76 | '''Compute the overlay and register it within realm. Override per overlay.''' 77 | 78 | 79 | class Skills(Overlay): 80 | def __init__(self, config, realm, renderer, *args): 81 | '''Indicates whether agents specialize in foraging or combat''' 82 | super().__init__(config, realm, renderer) 83 | self.num_skill = 2 84 | 85 | self.values = np.zeros((self.size, self.size, self.num_skill)) 86 | 87 | def update(self): 88 | '''Computes a count-based exploration map by painting 89 | tiles as agents walk over them''' 90 | for agent in self.realm.players.values(): 91 | r, c = agent.pos 92 | 93 | skill_lvl = (agent.skills.food.level.val + agent.skills.water.level.val)/2.0 94 | combat_lvl = combat.level(agent.skills) 95 | 96 | if skill_lvl == 10 and combat_lvl == 3: 97 | continue 98 | 99 | self.values[r, c, 0] = skill_lvl 100 | self.values[r, c, 1] = combat_lvl 101 | 102 | def register(self): 103 | values = np.zeros((self.size, self.size, self.num_skill)) 104 | for idx in range(self.num_skill): 105 | ary = self.values[:, :, idx] 106 | vals = ary[ary != 0] 107 | mean = np.mean(vals) 108 | std = np.std(vals) 109 | if std == 0: 110 | std = 1 111 | 112 | values[:, :, idx] = (ary - mean) / std 113 | values[ary == 0] = 0 114 | 115 | colors = np.array([Neon.BLUE.rgb, Neon.BLOOD.rgb]) 116 | colorized = np.zeros((self.size, self.size, 3)) 117 | amax = np.argmax(values, -1) 118 | 119 | for idx in range(self.num_skill): 120 | colorized[amax == idx] = colors[idx] / 255 121 | colorized[values[:, :, idx] == 0] = 0 122 | 123 | self.renderer.register(colorized) 124 | 125 | 126 | # CHECK ME: this was based on population, so disabling it for now 127 | # We may want this back for the team-level analysis 128 | class Counts(Overlay): 129 | def __init__(self, config, realm, renderer, *args): 130 | super().__init__(config, realm, renderer) 131 | self.values = np.zeros((self.size, self.size, config.PLAYER_POLICIES)) 132 | 133 | def update(self): 134 | '''Computes a count-based exploration map by painting 135 | tiles as agents walk over them''' 136 | for ent_id, agent in self.realm.players.items(): 137 | r, c = agent.pos 138 | self.values[r, c][ent_id] += 1 139 | 140 | def register(self): 141 | colors = self.realm.players.palette.colors 142 | colors = np.array([colors[pop].rgb 143 | for pop in range(self.config.PLAYER_POLICIES)]) 144 | 145 | colorized = self.values[:, :, :, None] * colors / 255 146 | colorized = np.sum(colorized, -2) 147 | count_sum = np.sum(self.values[:, :], -1) 148 | data = normalize(count_sum)[..., None] 149 | 150 | count_sum[count_sum==0] = 1 151 | colorized = colorized * data / count_sum[..., None] 152 | 153 | self.renderer.register(colorized) 154 | -------------------------------------------------------------------------------- /nmmo/entity/player.py: -------------------------------------------------------------------------------- 1 | from nmmo.systems.skill import Skills 2 | from nmmo.entity import entity 3 | from nmmo.lib.log import EventCode 4 | 5 | # pylint: disable=no-member 6 | class Player(entity.Entity): 7 | def __init__(self, realm, pos, agent, resilient=False): 8 | super().__init__(realm, pos, agent.iden, agent.policy) 9 | 10 | self.agent = agent 11 | self.immortal = realm.config.IMMORTAL 12 | self.resources.resilient = resilient 13 | 14 | # Scripted hooks 15 | self.target = None 16 | self.vision = 7 17 | 18 | # Logs 19 | self.buys = 0 20 | self.sells = 0 21 | self.ration_consumed = 0 22 | self.poultice_consumed = 0 23 | self.ration_level_consumed = 0 24 | self.poultice_level_consumed = 0 25 | 26 | # initialize skills with the base level 27 | self.skills = Skills(realm, self) 28 | if realm.config.PROGRESSION_SYSTEM_ENABLED: 29 | for skill in self.skills.skills: 30 | skill.level.update(realm.config.PROGRESSION_BASE_LEVEL) 31 | 32 | # Gold: initialize with 1 gold (EXCHANGE_BASE_GOLD). 33 | # If the base amount is more than 1, alss check the npc's init gold. 34 | if realm.config.EXCHANGE_SYSTEM_ENABLED: 35 | self.gold.update(realm.config.EXCHANGE_BASE_GOLD) 36 | 37 | @property 38 | def serial(self): 39 | return self.ent_id 40 | 41 | @property 42 | def is_player(self) -> bool: 43 | return True 44 | 45 | @property 46 | def level(self) -> int: 47 | # a player's level is the max of all skills 48 | # CHECK ME: the initial level is 1 because of Basic skills, 49 | # which are harvesting food/water and don't progress 50 | return max(e.level.val for e in self.skills.skills) 51 | 52 | def apply_damage(self, dmg, style): 53 | super().apply_damage(dmg, style) 54 | self.skills.apply_damage(style) 55 | 56 | # TODO(daveey): The returns for this function are a mess 57 | def receive_damage(self, source, dmg): 58 | if self.immortal: 59 | return False 60 | 61 | # super().receive_damage returns True if self is alive after taking dmg 62 | if super().receive_damage(source, dmg): 63 | return True 64 | 65 | if not self.config.ITEM_SYSTEM_ENABLED: 66 | return False 67 | 68 | # starting from here, source receive gold & inventory items 69 | if self.config.EXCHANGE_SYSTEM_ENABLED and source is not None: 70 | if self.gold.val > 0: 71 | source.gold.increment(self.gold.val) 72 | self.realm.event_log.record(EventCode.EARN_GOLD, source, amount=self.gold.val) 73 | self.gold.update(0) 74 | 75 | # TODO: make source receive the highest-level items first 76 | # because source cannot take it if the inventory is full 77 | item_list = list(self.inventory.items) 78 | self._np_random.shuffle(item_list) 79 | for item in item_list: 80 | self.inventory.remove(item) 81 | 82 | # if source is None or NPC, destroy the item 83 | if source.is_player: 84 | # inventory.receive() returns True if the item is received 85 | # if source doesn't have space, inventory.receive() destroys the item 86 | if source.inventory.receive(item): 87 | self.realm.event_log.record(EventCode.LOOT_ITEM, source, item=item) 88 | else: 89 | item.destroy() 90 | 91 | # CHECK ME: this is an empty function. do we still need this? 92 | self.skills.receive_damage(dmg) 93 | return False 94 | 95 | @property 96 | def equipment(self): 97 | return self.inventory.equipment 98 | 99 | def packet(self): 100 | data = super().packet() 101 | data['entID'] = self.ent_id 102 | data['resource'] = self.resources.packet() 103 | data['skills'] = self.skills.packet() 104 | data['inventory'] = self.inventory.packet() 105 | 106 | return data 107 | 108 | def update(self, realm, actions): 109 | '''Post-action update. Do not include history''' 110 | super().update(realm, actions) 111 | 112 | # Spawsn battle royale style death fog 113 | # Starts at 0 damage on the specified config tick 114 | # Moves in from the edges by 1 damage per tile per tick 115 | # So after 10 ticks, you take 10 damage at the edge and 1 damage 116 | # 10 tiles in, 0 damage in farther 117 | # This means all agents will be force killed around 118 | # MAP_CENTER / 2 + 100 ticks after spawning 119 | fog = self.config.PLAYER_DEATH_FOG 120 | if fog is not None and self.realm.tick >= fog: 121 | row, col = self.pos 122 | cent = self.config.MAP_BORDER + self.config.MAP_CENTER // 2 123 | 124 | # Distance from center of the map 125 | dist = max(abs(row - cent), abs(col - cent)) 126 | 127 | # Safe final area 128 | if dist > self.config.PLAYER_DEATH_FOG_FINAL_SIZE: 129 | # Damage based on time and distance from center 130 | time_dmg = self.config.PLAYER_DEATH_FOG_SPEED * (self.realm.tick - fog + 1) 131 | dist_dmg = dist - self.config.MAP_CENTER // 2 132 | dmg = max(0, dist_dmg + time_dmg) 133 | self.receive_damage(None, dmg) 134 | 135 | if not self.alive: 136 | return 137 | 138 | self.resources.update() 139 | self.skills.update() 140 | -------------------------------------------------------------------------------- /nmmo/systems/ai/utils.py: -------------------------------------------------------------------------------- 1 | #pylint: disable=protected-access, invalid-name 2 | 3 | import heapq 4 | from typing import Tuple 5 | 6 | import numpy as np 7 | 8 | from nmmo.lib.utils import in_bounds 9 | 10 | 11 | def validTarget(ent, targ, rng): 12 | if targ is None or not targ.alive or lInfty(ent.pos, targ.pos) > rng: 13 | return False 14 | return True 15 | 16 | 17 | def validResource(ent, tile, rng): 18 | return tile is not None and tile.state.tex in ( 19 | 'foilage', 'water') and lInfty(ent.pos, tile.pos) <= rng 20 | 21 | 22 | def directionTowards(ent, targ): 23 | sr, sc = ent.pos 24 | tr, tc = targ.pos 25 | 26 | if abs(sc - tc) > abs(sr - tr): 27 | direction = (0, np.sign(tc - sc)) 28 | else: 29 | direction = (np.sign(tr - sr), 0) 30 | 31 | return direction 32 | 33 | 34 | def closestTarget(ent, tiles, rng=1): 35 | sr, sc = ent.pos 36 | for d in range(rng+1): 37 | for r in range(-d, d+1): 38 | for e in tiles[sr+r, sc-d].entities.values(): 39 | if e is not ent and validTarget(ent, e, rng): 40 | return e 41 | 42 | for e in tiles[sr + r, sc + d].entities.values(): 43 | if e is not ent and validTarget(ent, e, rng): 44 | return e 45 | 46 | for e in tiles[sr - d, sc + r].entities.values(): 47 | if e is not ent and validTarget(ent, e, rng): 48 | return e 49 | 50 | for e in tiles[sr + d, sc + r].entities.values(): 51 | if e is not ent and validTarget(ent, e, rng): 52 | return e 53 | return None 54 | 55 | 56 | def lInf(ent, targ): 57 | sr, sc = ent.pos 58 | gr, gc = targ.pos 59 | return abs(gr - sr) + abs(gc - sc) 60 | 61 | 62 | def adjacentPos(pos): 63 | r, c = pos 64 | return [(r - 1, c), (r, c - 1), (r + 1, c), (r, c + 1)] 65 | 66 | 67 | def cropTilesAround(position: Tuple[int, int], horizon: int, tiles): 68 | line, column = position 69 | 70 | return tiles[max(line - horizon, 0): min(line + horizon + 1, len(tiles)), 71 | max(column - horizon, 0): min(column + horizon + 1, len(tiles[0]))] 72 | 73 | # A* Search 74 | 75 | 76 | def l1(start, goal): 77 | sr, sc = start 78 | gr, gc = goal 79 | return abs(gr - sr) + abs(gc - sc) 80 | 81 | 82 | def l2(start, goal): 83 | sr, sc = start 84 | gr, gc = goal 85 | return 0.5*((gr - sr)**2 + (gc - sc)**2)**0.5 86 | 87 | # TODO: unify lInfty and lInf 88 | 89 | 90 | def lInfty(start, goal): 91 | sr, sc = start 92 | gr, gc = goal 93 | return max(abs(gr - sr), abs(gc - sc)) 94 | 95 | 96 | CUTOFF = 100 97 | 98 | 99 | def aStar(realm_map, start, goal): 100 | cutoff = CUTOFF 101 | tiles = realm_map.tiles 102 | if start == goal: 103 | return (0, 0) 104 | if (start, goal) in realm_map.pathfinding_cache: 105 | return realm_map.pathfinding_cache[(start, goal)] 106 | initial_goal = goal 107 | pq = [(0, start)] 108 | 109 | backtrace = {} 110 | cost = {start: 0} 111 | 112 | closestPos = start 113 | closestHeuristic = l1(start, goal) 114 | closestCost = closestHeuristic 115 | 116 | while pq: 117 | # Use approximate solution if budget exhausted 118 | cutoff -= 1 119 | if cutoff <= 0: 120 | if goal not in backtrace: 121 | goal = closestPos 122 | break 123 | 124 | priority, cur = heapq.heappop(pq) 125 | 126 | if cur == goal: 127 | break 128 | 129 | for nxt in adjacentPos(cur): 130 | if not in_bounds(*nxt, tiles.shape): 131 | continue 132 | 133 | newCost = cost[cur] + 1 134 | if nxt not in cost or newCost < cost[nxt]: 135 | cost[nxt] = newCost 136 | heuristic = lInfty(goal, nxt) 137 | priority = newCost + heuristic 138 | 139 | # Compute approximate solution 140 | if heuristic < closestHeuristic or ( 141 | heuristic == closestHeuristic and priority < closestCost): 142 | closestPos = nxt 143 | closestHeuristic = heuristic 144 | closestCost = priority 145 | 146 | heapq.heappush(pq, (priority, nxt)) 147 | backtrace[nxt] = cur 148 | 149 | while goal in backtrace and backtrace[goal] != start: 150 | gr, gc = goal 151 | goal = backtrace[goal] 152 | sr, sc = goal 153 | realm_map.pathfinding_cache[(goal, initial_goal)] = (gr - sr, gc - sc) 154 | 155 | sr, sc = start 156 | gr, gc = goal 157 | realm_map.pathfinding_cache[(start, initial_goal)] = (gr - sr, gc - sc) 158 | return (gr - sr, gc - sc) 159 | # End A* 160 | 161 | # Adjacency functions 162 | def adjacentDeltas(): 163 | return [(-1, 0), (1, 0), (0, 1), (0, -1)] 164 | 165 | 166 | def l1Deltas(s): 167 | rets = [] 168 | for r in range(-s, s + 1): 169 | for c in range(-s, s + 1): 170 | rets.append((r, c)) 171 | return rets 172 | 173 | 174 | def posSum(pos1, pos2): 175 | return pos1[0] + pos2[0], pos1[1] + pos2[1] 176 | 177 | 178 | def adjacentEmptyPos(env, pos): 179 | return [p for p in adjacentPos(pos) 180 | if in_bounds(*p, env.size)] 181 | 182 | 183 | def adjacentTiles(env, pos): 184 | return [env.tiles[p] for p in adjacentPos(pos) 185 | if in_bounds(*p, env.size)] 186 | 187 | 188 | def adjacentMats(tiles, pos): 189 | return [type(tiles[p].state) for p in adjacentPos(pos) 190 | if in_bounds(*p, tiles.shape)] 191 | 192 | 193 | def adjacencyDelMatPairs(env, pos): 194 | return zip(adjacentDeltas(), adjacentMats(env.tiles, pos)) 195 | ### End### 196 | -------------------------------------------------------------------------------- /nmmo/core/log_helper.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Dict 4 | 5 | from nmmo.core.agent import Agent 6 | from nmmo.entity.player import Player 7 | from nmmo.lib.log import Logger, MilestoneLogger 8 | 9 | 10 | class LogHelper: 11 | @staticmethod 12 | def create(realm) -> LogHelper: 13 | if realm.config.LOG_ENV: 14 | return SimpleLogHelper(realm) 15 | return DummyLogHelper() 16 | 17 | class DummyLogHelper(LogHelper): 18 | def reset(self) -> None: 19 | pass 20 | 21 | def update(self, dead_players: Dict[int, Player]) -> None: 22 | pass 23 | 24 | def log_milestone(self, milestone: str, value: float) -> None: 25 | pass 26 | 27 | def log_event(self, event: str, value: float) -> None: 28 | pass 29 | 30 | class SimpleLogHelper(LogHelper): 31 | def __init__(self, realm) -> None: 32 | self.realm = realm 33 | self.config = realm.config 34 | 35 | self.reset() 36 | 37 | def reset(self): 38 | self._env_logger = Logger() 39 | self._player_logger = Logger() 40 | self._event_logger = DummyLogHelper() 41 | self._milestone_logger = DummyLogHelper() 42 | 43 | if self.config.LOG_EVENTS: 44 | self._event_logger = Logger() 45 | 46 | if self.config.LOG_MILESTONES: 47 | self._milestone_logger = MilestoneLogger(self.config.LOG_FILE) 48 | 49 | self._player_stats_funcs = {} 50 | self._register_player_stats() 51 | 52 | def log_milestone(self, milestone: str, value: float) -> None: 53 | if self.config.LOG_MILESTONES: 54 | self._milestone_logger.log(milestone, value) 55 | 56 | def log_event(self, event: str, value: float) -> None: 57 | if self.config.LOG_EVENTS: 58 | self._event_logger.log(event, value) 59 | 60 | @property 61 | def packet(self): 62 | packet = {'Env': self._env_logger.stats, 63 | 'Player': self._player_logger.stats} 64 | 65 | if self.config.LOG_EVENTS: 66 | packet['Event'] = self._event_logger.stats 67 | else: 68 | packet['Event'] = 'Unavailable: config.LOG_EVENTS = False' 69 | 70 | if self.config.LOG_MILESTONES: 71 | packet['Milestone'] = self._event_logger.stats 72 | else: 73 | packet['Milestone'] = 'Unavailable: config.LOG_MILESTONES = False' 74 | 75 | return packet 76 | 77 | def _register_player_stat(self, name: str, func: callable): 78 | assert name not in self._player_stats_funcs 79 | self._player_stats_funcs[name] = func 80 | 81 | def _register_player_stats(self): 82 | self._register_player_stat('Basic/TimeAlive', lambda player: player.history.time_alive.val) 83 | # Skills 84 | if self.config.PROGRESSION_SYSTEM_ENABLED: 85 | if self.config.COMBAT_SYSTEM_ENABLED: 86 | self._register_player_stat('Skill/Mage', lambda player: player.skills.mage.level.val) 87 | self._register_player_stat('Skill/Range', lambda player: player.skills.range.level.val) 88 | self._register_player_stat('Skill/Melee', lambda player: player.skills.melee.level.val) 89 | if self.config.PROFESSION_SYSTEM_ENABLED: 90 | self._register_player_stat('Skill/Fishing', lambda player: player.skills.fishing.level.val) 91 | self._register_player_stat('Skill/Herbalism', 92 | lambda player: player.skills.herbalism.level.val) 93 | self._register_player_stat('Skill/Prospecting', 94 | lambda player: player.skills.prospecting.level.val) 95 | self._register_player_stat('Skill/Carving', 96 | lambda player: player.skills.carving.level.val) 97 | self._register_player_stat('Skill/Alchemy', 98 | lambda player: player.skills.alchemy.level.val) 99 | if self.config.EQUIPMENT_SYSTEM_ENABLED: 100 | self._register_player_stat('Item/Held-Level', 101 | lambda player: player.inventory.equipment.held.item.level.val \ 102 | if player.inventory.equipment.held.item else 0) 103 | self._register_player_stat('Item/Equipment-Total', 104 | lambda player: player.equipment.total(lambda e: e.level)) 105 | 106 | if self.config.EXCHANGE_SYSTEM_ENABLED: 107 | self._register_player_stat('Exchange/Player-Sells', lambda player: player.sells) 108 | self._register_player_stat('Exchange/Player-Buys', lambda player: player.buys) 109 | self._register_player_stat('Exchange/Player-Wealth', lambda player: player.gold.val) 110 | 111 | # Item usage 112 | if self.config.PROFESSION_SYSTEM_ENABLED: 113 | self._register_player_stat('Item/Ration-Consumed', lambda player: player.ration_consumed) 114 | self._register_player_stat('Item/Poultice-Consumed', lambda player: player.poultice_consumed) 115 | self._register_player_stat('Item/Ration-Level', lambda player: player.ration_level_consumed) 116 | self._register_player_stat('Item/Poultice-Level', 117 | lambda player: player.poultice_level_consumed) 118 | 119 | def update(self, dead_players: Dict[int, Player]) -> None: 120 | for player in dead_players.values(): 121 | for key, val in self._player_stats(player).items(): 122 | self._player_logger.log(key, val) 123 | 124 | # TODO: handle env logging 125 | 126 | def _player_stats(self, player: Agent) -> Dict[str, float]: 127 | stats = {} 128 | policy = player.policy 129 | 130 | for key, stat_func in self._player_stats_funcs.items(): 131 | stats[f'{key}_{policy}'] = stat_func(player) 132 | 133 | stats['Time_Alive'] = player.history.time_alive.val 134 | 135 | return stats 136 | -------------------------------------------------------------------------------- /nmmo/lib/material.py: -------------------------------------------------------------------------------- 1 | 2 | from nmmo.systems import item, droptable 3 | 4 | class Material: 5 | capacity = 0 6 | tool = None 7 | table = None 8 | index = None 9 | respawn = 0 10 | 11 | def __init__(self, config): 12 | pass 13 | 14 | def __eq__(self, mtl): 15 | return self.index == mtl.index 16 | 17 | def __equals__(self, mtl): 18 | return self == mtl 19 | 20 | def harvest(self): 21 | return self.__class__.table 22 | 23 | class Void(Material): 24 | tex = 'void' 25 | index = 0 26 | 27 | class Water(Material): 28 | tex = 'water' 29 | index = 1 30 | 31 | table = droptable.Empty() 32 | 33 | def __init__(self, config): 34 | self.deplete = __class__ 35 | self.respawn = 1.0 36 | 37 | class Grass(Material): 38 | tex = 'grass' 39 | index = 2 40 | 41 | class Scrub(Material): 42 | tex = 'scrub' 43 | index = 3 44 | 45 | class Foilage(Material): 46 | tex = 'foilage' 47 | index = 4 48 | 49 | deplete = Scrub 50 | table = droptable.Empty() 51 | 52 | def __init__(self, config): 53 | if config.RESOURCE_SYSTEM_ENABLED: 54 | self.capacity = config.RESOURCE_FOILAGE_CAPACITY 55 | self.respawn = config.RESOURCE_FOILAGE_RESPAWN 56 | 57 | class Stone(Material): 58 | tex = 'stone' 59 | index = 5 60 | 61 | class Slag(Material): 62 | tex = 'slag' 63 | index = 6 64 | 65 | class Ore(Material): 66 | tex = 'ore' 67 | index = 7 68 | 69 | deplete = Slag 70 | tool = item.Pickaxe 71 | 72 | def __init__(self, config): 73 | cls = self.__class__ 74 | if cls.table is None: 75 | cls.table = droptable.Standard() 76 | cls.table.add(item.Whetstone) 77 | 78 | if config.EQUIPMENT_SYSTEM_ENABLED: 79 | cls.table.add(item.Wand, prob=config.WEAPON_DROP_PROB) 80 | 81 | if config.PROFESSION_SYSTEM_ENABLED: 82 | self.capacity = config.PROFESSION_ORE_CAPACITY 83 | self.respawn = config.PROFESSION_ORE_RESPAWN 84 | 85 | tool = item.Pickaxe 86 | deplete = Slag 87 | 88 | class Stump(Material): 89 | tex = 'stump' 90 | index = 8 91 | 92 | class Tree(Material): 93 | tex = 'tree' 94 | index = 9 95 | 96 | deplete = Stump 97 | tool = item.Axe 98 | 99 | def __init__(self, config): 100 | cls = self.__class__ 101 | if cls.table is None: 102 | cls.table = droptable.Standard() 103 | cls.table.add(item.Arrow) 104 | if config.EQUIPMENT_SYSTEM_ENABLED: 105 | cls.table.add(item.Spear, prob=config.WEAPON_DROP_PROB) 106 | 107 | if config.PROFESSION_SYSTEM_ENABLED: 108 | self.capacity = config.PROFESSION_TREE_CAPACITY 109 | self.respawn = config.PROFESSION_TREE_RESPAWN 110 | 111 | class Fragment(Material): 112 | tex = 'fragment' 113 | index = 10 114 | 115 | class Crystal(Material): 116 | tex = 'crystal' 117 | index = 11 118 | 119 | deplete = Fragment 120 | tool = item.Chisel 121 | 122 | def __init__(self, config): 123 | cls = self.__class__ 124 | if cls.table is None: 125 | cls.table = droptable.Standard() 126 | cls.table.add(item.Runes) 127 | if config.EQUIPMENT_SYSTEM_ENABLED: 128 | cls.table.add(item.Bow, prob=config.WEAPON_DROP_PROB) 129 | 130 | if config.PROFESSION_SYSTEM_ENABLED: 131 | self.capacity = config.PROFESSION_CRYSTAL_CAPACITY 132 | self.respawn = config.PROFESSION_CRYSTAL_RESPAWN 133 | 134 | class Weeds(Material): 135 | tex = 'weeds' 136 | index = 12 137 | 138 | class Herb(Material): 139 | tex = 'herb' 140 | index = 13 141 | 142 | deplete = Weeds 143 | tool = item.Gloves 144 | 145 | table = droptable.Standard() 146 | table.add(item.Potion) 147 | 148 | def __init__(self, config): 149 | if config.PROFESSION_SYSTEM_ENABLED: 150 | self.capacity = config.PROFESSION_HERB_CAPACITY 151 | self.respawn = config.PROFESSION_HERB_RESPAWN 152 | 153 | class Ocean(Material): 154 | tex = 'ocean' 155 | index = 14 156 | 157 | class Fish(Material): 158 | tex = 'fish' 159 | index = 15 160 | 161 | deplete = Ocean 162 | tool = item.Rod 163 | 164 | table = droptable.Standard() 165 | table.add(item.Ration) 166 | 167 | def __init__(self, config): 168 | if config.PROFESSION_SYSTEM_ENABLED: 169 | self.capacity = config.PROFESSION_FISH_CAPACITY 170 | self.respawn = config.PROFESSION_FISH_RESPAWN 171 | 172 | # TODO: Fix lint errors 173 | # pylint: disable=all 174 | class Meta(type): 175 | def __init__(self, name, bases, dict): 176 | self.indices = {mtl.index for mtl in self.materials} 177 | 178 | def __iter__(self): 179 | yield from self.materials 180 | 181 | def __contains__(self, mtl): 182 | if isinstance(mtl, Material): 183 | mtl = type(mtl) 184 | if isinstance(mtl, type): 185 | return mtl in self.materials 186 | return mtl in self.indices 187 | 188 | class All(metaclass=Meta): 189 | '''List of all materials''' 190 | materials = { 191 | Void, Water, Grass, Scrub, Foilage, 192 | Stone, Slag, Ore, Stump, Tree, 193 | Fragment, Crystal, Weeds, Herb, Ocean, Fish} 194 | 195 | class Impassible(metaclass=Meta): 196 | '''Materials that agents cannot walk through''' 197 | materials = {Void, Water, Stone, Ocean, Fish} 198 | 199 | class Habitable(metaclass=Meta): 200 | '''Materials that agents cannot walk on''' 201 | materials = {Grass, Scrub, Foilage, Ore, Slag, Tree, Stump, Crystal, Fragment, Herb, Weeds} 202 | 203 | class Harvestable(metaclass=Meta): 204 | '''Materials that agents can harvest''' 205 | materials = {Water, Foilage, Ore, Tree, Crystal, Herb, Fish} 206 | -------------------------------------------------------------------------------- /nmmo/systems/combat.py: -------------------------------------------------------------------------------- 1 | #Various utilities for managing combat, including hit/damage 2 | 3 | import numpy as np 4 | 5 | from nmmo.systems import skill as Skill 6 | from nmmo.lib.log import EventCode 7 | 8 | def level(skills): 9 | return max(e.level.val for e in skills.skills) 10 | 11 | def damage_multiplier(config, skill, targ): 12 | skills = [targ.skills.melee, targ.skills.range, targ.skills.mage] 13 | exp = [s.exp for s in skills] 14 | 15 | if max(exp) == min(exp): 16 | return 1.0 17 | 18 | idx = np.argmax([exp]) 19 | targ = skills[idx] 20 | 21 | if isinstance(skill, targ.weakness): 22 | return config.COMBAT_WEAKNESS_MULTIPLIER 23 | 24 | return 1.0 25 | 26 | # pylint: disable=unnecessary-lambda-assignment 27 | def attack(realm, player, target, skill_fn): 28 | config = player.config 29 | skill = skill_fn(player) 30 | skill_type = type(skill) 31 | skill_name = skill_type.__name__ 32 | 33 | # Per-style offense/defense 34 | level_damage = 0 35 | if skill_type == Skill.Melee: 36 | base_damage = config.COMBAT_MELEE_DAMAGE 37 | 38 | if config.PROGRESSION_SYSTEM_ENABLED: 39 | base_damage = config.PROGRESSION_MELEE_BASE_DAMAGE 40 | level_damage = config.PROGRESSION_MELEE_LEVEL_DAMAGE 41 | 42 | offense_fn = lambda e: e.melee_attack 43 | defense_fn = lambda e: e.melee_defense 44 | 45 | elif skill_type == Skill.Range: 46 | base_damage = config.COMBAT_RANGE_DAMAGE 47 | 48 | if config.PROGRESSION_SYSTEM_ENABLED: 49 | base_damage = config.PROGRESSION_RANGE_BASE_DAMAGE 50 | level_damage = config.PROGRESSION_RANGE_LEVEL_DAMAGE 51 | 52 | offense_fn = lambda e: e.range_attack 53 | defense_fn = lambda e: e.range_defense 54 | 55 | elif skill_type == Skill.Mage: 56 | base_damage = config.COMBAT_MAGE_DAMAGE 57 | 58 | if config.PROGRESSION_SYSTEM_ENABLED: 59 | base_damage = config.PROGRESSION_MAGE_BASE_DAMAGE 60 | level_damage = config.PROGRESSION_MAGE_LEVEL_DAMAGE 61 | 62 | offense_fn = lambda e: e.mage_attack 63 | defense_fn = lambda e: e.mage_defense 64 | 65 | elif __debug__: 66 | assert False, 'Attack skill must be Melee, Range, or Mage' 67 | 68 | # Compute modifiers 69 | multiplier = damage_multiplier(config, skill, target) 70 | skill_offense = base_damage + level_damage * skill.level.val 71 | 72 | if config.PROGRESSION_SYSTEM_ENABLED: 73 | skill_defense = config.PROGRESSION_BASE_DEFENSE + \ 74 | config.PROGRESSION_LEVEL_DEFENSE*level(target.skills) 75 | else: 76 | skill_defense = 0 77 | 78 | if config.EQUIPMENT_SYSTEM_ENABLED: 79 | equipment_offense = player.equipment.total(offense_fn) 80 | equipment_defense = target.equipment.total(defense_fn) 81 | 82 | # after tallying ammo damage, consume ammo (i.e., fire) when the skill type matches 83 | ammunition = player.equipment.ammunition.item 84 | if ammunition is not None and getattr(ammunition, skill_name.lower() + '_attack').val > 0: 85 | ammunition.fire(player) 86 | 87 | else: 88 | equipment_offense = 0 89 | equipment_defense = 0 90 | 91 | # Total damage calculation 92 | offense = skill_offense + equipment_offense 93 | defense = skill_defense + equipment_defense 94 | damage = config.COMBAT_DAMAGE_FORMULA(offense, defense, multiplier) 95 | #damage = multiplier * (offense - defense) 96 | damage = max(int(damage), 0) 97 | 98 | if player.is_player: 99 | equipment_level_offense = 0 100 | equipment_level_defense = 0 101 | if config.EQUIPMENT_SYSTEM_ENABLED: 102 | equipment_level_offense = player.equipment.total(lambda e: e.level) 103 | equipment_level_defense = target.equipment.total(lambda e: e.level) 104 | 105 | realm.event_log.record(EventCode.SCORE_HIT, player, 106 | combat_style=skill_type, damage=damage) 107 | 108 | realm.log_milestone(f'Damage_{skill_name}', damage, 109 | f'COMBAT: Inflicted {damage} {skill_name} damage ' + 110 | f'(attack equip lvl {equipment_level_offense} vs ' + 111 | f'defense equip lvl {equipment_level_defense})', 112 | tags={"player_id": player.ent_id}) 113 | 114 | player.apply_damage(damage, skill.__class__.__name__.lower()) 115 | target.receive_damage(player, damage) 116 | 117 | return damage 118 | 119 | 120 | def danger(config, pos): 121 | border = config.MAP_BORDER 122 | center = config.MAP_CENTER 123 | r, c = pos 124 | 125 | #Distance from border 126 | r_dist = min(r - border, center + border - r - 1) 127 | c_dist = min(c - border, center + border - c - 1) 128 | dist = min(r_dist, c_dist) 129 | norm = 2 * dist / center 130 | 131 | return norm 132 | 133 | def spawn(config, dnger, np_random): 134 | border = config.MAP_BORDER 135 | center = config.MAP_CENTER 136 | mid = center // 2 137 | 138 | dist = dnger * center / 2 139 | max_offset = mid - dist 140 | offset = mid + border + np_random.integers(-max_offset, max_offset) 141 | 142 | rng = np_random.random() 143 | if rng < 0.25: 144 | r = border + dist 145 | c = offset 146 | elif rng < 0.5: 147 | r = border + center - dist - 1 148 | c = offset 149 | elif rng < 0.75: 150 | c = border + dist 151 | r = offset 152 | else: 153 | c = border + center - dist - 1 154 | r = offset 155 | 156 | if __debug__: 157 | assert dnger == danger(config, (r,c)), 'Agent spawned at incorrect radius' 158 | 159 | r = int(r) 160 | c = int(c) 161 | 162 | return r, c 163 | -------------------------------------------------------------------------------- /tests/test_eventlog.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import nmmo 4 | from nmmo.datastore.numpy_datastore import NumpyDatastore 5 | from nmmo.lib.event_log import EventState, EventLogger 6 | from nmmo.lib.log import EventCode 7 | from nmmo.entity.entity import Entity 8 | from nmmo.systems.item import ItemState 9 | from nmmo.systems.item import Whetstone, Ration, Hat 10 | from nmmo.systems import skill as Skill 11 | 12 | 13 | class MockRealm: 14 | def __init__(self): 15 | self.config = nmmo.config.Default() 16 | self.datastore = NumpyDatastore() 17 | self.items = {} 18 | self.datastore.register_object_type("Event", EventState.State.num_attributes) 19 | self.datastore.register_object_type("Item", ItemState.State.num_attributes) 20 | self.tick = 0 21 | 22 | 23 | class MockEntity(Entity): 24 | # pylint: disable=super-init-not-called 25 | def __init__(self, ent_id, **kwargs): 26 | self.id = ent_id 27 | self.level = kwargs.pop('attack_level', 0) 28 | 29 | @property 30 | def ent_id(self): 31 | return self.id 32 | 33 | @property 34 | def attack_level(self): 35 | return self.level 36 | 37 | 38 | class TestEventLog(unittest.TestCase): 39 | 40 | def test_event_logging(self): 41 | mock_realm = MockRealm() 42 | event_log = EventLogger(mock_realm) 43 | 44 | mock_realm.tick = 0 # tick increase to 1 after all actions are processed 45 | event_log.record(EventCode.EAT_FOOD, MockEntity(1)) 46 | event_log.record(EventCode.DRINK_WATER, MockEntity(2)) 47 | event_log.record(EventCode.SCORE_HIT, MockEntity(2), 48 | combat_style=Skill.Melee, damage=50) 49 | event_log.record(EventCode.PLAYER_KILL, MockEntity(3), 50 | target=MockEntity(5, attack_level=5)) 51 | event_log.update() 52 | 53 | mock_realm.tick = 1 54 | event_log.record(EventCode.CONSUME_ITEM, MockEntity(4), 55 | item=Ration(mock_realm, 8)) 56 | event_log.record(EventCode.GIVE_ITEM, MockEntity(4)) 57 | event_log.record(EventCode.DESTROY_ITEM, MockEntity(5)) 58 | event_log.record(EventCode.HARVEST_ITEM, MockEntity(6), 59 | item=Whetstone(mock_realm, 3)) 60 | event_log.update() 61 | 62 | mock_realm.tick = 2 63 | event_log.record(EventCode.GIVE_GOLD, MockEntity(7)) 64 | event_log.record(EventCode.LIST_ITEM, MockEntity(8), 65 | item=Ration(mock_realm, 5), price=11) 66 | event_log.record(EventCode.EARN_GOLD, MockEntity(9), amount=15) 67 | event_log.record(EventCode.BUY_ITEM, MockEntity(10), 68 | item=Whetstone(mock_realm, 7), price=21) 69 | #event_log.record(EventCode.SPEND_GOLD, env.realm.players[11], amount=25) 70 | event_log.update() 71 | 72 | mock_realm.tick = 3 73 | event_log.record(EventCode.LEVEL_UP, MockEntity(12), 74 | skill=Skill.Fishing, level=3) 75 | event_log.update() 76 | 77 | mock_realm.tick = 4 78 | event_log.record(EventCode.GO_FARTHEST, MockEntity(12), distance=6) 79 | event_log.record(EventCode.EQUIP_ITEM, MockEntity(12), 80 | item=Hat(mock_realm, 4)) 81 | event_log.update() 82 | 83 | log_data = [list(row) for row in event_log.get_data()] 84 | self.assertListEqual(log_data, [ 85 | [1, 1, 1, EventCode.EAT_FOOD, 0, 0, 0, 0, 0], 86 | [1, 2, 1, EventCode.DRINK_WATER, 0, 0, 0, 0, 0], 87 | [1, 2, 1, EventCode.SCORE_HIT, 1, 0, 50, 0, 0], 88 | [1, 3, 1, EventCode.PLAYER_KILL, 0, 5, 0, 0, 5], 89 | [1, 4, 2, EventCode.CONSUME_ITEM, 16, 8, 1, 0, 0], 90 | [1, 4, 2, EventCode.GIVE_ITEM, 0, 0, 0, 0, 0], 91 | [1, 5, 2, EventCode.DESTROY_ITEM, 0, 0, 0, 0, 0], 92 | [1, 6, 2, EventCode.HARVEST_ITEM, 13, 3, 1, 0, 0], 93 | [1, 7, 3, EventCode.GIVE_GOLD, 0, 0, 0, 0, 0], 94 | [1, 8, 3, EventCode.LIST_ITEM, 16, 5, 1, 11, 0], 95 | [1, 9, 3, EventCode.EARN_GOLD, 0, 0, 0, 15, 0], 96 | [1, 10, 3, EventCode.BUY_ITEM, 13, 7, 1, 21, 0], 97 | [1, 12, 4, EventCode.LEVEL_UP, 4, 3, 0, 0, 0], 98 | [1, 12, 5, EventCode.GO_FARTHEST, 0, 0, 6, 0, 0], 99 | [1, 12, 5, EventCode.EQUIP_ITEM, 2, 4, 1, 0, 0]]) 100 | 101 | log_by_tick = [list(row) for row in event_log.get_data(tick = 4)] 102 | self.assertListEqual(log_by_tick, [ 103 | [1, 12, 4, EventCode.LEVEL_UP, 4, 3, 0, 0, 0]]) 104 | 105 | log_by_event = [list(row) for row in event_log.get_data(event_code = EventCode.CONSUME_ITEM)] 106 | self.assertListEqual(log_by_event, [ 107 | [1, 4, 2, EventCode.CONSUME_ITEM, 16, 8, 1, 0, 0]]) 108 | 109 | log_by_tick_agent = [list(row) for row in \ 110 | event_log.get_data(tick = 5, 111 | agents = [12], 112 | event_code = EventCode.EQUIP_ITEM)] 113 | self.assertListEqual(log_by_tick_agent, [ 114 | [1, 12, 5, EventCode.EQUIP_ITEM, 2, 4, 1, 0, 0]]) 115 | 116 | empty_log = event_log.get_data(tick = 10) 117 | self.assertTrue(empty_log.shape[0] == 0) 118 | 119 | if __name__ == '__main__': 120 | unittest.main() 121 | 122 | """ 123 | TEST_HORIZON = 50 124 | RANDOM_SEED = 338 125 | 126 | from tests.testhelpers import ScriptedAgentTestConfig, ScriptedAgentTestEnv 127 | 128 | config = ScriptedAgentTestConfig() 129 | env = ScriptedAgentTestEnv(config) 130 | 131 | env.reset(seed=RANDOM_SEED) 132 | 133 | from tqdm import tqdm 134 | for tick in tqdm(range(TEST_HORIZON)): 135 | env.step({}) 136 | 137 | # events to check 138 | log = env.realm.event_log.get_data() 139 | idx = (log[:,2] == tick+1) & (log[:,3] == EventCode.EQUIP_ITEM) 140 | if sum(idx): 141 | print(log[idx]) 142 | print() 143 | 144 | print('done') 145 | """ 146 | -------------------------------------------------------------------------------- /nmmo/entity/entity_manager.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Mapping 2 | from typing import Dict 3 | 4 | from nmmo.entity.entity import Entity 5 | from nmmo.entity.npc import NPC 6 | from nmmo.entity.player import Player 7 | from nmmo.lib import spawn 8 | from nmmo.systems import combat 9 | 10 | 11 | class EntityGroup(Mapping): 12 | def __init__(self, realm, np_random): 13 | self.datastore = realm.datastore 14 | self.realm = realm 15 | self.config = realm.config 16 | self._np_random = np_random 17 | 18 | self.entities: Dict[int, Entity] = {} 19 | self.dead_this_tick: Dict[int, Entity] = {} 20 | 21 | def __len__(self): 22 | return len(self.entities) 23 | 24 | def __contains__(self, e): 25 | return e in self.entities 26 | 27 | def __getitem__(self, key) -> Entity: 28 | return self.entities[key] 29 | 30 | def __iter__(self) -> Entity: 31 | yield from self.entities 32 | 33 | def items(self): 34 | return self.entities.items() 35 | 36 | @property 37 | def corporeal(self): 38 | return {**self.entities, **self.dead_this_tick} 39 | 40 | @property 41 | def packet(self): 42 | return {k: v.packet() for k, v in self.corporeal.items()} 43 | 44 | def reset(self, np_random): 45 | self._np_random = np_random # reset the RNG 46 | for ent in self.entities.values(): 47 | # destroy the items 48 | if self.config.ITEM_SYSTEM_ENABLED: 49 | for item in list(ent.inventory.items): 50 | item.destroy() 51 | ent.datastore_record.delete() 52 | 53 | self.entities = {} 54 | self.dead_this_tick = {} 55 | 56 | def spawn(self, entity): 57 | pos, ent_id = entity.pos, entity.id.val 58 | self.realm.map.tiles[pos].add_entity(entity) 59 | self.entities[ent_id] = entity 60 | 61 | def cull(self): 62 | self.dead_this_tick = {} 63 | for ent_id in list(self.entities): 64 | player = self.entities[ent_id] 65 | if not player.alive: 66 | r, c = player.pos 67 | ent_id = player.ent_id 68 | self.dead_this_tick[ent_id] = player 69 | 70 | self.realm.map.tiles[r, c].remove_entity(ent_id) 71 | 72 | # destroy the remaining items (of starved/dehydrated players) 73 | # of the agents who don't go through receive_damage() 74 | if self.config.ITEM_SYSTEM_ENABLED: 75 | for item in list(player.inventory.items): 76 | item.destroy() 77 | 78 | self.entities[ent_id].datastore_record.delete() 79 | del self.entities[ent_id] 80 | 81 | return self.dead_this_tick 82 | 83 | def update(self, actions): 84 | for entity in self.entities.values(): 85 | entity.update(self.realm, actions) 86 | 87 | 88 | class NPCManager(EntityGroup): 89 | def __init__(self, realm, np_random): 90 | super().__init__(realm, np_random) 91 | self.next_id = -1 92 | self.spawn_dangers = [] 93 | 94 | def reset(self, np_random): 95 | super().reset(np_random) 96 | self.next_id = -1 97 | self.spawn_dangers = [] 98 | 99 | def spawn(self): 100 | config = self.config 101 | 102 | if not config.NPC_SYSTEM_ENABLED: 103 | return 104 | 105 | for _ in range(config.NPC_SPAWN_ATTEMPTS): 106 | if len(self.entities) >= config.NPC_N: 107 | break 108 | 109 | if self.spawn_dangers: 110 | danger = self.spawn_dangers[-1] 111 | r, c = combat.spawn(config, danger, self._np_random) 112 | else: 113 | center = config.MAP_CENTER 114 | border = self.config.MAP_BORDER 115 | # pylint: disable=unbalanced-tuple-unpacking 116 | r, c = self._np_random.integers(border, center+border, 2).tolist() 117 | 118 | npc = NPC.spawn(self.realm, (r, c), self.next_id, self._np_random) 119 | if npc: 120 | super().spawn(npc) 121 | self.next_id -= 1 122 | 123 | if self.spawn_dangers: 124 | self.spawn_dangers.pop() 125 | 126 | def cull(self): 127 | for entity in super().cull().values(): 128 | self.spawn_dangers.append(entity.spawn_danger) 129 | 130 | # refill npcs to target config.NPC_N, within config.NPC_SPAWN_ATTEMPTS 131 | self.spawn() 132 | 133 | def actions(self, realm): 134 | actions = {} 135 | for idx, entity in self.entities.items(): 136 | actions[idx] = entity.decide(realm) 137 | return actions 138 | 139 | class PlayerManager(EntityGroup): 140 | def __init__(self, realm, np_random): 141 | super().__init__(realm, np_random) 142 | self.loader_class = self.realm.config.PLAYER_LOADER 143 | self._agent_loader: spawn.SequentialLoader = None 144 | self.spawned = None 145 | 146 | def reset(self, np_random): 147 | super().reset(np_random) 148 | self._agent_loader = self.loader_class(self.config, self._np_random) 149 | self.spawned = set() 150 | 151 | def spawn_individual(self, r, c, idx, resilient=False): 152 | agent = next(self._agent_loader) 153 | agent = agent(self.config, idx) 154 | player = Player(self.realm, (r, c), agent, resilient) 155 | super().spawn(player) 156 | self.spawned.add(idx) 157 | 158 | def spawn(self): 159 | # Check and assign the constant heal flag 160 | resilient_flag = [False] * self.config.PLAYER_N 161 | if self.config.RESOURCE_SYSTEM_ENABLED: 162 | num_resilient = round(self.config.RESOURCE_RESILIENT_POPULATION * self.config.PLAYER_N) 163 | for idx in range(num_resilient): 164 | resilient_flag[idx] = self.config.RESOURCE_DAMAGE_REDUCTION > 0 165 | self._np_random.shuffle(resilient_flag) 166 | 167 | # Spawn the players 168 | idx = 0 169 | while idx < self.config.PLAYER_N: 170 | idx += 1 171 | r, c = self._agent_loader.get_spawn_position(idx) 172 | 173 | if idx in self.entities: 174 | continue 175 | 176 | if idx in self.spawned: 177 | continue 178 | 179 | self.spawn_individual(r, c, idx, resilient_flag[idx-1]) 180 | -------------------------------------------------------------------------------- /tests/test_performance.py: -------------------------------------------------------------------------------- 1 | # import time 2 | import cProfile 3 | import io 4 | import pstats 5 | 6 | import nmmo 7 | from nmmo.core.config import (NPC, AllGameSystems, Combat, Communication, 8 | Equipment, Exchange, Item, Medium, Profession, 9 | Progression, Resource, Small, Terrain) 10 | from nmmo.task.task_api import nmmo_default_task, make_same_task 11 | from nmmo.task.base_predicates import CountEvent, FullyArmed 12 | from nmmo.systems.skill import Melee 13 | from tests.testhelpers import profile_env_step 14 | from scripted import baselines 15 | 16 | 17 | # Test utils 18 | def create_and_reset(conf): 19 | env = nmmo.Env(conf()) 20 | env.reset(map_id=1) 21 | 22 | def create_config(base, *systems): 23 | systems = (base, *systems) 24 | name = '_'.join(cls.__name__ for cls in systems) 25 | 26 | conf = type(name, systems, {})() 27 | 28 | conf.TERRAIN_TRAIN_MAPS = 1 29 | conf.TERRAIN_EVAL_MAPS = 1 30 | conf.IMMORTAL = True 31 | 32 | return conf 33 | 34 | def benchmark_config(benchmark, base, nent, *systems): 35 | conf = create_config(base, *systems) 36 | conf.PLAYER_N = nent 37 | conf.PLAYERS = [baselines.Random] 38 | 39 | env = nmmo.Env(conf) 40 | env.reset() 41 | 42 | benchmark(env.step, actions={}) 43 | 44 | # Small map tests -- fast with greater coverage for individual game systems 45 | def test_small_env_creation(benchmark): 46 | benchmark(lambda: nmmo.Env(Small())) 47 | 48 | def test_small_env_reset(benchmark): 49 | config = Small() 50 | config.PLAYERS = [baselines.Random] 51 | env = nmmo.Env(config) 52 | benchmark(lambda: env.reset(map_id=1)) 53 | 54 | def test_fps_base_small_1_pop(benchmark): 55 | benchmark_config(benchmark, Small, 1) 56 | 57 | def test_fps_minimal_small_1_pop(benchmark): 58 | benchmark_config(benchmark, Small, 1, Terrain, Resource, Combat, Progression) 59 | 60 | def test_fps_npc_small_1_pop(benchmark): 61 | benchmark_config(benchmark, Small, 1, Terrain, Resource, Combat, Progression, NPC) 62 | 63 | def test_fps_test_small_1_pop(benchmark): 64 | benchmark_config(benchmark, Small, 1, Terrain, Resource, Combat, Progression, Item, Exchange) 65 | 66 | def test_fps_no_npc_small_1_pop(benchmark): 67 | benchmark_config(benchmark, Small, 1, Terrain, Resource, 68 | Combat, Progression, Item, Equipment, Profession, Exchange, Communication) 69 | 70 | def test_fps_all_small_1_pop(benchmark): 71 | benchmark_config(benchmark, Small, 1, AllGameSystems) 72 | 73 | def test_fps_base_med_1_pop(benchmark): 74 | benchmark_config(benchmark, Medium, 1) 75 | 76 | def test_fps_minimal_med_1_pop(benchmark): 77 | benchmark_config(benchmark, Medium, 1, Terrain, Resource, Combat) 78 | 79 | def test_fps_npc_med_1_pop(benchmark): 80 | benchmark_config(benchmark, Medium, 1, Terrain, Resource, Combat, NPC) 81 | 82 | def test_fps_test_med_1_pop(benchmark): 83 | benchmark_config(benchmark, Medium, 1, Terrain, Resource, Combat, Progression, Item, Exchange) 84 | 85 | def test_fps_no_npc_med_1_pop(benchmark): 86 | benchmark_config(benchmark, Medium, 1, Terrain, Resource, 87 | Combat, Progression, Item, Equipment, Profession, Exchange, Communication) 88 | 89 | def test_fps_all_med_1_pop(benchmark): 90 | benchmark_config(benchmark, Medium, 1, AllGameSystems) 91 | 92 | def test_fps_base_med_100_pop(benchmark): 93 | benchmark_config(benchmark, Medium, 100) 94 | 95 | def test_fps_minimal_med_100_pop(benchmark): 96 | benchmark_config(benchmark, Medium, 100, Terrain, Resource, Combat) 97 | 98 | def test_fps_npc_med_100_pop(benchmark): 99 | benchmark_config(benchmark, Medium, 100, Terrain, Resource, Combat, NPC) 100 | 101 | def test_fps_test_med_100_pop(benchmark): 102 | benchmark_config(benchmark, Medium, 100, Terrain, Resource, Combat, Progression, Item, Exchange) 103 | 104 | def test_fps_no_npc_med_100_pop(benchmark): 105 | benchmark_config(benchmark, Medium, 100, Terrain, Resource, Combat, 106 | Progression, Item, Equipment, Profession, Exchange, Communication) 107 | 108 | def test_fps_all_med_100_pop(benchmark): 109 | benchmark_config(benchmark, Medium, 100, AllGameSystems) 110 | 111 | def set_seed_test(): 112 | random_seed = 5000 113 | conf = create_config(Medium, Terrain, Resource, Combat, NPC) 114 | conf.PLAYER_N = 10 115 | conf.PLAYERS = [baselines.Random] 116 | 117 | env = nmmo.Env(conf) 118 | 119 | env.reset(seed=random_seed) 120 | for _ in range(1024): 121 | env.step({}) 122 | 123 | def set_seed_test_complex(): 124 | tasks = nmmo_default_task(range(128)) 125 | tasks += make_same_task(CountEvent, range(128), 126 | pred_kwargs={'event': 'EAT_FOOD', 'N': 10}) 127 | tasks += make_same_task(FullyArmed, range(128), 128 | pred_kwargs={'combat_style': Melee, 'level': 3, 'num_agent': 1}) 129 | profile_env_step(tasks=tasks) 130 | 131 | if __name__ == '__main__': 132 | with open('profile.run','a', encoding="utf-8") as f: 133 | pr = cProfile.Profile() 134 | pr.enable() 135 | set_seed_test_complex() 136 | pr.disable() 137 | s = io.StringIO() 138 | ps = pstats.Stats(pr,stream=s).sort_stats('tottime') 139 | ps.print_stats() 140 | f.write(s.getvalue()) 141 | 142 | ''' 143 | def benchmark_env(benchmark, env, nent): 144 | env.config.PLAYER_N = nent 145 | env.config.PLAYERS = [nmmo.agent.Random] 146 | env.reset() 147 | 148 | benchmark(env.step, actions={}) 149 | # Reuse large maps since we aren't benchmarking the reset function 150 | def test_large_env_creation(benchmark): 151 | benchmark(lambda: nmmo.Env(Large())) 152 | 153 | def test_large_env_reset(benchmark): 154 | env = nmmo.Env(Large()) 155 | benchmark(lambda: env.reset(idx=1)) 156 | 157 | LargeMapsRCP = nmmo.Env(create_config(Large, Resource, Terrain, Combat, Progression)) 158 | LargeMapsAll = nmmo.Env(create_config(Large, AllGameSystems)) 159 | 160 | def test_fps_large_rcp_1_pop(benchmark): 161 | benchmark_env(benchmark, LargeMapsRCP, 1) 162 | 163 | def test_fps_large_rcp_100_pop(benchmark): 164 | benchmark_env(benchmark, LargeMapsRCP, 100) 165 | 166 | def test_fps_large_rcp_1000_pop(benchmark): 167 | benchmark_env(benchmark, LargeMapsRCP, 1000) 168 | 169 | def test_fps_large_all_1_pop(benchmark): 170 | benchmark_env(benchmark, LargeMapsAll, 1) 171 | 172 | def test_fps_large_all_100_pop(benchmark): 173 | benchmark_env(benchmark, LargeMapsAll, 100) 174 | 175 | def test_fps_large_all_1000_pop(benchmark): 176 | benchmark_env(benchmark, LargeMapsAll, 1000) 177 | ''' 178 | -------------------------------------------------------------------------------- /nmmo/entity/npc.py: -------------------------------------------------------------------------------- 1 | from nmmo.entity import entity 2 | from nmmo.core import action as Action 3 | from nmmo.systems import combat, droptable 4 | from nmmo.systems.ai import policy 5 | from nmmo.systems import item as Item 6 | from nmmo.systems import skill 7 | from nmmo.systems.inventory import EquipmentSlot 8 | from nmmo.lib.log import EventCode 9 | 10 | class Equipment: 11 | def __init__(self, total, 12 | melee_attack, range_attack, mage_attack, 13 | melee_defense, range_defense, mage_defense): 14 | 15 | self.level = total 16 | self.ammunition = EquipmentSlot() 17 | 18 | self.melee_attack = melee_attack 19 | self.range_attack = range_attack 20 | self.mage_attack = mage_attack 21 | self.melee_defense = melee_defense 22 | self.range_defense = range_defense 23 | self.mage_defense = mage_defense 24 | 25 | def total(self, getter): 26 | return getter(self) 27 | 28 | # pylint: disable=R0801 29 | # Similar lines here and in inventory.py 30 | @property 31 | def packet(self): 32 | packet = {} 33 | 34 | packet['item_level'] = self.total 35 | packet['melee_attack'] = self.melee_attack 36 | packet['range_attack'] = self.range_attack 37 | packet['mage_attack'] = self.mage_attack 38 | packet['melee_defense'] = self.melee_defense 39 | packet['range_defense'] = self.range_defense 40 | packet['mage_defense'] = self.mage_defense 41 | 42 | return packet 43 | 44 | 45 | # pylint: disable=no-member 46 | class NPC(entity.Entity): 47 | def __init__(self, realm, pos, iden, name, npc_type): 48 | super().__init__(realm, pos, iden, name) 49 | self.skills = skill.Combat(realm, self) 50 | self.realm = realm 51 | self.last_action = None 52 | self.droptable = None 53 | self.spawn_danger = None 54 | self.equipment = None 55 | self.npc_type.update(npc_type) 56 | 57 | def update(self, realm, actions): 58 | super().update(realm, actions) 59 | 60 | if not self.alive: 61 | return 62 | 63 | self.resources.health.increment(1) 64 | self.last_action = actions 65 | 66 | # Returns True if the entity is alive 67 | def receive_damage(self, source, dmg): 68 | if super().receive_damage(source, dmg): 69 | return True 70 | 71 | # run the next lines if the npc is killed 72 | # source receive gold & items in the droptable 73 | # pylint: disable=no-member 74 | if self.gold.val > 0: 75 | source.gold.increment(self.gold.val) 76 | self.realm.event_log.record(EventCode.EARN_GOLD, source, amount=self.gold.val) 77 | self.gold.update(0) 78 | 79 | for item in self.droptable.roll(self.realm, self.attack_level): 80 | if source.is_player and source.inventory.space: 81 | # inventory.receive() returns True if the item is received 82 | # if source doesn't have space, inventory.receive() destroys the item 83 | if source.inventory.receive(item): 84 | self.realm.event_log.record(EventCode.LOOT_ITEM, source, item=item) 85 | else: 86 | item.destroy() 87 | 88 | return False 89 | 90 | # NOTE: passing np_random here is a hack 91 | # Ideally, it should be passed to __init__ and also used in action generation 92 | @staticmethod 93 | def spawn(realm, pos, iden, np_random): 94 | config = realm.config 95 | 96 | # check the position 97 | if realm.map.tiles[pos].impassible: 98 | return None 99 | 100 | # Select AI Policy 101 | danger = combat.danger(config, pos) 102 | if danger >= config.NPC_SPAWN_AGGRESSIVE: 103 | ent = Aggressive(realm, pos, iden) 104 | elif danger >= config.NPC_SPAWN_NEUTRAL: 105 | ent = PassiveAggressive(realm, pos, iden) 106 | elif danger >= config.NPC_SPAWN_PASSIVE: 107 | ent = Passive(realm, pos, iden) 108 | else: 109 | return None 110 | 111 | ent.spawn_danger = danger 112 | 113 | # Select combat focus 114 | style = np_random.integers(0,3) 115 | if style == 0: 116 | style = Action.Melee 117 | elif style == 1: 118 | style = Action.Range 119 | else: 120 | style = Action.Mage 121 | ent.skills.style = style 122 | 123 | # Compute level 124 | level = 0 125 | if config.PROGRESSION_SYSTEM_ENABLED: 126 | level_min = config.NPC_LEVEL_MIN 127 | level_max = config.NPC_LEVEL_MAX 128 | level = int(danger * (level_max - level_min) + level_min) 129 | 130 | # Set skill levels 131 | if style == Action.Melee: 132 | ent.skills.melee.set_experience_by_level(level) 133 | elif style == Action.Range: 134 | ent.skills.range.set_experience_by_level(level) 135 | elif style == Action.Mage: 136 | ent.skills.mage.set_experience_by_level(level) 137 | 138 | # Gold 139 | if config.EXCHANGE_SYSTEM_ENABLED: 140 | # pylint: disable=no-member 141 | ent.gold.update(level) 142 | 143 | ent.droptable = droptable.Standard() 144 | 145 | # Equipment to instantiate 146 | if config.EQUIPMENT_SYSTEM_ENABLED: 147 | lvl = level - np_random.random() 148 | ilvl = int(5 * lvl) 149 | 150 | offense = int(config.NPC_BASE_DAMAGE + lvl*config.NPC_LEVEL_DAMAGE) 151 | defense = int(config.NPC_BASE_DEFENSE + lvl*config.NPC_LEVEL_DEFENSE) 152 | 153 | ent.equipment = Equipment(ilvl, offense, offense, offense, defense, defense, defense) 154 | 155 | armor = [Item.Hat, Item.Top, Item.Bottom] 156 | ent.droptable.add(np_random.choice(armor)) 157 | 158 | if config.PROFESSION_SYSTEM_ENABLED: 159 | tools = [Item.Rod, Item.Gloves, Item.Pickaxe, Item.Axe, Item.Chisel] 160 | ent.droptable.add(np_random.choice(tools)) 161 | 162 | return ent 163 | 164 | def packet(self): 165 | data = super().packet() 166 | 167 | data['skills'] = self.skills.packet() 168 | data['resource'] = { 'health': { 169 | 'val': self.resources.health.val, 'max': self.config.PLAYER_BASE_HEALTH } } 170 | 171 | return data 172 | 173 | @property 174 | def is_npc(self) -> bool: 175 | return True 176 | 177 | class Passive(NPC): 178 | def __init__(self, realm, pos, iden): 179 | super().__init__(realm, pos, iden, 'Passive', 1) 180 | 181 | def decide(self, realm): 182 | return policy.passive(realm, self) 183 | 184 | class PassiveAggressive(NPC): 185 | def __init__(self, realm, pos, iden): 186 | super().__init__(realm, pos, iden, 'Neutral', 2) 187 | 188 | def decide(self, realm): 189 | return policy.neutral(realm, self) 190 | 191 | class Aggressive(NPC): 192 | def __init__(self, realm, pos, iden): 193 | super().__init__(realm, pos, iden, 'Hostile', 3) 194 | 195 | def decide(self, realm): 196 | return policy.hostile(realm, self) 197 | -------------------------------------------------------------------------------- /nmmo/systems/inventory.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Tuple 2 | 3 | from ordered_set import OrderedSet 4 | 5 | from nmmo.systems import item as Item 6 | class EquipmentSlot: 7 | def __init__(self) -> None: 8 | self.item = None 9 | 10 | def equip(self, item: Item.Item) -> None: 11 | self.item = item 12 | 13 | def unequip(self) -> None: 14 | if self.item: 15 | self.item.equipped.update(0) 16 | self.item = None 17 | 18 | class Equipment: 19 | def __init__(self): 20 | self.hat = EquipmentSlot() 21 | self.top = EquipmentSlot() 22 | self.bottom = EquipmentSlot() 23 | self.held = EquipmentSlot() 24 | self.ammunition = EquipmentSlot() 25 | 26 | def total(self, lambda_getter): 27 | items = [lambda_getter(e).val for e in self] 28 | if not items: 29 | return 0 30 | return sum(items) 31 | 32 | def __iter__(self): 33 | for slot in [self.hat, self.top, self.bottom, self.held, self.ammunition]: 34 | if slot.item is not None: 35 | yield slot.item 36 | 37 | def conditional_packet(self, packet, slot_name: str, slot: EquipmentSlot): 38 | if slot.item: 39 | packet[slot_name] = slot.item.packet 40 | 41 | @property 42 | def item_level(self): 43 | return self.total(lambda e: e.level) 44 | 45 | @property 46 | def melee_attack(self): 47 | return self.total(lambda e: e.melee_attack) 48 | 49 | @property 50 | def range_attack(self): 51 | return self.total(lambda e: e.range_attack) 52 | 53 | @property 54 | def mage_attack(self): 55 | return self.total(lambda e: e.mage_attack) 56 | 57 | @property 58 | def melee_defense(self): 59 | return self.total(lambda e: e.melee_defense) 60 | 61 | @property 62 | def range_defense(self): 63 | return self.total(lambda e: e.range_defense) 64 | 65 | @property 66 | def mage_defense(self): 67 | return self.total(lambda e: e.mage_defense) 68 | 69 | @property 70 | def packet(self): 71 | packet = {} 72 | 73 | self.conditional_packet(packet, 'hat', self.hat) 74 | self.conditional_packet(packet, 'top', self.top) 75 | self.conditional_packet(packet, 'bottom', self.bottom) 76 | self.conditional_packet(packet, 'held', self.held) 77 | self.conditional_packet(packet, 'ammunition', self.ammunition) 78 | 79 | # pylint: disable=R0801 80 | # Similar lines here and in npc.py 81 | packet['item_level'] = self.item_level 82 | packet['melee_attack'] = self.melee_attack 83 | packet['range_attack'] = self.range_attack 84 | packet['mage_attack'] = self.mage_attack 85 | packet['melee_defense'] = self.melee_defense 86 | packet['range_defense'] = self.range_defense 87 | packet['mage_defense'] = self.mage_defense 88 | 89 | return packet 90 | 91 | 92 | class Inventory: 93 | def __init__(self, realm, entity): 94 | config = realm.config 95 | self.realm = realm 96 | self.entity = entity 97 | self.config = config 98 | 99 | self.equipment = Equipment() 100 | self.capacity = 0 101 | 102 | if config.ITEM_SYSTEM_ENABLED and entity.is_player: 103 | self.capacity = config.ITEM_INVENTORY_CAPACITY 104 | 105 | self._item_stacks: Dict[Tuple, Item.Stack] = {} 106 | self.items: OrderedSet[Item.Item] = OrderedSet([]) # critical for correct functioning 107 | 108 | @property 109 | def space(self): 110 | return self.capacity - len(self.items) 111 | 112 | def has_stack(self, signature: Tuple) -> bool: 113 | return signature in self._item_stacks 114 | 115 | def packet(self): 116 | item_packet = [] 117 | if self.config.ITEM_SYSTEM_ENABLED: 118 | item_packet = [e.packet for e in self.items] 119 | 120 | return { 121 | 'items': item_packet, 122 | 'equipment': self.equipment.packet} 123 | 124 | def __iter__(self): 125 | for item in self.items: 126 | yield item 127 | 128 | def receive(self, item: Item.Item) -> bool: 129 | # Return True if the item is received 130 | assert isinstance(item, Item.Item), f'{item} received is not an Item instance' 131 | assert item not in self.items, f'{item} object received already in inventory' 132 | assert not item.equipped.val, f'Received equipped item {item}' 133 | assert not item.listed_price.val, f'Received listed item {item}' 134 | assert item.quantity.val, f'Received empty item {item}' 135 | 136 | if isinstance(item, Item.Stack): 137 | signature = item.signature 138 | if self.has_stack(signature): 139 | stack = self._item_stacks[signature] 140 | assert item.level.val == stack.level.val, f'{item} stack level mismatch' 141 | stack.quantity.increment(item.quantity.val) 142 | # destroy the original item instance after the transfer is complete 143 | item.destroy() 144 | return False 145 | 146 | if not self.space: 147 | # if no space thus cannot receive, just destroy the item 148 | item.destroy() 149 | return False 150 | 151 | self._item_stacks[signature] = item 152 | 153 | if not self.space: 154 | # if no space thus cannot receive, just destroy the item 155 | item.destroy() 156 | return False 157 | 158 | self.realm.log_milestone(f'Receive_{item.__class__.__name__}', item.level.val, 159 | f'INVENTORY: Received level {item.level.val} {item.__class__.__name__}', 160 | tags={"player_id": self.entity.ent_id}) 161 | 162 | item.owner_id.update(self.entity.id.val) 163 | self.items.add(item) 164 | return True 165 | 166 | # pylint: disable=protected-access 167 | def remove(self, item, quantity=None): 168 | assert isinstance(item, Item.Item), f'{item} removing item is not an Item instance' 169 | assert item in self.items, f'No item {item} to remove' 170 | 171 | if isinstance(item, Item.Equipment) and item.equipped.val: 172 | item.unequip(item._slot(self.entity)) 173 | 174 | if isinstance(item, Item.Stack): 175 | signature = item.signature 176 | 177 | assert self.has_stack(item.signature), f'{item} stack to remove not in inventory' 178 | stack = self._item_stacks[signature] 179 | 180 | if quantity is None or stack.quantity.val == quantity: 181 | self._remove(stack) 182 | del self._item_stacks[signature] 183 | return 184 | 185 | assert 0 < quantity <= stack.quantity.val, \ 186 | f'Invalid remove {quantity} x {item} ({stack.quantity.val} available)' 187 | stack.quantity.val -= quantity 188 | return 189 | 190 | self._remove(item) 191 | 192 | def _remove(self, item): 193 | self.realm.exchange.unlist_item(item) 194 | item.owner_id.update(0) 195 | self.items.remove(item) 196 | -------------------------------------------------------------------------------- /nmmo/systems/exchange.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from collections import deque 3 | import math 4 | 5 | from typing import Dict 6 | 7 | from nmmo.systems.item import Item, Stack 8 | from nmmo.lib.log import EventCode 9 | 10 | """ 11 | The Exchange class is a simulation of an in-game item exchange. 12 | It has several methods that allow players to list items for sale, 13 | buy items, and remove expired listings. 14 | 15 | The _list_item() method is used to add a new item to the 16 | exchange, and the unlist_item() method is used to remove 17 | an item from the exchange. The step() method is used to 18 | regularly check and remove expired listings. 19 | 20 | The sell() method allows a player to sell an item, and the buy() method 21 | allows a player to purchase an item. The packet property returns a 22 | dictionary that contains information about the items currently being 23 | sold on the exchange, such as the maximum and minimum price, 24 | the average price, and the total supply of the items. 25 | 26 | """ 27 | class ItemListing: 28 | def __init__(self, item: Item, seller, price: int, tick: int): 29 | self.item = item 30 | self.seller = seller 31 | self.price = price 32 | self.tick = tick 33 | 34 | class Exchange: 35 | def __init__(self, realm): 36 | self._listings_queue: deque[(int, int)] = deque() # (item_id, tick) 37 | self._item_listings: Dict[int, ItemListing] = {} 38 | self._realm = realm 39 | self._config = realm.config 40 | 41 | def _list_item(self, item: Item, seller, price: int, tick: int): 42 | item.listed_price.update(price) 43 | self._item_listings[item.id.val] = ItemListing(item, seller, price, tick) 44 | self._listings_queue.append((item.id.val, tick)) 45 | 46 | def unlist_item(self, item: Item): 47 | if item.id.val in self._item_listings: 48 | self._unlist_item(item.id.val) 49 | 50 | def _unlist_item(self, item_id: int): 51 | item = self._item_listings.pop(item_id).item 52 | item.listed_price.update(0) 53 | 54 | def step(self, current_tick: int): 55 | """ 56 | Remove expired listings from the exchange's listings queue 57 | and item listings dictionary. It takes in one parameter, 58 | current_tick, which is the current time in the game. 59 | 60 | The method starts by checking the oldest listing in the listings 61 | queue using a while loop. If the current tick minus the 62 | listing tick is less than or equal to the EXCHANGE_LISTING_DURATION 63 | in the realm's configuration, the method breaks out of 64 | the loop as the oldest listing has not expired. 65 | If the oldest listing has expired, the method removes it from the 66 | listings queue and the item listings dictionary. 67 | 68 | It then checks if the actual listing still exists and that 69 | it is indeed expired. If it does exist and is expired, 70 | it calls the _unlist_item method to remove the listing and update 71 | the item's listed price. The process repeats until all expired listings 72 | are removed from the queue and dictionary. 73 | """ 74 | 75 | # Remove expired listings 76 | while self._listings_queue: 77 | (item_id, listing_tick) = self._listings_queue[0] 78 | if current_tick - listing_tick <= self._config.EXCHANGE_LISTING_DURATION: 79 | # Oldest listing has not expired 80 | break 81 | 82 | # Remove expired listing from queue 83 | self._listings_queue.popleft() 84 | 85 | # The actual listing might have been refreshed and is newer than the queue record. 86 | # Or it might have already been removed. 87 | listing = self._item_listings.get(item_id) 88 | if listing is not None and \ 89 | current_tick - listing.tick > self._config.EXCHANGE_LISTING_DURATION: 90 | self._unlist_item(item_id) 91 | 92 | def sell(self, seller, item: Item, price: int, tick: int): 93 | assert isinstance( 94 | item, object), f'{item} for sale is not an Item instance' 95 | assert item in seller.inventory, f'{item} for sale is not in {seller} inventory' 96 | assert item.quantity.val > 0, f'{item} for sale has quantity {item.quantity.val}' 97 | assert item.listed_price.val == 0, 'Item is already listed' 98 | assert item.equipped.val == 0, 'Item has been equiped so cannot be listed' 99 | assert price > 0, 'Price must be larger than 0' 100 | 101 | self._list_item(item, seller, price, tick) 102 | 103 | self._realm.event_log.record(EventCode.LIST_ITEM, seller, item=item, price=price) 104 | 105 | self._realm.log_milestone(f'Sell_{item.__class__.__name__}', item.level.val, 106 | f'EXCHANGE: Offered level {item.level.val} {item.__class__.__name__} for {price} gold', 107 | tags={"player_id": seller.ent_id}) 108 | 109 | def buy(self, buyer, item: Item): 110 | assert item.quantity.val > 0, f'{item} purchase has quantity {item.quantity.val}' 111 | assert item.equipped.val == 0, 'Listed item must not be equipped' 112 | assert buyer.gold.val >= item.listed_price.val, 'Buyer does not have enough gold' 113 | assert buyer.ent_id != item.owner_id.val, 'One cannot buy their own items' 114 | 115 | if not buyer.inventory.space: 116 | if isinstance(item, Stack): 117 | if not buyer.inventory.has_stack(item.signature): 118 | # no ammo stack with the same signature, so cannot buy 119 | return 120 | else: # no space, and item is not ammo stack, so cannot buy 121 | return 122 | 123 | # item is not in the listing (perhaps bought by other) 124 | if item.id.val not in self._item_listings: 125 | return 126 | 127 | listing = self._item_listings[item.id.val] 128 | price = item.listed_price.val 129 | 130 | self.unlist_item(item) 131 | listing.seller.inventory.remove(item) 132 | buyer.inventory.receive(item) 133 | buyer.gold.decrement(price) 134 | listing.seller.gold.increment(price) 135 | 136 | # TODO(kywch): tidy up the logs - milestone, event, etc ... 137 | #self._realm.log_milestone(f'Buy_{item.__name__}', item.level.val) 138 | #self._realm.log_milestone('Transaction_Amount', item.listed_price.val) 139 | self._realm.event_log.record(EventCode.BUY_ITEM, buyer, item=item, price=price) 140 | self._realm.event_log.record(EventCode.EARN_GOLD, listing.seller, amount=price) 141 | 142 | @property 143 | def packet(self): 144 | packet = {} 145 | for listing in self._item_listings.values(): 146 | item = listing.item 147 | key = f'{item.__class__.__name__}_{item.level.val}' 148 | max_price = max(packet.get(key, {}).get('max_price', -math.inf), listing.price) 149 | min_price = min(packet.get(key, {}).get('min_price', math.inf), listing.price) 150 | supply = packet.get(key, {}).get('supply', 0) + item.quantity.val 151 | 152 | packet[key] = { 153 | 'max_price': max_price, 154 | 'min_price': min_price, 155 | 'price': (max_price + min_price) / 2, 156 | 'supply': supply 157 | } 158 | 159 | return packet 160 | -------------------------------------------------------------------------------- /nmmo/task/constraint.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import random 4 | from numbers import Number 5 | from typing import Union, Callable, Dict 6 | from abc import ABC, abstractmethod 7 | 8 | from nmmo.systems import skill, item 9 | from nmmo.lib import material 10 | from nmmo.lib.log import EventCode 11 | from nmmo.core.config import Config 12 | 13 | class InvalidConstraint(Exception): 14 | pass 15 | 16 | class Constraint(ABC): 17 | """ To check the validity of predicates 18 | and assist generating new predicates. Similar to gym spaces. 19 | """ 20 | def __init__(self, systems=None): 21 | if systems is None: 22 | systems = [] 23 | self._systems = systems 24 | 25 | # pylint: disable=unused-argument 26 | def check(self, config: Config, value): 27 | """ Checks value is in bounds given config 28 | """ 29 | for system in self._systems: 30 | try: 31 | if not getattr(config,system): 32 | return False 33 | except AttributeError: 34 | return False 35 | return True 36 | 37 | @abstractmethod 38 | def sample(self, config: Config): 39 | """ Generator to sample valid values given config 40 | """ 41 | raise NotImplementedError 42 | 43 | def __str__(self): 44 | return self.__class__.__name__ 45 | 46 | # This is a dummy function for GroupConstraint 47 | # NOTE: config does not have team info 48 | def sample_one_big_team(config): 49 | from nmmo.task.group import Group 50 | team = list(range(1, config.PLAYER_N+1)) 51 | return [Group(team, 'All')] 52 | 53 | class GroupConstraint(Constraint): 54 | """ Ensures that all agents of a group exist in a config 55 | """ 56 | def __init__(self, 57 | sample_fn = sample_one_big_team, 58 | systems = None): 59 | """ 60 | Params 61 | sample_fn: given a Config, return groups to select from 62 | systems: systems required to operate 63 | """ 64 | super().__init__(systems) 65 | self._sample_fn = sample_fn 66 | 67 | def check(self, config, value): 68 | if not super().check(config,value): 69 | return False 70 | for agent in value.agents: 71 | if agent > config.PLAYER_N: 72 | return False 73 | return True 74 | 75 | def sample(self, config): 76 | return random.choice(self._sample_fn(config)) 77 | 78 | def sample_from_teams(self, teams: Dict[int, Dict]): 79 | from nmmo.task.group import Group 80 | team_id = random.choice(list(teams.keys())) 81 | return Group(teams[team_id], str(team_id)) 82 | 83 | class AgentListConstraint(Constraint): 84 | """ Ensures that all agents of the list exist in a config 85 | """ 86 | def check(self, config, value): 87 | for agent in value: 88 | if agent > config.PLAYER_N: 89 | return False 90 | return True 91 | 92 | def sample(self, config): 93 | return None 94 | 95 | class ScalarConstraint(Constraint): 96 | def __init__(self, 97 | low: Union[Callable, Number] = 0, 98 | high: Union[Callable, Number] = 1024, 99 | dtype = int, 100 | systems = None): 101 | super().__init__(systems) 102 | self._low = low 103 | self._high = high 104 | if isinstance(low, Number): 105 | self._low = lambda _ : low 106 | if isinstance(high, Number): 107 | self._high = lambda _ : high 108 | self._dtype = dtype 109 | 110 | def check(self, config, value): 111 | if not super().check(config,value): 112 | return False 113 | if self._low(config) <= value < self._high(config): 114 | return True 115 | return False 116 | 117 | def sample(self, config): 118 | l, h = self._low(config), self._high(config) 119 | return self._dtype(random.random()*(h-l)+l) 120 | 121 | class DiscreteConstraint(Constraint): 122 | def __init__(self, space, systems=None): 123 | super().__init__(systems) 124 | self._space = set(space) 125 | 126 | def check(self, config: Config, value): 127 | if not super().check(config,value): 128 | return False 129 | return value in self._space 130 | 131 | def sample(self, config: Config): 132 | # NOTE: this does NOT need to be deterministic 133 | return random.choice(self._space) 134 | 135 | # Group Constraints 136 | TEAM_GROUPS = GroupConstraint() 137 | INDIVIDUAL_GROUPS=GroupConstraint() 138 | AGENT_LIST_CONSTRAINT = AgentListConstraint() 139 | 140 | # Tile Constraints 141 | MATERIAL_CONSTRAINT = DiscreteConstraint(space=list(material.All.materials), 142 | systems=['TERRAIN_SYSTEM_ENABLED', 143 | 'RESOURCE_SYSTEM_ENABLED']) 144 | HABITABLE_CONSTRAINT = DiscreteConstraint(space=list(material.Habitable.materials), 145 | systems=['TERRAIN_SYSTEM_ENABLED']) 146 | 147 | # Event Constraints 148 | event_names = [k for k, v in EventCode.__dict__.items() if isinstance(v,int)] 149 | EVENTCODE_CONSTRAINT = DiscreteConstraint(space=event_names) 150 | 151 | # Skill Constraints 152 | combat_skills = [skill.Melee, skill.Mage, skill.Range] 153 | harvest_skills = [skill.Fishing, skill.Herbalism, skill.Prospecting, skill.Alchemy, skill.Carving] 154 | SKILL_CONSTRAINT = DiscreteConstraint(space=combat_skills+harvest_skills, 155 | systems=['PROFESSION_SYSTEM_ENABLED']) 156 | COMBAT_SKILL_CONSTRAINT = DiscreteConstraint(space=combat_skills, 157 | systems=['PROFESSION_SYSTEM_ENABLED']) 158 | 159 | # Item Constraints 160 | armour = [item.Hat, item.Top, item.Bottom] 161 | weapons = [item.Spear, item.Bow, item.Wand] 162 | tools = [item.Axe, item.Gloves, item.Rod, item.Pickaxe, item.Chisel] 163 | ammunition = [item.Runes, item.Arrow, item.Whetstone] 164 | consumables = [item.Potion, item.Ration] 165 | ITEM_CONSTRAINT = DiscreteConstraint(space=armour+weapons+tools+ammunition+consumables, 166 | systems=['ITEM_SYSTEM_ENABLED']) 167 | EQUIPABLE_CONSTRAINT = DiscreteConstraint(space=armour+weapons+tools+ammunition, 168 | systems=['ITEM_SYSTEM_ENABLED']) 169 | CONSUMABLE_CONSTRAINT = DiscreteConstraint(space=consumables, 170 | systems=['ITEM_SYSTEM_ENABLED']) 171 | HARVEST_CONSTRAINT = DiscreteConstraint(space=weapons+ammunition+consumables, 172 | systems=['ITEM_SYSTEM_ENABLED']) 173 | 174 | # Config Constraints 175 | COORDINATE_CONSTRAINT = ScalarConstraint(high = lambda c: c.MAP_CENTER) 176 | PROGRESSION_CONSTRAINT = ScalarConstraint(high = lambda c: c.PROGRESSION_LEVEL_MAX+1) 177 | INVENTORY_CONSTRAINT = ScalarConstraint(high=lambda c: c.ITEM_INVENTORY_CAPACITY+1) 178 | AGENT_NUMBER_CONSTRAINT = ScalarConstraint(low = 1, high = lambda c: c.PLAYER_N+1) 179 | 180 | # Arbitrary Constraints 181 | EVENT_NUMBER_CONSTRAINT = ScalarConstraint(low = 1, high = 110) 182 | GOLD_CONSTRAINT = ScalarConstraint(low = 1, high = 1000) 183 | AGENT_TYPE_CONSTRAINT = DiscreteConstraint(space=['npc','player']) 184 | -------------------------------------------------------------------------------- /nmmo/task/task_spec.py: -------------------------------------------------------------------------------- 1 | import functools 2 | from dataclasses import dataclass, field 3 | from typing import Iterable, Dict, List, Union, Type 4 | from types import FunctionType 5 | from copy import deepcopy 6 | 7 | import numpy as np 8 | 9 | import nmmo 10 | from nmmo.task.task_api import Task, make_same_task 11 | from nmmo.task.predicate_api import Predicate, make_predicate 12 | from nmmo.task.group import Group 13 | from nmmo.task import base_predicates as bp 14 | from nmmo.lib.team_helper import TeamHelper 15 | 16 | """ task_spec 17 | 18 | eval_fn can come from the base_predicates.py or could be custom functions like above 19 | eval_fn_kwargs are the additional args that go into predicate. There are also special keys 20 | * "target" must be ["left_team", "right_team", "left_team_leader", "right_team_leader"] 21 | these str will be translated into the actual agent ids 22 | 23 | task_cls specifies the task class to be used. Default is Task. 24 | task_kwargs are the optional, additional args that go into the task. 25 | 26 | reward_to: must be in ["team", "agent"] 27 | * "team" create a single team task, in which all team members get rewarded 28 | * "agent" create a task for each agent, in which only the agent gets rewarded 29 | 30 | sampling_weight specifies the weight of the task in the curriculum sampling. Default is 1 31 | """ 32 | 33 | REWARD_TO = ["agent", "team"] 34 | VALID_TARGET = ["left_team", "left_team_leader", 35 | "right_team", "right_team_leader", 36 | "my_team_leader", "all_foes"] 37 | 38 | @dataclass 39 | class TaskSpec: 40 | eval_fn: FunctionType 41 | eval_fn_kwargs: Dict 42 | task_cls: Type[Task] = Task 43 | task_kwargs: Dict = field(default_factory=dict) 44 | reward_to: str = "agent" 45 | sampling_weight: float = 1.0 46 | embedding: np.ndarray = None 47 | predicate: Predicate = None 48 | 49 | def __post_init__(self): 50 | if self.predicate is None: 51 | assert isinstance(self.eval_fn, FunctionType), \ 52 | "eval_fn must be a function" 53 | else: 54 | assert self.eval_fn is None, "Cannot specify both eval_fn and predicate" 55 | assert self.reward_to in REWARD_TO, \ 56 | f"reward_to must be in {REWARD_TO}" 57 | if "target" in self.eval_fn_kwargs: 58 | assert self.eval_fn_kwargs["target"] in VALID_TARGET, \ 59 | f"target must be in {VALID_TARGET}" 60 | 61 | @functools.cached_property 62 | def name(self): 63 | # pylint: disable=no-member 64 | kwargs_str = [] 65 | for key, val in self.eval_fn_kwargs.items(): 66 | val_str = str(val) 67 | if isinstance(val, type): 68 | val_str = val.__name__ 69 | kwargs_str.append(f"{key}:{val_str}_") 70 | kwargs_str = "(" + "".join(kwargs_str)[:-1] + ")" # remove the last _ 71 | pred_name = self.eval_fn.__name__ if self.predicate is None else self.predicate.name 72 | return "_".join([self.task_cls.__name__, pred_name, 73 | kwargs_str, "reward_to:" + self.reward_to]) 74 | 75 | def make_task_from_spec(assign_to: Union[Iterable[int], Dict], 76 | task_spec: List[TaskSpec]) -> List[Task]: 77 | """ 78 | Args: 79 | assign_to: either a Dict with { team_id: [agent_id]} or a List of agent ids 80 | task_spec: a list of tuples (reward_to, eval_fn, pred_fn_kwargs, task_kwargs) 81 | 82 | each tuple is assigned to the teams 83 | """ 84 | teams = assign_to 85 | if not isinstance(teams, Dict): # convert agent id list to the team dict format 86 | teams = {idx: [agent_id] for idx, agent_id in enumerate(assign_to)} 87 | team_list = list(teams.keys()) 88 | team_helper = TeamHelper(teams) 89 | 90 | # assign task spec to teams (assign_to) 91 | tasks = [] 92 | for idx in range(min(len(team_list), len(task_spec))): 93 | team_id = team_list[idx] 94 | 95 | # map local vars to spec attributes 96 | reward_to = task_spec[idx].reward_to 97 | pred_fn = task_spec[idx].eval_fn 98 | pred_fn_kwargs = deepcopy(task_spec[idx].eval_fn_kwargs) 99 | task_cls = task_spec[idx].task_cls 100 | task_kwargs = deepcopy(task_spec[idx].task_kwargs) 101 | task_kwargs["embedding"] = task_spec[idx].embedding # to pass to task_cls 102 | task_kwargs["spec_name"] = task_spec[idx].name 103 | predicate = task_spec[idx].predicate 104 | 105 | # reserve "target" for relative agent mapping 106 | if "target" in pred_fn_kwargs: 107 | target = pred_fn_kwargs.pop("target") 108 | assert target in VALID_TARGET, "Invalid target" 109 | # translate target to specific agent ids using team_helper 110 | target = team_helper.get_target_agent(team_id, target) 111 | pred_fn_kwargs["target"] = target 112 | 113 | # handle some special cases and instantiate the predicate first 114 | if pred_fn is not None and isinstance(pred_fn, FunctionType): 115 | # if a function is provided as a predicate 116 | pred_cls = make_predicate(pred_fn) 117 | 118 | # TODO: should create a test for these 119 | if (pred_fn in [bp.AllDead]) or \ 120 | (pred_fn in [bp.StayAlive] and "target" in pred_fn_kwargs): 121 | # use the target as the predicate subject 122 | pred_fn_kwargs.pop("target") # remove target 123 | predicate = pred_cls(Group(target), **pred_fn_kwargs) 124 | 125 | # create the task 126 | if reward_to == "team": 127 | assignee = team_helper.teams[team_id] 128 | if predicate is None: 129 | predicate = pred_cls(Group(assignee), **pred_fn_kwargs) 130 | tasks.append(predicate.create_task(task_cls=task_cls, **task_kwargs)) 131 | else: 132 | # this branch is for the cases like AllDead, StayAlive 133 | tasks.append(predicate.create_task(assignee=assignee, task_cls=task_cls, 134 | **task_kwargs)) 135 | 136 | elif reward_to == "agent": 137 | agent_list = team_helper.teams[team_id] 138 | if predicate is None: 139 | tasks += make_same_task(pred_cls, agent_list, pred_kwargs=pred_fn_kwargs, 140 | task_cls=task_cls, task_kwargs=task_kwargs) 141 | else: 142 | # this branch is for the cases like AllDead, StayAlive 143 | tasks += [predicate.create_task(assignee=agent_id, task_cls=task_cls, **task_kwargs) 144 | for agent_id in agent_list] 145 | 146 | return tasks 147 | 148 | # pylint: disable=bare-except,cell-var-from-loop 149 | def check_task_spec(spec_list: List[TaskSpec]) -> List[Dict]: 150 | teams = {0: [1, 2, 3], 3: [4, 5], 7: [6, 7], 11: [8, 9], 14: [10, 11]} 151 | config = nmmo.config.Default() 152 | env = nmmo.Env(config) 153 | results = [] 154 | for single_spec in spec_list: 155 | result = {"spec_name": single_spec.name} 156 | try: 157 | env.reset(make_task_fn=lambda: make_task_from_spec(teams, [single_spec])) 158 | for _ in range(3): 159 | env.step({}) 160 | result["runnable"] = True 161 | except: 162 | result["runnable"] = False 163 | 164 | results.append(result) 165 | return results 166 | -------------------------------------------------------------------------------- /nmmo/lib/event_log.py: -------------------------------------------------------------------------------- 1 | from types import SimpleNamespace 2 | from typing import List 3 | from copy import deepcopy 4 | 5 | import numpy as np 6 | 7 | from nmmo.datastore.serialized import SerializedState 8 | from nmmo.entity import Entity 9 | from nmmo.systems.item import Item 10 | from nmmo.lib.log import EventCode 11 | 12 | # pylint: disable=no-member 13 | EventState = SerializedState.subclass("Event", [ 14 | "recorded", # event_log is write-only, no update or delete, so no need for row id 15 | "ent_id", 16 | "tick", 17 | 18 | "event", 19 | 20 | "type", 21 | "level", 22 | "number", 23 | "gold", 24 | "target_ent", 25 | ]) 26 | 27 | EventAttr = EventState.State.attr_name_to_col 28 | 29 | EventState.Query = SimpleNamespace( 30 | table=lambda ds: ds.table("Event").where_eq(EventAttr["recorded"], 1), 31 | by_event=lambda ds, event_code: ds.table("Event").where_eq( 32 | EventAttr["event"], event_code), 33 | by_tick=lambda ds, tick: ds.table("Event").where_eq( 34 | EventAttr["tick"], tick), 35 | ) 36 | 37 | # defining col synoyms for different event types 38 | ATTACK_COL_MAP = { 39 | 'combat_style': EventAttr['type'], 40 | 'damage': EventAttr['number'] } 41 | 42 | ITEM_COL_MAP = { 43 | 'item_type': EventAttr['type'], 44 | 'quantity': EventAttr['number'], 45 | 'price': EventAttr['gold'] } 46 | 47 | LEVEL_COL_MAP = { 'skill': EventAttr['type'] } 48 | 49 | EXPLORE_COL_MAP = { 'distance': EventAttr['number'] } 50 | 51 | 52 | class EventLogger(EventCode): 53 | def __init__(self, realm): 54 | self.realm = realm 55 | self.config = realm.config 56 | self.datastore = realm.datastore 57 | 58 | self.valid_events = { val: evt for evt, val in EventCode.__dict__.items() 59 | if isinstance(val, int) } 60 | self._data_by_tick = {} 61 | self._last_tick = 0 62 | self._empty_data = np.empty((0, len(EventAttr))) 63 | 64 | # add synonyms to the attributes 65 | self.attr_to_col = deepcopy(EventAttr) 66 | self.attr_to_col.update(ATTACK_COL_MAP) 67 | self.attr_to_col.update(ITEM_COL_MAP) 68 | self.attr_to_col.update(LEVEL_COL_MAP) 69 | self.attr_to_col.update(EXPLORE_COL_MAP) 70 | 71 | def reset(self): 72 | EventState.State.table(self.datastore).reset() 73 | 74 | # define event logging 75 | def _create_event(self, entity: Entity, event_code: int): 76 | log = EventState(self.datastore) 77 | log.recorded.update(1) 78 | log.ent_id.update(entity.ent_id) 79 | # the tick increase by 1 after executing all actions 80 | log.tick.update(self.realm.tick+1) 81 | log.event.update(event_code) 82 | 83 | return log 84 | 85 | def record(self, event_code: int, entity: Entity, **kwargs): 86 | if event_code in [EventCode.EAT_FOOD, EventCode.DRINK_WATER, 87 | EventCode.GIVE_ITEM, EventCode.DESTROY_ITEM, 88 | EventCode.GIVE_GOLD]: 89 | # Logs for these events are for counting only 90 | self._create_event(entity, event_code) 91 | return 92 | 93 | if event_code == EventCode.GO_FARTHEST: # use EXPLORE_COL_MAP 94 | if ('distance' in kwargs and kwargs['distance'] > 0): 95 | log = self._create_event(entity, event_code) 96 | log.number.update(kwargs['distance']) 97 | return 98 | 99 | if event_code == EventCode.SCORE_HIT: 100 | # kwargs['combat_style'] should be Skill.CombatSkill 101 | if ('combat_style' in kwargs and kwargs['combat_style'].SKILL_ID in [1, 2, 3]) & \ 102 | ('damage' in kwargs and kwargs['damage'] >= 0): 103 | log = self._create_event(entity, event_code) 104 | log.type.update(kwargs['combat_style'].SKILL_ID) 105 | log.number.update(kwargs['damage']) 106 | return 107 | 108 | if event_code == EventCode.PLAYER_KILL: 109 | if ('target' in kwargs and isinstance(kwargs['target'], Entity)): 110 | target = kwargs['target'] 111 | log = self._create_event(entity, event_code) 112 | log.target_ent.update(target.ent_id) 113 | 114 | # CHECK ME: attack_level or "general" level?? need to clarify 115 | log.level.update(target.attack_level) 116 | return 117 | 118 | if event_code in [EventCode.CONSUME_ITEM, EventCode.HARVEST_ITEM, EventCode.EQUIP_ITEM, 119 | EventCode.LOOT_ITEM]: 120 | # CHECK ME: item types should be checked. For example, 121 | # Only Ration and Potion can be consumed 122 | # Only Ration, Potion, Whetstone, Arrow, Runes can be produced 123 | # The quantity should be 1 for all of these events 124 | if ('item' in kwargs and isinstance(kwargs['item'], Item)): 125 | item = kwargs['item'] 126 | log = self._create_event(entity, event_code) 127 | log.type.update(item.ITEM_TYPE_ID) 128 | log.level.update(item.level.val) 129 | log.number.update(item.quantity.val) 130 | return 131 | 132 | if event_code in [EventCode.LIST_ITEM, EventCode.BUY_ITEM]: 133 | if ('item' in kwargs and isinstance(kwargs['item'], Item)) & \ 134 | ('price' in kwargs and kwargs['price'] > 0): 135 | item = kwargs['item'] 136 | log = self._create_event(entity, event_code) 137 | log.type.update(item.ITEM_TYPE_ID) 138 | log.level.update(item.level.val) 139 | log.number.update(item.quantity.val) 140 | log.gold.update(kwargs['price']) 141 | return 142 | 143 | # NOTE: do we want to separate the source of income? from selling vs looting 144 | if event_code == EventCode.EARN_GOLD: 145 | if ('amount' in kwargs and kwargs['amount'] > 0): 146 | log = self._create_event(entity, event_code) 147 | log.gold.update(kwargs['amount']) 148 | return 149 | 150 | if event_code == EventCode.LEVEL_UP: 151 | # kwargs['skill'] should be Skill.Skill 152 | if ('skill' in kwargs and kwargs['skill'].SKILL_ID in range(1,9)) & \ 153 | ('level' in kwargs and kwargs['level'] >= 0): 154 | log = self._create_event(entity, event_code) 155 | log.type.update(kwargs['skill'].SKILL_ID) 156 | log.level.update(kwargs['level']) 157 | return 158 | 159 | # If reached here, then something is wrong 160 | # CHECK ME: The below should be commented out after debugging 161 | raise ValueError(f"Event code: {event_code}", kwargs) 162 | 163 | def update(self): 164 | curr_tick = self.realm.tick + 1 # update happens before the tick update 165 | if curr_tick > self._last_tick: 166 | self._data_by_tick[curr_tick] = EventState.Query.by_tick(self.datastore, curr_tick) 167 | self._last_tick = curr_tick 168 | 169 | def get_data(self, event_code=None, agents: List[int]=None, tick: int=None) -> np.ndarray: 170 | if tick is not None: 171 | if tick not in self._data_by_tick: 172 | return self._empty_data 173 | event_data = self._data_by_tick[tick] 174 | else: 175 | event_data = EventState.Query.table(self.datastore) 176 | 177 | if event_data.shape[0] > 0: 178 | if event_code is None: 179 | flt_idx = event_data[:, EventAttr["event"]] > 0 180 | else: 181 | flt_idx = event_data[:, EventAttr["event"]] == event_code 182 | if agents: 183 | flt_idx &= np.in1d(event_data[:, EventAttr["ent_id"]], agents) 184 | return event_data[flt_idx] 185 | 186 | return self._empty_data 187 | -------------------------------------------------------------------------------- /nmmo/core/realm.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from collections import defaultdict 5 | from typing import Dict 6 | 7 | import nmmo 8 | from nmmo.core.log_helper import LogHelper 9 | from nmmo.core.map import Map 10 | from nmmo.core.tile import TileState 11 | from nmmo.core.action import Action, Buy 12 | from nmmo.entity.entity import EntityState 13 | from nmmo.entity.entity_manager import NPCManager, PlayerManager 14 | from nmmo.datastore.numpy_datastore import NumpyDatastore 15 | from nmmo.systems.exchange import Exchange 16 | from nmmo.systems.item import Item, ItemState 17 | from nmmo.lib.event_log import EventLogger, EventState 18 | from nmmo.render.replay_helper import ReplayHelper 19 | 20 | def prioritized(entities: Dict, merged: Dict): 21 | """Sort actions into merged according to priority""" 22 | for idx, actions in entities.items(): 23 | for atn, args in actions.items(): 24 | merged[atn.priority].append((idx, (atn, args.values()))) 25 | return merged 26 | 27 | 28 | class Realm: 29 | """Top-level world object""" 30 | 31 | def __init__(self, config, np_random): 32 | self.config = config 33 | self._np_random = np_random # rng 34 | assert isinstance( 35 | config, nmmo.config.Config 36 | ), f"Config {config} is not a config instance (did you pass the class?)" 37 | 38 | Action.hook(config) 39 | 40 | # Generate maps if they do not exist 41 | # NOTE: Map generation interferes with determinism. 42 | # To ensure determinism, provide seed to env.reset() 43 | config.MAP_GENERATOR(config).generate_all_maps(self._np_random) 44 | 45 | self.datastore = NumpyDatastore() 46 | for s in [TileState, EntityState, ItemState, EventState]: 47 | self.datastore.register_object_type(s._name, s.State.num_attributes) 48 | 49 | self.tick = None # to use as a "reset" checker 50 | self.exchange = None 51 | 52 | # Load the world file 53 | self.map = Map(config, self, self._np_random) 54 | 55 | self.log_helper = LogHelper.create(self) 56 | self.event_log = EventLogger(self) 57 | 58 | # Entity handlers 59 | self.players = PlayerManager(self, self._np_random) 60 | self.npcs = NPCManager(self, self._np_random) 61 | 62 | # Global item registry 63 | self.items = {} 64 | 65 | # Replay helper 66 | self._replay_helper = None 67 | 68 | # Initialize actions 69 | nmmo.Action.init(config) 70 | 71 | def reset(self, np_random, map_id: int = None): 72 | """Reset the environment and load the specified map 73 | 74 | Args: 75 | idx: Map index to load 76 | """ 77 | self._np_random = np_random 78 | self.log_helper.reset() 79 | self.event_log.reset() 80 | 81 | map_id = map_id or self._np_random.integers(self.config.MAP_N) + 1 82 | self.map.reset(map_id, self._np_random) 83 | self.tick = 0 84 | 85 | # EntityState and ItemState tables must be empty after players/npcs.reset() 86 | self.players.reset(self._np_random) 87 | self.npcs.reset(self._np_random) 88 | assert EntityState.State.table(self.datastore).is_empty(), \ 89 | "EntityState table is not empty" 90 | # TODO: fix the item leak, then uncomment the below -- print out the table? 91 | # assert ItemState.State.table(self.datastore).is_empty(), \ 92 | # "ItemState table is not empty" 93 | 94 | # DataStore id allocator must be reset to be deterministic 95 | EntityState.State.table(self.datastore).reset() 96 | ItemState.State.table(self.datastore).reset() 97 | 98 | self.players.spawn() 99 | self.npcs.spawn() 100 | 101 | # Global item exchange 102 | self.exchange = Exchange(self) 103 | 104 | # Global item registry 105 | Item.INSTANCE_ID = 0 106 | self.items = {} 107 | 108 | if self._replay_helper is not None: 109 | self._replay_helper.reset() 110 | 111 | def packet(self): 112 | """Client packet""" 113 | return { 114 | "environment": self.map.repr, 115 | "border": self.config.MAP_BORDER, 116 | "size": self.config.MAP_SIZE, 117 | "resource": self.map.packet, 118 | "player": self.players.packet, 119 | "npc": self.npcs.packet, 120 | "market": self.exchange.packet, 121 | } 122 | 123 | @property 124 | def num_players(self): 125 | """Number of player agents""" 126 | return len(self.players.entities) 127 | 128 | def entity(self, ent_id): 129 | e = self.entity_or_none(ent_id) 130 | assert e is not None, f"Entity {ent_id} does not exist" 131 | return e 132 | 133 | def entity_or_none(self, ent_id): 134 | if ent_id is None: 135 | return None 136 | 137 | """Get entity by ID""" 138 | if ent_id < 0: 139 | return self.npcs.get(ent_id) 140 | 141 | return self.players.get(ent_id) 142 | 143 | def step(self, actions): 144 | """Run game logic for one tick 145 | 146 | Args: 147 | actions: Dict of agent actions 148 | 149 | Returns: 150 | dead: List of dead agents 151 | """ 152 | # Prioritize actions 153 | npc_actions = self.npcs.actions(self) 154 | merged = defaultdict(list) 155 | prioritized(actions, merged) 156 | prioritized(npc_actions, merged) 157 | 158 | # Update entities and perform actions 159 | self.players.update(actions) 160 | self.npcs.update(npc_actions) 161 | 162 | # Execute actions -- CHECK ME the below priority 163 | # - 10: Use - equip ammo, restore HP, etc. 164 | # - 20: Buy - exchange while sellers, items, buyers are all intact 165 | # - 30: Give, GiveGold - transfer while both are alive and at the same tile 166 | # - 40: Destroy - use with SELL/GIVE, if not gone, destroy and recover space 167 | # - 50: Attack 168 | # - 60: Move 169 | # - 70: Sell - to guarantee the listed items are available to buy 170 | # - 99: Comm 171 | 172 | for priority in sorted(merged): 173 | # TODO: we should be randomizing these, otherwise the lower ID agents 174 | # will always go first. --> ONLY SHUFFLE BUY 175 | if priority == Buy.priority: 176 | self._np_random.shuffle(merged[priority]) 177 | 178 | # CHECK ME: do we need this line? 179 | # ent_id, (atn, args) = merged[priority][0] 180 | for ent_id, (atn, args) in merged[priority]: 181 | ent = self.entity(ent_id) 182 | if ent.alive: 183 | atn.call(self, ent, *args) 184 | dead = self.players.cull() 185 | self.npcs.cull() 186 | 187 | # Update map 188 | self.map.step() 189 | self.exchange.step(self.tick) 190 | self.log_helper.update(dead) 191 | self.event_log.update() 192 | if self._replay_helper is not None: 193 | self._replay_helper.update() 194 | 195 | self.tick += 1 196 | 197 | return dead 198 | 199 | def log_milestone(self, category: str, value: float, message: str = None, tags: Dict = None): 200 | self.log_helper.log_milestone(category, value) 201 | self.log_helper.log_event(category, value) 202 | 203 | if self.config.LOG_VERBOSE: 204 | # TODO: more general handling of tags, if necessary 205 | if tags and 'player_id' in tags: 206 | logging.info("Milestone (Player %d): %s %s %s", tags['player_id'], category, value, message) 207 | else: 208 | logging.info("Milestone: %s %s %s", category, value, message) 209 | 210 | def record_replay(self, replay_helper: ReplayHelper) -> ReplayHelper: 211 | self._replay_helper = replay_helper 212 | self._replay_helper.set_realm(self) 213 | 214 | return replay_helper 215 | --------------------------------------------------------------------------------